* fix(render): bound xref streams, JBIG2 referred-to counts, saved graphics states and /W2 runs
Input bounds and crash fixes in the vendored hayro crates, ported from
the snapshot craft-libs carries (storytold/craft-libs#176, #239, #241):
- hayro-syntax: an xref stream subsection must be backed by its decoded
data. All-zero /W widths and a count longer than the data are refused
before any entry is inserted; a tiny stream with a large /Size or
/Index count inserted that many entries.
- hayro-syntax: a subsection running past object number u32::MAX is
refused; start + i overflowed.
- hayro-syntax: the xref stream type field is read big-endian at its
full /W[0] width, and /W[0] over 8 bytes is refused like /W[1].
- hayro-jbig2: the long-form referred-to segment count is capped at
u16::MAX before its retention bytes are read; the 29-bit count went
straight to Vec::with_capacity (about 2 GiB) and aborted the process.
- hayro-interpret: at most 4096 saved graphics states holding at most
64 MiB together. A q past either limit is counted but not copied, and
its Q still removes the clips added since.
- hayro-interpret: a /W2 run whose first CID is past MAX_CID stops there,
as /W does; a first CID of 4294967295 discarded the whole /W2 table.
Each patch is marked in the source, has a regression test in its crate,
and is listed in vendor/README.md.
* fix(render): a binary usecmap counts against the CMap nesting limit; /W2 checks the CID first
- hayro-cmap: a binary CMap's usecmap passed its own depth to the nested
parse, so a self-referencing binary CMap recursed until the stack
overflowed. It now counts like a text usecmap (ledger hayro-cmap 2).
- hayro-interpret: the /W2 first-CID check runs before the run's metrics
are read, so an incomplete run past MAX_CID no longer discards the rest
of the table (ledger 52).
* fix(render): refuse xref-stream object numbers past i32::MAX
ObjectIdentifier stores an i32; a number between i32::MAX and u32::MAX
was cast to a negative object number (ledger hayro-syntax 26).
Parallel PRs had each copied their crate's row and appended their patch as
the next number, leaving two hayro rows, four hayro-syntax rows and three
lopdf rows that disagreed with one another.
- hayro: the rows were identical except item (10), the native-gradient
backport in one and the `/Interpolate` pre-resize (#624) in the other.
The `/Interpolate` row is kept and the gradient backport is appended as
(23). The row already skipped (21); that gap is left as it was.
- hayro-syntax: the 24-item row is kept (it alone has the delimiter
extension of (4)); item (7) takes the wording that names ASCII85 (its
`decode_limit` patch is in filter/ascii_85.rs); the direct-object nesting
cap, numbered (14) in its own row, is appended as (25).
- lopdf: the six-item row is kept; the others were its prefix.
A blank line after the hayro-interpret row also ended the table, so the
crates after it rendered as loose text; it is removed. All 368 patch and
test entries in the old rows are still present, and each moved patch has
its `PdfCraft patch:` marker in vendor/.
Claude-Session: https://claude.ai/code/session_012rLxgUTc4atMXXgQEuJxh8
Co-authored-by: Claude <noreply@anthropic.com>
crates/render/src/raster.rs: main's tests plus the PR's #2970 test after the knockout test
vendor/README.md: main's rows with the PR's rewording of hayro patch 18
crates/render/src/raster.rs: both new tests (#2975 stencil masks, #2971 knockout groups)
vendor/hayro/src/renderer.rs: set_paint returns main's PaintSetup; the PR's knockout erase early return wraps its clip in PaintSetup::Clip
vendor/README.md: main's rows plus the PR's hayro-interpret patch 39 and hayro patch numbered 22 (19 is #3296's), with tests
vendor/hayro-interpret/src/pattern.rs: ShadingPattern::new takes the default colour spaces (#3018) and no opacity (this PR); without_opacity (#2977) and set_alpha both kept
vendor/hayro/src/renderer.rs: main's PaintSetup::Clip match with the PR's fill paint (is_stroke false) and comment
vendor/README.md: main's row plus the PR's hayro-interpret patch and test, numbered 46 (35 is #3296's)
A begin… block cut off at the end of the stream returned None through ? and discarded the whole map; it now ends the scan like any other unparseable tail. Test: stream_cut_off_inside_a_block_keeps_the_entries_before_it.
crates/cos/src/document.rs: #2220 moved the header scan into object_headers; it also returns the position after `obj`, and reconstruct applies this PR's /XRef candidate check (same 1024-byte window) to each header there. Tests: kept both sides.
vendor/hayro-interpret/src/encode.rs: both sides; a pixel sampled by either test (corner held, or centre covered) widens the mesh extent from #3296
vendor/README.md: main's row plus the PR's patch 36 and its test
crates/cos/src/document.rs: kept #2220's shifted_subsection_end and object_headers and this PR's prev_offset.
vendor/README.md: hayro-syntax row already has (22) and (23); this PR's patch and tests are numbered (24).
Vendored hayro-interpret refuses an embedded font whose `post` table
version isn't 1.0, 2.0, 2.5 or 3.0 (a guard from pdf.js issue 9462).
ScanSoft PDF Create! writes the version of every font it embeds with its
16-bit halves swapped (`00 00 00 03` for 3.0), so those fonts were refused
and their text drawn with a substitute: Hebrew came out as Latin
look-alikes or not at all.
1.0, 2.0 and 3.0 with swapped halves are now accepted too; any other
unknown version is still refused. PdfCraft patch (38) in vendor/README.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
CffFontBlob::glyph_index_by_name searched the whole charset, with a string lookup per glyph, for every character code of a font (there was a TODO noting it). Build a name to glyph map on first use and share it between clones. The first glyph with a name still wins, so results are unchanged.
Fall back only when missing sample or terminator bytes cannot fit the
content-window budget, including retained header and separator overhead.
Preserve available deterministic boundaries and ordinary large streams.
Add oversized, exact-budget, overflow and large-stream regressions.
* fix(render): read each object stream's index once (vendored hayro-syntax patch 22)
`XRef::get_with` parsed an object stream's whole index of `objnum offset`
pairs again for every object it fetched from that stream, so reading all of a
stream of N objects took N² steps. Firefox keeps every object of a page in one
stream: the pdf.js corpus file bug1978317.pdf (65,542 objects in one stream)
took 37 s to draw and 40 s to extract its text, and was a known timeout in the
robustness baseline.
The parsed index is now kept per stream, beside the decoded stream data it
indexes (`ObjectStreamIndexes`), and never cleared, as that data isn't. A
malformed index is remembered as malformed; `/N` or `/First` that can't be
read (a reference that can't be followed while another stream loads) is not
remembered. An offset past `usize` is malformed instead of wrapping. A poisoned
lock is still used: entries are inserted whole.
Release build, Linux: bug1978317.pdf draws in 0.5 s (was 37 s) and extracts
its text in 1.9 s (was 40 s), and leaves the baseline's known timeouts;
40,000 pages in one stream open in 0.2 s (was 54 s).
`raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`
times out at 20 s with the fix reverted and passes in 0.2 s with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ
* test(render): an object stream offset past usize is malformed, not a panic
Review follow-up on #2427. Mutation testing by mtlcl410 found that replacing
patch (22)'s `checked_add` in `read_object_stream_index` with a plain `+`
survived every test, although in a debug build that `+` panics on the very
input it guards against (and wraps in a release build). Their test pins it:
it fails with "attempt to add with overflow" under that mutant and passes on
this branch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
is_nearly_equal allows a 256th of a path unit, and stroke_path_impl
applies the matrix after path_as_rect, so a cm of 10,000 turns that
tolerance into tens of device units: a path that only nearly closes was
painted as the rectangle, with a side it does not have, and an edge off
true by that much counted as axis-aligned.
The fast path now compares coordinates exactly. Nothing that is a
rectangle loses it: a rectangle's coordinates are equal in the content
stream, not approximately equal. A path that misses takes the general
route and is painted as written, which is the safe direction.
I could not make a rendered case that distinguishes the two: this
renderer paints a 10,000x cm as a solid page whatever the line width, so
there are no pixels to compare at the scale where the tolerance shows.
The change rests on the argument above, and the three existing tests
still pin the behaviour that is measurable.
Reported by CodeRabbit on #3327.
FredApps's note on #3327: five points that are all on the bounding box's
corners still need not trace the rectangle. Three sides and a step back
along the third --
10 10 m 30 10 l 30 30 l 10 30 l 30 30 l S
-- visits every corner, so it was taken as the box and stroked with a
left side the path does not have.
path_as_rect now asks what a rectangle actually is: four distinct
points, each step from one to the next along a single axis, and the last
point back at the first (written as the fourth LineTo or as ClosePath).
That is a rectangle and nothing else is, so the corner test and its
off_corner flag are gone -- four points of an axis-aligned traversal are
necessarily the corners of their own bounding box. A degenerate path,
whose points are not distinct, now takes the general route as well,
where it painted a zero-width or zero-height rectangle before.
The vendored-patch table has the row this change was missing: without
it a re-vendor of hayro-interpret would drop the patch silently, which
is the other half of FredApps's review.