Commit Graph

51 Commits

Author SHA1 Message Date
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
skylar taylor-barrick e397efa854 Merge pull request #3285 from mtlcl410/fix/struct-refs-parent-tree-2532
M8: redaction's structure-tree walks start from the parent and ID trees too (#2447, #3302)
2026-10-19 10:05:04 -06:00
Earl Co 236ce9a064 Clear image pixels intersected by redaction marks (#3368) 2026-10-19 02:48:29 -04:00
skylar taylor-barrick cece9f5ccc M8: document discard-on-error for public redaction calls (#3302) 2026-10-18 16:45:35 -06:00
mtlcl410 3ef3c9efa9 M8: tag cleanup refuses what it can't finish instead of removing the tree (#3302)
Maintainer review on #3285: an element written in place that owns redacted
marked content or tags a removed annotation, an inherited page the lookup gave
up on (over 256 ancestors or a loop), and a walk past its limit are now
RedactError::Tags, so nothing is changed. Removing only /StructTreeRoot left
outline /SE and GoTo /SD reaching the elements. Inherited pages are cached per
ancestor. clear_every_element and drop_tree are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-19 03:20:34 +08:00
mtlcl410 f62bf71a77 M8: tag cleanup keeps inherited pages, fails closed over replaced streams and in-place elements (#3302)
Review follow-ups on #3285: walk /K before the parent and ID trees and look up
an element's inherited /Pg through /P; drop the structure tree when a cut-short
walk could leave MCRs naming a replaced stream; drop it when an element written
in place tags a removed annotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-19 01:33:42 +08:00
mtlcl410 ce3c0e46c9 M8: redaction clears tag text of elements reached only through the parent or ID tree, and fails closed past the walk limit (#3302)
tags::clean, which drops /Alt, /ActualText and /E that repeat redacted
text, walked the structure tree from the root's /K only, so an element
listed only in /ParentTree or /IDTree kept the redacted words. It now
starts from both trees too, as drop_object_refs does since 3f543ca1.

Past its 2,000,000-reference cap the walk used to skip the rest and
report success. It now fails closed: every structure element in the
document, found by reading each object rather than through the tree,
loses its alternate and actual text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-19 00:52:18 +08:00
mtlcl410 e6de108972 M8: note that structure elements written in place keep their references (#2447)
Review note from Losminek on #3285.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-19 00:44:58 +08:00
mtlcl410 3f543ca17f M8: redaction drops OBJRs of elements reached only through the parent or ID tree (#2447)
Follow-up to #2532 (review by aiongg): the structure-tree walk started
only at the root's /K, so an element listed only in /ParentTree or
/IDTree kept its object reference to a removed link or comment, and the
root kept that annotation reachable in a full rewrite. The walk now
also starts from both trees and follows their nodes (/Nums, /Names,
/Kids), written in place or as their own objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-19 00:09:17 +08:00
aiongg b6b02a1b27 Merge upstream main 1952a8880 into next
ROADMAP.md, docs/roadmap.md: union of both sides' lines, each once.
parity/acrobat-features.toml: the XFA entry has both sides' evidence and notes (#2604 page areas, #2813 CLI layout).
2026-10-18 14:22:30 +00:00
NetterSilver 2eb1639097 Find credit card numbers followed by an expiry, year or CVV (#2969)
* Find credit card numbers followed by an expiry, year or CVV

Found while testing crates/redact/src/patterns.rs:180-190 (credit_card, via digit_run at :53)

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

* Redact: a card followed by a number drops at most four trailing digits

A longer grouped number (an IBAN's digits) no longer matches on a Luhn-valid prefix; the run is
read far enough to see the tail and refused when it goes on past that.

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:49:13 -04:00
NetterSilver 7e3f413f52 Redact email addresses whose local part starts with punctuation (#2948)
Found while testing crates/redact/src/patterns.rs:141-146 (email)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 09:48:02 -04:00
aiongg 7f718ecc62 Merge upstream main 50f261d70 into next
ROADMAP.md, docs/roadmap.md: union of both sides' log lines and milestone rows, each line once.
parity/acrobat-features.toml, crates/xfdf/README.md: next's text, a superset of main's (#1917, #2686 plus #2688).
crates/edit/src/tests.rs: both sides' new tests (#2558 and #3084).
crates/ui-egui/src/editing.rs: the picked picture is cleared on every edit, and a new drawing commits its raster (#3149).
2026-10-18 13:12:21 +00:00
Ridwan 5827afdcde M8: measure fonts without /Widths with their embedded program (#2130) (#2232)
Find and the renderer place glyphs of a simple font that has no /Widths
with the embedded font program's own advances, but redaction guessed the
widths from the font's name (a standard-14 face, Helvetica by default).
When the guess was wider than the real font, the end of a line under a
mark seemed to lie past it and was kept in the content stream, and the
verify pass, using the same guess, agreed.

Metrics now fills the width table from an embedded sfnt program
(FontFile2, or OpenType FontFile3) when /Widths is missing, looking the
glyph up as the renderer does. A subset's unmapped codes get width 0 so
has_glyph stays false for them. Fonts with /Widths, and bare CFF or
Type 1 programs, are unchanged.

Fixes #2130
2026-10-18 03:48:00 -04:00
mtlcl410 2c4b84f6af M8: redaction removes the replies of the annotations it removes (#2659) (#2697)
* M8: redaction removes the replies of the annotations it removes (#2659)

A reply (/IRT) placed away from the mark survived, and its /IRT kept the
removed annotation - its comment text and the QuadPoints of the redacted
words - in the saved file. Replies to a removed annotation, and replies to
those, now go with it.

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

* M8: test replies listed before their parent, with a pop-up on a reply (#2659)

Review of #2697: the replies precede the annotation they answer in
/Annots, deepest first, and a reply has a pop-up. A single pass in
/Annots order would keep them; the test fails with the loop cut to one
pass, which the first test does not notice.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:45:46 -04:00
Jules Kreuer d8b2705825 Clear replacement text (#1350)
* M8: Clear marked-content replacement text

* M8: Move roadmap note

* M8: Keep inherited resource guard clean under Rust 1.96

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-17 22:10:22 -06:00
skylar taylor-barrick 392406eb4a fix(content): read a comment at the end of a content stream as the renderer does (#3081)
Pieces::join separated a page's content streams with a newline, which ended
a comment left open at the end of one stream. hayro, MuPDF and Poppler join
them with a space, so the comment runs on into the next stream and hides its
first line. Edit text, object_list, the redaction audit, redaction and the
Office/HTML export therefore saw text and boxes that nothing draws, and
redacting unrelated text on the page made the hidden content visible: the
rewritten stream dropped the comment.

- Pieces::join uses a space, so every reader of the joined streams agrees
  with rendering.
- The parser records, per piece, whether a top-level comment runs into it
  and out of it (boundaries only, not every comment). A redaction rewrite
  of a piece ends an incoming comment before its first operator and leaves
  an outgoing one open, so hidden content stays hidden.
- Reading for export parses the streams joined, instead of one by one.
- The redaction tag check compares the joined streams the same way.
- Pieces::piece_of uses a binary search.
2026-10-17 21:50:58 +02:00
mtlcl410 f5a2376c86 fix(redact): a structure tree too large to walk goes whole (#2447)
Review follow-up on #2532: drop_object_refs stopped visiting at 2,000,000
references and still reported success, so a reference beyond that could
keep a removed annotation reachable. A tree the walk can't finish is now
removed whole, with /MarkInfo: fail-closed, at the cost of the tags. The
limit is a parameter of drop_object_refs_within so the test can reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
2026-10-17 02:02:23 +08:00
mtlcl410 4d6812503d fix(redact): flattened widgets and far pop-ups leave the structure tree too (#2447)
Review follow-up on #2532:
- the structure-tree references dropped are those of every annotation no
  page lists any more after Sanitize, so widgets flattened with "form
  fields" go too, with their field value;
- the annotations to remove are collected on every page first, so a
  pop-up listed on another page than its parent goes with it;
- an element left with nothing to tag drops /Alt, /ActualText, /T and /E,
  which can repeat the URL or comment;
- the test's byte search saves without object streams, so it can see the
  strings it looks for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
2026-10-17 02:02:23 +08:00
mtlcl410 b7b6e6fe09 fix(redact): removed links and comments leave the structure tree too (#2447)
Remove Hidden Information and Sanitize took a link or comment off its
page's /Annots, but a tagged PDF's structure tree still referenced it
through an /OBJR, so the full rewrite kept the annotation, its URL or
text included, while link_list reported none. The annotations removed
from every page are now collected, and tags::drop_object_refs drops the
structure elements' object references to them (direct or indirect,
array or single kid), with the same seen-set bound as tags::clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
2026-10-17 02:02:23 +08:00
Anik Tahabilder 6e04f6eed7 fix(redact): size image clearing with checked arithmetic (#2177) (#2221)
* fix(redact): size image clearing with checked arithmetic (#2177)

An ICCBased image's /N comes from the file and was cast with `as usize`, then multiplied
unchecked into the row size. /N 2^60 overflowed it (debug: overflow panic; release: a zero row
emptied the buffer and the write loop indexed out of bounds), and a negative /N wrapped.

/N is now limited to 1, 3 or 4 (ISO 32000-2 §8.6.5.5), every size product is checked, and the
pixel writes use `get_mut`. An image that fails any of these is refused, and the caller already
removes it whole (fail-closed). Regression test covers /N 2^60, -1 and 2.

* redact tests: pass the pixel array by value (clippy needless_borrows_for_generic_args)

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:50:02 -04:00
ysrdevs e8d1980ded XFA: flatten a form into a plain PDF, or convert it to a static form (#1948)
Flattening the fields of a laid-out XFA form used to leave the XFA in
the file: Adobe's viewers then laid the form out again from the
template over the baked pages, so the "flattened" PDF was still a live
form there and a plain one everywhere else.

pdfcraft_xfa::strip takes the XFA out of a document: the packets
(template, data and all), /NeedsRendering on the catalog and the
AcroForm, the layout mark, the overrides, and the private keys on
every field down the tree. The engine calls it after Flatten form
fields on a document with XFA packets, so the result is a plain PDF
every viewer shows the same, and for the new Edit::XfaToStatic
("Convert to a static form"; tool xfa_to_static), which keeps the
laid-out pages and the fields with their values as an ordinary
AcroForm that no viewer lays out again and whose scripts no longer
run. Both undoable: the engine's post-edit refresh re-reads the
layout mark to drop or restore its XFA state and the cached template.

What the packets held beyond the fields is reported (packets by
name, data no field showed, buttons left inert, the layout's own
warnings, Reader usage rights) as document warnings, and a dynamic
form that was never laid out is refused rather than left with
nothing. Applying redactions shares the same strip. Closes the last
planned XFA parity entry (form.xfa-flatten shipped).
2026-10-15 02:32:04 -04:00
mtlcl410 1ae12090d0 fix(redact): the tail of a longer number is not a phone number (#1884) (#1911)
The phone pattern tried every position, so the last groups of a French
social security number, an IBAN or a SIRET matched as a trunk-0 or NANP
number once the groups before them were skipped, and the start of the
identifier stayed visible. A phone number is now a whole run of digit groups:
one reached through separators from a digit before it, or going on past the
28-character cap, is not one.


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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 02:29:06 -04:00
Sir Clangington III. 8a04b6d5c2 M8: Bound and reuse repeated redaction overlays (#1426)
Consolidate the reviewed change and its follow-ups on current upstream.

Original change notes:
M8: Bound and reuse repeated redaction overlays

Prepare appearances within a shared 8 MiB budget before changing the document. Reuse one measured and encoded row, reject excessive geometry, and preserve document and undo state on refusal.

Fixes #1326

M8: Preserve upstream font metrics in bounded redaction overlays

M8: reuse ordered redaction mark groups (#1326)

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-15 02:16:56 -04:00
Jules Kreuer 039706c437 Clear image leftovers (#1151)
* M8: Remove image redaction leftovers

* M8: Move roadmap note
2026-10-15 02:16:35 -04:00
Tanish Manku 50629eb6bf feat(testkit): one way to write a synthetic PDF fixture
55 files hand-rolled the same serialiser -- number the objects, record each one's offset, emit a
classic cross-reference table and a trailer -- 111 times over. The object bodies are properly the
business of the crate under test; the envelope around them is not, and every copy is a place for a
fixture to be quietly wrong in a way that makes a test prove less than it appears to.

`pdfcraft-testkit` holds that envelope. It has no workspace dependencies on purpose, so the crates
at the bottom of the layering can use it without a cycle, and it returns bytes rather than a parsed
document for the same reason.

The builder is byte-identical to the serialiser it replaces, asserted against a verbatim copy of
the old code across versions and trailer variations, because a migration that changes a fixture's
bytes silently changes what the test using it proves. Watched failing by breaking the free-entry
generation: three tests caught it. The one intended difference is that an empty stream dictionary
gives `<< /Length N >>` rather than a doubled space.

Migrates cos, measure and redact: 111 copies in 55 files becomes 108 in 53. The rest are
follow-ups rather than one sweep -- ui-egui alone is 27 of those files and is the most contested
area in the repository. Some copies should stay: a test about a malformed cross-reference table
needs to write its own bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 13:57:56 -07:00
Márk Bartalos 9ed3abe490 Keep undrawn comments and accurate search hits (#1317, #1327, #1302, #1315, #1340) (#1442)
Flattening deleted a cloudy comment it could not draw, and a refused text-box restyle or resize left the new properties in the file. Arrow defaults were stored on plain lines. A custom match ending on a separator included the next glyph, and the vertical-text redaction error claimed an overlap it had not checked.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:09:36 -04:00
Jeremy Yao 759cfa65f4 Security: fix three audit findings in redaction and signature validation (#1631)
* M8: redaction unlinks removed images and forms from inherited and shared resources

Apply Redactions and Remove Hidden Information give a rewritten page its own
pruned /Resources, but the dictionary the page drew through before could still
name the image or form that was removed:

- a /Resources inherited from a /Pages node;
- one resource dictionary object shared by several pages;
- the page's own dictionary, when a form without /Resources drew the image
  through it.

A full-rewrite save keeps whatever is still referenced, so the removed image
samples and form text stayed in the saved file while the operation reported
success. Per-page resources (the case fixed in #1099) were not affected.

The interpreter now records the original object behind every XObject it drops
or replaces. After the page loop, each page-tree resource dictionary that still
names one loses that entry unless a page drawing through that dictionary still
draws the name (the same image on another page, or again outside a mark). A
dictionary that loses an entry is replaced by a private copy on its node, so a
dictionary object shared with other nodes is left alone for them. If a page's
use of a name cannot be told (unreadable content, a form that will not decode),
the operation fails closed.

Tests: redacted_xobjects_do_not_survive_through_inherited_or_shared_resources,
an_image_another_page_still_draws_through_the_same_resources_is_kept,
an_image_a_form_drew_through_the_pages_resources_does_not_survive,
a_hidden_layers_image_does_not_survive_through_inherited_resources.

Not covered here: originals named by a form XObject's own /Resources, by an
annotation appearance or by a pattern.

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

* M9: a cross-reference entry the reader sets aside is never an unchanged signed object

The reader that judges post-signing changes and the renderer that draws the
page resolve some cross-reference entries differently. A revision appended
after signing could redefine a page's content stream and give the object an
entry the reader sets aside while the renderer follows it:

- an in-use entry at offset 0 (the reader skips it; the renderer fails to read
  the object there, rebuilds its index and takes the last definition);
- a second entry for the same object in one section (the reader keeps the
  first, the renderer the last).

The classifier compared the signed object with itself, found nothing changed
and reported the signature Valid while the page showed the new content.

pdfcraft-cos now records the entries it does not take at face value
(Document::xref_oddities: offset-0 in-use entries other than object 0,
duplicate entries that differ, reserved cross-reference stream types, and a
hybrid /XRefStm entry that disagrees with the table), and can say whether an
entry locates the object itself (Document::xref_entry_is_exact). What it
resolves is unchanged.

classify_changes treats any such entry added after the signed revision as a
change that is never permitted (page content when the object was a signed page
or its content, other changes otherwise). An object whose content compares
equal at a new place is unchanged only when its entry is exact: where the
reader had to search for the object, another reader may arrive elsewhere.

Tests: a_cross_reference_entry_the_reader_sets_aside_does_not_hide_new_page_content,
a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object,
cross_reference_entries_that_are_set_aside_are_recorded,
an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact.

This closes the two demonstrated triggers and their near variants. It does not
prove that the two parsers agree on every file: other differences in what each
tolerates may remain.

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

* M8: Apply Redactions is refused when the page still shows text under a mark

Redaction decides what lies under a mark from the positions its own
interpreter computes, and its verify pass reuses them. Where those differ from
the renderer's, a mark drawn over the text as displayed lies over glyphs the
interpreter places elsewhere, or does not see at all: they stayed in the file
and the operation reported success.

The case the test pins: a font and size set through an ExtGState /Font entry
(`gs`), which the renderer honours and the interpreter does not model. The same
kind of gap was observed for a font a form XObject takes from its caller, for
some predefined CMaps and for a rotated Type 3 font matrix. The standard-14
width error that first showed it is fixed at its root by d0d1ad81.

The engine now asks the renderer, which is what the user saw when placing the
marks. After redaction runs, the result is written as a save would write it
(without the marks' own overlay), its text is extracted, and a glyph still at
least half inside a mark fails the edit with the existing "could not be
verified" error. The edit runs on a copy, so the document is unchanged. A page
whose text cannot be read back fails the same way.

This makes such mismatches fail closed; it does not make those documents
redactable. Teaching the interpreter each of them is separate work.

Test: redaction_is_refused_when_the_page_still_shows_text_under_a_mark (the
line whose font is set by `gs` is refused and left unchanged; the same line set
with `Tf` is redacted).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:14:40 -04:00
Jules Kreuer 057e453a16 Retire inherited resources (#1348)
* M8: Retire inherited redaction resources

* Redaction: pin inherited resources only on redacted pages, shared by reference

localize_resources rewrote every page in the document on every apply and copied a direct
inherited /Resources dictionary into each one, so a large document grew by N x the dict.
Now only redacted pages that inherit resources are pinned; a direct inherited dictionary is
made one indirect object and shared by reference. The parent's /Resources is retired when no
other page still inherits it (the leak fix), and kept (through the shared reference) when an
unredacted page still draws with it. A /Parent chain that can't be followed or disagrees with
the page tree retires nothing. Tests: an unredacted page is left untouched and the rewrite is
bounded; redacting every inheriting page retires the parent's resources. ROADMAP entry moved
into the revision-history table.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:49:57 -04:00
Tanish Manku d0d1ad8192 F1: exact standard-14 metrics
pdfcraft_fonts::helvetica_width was a fifteen-line guess by character class,
and seven crates generated appearance streams through it: annot, edit, forms,
ocr, redact, sign and xfa. Times was Helvetica x 0.9, bold was x 1.05, Courier
a flat 0.6. Against the published metrics a Times capital A is 722, so that
estimate said 600 -- a 17% error on a letter, compounding across a line.

Std14 carries the real advance widths for all fourteen faces, indexed by
character code: WinAnsiEncoding for the twelve text fonts, which is what we
write into the appearance streams we generate, and their own built-in
encodings for Symbol and ZapfDingbats. The tables are generated by joining the
published encodings with the per-glyph widths.

helvetica_width now delegates, so every call site gained exact Helvetica with
no change. The three places that faked another face by scaling it -- Family
in edit::added, the paragraph measure in edit::text, and the redaction overlay
fonts -- now select the right face instead. So does Metrics::width in
fonts::pdf, which measures existing document text when a font carries no
/Widths; that path feeds redaction's glyph removal, where a wrong width means
cutting the wrong glyphs.

Tests spot-check the table against values typed in from the specification by
hand, so it is verified against the spec rather than against whatever produced
it; plus Courier being fixed pitch everywhere, full printable-ASCII and Latin-1
coverage, Symbol and ZapfDingbats not being WinAnsi, /BaseFont aliases and
subset prefixes resolving, and hostile text staying finite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 13:57:43 -07:00
Marco Arnulfo 9593ff7419 fix(redact): removing comments keeps non-comment annotations (#1325) (#1375)
Remove Hidden Information counted and removed as a comment every
annotation that was not a link, widget, pop-up, file attachment or
redaction mark, so a Comments-only cleanup also deleted Watermark,
PrinterMark, TrapNet, Screen, Movie, RichMedia and 3D annotations.

Use pdfcraft_annot::is_comment_subtype, which the comments list already
uses, and keep excluding file attachments (removed with Attachments)
and redaction marks as before.

Adds tests::removing_comments_keeps_annotations_that_are_not_comments,
which fails before this change.
2026-10-12 06:27:04 -04:00
Jules Kreuer 47d612817a M8: Clean indirect and form tag references (#1351) 2026-10-12 05:43:12 -04:00
Zeyu Li b91d38c073 M14: text the fonts can't draw is kept and reported, never written as ? silently (#1112)
* M14: text the fonts can't draw is kept and reported, never written as ? silently

Form values and captions, text boxes, callouts, Fill & Sign text, measurement labels and stamp by-lines keep text the standard fonts can't draw, draw ? for it, mark the annotation (PCUndrawn) and report the edit (interface notice, tools' warnings); forms set /NeedAppearances (not in PDF/A). Flattening, redaction overlay text, typed and visible signatures and create-from-text refuse instead. Reports escape such characters as [U+XXXX].

* Catalogs: the two new can't-draw messages in Czech, Slovene, Kazakh, Turkish and Greek; refresh hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:53:55 -04:00
Jeremy Yao b642e327e7 Security: fix 10 verified audit findings in redaction, signature validation, and untrusted-input handling (#1099)
* fix(create): cap decoded size of imported PNGs

A PNG's declared dimensions sized an infallible allocation, so a few-byte file claiming huge dimensions aborted the process when the allocation failed. Cap the decoded size at the TIFF ceiling and reserve every input-sized buffer fallibly, returning CreateError::Image instead. (Security audit run-2: v3-024.)

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

* fix(xfa): bound total picture bytes kept during layout

Each laid-out picture kept its own copy, so one picture in a repeating subform multiplied without bound and could exhaust memory on open. Add a per-layout aggregate cap (256 MiB) that stops with XfaError::TooLarge. (Security audit run-2: v3-103.)

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

* fix(xfdf): bound XML nesting and protect redaction marks on import

Deeply nested XFDF/XML overflowed roxmltree's recursive parser and aborted the process; a non-recursive depth scan now rejects it before parsing. Imported comments could also replace a locked or redaction mark by /NM or add a hidden one; place() no longer replaces locked/Redact annotations and clears the hidden/invisible flags on imported redaction marks. (Security audit run-2: v3-105, v3-111.)

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

* fix(sign): classify post-signing changes by the signed revision's roles

A signed object's modification was classified from labels in the attacker-written new version, so relabelling a signed page /Type /Sig (or giving it /ModDate), or relabelling a drawn stream /ObjStm or /XRef, let a forged page validate as Valid even under DocMDP P=1. Decide an object's role (page content, signature, metadata, file plumbing) from what it was in the signed revision, not its new dictionary. (Security audit run-2: v3-082, v3-083.)

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

* fix(redact): remove redacted content from the saved file, fail closed otherwise

Apply Redactions and Remove Hidden Information left the originals behind: dropped or cleaned image/form XObjects kept their resource names (so the full rewrite saved the originals), an XFA form kept the redacted value in its datasets packet, and vertical-writing text under a mark was never hit by the horizontal removal geometry yet reported success. Prune replaced XObject names the page no longer draws, drop /XFA and /NeedsRendering when redacting an XFA form, and fail closed (RedactError::VerticalText) rather than silently leave vertical text. (Security audit run-2: v3-064, v3-067, v3-062.)

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

* fix(ui-egui): trust the displayed signer, not the document's embedded root

The Signatures panel's one-click 'Add to trusted certificates' trusted the top of the chain the document supplied, installing the author's self-signed root as an anchor instead of the signer shown. Trust the displayed signer certificate. (Security audit run-2: v3-098.)

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

* Use main's decoded-size ceiling for PNGs too; keep the depth tests on main's checker

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 15:09:39 -04:00
snipeTR 9b3b98d2e2 fix(redact): the Email pattern finds internationalized addresses (#901)
The Email pattern of Search & Redact accepted only ASCII letters and
digits, so addresses such as "ayşe.yılmaz@örnek.com.tr", "müller@bücher.de"
or "иван@пример.рф" (RFC 6531 local parts, IDN domains) were not found
and stayed visible after a redaction by pattern.

Letters and digits of any script written with spaces now count in the
local part, the domain and the top-level domain. Scripts written without
spaces (Chinese, Japanese, Korean, Thai, …) still don't, so text that
runs straight into an address, as in "メールはtaro@example.jpまで", is not
swallowed into it. A domain still needs a dot and an alphabetic top-level
domain of two or more letters.
2026-10-10 17:04:41 -04:00
snipeTR d1d003c318 fix(redact): Turkish, German, French and Italian month names in the Dates pattern (#900)
The Dates pattern of Search & Redact knew only English month names, so
"10 Ekim 2026", "3 Mayıs 2025" or "5. Oktober 2026" stayed visible after a
redaction by pattern. It also lowercased with str::to_lowercase, under
which "MAYIS" never equals "mayıs" and "EKİM" grows a combining dot.

Month names are now listed for English, Turkish, German, French and
Italian, with English and Turkish abbreviations ("Şub.", "Ağu"), and are
compared one character at a time with I, i, İ and ı folded to i, so any
case matches. A day may end in a full stop before the month name
("5. Oktober 2026"). Words that only begin like a month ("Martı",
"Ekimde", "Oktoberfest") and month names without a day and year still
don't match.
2026-10-10 17:04:27 -04:00
Márk Bartalos 9cd07e4793 M8: redact lists of words or phrases (protect.redact-word-lists) (#757)
* M8: redact lists of words or phrases

Find text and redact gains "Multiple words or phrases": a list with one word
or phrase per line, typed or imported from a .txt file (Import list…), whose
every match is marked in one undo step. Lists are trimmed, deduplicated and
bounded (1,000 entries of 256 characters; files up to 1 MB are read).
redact_mark takes words and reports matched_words, the marks each entry made.

protect.redact-word-lists is shipped; whole-word and case-sensitive matching
and exporting the list are still missing.

* M8: translate word-list labels into Arabic

The Arabic catalog arrived on main after this branch, so the new Find
text and redact strings were still English and failed
arabic_covers_ui_literals.

* Tests: drop sharp grid renders when the thumbnail will cover the cell

Zooming the organize grid back out left the sharp page raster in place
until a thumbnail texture existed, so the zoom test failed when that
texture had not arrived yet.

* M8: Czech rows for the word-list redaction labels

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:32:09 -04:00
Márk Bartalos bfa65a9bfc M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text (#741)
* M8: redaction code sets (U.S. FOIA, Privacy Act) as overlay text

Redaction properties offers "Custom text" or "Redaction code" under "Use overlay
text". Picking the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and
ticking codes makes new marks carry them as overlay text, in the set's order.
redact_mark takes code_set and codes and rejects unknown sets or codes with the
allowed list.

Also merges a duplicated evidence/notes pair in form.field-list that made
parity/acrobat-features.toml fail to parse on main.

* M8: say why the default redaction code set can't fail

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

* Tests: pin the clock in form_bulk (saves a second apart differed in /ModDate on Windows CI)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:26 -04:00
Brandon Thomas 72d640f9f0 Fix #155: edit text across content stream pieces (#628)
A page's /Contents array is one content stream in pieces that writers may
split between any two tokens (ISO 32000-2 §7.8.2). Edit text parsed each
piece on its own, so an operator whose operands ended one piece and whose
keyword started the next arrived with no operands: a `[(After) -20 (wards)]`
/ `TJ` split made the line vanish from text_lines, text_paragraphs and
text_edit, and from the Edit text tool.

The joined-parse logic the redaction interpreter used (#153) moves into
pdfcraft-content as `Pieces`: join the pieces, parse them as one stream,
map positions back to pieces, and splice joined byte ranges back into
per-piece bytes (an edit replacing or removing an operator that spans
pieces rewrites every piece it touches; untouched pieces keep their
objects). text_lines now interprets the joined operators, and replace_line
and rewrite_block splice through `Pieces`. Redaction uses the same helper.

Lines and paragraphs are grouped by the piece their first operator starts
in, as before, so pages whose pieces are separate drawings don't merge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:57:02 -04:00
Brandon Thomas bd57377435 Fix #525 (item 4): phone pattern redacts international numbers whole (#543)
The Phone Numbers redaction pattern accepted only 10 digits (11 with a
country code), the North American form. For "+49 2151 123456" (12 digits)
the `+` candidate was rejected and the scan then matched "2151 123456",
leaving "+49" visible: a partial redaction in a document the user thinks
is clean.

The pattern now accepts, still grouped by at least one separator:
- E.164 numbers with a leading `+`: 8-15 digits in all;
- national numbers with a trunk 0 followed by 6-14 digits, whose first
  group has at least two digits (so decimals don't match) and which are
  not a day-first date (left to the date pattern);
- North American numbers as before.
A separator may now be followed by `(`, so "+49 (0) 2151 123456" is one
match instead of a partial "(0) 2151 123456".

Refs #525. The other items (page-number and date tokens, stamps) are not
addressed here.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:43:11 -04:00
EthanSuquet 7920f99f7a Redact: operators split across a page's content streams are redacted whole (#153)
* Redact: operators split across a page's content streams are redacted whole

A page's /Contents array is one content stream in pieces, and a writer
may split it between any two tokens (ISO 32000-2 §7.8.2). Some office
and layout exporters do: a TJ array ends one piece and its TJ operator
starts the next, or a marked-content dictionary closes in the next piece.

Redaction parsed each piece on its own, so such an operator was cut off
from its operands. Glyphs shown by a split TJ were never checked against
the marks: Apply drew the black box but left the text in the file, and
Verify reported nothing under the box. When the piece holding the
operands was rewritten for another mark, they were dropped, so the text
after it vanished and the next piece began with an operator without
operands, which other readers reject.

process() now parses the pieces joined, gives each operator back to the
piece its keyword is in, and rewrites every piece a split operator spans.
Pages whose pieces split only between operators are unchanged.

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

* Use the pdfcraft crate names after the rename

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

---------

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 21:12:27 -04:00
Brandon Thomas d3db99818f Rename PrintCraft to PdfCraft (#174)
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:57 -04:00
Brandon Thomas 1434db626a Never crash: deny the panic lints in crates that are already clean
annot, compare, content, cos, export, geom, model, ocr, preflight, print and redact have no
production `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!`. The attribute
keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:12:21 -04:00
Brandon Thomas ab47bf4b9a CI: as_chunks instead of constant-size chunks_exact (clippy 1.99 chunks_exact_to_as_chunks)
Stable clippy 1.99 denies chunks_exact/chunks_exact_mut with a constant
size (32 sites), which failed the test job on main and every open PR.
Behaviour unchanged; MSRV 1.90 has as_chunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:48:43 -04:00
storybored379 0523ccdfdc M8: Redaction drops /PCAdded from rewritten content streams so redacted added text does not stay in the file (#1)
Text added with Edit ▸ Add content keeps its source string in the stream dictionary's /PCAdded.
Applying a redaction rewrote the stream's glyphs but copied the dictionary unchanged, so the
redacted text survived in the saved PDF. The redacted item now stays as plain drawn content and
is no longer editable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:14:56 -04:00
Brandon Thomas 5a70838884 M7: PDF font tables move to the fonts crate with Unicode decoding/encoding (ToUnicode, WinAnsi/MacRoman/Standard, Differences glyph names, subset glyph checks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:42:57 -04:00
Brandon Thomas beb4e14c44 xtask: version test uses gates::root (fixes the xtask test build)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:42:57 -04:00
Brandon Thomas 294cbd6716 M8: Applying redactions cleans the tags: elements of changed marked content lose Alt/ActualText/E, empty marked content leaves them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:48:43 -04:00
Brandon Thomas 17e3eb255d M8: Redaction Properties: overlay text font (Helvetica, Times, Courier), size or auto-size, colour, alignment and repeat, written as /DA, /Q and /Repeat and drawn on apply; overlay fonts are added to the page only when used
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:33:01 -04:00
Brandon Thomas d2926538bd M8.4: Remove Hidden Information and Sanitize Document: scan with per-category counts (metadata, attachments, comments, form fields, hidden text in render modes 3/7 or off the page, hidden layers' content and groups, bookmarks, links/actions/JavaScript, private data); remove chosen categories (fields flattened so values stay visible) or all; full rewrite on save. Dialogs with checkboxes and counts, Sanitize confirmation; doc_hidden_info/doc_remove_hidden tools (59 tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:30:18 -04:00