From 89e6a86029c344248ba0f340d4c5c4001c65fbe9 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:03:13 +0200 Subject: [PATCH 1/2] Hidden layers includes layers a View usage rule turns off Hidden layers read only the default configuration's ON, OFF and BaseState. A layer listed on there but turned off on screen by a usage rule (/AS, ISO 32000-2, 8.11.4.4: the View event in the View category, with the layer's /Usage /View /ViewState OFF) is hidden in viewers that apply the rules, yet its content stayed. Such a layer now counts as hidden. Rules that turn a layer on, or that apply only when printing or exporting, leave it as the lists say, so the removal never keeps less than it did. --- crates/redact/src/sanitize.rs | 34 ++++++++++++++++++++++++++-------- crates/redact/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f8088e0bba..9b431736d7 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -324,22 +324,40 @@ fn is_comment(subtype: &[u8]) -> bool { !matches!(subtype, b"FileAttachment" | b"Redact") && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) } -/// The layers that are off in the default configuration. +/// The layers that are off in the default configuration: listed off (or not listed on, under +/// `/BaseState /OFF`), or turned off on screen by one of its usage rules (`/AS`, ISO 32000-2, +/// 8.11.4.4): a rule for the View event in the View category hides a layer whose +/// `/Usage /View /ViewState` is OFF. Rules that turn a layer on, or apply only when printing or +/// exporting, leave it as the lists say. fn hidden_layers(doc: &Document) -> Vec { let Some((_, cat)) = catalog(doc) else { return Vec::new() }; let Some(oc) = sub(doc, &cat, b"OCProperties") else { return Vec::new() }; let Some(d) = sub(doc, &oc, b"D") else { return Vec::new() }; - let all: Vec = - oc.get(b"OCGs").and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default(); - let list = |k: &[u8]| -> Vec { - d.get(k).and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default() + let refs = |o: Option<&Object>| -> Vec { + o.and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default() }; - let (on, off) = (list(b"ON"), list(b"OFF")); - if d.name(b"BaseState") == Some(b"OFF") { - all.into_iter().filter(|g| !on.contains(g)).collect() + let all = refs(oc.get(b"OCGs")); + let (on, off) = (refs(d.get(b"ON")), refs(d.get(b"OFF"))); + let mut hidden: Vec = if d.name(b"BaseState") == Some(b"OFF") { + all.iter().copied().filter(|g| !on.contains(g)).collect() } else { off.into_iter().filter(|g| all.contains(g)).collect() + }; + let rules = d.get(b"AS").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + for rule in rules.iter().filter_map(|r| doc.resolve(r).as_dict().cloned()) { + let categories = rule.get(b"Category").and_then(|c| doc.resolve(c).as_array().cloned()).unwrap_or_default(); + if rule.name(b"Event") != Some(b"View") || !categories.iter().any(|c| c.as_name() == Some(b"View")) { + continue; + } + for g in refs(rule.get(b"OCGs")) { + let usage = doc.get(g).as_dict().and_then(|layer| sub(doc, layer, b"Usage")); + let view_off = usage.and_then(|u| sub(doc, &u, b"View")).is_some_and(|v| v.name(b"ViewState") == Some(b"OFF")); + if view_off && all.contains(&g) && !hidden.contains(&g) { + hidden.push(g); + } + } } + hidden } /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8978e0e7d6..e552abe5e9 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4207,3 +4207,24 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); } + +/// A layer the default configuration lists on but a View usage rule turns off is hidden on screen +/// (ISO 32000-2, 8.11.4.4): Hidden layers removes it. A Print rule leaves its layer as it is. +#[test] +fn a_layer_a_view_rule_turns_off_is_hidden() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R 7 0 R] /D << /AS [<< /Event /View /OCGs [6 0 R] /Category [/View] >> << /Event /Print /OCGs [7 0 R] /Category [/Print] >>] >> >> >>".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 /L2 7 0 R >> >> >>".to_vec(), + stream("", b"/OC /L1 BDC BT /F1 10 Tf 10 50 Td (ScreenOff) Tj ET EMC /OC /L2 BDC BT /F1 10 Tf 10 100 Td (PrintOff) Tj ET EMC"), + FONT.replace("95 0 R", "8 0 R").into_bytes(), + b"<< /Type /OCG /Name (Screen) /Usage << /View << /ViewState /OFF >> >> >>".to_vec(), + b"<< /Type /OCG /Name (Print) /Usage << /Print << /PrintState /OFF >> >> >>".to_vec(), + widths(), + ]); + assert!(scan(&doc).into_iter().any(|(h, n)| h == Hidden::HiddenLayers && n > 0)); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let c = content(&doc, 0); + assert!(!c.contains("ScreenOff") && c.contains("(PrintOff)"), "{c}"); +} From f55e6163b69163a50891c9ea1a1232df46350dca Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:05:10 +0200 Subject: [PATCH 2/2] Hidden layers reads each usage rule and layer list once Many usage rules can name one long layer list. Each rule, each list and each layer is now looked at once, with sets for the membership checks, so the work grows with the file and not with how often it repeats a list. Which layers count as hidden does not change. --- crates/redact/src/sanitize.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 9b431736d7..608c38fade 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -343,16 +343,34 @@ fn hidden_layers(doc: &Document) -> Vec { } else { off.into_iter().filter(|g| all.contains(g)).collect() }; + // Each rule, layer list and layer is read once, however often the rules name them: many rules + // can share one long list. + let known: std::collections::HashSet = all.iter().copied().collect(); + let mut looked: std::collections::HashSet = hidden.iter().copied().collect(); + let mut read = std::collections::HashSet::new(); let rules = d.get(b"AS").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - for rule in rules.iter().filter_map(|r| doc.resolve(r).as_dict().cloned()) { + for rule in &rules { + if let Some(r) = rule.as_ref() + && !read.insert(r) + { + continue; + } + let Some(rule) = doc.resolve(rule).as_dict().cloned() else { continue }; let categories = rule.get(b"Category").and_then(|c| doc.resolve(c).as_array().cloned()).unwrap_or_default(); if rule.name(b"Event") != Some(b"View") || !categories.iter().any(|c| c.as_name() == Some(b"View")) { continue; } + if let Some(r) = rule.get(b"OCGs").and_then(Object::as_ref) + && !read.insert(r) + { + continue; + } for g in refs(rule.get(b"OCGs")) { + if !known.contains(&g) || !looked.insert(g) { + continue; + } let usage = doc.get(g).as_dict().and_then(|layer| sub(doc, layer, b"Usage")); - let view_off = usage.and_then(|u| sub(doc, &u, b"View")).is_some_and(|v| v.name(b"ViewState") == Some(b"OFF")); - if view_off && all.contains(&g) && !hidden.contains(&g) { + if usage.and_then(|u| sub(doc, &u, b"View")).is_some_and(|v| v.name(b"ViewState") == Some(b"OFF")) { hidden.push(g); } }