Skip to main content

pdfrum_edit/
prefs.rs

1//! Viewer preferences (ISO 32000-1 §12.2) on the write side: how a document
2//! asks to be displayed and printed.
3//!
4//! The reader is [`pdfrum_doc::ViewerPrefs`], and it is deliberately
5//! permissive — each accessor has its own default, and two of those differ
6//! between "no dictionary" and "an empty one". This writes the dictionary, so
7//! a caller who wants a particular default has to say so; there is no way to
8//! express "absent" other than clearing the key.
9
10use pdfrum_object::{Dict, Name, Object, Resolve, names};
11
12use crate::{doc::EditDoc, error::Error};
13
14/// A preference write either applies or names why it could not.
15type Result<T> = core::result::Result<T, Error>;
16
17/// The preferences a document can ask for.
18///
19/// Every field is optional, and an unset one **leaves the key alone** rather
20/// than writing a default — which is what lets this change one preference of
21/// a document without flattening the rest.
22///
23/// ```
24/// use pdfrum_edit::{Duplex, ViewerPreferences};
25///
26/// let prefs = ViewerPreferences::new()
27///     .hide_toolbar(true)
28///     .duplex(Duplex::DuplexFlipLongEdge)
29///     .num_copies(2);
30/// assert_eq!(prefs, prefs.clone());
31/// ```
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct ViewerPreferences {
34    hide_toolbar: Option<bool>,
35    hide_menubar: Option<bool>,
36    hide_window_ui: Option<bool>,
37    fit_window: Option<bool>,
38    center_window: Option<bool>,
39    display_doc_title: Option<bool>,
40    direction_r2l: Option<bool>,
41    print_scaling: Option<bool>,
42    num_copies: Option<i64>,
43    duplex: Option<Duplex>,
44}
45
46/// How a document asks to be printed on both sides (`/Duplex`).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Duplex {
49    /// One side only (`/Simplex`).
50    Simplex,
51    /// Both sides, flipping about the short edge (`/DuplexFlipShortEdge`).
52    DuplexFlipShortEdge,
53    /// Both sides, flipping about the long edge (`/DuplexFlipLongEdge`).
54    DuplexFlipLongEdge,
55}
56
57impl Duplex {
58    /// The `/Duplex` name this setting writes.
59    #[must_use]
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Self::Simplex => "Simplex",
63            Self::DuplexFlipShortEdge => "DuplexFlipShortEdge",
64            Self::DuplexFlipLongEdge => "DuplexFlipLongEdge",
65        }
66    }
67}
68
69impl ViewerPreferences {
70    /// Preferences that change nothing.
71    #[must_use]
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// `/HideToolbar`: hide the reader's toolbar while the document is open.
77    #[must_use]
78    pub fn hide_toolbar(mut self, yes: bool) -> Self {
79        self.hide_toolbar = Some(yes);
80        self
81    }
82
83    /// `/HideMenubar`: hide the reader's menu bar.
84    #[must_use]
85    pub fn hide_menubar(mut self, yes: bool) -> Self {
86        self.hide_menubar = Some(yes);
87        self
88    }
89
90    /// `/HideWindowUI`: hide the reader's own scroll bars and panes, leaving
91    /// the page.
92    #[must_use]
93    pub fn hide_window_ui(mut self, yes: bool) -> Self {
94        self.hide_window_ui = Some(yes);
95        self
96    }
97
98    /// `/FitWindow`: resize the window to the first page.
99    #[must_use]
100    pub fn fit_window(mut self, yes: bool) -> Self {
101        self.fit_window = Some(yes);
102        self
103    }
104
105    /// `/CenterWindow`: centre the window on the screen.
106    #[must_use]
107    pub fn center_window(mut self, yes: bool) -> Self {
108        self.center_window = Some(yes);
109        self
110    }
111
112    /// `/DisplayDocTitle`: title the window with `/Info /Title` rather than
113    /// the file name.
114    ///
115    /// PDF/UA requires this, since a file name is not an accessible name.
116    #[must_use]
117    pub fn display_doc_title(mut self, yes: bool) -> Self {
118        self.display_doc_title = Some(yes);
119        self
120    }
121
122    /// `/Direction`: `R2L` when true, `L2R` when false.
123    ///
124    /// This orders the *pages* in a spread, not the text in a line — a
125    /// right-to-left script in a left-to-right document is the ordinary case
126    /// and wants this left alone.
127    #[must_use]
128    pub fn direction_r2l(mut self, yes: bool) -> Self {
129        self.direction_r2l = Some(yes);
130        self
131    }
132
133    /// `/PrintScaling`: `AppDefault` when true, `None` when false.
134    ///
135    /// `false` is what a document asks for when its page size is the point —
136    /// a form, a label sheet — and scaling to the paper would falsify it.
137    #[must_use]
138    pub fn print_scaling(mut self, yes: bool) -> Self {
139        self.print_scaling = Some(yes);
140        self
141    }
142
143    /// `/NumCopies`: the number of copies the print dialog offers by default.
144    #[must_use]
145    pub fn num_copies(mut self, copies: i64) -> Self {
146        self.num_copies = Some(copies);
147        self
148    }
149
150    /// `/Duplex`: how the print dialog offers two-sided printing.
151    #[must_use]
152    pub fn duplex(mut self, duplex: Duplex) -> Self {
153        self.duplex = Some(duplex);
154        self
155    }
156
157    /// Merges these preferences into an existing dictionary, keeping the keys
158    /// this says nothing about.
159    fn merge_into(&self, mut dict: Dict) -> Dict {
160        let booleans = [
161            ("HideToolbar", self.hide_toolbar),
162            ("HideMenubar", self.hide_menubar),
163            ("HideWindowUI", self.hide_window_ui),
164            ("FitWindow", self.fit_window),
165            ("CenterWindow", self.center_window),
166            ("DisplayDocTitle", self.display_doc_title),
167        ];
168        for (key, value) in booleans {
169            if let Some(value) = value {
170                dict.insert(Name::from(key), Object::Bool(value));
171            }
172        }
173        if let Some(r2l) = self.direction_r2l {
174            let value = if r2l { "R2L" } else { "L2R" };
175            dict.insert(
176                Name::from("Direction"),
177                Object::Name(Name::from(value.as_bytes())),
178            );
179        }
180        if let Some(scaling) = self.print_scaling {
181            // The reader tests for the *string* `None`; anything else, this
182            // one included, means the application's default.
183            let value = if scaling { "AppDefault" } else { "None" };
184            dict.insert(
185                Name::from("PrintScaling"),
186                Object::Name(Name::from(value.as_bytes())),
187            );
188        }
189        if let Some(copies) = self.num_copies {
190            dict.insert(Name::from("NumCopies"), Object::Int(copies));
191        }
192        if let Some(duplex) = self.duplex {
193            dict.insert(
194                Name::from("Duplex"),
195                Object::Name(Name::from(duplex.as_str().as_bytes())),
196            );
197        }
198        dict
199    }
200}
201
202/// Sets the catalog's `/ViewerPreferences`.
203///
204/// Preferences the builder leaves unset keep whatever the document had, so a
205/// caller may change one without reading the rest. A document with no
206/// `/ViewerPreferences` gains one.
207///
208/// # Errors
209///
210/// [`Error::NoDestinationCatalog`] when the document has no catalog to hold
211/// them.
212///
213/// ```
214/// use std::sync::Arc;
215/// use pdfrum_edit::{EditDoc, SaveOptions, ViewerPreferences, save, set_viewer_preferences};
216/// use pdfrum_parser::{LoadOptions, load};
217///
218/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
219/// let doc = load(bytes, &LoadOptions::default())?;
220/// let mut edit = EditDoc::new(&doc);
221///
222/// set_viewer_preferences(
223///     &mut edit,
224///     &ViewerPreferences::new().display_doc_title(true),
225/// )?;
226///
227/// let mut out = Vec::new();
228/// save(&edit, &SaveOptions::default(), &mut out)?;
229/// # Ok::<(), Box<dyn std::error::Error>>(())
230/// ```
231pub fn set_viewer_preferences(
232    dest: &mut EditDoc<'_>,
233    preferences: &ViewerPreferences,
234) -> Result<()> {
235    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
236        return Err(Error::NoDestinationCatalog);
237    };
238    let Some(mut catalog) = dest
239        .fetch(root)
240        .ok()
241        .and_then(|object| object.as_dict().cloned())
242    else {
243        return Err(Error::NoDestinationCatalog);
244    };
245
246    // An indirect dictionary is edited in place, so anything else naming it
247    // keeps pointing at the one that now carries the change.
248    match catalog.raw(names::VIEWER_PREFERENCES).cloned() {
249        Some(Object::Ref(prefs_ref)) => {
250            let existing = dest
251                .fetch(prefs_ref)
252                .ok()
253                .and_then(|object| object.as_dict().cloned())
254                .unwrap_or_default();
255            dest.replace(prefs_ref, Object::Dict(preferences.merge_into(existing)));
256        }
257        existing => {
258            let dict = match existing {
259                Some(Object::Dict(dict)) => dict,
260                _ => Dict::new(),
261            };
262            catalog.insert(
263                names::VIEWER_PREFERENCES.clone(),
264                Object::Dict(preferences.merge_into(dict)),
265            );
266            dest.replace(root, Object::Dict(catalog));
267        }
268    }
269    Ok(())
270}
271
272/// Sets the catalog's `/OpenAction` to a destination on `page`.
273///
274/// This is the destination form of `/OpenAction`, which is what a "open at
275/// this page" request means; the action-dictionary form runs a script or a
276/// go-to and is not what a document usually wants on open.
277///
278/// # Errors
279///
280/// - [`Error::NoDestinationCatalog`] when the document has no catalog.
281/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page.
282///
283/// ```
284/// use std::sync::Arc;
285/// use pdfrum_edit::{AnnotGoToView, EditDoc, SaveOptions, save, set_open_action};
286/// use pdfrum_parser::{LoadOptions, load};
287///
288/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
289/// let doc = load(bytes, &LoadOptions::default())?;
290/// let mut edit = EditDoc::new(&doc);
291///
292/// set_open_action(&mut edit, 0u32, AnnotGoToView::Fit)?;
293///
294/// let mut out = Vec::new();
295/// save(&edit, &SaveOptions::default(), &mut out)?;
296/// # Ok::<(), Box<dyn std::error::Error>>(())
297/// ```
298pub fn set_open_action(
299    dest: &mut EditDoc<'_>,
300    page: impl Into<pdfrum_common::PageIndex>,
301    view: crate::AnnotGoToView,
302) -> Result<()> {
303    let page = page.into();
304    let Some((page_ref, _, _)) = dest.page_state(page)? else {
305        return Err(Error::InlinePage(page));
306    };
307    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
308        return Err(Error::NoDestinationCatalog);
309    };
310    let Some(mut catalog) = dest
311        .fetch(root)
312        .ok()
313        .and_then(|object| object.as_dict().cloned())
314    else {
315        return Err(Error::NoDestinationCatalog);
316    };
317    catalog.insert(
318        crate::names::OPEN_ACTION.clone(),
319        Object::Array(crate::annot::goto_dest_array(page_ref, view)),
320    );
321    dest.replace(root, Object::Dict(catalog));
322    Ok(())
323}
324
325/// Removes the catalog's `/OpenAction`, so the document opens at page one with
326/// whatever view the reader prefers.
327///
328/// Answers whether there was one, matching
329/// [`delete_attachment`](crate::delete_attachment): a delete that finds
330/// nothing is not an error.
331///
332/// # Errors
333///
334/// [`Error::NoDestinationCatalog`] when the document has no catalog.
335pub fn clear_open_action(dest: &mut EditDoc<'_>) -> Result<bool> {
336    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
337        return Err(Error::NoDestinationCatalog);
338    };
339    let Some(mut catalog) = dest
340        .fetch(root)
341        .ok()
342        .and_then(|object| object.as_dict().cloned())
343    else {
344        return Err(Error::NoDestinationCatalog);
345    };
346    if !catalog.contains_key(crate::names::OPEN_ACTION) {
347        return Ok(false);
348    }
349    catalog.remove(crate::names::OPEN_ACTION);
350    dest.replace(root, Object::Dict(catalog));
351    Ok(true)
352}