* M11: JBIG2 images share a symbol dictionary, up to 16 at a time With Monochrome Images set to JBIG2, Optimize stored each image's letters in a symbol dictionary of its own, so a document of N pages carried its font's shapes N times. Now up to 16 images the run stores as JBIG2 share one dictionary in a /JBIG2Globals stream, holding the marks found in more than one of them; each image's text regions refer to it first, then to a dictionary of its own for the marks only it repeats. An image is stored that way when it comes out smaller, and a group only when what its images save is more than the globals take. - filters: encode_jbig2_shared codes a group: the globals are a symbol dictionary segment numbered 0 and associated with no page (7.2.6); each page numbers its segments from 1, as a segment refers only to lower numbers (7.2.5), and the shared symbols' IDs come first, as the dictionaries are referred to in that order (Table 31, SBSYMS). At most 16,383 shared marks, those in the most images, so a page keeps room for its own under the decoder's 32,767. - optimize: the run holds the 1-bit samples of the images it stores as JBIG2 (16 at most, 64 MiB at most) and stores them again sharing, weighing each with the longest reference the globals could have. Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier, and 46% in Helvetica at 600 dpi; groups of 2, 4, 16 and 32 save 19%, 33%, 45% and 47%. Redacting one image stores the others of its group again without the dictionary, so 16 keeps that to 15 images. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: test what the mutants found untested in shared JBIG2 dictionaries - filters: the group goes through encode_jbig2_shared, which refuses it for an image the encoder refuses; two marks in common are enough to share; past the 65,536 distinct marks a group holds, a mark first seen later isn't shared while those held count every image. - optimize: optimizing again keeps both the shared pages and the pages coded alone as they are; under Retain, averaged JBIG2 pages share nothing; a last group of two shares. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: only images redaction can store again share a JBIG2 dictionary Redacting one image of a group stores the others again without the dictionary, which can hold the shapes of marks under the area, and refuses the redaction for one it can't: it takes image masks and images of one component as its own reading counts them (no DeviceN, the family named directly), and at most 64 Mi pixels. Optimize took DeviceN of one colourant and up to 80 million pixels, so such an image in a group made any redaction of the others fail, where before it was coded alone and the same redaction worked (the Consumers review). Only an image mask or a DeviceGray image named directly, of at most 2^26 pixels, now joins a group; others are coded alone. An engine test optimizes three pages, two DeviceGray and one DeviceN, and redacts the first: the DeviceN page stays alone, the second is stored again, and no image names the dictionary after a save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: optimize and redaction share one limit on the images redaction stores again The second Consumers review: Optimize's limit on images that share a JBIG2 dictionary copied redaction's private 64 Mi, with nothing tying the two, so a lower one in redaction would bring back refused redactions with no test going red. Both now read pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS. The engine test adds an image mask, the other kind that shares, so a redaction that stopped storing either kind again fails it. doc_optimize's description, the README, the parity note and ROADMAP say which images share (named directly). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * M11: a JBIG2 group is coded before its samples would pass the limit CodeRabbit on #5910: hold added an image to the group and then checked SHARED_BYTES, so a group could pass the 64 MiB it is documented to stay within by its last image, which can itself be up to 64 MiB (an image that shares has at most 2^26 pixels, and as its rows are whole bytes, at most that many bytes; a 1-pixel-wide one takes them all). hold_in codes the group first when an image would take it past the limit, and after when it has 16 images; with room for 10 bytes, images of 4 go two to a group, where the old order made one of 12. CodeRabbit's follow-up too: the 18-image test decodes every image and compares it with its samples, across the boundary between the groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY * optimize: count the globals' object framing; filters: test the shared coder's size limit - A JBIG2 group shares a dictionary only when it saves more than the globals' dictionary and data plus NEW_OBJECT (96 bytes: the object's framing, its xref entry, and an encrypted file's IV and padding), so a group that saves less than that no longer makes the file larger. - encode_jbig2_shared is Unsupported for an image taller than the decoder takes and codes one at the bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * filters: drop the unused rows binding in encode_shared page() no longer takes the rows since #5511. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * filters: the shared coder's at-bound test says what it returns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
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).