Files
pdfcraft/vendor/hayro-interpret/src/ocg.rs
T
mtlcl410andClaude Opus 5.5 d0051fe68b M10: a visibility expression naming an object that isn't a listed group is malformed (#2986)
Review follow-ups on #2987; the patch (40) test reference moves into the Test column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
2026-10-19 06:05:16 +08:00

245 lines
8.1 KiB
Rust

use hayro_syntax::object::dict::keys::{BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE, VE};
use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier};
use std::collections::HashSet;
pub(crate) struct OcgState {
inactive_ocgs: HashSet<ObjectIdentifier>,
/// PdfCraft patch (40): every group the document lists in `/OCProperties /OCGs`, so a
/// visibility expression naming anything else is known to be malformed.
known_ocgs: HashSet<ObjectIdentifier>,
visibility_stack: Vec<bool>,
}
impl OcgState {
fn dummy() -> Self {
Self {
inactive_ocgs: HashSet::default(),
known_ocgs: HashSet::default(),
visibility_stack: vec![],
}
}
pub(crate) fn from_catalog(catalog: &Dict<'_>) -> Self {
let Some(oc_properties) = catalog.get::<Dict<'_>>(OCPROPERTIES) else {
return Self::dummy();
};
let Some(config) = oc_properties.get::<Dict<'_>>(D) else {
return Self::dummy();
};
let mut inactive = HashSet::new();
let known: HashSet<ObjectIdentifier> = oc_properties
.get::<Array<'_>>(OCGS)
.map(|ocgs| {
ocgs.raw_iter()
.filter_map(|item| item.as_obj_ref())
.map(Into::into)
.collect()
})
.unwrap_or_default();
let base_state = config
.get::<Name<'_>>(BASE_STATE)
.and_then(|b| BaseState::from_name(b.as_ref()));
if base_state.unwrap_or(BaseState::On) == BaseState::Off
&& let Some(ocgs) = oc_properties.get::<Array<'_>>(OCGS)
{
for item in ocgs.raw_iter() {
if let Some(ref_) = item.as_obj_ref() {
let id: ObjectIdentifier = ref_.into();
inactive.insert(id);
}
}
}
let mut read_ocg_array = |key, insert_active: bool| {
if let Some(arr) = config.get::<Array<'_>>(key) {
for item in arr.raw_iter() {
if let Some(ref_) = item.as_obj_ref() {
let id: ObjectIdentifier = ref_.into();
if insert_active {
inactive.remove(&id);
} else {
inactive.insert(id);
}
}
}
}
};
read_ocg_array(ON, true);
read_ocg_array(OFF, false);
Self {
inactive_ocgs: inactive,
known_ocgs: known,
visibility_stack: Vec::new(),
}
}
pub(crate) fn begin_single_oc(&mut self, ocg_id: ObjectIdentifier) {
let is_active = !self.inactive_ocgs.contains(&ocg_id);
let visible = self.is_visible() && is_active;
self.visibility_stack.push(visible);
}
pub(crate) fn begin_ocmd(&mut self, ocmd: &Dict<'_>) {
// PdfCraft patch (40): a visibility expression takes precedence over /OCGs and /P
// (ISO 32000-2 §8.11.2.2); a malformed one is ignored and they decide instead.
if let Some(visible) = ocmd.get::<Array<'_>>(VE).and_then(|ve| self.visibility_expression(&ve, 0)) {
let visible = self.is_visible() && visible;
self.visibility_stack.push(visible);
return;
}
let policy = ocmd
.get::<Name<'_>>(P)
.and_then(|n| OcmdPolicy::from_name(n.as_ref()))
.unwrap_or(OcmdPolicy::AnyOn);
let mut ocg_ids: Vec<ObjectIdentifier> = Vec::new();
if let Some(arr) = ocmd.get::<Array<'_>>(OCGS) {
for item in arr.raw_iter() {
if let Some(ref_) = item.as_obj_ref() {
ocg_ids.push(ref_.into());
}
}
} else if let Some(ref_) = ocmd.get_ref(OCGS) {
ocg_ids.push(ref_.into());
}
let is_active = if ocg_ids.is_empty() {
true
} else {
match policy {
OcmdPolicy::AllOn => ocg_ids.iter().all(|id| !self.inactive_ocgs.contains(id)),
OcmdPolicy::AnyOn => ocg_ids.iter().any(|id| !self.inactive_ocgs.contains(id)),
OcmdPolicy::AnyOff => ocg_ids.iter().any(|id| self.inactive_ocgs.contains(id)),
OcmdPolicy::AllOff => ocg_ids.iter().all(|id| self.inactive_ocgs.contains(id)),
}
};
let visible = self.is_visible() && is_active;
self.visibility_stack.push(visible);
}
/// PdfCraft patch (40): evaluate a visibility expression (`/VE`, PDF 1.6): `[/And e…]`,
/// `[/Or e…]` or `[/Not e]`, each `e` an optional content group (by reference) or a nested
/// expression. `None` for a malformed expression: an unknown operator, a `/Not` without
/// exactly one operand, an `/And` or `/Or` without any, an operand that is neither (a
/// reference to an object the document doesn't list as a group is neither), or nesting
/// deeper than `MAX_VISIBILITY_EXPRESSION_DEPTH`.
fn visibility_expression(&self, expr: &Array<'_>, depth: usize) -> Option<bool> {
if depth > MAX_VISIBILITY_EXPRESSION_DEPTH {
return None;
}
let mut items = expr.raw_iter();
let operator = match items.next()? {
MaybeRef::NotRef(Object::Name(name)) => name,
_ => return None,
};
let (mut count, mut all, mut any) = (0usize, true, false);
for item in items {
let value = match item {
MaybeRef::Ref(r) => {
let id: ObjectIdentifier = r.into();
if !self.known_ocgs.contains(&id) {
return None;
}
!self.inactive_ocgs.contains(&id)
}
MaybeRef::NotRef(Object::Array(nested)) => self.visibility_expression(&nested, depth + 1)?,
_ => return None,
};
count = count.saturating_add(1);
all &= value;
any |= value;
}
match (operator.as_ref(), count) {
(b"And", 1..) => Some(all),
(b"Or", 1..) => Some(any),
(b"Not", 1) => Some(!all),
_ => None,
}
}
pub(crate) fn begin_ocg(&mut self, props: &Dict<'_>, ref_id: ObjectIdentifier) {
match props.get::<Name<'_>>(TYPE).as_deref() {
Some(OCMD) => self.begin_ocmd(props),
_ => self.begin_single_oc(ref_id),
}
}
pub(crate) fn begin_marked_content(&mut self) {
let visible = self.is_visible();
self.visibility_stack.push(visible);
}
pub(crate) fn end_marked_content(&mut self) {
self.visibility_stack.pop();
}
/// PdfCraft patch: override one group's state (viewer layer toggles).
pub(crate) fn set_active(&mut self, id: ObjectIdentifier, active: bool) {
if active {
self.inactive_ocgs.remove(&id);
} else {
self.inactive_ocgs.insert(id);
}
}
pub(crate) fn is_visible(&self) -> bool {
self.visibility_stack.last().copied().unwrap_or(true)
}
}
impl Default for OcgState {
fn default() -> Self {
Self::dummy()
}
}
/// PdfCraft patch (40): how deep visibility expressions may nest; deeper ones are malformed.
const MAX_VISIBILITY_EXPRESSION_DEPTH: usize = 32;
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum BaseState {
On,
Off,
Unchanged,
}
impl BaseState {
fn from_name(name: &[u8]) -> Option<Self> {
match name {
b"ON" => Some(Self::On),
b"OFF" => Some(Self::Off),
b"Unchanged" => Some(Self::Unchanged),
_ => None,
}
}
}
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum OcmdPolicy {
AllOn,
AnyOn,
AnyOff,
AllOff,
}
impl OcmdPolicy {
fn from_name(name: &[u8]) -> Option<Self> {
match name {
b"AllOn" => Some(Self::AllOn),
b"AnyOn" => Some(Self::AnyOn),
b"AnyOff" => Some(Self::AnyOff),
b"AllOff" => Some(Self::AllOff),
_ => None,
}
}
}