fix(redact): structure titles go with the descriptions of removed content (#5866)

Redaction and hidden-content removal cleared /Alt, /ActualText and /E from
the structure element owning removed marked content, but kept its /T title,
which producers often fill with the element's text. Ancestor cleanup and
in-place description detection shared a constant that also omitted /T, while
annotation removal had its own list that included it.

One DESCRIPTIONS policy (Alt, ActualText, T, E) now serves the owning
element, its ancestors, in-place detection and annotation removal.
This commit is contained in:
Dylan M. Taylor
2026-11-02 23:34:18 -05:00
committed by GitHub
parent 69ddcd89bf
commit 359ed56ffa
2 changed files with 65 additions and 5 deletions
+7 -5
View File
@@ -2,7 +2,7 @@
//!
//! Marked content (MCIDs) that applying redactions changed is found by comparing each MCID's
//! operators before and after. Elements owning such content lose their alternate text, actual
//! text and expansion (which may spell out the removed words);
//! text, title and expansion (which may spell out the removed words), as do their ancestors;
//! references to marked content that is now empty are dropped from the element.
use std::collections::{HashMap, HashSet};
@@ -281,7 +281,7 @@ pub(crate) fn clean_within(
}
doc.update_dict(r, |e| {
if sensitive {
for key in [&b"Alt"[..], b"ActualText", b"E"] {
for key in DESCRIPTIONS {
e.remove(key);
}
}
@@ -321,8 +321,10 @@ impl Above {
}
}
/// The keys of an element that describe its content.
const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"];
/// The keys of an element that describe its content, and so may repeat what was removed: one
/// policy for the element that owned it, the elements above it and elements written in place.
/// The title (`/T`) is included, as producers often fill it with the element's text.
const DESCRIPTIONS: [&[u8]; 4] = [b"Alt", b"ActualText", b"T", b"E"];
/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with.
struct Parents(HashMap<ObjRef, Vec<Above>>);
@@ -539,7 +541,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
// An element that lost an annotation keeps no text describing it, even with other
// kids left: a link's alternate text often repeats the URL, an annotation's its
// comment. Elements above it may describe it too.
for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
for key in DESCRIPTIONS {
e.remove(key);
}
})?;
+58
View File
@@ -62,3 +62,61 @@ fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annot
let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap();
assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text");
}
/// [`sectioned`], with titles that repeat the secret on the paragraph that owns it, on its section
/// and on the document element above both.
fn titled() -> Document {
let mut doc = sectioned("11 0 R");
let title = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
for (n, t) in [(8, "LEAK_DOC_TITLE"), (9, "LEAK_OWN_TITLE"), (11, "LEAK_SECT_TITLE"), (10, "Public title")] {
doc.update_dict(ObjRef::new(n, 0), |d| d.set(b"T".to_vec(), title(t))).unwrap();
}
doc
}
#[test]
fn titles_repeating_redacted_content_go_from_the_owner_and_its_ancestors() {
let mut doc = titled();
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the redacted word survived");
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
assert!(public.contains(b"T"), "an element beside it keeps its own title");
}
#[test]
fn titles_repeating_hidden_text_go_from_the_owner_and_its_ancestors() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = titled();
let hidden =
b"/P <</MCID 0>> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <</MCID 1>> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC";
doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), hidden.to_vec())));
remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap();
assert!(!full_save_holds(&doc, b"SECRET"), "the hidden text survived");
assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the hidden text survived");
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
assert!(public.contains(b"T"), "an element beside it keeps its own title");
}
#[test]
fn an_ancestor_title_describing_a_removed_annotation_goes() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = pdf(vec![
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(),
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] /StructParents 0 >>"
.to_vec(),
stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"),
FONT.replace("95 0 R", "12 0 R").into_bytes(),
b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(),
b"null".to_vec(),
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(),
b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(),
// Only a title above the link element repeats the address.
b"<< /Type /StructElem /S /P /P 9 0 R /T (LEAK_PARENT_TITLE) /K 11 0 R >>".to_vec(),
b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
widths(),
]);
remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap();
assert!(!full_save_holds(&doc, b"LEAK_"), "an ancestor's title describing the removed link survived");
}