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] 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"); +}