use std::collections::HashSet;
use zpdf_core::{ObjectId, PdfDict, PdfObject};
use zpdf_parser::PdfFile;
use crate::obj_util::{catalog_dict, resolve_array, resolve_dict, resolve_name, text};
const MAX_NUMBER_TREE_DEPTH: usize = 64;
const MAX_PAGE_LABEL_ENTRIES: usize = 200_000;
const MAX_PREFIX_CHARS: usize = 1024;
const MAX_FANCY_VALUE: u64 = 100_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageLabelStyle {
Decimal,
RomanUpper,
RomanLower,
LettersUpper,
LettersLower,
None,
}
impl PageLabelStyle {
fn from_name(name: Option<&str>) -> Self {
match name {
Some("D") => Self::Decimal,
Some("R") => Self::RomanUpper,
Some("r") => Self::RomanLower,
Some("A") => Self::LettersUpper,
Some("a") => Self::LettersLower,
_ => Self::None,
}
}
}
#[derive(Debug, Clone)]
struct LabelRange {
start: usize,
style: PageLabelStyle,
prefix: String,
first: u64,
}
#[derive(Debug, Clone)]
pub struct PageLabels {
ranges: Vec<LabelRange>,
}
impl PageLabels {
pub fn label(&self, page_index: usize) -> Option<String> {
let idx = match self.ranges.binary_search_by(|r| r.start.cmp(&page_index)) {
Ok(i) => i,
Err(0) => return None,
Err(i) => i - 1,
};
let range = &self.ranges[idx];
let offset = (page_index - range.start) as u64;
let value = range.first.saturating_add(offset);
let numeric = format_numeric(range.style, value);
Some(format!("{}{}", range.prefix, numeric))
}
}
pub fn parse_page_labels(file: &PdfFile) -> Option<PageLabels> {
let root = catalog_dict(file)?;
let tree = resolve_dict(file, root.get("PageLabels"))?;
let mut entries: Vec<(i64, PdfObject)> = Vec::new();
let mut visited = HashSet::new();
if let Some(PdfObject::Ref(id)) = root.get("PageLabels") {
visited.insert(*id);
}
let mut budget = MAX_PAGE_LABEL_ENTRIES;
collect_number_tree(file, &tree, 0, &mut visited, &mut budget, &mut entries);
let mut ranges: Vec<LabelRange> = Vec::new();
for (key, value) in entries {
let Ok(start) = usize::try_from(key) else {
continue;
};
let Some(dict) = resolve_dict(file, Some(&value)) else {
continue;
};
ranges.push(LabelRange {
start,
style: PageLabelStyle::from_name(resolve_name(file, dict.get("S")).as_deref()),
prefix: read_prefix(file, &dict),
first: read_start_value(file, &dict),
});
}
if ranges.is_empty() {
return None;
}
ranges.sort_by_key(|r| r.start);
ranges.dedup_by_key(|r| r.start);
Some(PageLabels { ranges })
}
fn read_prefix(file: &PdfFile, dict: &PdfDict) -> String {
match text(file, dict, "P") {
Some(p) if p.chars().count() > MAX_PREFIX_CHARS => {
p.chars().take(MAX_PREFIX_CHARS).collect()
}
Some(p) => p,
None => String::new(),
}
}
fn read_start_value(file: &PdfFile, dict: &PdfDict) -> u64 {
let raw = match dict.get("St") {
Some(PdfObject::Ref(r)) => file.resolve(*r).ok(),
Some(other) => Some(other.clone()),
None => None,
};
let n = match raw {
Some(PdfObject::Integer(n)) => n,
Some(PdfObject::Real(f)) if f.is_finite() && f.fract() == 0.0 => f as i64,
_ => return 1,
};
if n >= 1 {
n as u64
} else {
1
}
}
fn format_numeric(style: PageLabelStyle, value: u64) -> String {
use PageLabelStyle::*;
match style {
None => String::new(),
Decimal => value.to_string(),
_ if value == 0 => String::new(),
_ if value > MAX_FANCY_VALUE => value.to_string(),
RomanUpper => to_roman(value, true),
RomanLower => to_roman(value, false),
LettersUpper => to_letters(value, true),
LettersLower => to_letters(value, false),
}
}
fn to_roman(value: u64, upper: bool) -> String {
const TABLE: [(&str, u64); 13] = [
("M", 1000),
("CM", 900),
("D", 500),
("CD", 400),
("C", 100),
("XC", 90),
("L", 50),
("XL", 40),
("X", 10),
("IX", 9),
("V", 5),
("IV", 4),
("I", 1),
];
let mut n = value;
let mut s = String::new();
for (sym, v) in TABLE {
while n >= v {
s.push_str(sym);
n -= v;
}
}
if upper {
s
} else {
s.to_ascii_lowercase()
}
}
fn to_letters(value: u64, upper: bool) -> String {
let base = if upper { b'A' } else { b'a' };
let letter = ((value - 1) % 26) as u8;
let count = ((value - 1) / 26) + 1;
let ch = (base + letter) as char;
std::iter::repeat_n(ch, count as usize).collect()
}
fn collect_number_tree(
file: &PdfFile,
node: &PdfDict,
depth: usize,
visited: &mut HashSet<ObjectId>,
budget: &mut usize,
out: &mut Vec<(i64, PdfObject)>,
) {
if depth > MAX_NUMBER_TREE_DEPTH || *budget == 0 {
return;
}
*budget -= 1;
if let Some(nums) = resolve_array(file, node.get("Nums")) {
let mut i = 0;
while i + 1 < nums.len() {
if *budget == 0 {
return;
}
*budget -= 1;
if let PdfObject::Integer(k) = nums[i] {
out.push((k, nums[i + 1].clone()));
}
i += 2;
}
}
if let Some(kids) = resolve_array(file, node.get("Kids")) {
for kid in &kids {
if *budget == 0 {
return;
}
let kid_dict = match kid {
PdfObject::Ref(r) => {
if !visited.insert(*r) {
continue;
}
resolve_dict(file, Some(kid))
}
PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
_ => None,
};
let Some(d) = kid_dict else { continue };
collect_number_tree(file, &d, depth + 1, visited, budget, out);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::build_pdf;
use crate::PdfDocument;
const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
fn labels(catalog: &str) -> Option<PageLabels> {
let doc = PdfDocument::open(build_pdf(&[catalog, PAGES, PAGE])).expect("open");
doc.page_labels()
}
#[test]
fn no_page_labels_is_none() {
assert!(labels("<< /Type /Catalog /Pages 2 0 R >>").is_none());
}
#[test]
fn roman_front_matter_then_decimal_body() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [0 << /S /r >> 4 << /S /D >>] >> >>",
)
.expect("labels");
assert_eq!(pl.label(0).as_deref(), Some("i"));
assert_eq!(pl.label(1).as_deref(), Some("ii"));
assert_eq!(pl.label(3).as_deref(), Some("iv"));
assert_eq!(pl.label(4).as_deref(), Some("1"));
assert_eq!(pl.label(5).as_deref(), Some("2"));
}
#[test]
fn start_offset_and_prefix() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [0 << /S /D /P (A-) >> 3 << /S /D /St 5 >>] >> >>",
)
.expect("labels");
assert_eq!(pl.label(0).as_deref(), Some("A-1"));
assert_eq!(pl.label(2).as_deref(), Some("A-3"));
assert_eq!(pl.label(3).as_deref(), Some("5"));
assert_eq!(pl.label(4).as_deref(), Some("6"));
}
#[test]
fn uppercase_roman_and_letters() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [0 << /S /R >> 3 << /S /A >>] >> >>",
)
.expect("labels");
assert_eq!(pl.label(0).as_deref(), Some("I"));
assert_eq!(pl.label(2).as_deref(), Some("III"));
assert_eq!(pl.label(3).as_deref(), Some("A")); assert_eq!(pl.label(4).as_deref(), Some("B"));
}
#[test]
fn letters_wrap_past_z() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [0 << /S /a /St 26 >>] >> >>",
)
.expect("labels");
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")); }
#[test]
fn prefix_only_when_no_style() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [0 << /P (Cover) >>] >> >>",
)
.expect("labels");
assert_eq!(pl.label(0).as_deref(), Some("Cover"));
assert_eq!(pl.label(1).as_deref(), Some("Cover")); }
#[test]
fn pages_before_first_range_are_unlabeled() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [2 << /S /D >>] >> >>",
)
.expect("labels");
assert_eq!(pl.label(0), None);
assert_eq!(pl.label(1), None);
assert_eq!(pl.label(2).as_deref(), Some("1"));
}
#[test]
fn number_tree_with_kids_interior_node() {
let doc = PdfDocument::open(build_pdf(&[
"<< /Type /Catalog /Pages 2 0 R /PageLabels << /Kids [4 0 R] >> >>",
PAGES,
PAGE,
"<< /Limits [0 0] /Nums [0 << /S /D /P (p) >>] >>",
]))
.expect("open");
let pl = doc.page_labels().expect("labels via kids");
assert_eq!(pl.label(0).as_deref(), Some("p1"));
}
#[test]
fn cyclic_kids_terminate() {
let doc = PdfDocument::open(build_pdf(&[
"<< /Type /Catalog /Pages 2 0 R /PageLabels 4 0 R >>",
PAGES,
PAGE,
"<< /Kids [4 0 R] /Nums [0 << /S /D >>] >>",
]))
.expect("open");
let pl = doc.page_labels().expect("labels");
assert_eq!(pl.label(0).as_deref(), Some("1"));
}
#[test]
fn huge_start_value_falls_back_to_decimal() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [0 << /S /R /St 2000000000 >>] >> >>",
)
.expect("labels");
let l = pl.label(0).expect("label");
assert_eq!(l, "2000000000");
assert!(l.len() < 32, "must not expand into a huge roman string");
}
#[test]
fn negative_key_is_skipped() {
let pl = labels(
"<< /Type /Catalog /Pages 2 0 R /PageLabels \
<< /Nums [-5 << /S /R >> 0 << /S /D >>] >> >>",
)
.expect("labels");
assert_eq!(pl.label(0).as_deref(), Some("1"));
}
#[test]
fn roman_numeral_spot_values() {
assert_eq!(to_roman(4, true), "IV");
assert_eq!(to_roman(9, true), "IX");
assert_eq!(to_roman(40, false), "xl");
assert_eq!(to_roman(1990, true), "MCMXC");
assert_eq!(to_roman(2024, false), "mmxxiv");
}
#[test]
fn letter_sequence_spot_values() {
assert_eq!(to_letters(1, true), "A");
assert_eq!(to_letters(26, true), "Z");
assert_eq!(to_letters(27, true), "AA");
assert_eq!(to_letters(52, false), "zz");
assert_eq!(to_letters(53, true), "AAA");
}
}