Files
pdfcraft/crates/redact/src/interp/layers.rs
T
Dylan M. Taylor c73cccfb33 fix(redact): hidden-layer removal reads the default state as viewers do (#5867)
* 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.

* address review: follow chained /Type and /BaseState references, hash-set ON/OFF lists

* address review: a visibility expression naming a group through an alias reference fails closed
2026-11-11 15:37:44 -05:00

273 lines
11 KiB
Rust

use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope};
impl<'a> Scope<'a> {
/// Is this optional-content reference (an OCG or OCMD) hidden?
fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool {
if !self.layers_enabled {
return false;
}
let reference = o.as_ref();
if let Some(r) = reference {
if let Some(hidden) = self.ocmd_cache.get(&r) {
return *hidden;
}
if self.hidden_layers.contains(&r) {
self.ocmd_cache.insert(r, true);
return true;
}
}
// Evaluate the policy against the same default configuration used to select hidden
// groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if !has_type(doc, d, b"OCMD") {
return false;
}
let groups: Vec<ObjRef> = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(),
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
let hidden = if let Some(expression) = d.get(b"VE") {
let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
match visible {
Some(visible) => !visible,
None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096),
}
} else if groups.is_empty() {
false
} else {
let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec()));
match (d.get(b"P"), policy.as_deref()) {
(None, _) | (Some(_), Some(b"AnyOn")) => !any_on,
(Some(_), Some(b"AllOn")) => !all_on,
(Some(_), Some(b"AnyOff")) => all_on,
(Some(_), Some(b"AllOff")) => any_on,
// An invalid policy must not make a candidate hidden block survive sanitization.
_ => fail_closed(),
}
};
if !hidden {
self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied());
if let Some(expression) = d.get(b"VE") {
let mut referenced = HashSet::new();
if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) {
self.retained_off_layers.extend(referenced);
} else {
// If an expression cannot be fully walked, preserve every off group so that
// the content we kept does not change visibility after configuration cleanup.
self.retained_off_layers.extend(self.hidden_layers.iter().copied());
}
}
}
if let Some(r) = reference {
self.ocmd_cache.insert(r, hidden);
}
hidden
}
pub(super) fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool {
if oc.as_ref().is_some() {
return self.layer_hidden(doc, oc);
}
if let Some(hidden) = self.xobject_oc_cache.get(&xobject) {
return *hidden;
}
let hidden = self.layer_hidden(doc, oc);
self.xobject_oc_cache.insert(xobject, hidden);
hidden
}
fn collect_hidden_expression_groups(
doc: &Document,
expression: &Object,
hidden_layers: &HashSet<ObjRef>,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
found: &mut HashSet<ObjRef>,
) -> bool {
if depth > 64 || *budget == 0 {
return false;
}
*budget -= 1;
let reference = expression.as_ref();
if let Some(r) = reference
&& !path.insert(r)
{
return false;
}
let resolved = doc.resolve(expression);
let complete = match &*resolved {
Object::Dict(d) if has_type(doc, d, b"OCG") => {
if let Some(r) = reference
&& hidden_layers.contains(&r)
{
found.insert(r);
}
reference.is_some()
}
Object::Array(items) => {
let Some(Object::Name(operator)) = items.first() else { return false };
let operands = match operator.as_slice() {
b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..),
b"Not" if items.len() == 2 => items.get(1..),
_ => None,
};
operands.is_some_and(|operands| {
operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found))
})
}
_ => false,
};
if let Some(r) = reference {
path.remove(&r);
}
complete
}
/// Evaluate an optional-content visibility expression (`/VE`) against the default config.
/// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
fn visibility_expression(
&self,
doc: &Document,
expression: &Object,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
) -> Option<bool> {
if depth > 64 || *budget == 0 {
return None;
}
*budget -= 1;
let reference = expression.as_ref();
// A reference to a reference is no listed group: the renderer rejects the alias, so it
// is not taken as visible because the object it names isn't hidden.
if reference.is_some_and(|r| matches!(*doc.get(r), Object::Ref(_))) {
return None;
}
if let Some(r) = reference
&& !path.insert(r)
{
return None;
}
let resolved = doc.resolve(expression);
let result = match &*resolved {
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(),
_ => return None,
};
match operator {
b"And" if items.len() >= 2 => {
let mut visible = true;
for item in items.iter().skip(1) {
visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Or" if items.len() >= 2 => {
let mut visible = false;
for item in items.iter().skip(1) {
visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
_ => None,
}
}
_ => None,
};
if let Some(r) = reference {
path.remove(&r);
}
result
}
/// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
/// syntax, cycles, and exhausted budgets count as a possible hidden reference.
fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet<ObjRef>, budget: &mut usize) -> bool {
if depth > 64 || *budget == 0 {
return true;
}
*budget -= 1;
if let Some(r) = expression.as_ref() {
if self.hidden_layers.contains(&r) {
return true;
}
if matches!(*doc.get(r), Object::Ref(_)) {
return true;
}
if !path.insert(r) {
return true;
}
}
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 has_type(doc, d, b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(_) => true,
_ => false,
};
if let Some(r) = expression.as_ref() {
path.remove(&r);
}
found
}
}
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
/// included). Returns the remaining operators and how many blocks went.
pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec<bool> = Vec::new();
let mut removed = 0;
for op in ops {
let hidden_now = stack.last().copied().unwrap_or(false);
match op.op.as_slice() {
b"BDC" | b"BMC" => {
let this = op.is("BDC")
// The tag and the property list are the last two operands, as the renderer
// reads them: `(x) /OC /MC0 BDC` is a layer's block too.
&& op.name_back(1) == Some(b"OC")
&& match op.back(0) {
Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)),
Some(o) => scope.layer_hidden(doc, o),
None => false,
};
if this && !hidden_now {
removed += 1;
}
stack.push(hidden_now || this);
if !(hidden_now || this) {
out.push(op);
}
}
b"EMC" => {
let was = stack.pop().unwrap_or(false);
if !was {
out.push(op);
}
}
_ => {
if !hidden_now {
out.push(op);
}
}
}
}
(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| crate::resolve_chain(doc, t).as_name() == Some(name))
}