pdfrum_edit/page_label.rs
1//! Page labels (ISO 32000-1 §12.4.2) on the write side.
2//!
3//! `/PageLabels` is a number tree from a **starting** page index to a
4//! labelling rule, and every page from there until the next entry follows that
5//! rule. So the labels a document shows are not a list one per page — they are
6//! a handful of ranges, which is why this writes ranges rather than labels.
7//!
8//! The reader's half is [`pdfrum_doc::page_label`], and the two agree on the
9//! fallback: a page inside the document with no rule covering it gets its
10//! one-based index as a decimal.
11
12use pdfrum_common::PageIndex;
13use pdfrum_object::{Array, Dict, Object, PdfString, Resolve, encode_text, names};
14
15use crate::{doc::EditDoc, error::Error};
16
17/// A label write either applies or names why it could not.
18type Result<T> = core::result::Result<T, Error>;
19
20/// How the numeric part of a label is written.
21///
22/// An absent style is its own thing rather than a default: a rule with a
23/// prefix and no style labels every page in its range with that prefix alone,
24/// which is how a run of unnumbered front matter is spelled.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum PageLabelStyle {
27 /// No numeric part at all — the prefix is the whole label.
28 #[default]
29 None,
30 /// `1`, `2`, `3` (`/D`).
31 Decimal,
32 /// `I`, `II`, `III` (`/R`).
33 UpperRoman,
34 /// `i`, `ii`, `iii` (`/r`).
35 LowerRoman,
36 /// `A`, …, `Z`, `AA` (`/A`).
37 UpperLetters,
38 /// `a`, …, `z`, `aa` (`/a`).
39 LowerLetters,
40}
41
42impl PageLabelStyle {
43 /// The `/S` name, or nothing when the rule writes no numeric part.
44 fn key(self) -> Option<&'static str> {
45 match self {
46 Self::None => None,
47 Self::Decimal => Some("D"),
48 Self::UpperRoman => Some("R"),
49 Self::LowerRoman => Some("r"),
50 Self::UpperLetters => Some("A"),
51 Self::LowerLetters => Some("a"),
52 }
53 }
54}
55
56/// One labelling rule, and the page it starts at.
57///
58/// ```
59/// use pdfrum_edit::{PageLabelRange, PageLabelStyle};
60///
61/// // Front matter in lower-case roman, then the body restarting at 1.
62/// let front = PageLabelRange::new(0u32, PageLabelStyle::LowerRoman);
63/// let body = PageLabelRange::new(4u32, PageLabelStyle::Decimal).prefix("Part 1-");
64/// assert_eq!(front.start().get(), 0);
65/// assert_eq!(body.start().get(), 4);
66/// ```
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct PageLabelRange {
69 start: PageIndex,
70 style: PageLabelStyle,
71 prefix: Option<String>,
72 first: Option<i64>,
73}
74
75impl PageLabelRange {
76 /// A rule that takes effect at `start` and runs until the next one.
77 #[must_use]
78 pub fn new(start: impl Into<PageIndex>, style: PageLabelStyle) -> Self {
79 Self {
80 start: start.into(),
81 style,
82 prefix: None,
83 first: None,
84 }
85 }
86
87 /// The page this rule takes effect at.
88 #[must_use]
89 pub fn start(&self) -> PageIndex {
90 self.start
91 }
92
93 /// `/P`: text placed before the numeric part of every label in the range.
94 #[must_use]
95 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
96 self.prefix = Some(prefix.into());
97 self
98 }
99
100 /// `/St`: the number the first page of the range is labelled with.
101 ///
102 /// Defaults to 1, which is what makes a second range *restart* rather than
103 /// continue — the usual reason a document has more than one.
104 #[must_use]
105 pub fn first(mut self, first: i64) -> Self {
106 self.first = Some(first);
107 self
108 }
109
110 /// The rule as its `/PageLabels` value.
111 fn to_dict(&self) -> Dict {
112 let mut dict = Dict::new();
113 if let Some(style) = self.style.key() {
114 dict.insert(
115 names::S.clone(),
116 Object::Name(pdfrum_object::Name::from(style.as_bytes())),
117 );
118 }
119 if let Some(prefix) = &self.prefix {
120 dict.insert(
121 crate::names::P.clone(),
122 Object::Str(PdfString::literal(encode_text(prefix))),
123 );
124 }
125 if let Some(first) = self.first {
126 dict.insert(crate::names::ST.clone(), Object::Int(first));
127 }
128 dict
129 }
130}
131
132/// Sets the document's page labels, replacing whatever it had.
133///
134/// `ranges` are sorted by starting page and de-duplicated — a later range
135/// starting at the same page as an earlier one wins — so a caller may pass
136/// them in any order. An empty slice **removes** `/PageLabels`, which returns
137/// the document to labelling every page with its one-based index.
138///
139/// The tree is written flat, as one `/Nums` array. A number tree may be split
140/// into `/Kids` leaves, and the reader walks either; splitting pays off at
141/// thousands of entries, and a document with thousands of *distinct labelling
142/// rules* is not a document anyone has.
143///
144/// # Errors
145///
146/// [`Error::NoDestinationCatalog`] when the document has no catalog to hold
147/// the tree.
148///
149/// ```
150/// use std::sync::Arc;
151/// use pdfrum_edit::{
152/// EditDoc, PageLabelRange, PageLabelStyle, SaveOptions, save, set_page_labels,
153/// };
154/// use pdfrum_parser::{LoadOptions, load};
155///
156/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
157/// let doc = load(bytes, &LoadOptions::default())?;
158/// let mut edit = EditDoc::new(&doc);
159///
160/// set_page_labels(
161/// &mut edit,
162/// &[PageLabelRange::new(0u32, PageLabelStyle::LowerRoman)],
163/// )?;
164///
165/// let mut out = Vec::new();
166/// save(&edit, &SaveOptions::default(), &mut out)?;
167/// # Ok::<(), Box<dyn std::error::Error>>(())
168/// ```
169pub fn set_page_labels(dest: &mut EditDoc<'_>, ranges: &[PageLabelRange]) -> Result<()> {
170 let Some(root) = dest.base().trailer().reference(names::ROOT) else {
171 return Err(Error::NoDestinationCatalog);
172 };
173 let Some(mut catalog) = dest
174 .fetch(root)
175 .ok()
176 .and_then(|object| object.as_dict().cloned())
177 else {
178 return Err(Error::NoDestinationCatalog);
179 };
180
181 if ranges.is_empty() {
182 catalog.remove(names::PAGE_LABELS);
183 dest.replace(root, Object::Dict(catalog));
184 return Ok(());
185 }
186
187 // A stable sort keeps ranges that share a starting page in the order the
188 // caller gave them, so "the later one wins" is decided by skipping any
189 // range that a range further along the slice supersedes.
190 let mut ranges: Vec<&PageLabelRange> = ranges.iter().collect();
191 ranges.sort_by_key(|range| range.start.get());
192
193 let mut nums = Array::default();
194 for (index, range) in ranges.iter().enumerate() {
195 let superseded = ranges
196 .get(index + 1..)
197 .is_some_and(|rest| rest.iter().any(|other| other.start == range.start));
198 if superseded {
199 continue;
200 }
201 nums.push(Object::Int(i64::from(range.start.get())));
202 nums.push(Object::Dict(range.to_dict()));
203 }
204
205 let tree = Dict::from_pairs([(crate::names::NUMS.clone(), Object::Array(nums))]);
206 catalog.insert(names::PAGE_LABELS.clone(), Object::Dict(tree));
207 dest.replace(root, Object::Dict(catalog));
208 Ok(())
209}