* redact: add the independent redaction proof machinery * redact: harden the proof's paint check and count removed codes The manifest counts character codes, not raw bytes: a composite font's codes are several bytes, so the byte length overstated what was cut. Annotation appearances are checked whatever their storage (a direct dictionary has no reference of its own to dedupe on), and a stream shared by several annotations is checked at each annotation's own placement. The graphics-state stack and the clip holes of one walk are bounded; deeper or more is unverifiable, never unbounded memory. Even-odd clips cut holes only where the rule can be told cheaply - all subpaths axis-aligned, disjoint, few - and anything else keeps its paint reported, failing closed. The /W widths of a descendant font cost bounded work, like a CMap. * redact: settle the survivors a page sweep counted before its work ran out A page that the search budget stops on left the counts made on earlier pages unsettled: the survivors were seen but never reported, and the regions that owned them were never marked. The sweep now stops and still settles, so a located survivor is reported where it sits (the page reads unswept either way, and the proof still fails). * redact: prove the redacted output of every supported encryption The authenticated-reopen regression now covers all four crypt algorithms the writer supports, not only AES-128: the caller's credentials prove the saved output, and empty or wrong credentials still fail closed. * redact: answer the review round on the proof A glyph lying under several regions is now accounted for by every one of them, so overlapping marks each carry their own share in a shared proof instead of the first one absorbing the rest and the others reading as if no text was there. Each finding is recorded once per region as it is found, so a page that paints over a region a thousand times neither grows the report nor the memory with repeats. The byte search walks the haystack once (the pattern's prefix function is built once), so a needle of one letter over a haystack full of it can no longer spend comparisons the search budget never charged for. A numeric XML character reference may carry any number of leading zeros; the padded form now decodes like the short one, so a survivor spelled that way in the XMP is still found. The /W widths of a descendant font are paid for from the shared work budget: a form invoked again rebuilds its fonts, and a per-font counter alone let the same array be paid for many times over. * redact: sweep the proof's survivors out of every place a reader could look Plant the removed text where the review listed it and expect the sweep to report it: a compressed object stream, a page thumbnail, a form field value, an XFA packet, and an object nothing refers to. A file whose page content is bigger than the proof may decode for one stream is now shown to come out unverified on its surfaces and its region, never clean. * redact: spare the names a redacted word legitimately sits in, and charge failed decodes A redacted word that is also a structural name (a /Subtype, a /Filter, a /BaseFont) is structure, not a leftover copy: the name surface is now swept against the matches the input itself had, and only a surplus of matches is a survivor. The input's names are counted when the snapshot is taken, for the region's needles and for strings added later alike. A decode that fails after doing part of its work paid nothing: the proof's cumulative decode allowance now charges every attempt, failures included, so the same bomb cannot be decoded (and fail) again and again for free. * redact: count the input's names once each, and say so when the count runs out The name allowance counted one entry per occurrence, so a document whose pages repeat the same structural name paid for the same scan again and again; names are now kept once, with their count. A name search that runs out of work read as no matches, which would have swept names with none allowed: that is reported on the text surface now, and the proof fails rather than pass on an uncounted allowance. * Bound redaction proof searches across many needles Assisted-by: OpenAI Codex * Redaction proof: drop find_all, which nothing calls Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cargo.lock: put aho-corasick in its sorted place Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Redaction proof: review notes A word split across a TJ array is looked for whole. Overlapping marks count a removed copy once. A spent decode budget is a problem, not a raw-only stream. Short needles stay out of the attachment text search, and an indirect /Decode counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> Co-authored-by: FredApps <FredApps@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
pdfcraft-redact
Layer L4 (may use edit): redaction, architecture §11.2.
// Marks are Redact annotations (pdfcraft-annot: Shape::Redact { quads, overlay }).
let marks: Vec<Mark> = marks(&doc); // page, areas (user space), fill, overlay text
let report = apply(&mut doc, None)?; // everything, or Some(&[pages])
clear_marks(&mut doc, None)?; // remove marks without applying
apply removes, under every marked area:
- text: glyphs are cut out of
Tj/TJ/'/"and replaced by aTJdisplacement of the same advance, so the rest of the line keeps its exact position. Widths come from/Widths,/W//DW(composite fonts, embedded CMaps with codespace and CID ranges), Type 3 font matrices, and approximations for the standard 14. A glyph goes when it overlaps an area by more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is inside; - images: fully covered → removed; partly covered → a copy with the covered pixels cleared
(8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as
the last filter, decoded through
Document::decoded_image_within, a JBIG2 image with its/JBIG2Globals;/DecodeParmsgiven by reference, numbers inside included, count). The copy is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), those of marks under the area included, so every other image that uses the globals of a removed or replaced image is decoded and stored again in place without them, as a copy is (Flate, Group 4 or JBIG2, whichever is smallest;Report::images_restored), and they leave the file on the full rewrite; one that can't be decoded for certain refuses the redaction (RedactError::SharedJbig2Globals). Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (/K0 or more,/EncodedByteAlign): PdfCraft's renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate/SMaskor/Maskdata, and images whose data decodes to more than three times their declared raster and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop/Alternatesso alternate pixels cannot survive; A run that would decode more of its images than one redaction may (pdfcraft_cos::ImageBudget, filters, decoders and pixels alike) is refused and changes nothing: such an image can't be cleared, and removing it whole would take what no mark covers with it. - vectors: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas;
- form XObjects: rewritten recursively into new objects (pages sharing the original keep it);
- annotations whose rectangle overlaps (with their pop-ups and their replies,
/IRT), and form fields with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
page as a
/PCMark /Redactionstream.
A verification pass re-reads each redacted page and fails the operation if any glyph or inline
image is still under an area. It checks every content stream the page had, whatever its
dictionary calls it; only the overlay streams this apply added (Report::overlays) are skipped.
Text is placed with the font and size set by Tf or by an ExtGState's /Font through gs, and a
form XObject starts from the text state of the content that draws it. Content that can't be
decoded makes the operation fail rather than leave it unredacted, and so does an inline image the
renderer's tokenizer reads no image for (RedactError::Unreadable): where the page's operators
resume after it would be a guess. A Redact annotation with neither a usable /QuadPoints array nor a usable
/Rect makes it fail with RedactError::UnreadableMark before anything changes, instead of
skipping that mark. The count covers the marks on the pages being applied.
Before changing any page, apply prepares the redaction appearances with a shared 8 MiB
budget for their uncompressed PDF content across the selected pages. It bounds repeated-text
counts and working line buffers before allocating them, and measures and encodes each repeated
line once. Invalid or excessive appearances return RedactError::OverlayLimit without changing
the document. Reduce the marked area, disable repeated text, or apply fewer pages at a time.
This limits appearance generation; it is not a limit on the input PDF or the whole process's memory.
Structure cleanup resolves named MCIDs and indirect content references. Form content
references are updated to the cleaned copy; unchanged sibling descriptions remain. The structure
tree's object references (/OBJR) to the annotations and form fields apply removes are dropped,
as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed comment or field value.
A tree where that can't be done (an element written in place, or one too large to walk) makes
apply fail with RedactError::Tags rather than leave the annotation reachable.
Replacement descriptions (/Alt, /ActualText, /E) are removed from marked-content spans
changed by cleanup, from every structure element above them (found through both /P and /K),
and from elements that lose an annotation reference, which also lose /T. An element written in
place above one whose descriptions go makes apply fail with RedactError::Tags. Unchanged
spans retain their descriptions; unused property entries are retired during redaction
and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still
used by surviving pages or forms are kept.
A redacted page that inherits /Resources from the page tree gets them pinned (by reference;
a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's
/Resources is then retired unless another page still inherits it, so it cannot keep a removed
XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own
content doesn't draw, for the same reason. Pages that aren't redacted are left untouched.
Remove hidden information / Sanitize
let found = sanitize::scan(&doc); // [(Hidden, count)] per category
sanitize::remove_hidden(&mut doc, &[Hidden::Comments])?; // chosen categories
sanitize::sanitize(&mut doc)?; // all of them
Categories: metadata (/Info, XMP), file attachments, comments, form fields (flattened so the
values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers
(content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript,
and /PieceInfo private data. XMP metadata, private data, associated files (/AF) and
additional actions (/AA) are removed from every object reachable from the catalog that owns
them (images, fonts, Forms, annotations and fields as well as the catalog and pages), never from
name maps such as resource dictionaries. Content is read strictly: a page whose content can't be
read in full is refused, not cleaned in part. Both make the next save a full rewrite.
patterns finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social
Security numbers and dates for Search & Redact.
A phone label such as Tel. or Fax: also enables Italian international geographic numbers with a slash, such as Tel. +39 0426/123456 or Fax: +390426/123456 (#3205 follow-up).
The country code and national number are matched together, with the significant initial 0 and 6 to 11 national digits described in the AGCOM numbering plan published by ITU.
This narrow case accepts at most one space after +39 and one slash immediately after the area code; international slash numbers without a phone label, or with country codes other than +39, remain unsupported.
Patterns are heuristic: dates, protocol identifiers and longer number runs are retained, and users still need to review the marks before applying them.
codes holds the redaction code sets (U.S. FOIA and U.S. Privacy Act exemptions):
CodeSet::from_id("foia")?.overlay(&["(b)(6)"]) gives the overlay text for the picked codes.
Custom code sets aren't supported yet.
Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed instead).