langid and tocfind: what language a book is in, and what it says its chapters are

This commit is contained in:
funkpower
2026-09-20 10:02:15 -04:00
parent 05759ab902
commit d24e37bd12
15 changed files with 1928 additions and 0 deletions
+287
View File
@@ -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(_))));
}
}
+132
View File
@@ -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(""), "");
}
}
+81
View File
@@ -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)
}
+240
View File
@@ -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(_))
));
}
}