Skip to main content

oxidize_pdf/page_labels/
page_label.rs

1//! Page label definitions according to ISO 32000-1
2
3use crate::objects::{Dictionary, Object};
4
5/// Page label numbering style
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub enum PageLabelStyle {
8    /// Decimal arabic numerals (1, 2, 3, ...)
9    DecimalArabic,
10    /// Uppercase roman numerals (I, II, III, IV, ...)
11    UppercaseRoman,
12    /// Lowercase roman numerals (i, ii, iii, iv, ...)
13    LowercaseRoman,
14    /// Uppercase letters (A, B, C, ... AA, BB, ...)
15    UppercaseLetters,
16    /// Lowercase letters (a, b, c, ... aa, bb, ...)
17    LowercaseLetters,
18    /// No page numbers (labels consist only of prefix)
19    None,
20}
21
22impl PageLabelStyle {
23    /// Convert to PDF name per ISO 32000-1 §12.4.2 Table 159.
24    ///
25    /// Mapping:
26    /// - `D` — decimal arabic
27    /// - `R` — **uppercase** roman
28    /// - `r` — **lowercase** roman
29    /// - `A` — uppercase letters
30    /// - `a` — lowercase letters
31    ///
32    /// A conforming viewer uses the /S name to decide the case of the
33    /// rendered numeral, so this mapping must match the case implied by
34    /// [`Self::format`] — otherwise the Rust side and the rendered PDF
35    /// disagree on case.
36    pub fn to_pdf_name(&self) -> Option<&'static str> {
37        match self {
38            PageLabelStyle::DecimalArabic => Some("D"),
39            PageLabelStyle::UppercaseRoman => Some("R"),
40            PageLabelStyle::LowercaseRoman => Some("r"),
41            PageLabelStyle::UppercaseLetters => Some("A"),
42            PageLabelStyle::LowercaseLetters => Some("a"),
43            PageLabelStyle::None => None,
44        }
45    }
46
47    /// Format a page number in this style
48    pub fn format(&self, number: u32) -> String {
49        match self {
50            PageLabelStyle::DecimalArabic => number.to_string(),
51            PageLabelStyle::UppercaseRoman => to_roman(number).to_uppercase(),
52            PageLabelStyle::LowercaseRoman => to_roman(number),
53            PageLabelStyle::UppercaseLetters => to_letters(number, true),
54            PageLabelStyle::LowercaseLetters => to_letters(number, false),
55            PageLabelStyle::None => String::new(),
56        }
57    }
58}
59
60/// Page label for a range of pages
61#[derive(Debug, Clone)]
62pub struct PageLabel {
63    /// Numbering style
64    pub style: PageLabelStyle,
65    /// Label prefix (e.g., "Chapter " for "Chapter 1")
66    pub prefix: Option<String>,
67    /// First value of the numeric portion (default 1)
68    pub start: u32,
69}
70
71impl PageLabel {
72    /// Create a new page label
73    pub fn new(style: PageLabelStyle) -> Self {
74        Self {
75            style,
76            prefix: None,
77            start: 1,
78        }
79    }
80
81    /// Create decimal arabic label (1, 2, 3, ...)
82    pub fn decimal() -> Self {
83        Self::new(PageLabelStyle::DecimalArabic)
84    }
85
86    /// Create uppercase roman label (I, II, III, ...)
87    pub fn roman_uppercase() -> Self {
88        Self::new(PageLabelStyle::UppercaseRoman)
89    }
90
91    /// Create lowercase roman label (i, ii, iii, ...)
92    pub fn roman_lowercase() -> Self {
93        Self::new(PageLabelStyle::LowercaseRoman)
94    }
95
96    /// Create uppercase letter label (A, B, C, ...)
97    pub fn letters_uppercase() -> Self {
98        Self::new(PageLabelStyle::UppercaseLetters)
99    }
100
101    /// Create lowercase letter label (a, b, c, ...)
102    pub fn letters_lowercase() -> Self {
103        Self::new(PageLabelStyle::LowercaseLetters)
104    }
105
106    /// Create label with no numbers (prefix only)
107    pub fn prefix_only(prefix: impl Into<String>) -> Self {
108        Self {
109            style: PageLabelStyle::None,
110            prefix: Some(prefix.into()),
111            start: 1,
112        }
113    }
114
115    /// Set label prefix
116    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
117        self.prefix = Some(prefix.into());
118        self
119    }
120
121    /// Set starting number
122    pub fn starting_at(mut self, start: u32) -> Self {
123        self.start = start;
124        self
125    }
126
127    /// Format a page label for a given offset
128    pub fn format_label(&self, offset: u32) -> String {
129        let mut label = String::new();
130
131        if let Some(prefix) = &self.prefix {
132            label.push_str(prefix);
133        }
134
135        if self.style != PageLabelStyle::None {
136            let number = self.start + offset;
137            label.push_str(&self.style.format(number));
138        }
139
140        label
141    }
142
143    /// Convert to PDF dictionary
144    ///
145    /// Encodes the label per ISO 32000-1 §12.4.2 Table 159:
146    /// - `/Type` — optional name, when present shall be `PageLabel`.
147    /// - `/S`    — numbering style (`D`/`R`/`r`/`A`/`a`); absent when the
148    ///            label has no numeric portion (style `None`).
149    /// - `/P`    — optional prefix string.
150    /// - `/St`   — optional integer starting value (default 1).
151    pub fn to_dict(&self) -> Dictionary {
152        let mut dict = Dictionary::new();
153
154        dict.set("Type", Object::Name("PageLabel".to_string()));
155
156        if let Some(style_name) = self.style.to_pdf_name() {
157            dict.set("S", Object::Name(style_name.to_string()));
158        }
159
160        if let Some(prefix) = &self.prefix {
161            dict.set("P", Object::String(prefix.clone()));
162        }
163
164        if self.start != 1 {
165            dict.set("St", Object::Integer(self.start as i64));
166        }
167
168        dict
169    }
170}
171
172/// Page label range - associates a page label with a starting page
173#[derive(Debug, Clone)]
174pub struct PageLabelRange {
175    /// Starting page index (0-based)
176    pub start_page: u32,
177    /// Page label for this range
178    pub label: PageLabel,
179}
180
181impl PageLabelRange {
182    /// Create a new page label range
183    pub fn new(start_page: u32, label: PageLabel) -> Self {
184        Self { start_page, label }
185    }
186}
187
188/// Convert number to roman numerals
189fn to_roman(mut num: u32) -> String {
190    if num == 0 {
191        return String::new();
192    }
193
194    let values = [
195        (1000, "m"),
196        (900, "cm"),
197        (500, "d"),
198        (400, "cd"),
199        (100, "c"),
200        (90, "xc"),
201        (50, "l"),
202        (40, "xl"),
203        (10, "x"),
204        (9, "ix"),
205        (5, "v"),
206        (4, "iv"),
207        (1, "i"),
208    ];
209
210    let mut result = String::new();
211
212    for (value, numeral) in values.iter() {
213        while num >= *value {
214            result.push_str(numeral);
215            num -= value;
216        }
217    }
218
219    result
220}
221
222/// Convert number to letters (A, B, ... Z, AA, AB, ...)
223fn to_letters(num: u32, uppercase: bool) -> String {
224    if num == 0 {
225        return String::new();
226    }
227
228    let mut result = String::new();
229    let mut n = num;
230
231    while n > 0 {
232        let remainder = ((n - 1) % 26) as u8;
233        let letter = if uppercase {
234            (b'A' + remainder) as char
235        } else {
236            (b'a' + remainder) as char
237        };
238        result.insert(0, letter);
239        n = (n - 1) / 26;
240    }
241
242    result
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_page_label_styles() {
251        assert_eq!(PageLabelStyle::DecimalArabic.format(1), "1");
252        assert_eq!(PageLabelStyle::DecimalArabic.format(42), "42");
253
254        assert_eq!(PageLabelStyle::UppercaseRoman.format(1), "I");
255        assert_eq!(PageLabelStyle::UppercaseRoman.format(4), "IV");
256        assert_eq!(PageLabelStyle::UppercaseRoman.format(9), "IX");
257        assert_eq!(PageLabelStyle::UppercaseRoman.format(58), "LVIII");
258
259        assert_eq!(PageLabelStyle::LowercaseRoman.format(1), "i");
260        assert_eq!(PageLabelStyle::LowercaseRoman.format(4), "iv");
261
262        assert_eq!(PageLabelStyle::UppercaseLetters.format(1), "A");
263        assert_eq!(PageLabelStyle::UppercaseLetters.format(26), "Z");
264        assert_eq!(PageLabelStyle::UppercaseLetters.format(27), "AA");
265        assert_eq!(PageLabelStyle::UppercaseLetters.format(52), "AZ");
266
267        assert_eq!(PageLabelStyle::LowercaseLetters.format(1), "a");
268        assert_eq!(PageLabelStyle::LowercaseLetters.format(26), "z");
269        assert_eq!(PageLabelStyle::LowercaseLetters.format(27), "aa");
270
271        assert_eq!(PageLabelStyle::None.format(1), "");
272        assert_eq!(PageLabelStyle::None.format(100), "");
273    }
274
275    #[test]
276    fn test_page_label_creation() {
277        let label = PageLabel::decimal();
278        assert_eq!(label.style, PageLabelStyle::DecimalArabic);
279        assert_eq!(label.start, 1);
280        assert!(label.prefix.is_none());
281
282        let label = PageLabel::roman_lowercase()
283            .with_prefix("Page ")
284            .starting_at(5);
285        assert_eq!(label.style, PageLabelStyle::LowercaseRoman);
286        assert_eq!(label.start, 5);
287        assert_eq!(label.prefix, Some("Page ".to_string()));
288    }
289
290    #[test]
291    fn test_format_label() {
292        let label = PageLabel::decimal().with_prefix("Chapter ");
293        assert_eq!(label.format_label(0), "Chapter 1");
294        assert_eq!(label.format_label(1), "Chapter 2");
295
296        let label = PageLabel::roman_uppercase().starting_at(1);
297        assert_eq!(label.format_label(0), "I");
298        assert_eq!(label.format_label(3), "IV");
299
300        let label = PageLabel::prefix_only("Appendix");
301        assert_eq!(label.format_label(0), "Appendix");
302        assert_eq!(label.format_label(10), "Appendix");
303    }
304
305    #[test]
306    fn test_to_dict() {
307        // Per ISO 32000-1 §12.4.2 Table 159, the numbering style is /S (not
308        // /Type). /Type is optional and must be PageLabel when present.
309        let label = PageLabel::decimal();
310        let dict = label.to_dict();
311        assert_eq!(
312            dict.get("Type"),
313            Some(&Object::Name("PageLabel".to_string()))
314        );
315        assert_eq!(dict.get("S"), Some(&Object::Name("D".to_string())));
316        assert!(dict.get("P").is_none());
317        assert!(dict.get("St").is_none());
318
319        let label = PageLabel::roman_lowercase()
320            .with_prefix("p. ")
321            .starting_at(5);
322        let dict = label.to_dict();
323        assert_eq!(
324            dict.get("Type"),
325            Some(&Object::Name("PageLabel".to_string()))
326        );
327        // ISO 32000-1 §12.4.2 Table 159: lowercase roman numerals are
328        // encoded as /S /r (lowercase name). /R is reserved for the
329        // uppercase form.
330        assert_eq!(dict.get("S"), Some(&Object::Name("r".to_string())));
331        assert_eq!(dict.get("P"), Some(&Object::String("p. ".to_string())));
332        assert_eq!(dict.get("St"), Some(&Object::Integer(5)));
333    }
334
335    #[test]
336    fn test_roman_conversion() {
337        assert_eq!(to_roman(1), "i");
338        assert_eq!(to_roman(3), "iii");
339        assert_eq!(to_roman(4), "iv");
340        assert_eq!(to_roman(5), "v");
341        assert_eq!(to_roman(9), "ix");
342        assert_eq!(to_roman(10), "x");
343        assert_eq!(to_roman(40), "xl");
344        assert_eq!(to_roman(50), "l");
345        assert_eq!(to_roman(90), "xc");
346        assert_eq!(to_roman(100), "c");
347        assert_eq!(to_roman(400), "cd");
348        assert_eq!(to_roman(500), "d");
349        assert_eq!(to_roman(900), "cm");
350        assert_eq!(to_roman(1000), "m");
351        assert_eq!(to_roman(1984), "mcmlxxxiv");
352        assert_eq!(to_roman(3999), "mmmcmxcix");
353    }
354
355    #[test]
356    fn test_letter_conversion() {
357        assert_eq!(to_letters(1, true), "A");
358        assert_eq!(to_letters(26, true), "Z");
359        assert_eq!(to_letters(27, true), "AA");
360        assert_eq!(to_letters(52, true), "AZ");
361        assert_eq!(to_letters(53, true), "BA");
362        assert_eq!(to_letters(702, true), "ZZ");
363        assert_eq!(to_letters(703, true), "AAA");
364
365        assert_eq!(to_letters(1, false), "a");
366        assert_eq!(to_letters(26, false), "z");
367        assert_eq!(to_letters(27, false), "aa");
368    }
369
370    #[test]
371    fn test_page_label_style_to_pdf_name() {
372        // ISO 32000-1 §12.4.2 Table 159: /R = uppercase, /r = lowercase.
373        assert_eq!(PageLabelStyle::DecimalArabic.to_pdf_name(), Some("D"));
374        assert_eq!(PageLabelStyle::UppercaseRoman.to_pdf_name(), Some("R"));
375        assert_eq!(PageLabelStyle::LowercaseRoman.to_pdf_name(), Some("r"));
376        assert_eq!(PageLabelStyle::UppercaseLetters.to_pdf_name(), Some("A"));
377        assert_eq!(PageLabelStyle::LowercaseLetters.to_pdf_name(), Some("a"));
378        assert_eq!(PageLabelStyle::None.to_pdf_name(), None);
379    }
380
381    #[test]
382    fn test_roman_format_and_pdf_name_agree_on_case() {
383        // ISO 32000-1 §12.4.2 Table 159 ties the /S name to the render
384        // case: /R = uppercase, /r = lowercase. The crate's own format()
385        // method decides which case the Rust side renders. These two
386        // views MUST agree: if format(1) emits "I" then /S must be /R,
387        // otherwise a spec-conforming viewer will render the opposite
388        // case of what format() claims.
389        //
390        // Regression guard for the v2.5.5 bug where UppercaseRoman
391        // mapped to /S /r (so `PageLabel::roman_uppercase()` produced
392        // PDFs that Acrobat rendered as "i, ii, iii").
393        let cases: &[(PageLabelStyle, &str, &str)] = &[
394            (PageLabelStyle::UppercaseRoman, "I", "R"),
395            (PageLabelStyle::LowercaseRoman, "i", "r"),
396            (PageLabelStyle::UppercaseLetters, "A", "A"),
397            (PageLabelStyle::LowercaseLetters, "a", "a"),
398        ];
399        for (style, expected_format_1, expected_pdf_name) in cases {
400            let formatted = style.format(1);
401            let pdf_name = style.to_pdf_name().expect("style has /S name");
402            assert_eq!(&formatted, expected_format_1, "format(1) for {:?}", style);
403            assert_eq!(pdf_name, *expected_pdf_name, "/S name for {:?}", style);
404            let format_upper = formatted == formatted.to_uppercase();
405            let pdf_upper = pdf_name == pdf_name.to_uppercase();
406            assert_eq!(
407                format_upper, pdf_upper,
408                "style {:?}: format(1)={:?} (upper={}) disagrees with /S /{} (upper={})",
409                style, formatted, format_upper, pdf_name, pdf_upper
410            );
411        }
412    }
413
414    #[test]
415    fn test_page_label_with_all_styles() {
416        // Test all constructor methods
417        let decimal = PageLabel::decimal();
418        assert_eq!(decimal.style, PageLabelStyle::DecimalArabic);
419
420        let roman_upper = PageLabel::roman_uppercase();
421        assert_eq!(roman_upper.style, PageLabelStyle::UppercaseRoman);
422
423        let roman_lower = PageLabel::roman_lowercase();
424        assert_eq!(roman_lower.style, PageLabelStyle::LowercaseRoman);
425
426        let letters_upper = PageLabel::letters_uppercase();
427        assert_eq!(letters_upper.style, PageLabelStyle::UppercaseLetters);
428
429        let letters_lower = PageLabel::letters_lowercase();
430        assert_eq!(letters_lower.style, PageLabelStyle::LowercaseLetters);
431
432        let prefix_only = PageLabel::prefix_only("Prefix");
433        assert_eq!(prefix_only.style, PageLabelStyle::None);
434        assert_eq!(prefix_only.prefix, Some("Prefix".to_string()));
435    }
436
437    #[test]
438    fn test_page_label_chaining() {
439        let label = PageLabel::decimal().with_prefix("Page ").starting_at(10);
440
441        assert_eq!(label.style, PageLabelStyle::DecimalArabic);
442        assert_eq!(label.prefix, Some("Page ".to_string()));
443        assert_eq!(label.start, 10);
444
445        // Test formatting with chained settings
446        assert_eq!(label.format_label(0), "Page 10");
447        assert_eq!(label.format_label(5), "Page 15");
448    }
449
450    #[test]
451    fn test_format_label_edge_cases() {
452        // Test with empty prefix
453        let label = PageLabel::decimal().with_prefix("");
454        assert_eq!(label.format_label(0), "1");
455
456        // Test with long prefix
457        let long_prefix = "This is a very long prefix that might appear in some documents: ";
458        let label = PageLabel::roman_uppercase().with_prefix(long_prefix);
459        assert_eq!(label.format_label(0), format!("{}I", long_prefix));
460
461        // Test with high starting number
462        let label = PageLabel::decimal().starting_at(9999);
463        assert_eq!(label.format_label(0), "9999");
464        assert_eq!(label.format_label(1), "10000");
465    }
466
467    #[test]
468    fn test_roman_edge_cases() {
469        // Test edge cases for roman numerals
470        assert_eq!(to_roman(49), "xlix");
471        assert_eq!(to_roman(99), "xcix");
472        assert_eq!(to_roman(499), "cdxcix");
473        assert_eq!(to_roman(999), "cmxcix");
474        assert_eq!(to_roman(1444), "mcdxliv");
475        assert_eq!(to_roman(1994), "mcmxciv");
476        assert_eq!(to_roman(2023), "mmxxiii");
477
478        // Test with style formatting
479        assert_eq!(PageLabelStyle::UppercaseRoman.format(49), "XLIX");
480        assert_eq!(PageLabelStyle::LowercaseRoman.format(49), "xlix");
481    }
482
483    #[test]
484    fn test_letter_edge_cases() {
485        // Test more letter conversion cases
486        assert_eq!(to_letters(78, true), "BZ"); // 26*2 + 26
487        assert_eq!(to_letters(104, true), "CZ"); // 26*3 + 26
488        assert_eq!(to_letters(701, true), "ZY"); // Last before ZZ
489        assert_eq!(to_letters(728, true), "AAZ"); // 26*26 + 26 + 26
490        assert_eq!(to_letters(1378, true), "AZZ"); // Complex case
491
492        // Lowercase versions
493        assert_eq!(to_letters(78, false), "bz");
494        assert_eq!(to_letters(104, false), "cz");
495        assert_eq!(to_letters(701, false), "zy");
496    }
497
498    #[test]
499    fn test_prefix_only_variations() {
500        // Test prefix-only labels with different content
501        let label1 = PageLabel::prefix_only("Cover");
502        assert_eq!(label1.format_label(0), "Cover");
503        assert_eq!(label1.format_label(100), "Cover"); // Should always be same
504
505        let label2 = PageLabel::prefix_only("Appendix A");
506        assert_eq!(label2.format_label(0), "Appendix A");
507
508        // Unicode prefix
509        let label3 = PageLabel::prefix_only("附录");
510        assert_eq!(label3.format_label(0), "附录");
511
512        // Special characters
513        let label4 = PageLabel::prefix_only("§1");
514        assert_eq!(label4.format_label(0), "§1");
515    }
516
517    #[test]
518    fn test_to_dict_comprehensive() {
519        // Test dictionary generation with all combinations
520        let label1 = PageLabel::new(PageLabelStyle::DecimalArabic);
521        let dict1 = label1.to_dict();
522        assert_eq!(
523            dict1.get("Type"),
524            Some(&Object::Name("PageLabel".to_string()))
525        );
526        assert_eq!(dict1.get("S"), Some(&Object::Name("D".to_string())));
527        assert!(dict1.get("P").is_none()); // No prefix
528        assert!(dict1.get("St").is_none()); // Default start (1)
529
530        let label2 = PageLabel::new(PageLabelStyle::UppercaseLetters)
531            .with_prefix("Section ")
532            .starting_at(10);
533        let dict2 = label2.to_dict();
534        assert_eq!(
535            dict2.get("Type"),
536            Some(&Object::Name("PageLabel".to_string()))
537        );
538        assert_eq!(dict2.get("S"), Some(&Object::Name("A".to_string())));
539        assert_eq!(
540            dict2.get("P"),
541            Some(&Object::String("Section ".to_string()))
542        );
543        assert_eq!(dict2.get("St"), Some(&Object::Integer(10)));
544
545        // Prefix-only dicts still carry /Type PageLabel (§12.4.2 recommends
546        // it), but have no /S entry because the numbering style is None.
547        let label3 = PageLabel::prefix_only("Index");
548        let dict3 = label3.to_dict();
549        assert_eq!(
550            dict3.get("Type"),
551            Some(&Object::Name("PageLabel".to_string()))
552        );
553        assert!(dict3.get("S").is_none());
554        assert_eq!(dict3.get("P"), Some(&Object::String("Index".to_string())));
555    }
556
557    #[test]
558    fn test_sequential_page_labels() {
559        // Simulate a document with different label ranges
560        let front_matter = PageLabel::roman_lowercase().with_prefix("");
561        let main_content = PageLabel::decimal().starting_at(1);
562        let appendix = PageLabel::letters_uppercase().with_prefix("Appendix ");
563
564        // Front matter pages (i, ii, iii, iv)
565        assert_eq!(front_matter.format_label(0), "i");
566        assert_eq!(front_matter.format_label(1), "ii");
567        assert_eq!(front_matter.format_label(2), "iii");
568        assert_eq!(front_matter.format_label(3), "iv");
569
570        // Main content (1, 2, 3...)
571        assert_eq!(main_content.format_label(0), "1");
572        assert_eq!(main_content.format_label(99), "100");
573
574        // Appendix (Appendix A, Appendix B...)
575        assert_eq!(appendix.format_label(0), "Appendix A");
576        assert_eq!(appendix.format_label(1), "Appendix B");
577        assert_eq!(appendix.format_label(25), "Appendix Z");
578    }
579
580    #[test]
581    fn test_large_number_formatting() {
582        // Test with very large numbers
583        let label = PageLabel::decimal().starting_at(999999);
584        assert_eq!(label.format_label(0), "999999");
585        assert_eq!(label.format_label(1), "1000000");
586
587        // Roman numerals with large numbers (typically capped at 3999)
588        assert_eq!(to_roman(4000), "mmmm"); // Graceful handling
589        assert_eq!(to_roman(5000), "mmmmm");
590
591        // Letters with large numbers
592        assert_eq!(to_letters(18278, true).len() > 0, true); // Should produce something
593    }
594
595    #[test]
596    fn test_special_prefix_combinations() {
597        // Test various prefix and style combinations
598        let combinations = vec![
599            (PageLabel::decimal().with_prefix("№"), 0, "№1"),
600            (
601                PageLabel::roman_uppercase().with_prefix("Chapter "),
602                0,
603                "Chapter I",
604            ),
605            (
606                PageLabel::letters_lowercase()
607                    .with_prefix("(")
608                    .with_prefix(")"),
609                0,
610                ")a",
611            ),
612            (
613                PageLabel::decimal().with_prefix("Page ").starting_at(100),
614                0,
615                "Page 100",
616            ),
617        ];
618
619        for (label, offset, expected) in combinations {
620            assert_eq!(label.format_label(offset), expected);
621        }
622    }
623
624    #[test]
625    fn test_clone_and_equality() {
626        let label1 = PageLabel::decimal().with_prefix("Page ");
627        let label2 = label1.clone();
628
629        assert_eq!(label1.style, label2.style);
630        assert_eq!(label1.prefix, label2.prefix);
631        assert_eq!(label1.start, label2.start);
632    }
633}