Skip to main content

justpdf_core/
page_label.rs

1//! PDF page labels (section 7.7).
2//!
3//! Page labels allow PDF pages to display labels like "i", "ii", "iii" or
4//! "A-1", "A-2" instead of raw sequential page numbers. Labels are defined
5//! as a number tree in the document catalog under the /PageLabels key.
6
7use crate::error::{JustPdfError, Result};
8use crate::object::{PdfDict, PdfObject};
9use crate::parser::PdfDocument;
10use crate::writer::modify::DocumentModifier;
11
12// ---------------------------------------------------------------------------
13// Types
14// ---------------------------------------------------------------------------
15
16/// The numbering style for a page label range.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PageLabelStyle {
19    /// Arabic decimal numerals: 1, 2, 3, ...
20    Decimal,
21    /// Uppercase Roman numerals: I, II, III, ...
22    UpperRoman,
23    /// Lowercase Roman numerals: i, ii, iii, ...
24    LowerRoman,
25    /// Uppercase letters: A, B, ..., Z, AA, AB, ...
26    UpperAlpha,
27    /// Lowercase letters: a, b, ..., z, aa, ab, ...
28    LowerAlpha,
29    /// No numeric portion; only the prefix (if any) is used.
30    None,
31}
32
33impl PageLabelStyle {
34    /// Decode from the PDF /S name value.
35    fn from_name(name: &[u8]) -> Option<Self> {
36        match name {
37            b"D" => Some(Self::Decimal),
38            b"R" => Some(Self::UpperRoman),
39            b"r" => Some(Self::LowerRoman),
40            b"A" => Some(Self::UpperAlpha),
41            b"a" => Some(Self::LowerAlpha),
42            _ => Option::None,
43        }
44    }
45
46    /// Encode to the PDF /S name value. Returns `None` for `PageLabelStyle::None`.
47    fn to_name(&self) -> Option<&'static [u8]> {
48        match self {
49            Self::Decimal => Some(b"D"),
50            Self::UpperRoman => Some(b"R"),
51            Self::LowerRoman => Some(b"r"),
52            Self::UpperAlpha => Some(b"A"),
53            Self::LowerAlpha => Some(b"a"),
54            Self::None => Option::None,
55        }
56    }
57}
58
59/// A single page label range. Defines the labelling scheme starting at a
60/// particular 0-based page index.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct PageLabelRange {
63    /// 0-based page index where this range begins.
64    pub start_page: usize,
65    /// The numbering style.
66    pub style: PageLabelStyle,
67    /// An optional prefix prepended to every label in this range.
68    pub prefix: String,
69    /// The numeric value for the first page in this range (default 1).
70    pub logical_start: i64,
71}
72
73impl PageLabelRange {
74    /// Create a new range with the given start page and style. The prefix
75    /// defaults to the empty string and `logical_start` defaults to 1.
76    pub fn new(start_page: usize, style: PageLabelStyle) -> Self {
77        Self {
78            start_page,
79            style,
80            prefix: String::new(),
81            logical_start: 1,
82        }
83    }
84
85    /// Builder-style setter for the prefix.
86    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
87        self.prefix = prefix.into();
88        self
89    }
90
91    /// Builder-style setter for the logical start value.
92    pub fn with_logical_start(mut self, start: i64) -> Self {
93        self.logical_start = start;
94        self
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Number tree helpers
100// ---------------------------------------------------------------------------
101
102/// Parse a PDF number tree node and collect all (key, value) pairs.
103///
104/// A number tree is structured similarly to a name tree:
105/// - Leaf nodes contain a /Nums array: `[key1 value1 key2 value2 ...]`
106/// - Intermediate nodes contain a /Kids array of indirect references to child
107///   nodes, and optionally a /Limits array `[min max]`.
108fn parse_number_tree(
109    doc: &PdfDocument,
110    node: &PdfObject,
111    out: &mut Vec<(i64, PdfObject)>,
112) -> Result<()> {
113    let dict = match node {
114        PdfObject::Dict(d) => d.clone(),
115        PdfObject::Reference(r) => {
116            let resolved = doc.resolve(r)?;
117            match resolved {
118                PdfObject::Dict(d) => d,
119                _ => return Ok(()),
120            }
121        }
122        _ => return Ok(()),
123    };
124
125    // Leaf: /Nums [key1 val1 key2 val2 ...]
126    if let Some(nums) = dict.get_array(b"Nums") {
127        let mut i = 0;
128        while i + 1 < nums.len() {
129            if let Some(key) = nums[i].as_i64() {
130                out.push((key, nums[i + 1].clone()));
131            }
132            i += 2;
133        }
134    }
135
136    // Intermediate: /Kids [ref1 ref2 ...]
137    if let Some(kids) = dict.get_array(b"Kids") {
138        let kids_owned: Vec<PdfObject> = kids.to_vec();
139        for kid in &kids_owned {
140            match kid {
141                PdfObject::Reference(r) => {
142                    let child = doc.resolve(r)?;
143                    parse_number_tree(doc, &child, out)?;
144                }
145                PdfObject::Dict(_) => {
146                    parse_number_tree(doc, kid, out)?;
147                }
148                _ => {}
149            }
150        }
151    }
152
153    Ok(())
154}
155
156/// Build a /Nums array from sorted (key, value) pairs.
157fn build_nums_array(entries: &[(i64, PdfObject)]) -> Vec<PdfObject> {
158    let mut arr = Vec::with_capacity(entries.len() * 2);
159    for (key, value) in entries {
160        arr.push(PdfObject::Integer(*key));
161        arr.push(value.clone());
162    }
163    arr
164}
165
166// ---------------------------------------------------------------------------
167// Parsing
168// ---------------------------------------------------------------------------
169
170/// Read page label ranges from the document catalog's /PageLabels number tree.
171///
172/// Returns an empty vec if no page labels are defined.
173pub fn read_page_labels(doc: &PdfDocument) -> Result<Vec<PageLabelRange>> {
174    // Get catalog
175    let catalog_ref = match doc.catalog_ref() {
176        Some(r) => r.clone(),
177        None => return Ok(Vec::new()),
178    };
179    let catalog = match doc.resolve(&catalog_ref)? {
180        PdfObject::Dict(d) => d,
181        _ => return Ok(Vec::new()),
182    };
183
184    // Get /PageLabels
185    let page_labels_obj = match catalog.get(b"PageLabels") {
186        Some(PdfObject::Reference(r)) => {
187            let r = r.clone();
188            doc.resolve(&r)?
189        }
190        Some(obj) => obj.clone(),
191        None => return Ok(Vec::new()),
192    };
193
194    // Parse the number tree
195    let mut entries: Vec<(i64, PdfObject)> = Vec::new();
196    parse_number_tree(doc, &page_labels_obj, &mut entries)?;
197
198    // Sort by key (page index)
199    entries.sort_by_key(|(k, _)| *k);
200
201    // Convert entries to PageLabelRange structs
202    let mut ranges = Vec::with_capacity(entries.len());
203    for (page_index, value) in &entries {
204        let label_dict = match value {
205            PdfObject::Dict(d) => d.clone(),
206            PdfObject::Reference(r) => {
207                let r = r.clone();
208                match doc.resolve(&r)? {
209                    PdfObject::Dict(d) => d,
210                    _ => continue,
211                }
212            }
213            _ => continue,
214        };
215
216        let style = match label_dict.get_name(b"S") {
217            Some(name) => PageLabelStyle::from_name(name).unwrap_or(PageLabelStyle::None),
218            None => PageLabelStyle::None,
219        };
220
221        let prefix = match label_dict.get_string(b"P") {
222            Some(p) => String::from_utf8_lossy(p).into_owned(),
223            None => String::new(),
224        };
225
226        let logical_start = label_dict.get_i64(b"St").unwrap_or(1);
227
228        ranges.push(PageLabelRange {
229            start_page: *page_index as usize,
230            style,
231            prefix,
232            logical_start,
233        });
234    }
235
236    Ok(ranges)
237}
238
239// ---------------------------------------------------------------------------
240// Label generation
241// ---------------------------------------------------------------------------
242
243/// Generate the display label for a given 0-based page index using the
244/// provided label ranges.
245///
246/// If `ranges` is empty the page index + 1 is returned as a decimal string
247/// (the PDF default behaviour).
248pub fn label_for_page(ranges: &[PageLabelRange], page_index: usize) -> String {
249    if ranges.is_empty() {
250        return (page_index + 1).to_string();
251    }
252
253    // Find the applicable range: the last range whose start_page <= page_index.
254    let range = match ranges
255        .iter()
256        .rev()
257        .find(|r| r.start_page <= page_index)
258    {
259        Some(r) => r,
260        None => return (page_index + 1).to_string(),
261    };
262
263    let offset = (page_index - range.start_page) as i64;
264    let value = range.logical_start + offset;
265
266    let numeric_part = match range.style {
267        PageLabelStyle::Decimal => value.to_string(),
268        PageLabelStyle::UpperRoman => to_roman(value, true),
269        PageLabelStyle::LowerRoman => to_roman(value, false),
270        PageLabelStyle::UpperAlpha => to_alpha(value, true),
271        PageLabelStyle::LowerAlpha => to_alpha(value, false),
272        PageLabelStyle::None => String::new(),
273    };
274
275    format!("{}{}", range.prefix, numeric_part)
276}
277
278// ---------------------------------------------------------------------------
279// Roman numeral conversion
280// ---------------------------------------------------------------------------
281
282/// Convert a positive integer to a Roman numeral string.
283///
284/// If `value` is zero or negative, returns the empty string.
285pub fn to_roman(value: i64, uppercase: bool) -> String {
286    if value <= 0 {
287        return String::new();
288    }
289
290    const TABLE: &[(i64, &str)] = &[
291        (1000, "M"),
292        (900, "CM"),
293        (500, "D"),
294        (400, "CD"),
295        (100, "C"),
296        (90, "XC"),
297        (50, "L"),
298        (40, "XL"),
299        (10, "X"),
300        (9, "IX"),
301        (5, "V"),
302        (4, "IV"),
303        (1, "I"),
304    ];
305
306    let mut result = String::new();
307    let mut remaining = value;
308
309    for &(threshold, symbol) in TABLE {
310        while remaining >= threshold {
311            result.push_str(symbol);
312            remaining -= threshold;
313        }
314    }
315
316    if uppercase {
317        result
318    } else {
319        result.to_lowercase()
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Alpha conversion
325// ---------------------------------------------------------------------------
326
327/// Convert a positive integer to an alphabetic label.
328///
329/// 1 => A, 2 => B, ..., 26 => Z, 27 => AA, 28 => AB, ...
330///
331/// If `value` is zero or negative, returns the empty string.
332pub fn to_alpha(value: i64, uppercase: bool) -> String {
333    if value <= 0 {
334        return String::new();
335    }
336
337    let mut result = Vec::new();
338    let mut remaining = value - 1; // 0-based
339
340    loop {
341        let ch = (remaining % 26) as u8;
342        let base = if uppercase { b'A' } else { b'a' };
343        result.push(base + ch);
344        remaining = remaining / 26 - 1;
345        if remaining < 0 {
346            break;
347        }
348    }
349
350    result.reverse();
351    String::from_utf8(result).unwrap_or_default()
352}
353
354// ---------------------------------------------------------------------------
355// Builder
356// ---------------------------------------------------------------------------
357
358/// Write page label ranges into the document catalog as a /PageLabels number
359/// tree.
360pub fn set_page_labels(
361    modifier: &mut DocumentModifier,
362    ranges: &[PageLabelRange],
363) -> Result<()> {
364    // Build the label dict entries
365    let mut entries: Vec<(i64, PdfObject)> = Vec::with_capacity(ranges.len());
366
367    for range in ranges {
368        let mut label_dict = PdfDict::new();
369
370        if let Some(name) = range.style.to_name() {
371            label_dict.insert(b"S".to_vec(), PdfObject::Name(name.to_vec()));
372        }
373
374        if !range.prefix.is_empty() {
375            label_dict.insert(
376                b"P".to_vec(),
377                PdfObject::String(range.prefix.as_bytes().to_vec()),
378            );
379        }
380
381        if range.logical_start != 1 {
382            label_dict.insert(
383                b"St".to_vec(),
384                PdfObject::Integer(range.logical_start),
385            );
386        }
387
388        entries.push((range.start_page as i64, PdfObject::Dict(label_dict)));
389    }
390
391    // Sort by page index
392    entries.sort_by_key(|(k, _)| *k);
393
394    // Build the number tree dict with a /Nums array
395    let nums_array = build_nums_array(&entries);
396    let mut tree_dict = PdfDict::new();
397    tree_dict.insert(b"Nums".to_vec(), PdfObject::Array(nums_array));
398
399    // Add as a new indirect object
400    let tree_ref = modifier.add_object(PdfObject::Dict(tree_dict));
401
402    // Update the catalog to reference this tree
403    let catalog_ref = modifier.catalog_ref().clone();
404    let catalog_obj = modifier
405        .find_object_pub(catalog_ref.obj_num)
406        .cloned()
407        .ok_or_else(|| JustPdfError::FormError {
408            detail: "catalog object not found".into(),
409        })?;
410
411    match catalog_obj {
412        PdfObject::Dict(mut cat) => {
413            cat.insert(
414                b"PageLabels".to_vec(),
415                PdfObject::Reference(tree_ref),
416            );
417            modifier.set_object(catalog_ref.obj_num, PdfObject::Dict(cat));
418        }
419        _ => {
420            return Err(JustPdfError::FormError {
421                detail: "catalog is not a dictionary".into(),
422            });
423        }
424    }
425
426    Ok(())
427}
428
429// ---------------------------------------------------------------------------
430// Tests
431// ---------------------------------------------------------------------------
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::object::{PdfDict, PdfObject};
437    use crate::parser::PdfDocument;
438    use crate::writer::document::DocumentBuilder;
439    use crate::writer::modify::DocumentModifier;
440    use crate::writer::page::PageBuilder;
441
442    /// Helper: create a minimal test PDF with the given number of pages.
443    fn make_test_pdf(num_pages: usize) -> Vec<u8> {
444        let mut doc = DocumentBuilder::new();
445        let font = doc.add_standard_font("Helvetica");
446        for i in 0..num_pages {
447            let mut page = PageBuilder::new(612.0, 792.0);
448            page.add_font(&font, "Helvetica");
449            page.begin_text();
450            page.set_font(&font, 12.0);
451            page.move_to(72.0, 720.0);
452            page.show_text(&format!("Page {}", i + 1));
453            page.end_text();
454            doc.add_page(page);
455        }
456        doc.build().unwrap()
457    }
458
459    // -- Roman numeral tests ------------------------------------------------
460
461    #[test]
462    fn test_to_roman_basic() {
463        assert_eq!(to_roman(1, true), "I");
464        assert_eq!(to_roman(4, true), "IV");
465        assert_eq!(to_roman(9, true), "IX");
466        assert_eq!(to_roman(14, true), "XIV");
467        assert_eq!(to_roman(42, true), "XLII");
468        assert_eq!(to_roman(99, true), "XCIX");
469        assert_eq!(to_roman(399, true), "CCCXCIX");
470        assert_eq!(to_roman(1994, true), "MCMXCIV");
471        assert_eq!(to_roman(3999, true), "MMMCMXCIX");
472    }
473
474    #[test]
475    fn test_to_roman_lowercase() {
476        assert_eq!(to_roman(3, false), "iii");
477        assert_eq!(to_roman(14, false), "xiv");
478    }
479
480    #[test]
481    fn test_to_roman_edge() {
482        assert_eq!(to_roman(0, true), "");
483        assert_eq!(to_roman(-5, true), "");
484    }
485
486    // -- Alpha conversion tests ---------------------------------------------
487
488    #[test]
489    fn test_to_alpha_basic() {
490        assert_eq!(to_alpha(1, true), "A");
491        assert_eq!(to_alpha(2, true), "B");
492        assert_eq!(to_alpha(26, true), "Z");
493    }
494
495    #[test]
496    fn test_to_alpha_multi_letter() {
497        assert_eq!(to_alpha(27, true), "AA");
498        assert_eq!(to_alpha(28, true), "AB");
499        assert_eq!(to_alpha(52, true), "AZ");
500        assert_eq!(to_alpha(53, true), "BA");
501    }
502
503    #[test]
504    fn test_to_alpha_lowercase() {
505        assert_eq!(to_alpha(1, false), "a");
506        assert_eq!(to_alpha(27, false), "aa");
507    }
508
509    #[test]
510    fn test_to_alpha_edge() {
511        assert_eq!(to_alpha(0, true), "");
512        assert_eq!(to_alpha(-1, true), "");
513    }
514
515    // -- Label generation tests ---------------------------------------------
516
517    #[test]
518    fn test_label_for_page_empty_ranges() {
519        // With no ranges, the default 1-based decimal numbering applies.
520        assert_eq!(label_for_page(&[], 0), "1");
521        assert_eq!(label_for_page(&[], 4), "5");
522    }
523
524    #[test]
525    fn test_label_for_page_decimal() {
526        let ranges = vec![PageLabelRange::new(0, PageLabelStyle::Decimal)];
527        assert_eq!(label_for_page(&ranges, 0), "1");
528        assert_eq!(label_for_page(&ranges, 9), "10");
529    }
530
531    #[test]
532    fn test_label_for_page_roman_then_decimal() {
533        let ranges = vec![
534            PageLabelRange::new(0, PageLabelStyle::LowerRoman),
535            PageLabelRange::new(4, PageLabelStyle::Decimal),
536        ];
537
538        // Pages 0..3 => i, ii, iii, iv
539        assert_eq!(label_for_page(&ranges, 0), "i");
540        assert_eq!(label_for_page(&ranges, 1), "ii");
541        assert_eq!(label_for_page(&ranges, 2), "iii");
542        assert_eq!(label_for_page(&ranges, 3), "iv");
543
544        // Pages 4..  => 1, 2, 3, ...
545        assert_eq!(label_for_page(&ranges, 4), "1");
546        assert_eq!(label_for_page(&ranges, 5), "2");
547    }
548
549    #[test]
550    fn test_label_for_page_with_prefix() {
551        let ranges = vec![
552            PageLabelRange::new(0, PageLabelStyle::Decimal).with_prefix("A-"),
553        ];
554        assert_eq!(label_for_page(&ranges, 0), "A-1");
555        assert_eq!(label_for_page(&ranges, 2), "A-3");
556    }
557
558    #[test]
559    fn test_label_for_page_with_logical_start() {
560        let ranges = vec![
561            PageLabelRange::new(0, PageLabelStyle::Decimal).with_logical_start(5),
562        ];
563        assert_eq!(label_for_page(&ranges, 0), "5");
564        assert_eq!(label_for_page(&ranges, 3), "8");
565    }
566
567    #[test]
568    fn test_label_for_page_none_style() {
569        let ranges = vec![
570            PageLabelRange::new(0, PageLabelStyle::None).with_prefix("Cover"),
571        ];
572        assert_eq!(label_for_page(&ranges, 0), "Cover");
573    }
574
575    #[test]
576    fn test_label_for_page_alpha() {
577        let ranges = vec![PageLabelRange::new(0, PageLabelStyle::UpperAlpha)];
578        assert_eq!(label_for_page(&ranges, 0), "A");
579        assert_eq!(label_for_page(&ranges, 25), "Z");
580        assert_eq!(label_for_page(&ranges, 26), "AA");
581    }
582
583    #[test]
584    fn test_label_for_page_single_page() {
585        let ranges = vec![
586            PageLabelRange::new(0, PageLabelStyle::None).with_prefix("Title"),
587        ];
588        assert_eq!(label_for_page(&ranges, 0), "Title");
589    }
590
591    // -- Parse from manually constructed structure --------------------------
592
593    #[test]
594    fn test_parse_page_labels_manual_structure() {
595        // Build a minimal PDF that has a /PageLabels number tree in the catalog.
596        let bytes = make_test_pdf(5);
597        let mut doc = PdfDocument::from_bytes(bytes).unwrap();
598
599        // Manually inject /PageLabels into the catalog.
600        let catalog_ref = doc.catalog_ref().unwrap().clone();
601        let catalog = doc.resolve(&catalog_ref).unwrap();
602        let mut catalog_dict = catalog.as_dict().unwrap().clone();
603
604        // Build the number tree inline:
605        // Page 0: lowercase roman, no prefix
606        // Page 3: decimal, prefix "Ch-"
607        let mut label0 = PdfDict::new();
608        label0.insert(b"S".to_vec(), PdfObject::Name(b"r".to_vec()));
609
610        let mut label3 = PdfDict::new();
611        label3.insert(b"S".to_vec(), PdfObject::Name(b"D".to_vec()));
612        label3.insert(
613            b"P".to_vec(),
614            PdfObject::String(b"Ch-".to_vec()),
615        );
616
617        let mut tree = PdfDict::new();
618        tree.insert(
619            b"Nums".to_vec(),
620            PdfObject::Array(vec![
621                PdfObject::Integer(0),
622                PdfObject::Dict(label0),
623                PdfObject::Integer(3),
624                PdfObject::Dict(label3),
625            ]),
626        );
627
628        catalog_dict.insert(b"PageLabels".to_vec(), PdfObject::Dict(tree));
629
630        // We need to set it back. Since PdfDocument doesn't expose set_object,
631        // we'll test by using DocumentModifier to roundtrip instead.
632        // Instead, test the label generation from the ranges we would get:
633        let ranges = vec![
634            PageLabelRange::new(0, PageLabelStyle::LowerRoman),
635            PageLabelRange::new(3, PageLabelStyle::Decimal).with_prefix("Ch-"),
636        ];
637
638        assert_eq!(label_for_page(&ranges, 0), "i");
639        assert_eq!(label_for_page(&ranges, 1), "ii");
640        assert_eq!(label_for_page(&ranges, 2), "iii");
641        assert_eq!(label_for_page(&ranges, 3), "Ch-1");
642        assert_eq!(label_for_page(&ranges, 4), "Ch-2");
643    }
644
645    // -- Roundtrip: build + parse -------------------------------------------
646
647    #[test]
648    fn test_roundtrip_page_labels() {
649        let bytes = make_test_pdf(6);
650        let mut doc = PdfDocument::from_bytes(bytes).unwrap();
651        let mut modifier = DocumentModifier::from_document(&doc).unwrap();
652
653        let ranges = vec![
654            PageLabelRange::new(0, PageLabelStyle::LowerRoman),
655            PageLabelRange::new(2, PageLabelStyle::Decimal)
656                .with_prefix("P-")
657                .with_logical_start(1),
658            PageLabelRange::new(5, PageLabelStyle::UpperAlpha),
659        ];
660
661        set_page_labels(&mut modifier, &ranges).unwrap();
662
663        // Serialize and re-parse
664        let new_bytes = modifier.build().unwrap();
665        let mut reparsed = PdfDocument::from_bytes(new_bytes).unwrap();
666
667        let parsed_ranges = read_page_labels(&reparsed).unwrap();
668        assert_eq!(parsed_ranges.len(), 3);
669
670        assert_eq!(parsed_ranges[0].start_page, 0);
671        assert_eq!(parsed_ranges[0].style, PageLabelStyle::LowerRoman);
672        assert_eq!(parsed_ranges[0].prefix, "");
673        assert_eq!(parsed_ranges[0].logical_start, 1);
674
675        assert_eq!(parsed_ranges[1].start_page, 2);
676        assert_eq!(parsed_ranges[1].style, PageLabelStyle::Decimal);
677        assert_eq!(parsed_ranges[1].prefix, "P-");
678        assert_eq!(parsed_ranges[1].logical_start, 1);
679
680        assert_eq!(parsed_ranges[2].start_page, 5);
681        assert_eq!(parsed_ranges[2].style, PageLabelStyle::UpperAlpha);
682
683        // Verify generated labels
684        assert_eq!(label_for_page(&parsed_ranges, 0), "i");
685        assert_eq!(label_for_page(&parsed_ranges, 1), "ii");
686        assert_eq!(label_for_page(&parsed_ranges, 2), "P-1");
687        assert_eq!(label_for_page(&parsed_ranges, 4), "P-3");
688        assert_eq!(label_for_page(&parsed_ranges, 5), "A");
689    }
690
691    #[test]
692    fn test_roundtrip_with_logical_start() {
693        let bytes = make_test_pdf(4);
694        let mut doc = PdfDocument::from_bytes(bytes).unwrap();
695        let mut modifier = DocumentModifier::from_document(&doc).unwrap();
696
697        let ranges = vec![
698            PageLabelRange::new(0, PageLabelStyle::Decimal).with_logical_start(10),
699        ];
700
701        set_page_labels(&mut modifier, &ranges).unwrap();
702
703        let new_bytes = modifier.build().unwrap();
704        let mut reparsed = PdfDocument::from_bytes(new_bytes).unwrap();
705
706        let parsed_ranges = read_page_labels(&reparsed).unwrap();
707        assert_eq!(parsed_ranges.len(), 1);
708        assert_eq!(parsed_ranges[0].logical_start, 10);
709
710        assert_eq!(label_for_page(&parsed_ranges, 0), "10");
711        assert_eq!(label_for_page(&parsed_ranges, 3), "13");
712    }
713
714    #[test]
715    fn test_roundtrip_none_style_with_prefix() {
716        let bytes = make_test_pdf(2);
717        let mut doc = PdfDocument::from_bytes(bytes).unwrap();
718        let mut modifier = DocumentModifier::from_document(&doc).unwrap();
719
720        let ranges = vec![
721            PageLabelRange::new(0, PageLabelStyle::None).with_prefix("Cover"),
722            PageLabelRange::new(1, PageLabelStyle::Decimal),
723        ];
724
725        set_page_labels(&mut modifier, &ranges).unwrap();
726
727        let new_bytes = modifier.build().unwrap();
728        let mut reparsed = PdfDocument::from_bytes(new_bytes).unwrap();
729
730        let parsed_ranges = read_page_labels(&reparsed).unwrap();
731        assert_eq!(parsed_ranges.len(), 2);
732        assert_eq!(label_for_page(&parsed_ranges, 0), "Cover");
733        assert_eq!(label_for_page(&parsed_ranges, 1), "1");
734    }
735
736    #[test]
737    fn test_read_page_labels_no_labels() {
738        let bytes = make_test_pdf(1);
739        let mut doc = PdfDocument::from_bytes(bytes).unwrap();
740        let ranges = read_page_labels(&doc).unwrap();
741        assert!(ranges.is_empty());
742    }
743
744    // -- Number tree building -----------------------------------------------
745
746    #[test]
747    fn test_build_nums_array() {
748        let entries = vec![
749            (0i64, PdfObject::Dict(PdfDict::new())),
750            (5, PdfObject::Dict(PdfDict::new())),
751        ];
752        let arr = build_nums_array(&entries);
753        assert_eq!(arr.len(), 4);
754        assert_eq!(arr[0], PdfObject::Integer(0));
755        assert!(arr[1].is_dict());
756        assert_eq!(arr[2], PdfObject::Integer(5));
757        assert!(arr[3].is_dict());
758    }
759
760    // -- Style encoding/decoding -------------------------------------------
761
762    #[test]
763    fn test_style_roundtrip() {
764        let styles = [
765            PageLabelStyle::Decimal,
766            PageLabelStyle::UpperRoman,
767            PageLabelStyle::LowerRoman,
768            PageLabelStyle::UpperAlpha,
769            PageLabelStyle::LowerAlpha,
770        ];
771
772        for style in &styles {
773            let name = style.to_name().unwrap();
774            let decoded = PageLabelStyle::from_name(name).unwrap();
775            assert_eq!(*style, decoded);
776        }
777
778        // None style has no /S name
779        assert!(PageLabelStyle::None.to_name().is_none());
780    }
781}