Merge #6460: Hidden layers includes layers a View usage rule turns off

# Conflicts:
#	crates/redact/src/tests.rs
This commit is contained in:
bflatastic committed 2026-11-09 08:03:18 +07:00
commit 34a953a72a
2 files changed
+65 -8

No files matched your search

+44 -8
View File
@@ -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<ObjRef> {
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<ObjRef> =
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<ObjRef> {
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<ObjRef> {
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<ObjRef> = 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<ObjRef> = all.iter().copied().collect();
let mut looked: std::collections::HashSet<ObjRef> = 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.
+21
View File
@@ -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}");
}