Commit Graph
288 Commits
Author SHA1 Message Date
bflatastic d2619333e9 Merge #6438: M2: a page past the rasterizer's coverage renders what fitted instead of failing (view.render-error-isolation)
# Conflicts:
#	vendor/README.md
2026-11-09 08:15:14 +07:00
bflatastic 795f9a9508 Merge #6532: M2: a CMap's bfchar mappings read codes at their codespace length (view.render-cid-fonts)
# Conflicts:
#	crates/render/src/raster/tests.rs
#	vendor/README.md
2026-11-09 08:14:19 +07:00
bflatastic d5ea69db50 Merge #6348: fix(render): a ToUnicode CMap reads its mappings past a token it can't parse (hayro-cmap patch 3)
# Conflicts:
#	crates/render/src/raster/tests.rs
#	vendor/README.md
2026-11-09 08:13:19 +07:00
bflatastic 2992146abb Merge #6312: M1: a document that encrypts only its embedded files opens without a password (core.attachment-only-encryption)
# Conflicts:
#	crates/render/src/inspect/tests.rs
#	vendor/README.md
2026-11-09 08:12:28 +07:00
bflatastic e70cacf36f Merge #6290: fix(render): a stream with no endstream after it draws from its /Length instead of a blank page (hayro-syntax patch 36)
# Conflicts:
#	crates/render/src/raster/tests.rs
#	vendor/README.md
2026-11-09 08:11:43 +07:00
bflatastic 9e2c8130ad Merge #6561: M1: a file whose last update has no /Prev opens with all its pages and a repair note (core.repair-broken-files)
# Conflicts:
#	parity/acrobat-features.toml
#	vendor/README.md
#	vendor/hayro-syntax/src/pdf.rs
2026-11-09 08:11:01 +07:00
bflatastic 16312e1a10 Merge #6534: M2: a page without a /MediaBox is US Letter (view.render-pages)
# Conflicts:
#	vendor/README.md
#	vendor/hayro-syntax/src/page.rs
2026-11-09 08:07:49 +07:00
bflatastic 9cb7dc4094 Merge #6510: M1: the renderer reads a cut-off last update's objects, as the document model does (core.repair-broken-files)
# Conflicts:
#	vendor/README.md
2026-11-09 08:05:56 +07:00
bflatastic e7ecc84544 Merge #6506: fix(render): a fax image with more /Rows than its /Height is decoded to its height (hayro-syntax patch 41)
# Conflicts:
#	vendor/README.md
2026-11-09 08:05:40 +07:00
bflatastic 5c42721193 Merge #6501: fix(render): an image whose PNG predictor was declared but never applied draws its samples (hayro-syntax patch 40)
# Conflicts:
#	parity/acrobat-features.toml
#	vendor/README.md
2026-11-09 08:05:23 +07:00
bflatastic 9d8e21de25 Merge #6355: fix(render): a page whose MediaBox or CropBox encloses no area shows its content (hayro-syntax patch 38)
# Conflicts:
#	vendor/README.md
2026-11-09 08:04:18 +07:00
mtlcl410 58c30af312 fix(render): a JBIG2 scan whose data starts with a JBIG2 file header draws instead of a white page (hayro-jbig2 patch 17) (#6279) 2026-11-09 08:54:03 +08:00
c02341f6c0 M2: a wide stroke of a path with y curves is drawn, not dropped (vello_common patch 4) (#6526)
A cubic whose second control point is its end point (as the `y` operator builds,
ISO 32000-2 8.5.2.1 Table 58) has a zero derivative at its end. kurbo 0.13.1's
offset curve looks for cusps with a root search on the curvature, which grows
without bound towards that end; at some widths the search stops on the end point
and normalising the zero tangent there gives NaN. vello_common's fill drops any
outline with NaN, so the whole stroke vanished (a two-curve `y` path drew nothing
at 16, 32 and 63 pt; a PDFBox tracker file lost its 32 pt "XX" outline).

The NaN fallback that #4913 added to `flatten::stroke` now also moves such a
control point the stroke tolerance towards the previous control point before
stroking again (the curve moves by at most 4/9 of the tolerance). Outlines that
come back finite take the old path unchanged. The vendor ledger row also gets
back #4913's entry, which a merge had dropped.


Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA

Co-authored-by: mtlcl410 <chuanlin@niar.org.tw>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:31:00 +08:00
42b21e5362 fix(render): a form XObject that draws itself is drawn once (hayro-interpret patch 89) (#6515)
* M2: a form XObject that draws itself is drawn once (hayro-interpret patch 89)

A form whose content names itself, directly or through another form, a
pattern or a glyph, drew itself again at every level until the page's
nested-paint budget (50,000) ran out. PDFBOX-5782's poppler-748 page 2,
whose form names a /Pages dictionary as /X0 and so finds itself in the
page's resources, took 38 s.

hayro-interpret now keeps the forms being drawn on the context and skips
a form already on it, reporting ContentTruncated (ISO 32000-2 §8.10.1: a
form is a self-contained description). The lenient fallback from a form's
own resources to the page's stays: MuPDF, Poppler and PDFium all draw a
name from the page's resources when the form's /Resources has no
XObject subdictionary (MuPDF and Poppler also when it has an empty one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn

* fix(render): a form that reaches itself through a pattern or a glyph is drawn once (hayro-interpret patch 89)

The forms being drawn are now kept per page and thread rather than per
context, so the tiling patterns, Type 3 glyphs, soft masks and groups
painted inside a form, which hayro interprets in contexts of their own,
see that the form is being drawn and skip it inside themselves. A form
drawn again after it has finished draws as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA

* chore: re-run CI after the Fill & Sign save comparison failed on the clock (#6066)

`fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels`
compares two saves whose /ModDate fell in different seconds; it passes
locally on this commit. No change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA

---------

Co-authored-by: mtlcl410 <chuanlin@niar.org.tw>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:30:14 +08:00
9b231f241d perf(render): undo TIFF predictor 2 a byte row at a time at 8 and 16 bits (#6505)
TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10) went through the
bit-level BitChunks/BitWriter path for every bit depth, re-reading the
previous output pixel through a new BitReader per pixel. Whole-byte
samples (8 and 16 bits per component) are now undone on byte slices,
each sample added to the same component of the pixel to its left,
wrapping at its width; 1-, 2- and 4-bit samples keep the bit-level path.
The output is byte-identical, including the dropped partial last row.

A page of 45 full-page 8-bit grey images so predicted (PDFBOX-1350)
renders in about 4 s instead of 23 s with the same pixels.

Vendored hayro-syntax patch 42.


Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn

Co-authored-by: mtlcl410 <chuanlin@niar.org.tw>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:29:44 +08:00
mtlcl410andClaude Opus 5.5 9b470180ab M1: the delimiter test rewrites only the cut-off update, and a comment after obj is tested to end the keyword (core.repair-broken-files)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 16:29:58 +08:00
mtlcl410andClaude Opus 5.5 66763378aa M1: a rebuild whose trailer lacks the encryption dictionary is not taken, tested (core.repair-broken-files)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 14:12:50 +08:00
mtlcl410andClaude Opus 5.5 4a2507c086 M1: a cut-off update is rebuilt only after the named table refused a wrong password and only when encrypted as that table is; trailing object text is tested to read as the model reads it (core.repair-broken-files)
- Pdf::new_with_password reads the table the last startxref names first: a wrong password is
  refused through it, and the rebuilt table is taken only when it is encrypted exactly when
  that table is.
- Tests: an encrypted file with a cut-off update needs its password; data after %%EOF holding
  an object header reads as the document model reads it (both take its page).
- vendor/README.md lists the split tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-08 12:47:14 +08:00
mtlcl410andClaude Opus 5.5 7de85d520c M1: a hybrid update without /Prev is rebuilt by the renderer as by cos (core.repair-broken-files)
The renderer (hayro-syntax (44)) counted the sections its chain reached, and
a hybrid section's /XRefStm stream (ISO 32000-2 §7.5.8.4) counted as one, so
a hybrid update without /Prev was rebuilt by cos but not by the renderer. It
now asks what cos asks: whether the newest section names an earlier one
through /Prev. The /XRefStm stream belongs to the same update.

Tests: a synthetic hybrid fixture (pdfcraft-testkit), with and without
/Prev, in cos and render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 22:29:49 +08:00
mtlcl410andClaude Opus 5.5 6847b1ea7e M1: a file whose last update has no /Prev opens with all its pages and a repair note (core.repair-broken-files)
An update whose cross-reference section has no /Prev (ISO 32000-2 §7.5.6,
§7.5.8.2) cuts the earlier revisions, and the page tree with them, off the
chain. The signed PDFBOX-2720 and PDFBOX-2942 files have this shape: the
document model found no page tree and the renderer showed 2 blank pages
found by scanning for page dictionaries.

When the chain reaches only the newest section, the file holds another
startxref and the catalog's /Pages can't be read, both readers now rebuild
the table from the file's objects, as for a damaged file:

- cos: the existing reconstruction (newest in-file definition wins), a
  repair-log line saying why, and `Document::rebuilt_unlinked_update`.
- hayro-syntax (44): `root_xref` repairs the table before the page tree is
  read; `Pdf::rebuilt_unlinked_update` says so.
- render: an open warning, and the lazy structure reader accepts such a
  rebuilt file, so links, fields and bookmarks are read too.

A save rewrites the file in full (main's rule for reconstructed files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 22:22:47 +08:00
mtlcl410andClaude Opus 5.5 3a3fe00f69 M2: without codespace ranges, a bfchar CID mapping matches only codes of its own source length (view.render-cid-fonts)
hayro-cmap patch 4: text CMaps record each bf entry's source length
(BfRange::code_len). Where no codespace range gives a code its length,
the bfchar fallback of lookup_cid_code takes the entry's own length, so
the first byte of <fe96> is no longer taken for <00fe>. CMaps with
codespace ranges are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 18:50:32 +08:00
mtlcl410andClaude Opus 5.5 2c1b903075 M2: a page whose /MediaBox has no area is US Letter and shows its content (view.render-pages)
- hayro-syntax patch 44: a /MediaBox without area counts as missing, so
  the page is sized, placed and clipped by the same Letter region and its
  content is drawn (MuPDF and PDFium both draw it on 612 x 792).
- pdfcraft-model: Page::crop falls back to Letter for such a box too.
- vendor/README.md: the raw-crop summary names the Letter fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 18:44:18 +08:00
mtlcl410andClaude Opus 5.5 cdb94e6611 M2: a page without a /MediaBox is US Letter (view.render-pages)
A page with no /MediaBox, its own or inherited, rendered as A4, while
PdfCraft's page model and inspector already sized it as US Letter and
other readers draw it at 612 x 792 pt (PDFBOX-4001-reduced.pdf).
ISO 32000-2 §7.7.3.3 Table 31 requires the box and gives no default;
follow the readers. `base_dimensions`' fallback for a visible region
without area uses the same Letter size, so a /MediaBox [0 0 0 0] page
is Letter too. Vendored hayro-syntax patch 44. The model test pins the
size it already gave.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 17:17:26 +08:00
mtlcl410andClaude Opus 5.5 37e5708ed2 M2: a CMap's bfchar mappings read codes at their codespace length (view.render-cid-fonts)
A CMap that writes its CID mappings as `bfchar` was looked up without
the code's byte length, so the first byte of a two-byte code matched a
two-byte entry of the same value and drew as its own glyph: `<fe96>`
drew as "þ–" (PDFBOX-1282, Characters_Arial.pdf page 3). The bfchar
fallback of `lookup_cid_code` now applies only when the codespace
ranges give the code the length being tried (ISO 32000-2 §9.7.6.2),
byte by byte; a CMap without codespace ranges keeps the old fallback.
An unmapped code still advances one byte. Vendored hayro-cmap patch 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-07 17:03:56 +08:00
mtlcl410andClaude Opus 5.5 78d90416e6 M1: the renderer reads a cut-off last update's objects, as the document model does (core.repair-broken-files)
When a file's last incremental update stops after its cross-reference rows,
with no trailer or startxref, the last startxref in the file is the older
revision's. pdfcraft-cos finds no startxref in the last 4 KiB and rebuilds
from object headers (latest objects win); vendored hayro-syntax read the older
table, so the view showed the older revision (PDFBOX-3934: 12 pages, the model
13). ISO 32000-2 §7.5.6: a later section supersedes earlier ones; repair is
unspecified and PDFium, MuPDF, Poppler and qpdf take the latest objects.

hayro-syntax patch 43: when the last startxref is more than 4 KiB from the end
and N G obj headers follow it, Pdf::new_with_password rebuilds first; the older
table is read only when the rebuilt one has no page. Trailing data without
object headers and a startxref near the end read as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 12:38:45 +08:00
mtlcl410andClaude Opus 5.5 d4b4218cf1 M2: a fax image with more /Rows than its /Height is decoded to its height (hayro-syntax patch 41)
A CCITTFaxDecode image whose /DecodeParms /Rows exceeds the image's
/Height (PDFBOX-771: /Rows 1084975104 on 2,592 columns) asked the decoder
for /Rows rows; the size check refused that, and every page drew blank.
The image is /Height rows (ISO 32000-2 Table 87), so the decoder is asked
for those; /Rows still counts for a stream decoded without an image's
height. pdfcraft-cos already decodes /Height rows; a test pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 11:55:32 +08:00
mtlcl410andClaude Opus 5.5 e6c399811d fix(render): an image whose PNG predictor was declared but never applied draws its samples (hayro-syntax patch 40)
A writer declared /Predictor 15 on an image and its soft mask without
applying it, so the inflated data is exactly /Height rows of the row length
with no filter-type byte before each (ISO 32000-2 §7.4.4.4 requires one per
PNG-predicted row). Read with the predictor, every row's first sample was
taken for a filter type, the image came out a row short and shifted, and the
page drew it in fragments; image export said its data was shorter than its
size. PDFium, MuPDF and Poppler read such data as the samples it is.

Only that exact length takes the new path, and only for the last Flate
filter of an image whose height is known; anything else is predicted as
before. The renderer (vendored hayro-syntax, patch 40) reports it with
warn!, like its other lenient filter decodes; pdfcraft-filters returns it as
tolerated damage (partial in tolerant decoding, an error in strict), through
the new decode_image_tolerant_within that pdfcraft-cos uses for images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 11:05:20 +08:00
mtlcl410andClaude Opus 5.5 0805f07b89 M2: the layer clip coverage test checks both sides of the limit
The first clip is asserted to fit, and a fill inside it shows within the
circle only; a scene with room for exactly one path shows the clip pushed
after a fill is itself refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 08:33:24 +08:00
mtlcl410andClaude Opus 5.5 fe5fa9e908 fix(render): a number token the scanner can't read is skipped whole, so 1beginbfchar doesn't start mappings (hayro-postscript patch 2)
A number read stops at the first byte that can't be part of a number,
which may be inside the token: `parse_object_or_skip` then read the rest
(`beginbfchar` in `1beginbfchar`) as a token of its own, and the pairs
after it became mappings. The rest of the token is now skipped with the
failed number, one failure per token, as PostScript reads a run of
regular characters as one token.

The render test's run of unreadable tokens before the mappings is spaced:
a run of `-` with nothing between them is one token now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-07 08:26:11 +08:00
mtlcl410andClaude Opus 5.5 4bba22a122 M2: strokes, fast rectangles and layer clips past the rasterizer's coverage are left out too (view.render-error-isolation)
Each of the dispatcher's other room checks gets a case: a page whose only
coverage is strokes, or axis-aligned rectangles, drawn 2^10 times through
forms, then a red marker through the same path, rendered with the
rasterizer kept to 200,000 values (the marker is left out and the page
says so; unlimited, it is drawn), and clipped layers pushed through
vello_cpu's RenderContext directly (hayro pushes no clipped layers). Each
case fails with only its own path's check removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 07:08:53 +08:00
mtlcl410andClaude Opus 5.5 0647a4e724 fix(render): an unreadable CMap token counts once against the skip limit, with unit tests (hayro-cmap patch 3, hayro-postscript patch 2)
- `Scanner::parse_object_or_skip` skips whitespace and comments before
  taking its start offset, so a token after a space fails once, not
  twice, whoever calls it. (The CMap scan already skipped whitespace in
  `at_end`, so CMap results do not change.)
- Unit tests in hayro-postscript (stepping over a token, a failed read
  not read again, never past the end of the data) and in hayro-cmap
  (bfchar and bfrange after a stray `>` or a spaced name, the 1,024-token
  limit at its boundary, a CMap of unreadable tokens only failing).
- The render test checks a bfrange entry after the bad token, a run past
  the limit before the mappings, and a CMap of unreadable tokens only;
  its wall-clock assertion is gone, the no-re-read property is the
  deterministic hayro-postscript test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 06:52:15 +08:00
mtlcl410andClaude Opus 5.5 0e75ad08c8 M2: a page past the rasterizer's coverage renders what fitted instead of failing (view.render-error-isolation)
vello_cpu keeps every path's coverage (alpha) values in one buffer until
the scene is rasterized, and a strip packs its index into that buffer in
31 bits; Strip::new asserts it fits. A page that painted one form 2^22
times over (22 nested forms, each drawing the next twice) grew the
buffer past 2^31 values at about 7 GB and panicked, and the page failed.

- vello_common (2): StripStorage::room_for_path{,_within} says whether
  one more path's coverage, at most the whole surface counted twice,
  still fits below that limit or a smaller one.
- vello_cpu (1): the single-threaded dispatcher leaves out fills,
  strokes, fast rectangles and layer clips once there is no room,
  records it (RenderContext::coverage_full) and can be kept to fewer
  values (set_max_coverage).
- hayro (31): RenderSettings::max_coverage passes that limit down, and
  a page whose paths were left out reports
  InterpreterWarning::ContentTruncated, which PdfCraft already shows as
  content skipped past the page's safety budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-06 22:03:27 +08:00
mtlcl410andClaude Opus 5.5 7ece22b57e fix(render): keep main's files; an inherited page box with area wins (hayro-syntax patch 38)
The previous commit was made on main's newer head with the older tree, so
it undid #977 and other changes from main. This commit puts main's files
back: the PR changes only vendor/hayro-syntax/src/page.rs, vendor/README.md
and crates/render/src/text/tests.rs again.

Review: a page's own box without area now gives way to an inherited one
with area before the default (A4 for a media box, the media box for a crop
box); the test adds a page whose own /MediaBox [0 0 0 0] inherits a larger
box with text past A4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-06 07:26:59 +08:00
mtlcl410andClaude Opus 5.5 142283f4f8 fix(render): a page whose MediaBox or CropBox encloses no area shows its content (hayro-syntax patch 38)
A /MediaBox without area ([0 0 0 0]) is read as a missing one (the
default page), and a /CropBox without area, or with none in common with
the media box, as a missing one (the media box, ISO 32000-2 §14.11.2,
Table 33). Kept, the page's visible region had no area: nothing was drawn
and no text was extracted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-06 06:53:35 +08:00
mtlcl410andClaude Opus 5.5 6fe98c1c60 fix(render): a failed CMap token read is not read again (hayro-postscript patch 2)
parse_object_or_skip reads on from where a failed read stopped, stepping
one byte only when the read made no progress, so unclosed strings, each
reading to the end of the data, cost one pass over it instead of one each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-06 06:24:35 +08:00
mtlcl410andClaude Opus 5.5 c282f6ba33 fix(render): a ToUnicode CMap reads its mappings past a token it can't parse (hayro-cmap patch 3)
The CMap scan stopped at the first token the scanner couldn't read, so a
stray `>` after a CMap name, or a CMap name with spaces in it, ended it
before any bfchar entry: such fonts had no text to copy or find. The
scanner steps one byte past an unreadable token (hayro-postscript patch 2)
and the scan goes on, at most 1,024 times per CMap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-06 06:04:54 +08:00
mtlcl410andClaude Opus 5.5 306513c41d M1: the embedded-files-only encryption patch is hayro-syntax patch (37)
Patch (36) was also taken by #6290, opened earlier; this PR's number moves
to the next unused one in hayro-syntax (main's highest is 35): README row
and the code comments that name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-06 03:41:15 +08:00
mtlcl410andClaude Opus 5.5 390b2012e4 M1: a document that encrypts only its embedded files opens without a password
A crypt filter whose /AuthEvent is /EFOpen needs its password only when
an embedded file is accessed (ISO 32000-2 7.6.6, Table 25). A document
whose strings and streams are in the clear (/StmF, /StrF /Identity) and
whose encrypting filters are all EFOpen ones, /EFF naming one, now opens
without a password in cos and in the renderer (hayro-syntax patch 36).

Without the password there is no file key: the embedded files' data is
held and saved as stored, and Document::embedded_files_locked and
reads_as_stored say so. Asking for such an attachment's bytes is an
error. A full save that would renumber data whose key mixes in the
object number (RC4, AES-128), or change the document's security, is
refused. The password still opens the document and its attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-05 22:49:48 +08:00
mtlcl410andClaude Opus 5.5 bfaa20db23 M1: a stream with no endstream after it is the bytes its /Length gives (hayro-syntax patch 36)
ISO 32000-2 7.3.8.2: Length "indicates how many bytes of the PDF file are
used for the stream's data". cos and hayro-syntax used /Length only when
`endstream` followed it, and otherwise searched for the keyword; with
none left in the file the stream was an error and its object null. A
producer that writes `endstrea` after every stream lost every font and
every page's content, and its pages drew blank with no warning. With no
`endstream` left, a /Length the file holds is now the stream's extent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
2026-11-05 21:01:32 +08:00
FredApps f3dc867651 Merge remote-tracking branch 'origin/main' into w
# Conflicts:
#	vendor/README.md
2026-11-05 05:14:13 +01:00
KenM76andClaude Opus 5.5 69f4d0a8fe Merge origin/main into perf/png-predictor-bytes
Resolves vendor/README.md: main's hayro-syntax row, with this branch's
patch (35) and its tests appended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 08:33:14 -05:00
8717a085ff M2: a JBIG2 refinement reads a reference right of it from its first column (#6048)
A refinement whose reference is offset right of it (GRREFERENCEDX above
0, which a refined text instance has from a wider bitmap, ISO/IEC 14492
Table 12, and a refined symbol from an RDX above 0, Table 18) read the
reference's first 32 columns as 0. hayro-jbig2 emptied its buffers of
the reference left of it and, at its first column, didn't load them
again, so the refinement decoded to other pixels than were coded, or
failed and lost the page.

Vendored patch 15 (13 is #5999, 14 #6016): left of the reference the
buffers keep the words of its first column each row starts with. With
an offset of 0 or less nothing changes.


Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-04 13:51:47 +01:00
FredApps e989e48e63 Merge pull request #6058 from Piohrun/fix/jbig2-page-refinement-area
fix(render): a JBIG2 refinement of the page refines only its area (hayro-jbig2 patch 16)
2026-11-04 13:33:52 +01:00
9203e4a6be M1: encryption reads every legal form: crypt filters by reference, a stream's own filter on screen, and full saves that keep /Encrypt (#5548)
* M1: a crypt filter selected by reference is used

A stream can name its own crypt filter, and every part of that can be indirect. cos read only the
direct forms, and took anything else for no name, which is /Identity. So a stream selecting a filter
by reference was saved in the clear in a file meant to be encrypted, and in a file from elsewhere
its ciphertext was read as data. The /Encrypt dictionary's /StmF, /StrF, /EFF, /CFM, /Filter,
/EncryptMetadata and a filter's /Length had the same fault, and /StmF by reference left every
stream undecrypted. A stream's /Type and a signature dictionary's are read through references too.

transform now reads these from the document, so a full save encrypts before it renumbers: the
references in what it reads have to be the document's. Dropping a crypt filter that new protection
doesn't define comes before the walk, so what it copies out of a reference is encrypted too.

Loading can re-enter here, and LoadGuard ends a cycle, which reads as absent. A chain longer than
any decoder takes names no filter: its entries would be followed for every stream sharing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M2: a stream with a crypt filter of its own is drawn

The renderer decrypted every stream with the document's filter and then failed on /Crypt as a filter
it couldn't decode, so a stream naming its own filter read as nothing, however it was spelled, and a
page whose content was such a stream stayed blank. It now uses the filter the stream names, /Identity
without one, and /Crypt is nothing further to decode (hayro-syntax patch 30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: tests for a crypt filter's place among the others

A lone parameter dictionary is the first filter's, so /Crypt behind another filter has no name of
its own, and an array too short for /Crypt's entry stays as it is when the filter is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: encryption reads every legal form, and a full save keeps them

From a review of the crypt filter fix.

The renderer read a stream's own filter from the form /DecodeParms had to match the form of /Filter,
so [/Crypt] with a lone dictionary, an everyday spelling, read as /Identity and the page stayed
blank. Parameters are now taken in either form, which also gives [/FlateDecode] its predictor.
It also refused a V4 file without /StmF or /StrF, which are optional, and used a /CF entry named
/Identity, which is never one. A chain past what cos decodes names no filter there, so it names none
here either (hayro-syntax patch 30).

A full save lost what a direct /Encrypt refers to, and kept its references under the old numbers, so
nothing in the file read afterwards. /O or /U given by reference were decrypted and encrypted again
under a new number, so the file it wrote no longer opened. What /Encrypt and /ID refer to now stays
as stored. /ID by reference, which has to be direct, is read, and written direct.

A signature dictionary is known by a /ByteRange given by reference, and a null or dangling /Type or
/Subtype is an absent one. References to references are followed in Document::resolve, so /Name by
way of two no longer saves a stream in the clear. New protection drops every /Crypt in a chain.

An object stream's /DecodeParms can be given by reference.

Reading what decides how an object is treated can load more objects, each doing the same, which a
file could make a tree that doubles with every level once the cache lets go of it. What is loaded
for one of these no longer follows its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: what a read follows is bounded by a budget, and nothing shared is copied per stream

From a review of the fixes before this, all in them.

A flag that kept what is loaded for a selector from following its own stopped the fan-out, but a
legal file lost object stream members to it: read first through a selector, an object stream with
parameters by reference was kept undecoded, and a full save wrote nulls without saying so. A budget
replaces it: one read or write of an object, with all it loads on the way, follows up to 32
references, which is more than any object needs when all of it is by reference. Past that they
read as absent, so a file's ladder of selectors still stops, and nothing a real file holds is lost.

Finding out whether a dictionary is a signature's loaded objects for every dictionary of an object,
and a load that fails takes a scan of the file, so one read could hang. Only a dictionary with a
direct string /Contents is asked now, and the budget bounds those.

/ID written direct copied every entry, so a 1 MB file naming a large string thousands of times ran
out of memory: it is at most two strings now. Dropping a crypt filter copied a shared /DecodeParms or
/Filter into every stream, 1 GB from a 1 MB file: of what's shared only filter names and the
parameters decoders read are copied now, through Document::with_direct_parms.

The renderer skips /Crypt in a chain instead of copying the data for each entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: only an object itself follows references, and a failed scan is remembered

From a review of the budget.

Following a reference while walking deep inside an object added that depth again for each object
loaded, which overflowed a 2 MiB stack on a file under 10 KB and took the process with it, past any
catch_unwind. References are now followed for the object itself and never for what is nested in it.
A stream can't be nested, and a signature dictionary nested in another object reads as it says
directly, as it did before this PR. walk is back to following nothing.

A reference that fails to load scans the file, and nothing remembered that, so a 1 MB file took
minutes to save. What a scan didn't find is kept for the document: the file doesn't change.

Filter names copied out of a shared chain are bounded, since a name can be any length. /ID keeps each
entry in its place, or the key made from the first entry changes. direct_id's comment was on another
function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: tests with no deadline a busy machine can reach

Wide margins on the two that time what they guard. A test of dictionaries nested in an object goes:
nothing nested follows a reference now, which the small-stack test covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: a crypt filter past what a read may follow is the document's own

Past the budget a reference reads as absent, and a crypt filter with no /Name is /Identity, so a stream
that selected one by reference had its ciphertext read as data. What can't be told takes the document's
filter, as a /Filter that can't be read does.

Found by CodeRabbit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: a read refused, not a budget full, is what leaves a crypt filter unknown

My last commit took a read that used up all it may follow for one that was refused, so an /Identity
stream read at exactly 32 follows was decrypted to garbage. Refused reads are counted now, and only
one on the way to this stream's /Name says anything about it.

Found by CodeRabbit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M1: a save keeps the handler, the key and the permissions

Under every algorithm and by either kind of save, `/Encrypt`, `/ID`, the file key and the permissions
come back as they were. No test held that. `/ID` by reference is held through an incremental save
too, which leaves it as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* cos: encrypt an edited or added object whatever /Encrypt names

stored_as_is now also requires the object to be unedited, so an edit of
an object /Encrypt or /ID refers to, or a new object given a number they
name, is encrypted on both saves. encryption_members skips numbers with
no object behind them and streams, which those dictionaries never hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-04 13:17:29 +01:00
FredAppsandClaude Opus 5.5 031af8cef8 vendor/README.md: one Test cell in the hayro-jbig2 row
The merge left a | after patch (13)'s test, which made a sixth cell and
hid (14) and (16) from the rendered table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:16:44 +01:00
FredAppsandClaude Opus 5.5 160ee3d421 Merge main into fix/jbig2-page-refinement-area
vendor/README.md: patch (13), from #5999, goes into this branch's
hayro-jbig2 row ahead of (14) and (16).

The page-area test now fills the page's words with 1s, padding included:
since patch (13) new_with clears the padding, so the test no longer
exercised the page-width half of the mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:50:48 +01:00
PiohrunandClaude Opus 5.5 e38b9d182e fix(render): JBIG2 refinement reads 0 past its reference's width (hayro-jbig2 patch 13) (#5999)
* M2: a JBIG2 refinement reads 0 past its reference's width

hayro-jbig2 keeps each row of a bitmap in 32-bit words and filled a new
bitmap whole, so with a default pixel of 1 the bits past its width in
each row's last word were 1s too. A refinement reads its reference a word
at a time, those bits included, where ISO/IEC 14492 6.3.5.2 says a pixel
outside the reference is 0. A refinement of the page, or of an
intermediate text or halftone region, whose default pixel is 1 and whose
width isn't a multiple of 32 decoded to other pixels than were coded.

Vendored patch 13: Bitmap::clear_padding clears those bits when
new_with or reinitialize fills a bitmap with 1s, as cutting symbols and
patterns out of a collective bitmap already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M2: the doc on a JBIG2 bitmap's words says their padding is 0

Three places in hayro-jbig2 now clear the bits past a row's width, and
the field's doc said only that rows are padded. It now says the padding
is 0 and why: generic and refinement decoding read the rows above their
own pixels a word at a time, as a refinement reads its reference, so
whatever next writes whole words keeps it 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:49:49 +01:00
kronbiiandClaude Opus 5.5 c5065554a1 M2: shrink masked images a row at a time; keep near-1:1 scans sharp
Review of #3858 (aiongg):

- An area-shrunk image with a soft mask, a stencil's ink included,
  built its full-resolution RGBA before shrinking. A 10718 x 13701
  stencil at page size peaked at 1.16 GB (587 MB of RGBA). It is now
  premultiplied a source row at a time as it is averaged, and the
  shrink works a target row at a time, so it peaks at 1.03 GB, as the
  nearest-neighbour path did. Same bytes; about 5% slower per shrink.
- An image whose f32 scale passed 1 only by rounding was smoothed
  bilinearly and blurred by half a pixel at a half-pixel offset. It is
  now smoothed only above 1.01.
- A shrunk /ImageMask stencil keeping its thin strokes (#4800) is now
  tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:32:52 +02:00
kronbiiandClaude Opus 5.5 5ed2f4ba4f M2: keep an interpolated soft mask smooth on a shrunk image's grid
A soft mask no larger than its area-shrunk image is sampled onto the
image's grid, and it was always sampled nearest-neighbour: a mask that
asks for /Interpolate became a hard step where main drew a ramp
(jpeskov's 64 x 64 image at 32 x 32 under a 2 x 2 mask). An interpolated
mask is now blended bilinearly onto the grid, centres aligned; one
without /Interpolate is still sampled nearest, which keeps the
colour-fringe fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:32:52 +02:00
kronbiiandClaude Opus 5.5 ff227b770c M2: area-average shrunk scans and smooth enlarged ones
Scanned pages looked thin and broken at screen size. An image that does not
ask for /Interpolate (most scans) was pre-shrunk with nearest-neighbour since
about one sample in ten and dropped most strokes of its text, and the nearest
sampler then read the shrunk raster again at a fractional offset.

- Shrinking (vendored hayro patch 21): non-interpolated images outside Type 3
  glyphs are area-averaged (area_shrink: each device pixel the
  coverage-weighted mean of the samples it covers, 16.16 fixed point whose
  weights sum to exactly one, premultiplied when masked) to their snapped
  device rectangle and drawn 1:1. /Interpolate governs smoothing when an image
  is enlarged (ISO 32000-2 8.9.5.3); averaging adds nothing between samples
  and drops none. PicScale's Box/Area filters were not usable: shrinking, they
  average a window four target pixels wide.
- Enlarging (patch 28): an image of page-scan size (512+ samples on its
  shorter side) is drawn bilinearly when enlarged, so a zoomed scan has no
  staircase edges; icons, QR codes and pixel art keep their hard pixels.
- A snapped image whose shrink failed keeps the full transform instead of
  being drawn at its source size (latent in the interpolated path too).

Measured on a 13-page 300 dpi scan at 96, 120 and 192 dpi: every rendered
sample is within 1 of the exact area average (it was up to 255 off, 17-23%
of samples by more than 1). Bilinear zoom costs nothing measurable (four
1024 px tiles at 400%: 50 ms, nearest 48 ms; bicubic was 267 ms).

Review fixes:

- a tile snaps a shrunk image's half-pixel edges where the whole page does
- a scan shrunk one way is smoothed the way it is stretched
- a smaller soft mask doesn't let transparent colour through the area shrink

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:32:52 +02:00