fix(redact): hidden-layer removal reads the default state as viewers do

hidden_layers ignored an indirect /BaseState and, with /BaseState /OFF,
let /ON override /OFF, so a group both lists named survived as visible.
The OCMD and OCG classifier also read /Type directly, so a membership
dictionary with an indirect /Type was never evaluated and its content kept.

The default state is now /BaseState (resolved), then /ON, then /OFF, and
/Type is resolved before classifying a group or membership dictionary.
This commit is contained in:
Dylan M. Taylor
2026-11-02 22:26:24 -05:00
parent 3936f93a14
commit e71c5d3f1d
3 changed files with 68 additions and 9 deletions
+10 -4
View File
@@ -20,7 +20,7 @@ impl<'a> Scope<'a> {
// groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
if !has_type(doc, d, b"OCMD") {
return false;
}
let groups: Vec<ObjRef> = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
@@ -102,7 +102,7 @@ impl<'a> Scope<'a> {
}
let resolved = doc.resolve(expression);
let complete = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
Object::Dict(d) if has_type(doc, d, b"OCG") => {
if let Some(r) = reference
&& hidden_layers.contains(&r)
{
@@ -151,7 +151,7 @@ impl<'a> Scope<'a> {
}
let resolved = doc.resolve(expression);
let result = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Dict(d) if has_type(doc, d, b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Array(items) if (2..=4097).contains(&items.len()) => {
let operator = match items.first() {
Some(Object::Name(n)) => n.as_slice(),
@@ -202,7 +202,7 @@ impl<'a> Scope<'a> {
let resolved = doc.resolve(expression);
let found = match &*resolved {
Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(d) if has_type(doc, d, b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(_) => true,
_ => false,
};
@@ -254,3 +254,9 @@ pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Ve
}
(out, removed)
}
/// Is this dictionary's `/Type` the given name? An indirect `/Type` is followed, as a viewer
/// follows it: a group or membership dictionary must not escape classification through one.
fn has_type(doc: &Document, d: &Dict, name: &[u8]) -> bool {
d.get(b"Type").is_some_and(|t| doc.resolve(t).as_name() == Some(name))
}
+4 -5
View File
@@ -335,11 +335,10 @@ fn hidden_layers(doc: &Document) -> 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 (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()
} else {
off.into_iter().filter(|g| all.contains(g)).collect()
}
// The default state is /BaseState, then /ON turns groups on and /OFF turns them off, so a
// group both lists name ends up off (§8.11.4.3, Table 101) — the viewer reads it the same way.
let base_off = d.get(b"BaseState").map(|b| doc.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice()));
all.into_iter().filter(|g| off.contains(g) || (base_off && !on.contains(g))).collect()
}
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
+54
View File
@@ -3871,3 +3871,57 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone));
}
}
/// One page drawing `SECRET` in the `/OC /L` block, with layer 7 0 R in the default
/// configuration `config` (objects 8 and up are `extra`).
fn layer_state_page(config: Vec<(&str, Object)>, resource: &str, extra: Vec<Vec<u8>>) -> Document {
let mut objects = vec![b"<< /Type /OCG /Name (Secret) >>".to_vec()];
objects.extend(extra);
let mut doc = one_page(b"/OC /L BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", &format!("/Properties << /L {resource} >>"), objects);
let config = Object::Dict(Dict::from_iter(config.into_iter().map(|(k, v)| (k.as_bytes().to_vec(), v))));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), (b"D".to_vec(), config)])),
);
})
.unwrap();
doc
}
fn assert_layer_secret_removed(mut doc: Document) {
use crate::sanitize::{Hidden, remove_hidden, scan};
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
#[test]
fn an_indirect_base_state_off_hides_the_layer() {
assert_layer_secret_removed(layer_state_page(vec![("BaseState", Object::Ref(ObjRef::new(8, 0)))], "7 0 R", vec![b"/OFF".to_vec()]));
}
#[test]
fn off_wins_over_on_when_the_base_state_is_off() {
assert_layer_secret_removed(layer_state_page(
vec![
("BaseState", Object::name("OFF")),
("ON", Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
("OFF", Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
],
"7 0 R",
Vec::new(),
));
}
#[test]
fn a_membership_dictionary_with_an_indirect_type_is_evaluated() {
assert_layer_secret_removed(layer_state_page(
vec![("BaseState", Object::name("ON"))],
"8 0 R",
vec![b"<< /Type 9 0 R /OCGs [7 0 R] /P /AllOff >>".to_vec(), b"/OCMD".to_vec()],
));
}