mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
langid and tocfind: what language a book is in, and what it says its chapters are
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
//! An EPUB's navigation document.
|
||||
//!
|
||||
//! Two shapes, because the format changed. EPUB 3 keeps a `<nav>` of nested
|
||||
//! `<ol>` lists; EPUB 2 keeps an NCX of nested `<navPoint>` elements. Every
|
||||
//! real book in a library has the second, including most that declare
|
||||
//! themselves EPUB 3, because converters keep writing it for readers that
|
||||
//! never caught up.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use crate::{Entry, Missing, number, tidy};
|
||||
|
||||
/// How large a navigation document is worth reading.
|
||||
const MAX_NAV: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// Read an EPUB's table of contents.
|
||||
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, Missing> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| Missing::Unreadable(e.to_string()))?;
|
||||
|
||||
let names: Vec<String> = (0..archive.len())
|
||||
.filter_map(|at| {
|
||||
archive
|
||||
.by_index(at)
|
||||
.ok()
|
||||
.map(|file| file.name().to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The NCX first: it is what books actually carry, and its labels are
|
||||
// plain text where a nav document's are markup.
|
||||
if let Some(name) = names
|
||||
.iter()
|
||||
.find(|name| name.to_lowercase().ends_with(".ncx"))
|
||||
{
|
||||
if let Some(text) = member(&mut archive, name) {
|
||||
let mut entries = from_ncx(&text);
|
||||
if !entries.is_empty() {
|
||||
number(&mut entries);
|
||||
return Ok(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in names.iter().filter(|name| {
|
||||
let lower = name.to_lowercase();
|
||||
lower.ends_with("nav.xhtml") || lower.ends_with("nav.html") || lower.ends_with("toc.xhtml")
|
||||
}) {
|
||||
if let Some(text) = member(&mut archive, name) {
|
||||
let mut entries = from_nav(&text);
|
||||
if !entries.is_empty() {
|
||||
number(&mut entries);
|
||||
return Ok(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Missing::None("navigation document"))
|
||||
}
|
||||
|
||||
fn member<R: Read + std::io::Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<String> {
|
||||
let file = archive.by_name(name).ok()?;
|
||||
if file.size() > MAX_NAV {
|
||||
return None;
|
||||
}
|
||||
let mut text = String::new();
|
||||
file.take(MAX_NAV).read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// Walk an NCX, where depth is how deeply `<navPoint>` elements nest.
|
||||
pub fn from_ncx(ncx: &str) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut depth: u32 = 0;
|
||||
let mut rest = ncx;
|
||||
|
||||
// Element by element rather than with a parser: the shape is three tags
|
||||
// deep and a parser would be the largest dependency in the crate.
|
||||
while let Some(at) = rest.find('<') {
|
||||
let after = &rest[at..];
|
||||
let Some(end) = after.find('>') else { break };
|
||||
let tag = &after[1..end];
|
||||
|
||||
if tag.starts_with("navPoint") && !tag.starts_with("navPoint/") {
|
||||
let point = &after[end + 1..];
|
||||
if let Some(title) = between(point, "<text>", "</text>").map(tidy) {
|
||||
if !title.is_empty() {
|
||||
entries.push(Entry {
|
||||
v: corpus_core::V,
|
||||
idx: 0,
|
||||
depth,
|
||||
title,
|
||||
target: between(point, "src=\"", "\"")
|
||||
.or_else(|| between(point, "src='", "'"))
|
||||
.map(|src| src.to_string()),
|
||||
page: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
// A self-closing navPoint has no children and no depth to add.
|
||||
if !tag.ends_with('/') {
|
||||
depth += 1;
|
||||
}
|
||||
} else if tag.starts_with("/navPoint") {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// Walk an EPUB 3 navigation document, where depth is `<ol>` nesting.
|
||||
pub fn from_nav(nav: &str) -> Vec<Entry> {
|
||||
// Only the table of contents, not the page list or the landmarks.
|
||||
let scope = match nav
|
||||
.find("epub:type=\"toc\"")
|
||||
.or_else(|| nav.find("epub:type='toc'"))
|
||||
{
|
||||
Some(at) => {
|
||||
let from = &nav[at..];
|
||||
let end = from.find("</nav>").map_or(from.len(), |end| end + 6);
|
||||
&from[..end]
|
||||
}
|
||||
None => nav,
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut depth: i64 = -1;
|
||||
let mut rest = scope;
|
||||
|
||||
while let Some(at) = rest.find('<') {
|
||||
let after = &rest[at..];
|
||||
let Some(end) = after.find('>') else { break };
|
||||
let tag = &after[1..end];
|
||||
let lower = tag.to_lowercase();
|
||||
|
||||
if lower.starts_with("ol") && !lower.starts_with("ol/") {
|
||||
depth += 1;
|
||||
} else if lower.starts_with("/ol") {
|
||||
depth -= 1;
|
||||
} else if lower.starts_with("a ") || lower.starts_with("a>") {
|
||||
let body = &after[end + 1..];
|
||||
if let Some(close) = body.find("</a>") {
|
||||
let title = tidy(&strip_tags(&body[..close]));
|
||||
if !title.is_empty() {
|
||||
entries.push(Entry {
|
||||
v: corpus_core::V,
|
||||
idx: 0,
|
||||
depth: depth.max(0) as u32,
|
||||
title,
|
||||
target: attribute(tag, "href"),
|
||||
page: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// What sits between two markers.
|
||||
fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
|
||||
let start = text.find(open)? + open.len();
|
||||
let end = text[start..].find(close)? + start;
|
||||
Some(&text[start..end])
|
||||
}
|
||||
|
||||
/// An attribute out of one tag's text.
|
||||
fn attribute(tag: &str, name: &str) -> Option<String> {
|
||||
for quote in ['"', '\''] {
|
||||
let needle = format!("{name}={quote}");
|
||||
if let Some(at) = tag.find(&needle) {
|
||||
let rest = &tag[at + needle.len()..];
|
||||
if let Some(end) = rest.find(quote) {
|
||||
return Some(rest[..end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Inner text, with the markup a title is wrapped in removed.
|
||||
fn strip_tags(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut inside = false;
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'<' => inside = true,
|
||||
'>' => inside = false,
|
||||
c if !inside => out.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NCX: &str = r#"<ncx><navMap>
|
||||
<navPoint id="a" playOrder="1">
|
||||
<navLabel><text>Part One</text></navLabel>
|
||||
<content src="part1.xhtml"/>
|
||||
<navPoint id="b" playOrder="2">
|
||||
<navLabel><text>I. The Setting</text></navLabel>
|
||||
<content src="ch1.xhtml"/>
|
||||
</navPoint>
|
||||
<navPoint id="c" playOrder="3">
|
||||
<navLabel><text>II. The Second Estate</text></navLabel>
|
||||
<content src="ch2.xhtml"/>
|
||||
</navPoint>
|
||||
</navPoint>
|
||||
<navPoint id="d" playOrder="4">
|
||||
<navLabel><text>Part Two</text></navLabel>
|
||||
<content src="part2.xhtml"/>
|
||||
</navPoint>
|
||||
</navMap></ncx>"#;
|
||||
|
||||
#[test]
|
||||
fn nesting_becomes_depth() {
|
||||
let entries = from_ncx(NCX);
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| (e.depth, e.title.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(0, "Part One"),
|
||||
(1, "I. The Setting"),
|
||||
(1, "II. The Second Estate"),
|
||||
(0, "Part Two"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_entry_keeps_where_it_points() {
|
||||
assert_eq!(from_ncx(NCX)[1].target.as_deref(), Some("ch1.xhtml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nav_document_nests_by_its_lists() {
|
||||
let nav = r#"<nav epub:type="toc"><ol>
|
||||
<li><a href="p1.xhtml">Part One</a>
|
||||
<ol><li><a href="c1.xhtml">I. The Setting</a></li></ol>
|
||||
</li>
|
||||
<li><a href="p2.xhtml">Part Two</a></li>
|
||||
</ol></nav>"#;
|
||||
let entries = from_nav(nav);
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| (e.depth, e.title.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[(0, "Part One"), (1, "I. The Setting"), (0, "Part Two")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_contents_are_read_and_not_the_page_list() {
|
||||
let nav = r#"
|
||||
<nav epub:type="toc"><ol><li><a href="c1.xhtml">Chapter One</a></li></ol></nav>
|
||||
<nav epub:type="page-list"><ol><li><a href="c1.xhtml#p1">1</a></li></ol></nav>"#;
|
||||
let entries = from_nav(nav);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].title, "Chapter One");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_inside_a_title_is_not_part_of_the_title() {
|
||||
let nav = r#"<nav epub:type="toc"><ol><li><a href="c.xhtml">
|
||||
<span class="num">1</span> <em>The Setting</em></a></li></ol></nav>"#;
|
||||
assert_eq!(from_nav(nav)[0].title, "1 The Setting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_with_no_label_is_not_an_entry() {
|
||||
let ncx = "<navMap><navPoint><navLabel><text> </text></navLabel></navPoint></navMap>";
|
||||
assert!(from_ncx(ncx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_zip_says_so() {
|
||||
assert!(matches!(read(b"not a zip"), Err(Missing::Unreadable(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! A book's own table of contents.
|
||||
//!
|
||||
//! A book usually knows its own chapters. An EPUB carries them in a
|
||||
//! navigation document, a PDF in an outline, and both are put there by the
|
||||
//! publisher — which makes them better than any heading a converter guesses
|
||||
//! at afterwards, because they are what the book itself says its parts are.
|
||||
//!
|
||||
//! What comes back is the entries in reading order, each with how deeply it
|
||||
//! is nested, so a flat stream still carries the shape of the book.
|
||||
|
||||
pub mod epub;
|
||||
pub mod pdf;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One entry of a table of contents.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Entry {
|
||||
/// Record version.
|
||||
#[serde(default = "version")]
|
||||
pub v: u32,
|
||||
/// Position in reading order, from zero.
|
||||
pub idx: u32,
|
||||
/// How deeply nested: nought for a part, one for a chapter under it.
|
||||
pub depth: u32,
|
||||
/// What the book calls it.
|
||||
pub title: String,
|
||||
/// Where it points: a document within the archive, or a page number.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<String>,
|
||||
/// The page it begins on, where the source counts in pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<u32>,
|
||||
}
|
||||
|
||||
fn version() -> u32 {
|
||||
corpus_core::V
|
||||
}
|
||||
|
||||
impl corpus_core::Record for Entry {
|
||||
fn version(&self) -> u32 {
|
||||
self.v
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a file has no table of contents to read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Missing {
|
||||
/// The format keeps one, and this file has none.
|
||||
None(&'static str),
|
||||
/// Not a format that keeps one.
|
||||
Unsupported,
|
||||
/// The file could not be opened or parsed.
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Missing {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Missing::None(where_) => write!(f, "no {where_}"),
|
||||
Missing::Unsupported => write!(f, "not a format that carries a table of contents"),
|
||||
Missing::Unreadable(why) => write!(f, "{why}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Missing {}
|
||||
|
||||
/// Read a file's table of contents.
|
||||
pub fn read(path: &std::path::Path) -> Result<Vec<Entry>, Missing> {
|
||||
let bytes = std::fs::read(path).map_err(|e| Missing::Unreadable(e.to_string()))?;
|
||||
from_bytes(&bytes)
|
||||
}
|
||||
|
||||
/// The same, for bytes already in hand.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Vec<Entry>, Missing> {
|
||||
if bytes.starts_with(b"PK\x03\x04") {
|
||||
return epub::read(bytes);
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
return pdf::read(bytes);
|
||||
}
|
||||
Err(Missing::Unsupported)
|
||||
}
|
||||
|
||||
/// Number the entries in reading order.
|
||||
///
|
||||
/// Done once at the end rather than while walking, so that a reader of a
|
||||
/// nested structure does not have to thread a counter through it.
|
||||
pub fn number(entries: &mut [Entry]) {
|
||||
for (at, entry) in entries.iter_mut().enumerate() {
|
||||
entry.idx = at as u32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse runs of whitespace, which navigation documents are full of.
|
||||
pub fn tidy(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_format_with_nowhere_to_keep_one_says_so() {
|
||||
assert_eq!(from_bytes(b"just some text"), Err(Missing::Unsupported));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_numbered_in_reading_order() {
|
||||
let mut entries: Vec<Entry> = (0..3)
|
||||
.map(|_| Entry {
|
||||
v: corpus_core::V,
|
||||
idx: 99,
|
||||
depth: 0,
|
||||
title: "x".into(),
|
||||
target: None,
|
||||
page: None,
|
||||
})
|
||||
.collect();
|
||||
number(&mut entries);
|
||||
assert_eq!(entries.iter().map(|e| e.idx).collect::<Vec<_>>(), [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_in_a_title_is_collapsed() {
|
||||
assert_eq!(tidy(" Chapter\n One "), "Chapter One");
|
||||
assert_eq!(tidy(""), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A book's own table of contents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use corpus_core::exit;
|
||||
|
||||
/// Read a book's table of contents, one record per entry.
|
||||
///
|
||||
/// A book usually knows its own chapters: an EPUB keeps them in a navigation
|
||||
/// document, a PDF in an outline. Both are put there by the publisher, which
|
||||
/// makes them better than any heading guessed at from converted text.
|
||||
///
|
||||
/// Entries come out in reading order, each carrying how deeply it is nested,
|
||||
/// so a flat stream still has the shape of the book.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about,
|
||||
after_help = "\
|
||||
Exit codes: 0 it had one, 1 it had none, 65 it is not a format that keeps one,
|
||||
74 the file could not be read.
|
||||
|
||||
Example:
|
||||
|
||||
tocfind book.epub | jq -r '(\" \" * .depth) + .title'
|
||||
for f in *.epub; do tocfind --quiet \"$f\" || echo \"no contents: $f\"; done"
|
||||
)]
|
||||
struct Args {
|
||||
/// The book to read.
|
||||
path: PathBuf,
|
||||
|
||||
/// Say nothing; the exit code is the answer.
|
||||
#[arg(long, short)]
|
||||
quiet: bool,
|
||||
|
||||
/// Print the contents as an indented list rather than as records.
|
||||
#[arg(long)]
|
||||
outline: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Args = match catena_cli::parse() {
|
||||
Ok(args) => args,
|
||||
Err(code) => return code,
|
||||
};
|
||||
|
||||
let entries = match tocfind::read(&args.path) {
|
||||
Ok(entries) => entries,
|
||||
Err(why) => {
|
||||
if !args.quiet {
|
||||
eprintln!("tocfind: {}: {why}", args.path.display());
|
||||
}
|
||||
return ExitCode::from(match why {
|
||||
// Having none is an answer; not being able to have one is not.
|
||||
tocfind::Missing::None(_) => exit::NO,
|
||||
tocfind::Missing::Unsupported => exit::DATA,
|
||||
tocfind::Missing::Unreadable(_) => exit::IO,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if args.quiet {
|
||||
return ExitCode::from(exit::OK);
|
||||
}
|
||||
if args.outline {
|
||||
let text: String = entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let indent = " ".repeat(entry.depth as usize);
|
||||
match entry.page {
|
||||
Some(page) => format!("{indent}{} ({page})\n", entry.title),
|
||||
None => format!("{indent}{}\n", entry.title),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return catena_cli::text("tocfind", &text);
|
||||
}
|
||||
catena_cli::stream("tocfind", &entries)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user