Skip to main content

rpdfium_doc/
viewer_preferences.rs

1//! Viewer preferences (ISO 32000-2 section 12.2).
2//!
3//! Parses the `/ViewerPreferences` dictionary from the document catalog
4//! to control how a PDF viewer should display the document.
5
6use std::collections::HashMap;
7
8use rpdfium_core::{Name, PdfSource};
9use rpdfium_parser::object::Object;
10use rpdfium_parser::store::ObjectStore;
11
12/// Reading direction for the document.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ReadingDirection {
15    /// Left to right (default).
16    L2R,
17    /// Right to left.
18    R2L,
19}
20
21/// Duplex printing mode.
22///
23/// Corresponds to the `/Duplex` entry in the viewer preferences dictionary
24/// and to `CPDF_ViewerPreferences::Duplex()` in PDFium.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum DuplexMode {
27    /// Simplex (single-sided) printing.
28    #[default]
29    Simplex,
30    /// Duplex printing, flipping on the short edge.
31    DuplexFlipShortEdge,
32    /// Duplex printing, flipping on the long edge.
33    DuplexFlipLongEdge,
34}
35
36/// Parsed viewer preferences from the `/ViewerPreferences` dictionary.
37#[derive(Debug, Clone)]
38pub struct ViewerPreferences {
39    /// Whether to hide the toolbar when the document is active.
40    pub hide_toolbar: bool,
41    /// Whether to hide the menu bar when the document is active.
42    pub hide_menubar: bool,
43    /// Whether to hide UI elements in the document's window.
44    pub hide_window_ui: bool,
45    /// Whether to resize the document's window to fit the first page.
46    pub fit_window: bool,
47    /// Whether to position the document's window in the center of the screen.
48    pub center_window: bool,
49    /// Whether the window's title bar should display the document title.
50    pub display_doc_title: bool,
51    /// The predominant reading order for text.
52    pub direction: ReadingDirection,
53    /// Page scaling option (e.g., "None", "AppDefault").
54    pub print_scaling: Option<String>,
55    /// Number of copies to print.
56    pub num_copies: Option<u32>,
57    /// Duplex mode (e.g., "Simplex", "DuplexFlipShortEdge", "DuplexFlipLongEdge").
58    pub duplex: Option<String>,
59    /// Page ranges to print, as pairs `[start, end]` (0-based page indices).
60    pub print_page_range: Option<Vec<i64>>,
61}
62
63impl Default for ViewerPreferences {
64    fn default() -> Self {
65        Self {
66            hide_toolbar: false,
67            hide_menubar: false,
68            hide_window_ui: false,
69            fit_window: false,
70            center_window: false,
71            display_doc_title: false,
72            direction: ReadingDirection::L2R,
73            print_scaling: None,
74            num_copies: None,
75            duplex: None,
76            print_page_range: None,
77        }
78    }
79}
80
81impl ViewerPreferences {
82    /// Parse viewer preferences from a dictionary.
83    pub fn from_dict<S: PdfSource>(dict: &HashMap<Name, Object>, store: &ObjectStore<S>) -> Self {
84        let get_bool = |name: &Name| -> bool {
85            dict.get(name)
86                .and_then(|o| store.deep_resolve(o).ok())
87                .and_then(|o| o.as_bool())
88                .unwrap_or(false)
89        };
90
91        let get_name_string = |name: &Name| -> Option<String> {
92            dict.get(name)
93                .and_then(|o| store.deep_resolve(o).ok())
94                .and_then(|o| o.as_name().map(|n| n.as_str().into_owned()))
95        };
96
97        let get_string = |name: &Name| -> Option<String> {
98            dict.get(name)
99                .and_then(|o| store.deep_resolve(o).ok())
100                .and_then(|o| {
101                    if let Some(s) = o.as_string() {
102                        Some(s.to_string_lossy())
103                    } else {
104                        o.as_name().map(|n| n.as_str().into_owned())
105                    }
106                })
107        };
108
109        let direction = match get_name_string(&Name::direction()).as_deref() {
110            Some("R2L") => ReadingDirection::R2L,
111            _ => ReadingDirection::L2R,
112        };
113
114        let num_copies = dict
115            .get(&Name::num_copies())
116            .and_then(|o| store.deep_resolve(o).ok())
117            .and_then(|o| o.as_i64())
118            .map(|n| n.max(1) as u32);
119
120        // /PrintPageRange — array of page range pairs
121        let print_page_range = dict
122            .get(&Name::print_page_range())
123            .and_then(|o| store.deep_resolve(o).ok())
124            .and_then(|o| {
125                o.as_array().map(|arr| {
126                    arr.iter()
127                        .filter_map(|item| item.as_i64())
128                        .collect::<Vec<i64>>()
129                })
130            })
131            .filter(|v| !v.is_empty());
132
133        Self {
134            hide_toolbar: get_bool(&Name::hide_toolbar()),
135            hide_menubar: get_bool(&Name::hide_menubar()),
136            hide_window_ui: get_bool(&Name::hide_window_ui()),
137            fit_window: get_bool(&Name::fit_window()),
138            center_window: get_bool(&Name::center_window()),
139            display_doc_title: get_bool(&Name::display_doc_title()),
140            direction,
141            print_scaling: get_string(&Name::print_scaling()),
142            num_copies,
143            duplex: get_string(&Name::duplex()),
144            print_page_range,
145        }
146    }
147
148    /// Returns `true` if the reading direction is right-to-left.
149    ///
150    /// Corresponds to `CPDF_ViewerPreferences::IsDirectionR2L()` in PDFium.
151    pub fn is_direction_r2l(&self) -> bool {
152        self.direction == ReadingDirection::R2L
153    }
154
155    /// Returns `true` if print scaling is enabled (not suppressed).
156    ///
157    /// Returns `false` when `/PrintScaling` is `"None"` (the viewer should not
158    /// scale the document for printing). This is the primary with the real logic.
159    ///
160    /// Corresponds to upstream `CPDF_ViewerPreferences::PrintScaling()`.
161    pub fn print_scaling(&self) -> bool {
162        self.print_scaling.as_deref() != Some("None")
163    }
164
165    /// Returns `true` if print scaling is suppressed (`/PrintScaling None`).
166    ///
167    /// This is a convenience inverse of [`print_scaling()`](Self::print_scaling).
168    /// Corresponds to upstream `CPDF_ViewerPreferences::IsPrintScalingSuppressed()`.
169    pub fn is_print_scaling_suppressed(&self) -> bool {
170        !self.print_scaling()
171    }
172
173    /// Returns the number of copies to print.
174    ///
175    /// Returns `None` if the `/NumCopies` entry is absent.
176    /// Corresponds to `CPDF_ViewerPreferences::NumCopies()` in PDFium.
177    pub fn num_copies(&self) -> Option<u32> {
178        self.num_copies
179    }
180
181    /// Deprecated: use [`num_copies()`](Self::num_copies) directly (its name already matches
182    /// `CPDF_ViewerPreferences::NumCopies()`).
183    #[deprecated(since = "0.1.0", note = "use num_copies() instead")]
184    #[inline]
185    pub fn get_num_copies(&self) -> Option<u32> {
186        self.num_copies()
187    }
188
189    /// Returns the page ranges to print.
190    ///
191    /// Returns `None` if the `/PrintPageRange` entry is absent.
192    /// The range is stored as pairs `[start, end]` (0-based page indices).
193    ///
194    /// Corresponds to `CPDF_ViewerPreferences::PrintPageRange()` in PDFium.
195    pub fn print_page_range(&self) -> Option<&[i64]> {
196        self.print_page_range.as_deref()
197    }
198
199    /// Returns the duplex printing mode.
200    ///
201    /// Parses the `/Duplex` entry from the viewer preferences dictionary.
202    /// Returns `DuplexMode::Simplex` if the entry is absent or unrecognised.
203    ///
204    /// Corresponds to `CPDF_ViewerPreferences::Duplex()` in PDFium.
205    pub fn duplex_mode(&self) -> DuplexMode {
206        match self.duplex.as_deref() {
207            Some("DuplexFlipShortEdge") => DuplexMode::DuplexFlipShortEdge,
208            Some("DuplexFlipLongEdge") => DuplexMode::DuplexFlipLongEdge,
209            _ => DuplexMode::Simplex,
210        }
211    }
212
213    /// Upstream-aligned alias for [`duplex_mode()`](Self::duplex_mode).
214    ///
215    /// Corresponds to `CPDF_ViewerPreferences::Duplex()` in PDFium.
216    #[inline]
217    pub fn duplex(&self) -> DuplexMode {
218        self.duplex_mode()
219    }
220
221    /// Returns the value of an arbitrary viewer preference entry by name.
222    ///
223    /// Looks up the given `key` in the viewer preferences dictionary and
224    /// returns the value as a string if the entry is a PDF name or string
225    /// object. Returns `None` if the key is absent or the value is not a
226    /// name/string.
227    ///
228    /// Corresponds to `CPDF_ViewerPreferences::GenericName()` in PDFium.
229    pub fn generic_name(&self, key: &str) -> Option<&str> {
230        match key {
231            "HideToolbar" => self.hide_toolbar.then_some("true"),
232            "HideMenubar" => self.hide_menubar.then_some("true"),
233            "HideWindowUI" => self.hide_window_ui.then_some("true"),
234            "FitWindow" => self.fit_window.then_some("true"),
235            "CenterWindow" => self.center_window.then_some("true"),
236            "DisplayDocTitle" => self.display_doc_title.then_some("true"),
237            "Direction" => match self.direction {
238                ReadingDirection::R2L => Some("R2L"),
239                ReadingDirection::L2R => Some("L2R"),
240            },
241            "PrintScaling" => self.print_scaling.as_deref(),
242            "Duplex" => self.duplex.as_deref(),
243            _ => None,
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn build_store() -> ObjectStore<Vec<u8>> {
253        let pdf = build_minimal_pdf();
254        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
255    }
256
257    fn build_minimal_pdf() -> Vec<u8> {
258        let mut pdf = Vec::new();
259        pdf.extend_from_slice(b"%PDF-1.4\n");
260        let obj1_offset = pdf.len();
261        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
262        let obj2_offset = pdf.len();
263        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
264        let xref_offset = pdf.len();
265        pdf.extend_from_slice(b"xref\n0 3\n");
266        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
267        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
268        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
269        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
270        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
271        pdf
272    }
273
274    #[test]
275    fn test_default_preferences() {
276        let prefs = ViewerPreferences::default();
277        assert!(!prefs.hide_toolbar);
278        assert!(!prefs.hide_menubar);
279        assert!(!prefs.hide_window_ui);
280        assert!(!prefs.fit_window);
281        assert!(!prefs.center_window);
282        assert!(!prefs.display_doc_title);
283        assert_eq!(prefs.direction, ReadingDirection::L2R);
284        assert!(prefs.print_scaling.is_none());
285        assert!(prefs.num_copies.is_none());
286        assert!(prefs.duplex.is_none());
287        assert!(prefs.print_page_range.is_none());
288    }
289
290    #[test]
291    fn test_parse_all_fields() {
292        let store = build_store();
293        let mut dict = HashMap::new();
294        dict.insert(Name::hide_toolbar(), Object::Boolean(true));
295        dict.insert(Name::hide_menubar(), Object::Boolean(true));
296        dict.insert(Name::hide_window_ui(), Object::Boolean(true));
297        dict.insert(Name::fit_window(), Object::Boolean(true));
298        dict.insert(Name::center_window(), Object::Boolean(true));
299        dict.insert(Name::display_doc_title(), Object::Boolean(true));
300        dict.insert(Name::direction(), Object::Name(Name::from("R2L")));
301        dict.insert(Name::print_scaling(), Object::Name(Name::from("None")));
302        dict.insert(Name::num_copies(), Object::Integer(3));
303        dict.insert(
304            Name::duplex(),
305            Object::Name(Name::from("DuplexFlipLongEdge")),
306        );
307
308        let prefs = ViewerPreferences::from_dict(&dict, &store);
309        assert!(prefs.hide_toolbar);
310        assert!(prefs.hide_menubar);
311        assert!(prefs.hide_window_ui);
312        assert!(prefs.fit_window);
313        assert!(prefs.center_window);
314        assert!(prefs.display_doc_title);
315        assert_eq!(prefs.direction, ReadingDirection::R2L);
316        assert_eq!(prefs.print_scaling.as_deref(), Some("None"));
317        assert_eq!(prefs.num_copies, Some(3));
318        assert_eq!(prefs.duplex.as_deref(), Some("DuplexFlipLongEdge"));
319    }
320
321    #[test]
322    fn test_parse_empty_dict() {
323        let store = build_store();
324        let dict = HashMap::new();
325        let prefs = ViewerPreferences::from_dict(&dict, &store);
326        assert!(!prefs.hide_toolbar);
327        assert_eq!(prefs.direction, ReadingDirection::L2R);
328        assert!(prefs.print_scaling.is_none());
329    }
330
331    #[test]
332    fn test_print_page_range_parsed() {
333        let store = build_store();
334        let mut dict = HashMap::new();
335        dict.insert(
336            Name::print_page_range(),
337            Object::Array(vec![
338                Object::Integer(0),
339                Object::Integer(3),
340                Object::Integer(5),
341                Object::Integer(7),
342            ]),
343        );
344        let prefs = ViewerPreferences::from_dict(&dict, &store);
345        let range = prefs.print_page_range.unwrap();
346        assert_eq!(range, vec![0, 3, 5, 7]);
347    }
348
349    #[test]
350    fn test_print_page_range_empty_is_none() {
351        let store = build_store();
352        let mut dict = HashMap::new();
353        dict.insert(Name::print_page_range(), Object::Array(vec![]));
354        let prefs = ViewerPreferences::from_dict(&dict, &store);
355        assert!(prefs.print_page_range.is_none());
356    }
357
358    #[test]
359    fn test_direction_default_l2r() {
360        let store = build_store();
361        let mut dict = HashMap::new();
362        dict.insert(Name::direction(), Object::Name(Name::from("L2R")));
363        let prefs = ViewerPreferences::from_dict(&dict, &store);
364        assert_eq!(prefs.direction, ReadingDirection::L2R);
365    }
366
367    #[test]
368    fn test_direction_r2l() {
369        let store = build_store();
370        let mut dict = HashMap::new();
371        dict.insert(Name::direction(), Object::Name(Name::from("R2L")));
372        let prefs = ViewerPreferences::from_dict(&dict, &store);
373        assert_eq!(prefs.direction, ReadingDirection::R2L);
374    }
375
376    #[test]
377    fn test_is_print_scaling_suppressed_none_value() {
378        let store = build_store();
379        let mut dict = HashMap::new();
380        dict.insert(Name::print_scaling(), Object::Name(Name::from("None")));
381        let prefs = ViewerPreferences::from_dict(&dict, &store);
382        assert!(prefs.is_print_scaling_suppressed());
383    }
384
385    #[test]
386    fn test_is_print_scaling_suppressed_app_default() {
387        let store = build_store();
388        let mut dict = HashMap::new();
389        dict.insert(
390            Name::print_scaling(),
391            Object::Name(Name::from("AppDefault")),
392        );
393        let prefs = ViewerPreferences::from_dict(&dict, &store);
394        assert!(!prefs.is_print_scaling_suppressed());
395    }
396
397    #[test]
398    fn test_is_print_scaling_suppressed_absent() {
399        let store = build_store();
400        let prefs = ViewerPreferences::from_dict(&HashMap::new(), &store);
401        assert!(!prefs.is_print_scaling_suppressed());
402    }
403
404    #[test]
405    fn test_generic_name_direction() {
406        let store = build_store();
407        let mut dict = HashMap::new();
408        dict.insert(Name::direction(), Object::Name(Name::from("R2L")));
409        let prefs = ViewerPreferences::from_dict(&dict, &store);
410        assert_eq!(prefs.generic_name("Direction"), Some("R2L"));
411    }
412
413    #[test]
414    fn test_generic_name_print_scaling() {
415        let store = build_store();
416        let mut dict = HashMap::new();
417        dict.insert(Name::print_scaling(), Object::Name(Name::from("None")));
418        let prefs = ViewerPreferences::from_dict(&dict, &store);
419        assert_eq!(prefs.generic_name("PrintScaling"), Some("None"));
420    }
421
422    #[test]
423    fn test_generic_name_unknown_key_is_none() {
424        let prefs = ViewerPreferences::default();
425        assert_eq!(prefs.generic_name("SomeUnknownKey"), None);
426    }
427
428    #[test]
429    fn test_generic_name_duplex() {
430        let store = build_store();
431        let mut dict = HashMap::new();
432        dict.insert(
433            Name::duplex(),
434            Object::Name(Name::from("DuplexFlipShortEdge")),
435        );
436        let prefs = ViewerPreferences::from_dict(&dict, &store);
437        assert_eq!(prefs.generic_name("Duplex"), Some("DuplexFlipShortEdge"));
438    }
439
440    #[test]
441    fn test_duplex_mode_simplex_by_default() {
442        let prefs = ViewerPreferences::default();
443        assert_eq!(prefs.duplex_mode(), DuplexMode::Simplex);
444    }
445
446    #[test]
447    fn test_duplex_mode_flip_short_edge() {
448        let store = build_store();
449        let mut dict = HashMap::new();
450        dict.insert(
451            Name::duplex(),
452            Object::Name(Name::from("DuplexFlipShortEdge")),
453        );
454        let prefs = ViewerPreferences::from_dict(&dict, &store);
455        assert_eq!(prefs.duplex_mode(), DuplexMode::DuplexFlipShortEdge);
456    }
457
458    #[test]
459    fn test_duplex_mode_flip_long_edge() {
460        let store = build_store();
461        let mut dict = HashMap::new();
462        dict.insert(
463            Name::duplex(),
464            Object::Name(Name::from("DuplexFlipLongEdge")),
465        );
466        let prefs = ViewerPreferences::from_dict(&dict, &store);
467        assert_eq!(prefs.duplex_mode(), DuplexMode::DuplexFlipLongEdge);
468    }
469
470    #[test]
471    fn test_duplex_mode_unknown_is_simplex() {
472        let store = build_store();
473        let mut dict = HashMap::new();
474        dict.insert(Name::duplex(), Object::Name(Name::from("Unknown")));
475        let prefs = ViewerPreferences::from_dict(&dict, &store);
476        assert_eq!(prefs.duplex_mode(), DuplexMode::Simplex);
477    }
478}