mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
241 lines
8.2 KiB
Rust
241 lines
8.2 KiB
Rust
//! A PDF's outline, which is what a reader shows in its sidebar.
|
|
//!
|
|
//! The catalogue names an outline whose items nest, each with a title and a
|
|
//! destination. Resolving a destination to a page number means finding which
|
|
//! page object it names, which is worth doing: a chapter that knows its page
|
|
//! can be cited, and one that knows only an object number cannot.
|
|
|
|
use lopdf::{Document, Object, ObjectId};
|
|
|
|
use crate::{Entry, Missing, number, tidy};
|
|
|
|
/// How deep an outline is followed before something is assumed to be wrong.
|
|
const MAX_DEPTH: u32 = 16;
|
|
|
|
/// How many items are read before stopping.
|
|
///
|
|
/// A real outline is hundreds; a malformed one that loops is unbounded, and
|
|
/// the depth cap alone does not catch a cycle among siblings.
|
|
const MAX_ITEMS: usize = 10_000;
|
|
|
|
/// Read a PDF's outline.
|
|
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, Missing> {
|
|
let document = Document::load_mem(bytes).map_err(|e| Missing::Unreadable(e.to_string()))?;
|
|
if document.is_encrypted() {
|
|
return Err(Missing::Unreadable("it is encrypted".into()));
|
|
}
|
|
|
|
let pages: Vec<ObjectId> = document.get_pages().into_values().collect();
|
|
let Some(root) = outline_root(&document) else {
|
|
return Err(Missing::None("outline"));
|
|
};
|
|
|
|
let mut entries = Vec::new();
|
|
let mut seen: Vec<ObjectId> = Vec::new();
|
|
walk(&document, &pages, root, 0, &mut entries, &mut seen);
|
|
|
|
match entries.is_empty() {
|
|
true => Err(Missing::None("outline entries")),
|
|
false => {
|
|
number(&mut entries);
|
|
Ok(entries)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The first item of the catalogue's outline.
|
|
fn outline_root(document: &Document) -> Option<ObjectId> {
|
|
let outlines = document.catalog().ok()?.get(b"Outlines").ok()?;
|
|
let (_, object) = document.dereference(outlines).ok()?;
|
|
object
|
|
.as_dict()
|
|
.ok()?
|
|
.get(b"First")
|
|
.ok()?
|
|
.as_reference()
|
|
.ok()
|
|
}
|
|
|
|
/// Follow a chain of siblings, descending into each one's children.
|
|
fn walk(
|
|
document: &Document,
|
|
pages: &[ObjectId],
|
|
first: ObjectId,
|
|
depth: u32,
|
|
entries: &mut Vec<Entry>,
|
|
seen: &mut Vec<ObjectId>,
|
|
) {
|
|
if depth > MAX_DEPTH {
|
|
return;
|
|
}
|
|
let mut at = Some(first);
|
|
while let Some(id) = at {
|
|
if entries.len() >= MAX_ITEMS || seen.contains(&id) {
|
|
return;
|
|
}
|
|
seen.push(id);
|
|
|
|
let Ok(item) = document.get_dictionary(id) else {
|
|
return;
|
|
};
|
|
if let Some(title) = item.get(b"Title").ok().and_then(|t| text(document, t)) {
|
|
let title = tidy(&title);
|
|
if !title.is_empty() {
|
|
entries.push(Entry {
|
|
v: corpus_core::V,
|
|
idx: 0,
|
|
depth,
|
|
title,
|
|
target: None,
|
|
page: page_of(document, pages, item),
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(child) = item.get(b"First").ok().and_then(|f| f.as_reference().ok()) {
|
|
walk(document, pages, child, depth + 1, entries, seen);
|
|
}
|
|
at = item
|
|
.get(b"Next")
|
|
.ok()
|
|
.and_then(|next| next.as_reference().ok());
|
|
}
|
|
}
|
|
|
|
/// Which page an item points at, counting from one.
|
|
///
|
|
/// A destination is either an array beginning with a page reference or a
|
|
/// named one this does not chase, because a name table is a second index and
|
|
/// an entry without a page is still an entry.
|
|
fn page_of(document: &Document, pages: &[ObjectId], item: &lopdf::Dictionary) -> Option<u32> {
|
|
let destination = match item.get(b"Dest") {
|
|
Ok(dest) => dest.clone(),
|
|
Err(_) => {
|
|
// An action rather than a destination: `/A << /D [...] >>`.
|
|
let action = item.get(b"A").ok()?;
|
|
let (_, action) = document.dereference(action).ok()?;
|
|
action.as_dict().ok()?.get(b"D").ok()?.clone()
|
|
}
|
|
};
|
|
let (_, destination) = document.dereference(&destination).ok()?;
|
|
let target = match destination.as_array().ok()?.first()? {
|
|
Object::Reference(id) => *id,
|
|
_ => return None,
|
|
};
|
|
pages
|
|
.iter()
|
|
.position(|page| *page == target)
|
|
.map(|at| at as u32 + 1)
|
|
}
|
|
|
|
/// A PDF text string, in either of the two encodings.
|
|
fn text(document: &Document, object: &Object) -> Option<String> {
|
|
let (_, object) = document.dereference(object).ok()?;
|
|
let bytes = match object {
|
|
Object::String(bytes, _) => bytes,
|
|
_ => return None,
|
|
};
|
|
if bytes.starts_with(&[0xFE, 0xFF]) {
|
|
let units: Vec<u16> = bytes[2..]
|
|
.chunks_exact(2)
|
|
.map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
|
|
.collect();
|
|
return Some(String::from_utf16_lossy(&units));
|
|
}
|
|
Some(bytes.iter().map(|byte| *byte as char).collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A PDF whose objects are the ones given, with a cross-reference table
|
|
/// that points at them. lopdf needs a real one, as any reader would.
|
|
fn pdf(objects: &[(usize, &str)]) -> Vec<u8> {
|
|
let mut out = b"%PDF-1.4\n".to_vec();
|
|
let top = objects.iter().map(|(n, _)| *n).max().unwrap_or(0) + 1;
|
|
let mut offsets = vec![0usize; top];
|
|
for (number, body) in objects {
|
|
offsets[*number] = out.len();
|
|
out.extend_from_slice(format!("{number} 0 obj\n{body}\nendobj\n").as_bytes());
|
|
}
|
|
let start = out.len();
|
|
out.extend_from_slice(format!("xref\n0 {top}\n0000000000 65535 f \n").as_bytes());
|
|
for offset in offsets.iter().take(top).skip(1) {
|
|
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
|
}
|
|
out.extend_from_slice(
|
|
format!("trailer\n<< /Size {top} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n")
|
|
.as_bytes(),
|
|
);
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn a_pdf_with_no_outline_says_so_rather_than_failing() {
|
|
let minimal = pdf(&[
|
|
(1, "<< /Type /Catalog /Pages 2 0 R >>"),
|
|
(2, "<< /Type /Pages /Count 1 /Kids [3 0 R] >>"),
|
|
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"),
|
|
]);
|
|
assert_eq!(read(&minimal), Err(Missing::None("outline")));
|
|
}
|
|
|
|
#[test]
|
|
fn an_outline_comes_back_nested_and_with_its_pages() {
|
|
let doc = pdf(&[
|
|
(1, "<< /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >>"),
|
|
(2, "<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>"),
|
|
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"),
|
|
(4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"),
|
|
(10, "<< /Type /Outlines /First 11 0 R /Count 2 >>"),
|
|
(
|
|
11,
|
|
"<< /Title (Part One) /Dest [3 0 R /Fit] /First 12 0 R /Next 13 0 R >>",
|
|
),
|
|
(
|
|
12,
|
|
"<< /Title (I. The Setting) /Dest [3 0 R /Fit] /Parent 11 0 R >>",
|
|
),
|
|
(
|
|
13,
|
|
"<< /Title (Part Two) /Dest [4 0 R /Fit] /Parent 10 0 R >>",
|
|
),
|
|
]);
|
|
let entries = read(&doc).expect("an outline");
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|e| (e.depth, e.title.as_str(), e.page))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
(0, "Part One", Some(1)),
|
|
(1, "I. The Setting", Some(1)),
|
|
(0, "Part Two", Some(2)),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_outline_that_points_at_itself_does_not_loop_forever() {
|
|
let doc = pdf(&[
|
|
(1, "<< /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >>"),
|
|
(2, "<< /Type /Pages /Count 1 /Kids [3 0 R] >>"),
|
|
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"),
|
|
(10, "<< /Type /Outlines /First 11 0 R >>"),
|
|
(11, "<< /Title (Round) /Next 12 0 R >>"),
|
|
(12, "<< /Title (And round) /Next 11 0 R >>"),
|
|
]);
|
|
let entries = read(&doc).expect("an outline");
|
|
assert_eq!(entries.len(), 2, "a cycle was followed more than once");
|
|
}
|
|
|
|
#[test]
|
|
fn what_is_not_a_pdf_says_why() {
|
|
assert!(matches!(
|
|
read(b"%PDF-1.4 truncated"),
|
|
Err(Missing::Unreadable(_))
|
|
));
|
|
}
|
|
}
|