Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |

## Progress log

- **2026-10-16 (M8, PR #2598 review follow-up):** Match processing-instruction data without matching targets; bound fragment collection and total metadata comparison/decoding work, removing scoped metadata on exhaustion. Saved-secret regressions inspect reopened objects and decoded streams, with a compressed positive control. Whole-run matching policy and estimates unchanged.
- **2026-10-16 (M8, PR #2598):** Metadata comparison handles ambiguous TJ word gaps without breaking intra-word kerning, collapses whitespace, and matches decoded XML values rather than markup names. Partial/reordered-name matching and resource limits are unchanged. Estimates unchanged.

- **2026-10-16 (M5, PR #2598 CI):** Pen preview regressions hold raster delivery while checking pending strokes, then wait for delivery by state. Adapted narrowly from #2812; no fullscreen or redaction behavior change. Estimates unchanged.
- **2026-10-15 (M8, #2448):** Applying redactions clears what the marks removed from document metadata too: `/Info` entries whose value repeats a removed text run (case-insensitive) go with their mirrored XMP property, and an XMP stream that still holds removed text where per-entry editing cannot reach — custom schemas, entity-escaped values, page-level packets, or a stream that cannot be decoded and read — is removed whole, like the hidden-information metadata category. Unrelated entries and the rest of an editable packet survive. Text inside images and runs under two characters leave no searchable string, so Remove hidden information (metadata) remains the certain path. `redact` now uses `organize` (registered as a sideways edge). Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
Expand Down
28 changes: 28 additions & 0 deletions crates/automation/tests/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3295,6 +3295,34 @@ fn redacting_through_tools() {
assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
}

/// Issue #2448: applying redactions clears document information entries that repeat the redacted
/// text; unrelated entries stay, and the saved file keeps no trace of the secret.
#[test]
fn redacting_clears_matching_document_info_through_tools() {
let dir = workdir("redact-info");
std::fs::write(dir.join("memo.txt"), "Project Nimbus clearance is TOPSECRETX today\nPublic line").unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "TOPSECRETX clearance memo" }));
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Author", "value": "Ada" }));
ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "TOPSECRETX" }));
ok(&mut a, "redact_apply", json!({ "doc": doc }));
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
assert_eq!(info["title"].as_str(), None, "the title repeating redacted text is gone");
assert_eq!(info["author"].as_str(), Some("Ada"), "unrelated entries stay");
let text = page_text(&mut a, doc)[0].clone();
assert!(!text.contains("TOPSECRETX") && text.contains("Public line"), "{text}");
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "memo.pdf" }));
let bytes = std::fs::read(dir.join("memo.pdf")).unwrap();
assert!(!bytes.windows(10).any(|w| w == b"TOPSECRETX"), "the saved file has no trace of the secret");
// A full write may pack /Info into a compressed object stream, so survival is checked by
// opening the saved file, not by scanning its bytes.
let saved = ok(&mut a, "doc_open", json!({ "path": "memo.pdf" }))["doc"].as_u64().unwrap();
let info = ok(&mut a, "doc_info", json!({ "doc": saved }));
assert_eq!(info["author"].as_str(), Some("Ada"), "unrelated metadata survives in the saved file");
assert_eq!(info["title"].as_str(), None, "the secret title is gone from the saved file");
}

#[test]
fn redacting_with_codes_through_tools() {
let dir = workdir("redact-codes");
Expand Down
3 changes: 3 additions & 0 deletions crates/redact/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ pdfcraft-forms = { workspace = true }
pdfcraft-edit = { workspace = true }
pdfcraft-annot = { workspace = true }
pdfcraft-xfa = { workspace = true }
pdfcraft-organize = { workspace = true }
thiserror = { workspace = true }
# Already present in the lockfile: inspect XML values without searching markup names.
roxmltree = "0.20"

[dev-dependencies]
pdfcraft-testkit = { workspace = true }
78 changes: 77 additions & 1 deletion crates/redact/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pdfcraft-redact

Layer L4 (may use `edit`): redaction, architecture §11.2.
Layer L4 (may use `edit`, `organize`): redaction, architecture §11.2.

```rust
// Marks are Redact annotations (pdfcraft-annot: Shape::Redact { quads, overlay }).
Expand All @@ -27,13 +27,89 @@ clear_marks(&mut doc, None)?; // remove marks without app
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a
widget under a mark;
- **document metadata** that repeats removed text: `/Info` entries whose value contains a removed
text run (case-insensitive) are cleared — through `pdfcraft_organize::set_info`, which also
removes the XMP property mirroring each entry (`dc:title`, `dc:creator`, `dc:description`,
`pdf:Keywords`, `xmp:CreatorTool`, `pdf:Producer`) while leaving the rest of the packet
byte-for-byte intact;
- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
page as a `/PCMark /Redaction` stream.

A verification pass re-reads each redacted page and fails the operation if any glyph or inline
image is still under an area. Content that can't be decoded makes the operation fail rather than
leave it unredacted.

## Metadata redaction (#2448)

What the marks removed must not survive in metadata either. The interpreter decodes every glyph
run it cuts out into *fragments*: maximal runs that join across `TJ` kerning numbers (spacing
inside one text run: no space, no split) **and operators** — text drawn one glyph per operator,
as many generators do, still collects as one run — whenever the next removed glyph starts within
1.5 glyph heights of where the last one's advance (character and word spacing included) landed
in device space; a repositioning gap past 0.15 heights becomes a space, and kept glyphs, codes
with no Unicode meaning, and page ends still split runs.
Forward `TJ` displacements can also encode word spacing. Those gaps are optional spaces during
metadata comparison, retaining the unspaced interpretation for intra-word kerning. Mixed kerns
and word gaps are compared without enumerating combinations; literal spaces remain required.
After verification, fragments and values are lowercased with internal whitespace collapsed.
Each `/Info` string entry containing a complete fragment of two characters or more — or whose
complete value is contained in a fragment — is cleared with its mirrored XMP property. The
author of a whole redacted sentence is removed text too. Then every catalog and page
`/Metadata` stream is parsed as XML and checked value by value (decoded text, comments,
attribute values and processing-instruction data). Tag names, attribute names, namespace declarations
and processing-instruction targets are excluded. XML entities and CDATA are decoded by the parser. A packet is removed whole
when a fragment or part of one survives in a value — custom schemas cannot be
edited per-entry — or when the stream cannot be decoded and read as UTF-8 within 16 MiB or parsed
as XML. DTDs are unsupported and take this conservative whole-packet fallback. A
trailer `/Info` that is a direct dictionary (the one shape an entry clear cannot edit) is
removed whole when it still matches. All of this happens before the forced full save, so the
previous revision's bytes leave the file.

The match errs towards clearing, in both directions:

- **over-clearing**: a fully redacted common word is a fragment, and an unrelated entry or packet
merely containing it goes too; glyphs removed under *different* marks also join when they land
within 1.5 em of each other in stream order, so two one-letter removals can form a two-character
needle; the wholesale fallback drops a whole stream for one custom-schema hit. A short complete
XML value such as language `en` or version `1.7` can still match part of a longer removed run;
- **under-matching**: text inside images never becomes a fragment, runs shorter than two
characters are ignored, fragments split at gaps wider than 1.5 em and carry narrower
repositioning gaps as spaces, so a value that spells the same text with or without one of those
spaces matches only the spelling the run collected — only glyphs shown in stream order at
adjacent positions join, so overprint-interleaved, character-batched or logical-order RTL
generators still leave one-character runs, and codes with no Unicode meaning still split
them — and `/Metadata` on component objects (images, fonts, form XObjects) is not scanned — the
same scope as Remove hidden information.

Matching still requires a complete removed fragment inside a value, or a complete value inside
a fragment. Partial phrases such as Title `Case: James Bond` after redacting `JAMES BOND IS A SPY`,
reordered names such as `Smith, John` versus `John Smith`, and Unicode normalization remain
unsupported. A whole removed `James Bond` still matches `Case: James Bond`; the unsupported case
is a subphrase of a longer removed run. No word-term deletion is performed: unrelated Subject
`Spy stories` stays during selective matching.

Collection is limited across all selected pages to 1,024 runs, 16 KiB per run and 256 KiB of
run text in total, checked before appending (optional-gap copies have the same bounds).
Metadata comparison has one shared budget of 8 Mi work units, including failed prefix attempts
in repetitive text, value normalization, key scanning and XML inspection. Each prefix character
costs 16 units, including its optional-gap lookup; scanning a string costs its byte length,
and normalization/XML input costs four units per byte. A value is limited to 1 MiB and an
information dictionary to 1,024 entries. Encoded packet bytes are charged before decoding, which
is limited before allocation by the remaining allowance as well as the existing 16 MiB ceiling.
Decoding failures conservatively exhaust the allowance because the decoder does not expose
consumed work. These are bounds on this metadata
pass, not on the whole PDF or the process's memory.

If collection, a value or the shared work/decoding allowance exceeds its limit, the pass removes
**all** `/Info` and catalog/page `/Metadata`, including unrelated values and earlier clean packets.
It never silently truncates needles or treats unfinished matching as clean. The library report's
`metadata_cleared_on_limit` records this conservative fallback, alongside the actual removal counts;
the normal full rewrite removes retired objects. This does not expand the scan to component-level
metadata or change the partial-name matching policy.

Remove hidden information (metadata category, `doc_remove_hidden`) is therefore the certain
follow-up when a document's metadata must be provably free of the redacted text.

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
Expand Down
Loading
Loading