From 5b2048ef256bf3dd95ac41f8a1d7d2404e80c381 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:37:27 +0800 Subject: [PATCH] M5: a removed glyph whose text isn't known makes the metadata check fail closed (#2448) Review on #2539: a removed glyph with no known text (no /ToUnicode, a glyph name outside the list) went in as an empty string and nothing marked the collection as incomplete, so when every removed glyph was like that the terms were empty and /Info and XMP were left untouched. It now counts as text the check couldn't keep, and every /Info string and XMP stream goes. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/interp.rs | 9 ++++++++- crates/redact/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 2dcafb339f..b0029ac912 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -739,7 +739,14 @@ fn show(scope: &mut Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (V let end = trm.apply(spaced, 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); + let text = f.text_of(code); + // A glyph whose text isn't known could stand for anything in the + // metadata, so the metadata check can't rely on the text it has + // (review on #2539): it fails closed, as for text it couldn't keep. + if text.is_none() { + scope.removed_cut = true; + } + scope.removed(text.as_deref().unwrap_or(""), origin, end, height); } else { scope.flush(); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9862e9e245..fef02a85dd 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -173,6 +173,38 @@ fn an_info_value_inside_the_redacted_text_goes() { assert!(info.contains(b"Subject") && info.contains(b"Producer")); } +/// Removed glyphs whose text isn't known (no `/ToUnicode`, glyph names outside the glyph list) +/// leave nothing to search the metadata for, so every `/Info` string goes, as when the collected +/// text was cut short (review on #2539). Glyphs whose text is known keep the targeted check. +#[test] +fn removed_glyphs_with_no_known_text_clear_the_metadata() { + let page = |codes: &[u8], font: &str| -> Document { + let content = [&b"BT /F1 10 Tf 10 100 Td ("[..], codes, b") Tj ET"].concat(); + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 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 >> >> >>".to_vec(), + stream("", &content), + font.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Title (Project Nightjar) /Producer (Test) >>".to_vec(), + ]; + Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 7 0 R").build())).unwrap() + }; + let unnamed = FONT.replace(" /Widths", " /Encoding << /Differences [65 /g17 /g18 /g19] >> /Widths"); + let mut doc = page(b"ABC", &unnamed); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Title") && !info.contains(b"Producer"), "fail closed: {r:?}"); + // Control: the same glyphs with known text don't touch unrelated metadata. + let mut doc = page(b"ABC", FONT); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + apply(&mut doc, None).unwrap(); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(info.contains(b"Title") && info.contains(b"Producer")); +} + /// One page showing `content`, with `/Info` `info` and catalog XMP whose `rdf:RDF` holds /// `xmp_body`. fn with_metadata(content: &[u8], info: &str, xmp_body: &str) -> Document {