mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 15:55:33 +00:00
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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()],
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user