Skip to main content

pdfboss_core/
page_label.rs

1//! Page labels (ISO 32000-1 §12.4.2): the labelling ranges of the catalog's
2//! `/PageLabels` number tree, and the label each page shows.
3
4use crate::object::{decode_text_string, Dict};
5use crate::source::AsyncObjectSource;
6use crate::tree::{self, resolved_dict};
7
8/// A page-numbering style, the `/S` of a page label dictionary (Table 159).
9///
10/// Covers ISO 32000-1 §12.4.2.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum LabelStyle {
13    /// Arabic numerals: 1, 2, 3.
14    Decimal,
15    /// Uppercase Roman numerals: I, II, III.
16    RomanUpper,
17    /// Lowercase Roman numerals: i, ii, iii.
18    RomanLower,
19    /// Uppercase letters: A to Z, then AA to ZZ, and so on.
20    LettersUpper,
21    /// Lowercase letters: a to z, then aa to zz, and so on.
22    LettersLower,
23}
24
25impl LabelStyle {
26    /// The style's `/S` name.
27    pub fn code(self) -> &'static str {
28        match self {
29            LabelStyle::Decimal => "D",
30            LabelStyle::RomanUpper => "R",
31            LabelStyle::RomanLower => "r",
32            LabelStyle::LettersUpper => "A",
33            LabelStyle::LettersLower => "a",
34        }
35    }
36
37    /// The style an `/S` name selects; `None` for any other name.
38    pub fn from_code(code: &str) -> Option<LabelStyle> {
39        Some(match code {
40            "D" => LabelStyle::Decimal,
41            "R" => LabelStyle::RomanUpper,
42            "r" => LabelStyle::RomanLower,
43            "A" => LabelStyle::LettersUpper,
44            "a" => LabelStyle::LettersLower,
45            _ => return None,
46        })
47    }
48
49    /// The numeric portion of a label for the number `n`, which starts at
50    /// 1: Roman numerals in subtractive form, letters as one letter for 1
51    /// to 26 and the same letter repeated once more for every further 26.
52    /// The number 0 has no Roman or letter form and gives an empty string.
53    ///
54    /// Covers ISO 32000-1 §12.4.2.
55    pub fn numeral(self, n: u32) -> String {
56        match self {
57            LabelStyle::Decimal => n.to_string(),
58            LabelStyle::RomanUpper => roman(n),
59            LabelStyle::RomanLower => roman(n).to_lowercase(),
60            LabelStyle::LettersUpper => letters(n, b'A'),
61            LabelStyle::LettersLower => letters(n, b'a'),
62        }
63    }
64}
65
66/// `n` as an uppercase Roman numeral in subtractive form; thousands past
67/// 3999 keep adding M. Empty for 0.
68fn roman(mut n: u32) -> String {
69    const STEPS: [(u32, &str); 13] = [
70        (1000, "M"),
71        (900, "CM"),
72        (500, "D"),
73        (400, "CD"),
74        (100, "C"),
75        (90, "XC"),
76        (50, "L"),
77        (40, "XL"),
78        (10, "X"),
79        (9, "IX"),
80        (5, "V"),
81        (4, "IV"),
82        (1, "I"),
83    ];
84    let mut out = String::new();
85    for (value, numeral) in STEPS {
86        while n >= value {
87            out.push_str(numeral);
88            n -= value;
89        }
90    }
91    out
92}
93
94/// `n` as letters: the letter at position `(n - 1) % 26` from `first`,
95/// repeated once for every 26 the number spans. Empty for 0.
96fn letters(n: u32, first: u8) -> String {
97    if n == 0 {
98        return String::new();
99    }
100    let letter = char::from(first + ((n - 1) % 26) as u8);
101    let count = (n - 1) / 26 + 1;
102    std::iter::repeat_n(letter, count as usize).collect()
103}
104
105/// One labelling range: every page from `first_page` up to the next
106/// range's first page shows `prefix` followed by the numeral of
107/// `start_at + (page - first_page)` in `style`; without a style the label is
108/// the prefix alone.
109///
110/// Covers ISO 32000-1 §12.4.2.
111#[derive(Debug, Clone, PartialEq)]
112pub struct PageLabel {
113    /// 0-based page index where this range begins.
114    pub first_page: usize,
115    /// Numbering style, the `/S` entry; `None` shows only `prefix`.
116    pub style: Option<LabelStyle>,
117    /// Text before every number in the range, the `/P` entry.
118    pub prefix: Option<String>,
119    /// The number shown on `first_page`, the `/St` entry; 1 by default.
120    pub start_at: u32,
121}
122
123impl PageLabel {
124    /// The label of the page at `index`, which must lie in this range.
125    ///
126    /// Covers ISO 32000-1 §12.4.2.
127    pub fn label(&self, index: usize) -> String {
128        let mut label = self.prefix.clone().unwrap_or_default();
129        if let Some(style) = self.style {
130            let offset = u32::try_from(index.saturating_sub(self.first_page)).unwrap_or(u32::MAX);
131            label.push_str(&style.numeral(self.start_at.saturating_add(offset)));
132        }
133        label
134    }
135}
136
137/// The document's labelling ranges from the catalog's `/PageLabels` number
138/// tree, sorted by first page; `None` when the catalog has no such tree.
139/// A range whose value is not a dictionary is skipped, an unknown `/S`
140/// reads as no style, and a `/St` below 1 reads as 1.
141///
142/// Covers ISO 32000-1 §12.4.2.
143pub async fn page_labels_with<S: AsyncObjectSource>(
144    src: &S,
145    trailer: &Dict,
146) -> Option<Vec<PageLabel>> {
147    let catalog = resolved_dict(src, trailer.get("Root")?).await?;
148    let root = resolved_dict(src, catalog.get("PageLabels")?).await?;
149    let mut ranges = Vec::new();
150    for (first_page, value) in tree::entries::<i64, S>(src, &root).await {
151        let Ok(first_page) = usize::try_from(first_page) else {
152            continue;
153        };
154        let Some(dict) = resolved_dict(src, &value).await else {
155            continue;
156        };
157        ranges.push(PageLabel {
158            first_page,
159            style: match dict.get("S") {
160                Some(s) => src
161                    .resolve(s)
162                    .await
163                    .ok()
164                    .and_then(|s| LabelStyle::from_code(&s.as_name()?.0)),
165                None => None,
166            },
167            prefix: match dict.get("P") {
168                Some(p) => src
169                    .resolve(p)
170                    .await
171                    .ok()
172                    .and_then(|p| p.as_str_bytes().map(decode_text_string)),
173                None => None,
174            },
175            start_at: match dict.get("St") {
176                Some(st) => src
177                    .resolve(st)
178                    .await
179                    .ok()
180                    .and_then(|st| st.as_int())
181                    .and_then(|st| u32::try_from(st).ok())
182                    .filter(|st| *st >= 1)
183                    .unwrap_or(1),
184                None => 1,
185            },
186        });
187    }
188    ranges.sort_by_key(|range| range.first_page);
189    Some(ranges)
190}
191
192/// The label of the page at `index` under `ranges`: the last range starting
193/// at or before it. A page before the first range, which the clause does
194/// not allow, shows its 1-based page number, as viewers do.
195///
196/// Covers ISO 32000-1 §12.4.2.
197pub fn page_label(ranges: &[PageLabel], index: usize) -> String {
198    match ranges.iter().rev().find(|range| range.first_page <= index) {
199        Some(range) => range.label(index),
200        None => (index + 1).to_string(),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::Document;
208    use pdfboss_testkit::PdfBuilder;
209
210    // Covers ISO 32000-1 §12.4.2.
211    #[test]
212    fn styles_number_pages_as_table_159_says() {
213        assert_eq!(LabelStyle::Decimal.numeral(1), "1");
214        assert_eq!(LabelStyle::Decimal.numeral(1040), "1040");
215        for (n, roman) in [
216            (1, "i"),
217            (4, "iv"),
218            (9, "ix"),
219            (14, "xiv"),
220            (40, "xl"),
221            (90, "xc"),
222            (400, "cd"),
223            (1994, "mcmxciv"),
224            (3999, "mmmcmxcix"),
225            (4000, "mmmm"),
226        ] {
227            assert_eq!(LabelStyle::RomanLower.numeral(n), roman, "{n}");
228            assert_eq!(
229                LabelStyle::RomanUpper.numeral(n),
230                roman.to_uppercase(),
231                "{n}"
232            );
233        }
234        for (n, letters) in [(1, "a"), (26, "z"), (27, "aa"), (52, "zz"), (53, "aaa")] {
235            assert_eq!(LabelStyle::LettersLower.numeral(n), letters, "{n}");
236            assert_eq!(
237                LabelStyle::LettersUpper.numeral(n),
238                letters.to_uppercase(),
239                "{n}"
240            );
241        }
242        assert_eq!(LabelStyle::RomanLower.numeral(0), "");
243        assert_eq!(LabelStyle::LettersUpper.numeral(0), "");
244        assert_eq!(LabelStyle::Decimal.numeral(0), "0");
245        for style in [
246            LabelStyle::Decimal,
247            LabelStyle::RomanUpper,
248            LabelStyle::RomanLower,
249            LabelStyle::LettersUpper,
250            LabelStyle::LettersLower,
251        ] {
252            assert_eq!(LabelStyle::from_code(style.code()), Some(style));
253        }
254        assert_eq!(LabelStyle::from_code("X"), None);
255    }
256
257    /// `pages` pages whose catalog carries `catalog_extra`.
258    fn doc(pages: usize, catalog_extra: &str) -> Document {
259        let mut b = PdfBuilder::new();
260        b.object(
261            1,
262            &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
263        );
264        let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 10 + i)).collect();
265        b.object(
266            2,
267            &format!(
268                "<< /Type /Pages /Kids [{}] /Count {pages} >>",
269                kids.join(" ")
270            ),
271        );
272        for i in 0..pages {
273            b.object(
274                10 + i as u32,
275                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
276            );
277        }
278        Document::load(b.build(1)).expect("load")
279    }
280
281    fn labels(doc: &Document) -> Vec<String> {
282        (0..doc.page_count())
283            .map(|i| doc.page_label(i).unwrap())
284            .collect()
285    }
286
287    // Covers ISO 32000-1 §12.4.2.
288    #[test]
289    fn the_clauses_example_labels_its_pages() {
290        // The example of 12.4.2: roman front matter, decimal body, and an
291        // appendix prefixed A- starting at 8.
292        let doc = doc(
293            9,
294            "/PageLabels << /Nums [0 << /S /r >> 4 << /S /D >> 7 << /S /D /P (A-) /St 8 >>] >>",
295        );
296        assert_eq!(
297            labels(&doc),
298            ["i", "ii", "iii", "iv", "1", "2", "3", "A-8", "A-9"]
299        );
300        let ranges = doc.page_labels().unwrap();
301        assert_eq!(ranges.len(), 3);
302        assert_eq!(
303            ranges[2],
304            PageLabel {
305                first_page: 7,
306                style: Some(LabelStyle::Decimal),
307                prefix: Some("A-".to_string()),
308                start_at: 8,
309            }
310        );
311        assert_eq!(doc.page_label(9), None, "past the last page");
312    }
313
314    // Covers ISO 32000-1 §12.4.2.
315    #[test]
316    fn ranges_without_a_style_or_a_page_zero_still_label() {
317        // No /S: the prefix alone. The tree starts at page 1, so page 0
318        // (which the clause requires a range for) shows its page number. A
319        // /St of 0 counts as 1, an unknown /S as no style, a UTF-16 prefix
320        // decodes, and a non-dictionary value is skipped.
321        let mut b = PdfBuilder::new();
322        b.object(1, "<< /Type /Catalog /Pages 2 0 R /PageLabels 30 0 R >>");
323        b.object(
324            2,
325            "<< /Type /Pages /Kids [10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 5 >>",
326        );
327        for i in 10..15 {
328            b.object(i, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
329        }
330        b.object(
331            30,
332            "<< /Nums [1 << /P (Cover) >> 2 << /S /D /St 0 >> 3 << /S /Q /P <FEFF00A7> >> 4 (junk)] >>",
333        );
334        let doc = Document::load(b.build(1)).unwrap();
335        assert_eq!(labels(&doc), ["1", "Cover", "1", "§", "§"]);
336        // No /PageLabels at all: no labels, rather than page numbers.
337        let plain = self::doc(2, "");
338        assert_eq!(plain.page_labels(), None);
339        assert_eq!(plain.page_label(0), None);
340    }
341}