Skip to main content

zpdf_document/
page_labels.rs

1//! Page labels (ISO 32000-1 §12.4.2): the printed page "numbers" a viewer shows
2//! and a user types to navigate — which are *not* the physical 0-based page
3//! indices. A document commonly numbers front matter with lowercase roman
4//! numerals (`i, ii, iii …`), the body with decimals (`1, 2, 3 …`), and an
5//! appendix with a prefix (`A-1, A-2 …`). The catalog's `/PageLabels` entry is a
6//! *number tree* mapping the 0-based index of the first page of each labeling
7//! range to a label dictionary describing how that range is numbered.
8//!
9//! A label dictionary (Table 159) carries:
10//!
11//! * `/S` — the numbering *style* of the numeric portion: `/D` decimal, `/R` /
12//!   `/r` upper/lower roman, `/A` / `/a` upper/lower letters (`A…Z, AA…ZZ, AAA…`).
13//!   Absent `/S` means the label has *no* numeric portion (only the prefix).
14//! * `/P` — a label *prefix* string prepended to the numeric portion.
15//! * `/St` — the numeric value of the *first* page in the range (default `1`,
16//!   and `≥ 1`); subsequent pages count up from it.
17//!
18//! This module reads `/PageLabels` once into the sorted set of ranges and answers
19//! "what is page *i*'s label?" ([`PageLabels::label`]). It only reads the object
20//! graph; nothing here renders, and (like the other navigation readers) it runs
21//! only when explicitly called — never during `open` or rendering.
22
23use std::collections::HashSet;
24
25use zpdf_core::{ObjectId, PdfDict, PdfObject};
26use zpdf_parser::PdfFile;
27
28use crate::obj_util::{catalog_dict, resolve_array, resolve_dict, resolve_name, text};
29
30/// Maximum depth of a `/PageLabels` number-tree descent (mirrors the name-tree
31/// bound used for destinations).
32const MAX_NUMBER_TREE_DEPTH: usize = 64;
33/// Cap on tree nodes *and* collected entries materialized while flattening the
34/// number tree — bounds a crafted (huge or deeply-nested) tree. Far above any
35/// real document, which carries at most a handful of labeling ranges.
36const MAX_PAGE_LABEL_ENTRIES: usize = 200_000;
37/// Cap on a `/P` prefix's length (in `char`s) carried per range — a real prefix
38/// is a few characters (`"A-"`, `"Appendix "`); this bounds an adversarial one.
39const MAX_PREFIX_CHARS: usize = 1024;
40/// Above this numeric value a roman/letters rendering is both meaningless and a
41/// memory hazard (a multi-megabyte run of `M`s or `A`s from a crafted `/St`), so
42/// the numeric portion falls back to decimal. No real page label approaches it.
43const MAX_FANCY_VALUE: u64 = 100_000;
44
45/// The numbering style of a label's numeric portion (ISO 32000-1 Table 159).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PageLabelStyle {
48    /// `/D` — decimal arabic numerals (`1, 2, 3, …`).
49    Decimal,
50    /// `/R` — uppercase roman numerals (`I, II, III, …`).
51    RomanUpper,
52    /// `/r` — lowercase roman numerals (`i, ii, iii, …`).
53    RomanLower,
54    /// `/A` — uppercase letters (`A, B, …, Z, AA, BB, …`).
55    LettersUpper,
56    /// `/a` — lowercase letters (`a, b, …, z, aa, bb, …`).
57    LettersLower,
58    /// No `/S` — the label has only its `/P` prefix and no numeric portion.
59    None,
60}
61
62impl PageLabelStyle {
63    /// Map a `/S` name to a style; an absent or unrecognized name is [`None`].
64    fn from_name(name: Option<&str>) -> Self {
65        match name {
66            Some("D") => Self::Decimal,
67            Some("R") => Self::RomanUpper,
68            Some("r") => Self::RomanLower,
69            Some("A") => Self::LettersUpper,
70            Some("a") => Self::LettersLower,
71            _ => Self::None,
72        }
73    }
74}
75
76/// One labeling range: every page from `start` (a 0-based index) up to the next
77/// range's start carries this style/prefix, numbered from `first`.
78#[derive(Debug, Clone)]
79struct LabelRange {
80    /// 0-based index of the first page this range labels.
81    start: usize,
82    /// Numbering style of the numeric portion.
83    style: PageLabelStyle,
84    /// `/P` prefix, prepended to the numeric portion (possibly empty).
85    prefix: String,
86    /// `/St` — numeric value at `start` (≥ 1; counts up for later pages).
87    first: u64,
88}
89
90/// The document's page labels, parsed from `/PageLabels`. Built only when the
91/// document declares at least one well-formed labeling range.
92#[derive(Debug, Clone)]
93pub struct PageLabels {
94    /// Ranges sorted ascending by `start`, with duplicate starts collapsed
95    /// (first occurrence wins).
96    ranges: Vec<LabelRange>,
97}
98
99impl PageLabels {
100    /// The printed label for a 0-based page index, or `None` when the page falls
101    /// *before* the first labeling range (so no range covers it — the document
102    /// gave it no label). A range with neither a numeric style nor a prefix
103    /// yields `Some("")` — an explicit, deliberately-blank label.
104    pub fn label(&self, page_index: usize) -> Option<String> {
105        // The covering range is the one with the greatest `start ≤ page_index`.
106        let idx = match self.ranges.binary_search_by(|r| r.start.cmp(&page_index)) {
107            Ok(i) => i,
108            // `Err(0)` — page_index precedes every range's start: uncovered.
109            Err(0) => return None,
110            Err(i) => i - 1,
111        };
112        let range = &self.ranges[idx];
113        let offset = (page_index - range.start) as u64;
114        let value = range.first.saturating_add(offset);
115        let numeric = format_numeric(range.style, value);
116        Some(format!("{}{}", range.prefix, numeric))
117    }
118}
119
120/// Parse the catalog's `/PageLabels` number tree. Returns `None` when the
121/// document declares no page labels, or the tree yields no usable range.
122pub fn parse_page_labels(file: &PdfFile) -> Option<PageLabels> {
123    let root = catalog_dict(file)?;
124    let tree = resolve_dict(file, root.get("PageLabels"))?;
125
126    let mut entries: Vec<(i64, PdfObject)> = Vec::new();
127    let mut visited = HashSet::new();
128    // Seed the cycle guard with the tree-root reference itself.
129    if let Some(PdfObject::Ref(id)) = root.get("PageLabels") {
130        visited.insert(*id);
131    }
132    let mut budget = MAX_PAGE_LABEL_ENTRIES;
133    collect_number_tree(file, &tree, 0, &mut visited, &mut budget, &mut entries);
134
135    let mut ranges: Vec<LabelRange> = Vec::new();
136    for (key, value) in entries {
137        // A range starts at a non-negative page index; a negative or otherwise
138        // unusable key is skipped (it can never cover a real page).
139        let Ok(start) = usize::try_from(key) else {
140            continue;
141        };
142        let Some(dict) = resolve_dict(file, Some(&value)) else {
143            continue;
144        };
145        ranges.push(LabelRange {
146            start,
147            style: PageLabelStyle::from_name(resolve_name(file, dict.get("S")).as_deref()),
148            prefix: read_prefix(file, &dict),
149            first: read_start_value(file, &dict),
150        });
151    }
152
153    if ranges.is_empty() {
154        return None;
155    }
156
157    // Sort by start; a stable sort keeps the first tree occurrence of a duplicate
158    // start, then dedup collapses the rest so `label`'s binary search is sound.
159    ranges.sort_by_key(|r| r.start);
160    ranges.dedup_by_key(|r| r.start);
161
162    Some(PageLabels { ranges })
163}
164
165/// Read and bound a label dictionary's `/P` prefix (a text string), decoding
166/// UTF-16BE / PDFDoc like the other text-string readers. An absent prefix is the
167/// empty string; an adversarially long one is truncated on a `char` boundary.
168fn read_prefix(file: &PdfFile, dict: &PdfDict) -> String {
169    match text(file, dict, "P") {
170        Some(p) if p.chars().count() > MAX_PREFIX_CHARS => {
171            p.chars().take(MAX_PREFIX_CHARS).collect()
172        }
173        Some(p) => p,
174        None => String::new(),
175    }
176}
177
178/// Read a label dictionary's `/St` (the numeric value at the range's first page),
179/// following one indirect reference. Per spec `/St` is an integer `≥ 1`; a
180/// whole-valued real is accepted too (lax producers, mirroring the
181/// `embedded_files` integer helper). An absent, fractional, non-numeric, or
182/// out-of-range value clamps to the default `1`.
183fn read_start_value(file: &PdfFile, dict: &PdfDict) -> u64 {
184    let raw = match dict.get("St") {
185        Some(PdfObject::Ref(r)) => file.resolve(*r).ok(),
186        Some(other) => Some(other.clone()),
187        None => None,
188    };
189    let n = match raw {
190        Some(PdfObject::Integer(n)) => n,
191        Some(PdfObject::Real(f)) if f.is_finite() && f.fract() == 0.0 => f as i64,
192        _ => return 1,
193    };
194    if n >= 1 {
195        n as u64
196    } else {
197        1
198    }
199}
200
201/// Render the numeric portion of a label for `value` in `style`. Returns the
202/// empty string for [`PageLabelStyle::None`] (prefix-only labels). A value beyond
203/// [`MAX_FANCY_VALUE`] falls back to decimal so a crafted `/St` cannot inflate a
204/// roman/letters rendering into a huge string.
205fn format_numeric(style: PageLabelStyle, value: u64) -> String {
206    use PageLabelStyle::*;
207    match style {
208        None => String::new(),
209        Decimal => value.to_string(),
210        // A zero numeric value has no roman/letters form (St ≥ 1 makes this
211        // defensive); render nothing rather than an empty or bogus glyph.
212        _ if value == 0 => String::new(),
213        _ if value > MAX_FANCY_VALUE => value.to_string(),
214        RomanUpper => to_roman(value, true),
215        RomanLower => to_roman(value, false),
216        LettersUpper => to_letters(value, true),
217        LettersLower => to_letters(value, false),
218    }
219}
220
221/// Roman numeral for `value` (`≥ 1`, and bounded by [`MAX_FANCY_VALUE`] at the
222/// call site, so the leading run of `M`s stays short). Values above 3999 keep
223/// repeating `M` for the thousands, matching mainstream viewers.
224fn to_roman(value: u64, upper: bool) -> String {
225    const TABLE: [(&str, u64); 13] = [
226        ("M", 1000),
227        ("CM", 900),
228        ("D", 500),
229        ("CD", 400),
230        ("C", 100),
231        ("XC", 90),
232        ("L", 50),
233        ("XL", 40),
234        ("X", 10),
235        ("IX", 9),
236        ("V", 5),
237        ("IV", 4),
238        ("I", 1),
239    ];
240    let mut n = value;
241    let mut s = String::new();
242    for (sym, v) in TABLE {
243        while n >= v {
244            s.push_str(sym);
245            n -= v;
246        }
247    }
248    if upper {
249        s
250    } else {
251        s.to_ascii_lowercase()
252    }
253}
254
255/// Letter sequence for `value` (`≥ 1`): `1→A, …, 26→Z, 27→AA, 28→BB, …, 53→AAA`
256/// (the letter `(value-1) mod 26`, repeated `⌈value/26⌉` times). Bounded by
257/// [`MAX_FANCY_VALUE`] at the call site so the repeat count stays small.
258fn to_letters(value: u64, upper: bool) -> String {
259    let base = if upper { b'A' } else { b'a' };
260    let letter = ((value - 1) % 26) as u8;
261    let count = ((value - 1) / 26) + 1;
262    let ch = (base + letter) as char;
263    std::iter::repeat_n(ch, count as usize).collect()
264}
265
266/// Flatten every leaf `key → value` entry of a `/PageLabels` number tree into
267/// `out`. A number-tree leaf holds `/Nums [ key0 val0 key1 val1 … ]` with integer
268/// keys; interior nodes hold `/Kids` (each optionally `/Limits [lo hi]`, which we
269/// do not prune by since we want *all* entries). Bounded by depth, a
270/// per-reference visited set, and a shared budget counting each node and each
271/// collected entry.
272fn collect_number_tree(
273    file: &PdfFile,
274    node: &PdfDict,
275    depth: usize,
276    visited: &mut HashSet<ObjectId>,
277    budget: &mut usize,
278    out: &mut Vec<(i64, PdfObject)>,
279) {
280    if depth > MAX_NUMBER_TREE_DEPTH || *budget == 0 {
281        return;
282    }
283    *budget -= 1;
284
285    // Leaf: /Nums [ key0 val0 key1 val1 … ], keys ascending. The budget is spent
286    // per key/value pair *examined* (not only per integer key collected), so a
287    // crafted all-non-integer /Nums array can't be scanned in full for free.
288    if let Some(nums) = resolve_array(file, node.get("Nums")) {
289        let mut i = 0;
290        while i + 1 < nums.len() {
291            if *budget == 0 {
292                return;
293            }
294            *budget -= 1;
295            if let PdfObject::Integer(k) = nums[i] {
296                out.push((k, nums[i + 1].clone()));
297            }
298            i += 2;
299        }
300    }
301
302    // Interior: /Kids [ refs ].
303    if let Some(kids) = resolve_array(file, node.get("Kids")) {
304        for kid in &kids {
305            if *budget == 0 {
306                return;
307            }
308            let kid_dict = match kid {
309                PdfObject::Ref(r) => {
310                    if !visited.insert(*r) {
311                        continue;
312                    }
313                    resolve_dict(file, Some(kid))
314                }
315                PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
316                _ => None,
317            };
318            let Some(d) = kid_dict else { continue };
319            collect_number_tree(file, &d, depth + 1, visited, budget, out);
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::test_util::build_pdf;
328    use crate::PdfDocument;
329
330    const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
331    const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
332
333    fn labels(catalog: &str) -> Option<PageLabels> {
334        let doc = PdfDocument::open(build_pdf(&[catalog, PAGES, PAGE])).expect("open");
335        doc.page_labels()
336    }
337
338    #[test]
339    fn no_page_labels_is_none() {
340        assert!(labels("<< /Type /Catalog /Pages 2 0 R >>").is_none());
341    }
342
343    #[test]
344    fn roman_front_matter_then_decimal_body() {
345        // Pages 0-3 lowercase roman from i; pages 4+ decimal from 1.
346        let pl = labels(
347            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
348             << /Nums [0 << /S /r >> 4 << /S /D >>] >> >>",
349        )
350        .expect("labels");
351        assert_eq!(pl.label(0).as_deref(), Some("i"));
352        assert_eq!(pl.label(1).as_deref(), Some("ii"));
353        assert_eq!(pl.label(3).as_deref(), Some("iv"));
354        assert_eq!(pl.label(4).as_deref(), Some("1"));
355        assert_eq!(pl.label(5).as_deref(), Some("2"));
356    }
357
358    #[test]
359    fn start_offset_and_prefix() {
360        // Appendix: prefix "A-", decimal, first page numbered 1; another range
361        // starts numbering at 5 via /St.
362        let pl = labels(
363            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
364             << /Nums [0 << /S /D /P (A-) >> 3 << /S /D /St 5 >>] >> >>",
365        )
366        .expect("labels");
367        assert_eq!(pl.label(0).as_deref(), Some("A-1"));
368        assert_eq!(pl.label(2).as_deref(), Some("A-3"));
369        assert_eq!(pl.label(3).as_deref(), Some("5"));
370        assert_eq!(pl.label(4).as_deref(), Some("6"));
371    }
372
373    #[test]
374    fn uppercase_roman_and_letters() {
375        let pl = labels(
376            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
377             << /Nums [0 << /S /R >> 3 << /S /A >>] >> >>",
378        )
379        .expect("labels");
380        assert_eq!(pl.label(0).as_deref(), Some("I"));
381        assert_eq!(pl.label(2).as_deref(), Some("III"));
382        assert_eq!(pl.label(3).as_deref(), Some("A")); // letters value 1
383        assert_eq!(pl.label(4).as_deref(), Some("B"));
384    }
385
386    #[test]
387    fn letters_wrap_past_z() {
388        // A range numbered with letters from /St 26 → Z, 27 → AA, 28 → BB.
389        let pl = labels(
390            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
391             << /Nums [0 << /S /a /St 26 >>] >> >>",
392        )
393        .expect("labels");
394        assert_eq!(pl.label(0).as_deref(), Some("z")); // 26
395        assert_eq!(pl.label(1).as_deref(), Some("aa")); // 27
396        assert_eq!(pl.label(2).as_deref(), Some("bb")); // 28
397    }
398
399    #[test]
400    fn prefix_only_when_no_style() {
401        // No /S: the label is the prefix alone, with no numeric portion.
402        let pl = labels(
403            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
404             << /Nums [0 << /P (Cover) >>] >> >>",
405        )
406        .expect("labels");
407        assert_eq!(pl.label(0).as_deref(), Some("Cover"));
408        assert_eq!(pl.label(1).as_deref(), Some("Cover")); // range extends
409    }
410
411    #[test]
412    fn pages_before_first_range_are_unlabeled() {
413        // First range starts at page index 2: pages 0 and 1 have no label.
414        let pl = labels(
415            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
416             << /Nums [2 << /S /D >>] >> >>",
417        )
418        .expect("labels");
419        assert_eq!(pl.label(0), None);
420        assert_eq!(pl.label(1), None);
421        assert_eq!(pl.label(2).as_deref(), Some("1"));
422    }
423
424    #[test]
425    fn number_tree_with_kids_interior_node() {
426        // /PageLabels as an interior node with a /Kids leaf, not a flat /Nums.
427        let doc = PdfDocument::open(build_pdf(&[
428            "<< /Type /Catalog /Pages 2 0 R /PageLabels << /Kids [4 0 R] >> >>",
429            PAGES,
430            PAGE,
431            "<< /Limits [0 0] /Nums [0 << /S /D /P (p) >>] >>",
432        ]))
433        .expect("open");
434        let pl = doc.page_labels().expect("labels via kids");
435        assert_eq!(pl.label(0).as_deref(), Some("p1"));
436    }
437
438    #[test]
439    fn cyclic_kids_terminate() {
440        // A number-tree node listing itself as a kid must not hang.
441        let doc = PdfDocument::open(build_pdf(&[
442            "<< /Type /Catalog /Pages 2 0 R /PageLabels 4 0 R >>",
443            PAGES,
444            PAGE,
445            "<< /Kids [4 0 R] /Nums [0 << /S /D >>] >>",
446        ]))
447        .expect("open");
448        let pl = doc.page_labels().expect("labels");
449        assert_eq!(pl.label(0).as_deref(), Some("1"));
450    }
451
452    #[test]
453    fn huge_start_value_falls_back_to_decimal() {
454        // A crafted /St beyond MAX_FANCY_VALUE must not build a giant roman/letters
455        // string — it renders as decimal instead.
456        let pl = labels(
457            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
458             << /Nums [0 << /S /R /St 2000000000 >>] >> >>",
459        )
460        .expect("labels");
461        let l = pl.label(0).expect("label");
462        assert_eq!(l, "2000000000");
463        assert!(l.len() < 32, "must not expand into a huge roman string");
464    }
465
466    #[test]
467    fn negative_key_is_skipped() {
468        // A negative number-tree key can't index a page; it's dropped, and the
469        // valid range still applies.
470        let pl = labels(
471            "<< /Type /Catalog /Pages 2 0 R /PageLabels \
472             << /Nums [-5 << /S /R >> 0 << /S /D >>] >> >>",
473        )
474        .expect("labels");
475        assert_eq!(pl.label(0).as_deref(), Some("1"));
476    }
477
478    #[test]
479    fn roman_numeral_spot_values() {
480        assert_eq!(to_roman(4, true), "IV");
481        assert_eq!(to_roman(9, true), "IX");
482        assert_eq!(to_roman(40, false), "xl");
483        assert_eq!(to_roman(1990, true), "MCMXC");
484        assert_eq!(to_roman(2024, false), "mmxxiv");
485    }
486
487    #[test]
488    fn letter_sequence_spot_values() {
489        assert_eq!(to_letters(1, true), "A");
490        assert_eq!(to_letters(26, true), "Z");
491        assert_eq!(to_letters(27, true), "AA");
492        assert_eq!(to_letters(52, false), "zz");
493        assert_eq!(to_letters(53, true), "AAA");
494    }
495}