#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum StructuralRecord {
DocInfo(DocInfoRecord),
Outline(OutlineRecord),
Annotation(AnnotationRecord),
Dest(DestRecord),
PageOverride(PageOverrideRecord),
ViewerPrefs(ViewerPrefsRecord),
Metadata(MetadataRecord),
Form(FormRecord),
Embed(EmbedRecord),
OutputIntent(OutputIntentRecord),
}
#[derive(Default, Clone, Debug)]
pub struct DocumentStructure {
records: Vec<StructuralRecord>,
pub current_page: u32,
}
impl DocumentStructure {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, record: StructuralRecord) {
self.records.push(record);
}
pub fn records(&self) -> &[StructuralRecord] {
&self.records
}
pub fn drain(&mut self) -> Vec<StructuralRecord> {
std::mem::take(&mut self.records)
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
#[derive(Clone, Debug, Default)]
pub struct DocInfoRecord {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Option<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub creation_date: Option<DocDate>,
pub mod_date: Option<DocDate>,
pub trapped: Option<TrappedState>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TrappedState {
True,
False,
Unknown,
}
impl DocInfoRecord {
pub fn creation_date_string(&self) -> Option<String> {
self.creation_date.as_ref().map(DocDate::to_pdf_string)
}
pub fn mod_date_string(&self) -> Option<String> {
self.mod_date.as_ref().map(DocDate::to_pdf_string)
}
}
impl DocDate {
pub fn to_pdf_string(&self) -> String {
match self {
DocDate::Raw(s) => s.clone(),
DocDate::Parsed(d) => {
let mut out = format!(
"D:{:04}{:02}{:02}{:02}{:02}{:02}",
d.year, d.month, d.day, d.hour, d.minute, d.second
);
match d.tz_sign {
TzSign::Utc => out.push('Z'),
TzSign::East => out.push_str(&format!("+{:02}'{:02}'", d.tz_hour, d.tz_minute)),
TzSign::West => out.push_str(&format!("-{:02}'{:02}'", d.tz_hour, d.tz_minute)),
TzSign::Unknown => {}
}
out
}
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum DocDate {
Raw(String),
Parsed(PdfDate),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PdfDate {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
pub tz_sign: TzSign,
pub tz_hour: u8,
pub tz_minute: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TzSign {
East,
West,
Utc,
Unknown,
}
impl Default for PdfDate {
fn default() -> Self {
Self {
year: 0,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_sign: TzSign::Unknown,
tz_hour: 0,
tz_minute: 0,
}
}
}
impl PdfDate {
pub fn parse(s: &str) -> Option<Self> {
let body = s.strip_prefix("D:")?;
let bytes = body.as_bytes();
if bytes.len() < 4 || !bytes[..4].iter().all(|b| b.is_ascii_digit()) {
return None;
}
let year: u16 = std::str::from_utf8(&bytes[..4]).ok()?.parse().ok()?;
let mut date = PdfDate {
year,
..PdfDate::default()
};
let mut i = 4;
let take_pair = |idx: &mut usize, max: u8| -> Option<u8> {
if *idx + 2 > bytes.len() {
return None;
}
let pair = std::str::from_utf8(&bytes[*idx..*idx + 2]).ok()?;
if !pair.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let v: u8 = pair.parse().ok()?;
if v > max {
return None;
}
*idx += 2;
Some(v)
};
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.month = take_pair(&mut i, 12)?;
}
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.day = take_pair(&mut i, 31)?;
}
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.hour = take_pair(&mut i, 23)?;
}
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.minute = take_pair(&mut i, 59)?;
}
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.second = take_pair(&mut i, 59)?;
}
if i < bytes.len() {
match bytes[i] {
b'Z' => {
date.tz_sign = TzSign::Utc;
}
b'+' => {
date.tz_sign = TzSign::East;
i += 1;
date.tz_hour = take_pair(&mut i, 23)?;
if i < bytes.len() && bytes[i] == b'\'' {
i += 1;
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.tz_minute = take_pair(&mut i, 59)?;
}
}
}
b'-' => {
date.tz_sign = TzSign::West;
i += 1;
date.tz_hour = take_pair(&mut i, 23)?;
if i < bytes.len() && bytes[i] == b'\'' {
i += 1;
if i + 2 <= bytes.len() && bytes[i].is_ascii_digit() {
date.tz_minute = take_pair(&mut i, 59)?;
}
}
}
_ => return None,
}
}
Some(date)
}
}
#[derive(Clone, Debug)]
pub struct OutlineRecord {
pub title: String,
pub destination: Option<OutlineDestination>,
pub count: Option<i32>,
pub outline_level: Option<u32>,
pub color: Option<[f64; 3]>,
pub flags: Option<u32>,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum OutlineDestination {
PageView { page: u32, view: ViewSpec },
NamedDest(String),
Action(OutlineAction),
}
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub enum ViewSpec {
Xyz {
left: Option<f64>,
top: Option<f64>,
zoom: Option<f64>,
},
Fit,
FitH(Option<f64>),
FitV(Option<f64>),
FitR {
left: f64,
bottom: f64,
right: f64,
top: f64,
},
FitB,
FitBH(Option<f64>),
FitBV(Option<f64>),
}
impl Default for ViewSpec {
fn default() -> Self {
ViewSpec::Xyz {
left: None,
top: None,
zoom: None,
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum OutlineAction {
Uri(String),
GoTo(GoToTarget),
JavaScript(String),
Named(String),
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum GoToTarget {
Named(String),
Explicit { page: u32, view: ViewSpec },
}
#[derive(Clone, Debug)]
pub struct OutlineNode {
pub record: OutlineRecord,
pub children: Vec<OutlineNode>,
}
pub fn build_outline_tree(records: &[OutlineRecord]) -> Vec<OutlineNode> {
if records.is_empty() {
return Vec::new();
}
let any_level = records.iter().any(|r| r.outline_level.is_some());
if any_level {
build_level_based(records)
} else {
build_count_based(records)
}
}
fn build_count_based(records: &[OutlineRecord]) -> Vec<OutlineNode> {
let mut idx = 0;
let mut roots = Vec::new();
while idx < records.len() {
roots.push(consume_count_node(records, &mut idx));
}
roots
}
fn consume_count_node(records: &[OutlineRecord], idx: &mut usize) -> OutlineNode {
let record = records[*idx].clone();
let child_count = record.count.unwrap_or(0).unsigned_abs() as usize;
*idx += 1;
let mut children = Vec::with_capacity(child_count);
for _ in 0..child_count {
if *idx >= records.len() {
break;
}
children.push(consume_count_node(records, idx));
}
OutlineNode { record, children }
}
fn build_level_based(records: &[OutlineRecord]) -> Vec<OutlineNode> {
let mut roots: Vec<OutlineNode> = Vec::new();
let mut stack: Vec<Vec<OutlineNode>> = Vec::new();
let mut depths: Vec<u32> = Vec::new();
for record in records {
let mut depth = record.outline_level.unwrap_or(1).max(1);
let max_allowed = depths.last().copied().unwrap_or(0) + 1;
if depth > max_allowed {
depth = max_allowed;
}
while let Some(&top_depth) = depths.last() {
if top_depth < depth {
break;
}
let folded = stack.pop().unwrap_or_default();
depths.pop();
attach_children(&mut roots, &mut stack, folded);
}
let node = OutlineNode {
record: record.clone(),
children: Vec::new(),
};
if depth == 1 {
roots.push(node);
stack.push(Vec::new());
depths.push(1);
} else {
stack.last_mut().unwrap().push(node);
stack.push(Vec::new());
depths.push(depth);
}
}
while let Some(level_children) = stack.pop() {
depths.pop();
attach_children(&mut roots, &mut stack, level_children);
}
roots
}
fn attach_children(
roots: &mut [OutlineNode],
stack: &mut [Vec<OutlineNode>],
children: Vec<OutlineNode>,
) {
if children.is_empty() {
return;
}
let parent = match stack.last_mut() {
Some(siblings) => siblings.last_mut(),
None => roots.last_mut(),
};
if let Some(p) = parent {
p.children = children;
}
}
#[derive(Clone, Debug)]
pub struct AnnotationRecord {
pub page: u32,
pub rect: [f64; 4],
pub color: Option<[f64; 3]>,
pub border: Option<Border>,
pub title: Option<String>,
pub contents: Option<String>,
pub subtype: AnnotationSubtype,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum AnnotationSubtype {
Link {
target: Option<AnnotationTarget>,
highlight: Option<LinkHighlight>,
},
Text {
open: bool,
icon: TextAnnotationIcon,
},
FreeText {
default_appearance: Option<String>,
quadding: Option<u32>,
},
Widget(WidgetAnnotation),
}
#[derive(Clone, Debug, Default)]
pub struct WidgetAnnotation {
pub field_name: String,
pub field_type: Option<FieldType>,
pub value: Option<FieldValue>,
pub default_value: Option<FieldValue>,
pub flags: Option<i32>,
pub max_len: Option<i32>,
pub options: Option<Vec<ChoiceOption>>,
pub quadding: Option<i32>,
pub default_appearance: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldType {
Btn,
Tx,
Ch,
Sig,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum FieldValue {
Text(String),
Name(String),
TextArray(Vec<String>),
}
#[derive(Clone, Debug)]
pub struct ChoiceOption {
pub export: String,
pub display: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LinkHighlight {
None,
Invert,
Outline,
Push,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TextAnnotationIcon {
Comment,
Note,
Key,
Help,
NewParagraph,
Paragraph,
Insert,
}
impl Default for TextAnnotationIcon {
fn default() -> Self {
TextAnnotationIcon::Note
}
}
#[derive(Clone, Debug, Default)]
pub struct Border {
pub h_radius: f64,
pub v_radius: f64,
pub width: f64,
pub dash: Option<Vec<f64>>,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum AnnotationTarget {
PageView { page: u32, view: ViewSpec },
NamedDest(String),
Action(OutlineAction),
}
#[derive(Clone, Debug)]
pub struct DestRecord {
pub name: String,
pub page: u32,
pub view: ViewSpec,
}
#[derive(Clone, Debug)]
pub struct PageOverrideRecord {
pub scope: PageOverrideScope,
pub boxes: PageBoxes,
pub rotate: Option<i32>,
pub additional_actions: Option<PageAdditionalActions>,
}
#[derive(Clone, Debug, Default)]
pub struct PageAdditionalActions {
pub on_open: Option<OutlineAction>,
pub on_close: Option<OutlineAction>,
}
impl PageAdditionalActions {
pub fn is_empty(&self) -> bool {
self.on_open.is_none() && self.on_close.is_none()
}
pub fn merge_over(&self, other: &PageAdditionalActions) -> PageAdditionalActions {
PageAdditionalActions {
on_open: self.on_open.clone().or_else(|| other.on_open.clone()),
on_close: self.on_close.clone().or_else(|| other.on_close.clone()),
}
}
}
#[derive(Clone, Debug)]
pub struct EmbedRecord {
pub filename: String,
pub data: Vec<u8>,
pub unicode_filename: Option<String>,
pub description: Option<String>,
pub af_relationship: Option<String>,
pub mime_type: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum PageOverrideScope {
Single(u32),
All,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct PageBoxes {
pub crop_box: Option<[f64; 4]>,
pub bleed_box: Option<[f64; 4]>,
pub trim_box: Option<[f64; 4]>,
pub art_box: Option<[f64; 4]>,
}
#[derive(Clone, Debug, Default)]
pub struct ViewerPrefsRecord {
pub hide_toolbar: Option<bool>,
pub hide_menubar: Option<bool>,
pub hide_window_ui: Option<bool>,
pub fit_window: Option<bool>,
pub center_window: Option<bool>,
pub display_doc_title: Option<bool>,
pub non_full_screen_page_mode: Option<String>,
pub direction: Option<String>,
pub page_layout: Option<String>,
pub page_mode: Option<String>,
}
impl ViewerPrefsRecord {
pub fn merge_over(&self, other: &ViewerPrefsRecord) -> ViewerPrefsRecord {
ViewerPrefsRecord {
hide_toolbar: self.hide_toolbar.or(other.hide_toolbar),
hide_menubar: self.hide_menubar.or(other.hide_menubar),
hide_window_ui: self.hide_window_ui.or(other.hide_window_ui),
fit_window: self.fit_window.or(other.fit_window),
center_window: self.center_window.or(other.center_window),
display_doc_title: self.display_doc_title.or(other.display_doc_title),
non_full_screen_page_mode: self
.non_full_screen_page_mode
.clone()
.or_else(|| other.non_full_screen_page_mode.clone()),
direction: self.direction.clone().or_else(|| other.direction.clone()),
page_layout: self
.page_layout
.clone()
.or_else(|| other.page_layout.clone()),
page_mode: self.page_mode.clone().or_else(|| other.page_mode.clone()),
}
}
pub fn nested_is_empty(&self) -> bool {
self.hide_toolbar.is_none()
&& self.hide_menubar.is_none()
&& self.hide_window_ui.is_none()
&& self.fit_window.is_none()
&& self.center_window.is_none()
&& self.display_doc_title.is_none()
&& self.non_full_screen_page_mode.is_none()
&& self.direction.is_none()
}
}
#[derive(Clone, Debug)]
pub struct MetadataRecord {
pub xmp_bytes: Vec<u8>,
}
#[derive(Clone, Debug, Default)]
pub struct FormRecord {
pub need_appearances: Option<bool>,
pub sig_flags: Option<i32>,
pub calc_order: Option<Vec<String>>,
pub default_appearance: Option<String>,
pub quadding: Option<i32>,
}
impl FormRecord {
pub fn merge_over(&self, other: &FormRecord) -> FormRecord {
FormRecord {
need_appearances: self.need_appearances.or(other.need_appearances),
sig_flags: self.sig_flags.or(other.sig_flags),
calc_order: self.calc_order.clone().or_else(|| other.calc_order.clone()),
default_appearance: self
.default_appearance
.clone()
.or_else(|| other.default_appearance.clone()),
quadding: self.quadding.or(other.quadding),
}
}
}
#[derive(Clone, Debug)]
pub struct OutputIntentRecord {
pub subtype: Vec<u8>,
pub output_condition_identifier: Option<Vec<u8>>,
pub output_condition: Option<Vec<u8>>,
pub registry_name: Option<Vec<u8>>,
pub info: Option<Vec<u8>>,
pub dest_output_profile: Option<std::sync::Arc<Vec<u8>>>,
pub n: u32,
}
impl PageBoxes {
pub fn merge_over(&self, other: &PageBoxes) -> PageBoxes {
PageBoxes {
crop_box: self.crop_box.or(other.crop_box),
bleed_box: self.bleed_box.or(other.bleed_box),
trim_box: self.trim_box.or(other.trim_box),
art_box: self.art_box.or(other.art_box),
}
}
pub fn is_empty(&self) -> bool {
self.crop_box.is_none()
&& self.bleed_box.is_none()
&& self.trim_box.is_none()
&& self.art_box.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn date_parse_full() {
let d = PdfDate::parse("D:20261231120000-05'00'").unwrap();
assert_eq!(d.year, 2026);
assert_eq!(d.month, 12);
assert_eq!(d.day, 31);
assert_eq!(d.hour, 12);
assert_eq!(d.tz_sign, TzSign::West);
assert_eq!(d.tz_hour, 5);
}
#[test]
fn date_parse_utc() {
let d = PdfDate::parse("D:20260101000000Z").unwrap();
assert_eq!(d.tz_sign, TzSign::Utc);
assert_eq!(d.year, 2026);
}
#[test]
fn date_parse_year_only() {
let d = PdfDate::parse("D:2026").unwrap();
assert_eq!(d.year, 2026);
assert_eq!(d.month, 1);
assert_eq!(d.day, 1);
}
#[test]
fn date_parse_no_prefix() {
assert!(PdfDate::parse("20260101").is_none());
}
#[test]
fn date_parse_garbage() {
assert!(PdfDate::parse("D:abcd").is_none());
}
#[test]
fn buffer_round_trip() {
let mut buf = DocumentStructure::new();
assert!(buf.is_empty());
buf.push(StructuralRecord::DocInfo(DocInfoRecord {
title: Some("Hello".into()),
..DocInfoRecord::default()
}));
assert_eq!(buf.records().len(), 1);
let drained = buf.drain();
assert_eq!(drained.len(), 1);
assert!(buf.is_empty());
}
fn outline(title: &str, count: Option<i32>, level: Option<u32>) -> OutlineRecord {
OutlineRecord {
title: title.into(),
destination: None,
count,
outline_level: level,
color: None,
flags: None,
}
}
#[test]
fn outline_empty_input() {
let tree = build_outline_tree(&[]);
assert!(tree.is_empty());
}
#[test]
fn outline_count_based_three_with_two_kids_each() {
let records = vec![
outline("A", Some(2), None),
outline("A.1", None, None),
outline("A.2", None, None),
outline("B", Some(2), None),
outline("B.1", None, None),
outline("B.2", None, None),
outline("C", Some(2), None),
outline("C.1", None, None),
outline("C.2", None, None),
];
let tree = build_outline_tree(&records);
assert_eq!(tree.len(), 3);
for (i, root) in tree.iter().enumerate() {
assert_eq!(root.children.len(), 2, "root {i} should have 2 kids");
}
assert_eq!(tree[0].record.title, "A");
assert_eq!(tree[0].children[0].record.title, "A.1");
assert_eq!(tree[2].children[1].record.title, "C.2");
}
#[test]
fn outline_count_based_collapsed_negative() {
let records = vec![
outline("A", Some(-2), None),
outline("A.1", None, None),
outline("A.2", None, None),
];
let tree = build_outline_tree(&records);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), 2);
}
#[test]
fn outline_count_based_nested_grandchildren() {
let records = vec![
outline("A", Some(1), None),
outline("A.1", Some(2), None),
outline("A.1.1", None, None),
outline("A.1.2", None, None),
];
let tree = build_outline_tree(&records);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].children.len(), 2);
}
#[test]
fn outline_level_based_1_2_2_1_2_3_3_1() {
let records = vec![
outline("A", None, Some(1)),
outline("A.1", None, Some(2)),
outline("A.2", None, Some(2)),
outline("B", None, Some(1)),
outline("B.1", None, Some(2)),
outline("B.1.1", None, Some(3)),
outline("B.1.2", None, Some(3)),
outline("C", None, Some(1)),
];
let tree = build_outline_tree(&records);
assert_eq!(tree.len(), 3);
assert_eq!(tree[0].record.title, "A");
assert_eq!(tree[0].children.len(), 2);
assert_eq!(tree[1].record.title, "B");
assert_eq!(tree[1].children.len(), 1);
assert_eq!(tree[1].children[0].children.len(), 2);
assert_eq!(tree[1].children[0].children[1].record.title, "B.1.2");
assert_eq!(tree[2].record.title, "C");
assert!(tree[2].children.is_empty());
}
#[test]
fn outline_level_skip_clamps_to_next_depth() {
let records = vec![
outline("Root", None, Some(1)),
outline("Child", None, Some(5)),
];
let tree = build_outline_tree(&records);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].record.title, "Child");
}
#[test]
fn outline_mixed_input_uses_level_path() {
let records = vec![
outline("Bare", Some(2), None),
outline("Tagged-1", None, Some(1)),
outline("Tagged-2", None, Some(2)),
];
let tree = build_outline_tree(&records);
assert_eq!(tree.len(), 2);
assert!(tree[0].children.is_empty());
assert_eq!(tree[1].children.len(), 1);
}
}