Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions cmake/apply_patch.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Applies a unified diff to the current directory with `git apply`, once.
#
# Used as a FetchContent PATCH_COMMAND, which runs in the populated source
# directory. FetchContent re-runs the patch step when it re-populates a source
# tree, so an already-applied patch is detected (via a reverse dry run) and
# skipped instead of failing.
#
# cmake -DPATCH_FILE=<file> -P apply_patch.cmake

if(NOT DEFINED PATCH_FILE)
message(FATAL_ERROR "apply_patch.cmake: PATCH_FILE is not set")
endif()

find_package(Git REQUIRED)

execute_process(
COMMAND "${GIT_EXECUTABLE}" apply --check --reverse --ignore-whitespace "${PATCH_FILE}"
RESULT_VARIABLE already_applied
OUTPUT_QUIET
ERROR_QUIET
)
if(already_applied EQUAL 0)
message(STATUS "apply_patch: ${PATCH_FILE} is already applied")
return()
endif()

execute_process(
COMMAND "${GIT_EXECUTABLE}" apply --ignore-whitespace "${PATCH_FILE}"
RESULT_VARIABLE apply_result
)
if(NOT apply_result EQUAL 0)
message(FATAL_ERROR "apply_patch: failed to apply ${PATCH_FILE}")
endif()
message(STATUS "apply_patch: applied ${PATCH_FILE}")
25 changes: 25 additions & 0 deletions cmake/blend2d-fillanalytic-stale-i.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
--- a/blend2d/pipeline/jit/fillpart.cpp
+++ b/blend2d/pipeline/jit/fillpart.cpp
@@ -742,6 +742,10 @@
pc->add(x_off, x_off, pixels_per_bit_word); // x_off += pixels_per_bit_word;
pc->add(bit_ptr, bit_ptr, bw_size); // bit_ptr += bw_size;
pc->j(L_BitScan_Match, xor_nz(bit_word, -1)); // if ((bit_word ^= -1) != 0) goto L_BitScan_Match;
+ // The span covers this whole BitWord. If it was the last one, `L_BitScan_End` must see `i == bw_size_in_bits`.
+ // This loop is also entered from the BitGap section, where `i` holds the length of the span that was just
+ // composited (and the CMask loop may have counted it down to zero), so it has to be reset here.
+ pc->mov(i, bw_size_in_bits); // i = bw_size_in_bits;
pc->j(L_BitScan_End, cmp_eq(bit_ptr, bit_ptr_end)); // if (bit_ptr == bit_ptr_end) goto L_BitScan_End;
pc->j(L_BitScan_Iter); // goto L_BitScan_Iter;

--- a/blend2d/pipeline/reference/fillgeneric_p.h
+++ b/blend2d/pipeline/reference/fillgeneric_p.h
@@ -257,6 +257,9 @@
if (bit_word)
goto L_BitScan_Match;

+ // The span covers this whole BitWord. If it was the last one, `L_BitScan_End` must see `i == kNumBits`. This
+ // loop is also entered from the BitGap section, where `i` still holds the length of the previous span.
+ i = BitOps::kNumBits;
if (bit_ptr == bit_ptr_end)
goto L_BitScan_End;
}
22 changes: 22 additions & 0 deletions cmake/blend2d-stroke-offset-quad-stall.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
--- a/blend2d/core/pathstroke.cpp
+++ b/blend2d/core/pathstroke.cpp
@@ -869,8 +869,19 @@ SmoothPolyTo:
if (!(t > kOffsetQuadEpsilonT && t < 1.0 - kOffsetQuadEpsilonT))
t = 1.0;

+ BLPoint p1 = iter.part[1];
BLPoint part[3];
Geometry::split(Geometry::quad_ref(iter.part), Geometry::quad_out(part), Geometry::quad_out(iter.part), t);
+
+ // If the split returned the same curve, `t` passed the epsilon check, but the step it represents is below
+ // the precision of the coordinates and this loop would never terminate. This happens when a part starts at
+ // the turning point of a nearly collinear quad - the derivative there is only rounding noise, and so is `t`.
+ // The control point of such a part is noise as well, so offset the rest of it as its chord.
+ if (part[0] == part[2] && iter.part[1] == p1) {
+ offset_quad_simple(iter.part[0], Math::lerp(iter.part[0], iter.part[2]), iter.part[2]);
+ break;
+ }
+
offset_quad_simple(part[0], part[1], part[2]);

if (t == 1.0)
19 changes: 19 additions & 0 deletions cmake/extlib_blend2d.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ else()
# bump deliberately. This is the commit the font-rendering code was
# validated against.
GIT_TAG 6dbc2cefbc996379e07104e34519a440b49b15d7
# Backport of blend2d/blend2d#268 (fixes blend2d/blend2d#267): the JIT
# analytic fill reused a stale span length when the last BitWord of a
# scanline was all ones, which made an affine image blit whose clip
# width is 252..255 mod 256 run a zero-length span for 2^32 pixels and
# segfault (docling-parse #365). Drop the patch and the PATCH_COMMAND
# when the pin moves past the upstream fix.
#
# Backport of blend2d/blend2d#270 (fixes blend2d/blend2d#269): the
# stroker's quad offsetting loop split a curve forever when the split
# step was below the precision of the coordinates, which happens at the
# turning point of a nearly collinear quad (a sub-pixel stroked cubic
# hung page rendering, docling-parse #393). Drop it under the same
# condition as the patch above.
PATCH_COMMAND ${CMAKE_COMMAND}
-DPATCH_FILE=${CMAKE_CURRENT_LIST_DIR}/blend2d-fillanalytic-stale-i.patch
-P ${CMAKE_CURRENT_LIST_DIR}/apply_patch.cmake
COMMAND ${CMAKE_COMMAND}
-DPATCH_FILE=${CMAKE_CURRENT_LIST_DIR}/blend2d-stroke-offset-quad-stall.patch
-P ${CMAKE_CURRENT_LIST_DIR}/apply_patch.cmake
)
FetchContent_MakeAvailable(blend2d)
# Release wheels must not contain Blend2D's debug assertion path. Blend2D
Expand Down
117 changes: 117 additions & 0 deletions tests/test_unit_affine_image_clip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env python
"""A sheared image that runs off the right edge of a canvas whose width is
252..255 mod 256 must render, not crash the process.

docling-parse #365: page 7 of a scanned manual segfaulted in `get_image()` at
exactly scale 3.0. The page is one CCITT bitmap placed with a 0.17 degree skew,
so it is blitted through the affine path. At that scale the canvas is 1279 px
wide, and 1279 % 256 is 255, which puts the clip's x_end cell on bit 63 of the
last 64-bit coverage word in Blend2D's analytic rasterizer. The skewed top edge
crossing the right clip edge flags every cell of that word, and Blend2D's JIT
fill then reused a stale span length, ran a zero-length span for 2^32 pixels,
and walked the destination pointer off the heap (blend2d/blend2d#267, fixed by
blend2d/blend2d#268, carried here as `cmake/blend2d-fillanalytic-stale-i.patch`).

The fixture reproduces that configuration on a tiny page: 255.5 x 5 pt renders
to 511 x 10 px at scale 2, and a 16 x 16 image is placed so that its skewed top
edge crosses the right edge within the first scanline. The render runs in a
child process, so a regression shows up as a failed assertion naming the signal
instead of taking the whole test run down with it.
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

from tests.pdf_builder import simple_page_pdf, stream_object

REPO_ROOT = Path(__file__).resolve().parents[1]

SCALE = 2.0
PAGE_WIDTH_PT = 255.5 # ceil(255.5 * 2) = 511 = 2 * 256 - 1
PAGE_HEIGHT_PT = 5.0
EXPECTED_SIZE = (511, 10)

IMAGE_SIZE = 16

# The placement that crashed, expressed as the canvas-space affine map
# (pixels, y down) of a 2592 x 3312 blit rectangle: a scale of about 1.14 with
# a slight skew, starting 288 px left of the canvas and 2.4 px below its top.
# This is the geometry of the minimal reproducer attached to blend2d/blend2d#267.
BLIT_WIDTH, BLIT_HEIGHT = 2592, 3312
CANVAS_AFFINE = (
1.1372183005498808,
0.0012802436069027732,
0.0044605010442717481,
1.1372183005498808,
-288.41887602788347,
2.3954065681413779,
)


def image_matrix() -> str:
"""The `cm` operands that put the image's unit square on `CANVAS_AFFINE`.

Canvas x is PDF x times the scale; canvas y is the canvas height minus PDF
y times the scale. The image's top-left pixel is the unit square's (0, 1)
corner, so the blit rectangle's v axis runs against the unit square's.
"""
m00, m01, m10, m11, m20, m21 = CANVAS_AFFINE
a = m00 * BLIT_WIDTH / SCALE
b = -m01 * BLIT_WIDTH / SCALE
c = -m10 * BLIT_HEIGHT / SCALE
d = m11 * BLIT_HEIGHT / SCALE
e = (m20 + m10 * BLIT_HEIGHT) / SCALE
f = PAGE_HEIGHT_PT - (m21 + m11 * BLIT_HEIGHT) / SCALE
return f"{a:.9f} {b:.9f} {c:.9f} {d:.9f} {e:.9f} {f:.9f} cm"


def skewed_image_pdf() -> bytes:
payload = bytes(
(x * 15) & 0xFF for _ in range(IMAGE_SIZE) for x in range(IMAGE_SIZE)
)
image = stream_object(
f"/Type /XObject /Subtype /Image /Width {IMAGE_SIZE} /Height {IMAGE_SIZE} "
"/ColorSpace /DeviceGray /BitsPerComponent 8",
payload,
)
return simple_page_pdf(
f"q {image_matrix()} /Im1 Do Q",
resources="/XObject << /Im1 5 0 R >>",
media_box=f"[0 0 {PAGE_WIDTH_PT} {PAGE_HEIGHT_PT}]",
extra_objects=[image],
)


RENDER_CHILD = """
import sys
from pathlib import Path

from tests.pdf_builder import render_page

result = render_page(Path(sys.argv[1]).read_bytes(), scale=float(sys.argv[2]))
width, height = result.get_image().size
print(f"{width} {height}")
"""


def test_skewed_image_past_right_clip_edge_renders(tmp_path: Path) -> None:
pdf_path = tmp_path / "skewed_image.pdf"
pdf_path.write_bytes(skewed_image_pdf())

completed = subprocess.run(
[sys.executable, "-c", RENDER_CHILD, str(pdf_path), str(SCALE)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=300,
)

assert completed.returncode == 0, (
f"render exited with {completed.returncode} "
f"(negative values are signals; -11 is SIGSEGV)\n{completed.stderr[-2000:]}"
)
width, height = (int(v) for v in completed.stdout.split())
assert (width, height) == EXPECTED_SIZE
84 changes: 84 additions & 0 deletions tests/test_unit_subpixel_stroke_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python
"""A stroked cubic that is smaller than a pixel must render, not hang.

docling-parse #393: page 29 of a dense vector map never finished rendering at
scale 2. The page holds a hairline (`0 w`) with round caps and joins, a line
followed by a cubic, scaled by about 0.096, so the cubic's control polygon is
about 0.6 x 0.4 px on the canvas. Blend2D approximates the cubic with quads
and offsets each quad by splitting it at the parameter where its tangent has
turned by a fixed angle. One quad was nearly collinear and turned back on
itself; after a split landed on the turning point, the derivative of the rest
was only rounding noise, the next split parameter was computed from that noise,
and the split returned the same curve. The loop never terminated, inside the
native call, so neither Python nor Docling's document timeout could stop it
(blend2d/blend2d#269, fixed by blend2d/blend2d#270, carried here as
`cmake/blend2d-stroke-offset-quad-stall.patch`).

The fixture is the reporter's one-page reproducer, stroked in black instead of
white so the test can also check where the ink lands. The render runs in a
child process, so a regression shows up as a timeout instead of stalling the
whole test run.
"""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest

from tests.pdf_builder import simple_page_pdf

REPO_ROOT = Path(__file__).resolve().parents[1]

SCALE = 2.0
MEDIA_BOX = "[0 0 595.32 841.92]" # renders to 1191 x 1684 px, so the scale is 2.0006
EXPECTED_SIZE = (1191, 1684)
CONTENT = (
"q 1 0 0 1 56.9459229 0 cm 0.095993 0 0 -0.0959473 254.7695923 485.9789734 cm "
"0 G 0 w 1 j 1 J 0 0 m 4 4 l 7 3 5 4 7 2 c S Q"
)

# The stroke spans about (623.6, 711.9) - (625.0, 712.7) on the canvas; with
# a 1 px hairline and round caps, all of its ink falls inside this box.
INK_BOX = (620, 708, 629, 717)

RENDER_CHILD = """
import sys
from pathlib import Path

from tests.pdf_builder import render_page

image = render_page(Path(sys.argv[1]).read_bytes(), scale=float(sys.argv[2])).get_image()
gray = image.convert("L")
ink_box = gray.point(lambda v: 255 if v < 250 else 0).getbbox() or (-1, -1, -1, -1)
print(*gray.size, *ink_box, gray.getextrema()[0])
"""


def test_subpixel_stroked_cubic_renders(tmp_path: Path) -> None:
pdf_path = tmp_path / "subpixel_cubic.pdf"
pdf_path.write_bytes(simple_page_pdf(CONTENT, media_box=MEDIA_BOX))

try:
completed = subprocess.run(
[sys.executable, "-c", RENDER_CHILD, str(pdf_path), str(SCALE)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=60,
)
except subprocess.TimeoutExpired:
pytest.fail(
"rendering a sub-pixel stroked cubic did not return (blend2d/blend2d#269)"
)

assert completed.returncode == 0, completed.stderr[-2000:]
width, height, x0, y0, x1, y1, darkest = (int(v) for v in completed.stdout.split())
assert (width, height) == EXPECTED_SIZE
assert darkest < 128, "the stroke was not drawn"
bx0, by0, bx1, by1 = INK_BOX
assert bx0 <= x0 and by0 <= y0 and x1 <= bx1 and y1 <= by1, (
f"ink spans ({x0}, {y0}) - ({x1}, {y1}), outside the stroke's box {INK_BOX}"
)
Loading