mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:59:48 +00:00
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
This commit is contained in:
@@ -159,6 +159,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **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-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. 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.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
|
||||
|
||||
@@ -286,6 +286,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
|| (which.contains(&Hidden::Attachments) && s == b"FileAttachment")
|
||||
|| (which.contains(&Hidden::LinksActionsScripts) && s == b"Link")
|
||||
};
|
||||
// Every annotation taken off a page, for the structure tree below.
|
||||
let mut gone: std::collections::HashSet<ObjRef> = std::collections::HashSet::new();
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let list = annots_of(doc, &p.dict);
|
||||
let mut removed: Vec<ObjRef> = Vec::new();
|
||||
@@ -305,6 +307,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
let d = doc.get(r).as_dict().cloned().unwrap_or_default();
|
||||
let parent_gone = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed.contains(&x));
|
||||
if removed.contains(&r) || (d.name(b"Subtype") == Some(b"Popup") && parent_gone) {
|
||||
gone.insert(r);
|
||||
continue;
|
||||
}
|
||||
if which.contains(&Hidden::LinksActionsScripts) && (d.contains(b"AA") || (d.name(b"Subtype") == Some(b"Widget") && d.contains(b"A"))) {
|
||||
@@ -335,6 +338,9 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
}
|
||||
})?;
|
||||
}
|
||||
// A tagged PDF's structure tree still points at the removed links and comments (#2447): drop
|
||||
// those references, or the full rewrite keeps the annotations, URLs and text included.
|
||||
crate::tags::drop_object_refs(doc, &gone)?;
|
||||
let off = hidden_layers(doc);
|
||||
let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default();
|
||||
let mut names = sub(doc, &cat, b"Names");
|
||||
|
||||
@@ -202,3 +202,85 @@ pub(crate) fn clean(
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`.
|
||||
fn refers_to(doc: &Document, o: &Object, gone: &HashSet<ObjRef>) -> bool {
|
||||
doc.resolve(o)
|
||||
.as_dict()
|
||||
.is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r)))
|
||||
}
|
||||
|
||||
/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their
|
||||
/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the
|
||||
/// reference keeps the annotation, its URL or text included, in a full rewrite after the page
|
||||
/// no longer lists it. Returns how many references were dropped.
|
||||
pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet<ObjRef>) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
if gone.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let Some(root) = doc
|
||||
.root()
|
||||
.and_then(|r| doc.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()))
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
let mut todo: Vec<Object> = root.get(b"K").cloned().into_iter().collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut hits: Vec<ObjRef> = Vec::new();
|
||||
while let Some(k) = todo.pop() {
|
||||
match k {
|
||||
Object::Array(a) => todo.extend(a),
|
||||
Object::Ref(r) => {
|
||||
if !seen.insert(r) || seen.len() > 2_000_000 {
|
||||
continue;
|
||||
}
|
||||
let object = doc.get(r);
|
||||
if let Object::Array(a) = &*object {
|
||||
todo.extend(a.iter().cloned());
|
||||
continue;
|
||||
}
|
||||
let Some(d) = object.as_dict() else { continue };
|
||||
if !d.contains(b"S") {
|
||||
continue;
|
||||
}
|
||||
let kids: Vec<Object> = match d.get(b"K").map(|k| doc.resolve(k)) {
|
||||
Some(o) => match &*o {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => d.get(b"K").cloned().into_iter().collect(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
if kids.iter().any(|x| refers_to(doc, x, gone)) {
|
||||
hits.push(r);
|
||||
}
|
||||
todo.extend(kids);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut n = 0;
|
||||
for r in hits {
|
||||
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
|
||||
let kids = d.get(b"K").map(|k| doc.resolve(k));
|
||||
let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_)));
|
||||
let list: Vec<Object> = match kids.as_deref() {
|
||||
Some(Object::Array(a)) => a.clone(),
|
||||
Some(_) => d.get(b"K").cloned().into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let before = list.len();
|
||||
let keep: Vec<Object> = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect();
|
||||
n += before - keep.len();
|
||||
doc.update_dict(r, |e| {
|
||||
if array {
|
||||
e.set(b"K".to_vec(), Object::Array(keep));
|
||||
} else if let Some(k) = keep.into_iter().next() {
|
||||
e.set(b"K".to_vec(), k);
|
||||
} else {
|
||||
e.remove(b"K");
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -631,6 +631,57 @@ fn hidden_information_is_counted_and_removed() {
|
||||
assert!(shown, "the field's value stays visible as page content");
|
||||
}
|
||||
|
||||
/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags
|
||||
/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the
|
||||
/// annotations, the URL and the comment text included, while the page no longer lists them.
|
||||
#[test]
|
||||
fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
// 1 catalog, with a structure tree
|
||||
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
// 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(),
|
||||
stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"),
|
||||
FONT.replace("95 0 R", "13 0 R").into_bytes(),
|
||||
// 6 link, 7 comment
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(),
|
||||
// 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR),
|
||||
// 12 annotation element (OBJR by reference, as K's only kid)
|
||||
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(),
|
||||
widths(),
|
||||
b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(),
|
||||
];
|
||||
let mut doc = pdf(objs);
|
||||
let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap();
|
||||
assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}");
|
||||
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
|
||||
assert!(!has(b"secret.example"), "the removed link's URL survived the save");
|
||||
assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save");
|
||||
// The tagged paragraph and its content stay; the elements lose only the references.
|
||||
let saved = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(content(&saved, 0).contains("(See the site)"));
|
||||
let objrs = saved.object_numbers().into_iter().filter(|&n| {
|
||||
let o = saved.get(ObjRef::new(n, saved.generation(n)));
|
||||
let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"));
|
||||
let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) {
|
||||
Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))),
|
||||
_ => false,
|
||||
});
|
||||
direct || nested
|
||||
});
|
||||
assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left");
|
||||
let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays");
|
||||
}
|
||||
|
||||
/// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia
|
||||
/// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D).
|
||||
#[test]
|
||||
|
||||
@@ -5788,8 +5788,8 @@ milestone = "M8"
|
||||
status = "shipped"
|
||||
commands = ["protect.remove_hidden"]
|
||||
tools = ["doc_hidden_info", "doc_remove_hidden"]
|
||||
evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"]
|
||||
notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending"
|
||||
evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"]
|
||||
notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts (with the structure tree's references to removed links and comments, #2447), private data; overlapping objects and cropped content pending"
|
||||
|
||||
[[feature]]
|
||||
id = "protect.sanitize"
|
||||
|
||||
Reference in New Issue
Block a user