Commit Graph
90 Commits
Author SHA1 Message Date
FredApps 616ddca371 M8: images stored again without shared JBIG2 globals are stored as a cleared copy is
An image that shared a redacted image's /JBIG2Globals is now stored
again as the smallest of Flate, Group 4 or JBIG2 coded alone (smallest),
like a cleared copy, rather than always Flate, so the other pages of a
scan don't balloon either. The shared-globals test's second page is a
ragged scan and checks it is no longer Flate and is smaller.
2026-11-01 22:48:29 +01:00
FredApps 54d0ea7c59 Merge branch 'main' into feat/redact-bilevel-codec 2026-11-01 22:44:58 +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
Grzegorz MartensandClaude Opus 5.5 8a6373525e M8: the JBIG2 redaction test compares the whole saved picture
One eight-byte search would miss a clearing that took only the first byte
of it: the window is gone and seven covered bytes stay. The saved picture
now has to be the one with every pixel under the mark cleared and no other
changed, on both coded forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 17:17:59 +01:00
Grzegorz MartensandClaude Opus 5.5 c14968edf1 M8: no stream of a saved file holds the pixels under a mark on a JBIG2 scan
The tests of this checked that the JBIG2 code and its globals had gone, and that the pixels came out
cleared. This reads every stream of the saved file as it would be shown, and searches them for
pixels found only under the mark, and for pixels beside it, which have to stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 13:46:01 +01:00
Grzegorz MartensandClaude Opus 5.5 5660075295 M8: the test of rows too wide for Group 4 says Group 4 refused them
Flate coming out didn't show that: Group 4 could have been coded and lost on size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-11-01 12:15:58 +01:00
Grzegorz MartensandClaude Opus 5.5 53874375cb M8: test: a cleared copy stored as Group 4 or JBIG2 can be redacted again; the README's not-yet list
The README still listed metadata and hidden-information sanitising as not done, though sanitize
does it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 02:53:16 +01:00
Grzegorz MartensandClaude Opus 5.5 689a1e34cf M8: test: a cleared image of more than one bit stays Flate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
2026-10-31 00:41:23 +01:00
Grzegorz MartensandClaude Opus 5.5 7f9554f29b M8: a cleared black-and-white scan is stored as Group 4, or JBIG2 again, when smaller
Redaction wrote every cleared copy as Flate, so a redacted Group 4 or JBIG2 scan
page grew several times over until the optimizer ran. A cleared one-bit image of one
component is now stored as the smallest of Flate, CCITT Group 4 and, when it was
JBIG2, JBIG2 (PDF 1.4, which such a file already is), as stored, dictionary included:
a tiny image can lose by Group 4's parameters. Both are coded from the
cleared samples, so nothing of the original's code is carried over; rows Group 4 or
JBIG2 can't code (too wide) leave Flate. Images of more bits stay Flate.

Tests: a cleared one-bit Flate scan of ragged word blocks (where Group 4 is smaller
than Flate) comes out Group 4 and an eight-bit image Flate, each decoding to the
cleared samples (the whole buffer); a tiny image whose Group 4 code is shorter but whose
stream isn't, and rows wider than Group 4 codes, stay Flate and clear; the redacted
JBIG2 scan of #5461's test comes out JBIG2, smaller than
Flate, with no globals and no stream holding the original code. The tests' image
helpers decode through Document::decoded_image, as image codecs need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:08:32 +01:00
Grzegorz MartensandClaude Opus 5.5 78fc1e5568 M8: redaction clears the covered pixels of JBIG2 images instead of removing them
Part of #283. A scan stored as JBIG2 (as the optimizer writes them) is decoded
through Document::decoded_image_within, its /JBIG2Globals included, and its covered
pixels cleared in a Flate copy that refers to no globals, as CCITT fax images are.
Before, any JBIG2 image under a mark was removed whole, blanking the page.

Still removed whole (fail-closed): a JBIG2 page of another size than the image,
which decoded_image refuses (the renderer draws the whole page into the image, so
the rows cleared wouldn't be the ones shown); globals that can't be read; damaged
data; an image claiming more than one bit or one component, even where its rows
take as many bytes; a page far past the image's raster, refused before it is
decoded.

Globals no other image uses leave the file with the original on the full rewrite
(tested); globals another image still uses stay, and the README and parity note say
they may hold the shapes of marks from under the area, not where they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:03:06 +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 5f3fde33d6 M11: tests for what Monochrome Images keeps, and for the fax bounds; Retain's wording
Mutation testing found behaviour no test held, now covered:
- a Group 4 halftone above 450 ppi isn't resampled and stays byte for
  byte, alone by default and behind ASCIIHexDecode with Retain;
- under Retain a resampled Flate scan stays Flate, though Group 4 would
  be smaller (ragged bands);
- sixteen-bit grey of only 0x0000 and 0xFFFF keeps its bytes;
- a busy Group 4 image behind ASCIIHexDecode, whose code is over three
  times its raster, decodes within the fax bound and is stored again as
  Group 4 alone. Redaction's busy-fax test is that busy again: since the
  bound for other images is three times the raster, the old checkerboard
  fitted it, and dropping the fax bound passed.
The redaction README says three times, and doc_optimize's description
says what Retain keeps: CCITT stays CCITT, anything else becomes Flate.

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 bd56936795 M11: redaction reads an image mask's /Decode given by reference
Redaction clears an image mask's covered samples to the value that
paints nothing, which an inverting /Decode [1 0] flips. It read the
array only when direct, so given by reference (or with its first number
so) the cleared samples painted instead: the covered area came out
inked. That was already so on main, and this PR sends CCITT masks
through it. The array and its first number are resolved now, as export
and the optimizer read them; the fax test covers all three forms.

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
Grzegorz MartensandClaude Opus 5.5 e3b9e175b5 M11: redaction clears pixels in CCITT fax images instead of removing them whole
Create PDF and the optimizer now store black-and-white scans as Group 4,
and redaction couldn't decode CCITT, so redacting one word on such a
scan removed the whole page image. A CCITT image (the last filter,
after any others) now decodes through Document::decoded_image and is
cleared in a Flate copy like any other image; one that can't be decoded
in full is still removed whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:03 +01:00
Grzegorz MartensandClaude Opus 5.5 6a99d26c7d M11: redaction reads /DecodeParms given by reference before clearing pixels
Stream::decoded_within reads /DecodeParms only when it is direct, so an
image whose PNG predictor was given by reference decoded with its row
tags still in the data. The cleared bytes then drifted one byte further
from the covered pixels each row, and the covered samples survived in
the redacted copy, recoverable by applying the predictor. Parameters
are now made direct first (Document::with_direct_parms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:03 +01:00
Dylan M. Taylor 833cd63c8a proposal(redact): prevent retention of confidential vector paths behind clipping (F-008) (#4577)
Partial vector redaction previously hid covered areas with clipping
(`clip_out`, `W/W*`) while retaining the complete original drawing instructions
and painting operators in the content stream. This allowed recipients
to recover confidential outlined text or other vector drawings from
supposedly redacted documents.

This change removes vector paths that intersect redaction marks completely
and refuses partial clip retention, ensuring no confidential geometry is
preserved behind clipping. Report counts are updated to record removed paths.
2026-10-25 16:14:06 -04:00
skylar taylor-barrick e90f7882f1 Migrate PdfCraft to egui 0.37 (#4591)
* Record successful PdfCraft web application check

* E21: bound startup requests and validate font and license inputs

* E21: separate pending control completion and gesture regression phases

* M-egui037: enforce control admission and per-family font notices

* M-egui037: qualify capture fixes and document migration behavior

* egui-037: identify current and historical qualification results
2026-10-25 14:30:31 -04:00
Dylan M. Taylor aa9a9105fc fix(redact): remove AcroForm on Hidden::FormFields even when field count is zero (#4571)
* fix(redact): remove AcroForm on Hidden::FormFields even when field count is zero

* fix(redact): report AcroForm presence in scan() and format
2026-10-24 21:07:16 -04:00
aiongg f94b34adc9 Integration: fix(redact,cos): cleared redaction marks leave nothing behind (#4226) 2026-10-24 22:48:11 +00:00
aiongg 7733b619cf Integration: #3670's fan-out test allows for a loaded machine 2026-10-24 07:11:16 +00:00
aiongg 2a91f1d717 Integration: the page's decode budget owns the form count in redaction (#3670 × main)
#3670 raised the form depth limit to 50 and added its own 50,000-visit cap. Main's
per-page DecodeBudget (e2a677164) has the same 50,000-form limit, and the visit cap
fired first, so Apply removed the forms instead of refusing the page as main does.
The visit cap goes; the fan-out test expects the refusal, and the depth-limit
sanitization test builds its chain from MAX_DEPTH.
2026-10-24 07:11:16 +00:00
aiongg 44831a6047 Merge PR #3670: Redaction: follow nested forms as deep as the readers do
crates/redact/src/interp.rs: kept both sides: main's per-page DecodeBudget and the PR's
visits counter (MAX_FORM_VISITS, same 50,000 as MAX_PAGE_FORMS; the visit check fires first).
2026-10-24 06:22:04 +00:00
alehelandClaude Opus 5.5 195a2c9bab fix(annot,redact): inline replies and pop-ups go with what they point at
`/Annots` may hold an annotation dictionary inline instead of a reference
(the standard asks for references, but files do it and PdfCraft accepts
them). Deleting a comment or clearing redaction marks only matched
referenced entries, so an inline reply (`/IRT`) or pop-up (`/Parent`) of
what went stayed behind, pointing at nothing. The pop-up an inline reply
or an inline comment names (`/Popup`) stayed too. This predates the shared
walk.

`goes_with` matches an entry against what is going, inline entries
included; `delete_annotation` and `clear_marks` filter with it. The walk
follows the pop-up an inline reply names, and deleting an inline comment
takes the pop-up it names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:54:53 +02:00
alehelandClaude Opus 5.5 d9bc4b17a5 fix(redact): clearing marks leaves the page as it was and takes replies with them
Clear all wrote `/Annots []` on a page that had no annotations and replaced
an indirect `/Annots` array with an inline copy. It also left replies to a
mark in place, so their `/IRT` kept the cleared mark in the document (and in
the comment list, as a reply to nothing).

`clear_marks` now writes the list through the annot crate's `set_annots`
(made public), as deleting a comment does, and removes each mark's replies
and pop-ups with it. The "what goes with a deleted comment" walk moves from
`delete_annotation` into `with_replies_and_popups`, which both use; it visits
each annotation once instead of sweeping the list until nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:54:52 +02:00
Dylan M. Taylor 39ee93fe70 fix(redact,sign): address review — pop-up replies, signed-role deletions, widget role from signed revision 2026-10-23 10:11:10 -04:00
Dylan M. Taylor dad86bf9f5 fix(redact,sign,cos): close high-severity findings from the agent scan
Redact: refuse Redact marks written in place; remove replies and pop-ups listed on
other pages; delete form fields by object, not name; resolve Do by its last operand
like the renderer; sanitize drops PCAdded and flattens widgets before hidden-content
cleanup. Sign: freed signed objects and edits to an already-signed widget are
modifications. Cos: only reading trusts /Type /XRef to skip encryption.
2026-10-23 10:11:10 -04:00
aiongg 0d216199b9 fix(redact): apply redactions on a copy so a failure leaves the document as it was 2026-10-22 23:40:55 +00:00
aiongg e2a6771641 Redaction charges a page's content streams and its forms to one decode budget 2026-10-22 19:48:33 +00:00
aiongg a72845ad80 Merge upstream/main into fix/coderabbit-b-shared
Redaction reads page content and form XObjects strictly (upstream) and
within one page's decode budget (this branch): both now go through
DecodeBudget::decode_strict.
2026-10-22 19:44:33 +00:00
aiongg b72078871a fix(content): end inline images where the renderer ends them
Filtered inline image data has no length, so its end is an EI chosen
among candidates, and the content parser chose by a different rule
than the renderer's tokenizer (hayro-syntax): the renderer also takes an
EI with no whitespace before it, and checks a candidate by reading what
follows as operators. With image data such as "7FEI 1 0 0 1 200 0 cm",
the renderer ran a cm that redaction read as image data, so text was
drawn 200 pt from where removal and its verify pass looked.

The parser now takes each inline image's end from the renderer's
tokenizer (already in the tree through pdfcraft-cos), so both read the
same operators after the image; a test runs both on the same fixtures.
Only where that tokenizer reads no image (no EI it accepts, or
parameters it can't read) does the parser fall back to its own rules,
counting the image in Parsed::ambiguous_images, and applying redactions
refuses such a page as unreadable.
2026-10-22 15:17:44 -04:00
aiongg 7db37911ed fix(redact): clear descriptions above removed content and annotations
Structure cleanup cleared alternate text, actual text and expansions
only on the element that directly owned changed marked content, and an
element that lost an annotation reference kept its descriptions unless
it lost every kid. An ancestor's /ActualText covering a redacted span,
or a link element's /Alt repeating the address of a removed link, stayed
in the saved file. Elements that lose an annotation now always lose
their descriptions, and every ancestor of an affected element (found
through both /P and the tree's /K, which may disagree) loses its own.
An element written in place above one with a description to clear
refuses the operation, as other in-place cases already do.
2026-10-22 15:17:44 -04:00
aiongg cccd435a53 fix(redact): bound image decoding by the declared raster size
Clearing the pixels of a partly covered image decoded its stream with
the global 1 GiB allowance and only then cut it to the raster length its
Width, Height and bit depth declare. A small Flate stream could make
every such image allocate and inflate far more than it can use. Decoding
now stops at twice the raster plus a byte per row (filter-chain and
predictor overhead) and a little slack; an image past that is removed
whole, as an undecodable one already is.
2026-10-22 15:17:44 -04:00
aiongg 22aa6d5f9a fix(redact): place glyphs with the inherited and ExtGState-set text state
The redaction interpreter started every form XObject with an empty text
state (no font, size 0, no spacing) although a form inherits its caller's
graphics state, and it ignored the /Font entry of an ExtGState set by gs.
Text shown that way was placed at the wrong positions, so removal and
the verify pass could both miss glyphs under a mark. Forms now start
from the caller's state, and gs sets the font and size as Tf does.

The engine's renderer cross-check test used the gs case as its example
of interpreter/renderer disagreement; it now runs the check directly on
an unredacted page, which also covers an input stream labelled as a
redaction overlay.
2026-10-22 15:17:44 -04:00
aiongg f42fb0ad49 fix(forms): a deleted field leaves the calculation order
Deleting a field took it off its pages and out of the field tree but left
its entry in the form's /CO array. That reference kept the field
dictionary, its value and its widgets reachable, so a full save after
applying redactions over the field still wrote the redacted value.
2026-10-22 15:17:44 -04:00
aiongg 241c5512d6 fix(redact): charge repeated name-tree kids to the node budget
The name-tree count cloned each node's /Kids array and skipped repeated
references without charging the budget, so a node listing the same kid
many times cost work proportional to every entry while the budget saw one
step. The walk now borrows the array, charges every entry it does not
descend into, and stops once the budget is spent.
2026-10-22 13:28:40 -04:00
aiongg 822179ccf2 fix(redact): remove hidden data from every object that owns it
Metadata, private application data, associated files and additional
actions were removed from the catalog and pages only. The same entries on
an image or font (XMP), a Form XObject (PieceInfo), a page or annotation
(/AF with its embedded file) or a form field (/AA scripts) survived the
full rewrite, and the scan did not count them, so removing only such a
category reported nothing to remove. A walk of every object reachable from
the catalog now counts and strips these entries wherever they are, leaving
name maps such as resource dictionaries alone (their keys are names a
producer chose), and refuses a graph nested too deeply to walk in full.
2026-10-22 13:28:40 -04:00
aiongg 750bbe27f1 fix(redact): clean the structure tree after removing hidden content
Removing hidden text or hidden layers rewrote page and Form content but
left the structure tree alone, unlike applying redactions. An element's
ActualText, Alt or expansion text still described the removed content,
and a marked-content reference into a Form kept the original Form, with
the removed text, in the full rewrite. The hidden-content pass now cleans
the tags of what each page no longer shows and moves Form references to
the rewritten copy, refusing when the tree can't be cleaned completely.
2026-10-22 13:28:40 -04:00
aiongg 3fe9fe218e fix(redact): remove the widgets flattening keeps when removing form fields
Removing form fields flattens them and then drops the interactive form,
but flattening keeps hidden and no-view widgets (they draw nothing) and
widgets with no appearance. The pages still listed them, so their values
and their parent fields' values survived the full rewrite. Every widget
left after flattening now leaves the pages and the structure tree, inline
annotations included.
2026-10-22 13:28:40 -04:00
aiongg ee010fbbe5 fix(redact): read content strictly before sanitizing or redacting it
Page and Form content was decoded tolerantly, keeping what came before a
corrupt spot and dropping the rest without saying so. A page whose content
broke at its first bytes therefore read as empty: the hidden-text scan
counted nothing, the removal was skipped and the original stream, which
other viewers still draw, stayed in the file. Content is now decoded
strictly; an unreadable page counts as something to remove, so the removal
runs and refuses the page, and a Form read only in part is treated like an
undecodable one.
2026-10-22 13:28:40 -04:00
aiongg f1c56ef865 fix(redact): count a name tree's shared nodes once
The hidden-information count followed every /Kids entry of the attachment
and document-script name trees with only a depth limit, so a node listed
twice by each level was read once per path: a few hundred bytes made the
scan take 2^32 steps. Indirect nodes are now read once each, under a total
node budget.
2026-10-22 13:28:40 -04:00
aiongg d2e7ce72f0 fix(redact): form entries and page content share one budget per page
The interpreter entered a form again at every Do with only a nesting
limit, so a few shared forms drawing each other produced exponential
work in apply, sanitize and verify. Scope carries a per-page
DecodeBudget for form entries and form bytes; past it, apply refuses
the page and verification counts the form as possible residue. Page
content streams are decoded against a page budget too.
2026-10-22 16:57:55 +00:00
Hamza 59163e3ad3 Redaction: a per-pass form budget, so depth 50 cannot fan out
Review of #3670: with the depth limit at 50, depth was the only bound on the
form walk. A form that draws the next level twice, or a cycle of two forms,
costs 2^depth interpretations — at 12 that was 4096, at 50 it is a hang on a
file anyone can send.

The pass now also counts the forms it enters, and the budget is Edit text's own
`MAX_FORM_VISITS`, referenced rather than copied. Running out is treated as a
form that cannot be read, which is what the depth limit already did: removed in
Apply, the page left alone in Verify.

Measured with the new test:

  depth 50, fan-out and a 50-deep cycle, with the budget   5.9 s
  depth 22, fan-out, budget removed                        still running after
                                                           5 minutes

A `const` assertion ties this pass's depth limit to Edit text's, so the build
fails rather than a redaction quietly removing what a reader still shows (the
review's third point). Checked by setting the limit back to 12:

  error[E0080]: evaluation panicked: assertion failed:
  MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH

Tests: a page whose forms each draw the next twice, and the same with the last
form drawing the first again — both must finish and still redact the page.
2026-10-22 18:26:21 +05:00
aiongg 15f558aaa8 Merge r2 track t11: redaction, mixed: combine, compare, crate:edit, measure, ocr
# Conflicts:
#	ATTRIBUTION.toml
#	crates/automation/src/tools.rs
#	crates/edit/src/lib.rs
#	crates/ui-egui/tests/compare.rs
#	crates/ui-egui/tests/ui_group/marks.rs
2026-10-22 02:21:38 +00:00
aiongg 75fa0d0109 Merge PR #3571: fix: read the last array of a surplus-operand TJ, not the first (#3569) 2026-10-22 01:45:17 +00:00
aiongg c58f1a0ff7 Merge PR #3723: Redact: count shared outline items once so the walk ends 2026-10-22 01:44:56 +00:00
aiongg ee0b147a61 Merge PR #3728: Refuse redaction marks with no readable area instead of skipping them 2026-10-22 01:44:51 +00:00
aiongg 69ef1c18e6 Merge PR #3674: M8: applying redactions drops structure-tree references to the comments and fields it removes (#2874) 2026-10-22 01:44:49 +00:00
skylar taylor-barrick d695bc5654 fix(redact): evaluate OCMD visibility during sanitization (#3522)
* fix(redact): evaluate OCMD visibility during sanitization

Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively.

* fix(redact): preserve OCMD visibility during sanitization

* fix(redact): count hidden XObjects during verification

* test(redact): cover hidden form depth limit
2026-10-20 18:17:34 -06:00