1use 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
30const MAX_NUMBER_TREE_DEPTH: usize = 64;
33const MAX_PAGE_LABEL_ENTRIES: usize = 200_000;
37const MAX_PREFIX_CHARS: usize = 1024;
40const MAX_FANCY_VALUE: u64 = 100_000;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum PageLabelStyle {
48 Decimal,
50 RomanUpper,
52 RomanLower,
54 LettersUpper,
56 LettersLower,
58 None,
60}
61
62impl PageLabelStyle {
63 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#[derive(Debug, Clone)]
79struct LabelRange {
80 start: usize,
82 style: PageLabelStyle,
84 prefix: String,
86 first: u64,
88}
89
90#[derive(Debug, Clone)]
93pub struct PageLabels {
94 ranges: Vec<LabelRange>,
97}
98
99impl PageLabels {
100 pub fn label(&self, page_index: usize) -> Option<String> {
105 let idx = match self.ranges.binary_search_by(|r| r.start.cmp(&page_index)) {
107 Ok(i) => i,
108 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
120pub 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 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 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 ranges.sort_by_key(|r| r.start);
160 ranges.dedup_by_key(|r| r.start);
161
162 Some(PageLabels { ranges })
163}
164
165fn 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
178fn 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
201fn format_numeric(style: PageLabelStyle, value: u64) -> String {
206 use PageLabelStyle::*;
207 match style {
208 None => String::new(),
209 Decimal => value.to_string(),
210 _ 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
221fn 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
255fn 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
266fn 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 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 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 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 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")); assert_eq!(pl.label(4).as_deref(), Some("B"));
384 }
385
386 #[test]
387 fn letters_wrap_past_z() {
388 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")); assert_eq!(pl.label(1).as_deref(), Some("aa")); assert_eq!(pl.label(2).as_deref(), Some("bb")); }
398
399 #[test]
400 fn prefix_only_when_no_style() {
401 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")); }
410
411 #[test]
412 fn pages_before_first_range_are_unlabeled() {
413 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 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 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 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 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}