use crate::destination::{destination_value_with, Destination};
use crate::hash::FastSet;
use crate::object::{decode_text_string, Dict, ObjRef, Object};
use crate::source::AsyncObjectSource;
use crate::tree::resolved_dict;
pub const MAX_OUTLINE_ITEMS: usize = 65_536;
pub const MAX_OUTLINE_DEPTH: usize = 64;
#[derive(Debug, Clone, PartialEq)]
pub struct OutlineItem {
pub title: String,
pub destination: Option<Destination>,
pub open: bool,
pub color: [f32; 3],
pub italic: bool,
pub bold: bool,
pub structure_element: Option<ObjRef>,
pub children: Vec<OutlineItem>,
}
pub async fn outline_with<S: AsyncObjectSource>(src: &S, trailer: &Dict) -> Vec<OutlineItem> {
let Some(first) = outline_first(src, trailer).await else {
return Vec::new();
};
let mut flat: Vec<(usize, OutlineItem)> = Vec::new();
let mut visited: FastSet<ObjRef> = FastSet::default();
let mut stack: Vec<(Object, usize)> = vec![(first, 0)];
while let Some((node, depth)) = stack.pop() {
if flat.len() >= MAX_OUTLINE_ITEMS {
break;
}
if let Some(r) = node.as_ref() {
if !visited.insert(r) {
continue;
}
}
let Some(dict) = resolved_dict(src, &node).await else {
continue;
};
if let Some(next) = dict.get("Next") {
stack.push((next.clone(), depth));
}
if depth < MAX_OUTLINE_DEPTH {
if let Some(child) = dict.get("First") {
stack.push((child.clone(), depth + 1));
}
}
flat.push((depth, read_item(src, trailer, &dict).await));
}
nest(flat)
}
async fn outline_first<S: AsyncObjectSource>(src: &S, trailer: &Dict) -> Option<Object> {
let catalog = resolved_dict(src, trailer.get("Root")?).await?;
let outlines = resolved_dict(src, catalog.get("Outlines")?).await?;
outlines.get("First").cloned()
}
async fn read_item<S: AsyncObjectSource>(src: &S, trailer: &Dict, dict: &Dict) -> OutlineItem {
let title = match dict.get("Title") {
Some(t) => src
.resolve(t)
.await
.ok()
.and_then(|t| t.as_str_bytes().map(decode_text_string))
.unwrap_or_default(),
None => String::new(),
};
let destination = match dict.get("Dest") {
Some(dest) => destination_value_with(src, trailer, dest).await,
None => action_destination(src, trailer, dict).await,
};
let count = match dict.get("Count") {
Some(c) => src.resolve(c).await.ok().and_then(|c| c.as_int()),
None => None,
};
let color = match dict.get("C") {
Some(c) => src.resolve(c).await.ok().and_then(|c| rgb(&c)),
None => None,
};
let flags = match dict.get("F") {
Some(f) => src.resolve(f).await.ok().and_then(|f| f.as_int()),
None => None,
}
.unwrap_or(0);
OutlineItem {
title,
destination,
open: count.is_some_and(|c| c > 0),
color: color.unwrap_or([0.0; 3]),
italic: flags & 1 != 0,
bold: flags & 2 != 0,
structure_element: dict.get("SE").and_then(Object::as_ref),
children: Vec::new(),
}
}
async fn action_destination<S: AsyncObjectSource>(
src: &S,
trailer: &Dict,
dict: &Dict,
) -> Option<Destination> {
let action = resolved_dict(src, dict.get("A")?).await?;
let kind = src.resolve(action.get("S")?).await.ok()?;
if kind.as_name()?.0 != "GoTo" {
return None;
}
destination_value_with(src, trailer, action.get("D")?).await
}
fn rgb(o: &Object) -> Option<[f32; 3]> {
let items = o.as_array()?;
if items.len() != 3 {
return None;
}
let mut out = [0.0; 3];
for (slot, item) in out.iter_mut().zip(items) {
*slot = item.as_f64()?.clamp(0.0, 1.0) as f32;
}
Some(out)
}
fn nest(flat: Vec<(usize, OutlineItem)>) -> Vec<OutlineItem> {
let mut roots = Vec::new();
let mut open: Vec<(usize, OutlineItem)> = Vec::new();
for (depth, item) in flat {
while open.last().is_some_and(|(d, _)| *d >= depth) {
let (_, done) = open.pop().expect("checked by the loop condition");
attach(done, &mut open, &mut roots);
}
open.push((depth, item));
}
while let Some((_, done)) = open.pop() {
attach(done, &mut open, &mut roots);
}
roots
}
fn attach(done: OutlineItem, open: &mut [(usize, OutlineItem)], roots: &mut Vec<OutlineItem>) {
match open.last_mut() {
Some((_, parent)) => parent.children.push(done),
None => roots.push(done),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::destination::{DestinationPage, Fit};
use crate::Document;
use pdfboss_testkit::PdfBuilder;
fn doc(catalog_extra: &str, objects: &[(u32, &str)]) -> Document {
let mut b = PdfBuilder::new();
b.object(
1,
&format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
);
b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
for (num, body) in objects {
b.object(*num, body);
}
Document::load(b.build(1)).expect("load")
}
fn titles(items: &[OutlineItem]) -> Vec<String> {
items.iter().map(|i| i.title.clone()).collect()
}
fn page(fit: Fit) -> Option<Destination> {
Some(Destination {
page: DestinationPage::Object(ObjRef { num: 3, gen: 0 }),
fit,
})
}
#[test]
fn outline_items_nest_by_first_and_next() {
let doc = doc(
"/Outlines 10 0 R /Dests << /App [3 0 R /FitB] >>",
&[
(
10,
"<< /Type /Outlines /First 11 0 R /Last 16 0 R /Count 4 >>",
),
(
11,
"<< /Title (Chapter 1) /Parent 10 0 R /Next 14 0 R /First 12 0 R /Last 13 0 R \
/Count 2 /Dest [3 0 R /XYZ 0 792 0] >>",
),
(
12,
"<< /Title <FEFF00A70031> /Parent 11 0 R /Next 13 0 R /Dest [3 0 R /Fit] >>",
),
(
13,
"<< /Title (1.2) /Parent 11 0 R /Prev 12 0 R \
/A << /S /GoTo /D [3 0 R /FitH 400] >> >>",
),
(
14,
"<< /Title (Chapter 2) /Parent 10 0 R /Prev 11 0 R /Next 16 0 R \
/First 15 0 R /Last 15 0 R /Count -1 /SE 20 0 R >>",
),
(
15,
"<< /Title (2.1) /Parent 14 0 R /A << /S /URI /URI (http://x) >> >>",
),
(
16,
"<< /Title (Appendix) /Parent 10 0 R /Prev 14 0 R /Dest /App \
/C [1 0 0.5] /F 3 >>",
),
(20, "<< /Type /StructElem /S /Sect >>"),
],
);
let items = doc.outline();
assert_eq!(titles(&items), ["Chapter 1", "Chapter 2", "Appendix"]);
let chapter1 = &items[0];
assert!(chapter1.open);
assert_eq!(
chapter1.destination,
page(Fit::Xyz {
left: Some(0.0),
top: Some(792.0),
zoom: None
})
);
assert_eq!(titles(&chapter1.children), ["§1", "1.2"]);
assert_eq!(chapter1.children[0].destination, page(Fit::Fit));
assert_eq!(
chapter1.children[1].destination,
page(Fit::FitH { top: Some(400.0) }),
"a /GoTo action's /D counts as the destination"
);
assert!(chapter1.children.iter().all(|c| c.children.is_empty()));
let chapter2 = &items[1];
assert!(!chapter2.open, "a negative /Count is closed");
assert_eq!(chapter2.destination, None);
assert_eq!(chapter2.structure_element, Some(ObjRef { num: 20, gen: 0 }));
assert_eq!(titles(&chapter2.children), ["2.1"]);
assert_eq!(
chapter2.children[0].destination, None,
"a URI action is not a destination"
);
let appendix = &items[2];
assert_eq!(appendix.destination, page(Fit::FitB));
assert_eq!(appendix.color, [1.0, 0.0, 0.5]);
assert!(appendix.italic && appendix.bold);
assert!(!chapter1.italic && !chapter1.bold);
assert_eq!(chapter1.color, [0.0, 0.0, 0.0]);
}
#[test]
fn cycles_and_missing_outlines_end_quietly() {
assert!(doc("", &[]).outline().is_empty(), "no /Outlines");
assert!(
doc(
"/Outlines 10 0 R",
&[(10, "<< /Type /Outlines /Count 0 >>")]
)
.outline()
.is_empty(),
"an outline without /First"
);
let doc = doc(
"/Outlines 10 0 R",
&[
(10, "<< /Type /Outlines /First 11 0 R /Last 13 0 R >>"),
(11, "<< /Title (A) /Parent 10 0 R /Next 12 0 R >>"),
(
12,
"<< /Title (B) /Parent 10 0 R /Next 11 0 R /First 13 0 R >>",
),
(
13,
"<< /Title (C) /Parent 12 0 R /First (oops) /Next 14 0 R >>",
),
(14, "<< /Title (D) /Parent 12 0 R >>"),
],
);
let items = doc.outline();
assert_eq!(titles(&items), ["A", "B"]);
assert_eq!(titles(&items[1].children), ["C", "D"]);
assert!(items[1].children[0].children.is_empty());
}
}