From fd653e46ac5e0493df37ea437300ef9fa250bbe5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:31:49 +0800 Subject: [PATCH] M8: redaction removes Info entries and XMP that repeat the redacted text (#2448) Applying redactions removed the glyphs under the marks but left the same words in the document information dictionary and the XMP metadata. The redaction interpreter now collects the removed glyphs' text in runs by position. After verification, /Info entries containing a run (or one of its longer words) are removed, and the catalog's and pages' XMP streams whose text mentions one are removed whole; unreadable or oversized XMP fails closed. Report::metadata counts what went. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/redact/README.md | 8 +- crates/redact/src/interp.rs | 54 ++++++++- crates/redact/src/lib.rs | 18 ++- crates/redact/src/metadata.rs | 214 ++++++++++++++++++++++++++++++++++ crates/redact/src/tests.rs | 78 +++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 crates/redact/src/metadata.rs diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..3b207390bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -324,6 +324,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. +- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. 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-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are 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. diff --git a/crates/redact/README.md b/crates/redact/README.md index 34c9f4fe1f..55c0e02f85 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -29,7 +29,13 @@ clear_marks(&mut doc, None)?; // remove marks without app - **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 /Redaction` stream. + page as a `/PCMark /Redaction` stream; +- **metadata** that repeats the removed text (#2448): `/Info` entries containing it, and the + catalog's and pages' XMP streams whose text (element content and attribute values, entities + decoded) contains it, removed whole; unreadable XMP goes too. The removed glyphs' text is + collected in runs by position, and each run (two or more letters or digits) and each of its + words of three or more are matched without regard to case or spacing. `Report::metadata` + counts what went. 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 diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ac69c73acb..42e2045aab 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -46,6 +46,9 @@ pub(crate) const MAX_DEPTH: usize = 50; // A form a reader still shows must be walkable here, or a redaction removes content that is // visible; the build says so rather than a test, so the two limits cannot drift apart. const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); +/// Bounds on the removed text collected for the metadata check (#2448). +const MAX_RUNS: usize = 100_000; +const MAX_RUN_BYTES: usize = 64 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -122,6 +125,13 @@ pub(crate) struct Scope<'a> { xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, + /// Apply: the text of the glyphs removed under the regions, one entry per run of adjacent + /// glyphs (#2448), so the document's metadata can be checked for it. Call [`Scope::flush`] + /// after processing to close the last run. + pub removed_text: Vec, + /// The run being collected, and where its last glyph ended (device space) with its height. + run: String, + run_end: Option<((f64, f64), f64)>, fonts: HashMap, Rc>, used_names: Vec>, depth: usize, @@ -146,6 +156,9 @@ impl<'a> Scope<'a> { ocmd_cache: HashMap::new(), xobject_oc_cache: HashMap::new(), layer_blocks: 0, + removed_text: Vec::new(), + run: String::new(), + run_end: None, fonts: HashMap::new(), used_names: Vec::new(), depth: 0, @@ -153,6 +166,35 @@ impl<'a> Scope<'a> { } } + /// Close the run of removed text being collected. + pub fn flush(&mut self) { + self.run_end = None; + let run = std::mem::take(&mut self.run); + if !run.trim().is_empty() && self.removed_text.len() < MAX_RUNS { + self.removed_text.push(run); + } + } + + /// Note a removed glyph's text: `origin` and `end` are where it starts and ends in device + /// space, `height` its size there. A glyph that starts where the last one ended continues the + /// run, one a little further along the line is a new word of it, and anything else starts a + /// new run, so separate marks don't join their text. + fn removed(&mut self, text: &str, origin: (f64, f64), end: (f64, f64), height: f64) { + if let Some((last, h)) = self.run_end { + let gap = (origin.0 - last.0).hypot(origin.1 - last.1); + let h = h.max(height); + if !(gap.is_finite() && h.is_finite()) || gap > 1.5 * h { + self.flush(); + } else if gap > 0.15 * h && !self.run.ends_with(char::is_whitespace) { + self.run.push(' '); + } + } + if self.run.len().saturating_add(text.len()) <= MAX_RUN_BYTES { + self.run.push_str(text); + } + self.run_end = Some((end, height)); + } + /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { self.hidden_text.is_some() || self.layers_enabled @@ -641,7 +683,7 @@ fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], process /// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` /// items with the glyphs under a region replaced by displacements, and how many were removed. -fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { +fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { let f = &gs.font; let size = gs.size; let mut out: Vec = Vec::new(); @@ -679,6 +721,16 @@ fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec scope.glyph_hit(b, origin), }; + if scope.mode == Mode::Apply && scope.hidden_text.is_none() { + if hit { + let end = trm.apply(w0, 0.0); + let top = trm.apply(0.0, 1.0); + let height = (top.0 - origin.0).hypot(top.1 - origin.1); + scope.removed(f.text_of(code).as_deref().unwrap_or(""), origin, end, height); + } else { + scope.flush(); + } + } glyphs.push(Glyph { bytes: pos..pos + len, advance, hit }); *tm = Matrix::translate(advance * gs.scale, 0.0).then(tm); pos += len; diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..643dc5671e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -11,6 +11,7 @@ //! //! A verification pass then re-reads every redacted page; if any glyph or inline image is still //! under a region the whole operation fails (callers keep the previous document: fail-closed). +//! Finally, document metadata (`/Info` entries, XMP) that repeats the removed text is removed. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -21,6 +22,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; pub mod codes; mod image; mod interp; +mod metadata; mod overlay; pub mod patterns; pub mod sanitize; @@ -149,6 +151,8 @@ pub struct Report { /// only streams a check for text left under the marks may skip: an input stream labelled /// like an overlay (`/PCMark /Redaction`) is checked like any other. pub overlays: Vec, + /// Document information entries and XMP streams removed because they held redacted text. + pub metadata: usize, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -418,6 +422,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result 0 { return Err(RedactError::Residue(residue)); } + // 6. The document's metadata must not keep what the pages no longer show (#2448). + report.metadata = metadata::scrub(doc, &removed_text)?; // The previous revision still holds the removed content: the next save must rewrite. doc.require_full_save(); Ok(report) @@ -542,6 +549,8 @@ struct Applying { retired_properties: Vec, widget_owner: Vec<(ObjRef, ObjRef)>, removed_everywhere: HashSet, + /// The text of the removed glyphs, for the metadata check at the end. + removed_text: Vec, } /// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. @@ -552,14 +561,17 @@ fn redact_page_content( page: &pdfcraft_model::Page, rects: &[[f64; 4]], ) -> Result<(), RedactError> { - let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + let Applying { report, doomed_xobjects, retired_properties, removed_text, .. } = applying; // 1. Content. let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { let mut scope = Scope::new(rects, Mode::Apply, report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY); + scope.flush(); + removed_text.append(&mut scope.removed_text); + out }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot // tell whether those glyphs lie under a mark. Rather than report success and possibly diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs new file mode 100644 index 0000000000..821e744b06 --- /dev/null +++ b/crates/redact/src/metadata.rs @@ -0,0 +1,214 @@ +//! Metadata that repeats redacted text (#2448). +//! +//! [`crate::apply`] removes the glyphs under the marks, but the same words are often in the +//! document information dictionary (`/Title`, `/Subject`, `/Keywords`…) and the XMP metadata +//! that mirrors it. Once the page no longer shows them, the metadata must not keep them either. +//! +//! An `/Info` entry whose text contains a redacted term is removed. An XMP stream whose text +//! (element content and attribute values, entities decoded) contains one is removed whole: +//! editing the XML in place could leave it malformed or keep the term in another spelling. +//! Unreadable XMP goes too, and so does text too large to search against every term +//! (fail-closed). Terms are matched without regard to case or spacing. + +use std::collections::HashSet; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +/// Terms longer than this are cut (matching still finds the prefix). +const MAX_TERM_CHARS: usize = 4096; +const MAX_TERMS: usize = 20_000; +/// XMP packets larger than this, decoded, are removed without reading them. +const MAX_XMP_BYTES: usize = 16 * 1024 * 1024; +/// Text whose length times the number of terms exceeds this is treated as matching rather than +/// searched (fail-closed), so a page of removed text against a large packet stays quick. +const MAX_WORK: usize = 1 << 28; + +/// Lower-case `s` with every run of whitespace made one space, trimmed. +fn normalize(s: &str) -> String { + s.split_whitespace().flat_map(|w| w.chars().flat_map(char::to_lowercase).chain(std::iter::once(' '))).collect::().trim_end().to_string() +} + +fn significant(s: &str, min: usize) -> bool { + s.chars().filter(|c| c.is_alphanumeric()).count() >= min +} + +/// The terms to look for: each removed run (at least two letters or digits), and each of its +/// words with at least three, so "Smith, John" in `/Author` is caught when "John Smith" was +/// redacted. Shorter pieces would match nearly any text. +pub(crate) fn terms(removed: &[String]) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for run in removed { + let run: String = normalize(run).chars().take(MAX_TERM_CHARS).collect(); + let words = run.split(|c: char| !c.is_alphanumeric()).filter(|w| significant(w, 3)).map(str::to_string).collect::>(); + for t in std::iter::once(run.clone()).filter(|r| significant(r, 2)).chain(words) { + if out.len() >= MAX_TERMS { + return out; + } + if seen.insert(t.clone()) { + out.push(t); + } + } + } + out +} + +fn mentions(text: &str, terms: &[String]) -> bool { + let text = normalize(text); + text.len().saturating_mul(terms.len()) > MAX_WORK || terms.iter().any(|t| text.contains(t.as_str())) +} + +/// Decode the five predefined XML entities and character references; anything else stays. +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(rest.get(..at).unwrap_or("")); + let tail = rest.get(at..).unwrap_or(""); + let end = tail.find(';').filter(|&e| e <= 12); + let entity = end.and_then(|e| tail.get(1..e)); + let decoded = entity.and_then(|e| match e { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => { + let n = match e.strip_prefix("#x").or_else(|| e.strip_prefix("#X")) { + Some(h) => u32::from_str_radix(h, 16).ok(), + None => e.strip_prefix('#').and_then(|d| d.parse().ok()), + }; + n.and_then(char::from_u32) + } + }); + match (decoded, end) { + (Some(c), Some(e)) => { + out.push(c); + rest = tail.get(e + 1..).unwrap_or(""); + } + _ => { + out.push('&'); + rest = tail.get(1..).unwrap_or(""); + } + } + } + out.push_str(rest); + out +} + +/// The text an XML document carries: element content and attribute values (XMP stores simple +/// properties as attributes), each followed by a space, entities decoded. Names, comments and +/// markup are left out, so a term can't match a tag such as `dc:title`. +pub(crate) fn xml_text(xml: &str) -> String { + let mut out = String::new(); + let mut rest = xml; + while !rest.is_empty() { + let Some(open) = rest.find('<') else { + out.push_str(&unescape(rest)); + break; + }; + out.push_str(&unescape(rest.get(..open).unwrap_or(""))); + out.push(' '); + let tag = rest.get(open..).unwrap_or(""); + if let Some(body) = tag.strip_prefix("").unwrap_or(body.len()); + out.push_str(body.get(..end).unwrap_or("")); + out.push(' '); + rest = body.get(end + 3..).unwrap_or(""); + continue; + } + if tag.starts_with("").and_then(|e| tag.get(e + 3..)).unwrap_or(""); + continue; + } + // Attribute values inside the tag, up to its closing `>` (which can't be inside quotes). + let mut chars = tag.char_indices().skip(1); + let mut end = tag.len(); + while let Some((i, c)) = chars.next() { + match c { + '>' => { + end = i + 1; + break; + } + '"' | '\'' => { + let mut value = String::new(); + for (_, d) in chars.by_ref() { + if d == c { + break; + } + value.push(d); + } + out.push_str(&unescape(&value)); + out.push(' '); + } + _ => {} + } + } + rest = tag.get(end..).unwrap_or(""); + } + out +} + +/// The decoded text of an XMP stream (UTF-8, or UTF-16 with a byte-order mark), or `None` when it +/// can't be read. +fn xmp_text(doc: &Document, o: &Object) -> Option { + let Object::Stream(s) = &*doc.resolve(o) else { return None }; + let bytes = s.decoded().ok()?; + if bytes.len() > MAX_XMP_BYTES { + return None; + } + let units = |be: bool| { + bytes.get(2..).unwrap_or(&[]).as_chunks::<2>().0.iter().map(move |&p| if be { u16::from_be_bytes(p) } else { u16::from_le_bytes(p) }) + }; + let text = match bytes.get(..2) { + Some([0xFE, 0xFF]) => char::decode_utf16(units(true)).map(|c| c.unwrap_or('\u{FFFD}')).collect(), + Some([0xFF, 0xFE]) => char::decode_utf16(units(false)).map(|c| c.unwrap_or('\u{FFFD}')).collect(), + _ => String::from_utf8_lossy(&bytes).into_owned(), + }; + Some(xml_text(&text)) +} + +/// Remove the metadata that still holds `removed` text: matching `/Info` entries, and the +/// catalog's and pages' XMP streams that mention it. Returns how many entries and streams went. +pub(crate) fn scrub(doc: &mut Document, removed: &[String]) -> Result { + let terms = terms(removed); + if terms.is_empty() { + return Ok(0); + } + let mut n = 0; + + // The information dictionary, direct in the trailer or an object of its own. + let info_obj = doc.trailer().get(b"Info").cloned(); + if let Some(info) = info_obj.as_ref().and_then(|i| doc.resolve(i).as_dict().cloned()) { + let doomed: Vec> = info + .iter() + .filter(|(_, v)| doc.resolve(v).as_string().is_some_and(|s| mentions(&s.to_text(), &terms))) + .map(|(k, _)| k.to_vec()) + .collect(); + if !doomed.is_empty() { + n += doomed.len(); + let mut kept = info.clone(); + for k in &doomed { + kept.remove(k); + } + match info_obj.as_ref().and_then(Object::as_ref) { + Some(r) => doc.update_dict(r, |d| *d = kept)?, + None => doc.trailer_mut().set(b"Info".to_vec(), Object::Dict(kept)), + } + } + } + + // XMP on the catalog and on every page. + let mut holders: Vec = doc.root().into_iter().collect(); + holders.extend(pdfcraft_model::pages(doc).iter().map(|p| p.obj)); + for holder in holders { + let Some(xmp) = doc.get(holder).as_dict().and_then(|d| d.get(b"Metadata").cloned()) else { continue }; + if xmp_text(doc, &xmp).is_none_or(|t| mentions(&t, &terms)) { + doc.update_dict(holder, |d| { + d.remove(b"Metadata"); + })?; + n += 1; + } + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..a44dd68a63 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -104,6 +104,84 @@ fn a_whole_line_under_a_mark_goes_even_without_widths() { assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); } +/// Issue #2448: the redacted words are often in the document information dictionary and the XMP +/// metadata too. Applying removes the entries and streams that repeat them, and keeps the rest. +#[test] +fn redacted_text_does_not_survive_in_info_or_xmp() { + let xmp = |body: &str| { + stream( + "/Type /Metadata /Subtype /XML", + format!( + r#"{body}"# + ) + .as_bytes(), + ) + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Metadata 7 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Metadata 9 0 R >>".to_vec(), + // "BLUEFIN" spans x 55-90. + stream("", b"BT /F1 10 Tf 10 100 Td (Codename BLUEFIN report) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + // 7: the document's XMP names it in an attribute, through a character reference. + xmp(r#""#), + // 8: the information dictionary. + b"<< /Title (Codename BLUEFIN report) /Keywords (tuna, bluefin) /Author (Ada Lovelace) /Producer (Test) >>".to_vec(), + // 9: the page's XMP doesn't mention it. + xmp(r#"Annual figures"#), + ]; + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 8 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[55.0, 95.0, 90.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.metadata), (7, 3), "{r:?}"); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)); + assert!(!has(b"bluefin") && !has(b"BlueFin"), "the redacted word survived in the metadata"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let info = saved.trailer().get(b"Info").and_then(|i| saved.resolve(i).as_dict().cloned()).unwrap(); + let text = |k: &[u8]| info.get(k).and_then(|v| saved.resolve(v).as_string().map(|s| s.to_text())); + assert_eq!((text(b"Title"), text(b"Keywords")), (None, None)); + assert_eq!((text(b"Author").as_deref(), text(b"Producer").as_deref()), (Some("Ada Lovelace"), Some("Test"))); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Metadata"), "the document XMP named the redacted word"); + assert!(pdfcraft_model::pages(&saved)[0].dict.contains(b"Metadata"), "the page XMP didn't, and stays"); +} + +/// The removed text is collected in runs by position, not by operator: glyphs shown one operator +/// at a time still make one word, a gap makes a space, and separate marks make separate runs. +#[test] +fn removed_text_is_collected_in_runs() { + let mut doc = one_page( + b"BT /F1 10 Tf 10 100 Td (BLU) Tj 15 0 Td (E) Tj 5 0 Td [(F) -300 (IN)] TJ ( 7) Tj ET BT /F1 10 Tf 10 200 Td (Gary) Tj ET", + "", + vec![], + ); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + let (_, data) = page_streams(&doc, &page.dict, 0).unwrap(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut rep = Report::default(); + let rects = [[10.0, 95.0, 300.0, 110.0], [10.0, 195.0, 30.0, 210.0]]; + let mut scope = Scope::new(&rects, Mode::Apply, &mut rep, pdfcraft_cos::DecodeBudget::page()); + process(&mut doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY); + scope.flush(); + assert_eq!(scope.removed_text, ["BLUEF IN 7", "Gary"]); +} + +#[test] +fn metadata_terms_and_xmp_text() { + // Runs of two letters or digits or more are matched whole, and by their words of three or + // more, ignoring case and spacing. + let t = metadata::terms(&["John Smith".into(), "7".into(), "of".into(), "A-12".into()]); + assert_eq!(t, ["john smith", "john", "smith", "of", "a-12"]); + // XMP text is element content and attribute values, entities decoded; not names or comments. + let x = metadata::xml_text(r#"Résumé ]]>"#); + let words: Vec<&str> = x.split_whitespace().collect(); + assert_eq!(words, ["x", "&", "y", "Résumé", ""]); + assert!(!x.contains("title") && !x.contains("hidden")); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..2cf652df2a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5768,8 +5768,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources"] -notes = "confirmation; one undo step; next save is a full rewrite. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" +evidence = ["crates/ui-egui/tests/it/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/it/automation.rs::redacting_through_tools", "crates/engine/src/tests.rs::the_renderer_check_refuses_text_still_shown_under_a_mark", "crates/redact/src/tests.rs::redacted_xobjects_do_not_survive_through_inherited_or_shared_resources", "crates/redact/src/tests.rs::redacted_text_does_not_survive_in_info_or_xmp"] +notes = "confirmation; one undo step; next save is a full rewrite. Document information entries that contain the removed text, and the document's and pages' XMP streams that do, are removed (#2448; matched without regard to case or spacing, by the whole run of removed text and by its words of three letters or digits or more); other objects' XMP, bookmarks and attachments are not searched. Refused, with nothing changed, when the page still shows text under a mark afterwards: text in some predefined CMaps and rotated Type 3 fonts is not removed yet, so those documents cannot be redacted. Also refused on a page with an inline image the renderer reads no image for, since the operators after it can't be known. Removed images and forms are unlinked from page-tree resources (own, inherited or shared) and from surviving forms' resources, not yet from annotation appearances or patterns" [[feature]] id = "protect.redact-glyph-removal"