Skip to main content

stet_pdf_reader/
viewer_prefs.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF viewer preferences from the catalog's `/ViewerPreferences` dict
6//! (and the catalog-level `/PageLayout` and `/PageMode` entries that travel
7//! with them).
8//!
9//! These preferences are hints PDF viewers should honor when displaying
10//! the document — whether to hide chrome, fit the window to the first
11//! page, which page mode to open in, etc. They are advisory, not
12//! mandatory; consumers may override.
13
14use crate::objects::{PdfDict, PdfObj};
15use crate::resolver::Resolver;
16
17/// PDF viewer-preference hints, gathered from the catalog's
18/// `/ViewerPreferences` sub-dict plus the catalog-level `/PageLayout` and
19/// `/PageMode`.
20///
21/// Every field has a spec-defined default (see ISO 32000-2 §12.2 Table
22/// 147, §7.7.2 Table 28); a document with no `/ViewerPreferences` dict
23/// gets all defaults.
24#[derive(Debug, Clone, PartialEq)]
25pub struct ViewerPreferences {
26    /// `/HideToolbar` — hide viewer toolbar when document is active.
27    pub hide_toolbar: bool,
28    /// `/HideMenubar` — hide viewer menu bar.
29    pub hide_menubar: bool,
30    /// `/HideWindowUI` — hide UI elements like scroll bars.
31    pub hide_window_ui: bool,
32    /// `/FitWindow` — resize the window to fit the first page.
33    pub fit_window: bool,
34    /// `/CenterWindow` — center the window on the screen.
35    pub center_window: bool,
36    /// `/DisplayDocTitle` — display the document title in the title bar.
37    pub display_doc_title: bool,
38    /// `/NonFullScreenPageMode` — `/PageMode` to use when leaving full-screen.
39    pub non_full_screen_page_mode: PageMode,
40    /// `/Direction` — predominant reading order.
41    pub direction: ReadingDirection,
42    /// Catalog-level `/PageLayout`.
43    pub page_layout: PageLayout,
44    /// Catalog-level `/PageMode`.
45    pub page_mode: PageMode,
46    /// `/PrintScaling` — default print-scaling preference.
47    pub print_scaling: PrintScaling,
48    /// `/Duplex` — default print-duplex preference.
49    pub duplex: Option<Duplex>,
50    /// `/PickTrayByPDFSize` — choose paper tray based on PDF page size.
51    pub pick_tray_by_pdf_size: Option<bool>,
52    /// `/NumCopies` — default number of copies to print.
53    pub num_copies: Option<u32>,
54}
55
56impl Default for ViewerPreferences {
57    fn default() -> Self {
58        Self {
59            hide_toolbar: false,
60            hide_menubar: false,
61            hide_window_ui: false,
62            fit_window: false,
63            center_window: false,
64            display_doc_title: false,
65            non_full_screen_page_mode: PageMode::UseNone,
66            direction: ReadingDirection::L2R,
67            page_layout: PageLayout::SinglePage,
68            page_mode: PageMode::UseNone,
69            print_scaling: PrintScaling::AppDefault,
70            duplex: None,
71            pick_tray_by_pdf_size: None,
72            num_copies: None,
73        }
74    }
75}
76
77/// `/PageLayout` — how pages should be displayed.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum PageLayout {
81    /// `/SinglePage` — display one page at a time (default).
82    SinglePage,
83    /// `/OneColumn` — display pages in one continuous column.
84    OneColumn,
85    /// `/TwoColumnLeft` — two columns, odd-numbered pages on the left.
86    TwoColumnLeft,
87    /// `/TwoColumnRight` — two columns, odd-numbered pages on the right.
88    TwoColumnRight,
89    /// `/TwoPageLeft` — two pages, odd-numbered pages on the left.
90    TwoPageLeft,
91    /// `/TwoPageRight` — two pages, odd-numbered pages on the right.
92    TwoPageRight,
93}
94
95/// `/PageMode` — initial document presentation mode.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum PageMode {
99    /// `/UseNone` — neither outlines nor thumbnails visible (default).
100    UseNone,
101    /// `/UseOutlines` — outline panel visible.
102    UseOutlines,
103    /// `/UseThumbs` — thumbnails panel visible.
104    UseThumbs,
105    /// `/FullScreen` — full-screen mode, no menu/window/UI.
106    FullScreen,
107    /// `/UseOC` — optional content panel visible.
108    UseOC,
109    /// `/UseAttachments` — attachments panel visible.
110    UseAttachments,
111}
112
113/// `/Direction` — predominant reading order.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum ReadingDirection {
117    /// `/L2R` — left-to-right (default).
118    L2R,
119    /// `/R2L` — right-to-left, e.g. Arabic, Hebrew.
120    R2L,
121}
122
123/// `/PrintScaling` — default print-scaling preference.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum PrintScaling {
127    /// `/None` — no scaling.
128    None,
129    /// `/AppDefault` — let the print application decide (default).
130    AppDefault,
131}
132
133/// `/Duplex` — default print-duplex preference.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum Duplex {
137    /// `/Simplex` — single-sided.
138    Simplex,
139    /// `/DuplexFlipShortEdge` — duplex, flip on short edge.
140    DuplexFlipShortEdge,
141    /// `/DuplexFlipLongEdge` — duplex, flip on long edge.
142    DuplexFlipLongEdge,
143}
144
145/// Parse `/ViewerPreferences`, `/PageLayout`, and `/PageMode` from the
146/// document catalog.
147///
148/// Always returns a value — missing or malformed entries leave their
149/// fields at default.
150pub fn parse_viewer_preferences(resolver: &Resolver) -> ViewerPreferences {
151    let mut prefs = ViewerPreferences::default();
152
153    let Some(catalog) = catalog_dict(resolver) else {
154        return prefs;
155    };
156
157    if let Some(name) = catalog.get_name(b"PageLayout")
158        && let Some(layout) = parse_page_layout(name)
159    {
160        prefs.page_layout = layout;
161    }
162    if let Some(name) = catalog.get_name(b"PageMode")
163        && let Some(mode) = parse_page_mode(name)
164    {
165        prefs.page_mode = mode;
166    }
167
168    if let Some(vp_obj) = catalog.get(b"ViewerPreferences")
169        && let Ok(vp) = resolver.deref(vp_obj)
170        && let Some(vp_dict) = vp.as_dict()
171    {
172        fill_viewer_prefs(&mut prefs, vp_dict);
173    }
174
175    prefs
176}
177
178fn fill_viewer_prefs(prefs: &mut ViewerPreferences, dict: &PdfDict) {
179    if let Some(b) = dict.get(b"HideToolbar").and_then(as_bool) {
180        prefs.hide_toolbar = b;
181    }
182    if let Some(b) = dict.get(b"HideMenubar").and_then(as_bool) {
183        prefs.hide_menubar = b;
184    }
185    if let Some(b) = dict.get(b"HideWindowUI").and_then(as_bool) {
186        prefs.hide_window_ui = b;
187    }
188    if let Some(b) = dict.get(b"FitWindow").and_then(as_bool) {
189        prefs.fit_window = b;
190    }
191    if let Some(b) = dict.get(b"CenterWindow").and_then(as_bool) {
192        prefs.center_window = b;
193    }
194    if let Some(b) = dict.get(b"DisplayDocTitle").and_then(as_bool) {
195        prefs.display_doc_title = b;
196    }
197    if let Some(name) = dict.get_name(b"NonFullScreenPageMode")
198        && let Some(m) = parse_page_mode(name)
199    {
200        prefs.non_full_screen_page_mode = m;
201    }
202    if let Some(name) = dict.get_name(b"Direction")
203        && let Some(d) = parse_direction(name)
204    {
205        prefs.direction = d;
206    }
207    if let Some(name) = dict.get_name(b"PrintScaling")
208        && let Some(p) = parse_print_scaling(name)
209    {
210        prefs.print_scaling = p;
211    }
212    if let Some(name) = dict.get_name(b"Duplex") {
213        prefs.duplex = parse_duplex(name);
214    }
215    if let Some(b) = dict.get(b"PickTrayByPDFSize").and_then(as_bool) {
216        prefs.pick_tray_by_pdf_size = Some(b);
217    }
218    if let Some(n) = dict.get_int(b"NumCopies")
219        && (1..=10_000).contains(&n)
220    {
221        prefs.num_copies = Some(n as u32);
222    }
223}
224
225fn as_bool(obj: &PdfObj) -> Option<bool> {
226    match obj {
227        PdfObj::Bool(b) => Some(*b),
228        _ => None,
229    }
230}
231
232fn parse_page_layout(name: &[u8]) -> Option<PageLayout> {
233    match name {
234        b"SinglePage" => Some(PageLayout::SinglePage),
235        b"OneColumn" => Some(PageLayout::OneColumn),
236        b"TwoColumnLeft" => Some(PageLayout::TwoColumnLeft),
237        b"TwoColumnRight" => Some(PageLayout::TwoColumnRight),
238        b"TwoPageLeft" => Some(PageLayout::TwoPageLeft),
239        b"TwoPageRight" => Some(PageLayout::TwoPageRight),
240        _ => None,
241    }
242}
243
244fn parse_page_mode(name: &[u8]) -> Option<PageMode> {
245    match name {
246        b"UseNone" => Some(PageMode::UseNone),
247        b"UseOutlines" => Some(PageMode::UseOutlines),
248        b"UseThumbs" => Some(PageMode::UseThumbs),
249        b"FullScreen" => Some(PageMode::FullScreen),
250        b"UseOC" => Some(PageMode::UseOC),
251        b"UseAttachments" => Some(PageMode::UseAttachments),
252        _ => None,
253    }
254}
255
256fn parse_direction(name: &[u8]) -> Option<ReadingDirection> {
257    match name {
258        b"L2R" => Some(ReadingDirection::L2R),
259        b"R2L" => Some(ReadingDirection::R2L),
260        _ => None,
261    }
262}
263
264fn parse_print_scaling(name: &[u8]) -> Option<PrintScaling> {
265    match name {
266        b"None" => Some(PrintScaling::None),
267        b"AppDefault" => Some(PrintScaling::AppDefault),
268        _ => None,
269    }
270}
271
272fn parse_duplex(name: &[u8]) -> Option<Duplex> {
273    match name {
274        b"Simplex" => Some(Duplex::Simplex),
275        b"DuplexFlipShortEdge" => Some(Duplex::DuplexFlipShortEdge),
276        b"DuplexFlipLongEdge" => Some(Duplex::DuplexFlipLongEdge),
277        _ => None,
278    }
279}
280
281fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
282    if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
283        && let Ok(obj) = resolver.resolve(num, gen_num)
284        && let Some(dict) = obj.as_dict()
285        && (dict.get_name(b"Type") == Some(b"Catalog")
286            || dict.get(b"Pages").is_some()
287            || dict.get(b"PageLayout").is_some()
288            || dict.get(b"PageMode").is_some()
289            || dict.get(b"ViewerPreferences").is_some())
290    {
291        return Some(dict.clone());
292    }
293    crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn defaults_are_sane() {
302        let p = ViewerPreferences::default();
303        assert!(!p.hide_toolbar);
304        assert_eq!(p.page_layout, PageLayout::SinglePage);
305        assert_eq!(p.page_mode, PageMode::UseNone);
306        assert_eq!(p.print_scaling, PrintScaling::AppDefault);
307        assert!(p.duplex.is_none());
308    }
309
310    #[test]
311    fn fill_from_dict_sets_overrides() {
312        let mut dict = PdfDict::new();
313        dict.insert(b"HideToolbar".to_vec(), PdfObj::Bool(true));
314        dict.insert(b"FitWindow".to_vec(), PdfObj::Bool(true));
315        dict.insert(b"PrintScaling".to_vec(), PdfObj::Name(b"None".to_vec()));
316        dict.insert(
317            b"Duplex".to_vec(),
318            PdfObj::Name(b"DuplexFlipLongEdge".to_vec()),
319        );
320        dict.insert(b"NumCopies".to_vec(), PdfObj::Int(3));
321        dict.insert(
322            b"NonFullScreenPageMode".to_vec(),
323            PdfObj::Name(b"UseOutlines".to_vec()),
324        );
325
326        let mut prefs = ViewerPreferences::default();
327        fill_viewer_prefs(&mut prefs, &dict);
328
329        assert!(prefs.hide_toolbar);
330        assert!(prefs.fit_window);
331        assert_eq!(prefs.print_scaling, PrintScaling::None);
332        assert_eq!(prefs.duplex, Some(Duplex::DuplexFlipLongEdge));
333        assert_eq!(prefs.num_copies, Some(3));
334        assert_eq!(prefs.non_full_screen_page_mode, PageMode::UseOutlines);
335    }
336
337    #[test]
338    fn unknown_name_leaves_default() {
339        let mut dict = PdfDict::new();
340        dict.insert(b"PrintScaling".to_vec(), PdfObj::Name(b"Bogus".to_vec()));
341        let mut prefs = ViewerPreferences::default();
342        fill_viewer_prefs(&mut prefs, &dict);
343        assert_eq!(prefs.print_scaling, PrintScaling::AppDefault);
344    }
345
346    #[test]
347    fn num_copies_out_of_range_ignored() {
348        let mut dict = PdfDict::new();
349        dict.insert(b"NumCopies".to_vec(), PdfObj::Int(0));
350        let mut prefs = ViewerPreferences::default();
351        fill_viewer_prefs(&mut prefs, &dict);
352        assert!(prefs.num_copies.is_none());
353
354        let mut dict = PdfDict::new();
355        dict.insert(b"NumCopies".to_vec(), PdfObj::Int(99_999));
356        let mut prefs = ViewerPreferences::default();
357        fill_viewer_prefs(&mut prefs, &dict);
358        assert!(prefs.num_copies.is_none());
359    }
360
361    #[test]
362    fn page_layout_round_trip() {
363        for (name, expect) in [
364            (&b"SinglePage"[..], PageLayout::SinglePage),
365            (&b"OneColumn"[..], PageLayout::OneColumn),
366            (&b"TwoColumnLeft"[..], PageLayout::TwoColumnLeft),
367            (&b"TwoPageRight"[..], PageLayout::TwoPageRight),
368        ] {
369            assert_eq!(parse_page_layout(name), Some(expect));
370        }
371        assert!(parse_page_layout(b"unknown").is_none());
372    }
373}