From ac8eb5146430fa8d94d418addbbb0d5c4a1b44af Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:03:14 +0200 Subject: [PATCH 1/3] Removed layers leave every optional-content configuration Hidden layers took a removed layer out of the default configuration's top-level ON, OFF, Order and Locked lists only. A sub-list of /Order, a radio-button group (/RBGroups), a usage rule (/AS) and every alternate configuration in /Configs still named it, so its dictionary, name included, stayed in the full-rewrite save. The removal now cleans all of these lists, through nested arrays, in the default configuration and in every alternate one. A usage rule left with no layer goes. --- crates/redact/src/sanitize.rs | 60 ++++++++++++++++++++++++++++++++--- crates/redact/src/tests.rs | 24 ++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f8088e0bba..3777ac54d4 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -342,6 +342,52 @@ fn hidden_layers(doc: &Document) -> Vec { } } +/// `o` without the entries `keep` refuses, through nested arrays (an `/Order` holds sub-lists, and +/// `/RBGroups` holds groups); arrays written by reference are copied in place. +fn filtered(doc: &Document, o: &Object, keep: &impl Fn(&Object) -> bool, depth: usize) -> Result { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + match &*doc.resolve(o) { + Object::Array(a) => { + let mut out = Vec::with_capacity(a.len()); + for x in a.iter().filter(|x| keep(x)) { + out.push(filtered(doc, x, keep, depth + 1)?); + } + Ok(Object::Array(out)) + } + _ => Ok(o.clone()), + } +} + +/// 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 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: &Document, mut d: Dict, keep: &impl Fn(&Object) -> bool) -> 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)?; + d.set(k.to_vec(), v); + } + } + if let Some(Object::Array(rules)) = d.get(b"AS").map(|a| (*doc.resolve(a)).clone()) { + let mut kept = Vec::with_capacity(rules.len()); + for rule in rules { + let Some(mut rule) = doc.resolve(&rule).as_dict().cloned() else { continue }; + if let Some(layers) = rule.get(b"OCGs").cloned() { + let layers = filtered(doc, &layers, keep, 0)?; + if layers.as_array().is_some_and(Vec::is_empty) { + continue; + } + rule.set(b"OCGs".to_vec(), layers); + } + kept.push(Object::Dict(rule)); + } + d.set(b"AS".to_vec(), Object::Array(kept)); + } + 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() }; @@ -636,13 +682,17 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result>] >> /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"); +} From 68e2c65f55f2e03246c7bd06e5ccad7eb00b010a Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:59:23 +0200 Subject: [PATCH 2/3] Shared layer lists are cleaned once, not copied per path Removing hidden layers copied each array a configuration list names by reference, once for every path that reaches it, so lists that name one array many times could grow without bound. Each array, usage rule and configuration named by reference is now cleaned once where it is, and the lists keep the reference. --- crates/redact/src/sanitize.rs | 93 ++++++++++++++++++++++++----------- crates/redact/src/tests.rs | 26 ++++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 3777ac54d4..8965f82e6c 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -342,48 +342,83 @@ 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); arrays written by reference are copied in place. -fn filtered(doc: &Document, o: &Object, keep: &impl Fn(&Object) -> bool, depth: usize) -> Result { +/// `/RBGroups` holds groups), each array written by reference cleaned [`once`]. +fn filtered(doc: &mut Document, o: &Object, keep: &impl Fn(&Object) -> bool, depth: usize, cleaned: &mut Cleaned) -> Result { if depth > MAX_DIRECT_DEPTH { return Err(RedactError::TooDeep); } - match &*doc.resolve(o) { - Object::Array(a) => { - let mut out = Vec::with_capacity(a.len()); - for x in a.iter().filter(|x| keep(x)) { - out.push(filtered(doc, x, keep, depth + 1)?); - } - Ok(Object::Array(out)) + let out = 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.iter().filter(|x| keep(x)) { + out.push(filtered(doc, x, keep, depth + 1, cleaned)?); } - _ => Ok(o.clone()), - } + Ok(Some(Object::Array(out))) + })?; + Ok(out.unwrap_or_else(|| Object::Array(Vec::new()))) } /// 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 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: &Document, mut d: Dict, keep: &impl Fn(&Object) -> bool) -> Result { +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)?; + let v = filtered(doc, &v, keep, 0, cleaned)?; d.set(k.to_vec(), v); } } - if let Some(Object::Array(rules)) = d.get(b"AS").map(|a| (*doc.resolve(a)).clone()) { - let mut kept = Vec::with_capacity(rules.len()); + 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 { - let Some(mut rule) = doc.resolve(&rule).as_dict().cloned() else { continue }; - if let Some(layers) = rule.get(b"OCGs").cloned() { - let layers = filtered(doc, &layers, keep, 0)?; - if layers.as_array().is_some_and(Vec::is_empty) { - continue; + 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 layers = filtered(doc, &layers, keep, 0, cleaned)?; + if doc.resolve(&layers).as_array().is_some_and(Vec::is_empty) { + return Ok(None); + } + rule.set(b"OCGs".to_vec(), layers); } - rule.set(b"OCGs".to_vec(), layers); - } - kept.push(Object::Dict(rule)); + Ok(Some(Object::Dict(rule))) + })?); } - d.set(b"AS".to_vec(), Object::Array(kept)); + Ok(Some(Object::Array(kept))) + })?; + if let Some(rules) = rules { + d.set(b"AS".to_vec(), rules); } Ok(d) } @@ -682,15 +717,17 @@ 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"Configs".to_vec(), Object::Array(kept)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 914ea19a19..6c5d8ae800 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4208,6 +4208,32 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); } +/// 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`, a radio-button group, a usage rule, and the /// alternate configurations in `/Configs`. From eee967330a8262d0ee7ed85152f99be0ad70b773 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:00:46 +0200 Subject: [PATCH 3/3] Emptied layer sub-lists and direct layer references go too An /Order sub-list or radio-button group whose layers were all removed stayed behind, an /Order heading with it, and a list or usage rule that named a removed layer directly instead of in an array kept that layer in the file. Such sub-lists and groups are now dropped, a top-level list keeps its place, and a direct reference is treated as an entry. --- crates/redact/src/sanitize.rs | 38 ++++++++++++++++++++++++----------- crates/redact/src/tests.rs | 12 ++++++++--- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 8965f82e6c..49b7adaf38 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -372,29 +372,43 @@ fn once( } /// `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`]. -fn filtered(doc: &mut Document, o: &Object, keep: &impl Fn(&Object) -> bool, depth: usize, cleaned: &mut Cleaned) -> Result { +/// `/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); } - let out = once(doc, o, cleaned, |doc, o, cleaned| { + 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.iter().filter(|x| keep(x)) { - out.push(filtered(doc, x, keep, depth + 1, cleaned)?); + for x in a { + out.extend(filtered(doc, x, keep, depth + 1, cleaned)?); } - Ok(Some(Object::Array(out))) - })?; - Ok(out.unwrap_or_else(|| Object::Array(Vec::new()))) + // 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 left with no layer -/// goes). A removed layer still named in any of them would stay in the file, its name included. +/// 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)?; + let v = filtered(doc, &v, keep, 0, cleaned)?.unwrap_or_else(|| Object::Array(Vec::new())); d.set(k.to_vec(), v); } } @@ -406,7 +420,7 @@ fn config_without(doc: &mut Document, mut d: Dict, keep: &impl Fn(&Object) -> bo 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 layers = filtered(doc, &layers, keep, 0, cleaned)?; + 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); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 6c5d8ae800..f28fdeb353 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4235,13 +4235,14 @@ fn a_shared_layer_list_is_cleaned_once() { } /// 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`, a radio-button group, a usage rule, and the -/// alternate configurations in `/Configs`. +/// 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]] /AS [<< /Event /Print /OCGs [6 0 R] /Category [/Print] >>] >> /Configs [8 0 R] >> >>".to_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"), @@ -4256,4 +4257,9 @@ fn a_removed_layer_leaves_every_configuration() { 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"); }