Skip to main content

stet_pdf_reader/layers/
configuration.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Optional Content Group hierarchy and configurations.
6//!
7//! Defined in ISO 32000-2 §8.11.4. Each PDF carries one default
8//! configuration (`/OCProperties /D`) and an optional array of
9//! alternate configurations (`/OCProperties /Configs`); a configuration
10//! says which layers are initially on/off, how they're presented in a
11//! UI tree (`/Order`), how their visibility is grouped (`/RBGroups`),
12//! and which layers cannot be toggled by the user (`/Locked`).
13//!
14//! This module turns each configuration dict into a typed
15//! [`Configuration`] and exposes the hierarchy as a [`LayerTree`].
16
17use crate::diagnostics::{ParsePhase, Severity, WarningSink};
18use crate::metadata::pdf_string_to_rust_pub;
19use crate::objects::{PdfDict, PdfObj};
20use crate::resolver::Resolver;
21
22use super::metadata::LayerIntent;
23
24/// A presentation hierarchy for layers, parsed from `/Order`.
25///
26/// Empty when the configuration has no `/Order` entry — UI consumers
27/// should fall back to a flat list of [`super::Layer`] in document
28/// order.
29#[derive(Debug, Clone, Default)]
30pub struct LayerTree {
31    /// Top-level nodes, in display order.
32    pub nodes: Vec<LayerTreeNode>,
33}
34
35/// One node in the layer tree.
36#[derive(Debug, Clone)]
37#[non_exhaustive]
38pub enum LayerTreeNode {
39    /// A leaf referencing a single layer by OCG object number.
40    Layer(u32),
41    /// A labelled section. The label and header layer are both
42    /// optional:
43    ///
44    /// - `label = Some(_), header_layer = None` — anonymous section
45    ///   with a string-literal heading (e.g. `(Backgrounds)` followed
46    ///   by a child array).
47    /// - `header_layer = Some(_), label = None` — section whose
48    ///   heading is the layer immediately preceding a child array.
49    /// - both `None` — anonymous section (a bare nested array).
50    Section {
51        label: Option<String>,
52        header_layer: Option<u32>,
53        children: Vec<LayerTreeNode>,
54    },
55}
56
57/// One configuration of layer state and presentation.
58///
59/// `index = 0` is the default configuration (`/OCProperties /D`);
60/// indices 1..N correspond to entries 0..N-1 in `/OCProperties /Configs`.
61#[derive(Debug, Clone)]
62pub struct Configuration {
63    /// 0 = default `/D`; 1..N = `/Configs[i-1]`.
64    pub index: usize,
65    /// `/Name` — display label for this configuration.
66    pub name: Option<String>,
67    /// `/Creator` — application that authored this configuration.
68    pub creator: Option<String>,
69    /// `/BaseState` — starting visibility for every layer before
70    /// `/ON` and `/OFF` overrides apply.
71    pub base_state: BaseState,
72    /// `/ON` — layers explicitly turned on.
73    pub on: Vec<u32>,
74    /// `/OFF` — layers explicitly turned off.
75    pub off: Vec<u32>,
76    /// `/Intent` — author's hint about the audiences this
77    /// configuration is meant for.
78    pub intent: LayerIntent,
79    /// `/AS` — automatic-state rules that re-apply `/Usage` hints
80    /// under render intents.
81    pub auto_state: Vec<AutoStateRule>,
82    /// `/Order` — display hierarchy.
83    pub order: LayerTree,
84    /// `/ListMode` — whether the layer panel should show all pages or
85    /// only the visible page's layers.
86    pub list_mode: ListMode,
87    /// `/RBGroups` — radio-button groups: turning one layer on in a
88    /// group implies turning the others in that group off.
89    pub rb_groups: Vec<Vec<u32>>,
90    /// `/Locked` — layers the user is not allowed to toggle from a
91    /// layer panel.
92    pub locked: Vec<u32>,
93}
94
95/// `/BaseState` value.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum BaseState {
99    /// `/ON` — all layers visible until `/OFF` flips them.
100    On,
101    /// `/OFF` — all layers hidden until `/ON` flips them.
102    Off,
103    /// `/Unchanged` — preserve the current visibility from a previous
104    /// configuration. Only meaningful for alternate configurations.
105    Unchanged,
106}
107
108impl BaseState {
109    fn from_name(name: &[u8]) -> Option<Self> {
110        match name {
111            b"ON" => Some(BaseState::On),
112            b"OFF" => Some(BaseState::Off),
113            b"Unchanged" => Some(BaseState::Unchanged),
114            _ => None,
115        }
116    }
117}
118
119/// `/ListMode` — layer-panel visibility scope.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121#[non_exhaustive]
122pub enum ListMode {
123    /// `/AllPages` — show every layer regardless of which pages use
124    /// it. Default.
125    #[default]
126    AllPages,
127    /// `/VisiblePages` — show only layers that appear on the
128    /// currently displayed page.
129    VisiblePages,
130}
131
132impl ListMode {
133    fn from_name(name: &[u8]) -> Option<Self> {
134        match name {
135            b"AllPages" => Some(ListMode::AllPages),
136            b"VisiblePages" => Some(ListMode::VisiblePages),
137            _ => None,
138        }
139    }
140}
141
142/// One `/AS` automatic-state rule.
143#[derive(Debug, Clone)]
144pub struct AutoStateRule {
145    /// `/Event` — render intent the rule applies to.
146    pub event: AutoStateEvent,
147    /// `/Category` — `/Usage` sub-dict names this rule consults.
148    pub categories: Vec<String>,
149    /// `/OCGs` — layers this rule applies to.
150    pub ocgs: Vec<u32>,
151}
152
153/// `/Event` value on an `/AS` rule.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum AutoStateEvent {
157    View,
158    Print,
159    Export,
160}
161
162impl AutoStateEvent {
163    fn from_name(name: &[u8]) -> Option<Self> {
164        match name {
165            b"View" => Some(AutoStateEvent::View),
166            b"Print" => Some(AutoStateEvent::Print),
167            b"Export" => Some(AutoStateEvent::Export),
168            _ => None,
169        }
170    }
171}
172
173/// Walk `/OCProperties` and produce one [`Configuration`] for the
174/// default `/D` plus one for each entry in `/Configs`.
175///
176/// Returns an empty `Vec` when the document has no `/OCProperties`.
177/// The default configuration is always at index 0; alternate configs
178/// follow at indices 1..N preserving the order in `/Configs`.
179pub fn parse_configurations(resolver: &Resolver, sink: &WarningSink) -> Vec<Configuration> {
180    let Some(catalog) = catalog_dict(resolver) else {
181        return Vec::new();
182    };
183    let Some(oc_props_obj) = catalog.get(b"OCProperties") else {
184        return Vec::new();
185    };
186    let Ok(oc_props) = resolver.deref(oc_props_obj) else {
187        return Vec::new();
188    };
189    let Some(oc_dict) = oc_props.as_dict() else {
190        return Vec::new();
191    };
192
193    let mut configs = Vec::new();
194
195    // /D — default configuration. Required when /OCProperties exists.
196    if let Some(d_obj) = oc_dict.get(b"D")
197        && let Ok(d_resolved) = resolver.deref(d_obj)
198        && let Some(d_dict) = d_resolved.as_dict()
199    {
200        configs.push(parse_configuration(resolver, d_dict, 0, sink));
201    } else {
202        sink.record(
203            ParsePhase::Layers,
204            None,
205            Severity::Warning,
206            "/OCProperties missing required /D configuration; skipped",
207        );
208    }
209
210    // /Configs — alternate configurations.
211    if let Some(configs_obj) = oc_dict.get(b"Configs")
212        && let Ok(configs_resolved) = resolver.deref(configs_obj)
213        && let Some(arr) = configs_resolved.as_array()
214    {
215        for (i, entry) in arr.iter().enumerate() {
216            let Ok(resolved) = resolver.deref(entry) else {
217                sink.record(
218                    ParsePhase::Layers,
219                    None,
220                    Severity::Warning,
221                    format!("/Configs[{i}] could not be resolved; skipped"),
222                );
223                continue;
224            };
225            let Some(dict) = resolved.as_dict() else {
226                sink.record(
227                    ParsePhase::Layers,
228                    None,
229                    Severity::Warning,
230                    format!("/Configs[{i}] is not a dict; skipped"),
231                );
232                continue;
233            };
234            configs.push(parse_configuration(resolver, dict, i + 1, sink));
235        }
236    }
237
238    configs
239}
240
241/// Parse a single configuration dict (the `/D` dict or one
242/// `/Configs[i]`).
243pub fn parse_configuration(
244    resolver: &Resolver,
245    dict: &PdfDict,
246    index: usize,
247    sink: &WarningSink,
248) -> Configuration {
249    let name = dict.get(b"Name").and_then(pdf_string_to_rust_pub);
250    let creator = dict.get(b"Creator").and_then(pdf_string_to_rust_pub);
251
252    let base_state = dict
253        .get_name(b"BaseState")
254        .and_then(BaseState::from_name)
255        .unwrap_or(BaseState::On);
256
257    let on = collect_ocg_refs(resolver, dict, b"ON");
258    let off = collect_ocg_refs(resolver, dict, b"OFF");
259    let locked = collect_ocg_refs(resolver, dict, b"Locked");
260
261    let intent = dict
262        .get(b"Intent")
263        .map(|obj| LayerIntent::from_obj(resolver, obj))
264        .unwrap_or(LayerIntent::View);
265
266    let order = dict
267        .get(b"Order")
268        .and_then(|obj| resolver.deref(obj).ok())
269        .map(|resolved| parse_order(resolver, &resolved, sink))
270        .unwrap_or_default();
271
272    let list_mode = dict
273        .get_name(b"ListMode")
274        .and_then(ListMode::from_name)
275        .unwrap_or_default();
276
277    let auto_state = dict
278        .get(b"AS")
279        .and_then(|obj| resolver.deref(obj).ok())
280        .map(|resolved| parse_auto_state(resolver, &resolved, sink))
281        .unwrap_or_default();
282
283    let rb_groups = dict
284        .get(b"RBGroups")
285        .and_then(|obj| resolver.deref(obj).ok())
286        .map(|resolved| parse_rb_groups(resolver, &resolved))
287        .unwrap_or_default();
288
289    Configuration {
290        index,
291        name,
292        creator,
293        base_state,
294        on,
295        off,
296        intent,
297        auto_state,
298        order,
299        list_mode,
300        rb_groups,
301        locked,
302    }
303}
304
305/// Parse `/Order` according to ISO 32000-2 §8.11.4.3.
306///
307/// The array is walked left-to-right with one element of look-back
308/// state to classify each item:
309///
310/// - **OCG ref** — leaf [`LayerTreeNode::Layer`].
311/// - **OCG ref immediately followed by a nested array** — the array
312///   is consumed as that layer's section (header-layer section).
313/// - **String literal followed by a nested array** — labelled section.
314/// - **Bare nested array** — anonymous section.
315/// - **String not followed by an array** — dropped with a warning.
316pub fn parse_order(resolver: &Resolver, obj: &PdfObj, sink: &WarningSink) -> LayerTree {
317    let Some(arr) = obj.as_array() else {
318        return LayerTree::default();
319    };
320    LayerTree {
321        nodes: parse_order_nodes(resolver, arr, sink),
322    }
323}
324
325fn parse_order_nodes(
326    resolver: &Resolver,
327    items: &[PdfObj],
328    sink: &WarningSink,
329) -> Vec<LayerTreeNode> {
330    let mut nodes = Vec::new();
331    let mut i = 0;
332    while i < items.len() {
333        let item = &items[i];
334
335        // Resolve refs to inspect type, but keep the original ref's
336        // (num, gen) for layer leaves.
337        let resolved = resolver.deref(item).ok();
338        let view = resolved.as_ref().unwrap_or(item);
339
340        match view {
341            // String literal: labelled section. The next item must
342            // be a nested array; if it isn't, drop the string.
343            PdfObj::Str(s) => {
344                let label = crate::metadata::decode_pdf_text_string_pub(s);
345                if let Some(next) = items.get(i + 1) {
346                    let next_resolved = resolver.deref(next).ok();
347                    let next_view = next_resolved.as_ref().unwrap_or(next);
348                    if let PdfObj::Array(children_arr) = next_view {
349                        let children = parse_order_nodes(resolver, children_arr, sink);
350                        nodes.push(LayerTreeNode::Section {
351                            label: Some(label),
352                            header_layer: None,
353                            children,
354                        });
355                        i += 2;
356                        continue;
357                    }
358                }
359                sink.record(
360                    ParsePhase::Layers,
361                    None,
362                    Severity::Warning,
363                    format!("/Order string label {label:?} not followed by a child array; dropped"),
364                );
365                i += 1;
366            }
367
368            // Bare nested array: anonymous section.
369            PdfObj::Array(children_arr) => {
370                let children = parse_order_nodes(resolver, children_arr, sink);
371                nodes.push(LayerTreeNode::Section {
372                    label: None,
373                    header_layer: None,
374                    children,
375                });
376                i += 1;
377            }
378
379            // OCG dict (we resolved a ref to a dict). Decide whether
380            // the *next* item is a nested array, in which case this
381            // layer is a section header.
382            PdfObj::Dict(_) => {
383                let Some((ocg_id, _gen)) = item.as_ref() else {
384                    sink.record(
385                        ParsePhase::Layers,
386                        None,
387                        Severity::Warning,
388                        "/Order layer entry is an inline OCG dict (not a ref); skipped",
389                    );
390                    i += 1;
391                    continue;
392                };
393                if let Some(next) = items.get(i + 1) {
394                    let next_resolved = resolver.deref(next).ok();
395                    let next_view = next_resolved.as_ref().unwrap_or(next);
396                    if let PdfObj::Array(children_arr) = next_view {
397                        let children = parse_order_nodes(resolver, children_arr, sink);
398                        nodes.push(LayerTreeNode::Section {
399                            label: None,
400                            header_layer: Some(ocg_id),
401                            children,
402                        });
403                        i += 2;
404                        continue;
405                    }
406                }
407                nodes.push(LayerTreeNode::Layer(ocg_id));
408                i += 1;
409            }
410
411            _ => {
412                sink.record(
413                    ParsePhase::Layers,
414                    None,
415                    Severity::Warning,
416                    "/Order item is neither layer ref, string, nor array; skipped",
417                );
418                i += 1;
419            }
420        }
421    }
422    nodes
423}
424
425/// Parse `/AS` — an array of rule dicts.
426pub fn parse_auto_state(
427    resolver: &Resolver,
428    obj: &PdfObj,
429    sink: &WarningSink,
430) -> Vec<AutoStateRule> {
431    let Some(arr) = obj.as_array() else {
432        return Vec::new();
433    };
434    let mut rules = Vec::with_capacity(arr.len());
435    for entry in arr {
436        let Ok(resolved) = resolver.deref(entry) else {
437            continue;
438        };
439        let Some(dict) = resolved.as_dict() else {
440            continue;
441        };
442        let Some(event) = dict.get_name(b"Event").and_then(AutoStateEvent::from_name) else {
443            sink.record(
444                ParsePhase::Layers,
445                None,
446                Severity::Warning,
447                "/AS rule missing or unknown /Event; skipped",
448            );
449            continue;
450        };
451
452        let categories = dict
453            .get_array(b"Category")
454            .map(|arr| {
455                arr.iter()
456                    .filter_map(|o| o.as_name())
457                    .map(|n| String::from_utf8_lossy(n).into_owned())
458                    .collect()
459            })
460            .unwrap_or_default();
461
462        let ocgs = dict
463            .get(b"OCGs")
464            .and_then(|obj| resolver.deref(obj).ok())
465            .and_then(|resolved| resolved.as_array().map(<[PdfObj]>::to_vec))
466            .map(|arr| {
467                arr.iter()
468                    .filter_map(|o| o.as_ref().map(|(n, _)| n))
469                    .collect()
470            })
471            .unwrap_or_default();
472
473        rules.push(AutoStateRule {
474            event,
475            categories,
476            ocgs,
477        });
478    }
479    rules
480}
481
482/// Parse `/RBGroups` — an array of nested arrays, each containing
483/// OCG refs.
484pub fn parse_rb_groups(resolver: &Resolver, obj: &PdfObj) -> Vec<Vec<u32>> {
485    let Some(arr) = obj.as_array() else {
486        return Vec::new();
487    };
488    let mut groups = Vec::with_capacity(arr.len());
489    for entry in arr {
490        let Ok(resolved) = resolver.deref(entry) else {
491            continue;
492        };
493        let Some(group_arr) = resolved.as_array() else {
494            continue;
495        };
496        let group: Vec<u32> = group_arr
497            .iter()
498            .filter_map(|o| o.as_ref().map(|(n, _)| n))
499            .collect();
500        if !group.is_empty() {
501            groups.push(group);
502        }
503    }
504    groups
505}
506
507/// Read `<key>` on a configuration dict as a flat list of OCG object
508/// numbers. The value can be an indirect-referenced array.
509fn collect_ocg_refs(resolver: &Resolver, dict: &PdfDict, key: &[u8]) -> Vec<u32> {
510    let Some(obj) = dict.get(key) else {
511        return Vec::new();
512    };
513    let Ok(resolved) = resolver.deref(obj) else {
514        return Vec::new();
515    };
516    let Some(arr) = resolved.as_array() else {
517        return Vec::new();
518    };
519    arr.iter()
520        .filter_map(|o| o.as_ref().map(|(n, _)| n))
521        .collect()
522}
523
524fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
525    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
526        && let Ok(obj) = resolver.resolve(num, gen_num)
527        && let Some(dict) = obj.as_dict()
528    {
529        return Some(dict.clone());
530    }
531    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn base_state_names() {
540        assert_eq!(BaseState::from_name(b"ON"), Some(BaseState::On));
541        assert_eq!(BaseState::from_name(b"OFF"), Some(BaseState::Off));
542        assert_eq!(
543            BaseState::from_name(b"Unchanged"),
544            Some(BaseState::Unchanged)
545        );
546        assert_eq!(BaseState::from_name(b"on"), None);
547    }
548
549    #[test]
550    fn list_mode_names() {
551        assert_eq!(ListMode::from_name(b"AllPages"), Some(ListMode::AllPages));
552        assert_eq!(
553            ListMode::from_name(b"VisiblePages"),
554            Some(ListMode::VisiblePages)
555        );
556        assert_eq!(ListMode::from_name(b"Other"), None);
557    }
558
559    #[test]
560    fn auto_state_event_names() {
561        assert_eq!(
562            AutoStateEvent::from_name(b"View"),
563            Some(AutoStateEvent::View)
564        );
565        assert_eq!(
566            AutoStateEvent::from_name(b"Print"),
567            Some(AutoStateEvent::Print)
568        );
569        assert_eq!(
570            AutoStateEvent::from_name(b"Export"),
571            Some(AutoStateEvent::Export)
572        );
573        assert_eq!(AutoStateEvent::from_name(b"Save"), None);
574    }
575}