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}