diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index a1b46c95d6..c1f6afd740 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -428,6 +428,101 @@ fn hidden_layers(doc: &Document) -> Vec { } } +/// The objects a layer cleanup has reached by reference, and whether the lists naming each keep it. +type Cleaned = std::collections::HashMap; + +/// `o` cleaned by `clean`: in its list when written there, and when written by reference, once and +/// where it is, however many lists name it (they keep the reference, or drop it when `clean` gives +/// `None`). A list that names one object many times costs one step per object, not one per path, +/// and a cycle stops where it began. +fn once( + doc: &mut Document, + o: &Object, + cleaned: &mut Cleaned, + clean: impl FnOnce(&mut Document, &Object, &mut Cleaned) -> Result, RedactError>, +) -> Result, RedactError> { + let Object::Ref(r) = o else { return clean(doc, o, cleaned) }; + if let Some(&kept) = cleaned.get(r) { + return Ok(kept.then(|| o.clone())); + } + cleaned.insert(*r, true); + let held: &Object = &doc.resolve(o); + let Some(new) = clean(doc, held, cleaned)? else { + cleaned.insert(*r, false); + return Ok(None); + }; + if new != *held { + doc.set_existing(*r, new)?; + } + Ok(Some(o.clone())) +} + +/// `o` without the entries `keep` refuses, through nested arrays (an `/Order` holds sub-lists, and +/// `/RBGroups` holds groups), each array written by reference cleaned [`once`]. `None` when `keep` +/// refuses `o` itself, or when `o` is a sub-list (`depth` above 0) that named a layer and names +/// none now: it goes, with the heading that labelled it. +fn filtered( + doc: &mut Document, + o: &Object, + keep: &impl Fn(&Object) -> bool, + depth: usize, + cleaned: &mut Cleaned, +) -> Result, RedactError> { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + if !keep(o) { + return Ok(None); + } + once(doc, o, cleaned, |doc, o, cleaned| { + let Object::Array(a) = o else { return Ok(Some(o.clone())) }; + let mut out = Vec::new(); + for x in a { + out.extend(filtered(doc, x, keep, depth + 1, cleaned)?); + } + // Anything but a text string (a heading) is a layer or a sub-list. + let names = |l: &[Object]| l.iter().any(|x| !matches!(x, Object::String(_))); + let emptied = depth > 0 && names(a.as_slice()) && !names(out.as_slice()); + Ok((!emptied).then_some(Object::Array(out))) + }) +} + +/// An optional-content configuration without the layers `keep` refuses, in every list that names +/// them: on, off, order, locked, radio-button groups and usage rules (a rule, group or sub-list left +/// with no layer goes). A removed layer still named in any of them would stay in the file, its name +/// included. +fn config_without(doc: &mut Document, mut d: Dict, keep: &impl Fn(&Object) -> bool, cleaned: &mut Cleaned) -> Result { + for k in [&b"ON"[..], b"OFF", b"Order", b"Locked", b"RBGroups"] { + if let Some(v) = d.get(k).cloned() { + let v = filtered(doc, &v, keep, 0, cleaned)?.unwrap_or_else(|| Object::Array(Vec::new())); + d.set(k.to_vec(), v); + } + } + let Some(rules) = d.get(b"AS").cloned() else { return Ok(d) }; + let rules = once(doc, &rules, cleaned, |doc, rules, cleaned| { + let Object::Array(rules) = rules else { return Ok(Some(rules.clone())) }; + let mut kept = Vec::new(); + for rule in rules { + kept.extend(once(doc, rule, cleaned, |doc, rule, cleaned| { + let Some(mut rule) = rule.as_dict().cloned() else { return Ok(None) }; + if let Some(layers) = rule.get(b"OCGs").cloned() { + let Some(layers) = filtered(doc, &layers, keep, 0, cleaned)? else { return Ok(None) }; + if doc.resolve(&layers).as_array().is_some_and(Vec::is_empty) { + return Ok(None); + } + rule.set(b"OCGs".to_vec(), layers); + } + Ok(Some(Object::Dict(rule))) + })?); + } + Ok(Some(Object::Array(kept))) + })?; + if let Some(rules) = rules { + d.set(b"AS".to_vec(), rules); + } + Ok(d) +} + /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> { let off = if layers { hidden_layers(doc) } else { Vec::new() }; @@ -755,13 +850,19 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Ok(Some(Object::Dict(config_without(doc, c.clone(), &keep, cleaned)?))), + None => Ok(None), + })?); } - oc.set(b"D".to_vec(), Object::Dict(d)); + oc.set(b"Configs".to_vec(), Object::Array(kept)); } cat.set(b"OCProperties".to_vec(), Object::Dict(oc)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index f12710641f..1ca8902b8b 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4754,3 +4754,59 @@ fn images_drawn_by_type3_glyphs_stay() { assert!(has(b"BYGLYPHIMAGE"), "the glyph's image is drawn and must stay"); assert!(has(b"BYFONTFORMIMAGE"), "the image a form in the font's resources draws must stay"); } + +/// Layer lists that name one array many times, through arrays that do the same, are cleaned once +/// where each array is, not copied once per path: the file stays as small as it was. +#[test] +fn a_shared_layer_list_is_cleaned_once() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut objs = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /OFF [6 0 R] /Order [9 0 R 9 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("", b"BT /F1 10 Tf 10 100 Td (Kept) Tj ET"), + FONT.replace("95 0 R", "8 0 R").into_bytes(), + b"<< /Type /OCG /Name (SECRETLAYERNAME) >>".to_vec(), + b"<< /Type /OCG /Name (Shown layer) >>".to_vec(), + widths(), + ]; + // Objects 9 to 22 each name the next one twice; 23 names both layers. + objs.extend((10..24).map(|n| format!("[{n} 0 R {n} 0 R]").into_bytes())); + objs.push(b"[6 0 R 7 0 R]".to_vec()); + let mut doc = pdf(objs); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(Shown layer)") && !has(b"SECRETLAYERNAME"), "the removed layer survived the save"); + assert!(bytes.len() < 32 * 1024, "the shared lists were copied once per path: {} bytes", bytes.len()); +} + +/// A removed off layer leaves every list that names it, or its dictionary, name included, stays in +/// the file: a sub-list of the default `/Order` (its heading too, once it names no layer), a +/// radio-button group (a group left empty goes), a usage rule (one that names it directly too), and +/// the alternate configurations in `/Configs`. +#[test] +fn a_removed_layer_leaves_every_configuration() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /OFF [6 0 R] /Order [7 0 R [(Group) 6 0 R]] /RBGroups [[6 0 R 7 0 R] [6 0 R]] /AS [<< /Event /Print /OCGs [6 0 R] /Category [/Print] >> << /Event /View /OCGs 6 0 R /Category [/View] >>] >> /Configs [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 >> /Properties << /L1 6 0 R >> >> >>".to_vec(), + stream("", b"/OC /L1 BDC BT /F1 10 Tf 10 50 Td (Gone) Tj ET EMC BT /F1 10 Tf 10 100 Td (Kept) Tj ET"), + FONT.replace("95 0 R", "9 0 R").into_bytes(), + b"<< /Type /OCG /Name (SECRETLAYERNAME) >>".to_vec(), + b"<< /Type /OCG /Name (Shown layer) >>".to_vec(), + b"<< /Name (Other view) /OFF [7 0 R] /ON [6 0 R] /Order [6 0 R 7 0 R] >>".to_vec(), + widths(), + ]); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(Shown layer)") && has(b"(Other view)"), "the layer that stays and the configuration stay"); + assert!(!has(b"SECRETLAYERNAME"), "the removed layer survived the save"); + assert!(!has(b"(Group)"), "the heading of a sub-list left with no layer stayed"); + let catalog = doc.get(doc.root().unwrap()); + let config = catalog.as_dict().and_then(|c| c.get(b"OCProperties")).and_then(Object::as_dict).and_then(|p| p.get(b"D")).and_then(Object::as_dict); + let group = Object::Array(vec![Object::Ref(ObjRef::new(7, 0))]); + assert_eq!(config.and_then(|d| d.get(b"RBGroups")), Some(&Object::Array(vec![group])), "a radio-button group left empty stayed"); +}