use pdfrum_common::{DiagKind, Diagnostics, Limits, PageIndex, Severity};
use pdfrum_object::{Dict, Object, Resolve};
use crate::names;
use crate::nav::number_tree;
#[must_use]
pub fn page_label<R: Resolve>(
catalog: &Dict,
page: impl Into<PageIndex>,
page_count: u32,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<String> {
let page = page.into();
if page.get() >= page_count {
return None;
}
let page_index = i64::from(page.get());
let labels = catalog.dict(names::PAGE_LABELS, r)?;
let (key, value) = number_tree::lower_bound(&labels, page_index, r, limits, diags)?;
let Some(rule) = value.as_ref().and_then(Object::as_dict) else {
return Some((page_index + 1).to_string());
};
let mut label = if rule.contains_key(names::P) {
rule.text(names::P, r).unwrap_or_default()
} else {
String::new()
};
let style = rule.byte_string(names::S, r).unwrap_or_default();
let number = page_index - key + rule.int(names::ST, r).unwrap_or(1);
label.push_str(&num_portion(number, &style, diags));
Some(label)
}
fn num_portion(number: i64, style: &[u8], diags: &mut Diagnostics) -> String {
match style {
b"D" => decimal(number),
b"R" => roman(number).to_uppercase(),
b"r" => roman(number),
b"A" => letters(number).to_uppercase(),
b"a" => letters(number),
_ => {
if !style.is_empty() {
diags.record(Severity::Recovered, DiagKind::PageLabelStyleUnknown, None);
}
String::new()
}
}
}
fn decimal(number: i64) -> String {
pdfrum_object::fmt_int(number)
}
fn roman(number: i64) -> String {
const TABLE: [(i64, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut remaining = number % 1_000_000;
let mut out = String::new();
for (value, glyph) in TABLE {
if remaining <= 0 {
break;
}
while remaining >= value {
out.push_str(glyph);
remaining -= value;
}
}
out
}
fn letters(number: i64) -> String {
if number <= 0 {
return String::new();
}
let index = number - 1;
let count = (index / 26 + 1) % 1000;
if count <= 0 {
return String::new();
}
let letter = char::from(b'a' + u8::try_from(index % 26).unwrap_or(0));
std::iter::repeat_n(letter, usize::try_from(count).unwrap_or(0)).collect()
}
#[cfg(test)]
mod tests {
use super::{letters, page_label, roman};
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn catalog(entries: &[(i64, Dict)]) -> Dict {
let mut nums = Array::new();
for (start, rule) in entries {
nums.push(Object::Int(*start));
nums.push(Object::Dict(rule.clone()));
}
dict(&[(
"PageLabels",
Object::Dict(dict(&[("Nums", Object::Array(nums))])),
)])
}
fn label(catalog: &Dict, index: u32, count: u32) -> Option<String> {
let (l, mut d) = (Limits::default(), Diagnostics::default());
page_label(catalog, index, count, &NoResolve, &l, &mut d)
}
#[test]
fn roman_numerals_cover_the_greedy_table_and_its_cliffs() {
assert_eq!(roman(0), "");
assert_eq!(roman(1), "i");
assert_eq!(roman(4), "iv");
assert_eq!(roman(38), "xxxviii");
assert_eq!(roman(1900), "mcm");
assert_eq!(roman(-5), "");
assert_eq!(roman(1_000_000), "");
assert_eq!(roman(1_000_001), "i");
}
#[test]
fn letter_labels_repeat_and_then_fall_off_a_cliff() {
assert_eq!(letters(0), "");
assert_eq!(letters(1), "a");
assert_eq!(letters(26), "z");
assert_eq!(letters(27), "aa");
assert_eq!(letters(52), "zz");
assert_eq!(letters(53), "aaa");
assert_eq!(letters(-1), "");
assert_eq!(letters(26_000), "");
}
#[test]
fn an_index_outside_the_document_has_no_label_while_one_inside_falls_back() {
let empty = dict(&[("PageLabels", Object::Dict(Dict::new()))]);
assert_eq!(label(&empty, 7, 7), None);
assert_eq!(label(&empty, 3, 7), None);
let no_rule = catalog(&[(0, Dict::new())]);
assert_eq!(label(&no_rule, 3, 7), Some(String::new()));
}
#[test]
fn a_run_continues_until_the_next_entry() {
let cat = catalog(&[
(0, dict(&[("S", Object::Name(Name::from("r")))])),
(2, dict(&[("S", Object::Name(Name::from("D")))])),
]);
assert_eq!(label(&cat, 0, 7).as_deref(), Some("i"));
assert_eq!(label(&cat, 1, 7).as_deref(), Some("ii"));
assert_eq!(label(&cat, 2, 7).as_deref(), Some("1"));
assert_eq!(label(&cat, 3, 7).as_deref(), Some("2"));
}
#[test]
fn a_prefix_and_a_start_number_combine() {
let cat = catalog(&[(
0,
dict(&[
("P", Object::Str(PdfString::literal(b"zz"))),
("S", Object::Name(Name::from("A"))),
("St", Object::Int(1)),
]),
)]);
assert_eq!(label(&cat, 0, 7).as_deref(), Some("zzA"));
assert_eq!(label(&cat, 1, 7).as_deref(), Some("zzB"));
}
#[test]
fn an_unrecognized_style_leaves_the_prefix_alone() {
let cat = catalog(&[(0, dict(&[("P", Object::Str(PdfString::literal(b"x")))]))]);
assert_eq!(label(&cat, 0, 7).as_deref(), Some("x"));
let odd = catalog(&[(
0,
dict(&[
("P", Object::Str(PdfString::literal(b"x"))),
("S", Object::Name(Name::from("Q"))),
]),
)]);
assert_eq!(label(&odd, 0, 7).as_deref(), Some("x"));
}
#[test]
fn a_string_valued_style_still_selects_a_numbering() {
let cat = catalog(&[(0, dict(&[("S", Object::Str(PdfString::literal(b"R")))]))]);
assert_eq!(label(&cat, 0, 7).as_deref(), Some("I"));
}
#[test]
fn a_present_but_empty_label_is_not_the_same_as_none() {
let cat = catalog(&[(0, dict(&[("P", Object::Str(PdfString::literal(b"")))]))]);
assert_eq!(label(&cat, 0, 7), Some(String::new()));
assert_eq!(label(&cat, 9, 7), None);
}
}