diff --git a/ROADMAP.md b/ROADMAP.md index 66b967756..36de00c77 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 64d6ceefc..75b8c89be 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -286,6 +286,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = std::collections::HashSet::new(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); let mut removed: Vec = Vec::new(); @@ -305,6 +307,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Result) -> 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) -> Result { + 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 = root.get(b"K").cloned().into_iter().collect(); + let mut seen = HashSet::new(); + let mut hits: Vec = 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 = 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 = 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 = 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) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b9a230fc4..f9e08d35d 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -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![ + // 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] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b54..65e7afcc4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"