Skip to main content

stet_pdf_reader/layers/
metadata.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Per-layer metadata (Optional Content Groups).
6//!
7//! Defined in ISO 32000-2 §8.11.2 (the OCG dictionary itself) and
8//! §8.11.4.4 (the `/Usage` sub-dict, which describes the contexts —
9//! view, print, export — in which the layer is meaningful).
10//!
11//! Phase 1 is **document-wide enumeration**: produce one [`Layer`]
12//! record per OCG referenced from the catalog's
13//! `/OCProperties /OCGs` array. Visibility defaults come from the
14//! default configuration's `/OFF` array; locked state from `/Locked`.
15//! Hierarchy (`/Order`) and alternate configurations live in Phase 2.
16
17use std::collections::HashSet;
18
19use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
20use crate::metadata::{decode_pdf_text_string_pub, pdf_string_to_rust_pub};
21use crate::objects::{PdfDict, PdfObj};
22use crate::resolver::Resolver;
23
24/// One Optional Content Group (PDF "layer").
25#[derive(Debug, Clone)]
26pub struct Layer {
27    /// PDF object number of the OCG dict — stable across renders and
28    /// the canonical key for matching layers to display-list
29    /// `OcgGroup` elements.
30    pub ocg_id: u32,
31    /// `/Name` — UTF-8 decoded display label for layer panels.
32    pub name: String,
33    /// `/Intent` — author's hint about which contexts this layer is
34    /// meaningful in. Default `View`.
35    pub intent: LayerIntent,
36    /// True when listed in `/OCProperties /D /Locked`. UI consumers
37    /// should disable user-driven toggling for locked layers; the
38    /// reader does not enforce this.
39    pub locked: bool,
40    /// `/Usage` sub-dict — context-specific hints (view/print/export
41    /// state, zoom range, language, page-element role, etc.).
42    pub usage: LayerUsage,
43    /// `/CreatorInfo` on the OCG itself, if present. The same
44    /// sub-dict can also appear under `/Usage`; Phase 1 captures
45    /// both.
46    pub creator_info: Option<CreatorInfo>,
47    /// Initial visibility under the default configuration. Derived
48    /// from membership in `/OCProperties /D /OFF` (false) or `/ON`
49    /// (true); absent OCGs default to true per ISO 32000-2.
50    pub default_visible: bool,
51}
52
53/// `/Intent` on an OCG, declaring which audiences the layer is for.
54///
55/// PDF allows a single name or an array of names; an array becomes
56/// [`LayerIntent::Multiple`]. Names other than `View`/`Design`/`Export`
57/// are preserved verbatim under [`LayerIntent::Other`].
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum LayerIntent {
61    /// Default. Layer is meaningful for on-screen viewing.
62    View,
63    /// Layer represents structural design content (CAD drawings,
64    /// engineering layers, etc.). Some viewers ignore non-`View`
65    /// intents in interactive mode.
66    Design,
67    /// Layer is intended for export workflows (data extraction,
68    /// archiving).
69    Export,
70    /// Multiple intents — array form.
71    Multiple(Vec<String>),
72    /// An intent name not covered by the spec's standard set.
73    Other(String),
74}
75
76impl LayerIntent {
77    /// Construct from the raw `/Intent` value.
78    pub(super) fn from_obj(resolver: &Resolver, obj: &PdfObj) -> Self {
79        match resolver.deref(obj).ok().as_ref().unwrap_or(obj) {
80            PdfObj::Name(name) => Self::from_single_name(name),
81            PdfObj::Array(items) => {
82                let names: Vec<String> = items
83                    .iter()
84                    .filter_map(|o| {
85                        let resolved = resolver.deref(o).ok();
86                        let bytes = resolved
87                            .as_ref()
88                            .and_then(|r| r.as_name())
89                            .or_else(|| o.as_name())?;
90                        Some(String::from_utf8_lossy(bytes).into_owned())
91                    })
92                    .collect();
93                if names.len() == 1 {
94                    Self::from_single_name(names[0].as_bytes())
95                } else {
96                    LayerIntent::Multiple(names)
97                }
98            }
99            _ => LayerIntent::View,
100        }
101    }
102
103    fn from_single_name(name: &[u8]) -> Self {
104        match name {
105            b"View" => LayerIntent::View,
106            b"Design" => LayerIntent::Design,
107            b"Export" => LayerIntent::Export,
108            other => LayerIntent::Other(String::from_utf8_lossy(other).into_owned()),
109        }
110    }
111}
112
113/// Hints from the OCG `/Usage` sub-dict.
114///
115/// Every field is optional — a layer with no `/Usage` produces a
116/// fully-`None` value. Render-intent helpers in `crate::layers`
117/// (e.g. `layer_set_for`) turn these hints into automatic visibility
118/// adjustments under View / Print / Export intents.
119#[derive(Debug, Clone, Default)]
120pub struct LayerUsage {
121    /// `/CreatorInfo` sub-dict — application that authored the layer.
122    pub creator_info: Option<CreatorInfo>,
123    /// `/Language` sub-dict — language tag and "preferred" flag.
124    pub language: Option<LanguageUsage>,
125    /// `/Export` sub-dict — visibility under export intent.
126    pub export: Option<ExportUsage>,
127    /// `/Zoom` sub-dict — min/max zoom range in which the layer is
128    /// visible.
129    pub zoom: Option<ZoomUsage>,
130    /// `/Print` sub-dict — visibility and subtype hint under print
131    /// intent.
132    pub print: Option<PrintUsage>,
133    /// `/View` sub-dict — visibility under interactive view intent.
134    pub view: Option<ViewUsage>,
135    /// `/User` sub-dict — user/group ownership.
136    pub user: Option<UserUsage>,
137    /// `/PageElement` sub-dict — role on the page (header, footer,
138    /// foreground, background, logo).
139    pub page_element: Option<PageElementSubtype>,
140}
141
142/// Usage state — `/ON` or `/OFF`.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum UsageState {
146    On,
147    Off,
148}
149
150impl UsageState {
151    fn from_name(name: &[u8]) -> Option<Self> {
152        match name {
153            b"ON" => Some(UsageState::On),
154            b"OFF" => Some(UsageState::Off),
155            _ => None,
156        }
157    }
158}
159
160/// `/View` sub-dict.
161#[derive(Debug, Clone, Copy)]
162pub struct ViewUsage {
163    /// `/ViewState` — visibility for interactive view.
164    pub state: UsageState,
165}
166
167/// `/Print` sub-dict.
168#[derive(Debug, Clone)]
169pub struct PrintUsage {
170    /// `/Subtype` — `Trapped`, `PrintersMarks`, `Watermark`, etc.
171    /// Preserved verbatim; the reader does not interpret it.
172    pub subtype: Option<String>,
173    /// `/PrintState` — visibility for print intent.
174    pub state: UsageState,
175}
176
177/// `/Export` sub-dict.
178#[derive(Debug, Clone, Copy)]
179pub struct ExportUsage {
180    /// `/ExportState` — visibility for export intent.
181    pub state: UsageState,
182}
183
184/// `/Zoom` sub-dict — visibility range.
185#[derive(Debug, Clone, Copy, Default)]
186pub struct ZoomUsage {
187    /// `/min` — minimum magnification (inclusive). Below this the
188    /// layer is hidden.
189    pub min: Option<f64>,
190    /// `/max` — maximum magnification (exclusive). At or above this
191    /// the layer is hidden.
192    pub max: Option<f64>,
193}
194
195/// `/Language` sub-dict.
196#[derive(Debug, Clone)]
197pub struct LanguageUsage {
198    /// `/Lang` — BCP 47 language tag (e.g. `en-US`).
199    pub lang: String,
200    /// `/Preferred` — true when the layer is the preferred choice
201    /// for its language.
202    pub preferred: bool,
203}
204
205/// `/User` sub-dict — user/group ownership.
206#[derive(Debug, Clone, Default)]
207pub struct UserUsage {
208    /// `/Type` — `Ind` (individual), `Ttl` (title), `Org`
209    /// (organisation).
210    pub user_type: Option<String>,
211    /// `/Name` — list of user/group names. PDF allows either a
212    /// single string or an array; both forms collapse to this `Vec`.
213    pub names: Vec<String>,
214}
215
216/// `/PageElement /Subtype` value — role of the layer on the page.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218#[non_exhaustive]
219pub enum PageElementSubtype {
220    /// `/HF` — headers and footers. PDF combines them under one name.
221    HeaderFooter,
222    /// `/FG` — foreground content.
223    Foreground,
224    /// `/BG` — background content.
225    Background,
226    /// `/L` — logo.
227    Logo,
228}
229
230impl PageElementSubtype {
231    fn from_name(name: &[u8]) -> Option<Self> {
232        match name {
233            b"HF" => Some(PageElementSubtype::HeaderFooter),
234            b"FG" => Some(PageElementSubtype::Foreground),
235            b"BG" => Some(PageElementSubtype::Background),
236            b"L" => Some(PageElementSubtype::Logo),
237            _ => None,
238        }
239    }
240}
241
242/// `/CreatorInfo` sub-dict — authoring application + subtype hint.
243#[derive(Debug, Clone)]
244pub struct CreatorInfo {
245    /// `/Creator` — application name.
246    pub creator: String,
247    /// `/Subtype` — application-specific layer subtype hint
248    /// (e.g. `Artwork`, `Technical`).
249    pub subtype: Option<String>,
250}
251
252/// Walk the catalog's `/OCProperties /OCGs` array and produce one
253/// [`Layer`] per OCG.
254///
255/// Returns an empty `Vec` when the document has no OCGs. Default
256/// visibility comes from `/OCProperties /D /OFF`; locked state from
257/// `/OCProperties /D /Locked`. Both arrays may be indirect-referenced.
258///
259/// Cycles cannot occur (the OCGs array is a flat list of refs), so no
260/// visited-set guarding is needed; malformed entries (non-dict, no
261/// `/Type /OCG`) are skipped with a warning.
262pub fn parse_layers(resolver: &Resolver, sink: &WarningSink) -> Vec<Layer> {
263    let Some(catalog) = catalog_dict(resolver) else {
264        return Vec::new();
265    };
266    let Some(oc_props_obj) = catalog.get(b"OCProperties") else {
267        return Vec::new();
268    };
269    let Ok(oc_props) = resolver.deref(oc_props_obj) else {
270        return Vec::new();
271    };
272    let Some(oc_dict) = oc_props.as_dict() else {
273        return Vec::new();
274    };
275
276    let off_set = collect_ocg_id_set(resolver, oc_dict, b"D", b"OFF");
277    let locked_set = collect_ocg_id_set(resolver, oc_dict, b"D", b"Locked");
278
279    // Resolve /OCGs — may be a direct array or an indirect ref.
280    let Some(ocgs_obj) = oc_dict.get(b"OCGs") else {
281        return Vec::new();
282    };
283    let ocgs_resolved = match resolver.deref(ocgs_obj) {
284        Ok(o) => o,
285        Err(_) => {
286            sink.record(
287                ParsePhase::Layers,
288                None,
289                Severity::Warning,
290                "/OCProperties /OCGs could not be resolved",
291            );
292            return Vec::new();
293        }
294    };
295    let Some(ocgs) = ocgs_resolved.as_array() else {
296        sink.record(
297            ParsePhase::Layers,
298            None,
299            Severity::Warning,
300            "/OCProperties /OCGs is not an array",
301        );
302        return Vec::new();
303    };
304
305    let mut layers = Vec::with_capacity(ocgs.len());
306    let mut seen = HashSet::new();
307    for entry in ocgs {
308        let Some((num, gen_num)) = entry.as_ref() else {
309            sink.record(
310                ParsePhase::Layers,
311                None,
312                Severity::Warning,
313                "OCG entry is not an indirect reference; skipped",
314            );
315            continue;
316        };
317        if !seen.insert(num) {
318            continue;
319        }
320        match resolver.resolve(num, gen_num) {
321            Ok(obj) => {
322                if let Some(dict) = obj.as_dict() {
323                    if let Some(layer) =
324                        parse_layer(resolver, dict, num, &off_set, &locked_set, sink)
325                    {
326                        layers.push(layer);
327                    }
328                } else {
329                    sink.record(
330                        ParsePhase::Layers,
331                        Some(LocationHint::Object {
332                            obj_num: num,
333                            gen_num,
334                        }),
335                        Severity::Warning,
336                        "OCG object is not a dict; skipped",
337                    );
338                }
339            }
340            Err(_) => {
341                sink.record(
342                    ParsePhase::Layers,
343                    Some(LocationHint::Object {
344                        obj_num: num,
345                        gen_num,
346                    }),
347                    Severity::Warning,
348                    "OCG object could not be resolved; skipped",
349                );
350            }
351        }
352    }
353    layers
354}
355
356/// Parse one OCG dict into a [`Layer`].
357///
358/// Returns `None` only if `/Type` is present and is not `/OCG`; spec-
359/// conformant generators always include `/Type /OCG`, but we tolerate
360/// its absence for malformed PDFs that omit it.
361pub fn parse_layer(
362    resolver: &Resolver,
363    dict: &PdfDict,
364    ocg_id: u32,
365    off_set: &HashSet<u32>,
366    locked_set: &HashSet<u32>,
367    sink: &WarningSink,
368) -> Option<Layer> {
369    if let Some(type_name) = dict.get_name(b"Type")
370        && type_name != b"OCG"
371    {
372        sink.record(
373            ParsePhase::Layers,
374            Some(LocationHint::Object {
375                obj_num: ocg_id,
376                gen_num: 0,
377            }),
378            Severity::Warning,
379            format!(
380                "OCG dict has unexpected /Type {}; skipped",
381                String::from_utf8_lossy(type_name)
382            ),
383        );
384        return None;
385    }
386
387    let name = dict
388        .get(b"Name")
389        .and_then(pdf_string_to_rust_pub)
390        .unwrap_or_default();
391
392    let intent = dict
393        .get(b"Intent")
394        .map(|obj| LayerIntent::from_obj(resolver, obj))
395        .unwrap_or(LayerIntent::View);
396
397    let usage = dict
398        .get(b"Usage")
399        .and_then(|obj| resolver.deref(obj).ok())
400        .and_then(|resolved| resolved.as_dict().cloned())
401        .map(|d| parse_usage_dict(resolver, &d))
402        .unwrap_or_default();
403
404    let creator_info = dict
405        .get(b"CreatorInfo")
406        .and_then(|obj| resolver.deref(obj).ok())
407        .and_then(|resolved| resolved.as_dict().cloned())
408        .and_then(|d| parse_creator_info(&d));
409
410    Some(Layer {
411        ocg_id,
412        name,
413        intent,
414        locked: locked_set.contains(&ocg_id),
415        usage,
416        creator_info,
417        default_visible: !off_set.contains(&ocg_id),
418    })
419}
420
421/// Parse an entire `/Usage` dict into a [`LayerUsage`].
422pub fn parse_usage_dict(resolver: &Resolver, dict: &PdfDict) -> LayerUsage {
423    LayerUsage {
424        creator_info: dict
425            .get(b"CreatorInfo")
426            .and_then(|obj| resolver.deref(obj).ok())
427            .and_then(|resolved| resolved.as_dict().cloned())
428            .and_then(|d| parse_creator_info(&d)),
429        language: dict
430            .get(b"Language")
431            .and_then(|obj| resolver.deref(obj).ok())
432            .and_then(|resolved| resolved.as_dict().cloned())
433            .and_then(|d| parse_language(&d)),
434        export: dict
435            .get(b"Export")
436            .and_then(|obj| resolver.deref(obj).ok())
437            .and_then(|resolved| resolved.as_dict().cloned())
438            .and_then(|d| parse_export(&d)),
439        zoom: dict
440            .get(b"Zoom")
441            .and_then(|obj| resolver.deref(obj).ok())
442            .and_then(|resolved| resolved.as_dict().cloned())
443            .map(|d| parse_zoom(&d)),
444        print: dict
445            .get(b"Print")
446            .and_then(|obj| resolver.deref(obj).ok())
447            .and_then(|resolved| resolved.as_dict().cloned())
448            .and_then(|d| parse_print(&d)),
449        view: dict
450            .get(b"View")
451            .and_then(|obj| resolver.deref(obj).ok())
452            .and_then(|resolved| resolved.as_dict().cloned())
453            .and_then(|d| parse_view(&d)),
454        user: dict
455            .get(b"User")
456            .and_then(|obj| resolver.deref(obj).ok())
457            .and_then(|resolved| resolved.as_dict().cloned())
458            .map(|d| parse_user(&d)),
459        page_element: dict
460            .get(b"PageElement")
461            .and_then(|obj| resolver.deref(obj).ok())
462            .and_then(|resolved| resolved.as_dict().cloned())
463            .and_then(|d| {
464                d.get_name(b"Subtype")
465                    .and_then(PageElementSubtype::from_name)
466            }),
467    }
468}
469
470fn parse_creator_info(dict: &PdfDict) -> Option<CreatorInfo> {
471    let creator = dict.get(b"Creator").and_then(pdf_string_to_rust_pub)?;
472    let subtype = dict
473        .get_name(b"Subtype")
474        .map(|n| String::from_utf8_lossy(n).into_owned());
475    Some(CreatorInfo { creator, subtype })
476}
477
478fn parse_language(dict: &PdfDict) -> Option<LanguageUsage> {
479    let lang = dict.get(b"Lang").and_then(pdf_string_to_rust_pub)?;
480    let preferred = dict.get_name(b"Preferred") == Some(b"ON");
481    Some(LanguageUsage { lang, preferred })
482}
483
484fn parse_export(dict: &PdfDict) -> Option<ExportUsage> {
485    let state = dict
486        .get_name(b"ExportState")
487        .and_then(UsageState::from_name)?;
488    Some(ExportUsage { state })
489}
490
491fn parse_zoom(dict: &PdfDict) -> ZoomUsage {
492    ZoomUsage {
493        min: dict.get_f64(b"min"),
494        max: dict.get_f64(b"max"),
495    }
496}
497
498fn parse_print(dict: &PdfDict) -> Option<PrintUsage> {
499    let state = dict
500        .get_name(b"PrintState")
501        .and_then(UsageState::from_name)?;
502    let subtype = dict
503        .get_name(b"Subtype")
504        .map(|n| String::from_utf8_lossy(n).into_owned());
505    Some(PrintUsage { subtype, state })
506}
507
508fn parse_view(dict: &PdfDict) -> Option<ViewUsage> {
509    let state = dict
510        .get_name(b"ViewState")
511        .and_then(UsageState::from_name)?;
512    Some(ViewUsage { state })
513}
514
515fn parse_user(dict: &PdfDict) -> UserUsage {
516    let user_type = dict
517        .get_name(b"Type")
518        .map(|n| String::from_utf8_lossy(n).into_owned());
519    let names = match dict.get(b"Name") {
520        Some(PdfObj::Str(s)) => vec![decode_pdf_text_string_pub(s)],
521        Some(PdfObj::Array(items)) => items
522            .iter()
523            .filter_map(|o| match o {
524                PdfObj::Str(s) => Some(decode_pdf_text_string_pub(s)),
525                _ => None,
526            })
527            .collect(),
528        _ => Vec::new(),
529    };
530    UserUsage { user_type, names }
531}
532
533/// Read `/OCProperties /<config_key> /<set_key>` as a flat set of OCG
534/// object numbers. `config_key` is `b"D"` for the default config;
535/// `set_key` is `b"OFF"`, `b"ON"`, or `b"Locked"`.
536///
537/// Tolerates an indirect-referenced array. Non-ref entries are
538/// silently dropped (the OCG object number is the unique stable key,
539/// and inline OCG dicts are spec-violating).
540fn collect_ocg_id_set(
541    resolver: &Resolver,
542    oc_dict: &PdfDict,
543    config_key: &[u8],
544    set_key: &[u8],
545) -> HashSet<u32> {
546    let mut ids = HashSet::new();
547    let Some(config_obj) = oc_dict.get(config_key) else {
548        return ids;
549    };
550    let Ok(config_resolved) = resolver.deref(config_obj) else {
551        return ids;
552    };
553    let Some(config_dict) = config_resolved.as_dict() else {
554        return ids;
555    };
556    let Some(set_obj) = config_dict.get(set_key) else {
557        return ids;
558    };
559    let Ok(set_resolved) = resolver.deref(set_obj) else {
560        return ids;
561    };
562    if let Some(arr) = set_resolved.as_array() {
563        for o in arr {
564            if let Some((num, _gen)) = o.as_ref() {
565                ids.insert(num);
566            }
567        }
568    }
569    ids
570}
571
572fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
573    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
574        && let Ok(obj) = resolver.resolve(num, gen_num)
575        && let Some(dict) = obj.as_dict()
576    {
577        return Some(dict.clone());
578    }
579    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn intent_from_single_name() {
588        assert_eq!(LayerIntent::from_single_name(b"View"), LayerIntent::View);
589        assert_eq!(
590            LayerIntent::from_single_name(b"Design"),
591            LayerIntent::Design
592        );
593        assert_eq!(
594            LayerIntent::from_single_name(b"Export"),
595            LayerIntent::Export
596        );
597        match LayerIntent::from_single_name(b"Custom") {
598            LayerIntent::Other(s) => assert_eq!(s, "Custom"),
599            other => panic!("expected Other, got {other:?}"),
600        }
601    }
602
603    #[test]
604    fn page_element_names() {
605        assert_eq!(
606            PageElementSubtype::from_name(b"HF"),
607            Some(PageElementSubtype::HeaderFooter)
608        );
609        assert_eq!(
610            PageElementSubtype::from_name(b"FG"),
611            Some(PageElementSubtype::Foreground)
612        );
613        assert_eq!(
614            PageElementSubtype::from_name(b"BG"),
615            Some(PageElementSubtype::Background)
616        );
617        assert_eq!(
618            PageElementSubtype::from_name(b"L"),
619            Some(PageElementSubtype::Logo)
620        );
621        assert_eq!(PageElementSubtype::from_name(b"Other"), None);
622    }
623
624    #[test]
625    fn usage_state_names() {
626        assert_eq!(UsageState::from_name(b"ON"), Some(UsageState::On));
627        assert_eq!(UsageState::from_name(b"OFF"), Some(UsageState::Off));
628        assert_eq!(UsageState::from_name(b"on"), None);
629    }
630}