mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
5 files changed
+197
-1
No files matched your search
@@ -403,6 +403,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
|
||||
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
|
||||
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
|
||||
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
|
||||
|
||||
@@ -36,6 +36,11 @@ leave it unredacted.
|
||||
Structure cleanup resolves named MCIDs and indirect content references. Form content
|
||||
references are updated to the cleaned copy; unchanged sibling descriptions remain.
|
||||
|
||||
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. Pages that aren't redacted are left untouched.
|
||||
|
||||
## Remove hidden information / Sanitize
|
||||
|
||||
```rust
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
pub mod codes;
|
||||
@@ -272,6 +274,97 @@ fn overlay_content(marks: &[&Mark]) -> Vec<u8> {
|
||||
c
|
||||
}
|
||||
|
||||
/// The nearest ancestor of `page` that holds `/Resources` (where an inheriting page gets them):
|
||||
/// `Some(None)` when none does, `None` when the chain can't be followed (a cycle, a broken node or
|
||||
/// a tree deeper than any real one), so callers can stay conservative.
|
||||
fn resource_source(doc: &Document, page: ObjRef) -> Option<Option<ObjRef>> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut parent = doc.get(page).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref);
|
||||
while let Some(r) = parent {
|
||||
if !seen.insert(r) || seen.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
let node = doc.get(r);
|
||||
let d = node.as_dict()?;
|
||||
if d.contains(b"Resources") {
|
||||
return Some(Some(r));
|
||||
}
|
||||
parent = d.get(b"Parent").and_then(Object::as_ref);
|
||||
}
|
||||
Some(None)
|
||||
}
|
||||
|
||||
/// Before rewriting `selected` pages, pin the resources they inherit onto them, and retire the
|
||||
/// ancestor `/Resources` they came from once no other page still inherits it, so the parent can't
|
||||
/// keep a removed XObject reachable. Pages that aren't redacted are left untouched; an inherited
|
||||
/// dictionary that is direct becomes one shared indirect object, never a copy per page.
|
||||
fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfcraft_cos::CosError> {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let inherits = |doc: &Document, p: &pdfcraft_model::Page| doc.get(p.obj).as_dict().is_some_and(|d| !d.contains(b"Resources"));
|
||||
// Ancestor → the reference its resources are shared through, and whether that reference is new.
|
||||
let mut shared: HashMap<ObjRef, (Object, bool)> = HashMap::new();
|
||||
for page in selected.iter().filter_map(|&i| pages.get(i)) {
|
||||
// What the page tree gives the page; nothing to pin if it gets nothing.
|
||||
let Some(effective) = page.dict.get(b"Resources").cloned() else { continue };
|
||||
if !inherits(doc, page) {
|
||||
continue;
|
||||
}
|
||||
let source = resource_source(doc, page.obj).flatten();
|
||||
let original = source.and_then(|s| doc.get(s).as_dict().and_then(|d| d.get(b"Resources").cloned()));
|
||||
let value = match source.filter(|_| original.as_ref() == Some(&effective)) {
|
||||
// `/Parent` agrees with the page tree: share one reference per ancestor.
|
||||
Some(source) => match shared.get(&source) {
|
||||
Some((v, _)) => v.clone(),
|
||||
None => {
|
||||
// A direct dictionary is made indirect once, so every pinned page shares it.
|
||||
let (v, new) = match effective {
|
||||
Object::Ref(_) => (effective, false),
|
||||
direct => (Object::Ref(doc.add(direct)), true),
|
||||
};
|
||||
shared.insert(source, (v.clone(), new));
|
||||
v
|
||||
}
|
||||
},
|
||||
// A malformed tree whose `/Parent` disagrees: pin this page alone and retire nothing.
|
||||
None => match effective {
|
||||
Object::Ref(_) => effective,
|
||||
direct => Object::Ref(doc.add(direct)),
|
||||
},
|
||||
};
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), value))?;
|
||||
}
|
||||
if shared.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Ancestors some page still inherits from keep their resources (that page draws them), through
|
||||
// the shared reference; the others are retired. A page that inherits resources through a chain
|
||||
// that can't be followed might inherit from any of them, so then none is retired.
|
||||
let mut still_used: HashSet<ObjRef> = HashSet::new();
|
||||
for page in pages.iter().filter(|p| p.dict.contains(b"Resources") && inherits(doc, p)) {
|
||||
match resource_source(doc, page.obj) {
|
||||
Some(Some(source)) => {
|
||||
if shared.contains_key(&source) {
|
||||
still_used.insert(source);
|
||||
}
|
||||
}
|
||||
_ => still_used.extend(shared.keys().copied()),
|
||||
}
|
||||
}
|
||||
for (source, (value, new)) in shared {
|
||||
if still_used.contains(&source) {
|
||||
if !new {
|
||||
continue;
|
||||
}
|
||||
doc.update_dict(source, |d| d.set(b"Resources".to_vec(), value))?;
|
||||
} else {
|
||||
doc.update_dict(source, |d| {
|
||||
d.remove(b"Resources");
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved
|
||||
/// file; callers keep the previous document for undo.
|
||||
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
|
||||
@@ -293,6 +386,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
by_page.sort_unstable();
|
||||
by_page.dedup();
|
||||
report.pages = by_page.len();
|
||||
localize_resources(doc, &by_page)?;
|
||||
let mut doomed_fields: Vec<String> = Vec::new();
|
||||
let widget_owner: Vec<(ObjRef, String)> =
|
||||
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
|
||||
|
||||
@@ -726,3 +726,99 @@ fn form_tags_are_cleaned_and_surviving_references_point_at_the_copy() {
|
||||
assert!(String::from_utf8(s.decoded().unwrap()).unwrap().contains("Public"));
|
||||
assert!(doc.get(ObjRef::new(11, 0)).as_dict().unwrap().contains(b"ActualText"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_resources_do_not_keep_the_original_form() {
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (LEAK_INHERITED) Tj ET");
|
||||
let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
|
||||
let resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().clone();
|
||||
doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap();
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.remove(b"Resources");
|
||||
})
|
||||
.unwrap();
|
||||
mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
assert_no_secret(&doc, b"LEAK_");
|
||||
assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unselected_pages_keep_inherited_forms() {
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET");
|
||||
let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
|
||||
let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
|
||||
let resources = page.remove(b"Resources").unwrap();
|
||||
let second = doc.add(Object::Dict(page));
|
||||
doc.update_dict(ObjRef::new(2, 0), |d| {
|
||||
d.set(b"Resources".to_vec(), resources);
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(2));
|
||||
})
|
||||
.unwrap();
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
d.remove(b"Resources");
|
||||
})
|
||||
.unwrap();
|
||||
mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], "");
|
||||
apply(&mut doc, Some(&[0])).unwrap();
|
||||
let mut saved = reopen(&doc);
|
||||
assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0);
|
||||
assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6);
|
||||
}
|
||||
|
||||
/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root.
|
||||
fn inheriting_pages(n: usize) -> (Document, Vec<ObjRef>) {
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET");
|
||||
let mut doc = one_page(b"/Fm Do", "/XObject << /Fm 7 0 R >>", vec![form]);
|
||||
let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
|
||||
let resources = page.remove(b"Resources").unwrap();
|
||||
doc.set(ObjRef::new(3, 0), Object::Dict(page.clone()));
|
||||
let mut kids = vec![ObjRef::new(3, 0)];
|
||||
kids.extend((1..n).map(|_| doc.add(Object::Dict(page.clone()))));
|
||||
doc.update_dict(ObjRef::new(2, 0), |d| {
|
||||
d.set(b"Resources".to_vec(), resources);
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect()));
|
||||
d.set(b"Count".to_vec(), Object::Int(n as i64));
|
||||
})
|
||||
.unwrap();
|
||||
let saved = reopen(&doc);
|
||||
(saved, kids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacting_one_page_leaves_the_other_pages_untouched() {
|
||||
let (mut doc, kids) = inheriting_pages(50);
|
||||
mark(&mut doc, 0, &[[0.0, 190.0, 300.0, 220.0]], "");
|
||||
let before = doc.modified_objects();
|
||||
apply(&mut doc, Some(&[0])).unwrap();
|
||||
let changed: Vec<u32> = doc.modified_objects().into_iter().filter(|n| !before.contains(n)).collect();
|
||||
for kid in &kids[1..] {
|
||||
assert!(!changed.contains(&kid.num), "unredacted page {kid:?} was rewritten");
|
||||
}
|
||||
// The page, its new content and resources, the cleaned form, the parent and one shared
|
||||
// resources object: not one object per page.
|
||||
assert!(changed.len() < 10, "{changed:?}");
|
||||
// The parent keeps the resources the other pages draw with, through a single shared object.
|
||||
let parent = doc.get(ObjRef::new(2, 0)).as_dict().unwrap().clone();
|
||||
assert!(parent.get(b"Resources").and_then(Object::as_ref).is_some());
|
||||
let mut saved = reopen(&doc);
|
||||
assert_eq!(under(&mut saved, 0, &[[0.0, 190.0, 300.0, 220.0]]), 0);
|
||||
assert_eq!(under(&mut saved, 49, &[[0.0, 190.0, 300.0, 220.0]]), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacting_every_inheriting_page_retires_the_parent_resources() {
|
||||
let (mut doc, kids) = inheriting_pages(20);
|
||||
for page in 0..kids.len() {
|
||||
mark(&mut doc, page, &[[0.0, 190.0, 300.0, 220.0]], "");
|
||||
}
|
||||
apply(&mut doc, None).unwrap();
|
||||
assert!(!doc.get(ObjRef::new(2, 0)).as_dict().unwrap().contains(b"Resources"));
|
||||
assert_no_secret(&doc, b"SECRET");
|
||||
let mut saved = reopen(&doc);
|
||||
for page in 0..kids.len() {
|
||||
assert!(pdfcraft_model::pages(&saved)[page].dict.contains(b"Resources"));
|
||||
assert_eq!(under(&mut saved, page, &[[0.0, 190.0, 300.0, 220.0]]), 0);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -93,7 +93,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
|
||||
Reference in new issue
Block a user