M4: write each page label dictionary as an indirect object (#1763) (#1959)

This commit is contained in:
Marco Arnulfo
2026-10-15 02:33:35 -04:00
committed by GitHub
parent e4657eb577
commit 03f232be5a
2 changed files with 35 additions and 1 deletions
+3 -1
View File
@@ -385,7 +385,9 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul
spec.set(b"St".to_vec(), Object::Int(i64::from(r.first)));
}
nums.push(Object::Int(r.start as i64));
nums.push(Object::Dict(spec));
// Indirect, as Acrobat writes them: a direct dictionary is valid too, but some readers
// (pdfannots2json, through unipdf) skip it and show physical page numbers (#1763).
nums.push(Object::Ref(doc.add(Object::Dict(spec))));
}
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(nums));
+32
View File
@@ -1550,6 +1550,38 @@ fn page_label_limits_validate_the_same_ranges_that_are_written() {
assert_eq!(nums[2].as_int(), Some(1));
}
/// Each label dictionary is its own object, referenced from `/Nums`, as Acrobat writes them. A
/// direct dictionary is just as valid, but some readers (pdfannots2json, through unipdf) skip it
/// and fall back to physical page numbers (#1763).
#[test]
fn page_labels_are_written_as_indirect_dictionaries() {
use crate::{LabelRange, LabelStyle};
let mut d = doc_a();
let ranges = [
LabelRange { start: 0, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 },
LabelRange { start: 1, style: LabelStyle::Decimal, prefix: "A-".into(), first: 324 },
LabelRange { start: 2, style: LabelStyle::None, prefix: "Cover".into(), first: 1 },
];
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
let back = full_roundtrip(&d);
let catalog = back.get(back.root().unwrap());
let tree = back.resolve(catalog.as_dict().unwrap().get(b"PageLabels").unwrap());
let nums = tree.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
assert_eq!(nums.len(), 6);
let expected = [(0, Some(b"r".as_slice()), None, None), (1, Some(b"D".as_slice()), Some("A-"), Some(324)), (2, None, Some("Cover"), None)];
for (pair, (start, style, prefix, first)) in nums.chunks(2).zip(expected) {
assert_eq!(pair[0].as_int(), Some(start));
let Object::Ref(r) = &pair[1] else { panic!("the label of page {start} is not an indirect object: {:?}", pair[1]) };
let spec = back.get(*r);
let spec = spec.as_dict().unwrap();
assert_eq!(spec.name(b"S"), style);
assert_eq!(spec.get(b"P").and_then(|p| p.as_string()).map(|p| p.to_text()).as_deref(), prefix);
assert_eq!(spec.int(b"St"), first);
}
assert_eq!(crate::page_label_ranges(&back), ranges);
assert_eq!(crate::page_labels(&back).unwrap(), ["i", "A-324", "Cover"]);
}
#[test]
fn page_label_duplicate_starts_keep_the_later_range() {
let mut d = doc_a();