Commit Graph
305 Commits
Author SHA1 Message Date
Dylan M. Taylor c73cccfb33 fix(redact): hidden-layer removal reads the default state as viewers do (#5867)
* fix(redact): hidden-layer removal reads the default state as viewers do

hidden_layers ignored an indirect /BaseState and, with /BaseState /OFF,
let /ON override /OFF, so a group both lists named survived as visible.
The OCMD and OCG classifier also read /Type directly, so a membership
dictionary with an indirect /Type was never evaluated and its content kept.

The default state is now /BaseState (resolved), then /ON, then /OFF, and
/Type is resolved before classifying a group or membership dictionary.

* address review: follow chained /Type and /BaseState references, hash-set ON/OFF lists

* address review: a visibility expression naming a group through an alias reference fails closed
2026-11-11 15:37:44 -05:00
Dylan M. Taylor 13d6367385 fix(redact): read each Type 3 glyph program once per cleanup (#5993)
clean_type3_font decoded and parsed every surviving glyph program of a
font, for every font. Many small font dictionaries sharing one
compressed CharProc multiplied that work without multiplying the stored
data, with a fresh allowance for each decode.

The resource names each indirect glyph program uses are now cached by
object across fonts (resource-name interpretation stays per font, since
each font prunes its own /Resources), and all decoding spends one
cleanup-wide DecodeBudget; past it the cleanup fails closed.
2026-11-11 14:47:42 -05:00
Dylan M. Taylor f44ea8380d redact: a covered clip-and-paint path leaves an empty clip, not its outline (#6996) 2026-11-11 10:57:36 -05:00
Dylan M. Taylor a23b7c7f73 fix(redact): apply marks with an indirect /Subtype and judge inline annotations (#5849)
* fix(redact): apply marks with an indirect /Subtype and judge inline annotations

A Redact annotation whose /Subtype is an indirect reference was passed
over by apply although the audit, which resolves it, reports it as a
mark: the other marks applied and the content under that one stayed.
Annotation dictionaries written in place in /Annots were kept whatever
they covered, and an inline reply or pop-up kept a removed comment
reachable through /IRT or /Parent.

/Subtype is now resolved wherever redaction classifies an annotation.
Inline entries are judged by their rectangle and relationships like
referenced ones, on the marked page and across pages, and the pop-up an
annotation names (/Popup) goes with it.

* address review: an inline widget's /Parent field goes even when no referenced widget names it

* fmt
2026-11-11 09:17:32 -05:00
FredApps 2c39b1d625 Merge pull request #6882 from Piohrun/fix/redact-unparsed-bytes
M8: redaction refuses a page whose content it can't fully parse
2026-11-11 14:40:29 +01:00
FredApps 203c4fd8fa Merge pull request #6927 from Piohrun/fix/redact-marked-content-last-operands
M8: redaction reads an operator's operands from the end, as the renderer does
2026-11-11 14:38:05 +01:00
FredApps 209bb40d88 Merge pull request #6886 from Piohrun/fix/redact-form-matrix-by-reference
M8: the redaction walk reads a form's /Matrix entries by reference
2026-11-11 14:37:09 +01:00
Grzegorz MartensandClaude Opus 5.5 042371bda9 M8: a test of its own for each operand read the fix changes
Review of the whole change found reads it fixes that no test failed
without: each was put back to main's read alone, and the tests passed.
Each now has a case that fails without it:
- the proof's `Tf` size (text starting outside the region reaches into
  it only at its size) and font (an undecodable font's text leaves the
  region unverifiable), and the strings a `TJ` carries;
- the proof's `l`, `c`, `v` and `y` points after a surplus, from a point
  outside the region, and the walk's `m`, `l`, `c`, `v` and `y` points
  beside a mark, each alone;
- the property list a second page sharing the resources still names;
- a Type 3 font an ExtGState's `/Font` sets, kept by the page and its
  glyph by the font, and one without resources of its own, which keeps
  the page's pattern its glyph may paint with;
- a Type 3 font only a soft mask's group selects, the mask's ExtGState
  named after a surplus;
- the named property lists read once, with each `BDC` naming another
  entry, so that it is the lists, not each name's answer, that are kept.

And the walk's `cs` says why it tells a Pattern space by the name
`/Pattern` alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 23:11:09 +01:00
Grzegorz MartensandClaude Opus 5.5 5e2552dcb0 M8: named property lists and their MCIDs are read once for a stream
The tags cleanup resolved the page's `/Properties` at every named `BDC`,
on main too: 20,000 of them with 5,000 entries took 28 s, as measured in
review (optimized), against 0.07 s with one entry. And the second commit's
`MCID` by reference was read again at every `BDC`: the document keeps an
object larger than its cache only until it reads another, so reading the
`MCID` evicted a property list that large, which was parsed again, all of
it, at the next `BDC` (50 `BDC` took 12.3 s against 0.44 s with the
`MCID` given directly). Now the named property lists are read once for
the stream, in place when they are direct, and each name's `MCID` once.

Tests (`crates/redact/tests/operator_work.rs`, a binary of its own whose
tests run one at a time), each the least of three runs against one entry
or operator, allowing ten times as long plus a second:
`named_property_lists_are_read_once_for_a_page` (5,000 `BDC`, 2,000
entries) and `an_oversized_property_list_is_read_once_for_a_stream` (100
`BDC` naming a list too large to keep).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 23:11:09 +01:00
Grzegorz MartensandClaude Opus 5.5 7c78222612 M8: path points and an MCID by reference are read as the renderer does
Review of the operand reads. Path construction was still read from the
front, in the walk (`construct_path`) and in the proof (`extend_path`):
after a surplus number, `9 100 100 m 9 140 100 l ...` placed the square at
x = 9, so a mark over it removed nothing, and an even surplus placed a
square beside a mark under it, which was removed. `m` and `l` now take
their last two numbers, `c` six, `v` and `y` four, and the proof's `re`
four, as the walk's already did.

The tags cleanup read an `MCID` with `Dict::int`, so one given by
reference in a named property list, or in a structure element's `/MCR`,
was missed and the element kept the removed text in `/ActualText` and
`/Alt`. It is read through the reference now.

Tests: `a_paths_points_after_a_surplus_number_are_where_the_renderer_draws_them`
(lines, and curves by `c`, `v` and `y`),
`an_mcid_given_by_reference_loses_what_redaction_removed` (and an `/MCR` of a
form's stream that stays), and
`the_proof_reads_a_paths_points_from_the_end` (lines, `re`, and each of
`c`, `v` and `y` from a point outside the region).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 22:22:09 +01:00
Grzegorz MartensandClaude Opus 5.5 b16f0c0a77 M8: redaction reads an operator's operands from the end, as the renderer does
An operator takes its operands from the end, and the renderer ignores a
surplus before them: `(x) /OC /MC0 BDC` marks a layer's block, and
`(x) /GS1 gs` selects `/GS1`. Redaction read some of them from the front,
so a surplus stood in for them and what they named went unseen. Found in
review of #6882, where Remove Hidden Information left the text of a
hidden layer in `(x) /OC /MC0 BDC (LAYERSECRET) Tj EMC`, which both the
app's renderer and Poppler hide.

In what Apply Redactions and Remove Hidden Information decide:
- the layers walk read a block's tag and property list from the front,
  and kept a hidden layer's block;
- the cleanup of `/ActualText`, `/Alt`, `/E` and `/T` in a block whose
  paint was removed read (and wrote) the property list from the front,
  and kept the name that still read the removed word;
- the tags cleanup read a block's `MCID` from the front, and left the
  removed text in its structure element's `/ActualText` and `/Alt`; and a
  `TJ`'s array, so a block that still drew was taken for empty and its
  structure element lost the content the page still shows;
- the property cleanups read the name of a `BDC` or `DP` property list
  from the front, and retired a property list the page still draws with;
- the walk read the ExtGState a `gs` selects from the front, so a font set
  only that way was not set, and `cs`/`CS` the same for a pattern space;
- the Type 3 cleanup's reads of `cs`, `gs`, `Tf`, `BDC` and `DP`, which
  decide which resources and fonts are still used.
The redaction proof read `Tc`, `Tw`, `Tz`, `TL`, `Ts`, `Tf`, `"`, `TJ` and
`Do` from the front too. `pdfcraft_content::Op` gains `back`, `name_back`
and `num_back`: operand `k` counted from the end.

Tests (`crates/redact/src/tests/operands_from_the_end.rs`): a hidden
layer's block, `/ActualText` on removed paint, a tag's `MCID`, a tagged
`TJ` that still draws, a property list still drawn with, a font from an
ExtGState, the pattern a `/Pattern cs` keeps, and a Type 3 font with the
ExtGState, colour space, property list and font its glyph uses, each
after a surplus operand. In `tests_verify.rs`,
`the_proof_reads_operands_from_the_end` (`Tf`, `TJ`, and `Do` of a form
and of images), `the_proof_reads_the_text_states_operands_from_the_end`
(`Tc`, `Tw`, `Tz`, `TL`, `Ts` and `"`) and
`an_image_a_type3_glyph_draws_after_a_surplus_operand_is_found`. And
`operands_are_counted_from_the_end` in the content crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 22:22:09 +01:00
Grzegorz MartensandClaude Opus 5.5 4f107a6f40 M8: a form /Matrix through a reference to a reference is refused
Review of the walk's read of a form's `/Matrix`. Through `Document::resolve`
it followed a reference to a reference, for the matrix and for each entry,
as the app's renderer does; Poppler doesn't, and reads such a matrix as
the identity and such an entry as 0. With
`/Matrix [1 0 0 1 0 8 0 R]`, `8 0 obj 9 0 R` and `9 0 obj 200`, a mark
where Poppler shows the text applied with success and left it in the file,
where `main`, reading any entry by reference as the identity, removed it.

`matrix_of` now follows each reference one hop, and a reference to a
reference fails as a malformed matrix: where the content draws can't be
told, so Apply refuses the page, as for a matrix that isn't six numbers.
The refusal now says "isn't six numbers given directly or through one
reference", and `matrix_of`'s doc names the appearances it reads, not
patterns. The proof, which reads the matrix of forms and appearances
with it, treats such a matrix as malformed too.

Test: `a_form_whose_matrix_goes_through_a_reference_to_a_reference_is_refused`
(an entry and the whole matrix, with a mark where either reader draws the
text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 16:45:34 +01:00
Grzegorz MartensandClaude Opus 5.5 cf26980471 M8: a long form /Matrix is read cheaply, an unreadable one leaves layer removal alone
Review of the walk's read of a form's `/Matrix`.

Removing hidden layers alone never asks where content lands, but a form
whose `/Matrix` isn't six numbers was counted in its scan and refused, in
a document without layers too. Such a form is now walked as it stands
there; Apply Redactions, and Remove Hidden Information with hidden text,
which tells hidden text by where it lands, still refuse it.

`matrix_of` resolved and copied every entry before checking there were
six, and the walk reads it at every `Do`. Measured in review (debug
build), 20,000 draws of a form whose
`/Matrix` is 20,000 entries long took 9 s by reference (16 ms before the
walk read entries) and 12 s written directly (2.6 s before, the array
being copied by `resolve`). It now reads an array written directly in
place, and checks the length before resolving any entry.

Tests: `a_form_whose_matrix_is_not_six_numbers_leaves_removing_hidden_layers_alone`,
and `tests/matrix_work.rs` (a binary of its own), whose
`a_long_form_matrix_costs_no_more_than_a_short_one` times 4,000 draws of
a form with a 4,000-entry `/Matrix` against a 7-entry one, written
directly and by reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 16:45:34 +01:00
Grzegorz MartensandClaude Opus 5.5 d2c87ae789 M8: the redaction walk reads a form's /Matrix entries by reference
The walk read a form's `/Matrix` with `Matrix::from_operands` on the array
as written, so an entry given by reference made it the identity. That is
legal: an array's entries may each be an indirect reference (ISO 32000-2
§7.3.10), and Arlington's `XObjectFormType1` allows the matrix itself to
be one. A form moved onto a mark by such a matrix was then tested against
the wrong place, never walked, and Apply Redactions reported success with
its text still in the file: `/Matrix [1 0 0 1 0 8 0 R]` with `8 0 obj 200`
left SECRET, which Poppler's `pdftotext` places under the mark. Remove
Hidden Information missed text such a matrix moves off the page the same
way. Found in review of the unparsed-bytes refusal.

The walk now reads it with the proof's `matrix_of` (`verify/extract.rs`),
which resolves the array and each entry. A `/Matrix` that isn't six
numbers was the identity too; now where the form draws can't be told, so
Apply refuses the page and the scans count it, as for a form past the
page's budget. `matrix_of` now reads a null `/Matrix`, given directly or
by reference, as the identity: a null entry is no entry (ISO 32000-2
§7.3.9).

Tests (`crates/redact/src/tests/form_matrix.rs`): a form moved onto the
mark by a matrix written direct, as a reference, and with an entry by
reference, redacted; a null matrix, the identity; a matrix that isn't six
numbers, refused, and counted by the verify pass and Remove Hidden
Information's scan; and hidden text a matrix entry by reference moves off
the page, removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 16:45:34 +01:00
Grzegorz MartensandClaude Opus 5.5 a7f0074381 M8: doc_hidden_info and the parity note say what the refusal covers
Review of the refusal. `doc_hidden_info` said it lists what Remove Hidden
Information would remove. A page that can't be read in full is counted as
one item of hidden text and of hidden layers, so that the removal runs and
refuses it: since the refusal before this, that includes a page holding
bytes the parser can't read or an inline image whose end is a guess, not
only content streams that don't decode. The description now says so, and
that with form fields removed too, a field's appearance is read the same
way though it isn't counted. The parity note on Apply Redactions lists the
new refusal beside the inline image's.

Removing hidden layers alone refuses such a page in a document without
`/OCProperties` too, which a review asked to narrow: a membership
dictionary there (`/AllOff` of a layer that is on) still hides what it
marks, for the walk and for the app's renderer. The comment at the
refusal in `sanitize.rs` says only what the bytes are shown to hide: text
in a broken `TJ`.

Tests: `bytes_the_parser_cannot_read_refuse_removing_hidden_layers_alone`;
`hidden_text_beside_bytes_the_parser_cannot_read_in_a_field_appearance_is_refused`
(removing form fields with hidden text, and Sanitize Document, which went
on and left it); and `hidden_text_beside_bytes_the_parser_cannot_read_is_refused`
gains hidden text in a broken `TJ` inside a form. The form-away test's doc
says the check after the walk would still refuse a form without resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 15:55:59 +01:00
Grzegorz MartensandClaude Opus 5.5 f2d64ca136 M8: redaction refuses a page whose content it can't fully parse
The redaction walk reads past bytes the content parser can't read: a
stray `)` or `}` in a `TJ` array, say. Text they hide is neither found nor
removed, while other readers draw it: Poppler's `pdftotext` extracts
`[(TAIL) ) (SECRET)] TJ` as TAILSECRET. The only refusal was a side effect
of the reads after the walk, which run only when the walk rewrote
something, read what it rewrote as rewritten, and in the font cleanup's
case only when the resources list a font. So Apply Redactions reported
success and left the text where the walk rewrites nothing (the only text
under the mark in such a `TJ`, or one in a form with resources of its
own), in a stream it rewrites (it wrote the array out as
`(TAIL) (SECRET) TJ`, without the stray byte or the brackets), and on a
page whose font is set only through an ExtGState. Remove Hidden
Information never counted hidden text in such a `TJ`: it found nothing to
remove, or left it beside what it removed.

The walk now records that it met such bytes (`Output::unparsed`, from
`Parsed::skipped`, nested forms included), and Apply Redactions refuses
the page with `RedactError::Unreadable`, as it does an inline image whose
end is a guess. Remove Hidden Information counts such a page in its scan,
so the removal runs and refuses it, as it does content that doesn't
decode; it refuses an inline image whose end is a guess the same way,
which it took on trust before. Apply walks only the forms that reach a
mark, so bytes in a form away from the marks don't make the walk refuse
the page; in a form without resources of its own, the check after the
walk still refuses them when it runs, as before.

Tests (`crates/redact/src/tests/unparsed.rs`): text under a mark in four
broken `TJ` arrays, alone and beside other text; the same in a stream the
walk rewrites; in a form under the mark with resources of its own; on a
page without font resources; hidden text in such a `TJ`, alone and beside
other hidden text, through the scan, Remove Hidden Information and
Sanitize Document; hidden text beside an inline image the renderer can't
end; and a form away from the marks, which is still redacted around. Two
tests in `tests.rs` now expect the walk's refusal; the check after the
walk keeps a case of its own, a form without resources away from the
mark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 15:53:40 +01:00
Grzegorz MartensandClaude Opus 5.5 ac1eba1092 M8: the form and hidden-text callers are tested without a Type 3 font too
Review of the allowance change. Only Apply's page caller had a row with no
Type 3 font listed. `the_cleanups_of_a_rewritten_form_spend_the_pages_allowance`
and `removing_hidden_text_spends_what_the_content_walk_left_of_the_pages_allowance`
now have one too: at 100 MiB the redaction and the removal go on.

Doc comments that said every cleanup of a page spends the page's allowance
(`prune_type3_from_resources`, `resource_ops`, `prune`) now say the font
cleanup has one of its own when no Type 3 font is listed. The doc of
`text_beside_bytes_the_parser_cannot_read_refuses_the_redaction` says the
cleanups run only when the walk rewrites some of the page, and read what it
rewrote as rewritten. The comment in `prune_type3_from_resources` no longer
calls its read the only check for text: `prune_form_resources` (for a
rewritten form), `prune` (when an image or form is replaced) and
`prune_properties` make the same read when they run, and `unlink_type3`
reads every page when the document still lists a Type 3 font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 14:10:19 +01:00
Grzegorz MartensandClaude Opus 5.5 506502386e M8: the Type 3 cleanup charges the page only when there is a Type 3 font
CodeRabbit found this on #6805, which landed #6341 on main.
`prune_type3_from_resources` reads what the content draws through its
resources (`resource_ops`) whenever the resources list any font, and only
then looks for a Type 3 font, the only kind it prunes. Since #6341 that read
is charged to what the content walk left of the page's allowance. So a page
with only TrueType fonts and a large form without resources of its own
could be refused, by Apply Redactions or Remove Hidden Information, for a
read that prunes nothing: a 100 MiB form crossing the mark is read by the
walk, the page's check and this read, 300 MiB against the page's 256.

Without a Type 3 font, the read is now charged to an allowance of a page of
its own; with one, to the page's, as before. The read is still made with
any font listed, as CodeRabbit's suggested early return would not: it
refuses bytes the parser can't read in the streams and forms it reads, and
an early return would drop that refusal on every page without a Type 3
font. The walk reads past such bytes, so where nothing else reads the same
content, text a stray `)` hides in a `TJ` array under the mark would stay
while Apply Redactions and Remove Hidden Information reported success
(found in review). The refusal covers only what the cleanups read, so such
bytes still go by elsewhere, here as on main. The read and the
pruning tell a Type 3 font the same way (`is_type3`).

Tests:
- `the_cleanups_after_the_content_walk_spend_what_it_left_of_the_pages_allowance`
  now also redacts the 100 MiB page with no Type 3 font, which goes on; it
  and the two tests like it (a rewritten form's cleanups, Remove Hidden
  Information) list an unused Type 3 font where their font cleanup runs,
  so that it still charges the page's allowance (`list_type3`).
- `text_beside_bytes_the_parser_cannot_read_refuses_the_redaction`: four
  such `TJ` arrays in a page's second stream, one in a form without
  resources, and hidden text beside one, on pages listing only a TrueType
  font, are refused by Apply Redactions and Remove Hidden Information.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-10 13:59:52 +01:00
BFlat 2b46f8bf8c Merge pull request #5698 from Piohrun/fix/jbig2-dictionary-any-route
fix(redact): a shared JBIG2 dictionary leaves with whatever used it, by any route
2026-11-10 14:24:09 +07:00
bflatastic c6740a16d8 Merge remote-tracking branch 'origin/main' into merge/redact-forms-6341
# Conflicts:
#	crates/edit/src/tests.rs
2026-11-10 10:46:07 +07:00
mtlcl410 d9ec80243b M12: XFA forms with many repeated rows lay out in linear time (form.xfa-dynamic) (#6776) 2026-11-10 10:42:57 +07:00
Grzegorz MartensandClaude Opus 5.5 650419766b Merge main again into fix/jbig2-dictionary-any-route
One conflict, in redact lib.rs: the module list, where main added `elsewhere` and
`identification` (#6714) and this branch `globals`. All three stay, in order.

Main's #2448 work also added a sixth step to `apply`, after the verification and the pattern
guard: `metadata::scrub` removes the `/Info` entries and the catalog's and pages' XMP that repeat
the removed text. An image that only such a packet refers to leaves the file then, after
`unshare` had looked for what left, so its shared dictionary stayed. `unshare` now runs after
the scrub, last of what can take something out. Storing images again changes no page's content
and no pattern cell, so the verification and the pattern guard before it check the same pages
they did. The crate's doc says the dictionary goes last. Remove Hidden Information's order is
unchanged: main's changes to `sanitize.rs` only read (`owned_values`).

Test: `an_image_only_the_scrubbed_metadata_refers_to_takes_a_shared_jbig2_dictionary_too`. An
image under the mark is also named by the catalog's XMP, which repeats the text removed beside
it. After the redaction the packet is gone, the image on the other page is stored again, and
the dictionary is gone too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 16:41:19 +01:00
Grzegorz MartensandClaude Opus 5.5 64dfe1fd44 Merge main again into fix/redact-prune-through-forms
A third merge of main, at ba28458ad. Main's #2448 (in #6714) collects
the text a redaction removes from the walk's scope, which this branch
also reads after the walk for what it left of the page's allowance.

- `interp.rs`: `Scope` keeps this branch's `into_budget` and gains
  main's `with_removed_room`, `flush` and `removed`.
- `lib.rs`: the page's walk starts with main's room for removed text,
  and after it the scope's removed text is collected as on main, then
  what it left of the page's allowance is taken for the cleanups, last,
  as taking it consumes the scope.

Main's new code reads no content stream or form in the walk or the
cleanups, so nothing of this branch's allowance or one-operator reads
needed carrying into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 16:32:21 +01:00
Grzegorz MartensandClaude Opus 5.5 587d6528b0 redact: the refusal test checks a copy with its filter by reference too
In `a_shared_jbig2_dictionary_that_cant_leave_refuses_the_redaction`, the case of a copy of
the dictionary whose `/Filter` is given by reference may end `Ok`, the image stored again
without it. That branch checked only that the dictionary, object 9, left, and that its marker
was nowhere in the file. But `assert_gone` can't decode the copy, object 10, with its filter by
reference, and its bytes are Flate, so a run that never found the copy, and left it in the
file, passed. The branch now checks that object 10 left too, through `reachable_objects`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 16:20:40 +01:00
bflatastic 36e7121ab0 Merge main into merge/ui-print-optimize2
# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
2026-11-09 21:16:23 +07:00
bflatastic 2ce2d3cf81 Merge main into merge/ui-print-optimize2
# Conflicts:
#	crates/automation/src/redact.rs
#	crates/automation/src/tools.rs
#	crates/print/src/lib.rs
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
2026-11-09 20:56:15 +07:00
bflatastic 856d3fb0ac Merge main into merge/comments-annot2
# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas/page_pointer/tests.rs
#	crates/ui-egui/tests/it/comments.rs
2026-11-09 20:56:09 +07:00
Grzegorz MartensandClaude Opus 5.5 b9fda64bb6 Merge main into fix/redact-prune-through-forms
A second merge of main, at b6cbf9bb3. Nothing needed resolving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 13:08:31 +01:00
Grzegorz MartensandClaude Opus 5.5 b9e83d8b67 Merge main again into fix/jbig2-dictionary-any-route
One conflict, in redact sanitize.rs. Main's #6021 (930b44131) split `remove_hidden` in two:
- it now counts only what the chosen categories need (`scan_only`);
- it hands the removal to `remove_counted`, which tests also call with `every_page`.

This branch had added one line to the old body: `kept_before`, what a full save keeps before
anything is removed. Main's side is taken whole, and that line is now the first thing
`remove_counted` does after finding the catalog, before any change to the document. So every
caller of `remove_counted` has it. `unshare_unmarked` stays last, after `unlink_undrawn`, and
merged cleanly there. The only early return after a change is `NothingToApply` when nothing
was removed and nothing unlinked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 13:05:41 +01:00
Grzegorz MartensandClaude Opus 5.5 4cc0abbe62 M8: a test that each form a kept Type 3 glyph draws is walked within an allowance of a page
The merge gave `prune_font_forms`' walk of each Form a kept glyph
draws the allowance of a page, which its call of `resource_ops` now
needs; no test pinned it. In
`the_form_a_kept_type3_glyph_draws_is_walked_within_the_allowance_of_a_page`
such a Form draws two forms without resources of their own, each past
half of the allowance, and the redaction is refused with the decoder's
limit; with two small ones it goes on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 13:03:50 +01:00
Grzegorz MartensandClaude Opus 5.5 1184738b54 redact: an image stored again keeps the generation its header gives
Main's #1143 (863af864a), merged in 77b9cfa31, has cos accept both generations of a slot whose
cross-reference row and object header disagree: `8 0` in the row and `8 1 obj` in the file, a
reference of either reads the object. `unshare` stored an image again with `Document::set` under
the row's generation, which keeps that one alone. A page that named the image by its header's,
`/Im 8 1 R`, then read null, in the document and after a full save: a redaction on another page
took the image off a page no mark reached.

It now stores it with `Document::set_existing`, which keeps both. It was the one object this
PR's code writes back after reading.

Test: `an_image_stored_again_keeps_the_generation_its_header_gives` patches the second page's
image to `8 1 obj` behind a row of generation 0, and its page to `/Im 8 1 R`. After a redaction
on the first page, that page still draws the scan, in the document and after `write_full` and
reopening, and the dictionary is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 13:00:28 +01:00
Grzegorz MartensandClaude Opus 5.5 0a00862e91 redact: the shared-JBIG2 walk names by object number, and reads what it stores by number and generation
Main's #1143 (863af864a), merged in 77b9cfa31, makes cos read a reference whose generation isn't
its object's as null. The walk that finds what shares a /JBIG2Globals dictionary with what left
fetched through `Document::get`, but remembered what it read by object number alone, so a
reference of another generation and the object's own one shared what the first of them read:

- What a parameters value names (`Run::names_in`): `/DecodeParms [10 1 R 10 0 R]` on an image
  that left, object 10 naming dictionary 9, read 10 1 R as null, named nothing, and remembered
  that for object 10. The 10 0 R after it found it, so nothing named 9, and the dictionary
  stayed in the file with the image another page kept.
- A size, filters or parameters an image is stored again with (`Run::follow`): an image whose
  `/Width 11 1 R` reads as null can't be stored again, and refused the run alone. Once another
  image had read 11 0 R, it took that width and was stored with it.

What an image names is now read by object number, whatever generation the reference gives
(`Run::fetch_numbered`), in `names_in` and in the `/JBIG2Globals` chain alike: a reader that
doesn't compare generations draws with that object, so what an image that left names this way
may hold its shapes. That is how the chain already named `9 1 R` (the "another generation" way
out), and how both read before #1143. What is read by number is remembered by number. The
values an image is stored again with are read as cos reads them, and remembered by number and
generation, so a reference of another generation reads its own way.

`kept`'s comment said a reference reads the object of its number whatever its generation, which
is no longer so for a full save. It says how `reachable_objects` reads one now. The README and
`Run`'s comments say which reads take the number and which the generation too.

Tests:
- `a_redacted_scans_shared_jbig2_dictionary_leaves_the_file` gains three ways out:
  - parameters through another generation, then their own;
  - parameters through another generation alone;
  - a `/JBIG2Globals` reference to a reference of another generation.
- `a_size_through_another_generation_isnt_the_one_its_own_reference_reads` stores no image
  with the width another reference read. It refuses instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 12:38:38 +01:00
Grzegorz MartensandClaude Opus 5.5 718a725812 M8: the Type 3 cleanup reads forms, appearances and soft masks with /DecodeParms by reference
Three reads of the Type 3 cleanup came in from main in the merge, and
took `/DecodeParms` only when direct: the forms a surviving glyph draws
(`prune_font_forms`), a page's appearances without resources of their
own, and the soft mask groups without them (`add_soft_mask_ops`). Behind
a predictor whose parameters are an indirect object, each read the
predictor's tag bytes and row differences, which lex as operators that
use no resource, so nothing refused. The form's resources then lost the
image it draws, and the page lost the Type 3 font only the appearance
or the soft mask selects, with its glyphs. Each is now read with them
too, as this branch's other reads in the cleanup are; the appearances
still against the page's allowance.

Tests, each the by-reference form of one already on main:
- `an_image_a_type3_glyphs_form_with_parameters_by_reference_draws_is_kept`
- `a_type3_font_only_an_appearance_with_parameters_by_reference_selects_stays_on_the_page`
- `a_type3_font_only_a_soft_mask_with_parameters_by_reference_selects_stays_on_the_page`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 12:26:27 +01:00
bflatastic 117f3b1102 Merge #4497: M8: after Apply redactions, Save asks where and proposes the name with _Redacted (protect.redact-filename-suffix) 2026-11-09 18:21:20 +07:00
bflatastic 5af131db8e Merge #4358: M5: comments in an /RT /Group are one comment, with the primary's attributes (#4357)
# Conflicts:
#	crates/automation/src/comments.rs
#	crates/automation/tests/it/automation.rs
#	crates/render/src/inspect.rs
#	crates/render/src/inspect/annotations.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/panels/comments_panel.rs
2026-11-09 18:17:31 +07:00
Grzegorz MartensandClaude Opus 5.5 77b9cfa310 Merge main into fix/jbig2-dictionary-any-route
Conflicts, and how they were resolved:

- cos writer.rs and lib.rs: main now numbers a full save in `full_save_order` and exports
  `reachable_objects`, the object numbers a full save writes, for the pattern guard. This
  branch had added the same walk as `walk` and `reachable`. Main's is kept, with its alias
  handling, and this branch's is dropped: `globals::kept` is now `reachable_objects`, so the
  JBIG2 cleanup and the pattern guard read one walk. `kept` returns the walk's error (a save
  that can't number its objects), which redaction and Remove Hidden Information pass on. The
  cos test of what is reachable, and two redaction tests, read `reachable_objects` instead.
  The exports keep this branch's `DECODE_PARMS_KEYS` and main's `TextChars`,
  `civil_from_days` and `new_file_id`.
- redact lib.rs: main unlinks pruned patterns from `/Pages` nodes after `unlink_doomed`; this
  branch replaced `restore_shared_jbig2` with `globals::unshare`. Both stay, the unlink first:
  an image a pruned pattern's cell drew leaves the file with it, and is a user of a dictionary
  that left too.
- redact sanitize.rs: main's `strip_outline_actions` and this branch's `unshare_unmarked` both
  stay. At the end of `remove_hidden`, `unshare_unmarked` runs after main's `unlink_undrawn`,
  so an image unlinked for being drawn nowhere takes a shared dictionary with it, and is
  followed by main's `require_full_save_with_new_id`.
- redact image.rs: `MAX_PIXELS` is `pub(crate)` on both sides; the comment names
  `globals::without_dictionary`, where this branch moved `without_globals`.

Tests: `an_undrawn_image_unlinked_takes_a_shared_jbig2_dictionary_too` (Remove Hidden
Information, through `unlink_undrawn`) and
`an_image_a_pruned_patterns_cell_drew_takes_a_shared_jbig2_dictionary_too` (a redaction,
through `unlink_pruned_patterns`) pin the order of the two new routes before `unshare`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 11:59:18 +01:00
mtlcl410 ed6c8afdef Merge remote-tracking branch 'upstream/main' into lane3-2539b 2026-11-09 18:49:37 +08:00
Grzegorz MartensandClaude Opus 5.5 e4d150a92a Merge main into fix/redact-prune-through-forms
Main reworked the redaction cleanups this branch changes: #5969 reads
each inherited form once for all names (`names_content_draws`), #5924
prunes the forms a surviving Type 3 glyph draws and passes the page to
`prune_type3_from_resources`, and #6474 reads a page's annotation
appearances toward one operator total and one byte allowance.

- `interp/prune.rs`: `names_content_draws` keeps main's one read per
  form and takes this branch's three changes to `content_draws`: it
  reads operators one at a time, holding no stream's whole and decoding
  each form only in its turn; it reads forms with `/DecodeParms` given
  by reference; and a `/Resources` that isn't a dictionary is none of
  the form's own. It charges no allowance, as `content_draws` didn't:
  it runs after the pages' walks, over every holder, where no page's
  allowance is left to spend.
- `type3.rs`: `prune_type3_from_resources` takes both the page's
  allowance and main's `page`. The appearances main reads are charged
  to that allowance, which the content and its forms spend too, in
  place of a fresh one of their own, so one page has one allowance.
  Main's `record_used` serves this branch's one-operator-at-a-time read
  of glyph programs, and `scan_stream_object`, which took over
  `scan_xobject`'s read, reads with `/DecodeParms` by reference.
  `prune_font_forms` gives each form's walk the allowance of a page, as
  `prune_retired_properties` does for each content.
- `interp.rs`, `lib.rs`, `sanitize.rs`: both arguments at each call.
- `edit/src/rules.rs`: the refusal of incomplete content stays, with
  main's `text::joined`.
- Tests: both sides' kept, in `edit/src/tests.rs` and
  `redact/src/tests/surviving_references.rs`. A new one,
  `a_page_s_appearances_spend_what_its_content_left_of_the_allowance`,
  pins the shared allowance: 1 MiB of content and an appearance within
  half a MiB of the allowance are refused together, though the
  appearance alone fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-09 11:42:10 +01:00
Dylan M. Taylor 4cdc3d15dd fix(redact): read the page tree once per sanitize pass, not per page (#5983) 2026-11-09 18:37:46 +08:00
Dylan M. Taylor ca56433f5f fix(redact): read text-state operands from the end, as the renderer does (#5951) 2026-11-09 18:36:34 +08:00
Dylan M. Taylor 743a772ee9 fix(content): an inline image only the renderer reads counts as ambiguous (#5874) 2026-11-09 18:36:04 +08:00
dotCooCoo 2d29f7c370 Merge main into feat/redacted-file-name
# Conflicts:
#	crates/automation/src/redact.rs
2026-11-09 02:27:02 -08:00
mtlcl410 bd74ecab92 Merge remote-tracking branch 'upstream/main' into lane3-2539b
# Conflicts:
#	crates/redact/src/tests.rs
2026-11-09 18:20:12 +08:00
ljkx 930b441312 fix(redact): Remove Hidden Information reads only what the chosen categories need (part of #5873) (#6021) 2026-11-09 18:01:36 +08:00
mtlcl410andClaude Opus 5.5 cc1de556be Merge upstream/main into feat/comment-groups
Main's Replace Text (#5689) makes the caret the group's primary, as this
branch does. Kept: this branch's member-by-member deletion and release of
orphaned subordinates, with main's seed() (the strikeout also seeds its
caret) and its pair restyle; the writer returns the caret (the group's
comment) and leaves the strikeout without /Contents. Main's tests read the
strikeout from the caret's group instead of its replies, and the automation
test reads the strikeout's own colour from an XFDF export, since
comment_list gives a group its primary's attributes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-09 10:57:18 +08:00
mtlcl410andClaude Opus 5.5 8c142bef2e Merge upstream/main into fix/redact-metadata-2448
Keeps main's recovery renewal after applying redactions alongside the
combined notification, and reads XMP and rich-text streams with their
/DecodeParms given by reference made direct, as main's stream readers do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
2026-11-09 10:12:29 +08:00
bflatastic 12dfc73cfd Format the renamed helper's call 2026-11-09 09:03:50 +07:00
bflatastic fb9926858a Name the content-draws helper apart from the undrawn-image one in redaction pruning 2026-11-09 09:02:23 +07:00
dotCooCoo b2988cb8e1 Merge main into feat/redacted-file-name 2026-11-08 17:25:08 -08:00
bflatastic 52f31397be Merge #5969: fix(redact): read inherited forms once for all names in resource cleanup
# Conflicts:
#	crates/redact/src/tests/surviving_references.rs
2026-11-09 08:06:41 +07:00