mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
A catalogue's worst records are the ones whose file says nothing true about itself: a PDF titled `Untitled` by an author called `Oxford`, which is a publisher, from a filename that was never anything but a filename. What those books do have is pages. The title page carries the title as the publisher set it; the copyright page carries the imprint, the year, the ISBN, and in most English books since the 1970s the cataloguing block, which is a catalogue record a librarian already made, subject headings and all. So this reads the pages. Every field names the page it came from, and a book that printed nothing comes back empty with its fields in `unsettled` rather than filled with something plausible — an extraction that starts at chapter one has no front matter in it, and saying so is the answer. Two pages look like a title page and are not, and both are how a catalogue comes to name the wrong book. A series advertisement lists other titles in the shape a title page uses, over as many as four pages with only the first announcing itself. A translation's cataloguing block prints the original title in brackets above the real one. Both are found and refused. Three things the pages give that no file does: the subject headings a librarian assigned, an ISBN that exists only in print — which turns a book with no identifier into one `bibfetch` can look up — and a title in the case the publisher meant, because a half title prints unbroken what the title page breaks across two lines and sets in capitals. `--prompt` and `--merge` are citefind's: a question about exactly the fields the pages left unsettled, composed here and answered wherever the caller keeps a model, with the advertising taken out of what the model is shown. A field a model answered is marked `model`, so a record always says which of it was read and which was inferred. Measured against 600 books of the home corpus: the cataloguing block answered for 21% of them and agreed with the catalogue 89% of the time, most of the residue being the catalogue's error rather than this one's. Title pages answered for another 41%. The remaining 38% print nothing readable, and are what the prompt is for.
279 lines
9.9 KiB
Rust
279 lines
9.9 KiB
Rust
//! The opening of a book, cut into pages and each page named for what it is.
|
||
//!
|
||
//! Front matter is not prose and does not read like it. A title page is four
|
||
//! lines and no sentence; a copyright page is a dense block of formulae that
|
||
//! recur from publisher to publisher; a series advertisement is a list of
|
||
//! other people's books. Telling them apart is most of the work, because the
|
||
//! title that matters is on one of them and the titles that will mislead you
|
||
//! are on another.
|
||
|
||
use std::sync::LazyLock;
|
||
|
||
use regex::Regex;
|
||
|
||
/// What a page of front matter is.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Kind {
|
||
/// Nothing legible on it.
|
||
Blank,
|
||
/// Other books, advertised. The trap: it names two hundred works that
|
||
/// are not this one, in the same shapes a title page uses.
|
||
SeriesList,
|
||
/// Publication facts: the imprint, the copyright, the ISBN, the CIP block.
|
||
Copyright,
|
||
/// The book's own table of contents.
|
||
Contents,
|
||
/// Few lines, no sentences — a title page, a half title, or a dedication.
|
||
Title,
|
||
/// Running text. The front matter is over.
|
||
Text,
|
||
}
|
||
|
||
/// One page, cleaned of what the converter added.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Page {
|
||
/// Page number as the marker gave it, or position when there was none.
|
||
pub number: u32,
|
||
/// Legible lines, in order, with markup and typesetting slugs removed.
|
||
pub lines: Vec<String>,
|
||
/// The page's text as it arrived, for the block readers.
|
||
pub text: String,
|
||
/// What the page is.
|
||
pub kind: Kind,
|
||
}
|
||
|
||
static PAGE_MARKER: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?im)^\s*<!--\s*page\s+(\d+)\s*-->\s*$").expect("a valid pattern")
|
||
});
|
||
static IMAGE: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"!\[[^\]]*\]\([^)]*\)").expect("a valid pattern"));
|
||
static HEADING: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"^#{1,6}\s+").expect("a valid pattern"));
|
||
/// `DESA01 19/9/06 6:02 PM Page i` — the typesetter's slug, printed by
|
||
/// accident and carried by every page of some books.
|
||
static SLUG: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?i)^[a-z]{2,10}[-_ ]?\d{1,6}\s+\d{1,2}/\d{1,2}/\d{2,4}.*page\s+[ivxlcdm\d]+\s*$")
|
||
.expect("a valid pattern")
|
||
});
|
||
/// A converted EPUB's navigation: `[Begin Reading](dedication.xhtml#ded)`,
|
||
/// and the page links some converters leave behind.
|
||
static NAVIGATION: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?i)^(<|\[[^\]]*\]\([^)]*\)|https?://|www\.|previous page|next page|page_\w+)")
|
||
.expect("a valid pattern")
|
||
});
|
||
static FOLIO: LazyLock<Regex> =
|
||
LazyLock::new(|| Regex::new(r"^[\d\s.\-–—ivxlcdmIVXLCDM|]+$").expect("a valid pattern"));
|
||
static BLANK_NOTE: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?i)^(this page (has been )?intentionally left blank|blank page)\.?$")
|
||
.expect("a valid pattern")
|
||
});
|
||
static LIST_CUE: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(
|
||
r"(?i)(available now|available soon|also by|also in this series|also available|\
|
||
other titles|other books|forthcoming|in the same series|praise for|advance praise|\
|
||
very short introductions|series editors?|titles in this series)",
|
||
)
|
||
.expect("a valid pattern")
|
||
});
|
||
static COPYRIGHT_CUE: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(
|
||
r"(?i)(all rights reserved|first published|first edition|copyright\s*[©®(]?|[©®]\s*\d{4}|\(c\)\s*\d{4}|\
|
||
library of congress|british library|catalog(u)?ing[- ]in[- ]publication|\
|
||
printed (in|and bound)|isbn|no part of this (book|publication)|\
|
||
typeset (in|by)|a cip (record|catalogue))",
|
||
)
|
||
.expect("a valid pattern")
|
||
});
|
||
static CONTENTS_CUE: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"(?i)^(table of )?contents$|\.{4,}\s*\d+\s*$").expect("a valid pattern")
|
||
});
|
||
/// A line of a series list: a work, then whoever wrote it, on one line.
|
||
static LISTED_WORK: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"^[A-Z][A-Za-z'’\-]*(?:[ ,][A-Z][A-Za-z'’\-]*){0,7}\s+[A-Z][a-z'’\-]+\s+[A-Z][A-Za-z'’\-]+\.?$")
|
||
.expect("a valid pattern")
|
||
});
|
||
/// A person's name alone on a line.
|
||
pub static PERSON: LazyLock<Regex> = LazyLock::new(|| {
|
||
Regex::new(r"^(?:[A-Z][A-Za-z'’\-\.]{0,20}\s+){1,3}[A-Z][A-Za-z'’\-]{1,25}\.?$")
|
||
.expect("a valid pattern")
|
||
});
|
||
|
||
/// Letters spread across a line by a PDF's glyph positions, put back together.
|
||
///
|
||
/// `A N E W T R A N SL AT ION` is one word to a reader and eleven tokens to a
|
||
/// parser, and the line it ruins is usually the title.
|
||
pub fn despread(line: &str) -> String {
|
||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||
if tokens.len() < 4 {
|
||
return line.to_string();
|
||
}
|
||
let singles = tokens.iter().filter(|t| t.chars().count() == 1).count();
|
||
if singles * 2 < tokens.len() {
|
||
return line.to_string();
|
||
}
|
||
let mut out: Vec<String> = Vec::new();
|
||
let mut run = String::new();
|
||
for token in tokens {
|
||
if token.chars().count() <= 3 && token.chars().all(char::is_alphabetic) {
|
||
run.push_str(token);
|
||
} else {
|
||
if !run.is_empty() {
|
||
out.push(std::mem::take(&mut run));
|
||
}
|
||
out.push(token.to_string());
|
||
}
|
||
}
|
||
if !run.is_empty() {
|
||
out.push(run);
|
||
}
|
||
out.join(" ")
|
||
}
|
||
|
||
/// Is the line so damaged that reading it would invent something?
|
||
///
|
||
/// A font whose glyphs map into the private use area comes out of extraction
|
||
/// as a line of unassigned code points. It looks like text to a length check
|
||
/// and is not text.
|
||
fn illegible(line: &str) -> bool {
|
||
let total = line.chars().filter(|c| !c.is_whitespace()).count();
|
||
if total == 0 {
|
||
return true;
|
||
}
|
||
let bad = line
|
||
.chars()
|
||
.filter(|c| {
|
||
let n = *c as u32;
|
||
(0xE000..=0xF8FF).contains(&n) || *c == '\u{FFFD}'
|
||
})
|
||
.count();
|
||
bad * 5 > total
|
||
}
|
||
|
||
fn clean_line(raw: &str) -> Option<String> {
|
||
let without_images = IMAGE.replace_all(raw, " ");
|
||
let stripped = HEADING.replace(without_images.trim(), "");
|
||
let line = despread(
|
||
stripped
|
||
.trim()
|
||
.trim_matches(|c| c == '*' || c == '_' || c == '|')
|
||
.trim(),
|
||
);
|
||
if line.is_empty()
|
||
|| illegible(&line)
|
||
|| SLUG.is_match(&line)
|
||
|| NAVIGATION.is_match(&line)
|
||
|| FOLIO.is_match(&line)
|
||
|| BLANK_NOTE.is_match(&line)
|
||
{
|
||
return None;
|
||
}
|
||
Some(line)
|
||
}
|
||
|
||
fn looks_like_prose(line: &str) -> bool {
|
||
let words = line.split_whitespace().count();
|
||
words > 14 || (words > 8 && line.trim_end().ends_with(['.', ';', ',']))
|
||
}
|
||
|
||
fn classify(lines: &[String], text: &str) -> Kind {
|
||
if lines.is_empty() {
|
||
return Kind::Blank;
|
||
}
|
||
if COPYRIGHT_CUE.find_iter(text).count() >= 2 {
|
||
return Kind::Copyright;
|
||
}
|
||
if LIST_CUE.is_match(text) && lines.len() >= 5 {
|
||
return Kind::SeriesList;
|
||
}
|
||
let listed = lines.iter().filter(|l| LISTED_WORK.is_match(l)).count();
|
||
if lines.len() >= 6 && listed * 5 >= lines.len() * 2 {
|
||
return Kind::SeriesList;
|
||
}
|
||
if lines.iter().take(3).any(|l| CONTENTS_CUE.is_match(l))
|
||
|| lines.iter().filter(|l| CONTENTS_CUE.is_match(l)).count() >= 4
|
||
{
|
||
return Kind::Contents;
|
||
}
|
||
if lines.len() <= 12 && !lines.iter().any(|l| looks_like_prose(l)) {
|
||
return Kind::Title;
|
||
}
|
||
Kind::Text
|
||
}
|
||
|
||
/// Cut the opening into pages and name each one.
|
||
///
|
||
/// Page markers are used where the converter left them. Where it did not —
|
||
/// an EPUB has no pages to mark — the blank lines are the only boundary
|
||
/// there is, and blocks stand in for pages. That is weaker, which is why a
|
||
/// book converted from EPUB usually comes back with nothing but its ISBN:
|
||
/// the metadata is in its OPF, and [`bookmeta`] reads that.
|
||
///
|
||
/// [`bookmeta`]: https://git.pumpin.online/funkpower/catena
|
||
pub fn split(text: &str, budget: usize) -> Vec<Page> {
|
||
let mut pages = Vec::new();
|
||
if PAGE_MARKER.is_match(text) {
|
||
let mut last_end = 0;
|
||
let mut number = 0u32;
|
||
for m in PAGE_MARKER.captures_iter(text) {
|
||
let whole = m.get(0).expect("group zero is always there");
|
||
if number > 0 || whole.start() > 0 {
|
||
push(&mut pages, number, &text[last_end..whole.start()]);
|
||
}
|
||
number = m[1].parse().unwrap_or(number + 1);
|
||
last_end = whole.end();
|
||
if pages.len() > budget {
|
||
break;
|
||
}
|
||
}
|
||
push(&mut pages, number, &text[last_end..]);
|
||
} else {
|
||
for (i, block) in text.split("\n\n").enumerate() {
|
||
push(&mut pages, i as u32 + 1, block);
|
||
if pages.len() > budget {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
pages.truncate(budget);
|
||
continuations(&mut pages);
|
||
pages
|
||
}
|
||
|
||
fn push(pages: &mut Vec<Page>, number: u32, text: &str) {
|
||
let lines: Vec<String> = text.lines().filter_map(clean_line).collect();
|
||
let kind = classify(&lines, text);
|
||
pages.push(Page {
|
||
number,
|
||
lines,
|
||
text: text.to_string(),
|
||
kind,
|
||
});
|
||
}
|
||
|
||
/// A series list runs over several pages and only the first one announces
|
||
/// itself. The pages after it are the same list and have to be refused too,
|
||
/// or the title taken off one of them is another book's.
|
||
fn continuations(pages: &mut [Page]) {
|
||
for i in 1..pages.len() {
|
||
if pages[i - 1].kind != Kind::SeriesList {
|
||
continue;
|
||
}
|
||
let page = &pages[i];
|
||
if page.kind == Kind::Blank || page.lines.len() < 6 {
|
||
continue;
|
||
}
|
||
// Only a page that is itself a list continues one. Counting bare
|
||
// names here would take a title page for advertising, because a
|
||
// title set in capitals and broken across lines is, line by line,
|
||
// the same shape as a person's name.
|
||
let listed = page
|
||
.lines
|
||
.iter()
|
||
.filter(|l| LISTED_WORK.is_match(l))
|
||
.count();
|
||
if page.lines.len() >= 6 && listed * 2 >= page.lines.len() {
|
||
pages[i].kind = Kind::SeriesList;
|
||
}
|
||
}
|
||
}
|