From 459cf4af5d2559b56302ced43b5606eec3c4c2f0 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: test that a shorter value goes from custom XMP too (#2448) A value shorter than the redacted sentence ("James Bond" from "JAMES BOND IS A SPY") in a custom schema's element or attribute takes its packet with it, as it goes from /Info, while a clean packet and an unrelated entry stay. The regression #2598 suggested keeping whichever fix lands. Co-Authored-By: Claude Opus 5.5 --- crates/redact/src/tests.rs | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c8459ba4d0..eff0c3d525 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -171,6 +171,51 @@ fn an_info_value_inside_the_redacted_text_goes() { assert!(info.contains(b"Subject") && info.contains(b"Producer")); } +/// A value shorter than the redacted sentence goes from XMP too, in a custom schema's element or +/// attribute, as from `/Info` (the regression #2598 suggested keeping): the packets holding it are +/// removed, one that doesn't stays. +#[test] +fn a_shorter_value_in_custom_xmp_goes() { + 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 8 0 R >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (JAMES BOND IS A SPY) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + // 7: an element of a custom schema. + xmp(r#"James Bond"#), + // 8: an attribute of a custom schema. + xmp(r#""#), + // 9: a third packet, on no holder yet; swapped onto the page below to show a clean one stays. + xmp(r#"Annual figures"#), + b"<< /Author (James Bond) /Subject (Annual figures) >>".to_vec(), + ]; + for (holder_of_8, expected) in [("8 0 R", 3), ("9 0 R", 2)] { + let objs: Vec> = + objs.iter().map(|o| String::from_utf8_lossy(o).replace("/Metadata 8 0 R", &format!("/Metadata {holder_of_8}")).into_bytes()).collect(); + let mut doc = Document::open(Arc::new(Pdf::new().objs(objs).trailer_entry("/Info 10 0 R").build())).unwrap(); + mark(&mut doc, 0, &[[5.0, 95.0, 200.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.metadata, expected, "{holder_of_8}: {r:?}"); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Metadata"), "the custom element named him"); + let page_keeps = pdfcraft_model::pages(&doc)[0].dict.contains(b"Metadata"); + assert_eq!(page_keeps, holder_of_8 == "9 0 R", "the attribute packet goes, a clean one stays"); + let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().cloned()).unwrap(); + assert!(!info.contains(b"Author") && info.contains(b"Subject"), "the author goes, the unrelated subject 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]