Skip to main content

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}