182 Commits
Author SHA1 Message Date
FredApps 33e3945c04 Merge main and preserve unreadable diagnostics with cached failures 2026-11-07 15:48:09 +01:00
KenM76andClaude Opus 5.5 1107834209 Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-06 06:03:59 -05:00
KenM76andClaude Opus 5.5 80ab49d09a Merge origin/main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-06 05:59:18 -05:00
Hamza Naveedandskylar taylor-barrick 97cef90e01 Redaction: a pattern nothing paints with any more leaves the page (#3680)
* Redaction: a pattern nothing paints with any more leaves the page

A tiling pattern's cell is a content stream of its own and can draw
anything, text included. Redacting a shape filled with such a pattern
removed the fill from the page's content -- the page looked right, a
black box -- but left the pattern in /Resources /Pattern, and its cell,
with the text that was under the mark, was written into the saved file
unchanged.

The pass does not enter pattern cells, so it cannot redact inside one.
What it can do is notice that nothing paints with the pattern any more.
The graphics state now carries the fill and stroke pattern a `scn` or
`SCN` selected (and `cs`, `g`, `rg`, `k` and their stroke forms clear
it), every painting operator that survives the rewrite records the
pattern it drew with, and a pattern the page selected but no surviving
paint used leaves the page's resources.

Measured on the report's file:

    before   applied: 1, (SECRET) in the saved bytes
    after    applied: 1, no /Pattern on the page, (SECRET) gone

A pattern some surviving paint still uses is kept, and so is its cell:
with two boxes filled from one pattern and only the first marked,
patterns_removed is 0 and the resources keep the entry -- the second box
still shows the word, so it is not a leak.

Report gains patterns_removed.

Fixes #3647.

* Keep a pattern every operator that can paint with it still uses

Two defects CodeRabbit found in the first version of this change, both
of which could remove a pattern the page still draws with.

Pattern use was recorded for path painting only. Text shows with the
fill pattern too -- and with the stroke pattern in the stroking render
modes -- as do image masks and anything a form draws, and a form's
content is interpreted with a graphics state of its own, so a pattern it
inherits is never seen inside it. Content like

    /Pattern cs /P0 scn 20 140 100 30 re f BT (KEEP) Tj ET

with the box redacted therefore lost the fill of text still on the page.
Every operator that can paint with the current pattern now records it:
text (except mode 3, which paints nothing, and mode 7, which only
clips), inline images, and image or form XObjects. Only path painting
knows exactly what it drew and whether the paint survived, so the others
are recorded as they are met. That can keep a pattern whose paint was in
fact all redacted, which costs an unused resource entry, while dropping
one something still draws with takes paint off the page.

/Resources /Pattern may be an indirect object, like any value. Reading
the reference itself as a dictionary found none and pruned nothing, so
on such a file the cell -- with the text that was under the mark --
stayed in the saved bytes, which is the leak this change is for. It is
resolved now, and what is written back is a dictionary of this page's
own, so pruning never reaches through a /Pattern dictionary another page
shares.

The controls in the test: without the first fix the surviving text's
pattern is removed (left: 1, right: 0), and without the second the
indirect file prunes nothing (left: 0, right: 1).

* M8: refuse retained unsafe tiling patterns

* M8: guard shared appearances and narrow pattern scans

* M8: update TLS dependencies for new advisories

* Redaction: the comment on prune_patterns said more than the code does

`prune_patterns` drops the `/Pattern` resource entry; the `scn` that selected
it stays in the content, so the page names a pattern its resources no longer
hold. The comment claimed the selector went too (noted in review).

ISO 32000-1 8.6.6.2 makes that operand a key of the `/Pattern` subdictionary,
so the open question is whether a reader stops reading the stream at the stale
name. `a_page_whose_pruned_pattern_is_still_selected_renders` answers it with
the renderer rather than with prose: the redacted page is saved, reopened and
rasterized, and

* it renders with no error and the same 300x300 page box,
* the text written *after* the stale selector is still painted -- which is
  exactly what a reader would drop if it gave up at the bad operand,
* the redaction overlay is black over the box the pattern filled.

Control: with the drawing after the selector removed, the pixel assertion fails
("the text after the stale selector is still painted"), so it is reading that
text and not a stray pixel.

The selector is left in place rather than cut out: removing operators is a
rewrite of the content this pass does not otherwise do, and nothing surviving
paints with that colour. `pdfcraft-render` is a dev dependency of this crate
only -- layer 4 on layer 3, which the layering allows.

* WIP: pattern guard depth bound sized from the interpreter's

* Pattern tests: keep the guard's cases reaching the guard now a hit path is removed whole

* Pattern automation tests: keep the pattern in use where they test the guard, since a hit path is now removed whole

* Redaction: a form without resources keeps the page pattern it still paints

A form with no /Resources of its own paints with its caller's patterns, but
its surviving pattern paint was recorded only in the form's own output and
never reached the page. With the page's own fill under the mark removed
whole, prune_patterns then dropped /P0 from the page while the form outside
the mark still painted with it, so that fill lost its paint in the saved file.

Pass the painted names up from a form that inherits its caller's resources.
Only the painted side goes up, so this can keep a pattern, never prune one.

* Redaction: a pruned pattern does not stay named by the page tree

prune_patterns writes only the page's own /Resources, and the guard walked
only pages' effective resources. A /Pages node that still named the pruned
pattern kept its cell reachable from Root, so a full save wrote the cell,
text included, while apply returned Ok. Three shapes did it: a page with no
/Parent inheriting from the root, a /Parent that disagrees with the /Kids
tree, and a root /Resources shadowed by a nearer holder that was retired.

Patterns pruned from a page are now unlinked from page-tree holders no page
inherits from, as unlink_doomed does for XObjects, when every /Parent agrees
with the tree. Whenever a pattern was pruned (or a selected page had any),
the guard also inspects the /Resources of every /Pages node Root reaches
down /Kids and up each page's /Parent chain, so whatever stays reachable is
proven safe or the redaction is refused, leaving the document unchanged.

* Redaction: a Type 3 glyph without resources keeps the page pattern it paints

A Type 3 font with no /Resources looks the names in its glyph descriptions
up in the resources of the page or form showing it, so a glyph outside the
mark can paint with the page's pattern. Text recorded only the fill and
stroke pattern of the graphics state, so with the page's own fill under the
mark removed whole, prune_patterns dropped the pattern and the glyph lost its
paint. Showing such a font now keeps every pattern of the scope, as a form
without /Resources keeps what it paints.

* Redaction: say what text-recorded pattern use keeps

Text, inline images and XObjects record their pattern before the hit test,
so a pattern painted only by redacted text keeps its cell. The guard refuses
a cell with text or images; a vector-only cell stays in the file. The comment
said this cost only an unused resource entry. Pin the behaviour in a test.

* Redaction: the page tree is followed up every /Parent link

A full save writes every node a /Parent link reaches, but the guard followed
/Parent only from pages and stopped at the first node it had already seen,
and unlinking never followed it. A /Pages node reached only through the
/Parent of a /Kids node -- a root /Pages with a /Parent, or an intermediate
node whose /Parent leads out of the tree -- kept naming the pruned pattern,
and its cell, text included, stayed in the saved file while apply returned
Ok.

The guard now follows /Parent from every node it visits, page or /Pages, with
the same seen-set against cycles. Unlinking treats a root with a /Parent as an
inconsistent tree, like a disagreeing link, and leaves the decision to the
guard, which inspects every node those links reach and refuses an unsafe
cell.

* Redaction: a long page tree does not exhaust the pattern guard's budget

The page-tree walk counted every node, pages included, against the guard's
16,384-object budget, so redacting a pattern page of a 17,000-page document
was refused with a limit error where it had applied before. Only the holder
nodes the guard inspects count against that budget now; the walk itself is
bounded at a million nodes, as unlink_doomed bounds the same tree.

* Redaction: a Type 3 font set by an ExtGState or shown in a form keeps the page patterns

The resourceless Type 3 check covered only a font set by Tf in the scope that
paints. A Type 3 font without /Resources set through an ExtGState /Font entry
was not flagged, and a glyph shown inside a form with /Resources of its own
recorded its patterns in the form's scope, which the page never saw. In both
cases the page's own fill under the mark was removed whole, prune_patterns
dropped the pattern, and the glyph outside the mark lost its paint.

An ExtGState font is now checked the same way, and a glyph shown in any
nested form marks the page as keeping every pattern it selected. This can
only keep patterns; whatever is kept is checked by the guard.

* Redaction: a pruned pattern a full save would still write is proven safe or refused

The walks that check retained pattern cells follow the references they know
-- resource categories, the page tree, appearances -- but a full save writes
whatever the trailer reaches by any reference. A direct /Parent dictionary, a
/Kids array on a page, or a stray resource entry (/Shading naming the pattern
stream) kept a pruned cell, text included, in the saved file while apply
returned Ok.

pdfcraft_cos gains reachable_objects, which runs the same breadth-first walk
write_full uses (factored out as full_save_order, so the two cannot drift).
Before apply succeeds, every pruned pattern that walk still reaches is
inspected like a retained cell: vector-only passes, anything else refuses
with the existing pattern error. It runs only when a pattern was pruned and
costs one walk of the reachable objects.

* Redaction: a direct /Parent dictionary is part of the page tree too

A /Parent given as a dictionary rather than a reference is written inline by
a full save, but the page-tree walk followed /Parent only as a reference, and
unlinking took such a tree for consistent. A root /Pages whose direct /Parent
held /Resources naming the pruned pattern kept its cell in the saved file.

The guard now walks a direct /Parent like any node (its /Resources, /Parent
and /Kids), and unlinking counts any /Parent that is not the reference /Kids
came from as an inconsistent tree, leaving the decision to the guard.

* Redaction: page-tree walks queue each node once and borrow /Kids

On a hostile tree whose /Pages nodes share one long /Kids array, the guard's
page-tree walk and pattern unlinking pushed every /Kids entry of every node
before checking whether it had been seen, and copied the array on each
visit. Both now queue only nodes not seen yet and read /Kids in place;
unlinking also stops at the first node reached twice, since its /Parent can
agree with at most one of the nodes above it, and leaves that tree to the
guard.

* Redaction: a Type 3 font with /Resources null is resourceless too

/Resources null is the same as no /Resources, but the check counted any
entry, so such a font's glyph outside the mark lost the page pattern it
painted with. A Type 3 glyph shown by a kept annotation appearance is a known
limit, pinned as an ignored test: appearances are not interpreted for pattern
use, and under ISO 32000 the glyph resolves in the appearance's own resources.

* Redaction: a pruned pattern is recorded along its whole reference chain

A pattern name can lead to a reference-valued object whose value is the cell
(/P0 7 0 R, object 7 being 6 0 R). Pruning recorded only object 7, so the
reachability backstop checked an object nothing reached while a stray
reference kept the cell, object 6, in the saved file. Every object along the
chain is recorded now, bounded like the other walks.

* Pattern tests: pin the file identifier where two saves are compared

main now gives a redacted rewrite a new identifier from fresh entropy, so an
incremental and a full save after Apply differ in /ID alone. The test that
checks they are the same save passes fixed entropy to both.

* Redaction: replies and pop-ups removed on other pages feed the shared-appearance check

Apply checks every page for retained unsafe pattern cells when a removed
annotation's appearance is shared with one that stays on an unselected page.
Only annotations removed under a mark fed that check. Replies and pop-ups
that go with them in step 2b (removed_everywhere), often listed on another
page, did not, so a text-bearing cell in an appearance a kept annotation
shares with them escaped the all-pages check and Apply returned Ok.

Their appearance references now go into removed_appearances too, with the
same limit. Raised by FredApps' review of the rebase.

---------

Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-11-06 10:01:42 +01:00
Martin BallandClaude Opus 5.5 ce3857c2e5 sign: classify changes by signed object role
Judge each object changed after signing by the role it held in the signed
revision, keyed by object number, and compare a slot's accepted generations
across revisions. Adds `Document::accepted_generations` to cos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:10:06 +00:00
FredAppsandClaude Opus 5.5 250c2623d1 Tests: pin the file identifier formula, not a whole rewritten file (#6346)
The written file differs on Windows, so an identifier hashed from it cannot be
pinned, and main's Windows gate failed. The formula is pinned on fixed bytes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:44:29 +01:00
60f60fc0a2 Give a redacted or sanitized copy a new file identifier (#3734)
* cos: give a redacted or sanitized full save a new file identifier

A full save that the document asked for (Document::require_full_save_with_new_id)
now writes a fresh /ID pair, so a redacted or sanitized copy does not claim to be the
file it came from (ISO 32000-2 section 14.4). Ordinary saves are unchanged.

An encrypted file keeps the first string of /ID, because the file key is derived from
it, and gets a new second string. An encrypted file whose /ID is not a pair of strings
is left as it was, as before. SaveOptions::id_entropy lets a caller or a test supply
entropy; with it the new identifier depends only on that entropy and the written bytes.
Without it the identifier mixes the standard library's random hash keys and a
process-wide counter, the same facility the engine uses for new encryption keys.

Checked: the new tests in crates/cos/tests/file_identifier.rs fail on the clean base
(compile errors for the missing API), fail on assertions when only the API is in place,
and fail again when each pinned line is reverted (the regeneration call, the first-string
keep, the flag, the entropy input, and the ordinary-save guard). cargo test -p
pdfcraft-cos passes, and clippy with warnings denied is clean. A probe that wrote 16
ordinary full and incremental saves gave byte-identical files with and without this change.

* redact: give a redacted or sanitized copy a new file identifier

Applying redactions, sanitizing and removing hidden information now ask the cos
layer for a new file identifier (Document::require_full_save_with_new_id), so the
saved copy no longer carries the /ID of its source. Before this change the
previous revision was dropped but the identifier still linked the copy to the
original.

The request sits in remove_hidden itself, because its per-category callers (the
remove-hidden edit and the optimizer's discard option) call it directly, not
through sanitize(). sanitize() asks only on its nothing-hidden path, where
remove_hidden returned before asking: the rewrite is a new file either way.

Checked: the new tests a_redacted_or_sanitized_copy_gets_a_new_identifier and
a_remove_hidden_copy_gets_a_new_identifier fail before this change (the request
is missing after apply, and after remove_hidden) and fail again when the apply
call, the nothing-hidden branch or the remove_hidden request is reverted. cargo
test -p pdfcraft-redact passes with the tests included (152 passed, 1 ignored);
cargo clippy with warnings denied and cargo fmt --check are clean.

* docs(cos): say when a full save regenerates the file identifier

The writing section of the crate README described full saves without saying what
happens to /ID. It now says that an ordinary save keeps the identifier, that a
document which asks for a new one (redaction and sanitizing) gets both strings
regenerated, that SaveOptions::id_entropy supplies entropy for reproducible output,
and that an encrypted file keeps its first string because the key depends on it.

Checked: the text matches the code added in e1f761cc (regenerate_id, new_id_bytes,
require_full_save_with_new_id) and the tests in crates/cos/tests/file_identifier.rs.
No code changed in this commit.

* cos: hash the whole rewritten file into the new identifier

The identifier of a requested rewrite hashed only the first 64 KiB of the written file.
With caller-supplied id_entropy the identifier is deterministic, so two rewrites of
documents with the same length that differ only after byte 65,536 received the same
/ID, and a later edit of a large document could keep its previous /ID.

Every written byte is now hashed. The output is read once: one loop over 4 KiB chunks
feeds both halves of the 16-byte identifier, so the extra cost is one linear pass over
the output. Measured with a probe in the workspace dev profile (opt-level 1): hashing
64 MiB took 0.024 s, about 24 ms per 64 MiB of output. A release build was not measured.
Ordinary saves never reach this code, and their output is unchanged.

Checked: crates/cos/tests/file_identifier.rs gains
the_identifier_depends_on_the_whole_rewritten_file. It fails on the branch head before
this change ("content past 64 KiB must change the identifier", both identifiers equal)
and passes after it; reverting only the chunk loop to the 64 KiB prefix makes it fail
again. The six existing tests in that file pass, and so do the full cos and redact test
suites.

* cos: keep the key valid when an encrypted rewrite has no usable /ID

A requested rewrite of an encrypted file whose /ID was absent, had one entry, or had a
first entry that is not a string wrote no identifier at all, although the document had
asked for a new one.

The reader takes the empty string as the key input for these shapes (Document::first_id),
so the rewrite now writes the empty string (<>) as the first entry and a new second entry.
The key stays valid. A file whose first entry is a string keeps that string, as before.
Ordinary saves and unencrypted rewrites are unchanged.

Checked: an_encrypted_rewrite_without_a_usable_identifier_still_gets_a_new_one in
crates/cos/tests/file_identifier.rs covers the three shapes for RC4-128, AES-128 and
AES-256. After the rewrite each file opens with its password, the first string is empty
and the second is new. On the branch head the test fails with "the rewrite writes a
pair" (left 0, right 2). Reverting only the guard to the head's early return makes it
fail again. Isolated, the one-entry shape fails with left 1, right 2, and the non-string
shape with left Some(0), right Some(16). The other seven tests in the file pass.

* cos: derive the identifier from caller entropy with a fixed hash

With SaveOptions::id_entropy the identifier was built with std's DefaultHasher, whose
algorithm the standard library does not specify, and with write_usize (4 bytes on wasm32,
8 on 64-bit targets) and native-endian integers. The same entropy and the same file could
therefore give different identifiers on different platforms or toolchains, although the
documentation promised the bytes depend only on the entropy and the written file.

The identifier is now FNV-1a (64 bit, the published constants) over fixed-width big-endian
fields and the whole written file: the salt, the lane, id_seed and the length, then the
bytes. With caller entropy the salt is the FNV-1a hash of that entropy. Without entropy it
is still the standard library's random hash of a process counter, so only the salt differs
between the two paths. Every written byte is hashed, in one pass that feeds both lanes.

Cost: this pass is slower than the SipHash it replaces. A temporary probe in the workspace
dev profile (opt-level 1) hashed 64 MiB through both lanes in 0.129 s (about 500 MiB/s),
against 0.024 s before. A release build was not measured. The cost is paid once per
requested rewrite; ordinary saves do not reach this code.

Checked: the golden test fixed_entropy_rewrite in crates/cos/tests/file_identifier.rs pins
the 16 bytes for a fixed entropy and the standard fixture. On the previous hash it fails
(left [64, 145, ...], right [50, 125, ...]). An independent re-implementation of the same
formula in Python, run over the bytes the writer hashed (everything before startxref),
gives the same 16 bytes. The unit test fnv1a_is_the_published_64_bit_algorithm checks the
constants against the published vectors. FNV is not collision-resistant against chosen
content; that matters only if someone could choose a file's identifier, and the identifier
only has to be unique.

* Repair review branch integration

* cos: take fresh identifier entropy from the browser on wasm

Without SaveOptions::id_entropy the salt of a rewritten file's identifier was
RandomState and a process-wide counter. On wasm32-unknown-unknown those hash
keys are fixed at compile time, so two fresh web apps that followed the same
allocation and call sequence reproduced the same salt and the same identifier
for identical output, and a redacted copy stayed linkable to its source.

The default salt now comes from the browser first: crypto.getRandomValues
(Web Crypto, through js_sys, sixteen bytes folded with the same FNV-1a the
identifier uses), or Math.random where that API is missing, and only when
neither is reachable the previous RandomState salt. js-sys and wasm-bindgen
join pdfcraft-cos as wasm32-only dependencies; both are already in the
dependency tree of the web shell, and native builds compile neither. Caller
entropy is untouched: with id_entropy the identifier is still a pure function
of that entropy, the seed and the written bytes.

Checked: separate_fresh_processes_get_different_identifiers re-runs this test
binary as two fresh child processes over the same fixture and fails when the
default salt is made constant (so does the in-process divergence test);
stand_in_for_a_fresh_process is its no-op child half. Both pass with the real
salt. On WebAssembly no browser test harness exists, so the browser path is
covered by the wasm32 cargo check and by construction, not by a test. cargo
check --target wasm32-unknown-unknown -p pdfcraft-cos passes, cargo test -p
pdfcraft-cos and -p pdfcraft-redact pass, and clippy with warnings denied is
clean on both.

* Extract full-save trailer assembly

Assisted-by: OpenAI Codex

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-05 21:25:30 +01:00
KenM76andClaude Opus 5.5 c899160c37 Merge origin/main into feat/unreadable-object-report
Conflicts with the generation-aware references (#1143): the rebuilt xref
index takes its new repair-notes field; the parity entry keeps both sets of
evidence and notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-05 08:15:46 -05:00
KenM76andClaude Opus 5.5 77cbd47bb0 Merge origin/main into perf/failed-object-cache
Conflicts with the generation-aware references (#1143): stream lengths are
kept by full reference, with whether resolving them read unsaved edits; a
repaired slot counts as an edit read like any other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-05 07:50:48 -05:00
863af864a7 M1: respect generations in PDF object references and full saves (#1143)
* M1: respect generations in COS object references and full saves

Resolve stale references to null instead of replacement objects with the
same number. Validate complete identities in writer readers, indirect
stream lengths and catalog checks, and keep generation-aware length caches.

Cover cache reuse, edit overlays, snapshots, incremental and packed/classic
full saves, and stream lengths with synthetic regressions and independent
hayro checks.

Co-Authored-By: Codex <noreply@openai.com>

* M0: clarify refreshed CI roadmap note

Co-authored-by: OpenAI Codex <noreply@openai.com>

* M1: repair contradictory xref generation references

Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits.

* M1: cover repaired generations and freed-slot round trips

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* M1: document generation repair and bounded diagnostics

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* M1: record generation repair regression evidence

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* M1: document bounded COS generation repair

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* M1: record generation repair parity evidence

Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.

* fix(cos): preserve repaired generations through preflight and saves

Keep accepted xref/header aliases across repair-aware edits and snapshots, canonicalize full-save identities, and retain bounded generation diagnostics from older revisions. Add COS, writer/encryption, and real preflight regression coverage.

* test(preflight): assert repaired aliases share saved identity

Address the review nitpick by comparing reopened HeaderImage and RowImage references after damaged full saves, for classic/packed output and edited/restored snapshots. Focused preflight regression and rustfmt check pass.

* M14: update certificate dependencies for RustSec advisories

Bump rustls-webpki to0.103.16 and webpki-root-certs to1.0.10. Preserve all other locked packages and dependency lists, manifests, Git pins, and advisory policy.

---------

Co-authored-by: MMexicann <MMexicann@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 09:28:43 +01:00
KenM76andClaude Opus 5.5 34426ebd9e Merge origin/main (3788af11) into feat/unreadable-object-report
Keeps both new cos document tests side by side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 13:05:10 -05:00
FredApps 3788af11d3 Merge pull request #5808 from KenM76/feat/3d-embed
M12: add a 3D model (U3D or PRC) to a page with a poster (#1297)
2026-11-04 18:53:39 +01:00
KenM76andClaude Opus 5.5 7971fb73f2 fix(cos): a clone's edited objects don't use up its unreadable-object allowance
The unreadable list is shared by clones, and each one hides the entries
it has edited. The 100-entry cap counted the whole shared list, so a clone
that edited every listed object recorded none of its own failures and
listed only "more objects could not be read". The cap now counts the
entries the asking document still shows. Each document still lists at
most 100 entries, then the "more" line.

Test: objects_a_clone_edited_do_not_use_up_its_unreadable_allowance. It
was seen failing with each half reverted (left: 1, right: 10; then
left: 110, right: 101).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 11:04:00 -05:00
KenM76andClaude Opus 5.5 02dbc21207 Merge main into feat/unreadable-object-report
Brings the branch up to date with main (8717a085), which added the
follow budget, the remembered object-search misses and held_bytes
(#5548) to Document::try_get.

The one conflict was the load at the end of try_get in
crates/cos/src/document/objects.rs. The resolution keeps both sides:
main's FollowScope guard around the load, and this branch's record of
a load failure in the unreadable-object list (a poisoned lock is still
not recorded, since it says nothing about the object). The held_bytes
caching after a successful load is main's, unchanged. Every other file
merged cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 10:12:41 -05:00
KenM76andClaude Opus 5.5 5d2f92c6a7 Merge origin/main into perf/failed-object-cache
Resolves the conflict with #5548 in crates/cos/src/document/objects.rs,
keeping both sides:

- try_get enters main's FollowScope, then loads and caches the load result
  as this branch does: a failure is kept too, except a Poisoned error or a
  load that read this document's unsaved edits (overlay_reads). The bytes a
  success holds are measured by held_bytes through loaded_bytes.
- scan_for keeps main's `unfound` set and counts with main's
  tests::FILE_SCANS. This branch's OBJECT_SEARCHES counter is removed and
  its test uses FILE_SCANS.

Main's `unfound` now remembers a whole-file search that found nothing,
which was this branch's original case. What the failure cache still adds:
a failure that is not a scan miss. A member of an object stream that can't
be decoded decoded the whole stream again on every read, because only
decoded streams are kept. New test
a_member_of_an_undecodable_object_stream_is_decoded_once counts those
decodes with a test-only OBJSTM_DECODES counter: 1 with the fix, 12 with
failures not cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-04 08:53:46 -05:00
d579f26de3 M11: JBIG2 images share a symbol dictionary, up to 16 at a time (optimize.mono-compression) (#5910)
* M11: JBIG2 images share a symbol dictionary, up to 16 at a time

With Monochrome Images set to JBIG2, Optimize stored each image's
letters in a symbol dictionary of its own, so a document of N pages
carried its font's shapes N times. Now up to 16 images the run stores
as JBIG2 share one dictionary in a /JBIG2Globals stream, holding the
marks found in more than one of them; each image's text regions refer
to it first, then to a dictionary of its own for the marks only it
repeats. An image is stored that way when it comes out smaller, and a
group only when what its images save is more than the globals take.

- filters: encode_jbig2_shared codes a group: the globals are a symbol
  dictionary segment numbered 0 and associated with no page (7.2.6);
  each page numbers its segments from 1, as a segment refers only to
  lower numbers (7.2.5), and the shared symbols' IDs come first, as the
  dictionaries are referred to in that order (Table 31, SBSYMS). At
  most 16,383 shared marks, those in the most images, so a page keeps
  room for its own under the decoder's 32,767.
- optimize: the run holds the 1-bit samples of the images it stores as
  JBIG2 (16 at most, 64 MiB at most) and stores them again sharing,
  weighing each with the longest reference the globals could have.

Eight pages of 10 pt text rendered at 300 dpi take 41% less in
Helvetica and 42% in Times than coded alone, 10% in Courier, and 46%
in Helvetica at 600 dpi; groups of 2, 4, 16 and 32 save 19%, 33%, 45%
and 47%. Redacting one image stores the others of its group again
without the dictionary, so 16 keeps that to 15 images.

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

* M11: test what the mutants found untested in shared JBIG2 dictionaries

- filters: the group goes through encode_jbig2_shared, which refuses it
  for an image the encoder refuses; two marks in common are enough to
  share; past the 65,536 distinct marks a group holds, a mark first seen
  later isn't shared while those held count every image.
- optimize: optimizing again keeps both the shared pages and the pages
  coded alone as they are; under Retain, averaged JBIG2 pages share
  nothing; a last group of two shares.

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

* M11: only images redaction can store again share a JBIG2 dictionary

Redacting one image of a group stores the others again without the
dictionary, which can hold the shapes of marks under the area, and
refuses the redaction for one it can't: it takes image masks and
images of one component as its own reading counts them (no DeviceN,
the family named directly), and at most 64 Mi pixels. Optimize took
DeviceN of one colourant and up to 80 million pixels, so such an image
in a group made any redaction of the others fail, where before it was
coded alone and the same redaction worked (the Consumers review).

Only an image mask or a DeviceGray image named directly, of at most
2^26 pixels, now joins a group; others are coded alone. An engine test
optimizes three pages, two DeviceGray and one DeviceN, and redacts the
first: the DeviceN page stays alone, the second is stored again, and
no image names the dictionary after a save.

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

* M11: optimize and redaction share one limit on the images redaction stores again

The second Consumers review: Optimize's limit on images that share a
JBIG2 dictionary copied redaction's private 64 Mi, with nothing tying
the two, so a lower one in redaction would bring back refused
redactions with no test going red. Both now read
pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS. The engine test adds an image
mask, the other kind that shares, so a redaction that stopped storing
either kind again fails it. doc_optimize's description, the README,
the parity note and ROADMAP say which images share (named directly).

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

* M11: a JBIG2 group is coded before its samples would pass the limit

CodeRabbit on #5910: hold added an image to the group and then checked
SHARED_BYTES, so a group could pass the 64 MiB it is documented to stay
within by its last image, which can itself be up to 64 MiB (an image
that shares has at most 2^26 pixels, and as its rows are whole bytes,
at most that many bytes; a 1-pixel-wide one takes them all). hold_in
codes the group first when an image would take it past the limit, and
after when it has 16 images; with room for 10 bytes, images of 4 go two
to a group, where the old order made one of 12. CodeRabbit's follow-up
too: the 18-image test decodes every image and compares it with its
samples, across the boundary between the groups.

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

* optimize: count the globals' object framing; filters: test the shared coder's size limit

- A JBIG2 group shares a dictionary only when it saves more than the
  globals' dictionary and data plus NEW_OBJECT (96 bytes: the object's
  framing, its xref entry, and an encrypted file's IV and padding), so a
  group that saves less than that no longer makes the file larger.
- encode_jbig2_shared is Unsupported for an image taller than the
  decoder takes and codes one at the bound.

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

* filters: drop the unused rows binding in encode_shared

page() no longer takes the rows since #5511.

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

* filters: the shared coder's at-bound test says what it returns

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:34:08 +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
KenM76andClaude Opus 5.5 57ca07dea3 cos: test that a clone's scan reaches an unread original
The clone-scan check ran after the original had already listed the
object, so it passed whether or not scan findings were shared (CodeRabbit
on #5816). A fresh document is now cloned before anything reads it, only
the clone is scanned, and the original must list the object. Seen failing
with the scan's list unshared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 12:51:45 -05:00
KenM76andClaude Opus 5.5 bad9a8b44c cos: an edited object stream forgets its members' cached failures
A member that failed to load from the file's object stream was cached as
a failure. Replacing or freeing that stream left the failure in place, so
the member kept reading as null instead of from the edit (CodeRabbit on
#5802). set and free now drop the cached failures of the stream's members.
Cached objects are untouched; a failure holds no bytes, so the cache's
total stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 12:47:31 -05:00
KenM76andClaude Opus 5.5 ace92f3f2f Merge origin/main
Tests only: both sides added tests at the same place; both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 11:09:49 -05:00
KenM76andClaude Opus 5.5 c013b7945e Merge origin/main
Tests only: both sides added tests at the same place; both kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 11:09:45 -05:00
KenM76andClaude Opus 5.5 66e1c7c2f2 Merge origin/main into perf/failed-object-cache
Main's ObjectCache (#5958) measures each object once and keeps the total.
It now holds a load result, so failures stay cached: a failure counts no
bytes, an object its held_bytes. The budget check uses the running total,
which replaces this branch's snapshot fold and large-object fast path.
The overlay-read and Poisoned guards are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-03 10:46:40 -05:00
Hamza Naveed cdfdd6b782 Open: measure cached objects once and by what they hold, read plain reals without a copy, and keep no /PieceInfo in inspection (#5873) (#5958) 2026-11-03 10:14:17 -05:00
KenM76andClaude Opus 5.5 eaf06f9cb9 fix(cos): a save after undo keeps objects packed in a dropped object stream
When the working snapshot is restored from an earlier revision (undo after a save), the
next save appends to the last saved file through overlay_changes_from. An object stream of
that file that the restored graph frees or reuses the number of took its unchanged members
with it: they stayed listed in the dropped stream, and the file reread with no pages. Such
members are now written again on their own.

Adding a PRC model now edits the catalog (the ADBE extension), which made the model_3d
automation test (save, undo, save) reach this case on CI.

The same change is part of #3305 (skylartaylor), which also covers security changes.

Test: cos a_restored_revision_keeps_what_the_saved_file_held_in_object_streams, failing
with the fix reverted (the reread catalog's /Pages resolves to nothing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 20:28:06 -05:00
KenM76andClaude Opus 5.5 649e97930f fix(cos): a rebuilt index keeps the read failures its scan found
Rebuilding the cross-reference index from object headers reset the
unreadable-object list a second time, after adding the object-stream
members, so an object the header scan failed to read dropped off
unreadable_objects() unless something read it again. Those failures
still stand against the full index: the added members leave every
scanned object's entry as it was, and a /Length found only then changes
how much of a stream is read, never whether it reads. The list is now
reset once, before the scan; the object cache is still cleared after
it.

Test: a_rebuilt_file_lists_the_objects_its_scan_could_not_read fails
without the fix (the list is empty) and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 18:03:00 -05:00
KenM76andClaude Opus 5.5 96d3d1e073 fix(cos): a reused /Length keeps the edits it was read through
The indirect /Length memo kept only the value. A length first resolved
through one clone's edited object stream and reused later in the same
load did not count as an edit read, so an object stream framed by it
could still be parsed with the edited length and put in the
object-stream map and cache the clones share. The memo now keeps
whether resolving the length read edits, and a reuse counts it again.

Test: a_reused_edited_length_leaves_other_clones_members_alone fails
without the fix (the other clone reads None) and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 17:56:44 -05:00
KenM76andClaude Opus 5.5 8cc4d37847 fix(cos): an object stream framed by one clone's edited /Length is not shared
An unedited object stream whose indirect /Length is edited in one clone
was parsed with that clone's length and put in the object-stream map
the clones share, so the other clones read its members within the
edited length. A container load that read the clone's edits is now
kept to that clone, like an edited stream.

Test: an_edited_object_stream_length_leaves_other_clones_members_alone
fails without the fix (the other clone reads None) and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 17:18:30 -05:00
KenM76andClaude Opus 5.5 85df8ee668 feat(cos): list the objects that could not be read in doc_info
An object whose load fails reads as null (ISO 32000-2 §7.3.10) and the
file opens, but nothing said so: the repair log records only
document-level repairs, so a file with an unparseable /Info showed no
repair and no reason for its missing title.

pdfcraft-cos Document now records each object whose load fails, with
its generation and the reason, in a set shared by clones and scans like
the object cache: at most 100, keyed by number, first reason kept, cut
to 160 characters, built only for an object not yet listed. It is reset
where the caches are (authenticate, reconstruct); edited objects drop
off a document's list. Document::unreadable_objects() gives neutral
lines ("object 4 0 could not be read: ..."), the engine passes them on,
and automation doc_info adds an unreadable_objects array, kept apart
from repairs so the "has been repaired" notice and the clean-file
checks keep their meaning.

Tests: a cos unit test for listing, dedup, clones, scans and edits; a
cos test for the 100 cap and the no-readable-copy reason; an end-to-end
doc_info test on a synthetic broken file. Each fails with the fix
reverted. The core.repair-log parity entry cites them and says what is
not listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 17:08:05 -05:00
KenM76andClaude Opus 5.5 84d4b8cab9 fix(cos): a load that read one clone's edits is not shared with the others
Clones share the object cache and the parsed object streams. A clone that
freed or replaced an object stream then read a member of it put the result
in those shared maps: a failure (which this branch now caches) or, as on
main, the replacement stream and the member parsed from it. Another clone
whose stream is intact then read that instead of the file.

A load that reads an unsaved edit (an edited object stream, or an edited
indirect /Length) is no longer cached, and an edited object stream is
parsed for its own document only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 16:21:45 -05:00
KenM76andClaude Opus 5.5 315e83b88c perf(cos): an object that can't be read is searched for once, not on every read
Document::try_get kept only objects that loaded. An object whose entry
points at something that doesn't parse falls back to a whole-file
search (scan_for); that failed too, nothing was kept, and the next read
searched the whole file again. A caller that reads such an object
repeatedly, such as the window title of a file whose document
information dictionary is damaged and that asks for its title to be
shown, paid one whole-file search per read, several per frame.

The cache now keeps the load result, a failure as well. It is the same
map with the same bounds, clears and per-reader privacy, so every place
that drops loaded objects (authentication, reconstruction, private scan
readers, eviction) drops remembered failures too. Edits are read from
the overlay before the cache, as before, so a set or freed number still
answers for itself. A failure is kept in nested loads too, as a success
already is; only a poisoned lock is never kept. Failures count towards
the cache's object caps and hold no bytes of their own. Sharing the map
between clones stays sound because entries, data and the stream limit
don't change after open.

Test: an_unreadable_object_is_searched_for_once counts whole-file
searches through a test-only counter (like SECTION_READS): one for
eleven reads and a clone's read. With failures not kept it counts 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 15:28:12 -05:00
FredApps ec25e7d327 M2.6: images decode outside the renderer only through an ImageBudget
`ImageBudget::decode` is now the one public way to decode an image's
CCITT fax or JBIG2 data: `Document::decoded_image` and
`decoded_image_within`, which decoded with no limit on the work, are
test-only in pdfcraft-cos, and pdfcraft-filters exports only
`decode_ccitt_within` and `decode_jbig2_within`, which spend from a
caller's allowance (`decode_jbig2` is gone, `decode_ccitt` private).
So an operation over many images can't decode them without sharing an
allowance; the compiler holds new callers to it. Doctests that must not
compile check both crates; with the old entry points public they fail.

Tests outside the crates decode through an unlimited ImageBudget.
Streams in general (`Stream::decoded`) are left for a follow-up.
2026-11-02 14:24:21 +01:00
FredApps f89a5917ed M2.6: streams made in memory enlarge a run's image allowance as a file's bytes do 2026-11-01 23:52:37 +01:00
FredApps 5eb82d1f68 M2.6: an image pays for the pixels its data holds, not those its size declares 2026-11-01 23:52:07 +01:00
FredApps bc4acf7bfc Merge origin/main into feat/jbig2-run-allowance 2026-11-01 23:50:04 +01:00
FredApps 99e63d4238 Merge pull request #5461 from Piohrun/feat/redact-jbig2
M8: redaction clears the covered pixels of JBIG2 scans instead of removing them (protect.redact-image-pixels)
2026-11-01 22:39:07 +01:00
FredApps a87c4fb6b7 M8: redaction stores again without them the images that share a redacted JBIG2 image's globals, so the globals leave the file (protect.redact-image-pixels)
A JBIG2 image's /JBIG2Globals hold the symbol dictionary of every image that
shares them (jbig2enc -s and most scanners give one to all of a scan's pages),
so the bitmaps of marks drawn only under a redaction stayed in the file while
another page's image used them. Every image using the globals of a removed or
replaced image (or of one in a removed or replaced form) is now decoded and
stored again in place as Flate without them; a full save then drops them. One
that can't be decoded for certain refuses the redaction
(RedactError::SharedJbig2Globals). Report::images_restored counts them.
2026-11-01 22:14:52 +01:00
FredApps cc986fa289 Merge origin/main into feat/jbig2-run-allowance 2026-11-01 21:06:29 +01:00
NetterSilverandClaude Haiku 5.5 2289b5755d Ignore a negative trailer /Size in incremental saves (#5566)
Found while testing crates/cos/src/writer.rs:248

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 15:04:36 -05:00
Grzegorz MartensandClaude Opus 5.5 43202e10f9 M2.6: a page of many pictures stops at the export's allowance
The allowance's sharing was tested on a few images. This runs a whole page through the export: the
pictures past it are left out and counted, and the page is listed, which is what the warning goes
by. A few large pictures all come through. The README gives the numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 09:31:58 +01:00
Grzegorz MartensandClaude Opus 5.5 2fb88feaf7 M2.6: a JPEG pays for being read before its header is
Reading a JPEG's header was free, and it can be most of the file: a mask of
1 MiB of tables that disagreed with its dictionary, shared by 4,000 images,
took 101 s with none refused, and that time grows as the square of the file.
A JPEG now pays 4 steps a byte before its tables are parsed, on every path, and
a mask's header is read once. That is a bound and not a speed-up: the same
file of 4,000 images fits the allowance and still takes 97 s, and 12,000 stop
after 10,233 in 250 s.

A chain that can't run pays nothing for its pixels, and the docs say what past
the allowance isn't counted: LZW streams the image pass didn't read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 01:57:39 +01:00
Grzegorz MartensandClaude Opus 5.5 ade8ebbdb4 M2.6: a run pays for what a stream costs, not what it holds
Four ways round the allowance, each measured by a review of hostile input:

A Flate stream read as zlib that fails at once, and then as raw deflate that
decodes everything before failing, gave nothing back and so was charged
nothing, here and in DecodeBudget: 110 s for a 387 KB file, all decoded. What
the retry decoded now counts, though the data is still dropped as it was.

Fax and 1-bit Flate images paid by the byte for work done by the pixel: 19
bytes can be a blank page of 100 million pixels, about a second each. Every
image decoded here now pays for its pixels first, as JBIG2 already did.

A JPEG paid nothing, and what one asks for doesn't grow with its length: 156
bytes declared 80 megapixels, 72 s for an 8 KB file. A JPEG pays for its
samples before it is decoded, in Optimize and in an export that has to decode
one for its mask.

Merging fragments paid for reading strips and not for the picture it built:
393 s for 1,154 bytes. It pays for the picture before allocating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 00:20:43 +01:00
Grzegorz MartensandClaude Opus 5.5 696728686f M2.6: Optimize's LZW pass spends the run's allowance, and Cancel reaches it
Storing LZW streams with Flate decoded image streams outside the allowance,
with a limit for each stream and no total, and nothing asked whether to go
on: a 593 KB file of 100 images took 113 s with none refused, and images
refused by the image pass were still rewritten. The pass now pays for what
it decodes and for storing and checking it, at 8 steps a byte since Flate
takes up to 78 ns a byte, and streams past that stay LZW.

An image is counted as refused only when it stayed as it was: one packed
before the allowance ran out on its second read was rewritten. doc_reduce
reports images_refused as doc_optimize does, and the app's action says so
in its log. A refused image no longer reads as an unsupported filter:
FilterError::Spent says what happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 23:26:25 +01:00
Grzegorz MartensandClaude Opus 5.5 083191f970 M2.6: every filter of a run's images pays, whether or not it works
Filters were charged only for what they handed a JBIG2 decoder, once they
had handed it over: a chain that failed, or went past its image's limit,
cost its time and nothing else, and images that weren't JBIG2 weren't
charged at all. A 734 KB file of 200 images of Flate inside Flate took an
image export 111 s, and that grew with the file. It now takes 12 s.

Each byte read and each byte a stage decodes is now a step of the same
allowance, paid as it happens, so no stage runs past what is left. That
holds for every image read through ImageBudget, and a fax image pays for
its rows and its code. JBIG2 globals pay when they have to be read, and
kept ones don't. Optimize's two reads that went round the budget go
through it, and merging fragments shares it.

Optimize reads an image twice, to pack it and to resample it, so a refused
image is now counted once a run rather than once a read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 22:23:06 +01:00
Grzegorz MartensandClaude Opus 5.5 0ed339901e M2.6: the JBIG2 images of an operation or a page share one allowance of decoding
One image has its own limit, about 6 s to 17 s, but nothing bounded a file of thousands of images
made to reach it: an operation took that long for each, and an image sharing /JBIG2Globals decodes
them again.

Image::decode_within (hayro-jbig2 patch 11) spends from what a run may still do, starting with its
page's pixels, and charge_reading charges reading a stream and its globals, 4 steps a byte, before
anything is parsed, so an image that fails has paid too. pdfcraft_filters::decode_jbig2_within takes
it, and ImageBudget in cos is that allowance: 2^34 steps, about 1,000 scanned pages, and 2^13 more for
each byte of the file, so a long scan is read whole. An image past it spends all that's left, and
later ones aren't read, globals included.

Optimize leaves such an image as it is and counts it in Report::images_refused. Image export leaves it
out, with the reason, and stops at 2 GiB of files held. Office export shares one allowance, leaves the
picture out and says the page was truncated. Redaction removes it whole, as any image it can't read.

Rendering gives a page's images 2^30 steps between them, inside the watchdog, and each page starts
afresh in Context::new as its other budgets do (hayro-syntax patch 31, hayro-interpret patch 73).
It's a thread-local, unbounded until a page sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 13:21:45 +01:00
Grzegorz MartensandClaude Opus 5.5 5480dbe163 M2.6: shared JBIG2 globals are read once a document; Copy Image is tested on JBIG2
From CodeRabbit's review of the stack:
- Every JBIG2 image read its /JBIG2Globals afresh, within 32 MiB each but with no
  bound across an export or a redaction. A globals stream of the file, not edited since
  it was read and with its /DecodeParms direct, now has its filters undone once a
  document and is kept for the images that share it (in every clone, which share the
  file), up to 64 MiB in all. An edited one, one added in memory, or one with
  /DecodeParms by reference (another object, which can change under it) is read each
  time as before. This saves the filters only: the decoder reads the segments afresh for
  every image, so a real symbol dictionary still costs its decoding once an image.
- view.copy-image dropped "JBIG2 inside other filters" from its missing list on the
  strength of Export's test; Copy Image now has its own: a JBIG2 picture behind
  ASCIIHexDecode is found under the pointer and copied as its pixels and as a PNG, and
  one whose page information is in its globals is read through them.

A JBIG2 image's /Width and /Height are read as given directly; one by reference is an
error rather than an image of no rows.

A /JBIG2Globals reference to no object still reads as no globals: it is a reference
to null (ISO 32000-2 §7.3.10), which an entry may hold for being absent (§7.3.7), and
the renderer draws the page so. An image that needed those globals then mostly fails
to decode; in a probe of twelve pictures that decoded with them, eleven were refused
and one decoded to other pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 00:06:56 +01:00
Grzegorz MartensandClaude Opus 5.5 ea6c329731 M2.6: JBIG2 decoding in pdfcraft-filters; Document::decoded_image reads JBIG2 images
decode_jbig2 sits next to decode_ccitt, through hayro-jbig2 as Export ▸ Images
used it, and shares CCITT's row sink. The JBIG2 page has to be the image's size:
PdfCraft's renderer draws the whole page into the image (black box: a 4-row page
behind /Height 2 fills the image box with all four rows), so the rows of a taller
page aren't the image's. The decoder decodes a page whole, so the page is checked
before it is decoded: that size, within the caller's bound as rows of one bit, and
at most 2^28 pixels (MAX_JBIG2_PIXELS; the decoder keeps a bit a pixel, about 34 MB
there).

Document::decoded_image_within decodes a JBIG2 image as it does a fax image: the
filters before it undone first, then the decoder with the /JBIG2Globals of the
image's own /DecodeParms entry (/Crypt counts in the position; fax_params and the
globals now share last_filter_parms). Globals have a limit of their own,
MAX_JBIG2_GLOBALS (32 MiB, moved from Export), and globals that can't be read are an
error rather than ignored.

Export ▸ Images reads JBIG2 through it, so its own decoder, row sink and DecodeParms
reader go, and hayro-jbig2 leaves pdfcraft-create. JBIG2 behind another filter now
exports, and so copies (Copy Image uses the same function); a page taller than the
image, which Export cut to the image's rows, now skips the image, as one of another
width did. doc_export_all_images' description no longer lists JBIG2 inside other
filters as skipped.

The optimizer's monochrome step read images through decoded_image_within and
relied on JBIG2 failing there: it now leaves JBIG2 images alone explicitly, as
before (a resampled one would come out Flate under Retain), and the README and
parity note list them as not yet handled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:03:06 +01:00
FredApps b21af3b969 Integrate 21 clean-merging PdfCraft parity PRs
Preserve each original PR head and its authorship as a merge parent.
Includes #4304 #4335 #4384 #4404 #4179 #5300 #4719 #4541 #2815 #4620 #3789 #3779 #2960 #5208 #4596 #3636 #4494 #4013 #5134 #2133 #2635.
Conflicting candidates skipped. Combined CI pending; see docs/integration-20261030.md.
2026-10-30 10:35:42 +01:00
Grzegorz MartensandClaude Opus 5.5 1ad951e25b M11: redaction removes Group 3 on bytes whole, which the renderer may draw as other rows
PdfCraft's bootstrap renderer draws byte-aligned Group 3 whose fill ends
each end-of-line code on a byte (as TIFF writes it) as other rows than
decode_ccitt reads (black box, at every /K, with /EndOfLine or not:
mostly the first row, then blank). Redaction cleared pixels in the
picture decode_ccitt reads, so a mark over what PdfCraft showed needn't
cover what was cleared, and content shown blank stayed in the copy. Such
an image (/K 0 or more, /EncodedByteAlign) is now removed whole, as
every CCITT image was before this PR; Group 3 off bytes and Group 4 are
still cleared.

The image's parameters come from Document::fax_params, the reading of a
fax image's /DecodeParms entry that decoded_image already did, now on
its own (it was to come with #4404, which uses it too). The redaction
README, the module doc, the redaction and core.image-filters notes and
the spec parity table's status line and revision row say so, and the
core.image-filters note now records the renderer's misdrawing.

group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared: a white
16 × 2 picture, checked to decode as such, is removed whole on bytes (no
codes; codes at /K 0; at /K 1 with /EncodedByteAlign by reference) and
cleared off bytes and as Group 4 on bytes. It fails without the guard;
cargo-mutants catches every viable mutant of it.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 68c6d4b052 M11: redaction's decode allowance takes ASCIIHex text broken into lines; tests for the bounds
- Redaction decodes an image no further than twice its raster plus a
  byte a row, meant for ASCIIHex text under Flate; hex text broken into
  lines is a little more, so such an image was removed whole instead of
  cleared (already so on main). It is three bytes for each now, as the
  optimizer allows.
- Tests: grey stored as hex text in lines under Flate, and RGB behind a
  filter array (which a fax image's bound would refuse), are cleared;
  decoded_image_within gives a fax image no more rows than its bound
  holds.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 1f801bf4c9 M11: redaction bounds a fax image's decoding; a test for numbers inside /DecodeParms given by reference
- Redacting a CCITT image went through decoded_image, which undoes the
  filters before the fax decoder up to MAX_DECODED, so a Flate bomb in
  front of a fax image cost a gigabyte while every other image stops at
  twice its raster. Document::decoded_image_within takes a bound, and
  redaction gives a fax image's code about a byte per pixel. A fax image
  cut short, or behind a bomb, is removed whole; a busy one whose code
  is bigger than twice its raster is still cleared.
- Every image is now decoded through decoded_image_within, which reads
  /DecodeParms through Document::with_direct_parms: given by reference,
  the numbers inside (/Predictor 7 0 R) included. That reading was first
  written here and has moved down to #4304. The predictor test now covers
  the dictionary, an array's entry and the number itself given by
  reference; read without them, the cleared bytes drift off the covered
  pixel and the covered sample stays in the copy.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:03 +01:00