diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index c1f6afd740..6164678428 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -410,22 +410,58 @@ 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() + }; + // 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 { + 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")); + if usage.and_then(|u| sub(doc, &u, b"View")).is_some_and(|v| v.name(b"ViewState") == Some(b"OFF")) { + hidden.push(g); + } + } } + hidden } /// The objects a layer cleanup has reached by reference, and whether the lists naming each keep it. diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 1ca8902b8b..6518817899 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4810,3 +4810,24 @@ fn a_removed_layer_leaves_every_configuration() { 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"); } + +/// 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}"); +}