Skip to main content

pdfboss_core/
viewer_preferences.rs

1//! The viewer preferences the catalog declares (ISO 32000-1 §12.2): how a
2//! reader should present the document on the screen and in print, read
3//! from the catalog's `/ViewerPreferences` dictionary (Table 150).
4
5use crate::object::{Dict, Object};
6use crate::source::AsyncObjectSource;
7use crate::tree::resolved_dict;
8
9/// The page mode a reader returns to on leaving full-screen mode
10/// (`/NonFullScreenPageMode`), meaningful only when the catalog's
11/// `/PageMode` is `FullScreen`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum NonFullScreenPageMode {
14    /// Neither the outline nor the thumbnails are shown.
15    #[default]
16    UseNone,
17    /// The document outline is shown.
18    UseOutlines,
19    /// The thumbnail images are shown.
20    UseThumbs,
21    /// The optional content group panel is shown.
22    UseOC,
23}
24
25impl NonFullScreenPageMode {
26    fn from_name(name: &str) -> Option<Self> {
27        match name {
28            "UseNone" => Some(Self::UseNone),
29            "UseOutlines" => Some(Self::UseOutlines),
30            "UseThumbs" => Some(Self::UseThumbs),
31            "UseOC" => Some(Self::UseOC),
32            _ => None,
33        }
34    }
35}
36
37/// The predominant reading order of the text (`/Direction`), which places
38/// pages shown side by side; it does not change the content.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum Direction {
41    /// `L2R`: left to right.
42    #[default]
43    LeftToRight,
44    /// `R2L`: right to left, vertical writing systems included.
45    RightToLeft,
46}
47
48impl Direction {
49    fn from_name(name: &str) -> Option<Self> {
50        match name {
51            "L2R" => Some(Self::LeftToRight),
52            "R2L" => Some(Self::RightToLeft),
53            _ => None,
54        }
55    }
56}
57
58/// A page boundary named by key (§14.11.2), as the view and print area
59/// and clip entries name one.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum PageBoundary {
62    MediaBox,
63    #[default]
64    CropBox,
65    BleedBox,
66    TrimBox,
67    ArtBox,
68}
69
70impl PageBoundary {
71    fn from_name(name: &str) -> Option<Self> {
72        match name {
73            "MediaBox" => Some(Self::MediaBox),
74            "CropBox" => Some(Self::CropBox),
75            "BleedBox" => Some(Self::BleedBox),
76            "TrimBox" => Some(Self::TrimBox),
77            "ArtBox" => Some(Self::ArtBox),
78            _ => None,
79        }
80    }
81}
82
83/// The page scaling a print dialog starts with (`/PrintScaling`); a value
84/// the standard does not know reads as `AppDefault`, as it says.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum PrintScaling {
87    /// No page scaling.
88    None,
89    /// The reader's own default.
90    #[default]
91    AppDefault,
92}
93
94impl PrintScaling {
95    fn from_name(name: &str) -> Option<Self> {
96        match name {
97            "None" => Some(Self::None),
98            "AppDefault" => Some(Self::AppDefault),
99            _ => None,
100        }
101    }
102}
103
104/// The paper handling a print dialog starts with (`/Duplex`).
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Duplex {
107    /// Single-sided.
108    Simplex,
109    /// Two-sided, flipped on the short edge.
110    DuplexFlipShortEdge,
111    /// Two-sided, flipped on the long edge.
112    DuplexFlipLongEdge,
113}
114
115impl Duplex {
116    fn from_name(name: &str) -> Option<Self> {
117        match name {
118            "Simplex" => Some(Self::Simplex),
119            "DuplexFlipShortEdge" => Some(Self::DuplexFlipShortEdge),
120            "DuplexFlipLongEdge" => Some(Self::DuplexFlipLongEdge),
121            _ => None,
122        }
123    }
124}
125
126/// The catalog's viewer preferences (ISO 32000-1 §12.2, Table 150). Every
127/// entry is optional; a missing one holds the default the table gives, and
128/// a `None` where the table leaves the default to the reader.
129#[derive(Debug, Clone, PartialEq, Default)]
130pub struct ViewerPreferences {
131    /// `/HideToolbar`: hide the reader's tool bars.
132    pub hide_toolbar: bool,
133    /// `/HideMenubar`: hide the reader's menu bar.
134    pub hide_menubar: bool,
135    /// `/HideWindowUI`: hide the window's scroll bars and controls.
136    pub hide_window_ui: bool,
137    /// `/FitWindow`: size the window to the first page.
138    pub fit_window: bool,
139    /// `/CenterWindow`: center the window on the screen.
140    pub center_window: bool,
141    /// `/DisplayDocTitle`: title the window by the information dictionary's
142    /// `/Title` rather than the file name.
143    pub display_doc_title: bool,
144    /// `/NonFullScreenPageMode`, `UseNone` by default.
145    pub non_full_screen_page_mode: NonFullScreenPageMode,
146    /// `/Direction`, left to right by default.
147    pub direction: Direction,
148    /// `/ViewArea`: the boundary shown on the screen, `CropBox` by default.
149    pub view_area: PageBoundary,
150    /// `/ViewClip`: the boundary the screen clips to, `CropBox` by default.
151    pub view_clip: PageBoundary,
152    /// `/PrintArea`: the boundary printed, `CropBox` by default.
153    pub print_area: PageBoundary,
154    /// `/PrintClip`: the boundary printing clips to, `CropBox` by default.
155    pub print_clip: PageBoundary,
156    /// `/PrintScaling`, `AppDefault` by default and for a value the standard
157    /// does not know.
158    pub print_scaling: PrintScaling,
159    /// `/Duplex`; `None` when absent or not one of the three values, the
160    /// reader deciding.
161    pub duplex: Option<Duplex>,
162    /// `/PickTrayByPDFSize`; `None` when absent, the reader deciding.
163    pub pick_tray_by_pdf_size: Option<bool>,
164    /// `/PrintPageRange`: the sub-ranges to print as (first, last) pairs of
165    /// page numbers counted from 1; empty when absent or when no complete
166    /// pair of positive integers is given.
167    pub print_page_range: Vec<(u32, u32)>,
168    /// `/NumCopies`; `None` when absent or not a positive integer, the
169    /// reader deciding.
170    pub num_copies: Option<u32>,
171}
172
173/// The catalog's `/ViewerPreferences` (ISO 32000-1 §12.2), `None` when the
174/// catalog has none; a present dictionary reads every entry of Table 150
175/// with the table's defaults for the missing ones, indirect values
176/// followed, and a name the standard does not know left at the default.
177///
178/// Covers ISO 32000-1 §12.2.
179pub async fn viewer_preferences_with<S: AsyncObjectSource>(
180    src: &S,
181    trailer: &Dict,
182) -> Option<ViewerPreferences> {
183    let catalog = resolved_dict(src, trailer.get("Root")?).await?;
184    let dict = resolved_dict(src, catalog.get("ViewerPreferences")?).await?;
185    let entries = Entries { src, dict: &dict };
186    Some(ViewerPreferences {
187        hide_toolbar: entries.flag("HideToolbar").await.unwrap_or(false),
188        hide_menubar: entries.flag("HideMenubar").await.unwrap_or(false),
189        hide_window_ui: entries.flag("HideWindowUI").await.unwrap_or(false),
190        fit_window: entries.flag("FitWindow").await.unwrap_or(false),
191        center_window: entries.flag("CenterWindow").await.unwrap_or(false),
192        display_doc_title: entries.flag("DisplayDocTitle").await.unwrap_or(false),
193        non_full_screen_page_mode: entries
194            .named("NonFullScreenPageMode", NonFullScreenPageMode::from_name)
195            .await
196            .unwrap_or_default(),
197        direction: entries
198            .named("Direction", Direction::from_name)
199            .await
200            .unwrap_or_default(),
201        view_area: entries
202            .named("ViewArea", PageBoundary::from_name)
203            .await
204            .unwrap_or_default(),
205        view_clip: entries
206            .named("ViewClip", PageBoundary::from_name)
207            .await
208            .unwrap_or_default(),
209        print_area: entries
210            .named("PrintArea", PageBoundary::from_name)
211            .await
212            .unwrap_or_default(),
213        print_clip: entries
214            .named("PrintClip", PageBoundary::from_name)
215            .await
216            .unwrap_or_default(),
217        print_scaling: entries
218            .named("PrintScaling", PrintScaling::from_name)
219            .await
220            .unwrap_or_default(),
221        duplex: entries.named("Duplex", Duplex::from_name).await,
222        pick_tray_by_pdf_size: entries.flag("PickTrayByPDFSize").await,
223        print_page_range: page_ranges(entries.value("PrintPageRange").await.as_ref()),
224        num_copies: entries
225            .value("NumCopies")
226            .await
227            .and_then(|value| value.as_int())
228            .and_then(|copies| u32::try_from(copies).ok())
229            .filter(|copies| *copies > 0),
230    })
231}
232
233/// The entries of one dictionary, each resolved through the source when it
234/// is an indirect object.
235struct Entries<'a, S> {
236    src: &'a S,
237    dict: &'a Dict,
238}
239
240impl<S: AsyncObjectSource> Entries<'_, S> {
241    async fn value(&self, key: &str) -> Option<Object> {
242        self.src.resolve(self.dict.get(key)?).await.ok()
243    }
244
245    async fn flag(&self, key: &str) -> Option<bool> {
246        self.value(key).await?.as_bool()
247    }
248
249    async fn named<T>(&self, key: &str, from_name: fn(&str) -> Option<T>) -> Option<T> {
250        from_name(&self.value(key).await?.as_name()?.0)
251    }
252}
253
254/// The (first, last) pairs of a `/PrintPageRange` array: complete pairs of
255/// positive integers, a trailing odd element and a pair with anything else
256/// in it dropped.
257fn page_ranges(value: Option<&Object>) -> Vec<(u32, u32)> {
258    let Some(items) = value.and_then(Object::as_array) else {
259        return Vec::new();
260    };
261    items
262        .as_chunks::<2>()
263        .0
264        .iter()
265        .filter_map(|[first, last]| {
266            let first = u32::try_from(first.as_int()?).ok()?;
267            let last = u32::try_from(last.as_int()?).ok()?;
268            (first > 0 && last > 0).then_some((first, last))
269        })
270        .collect()
271}
272
273#[cfg(test)]
274mod tests {
275    use super::{
276        Direction, Duplex, NonFullScreenPageMode, PageBoundary, PrintScaling, ViewerPreferences,
277    };
278    use crate::Document;
279    use pdfboss_testkit::PdfBuilder;
280
281    fn doc(catalog_extra: &str, objects: &[(u32, &str)]) -> Document {
282        let mut b = PdfBuilder::new();
283        b.object(
284            1,
285            &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
286        );
287        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
288        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
289        for (num, body) in objects {
290            b.object(*num, body);
291        }
292        Document::load(b.build(1)).unwrap()
293    }
294
295    /// Every entry of Table 150 set, two of them through indirect objects
296    /// and the dictionary itself indirect.
297    // Covers ISO 32000-1 §12.2.
298    #[test]
299    fn every_viewer_preference_is_read() {
300        let found = doc(
301            "/ViewerPreferences 4 0 R",
302            &[
303                (
304                    4,
305                    "<< /HideToolbar true /HideMenubar true /HideWindowUI true /FitWindow true \
306                     /CenterWindow true /DisplayDocTitle true /NonFullScreenPageMode /UseOutlines \
307                     /Direction /R2L /ViewArea /MediaBox /ViewClip /BleedBox /PrintArea /TrimBox \
308                     /PrintClip /ArtBox /PrintScaling /None /Duplex /DuplexFlipLongEdge \
309                     /PickTrayByPDFSize 5 0 R /PrintPageRange [1 3 7 7] /NumCopies 6 0 R >>",
310                ),
311                (5, "false"),
312                (6, "2"),
313            ],
314        )
315        .viewer_preferences();
316        assert_eq!(
317            found,
318            Some(ViewerPreferences {
319                hide_toolbar: true,
320                hide_menubar: true,
321                hide_window_ui: true,
322                fit_window: true,
323                center_window: true,
324                display_doc_title: true,
325                non_full_screen_page_mode: NonFullScreenPageMode::UseOutlines,
326                direction: Direction::RightToLeft,
327                view_area: PageBoundary::MediaBox,
328                view_clip: PageBoundary::BleedBox,
329                print_area: PageBoundary::TrimBox,
330                print_clip: PageBoundary::ArtBox,
331                print_scaling: PrintScaling::None,
332                duplex: Some(Duplex::DuplexFlipLongEdge),
333                pick_tray_by_pdf_size: Some(false),
334                print_page_range: vec![(1, 3), (7, 7)],
335                num_copies: Some(2),
336            })
337        );
338    }
339
340    /// An empty dictionary holds the table's defaults, a catalog without
341    /// one has no preferences, and values the standard does not know (a
342    /// name it does not list, an odd page range, a zero copy count) fall
343    /// back to the defaults entry by entry.
344    // Covers ISO 32000-1 §12.2.
345    #[test]
346    fn missing_and_unknown_viewer_preferences_take_the_defaults() {
347        assert_eq!(
348            doc("/ViewerPreferences << >>", &[]).viewer_preferences(),
349            Some(ViewerPreferences::default())
350        );
351        assert_eq!(doc("", &[]).viewer_preferences(), None);
352        assert_eq!(doc("/ViewerPreferences 7", &[]).viewer_preferences(), None);
353        let odd = doc(
354            "/ViewerPreferences << /Direction /Down /PrintScaling /Bogus /Duplex /Both \
355             /ViewArea /Page /PrintPageRange [1 3 5] /NumCopies 0 /HideToolbar /yes >>",
356            &[],
357        )
358        .viewer_preferences()
359        .unwrap();
360        assert_eq!(
361            odd,
362            ViewerPreferences {
363                print_page_range: vec![(1, 3)],
364                ..ViewerPreferences::default()
365            }
366        );
367    }
368}