mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
colophon: the record a book prints in its own front matter
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.
This commit is contained in:
Generated
+14
@@ -395,6 +395,20 @@ dependencies = [
|
||||
"roff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colophon"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"catena-cli",
|
||||
"clap",
|
||||
"corpus-core",
|
||||
"regex",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
|
||||
@@ -34,6 +34,7 @@ langid = { path = "crates/langid", version = "0.1.0" }
|
||||
tocfind = { path = "crates/tocfind", version = "0.1.0" }
|
||||
booksniff = { path = "crates/booksniff", version = "0.1.0" }
|
||||
citefind = { path = "crates/citefind", version = "0.1.0" }
|
||||
colophon = { path = "crates/colophon", version = "0.1.0" }
|
||||
emb = { path = "crates/emb", version = "0.1.0" }
|
||||
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
@@ -25,6 +25,7 @@ Each tool installs by name, so you can take one without taking the rest.
|
||||
|---|---|
|
||||
| [`booksniff`](crates/booksniff/README.md) | What a file actually is, whatever its name claims |
|
||||
| [`bookmeta`](crates/bookmeta/README.md) | The metadata a file already carries |
|
||||
| [`colophon`](crates/colophon/README.md) | The record a book prints in its own front matter |
|
||||
| [`tocfind`](crates/tocfind/README.md) | A book's own table of contents |
|
||||
| [`langid`](crates/langid/README.md) | What language a document is in |
|
||||
| [`pdfgutter`](crates/pdfgutter/README.md) | Books scanned two pages to a sheet, split back into pages |
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "colophon"
|
||||
description = "The record a book prints in its own front matter."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
catena-cli.workspace = true
|
||||
clap.workspace = true
|
||||
corpus-core.workspace = true
|
||||
bibclean.workspace = true
|
||||
regex.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,119 @@
|
||||
# colophon
|
||||
|
||||
The record a book prints in its own front matter.
|
||||
|
||||
Part of [catena](../../README.md).
|
||||
|
||||
## What it does
|
||||
|
||||
A book states what it is twice before the text starts. The title page carries
|
||||
the title as the publisher set it and the name of whoever wrote it. The
|
||||
copyright page carries the imprint, the year, the ISBN, and — in most
|
||||
English-language books printed since the 1970s — the cataloguing block, which
|
||||
is a catalogue record a librarian made from the manuscript, subject headings
|
||||
and all.
|
||||
|
||||
So this reads the pages rather than the file. A PDF whose embedded title is
|
||||
`Untitled` and whose filename says `Oxford` opens on *The First World War: A
|
||||
Very Short Introduction*, by Michael Howard, and that is what comes back.
|
||||
|
||||
Every field names the page it was read off. Nothing is inferred: a scan that
|
||||
begins at the preface printed no title page, and the record of that is an
|
||||
empty one with every field listed in `unsettled`.
|
||||
|
||||
## When you would use it
|
||||
|
||||
- **Cataloguing scans and secondhand files**, where the embedded metadata is
|
||||
whatever the last tool to touch the file happened to write.
|
||||
- **Checking a catalogue against the books themselves.** A record that
|
||||
disagrees with the book's own title page is a record worth looking at.
|
||||
- **Recovering an ISBN** that only exists in print, which turns a book with
|
||||
no identifier into one [`bibfetch`](../bibfetch/README.md) can look up.
|
||||
- **Getting subject headings** that nobody had to invent, from the
|
||||
cataloguing block of the books that print one.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cargo install --git https://git.pumpin.online/funkpower/catena colophon
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
$ colophon first-world-war.md
|
||||
{"v":1,"path":"first-world-war.md","title":"The First World War","subtitle":"A Very Short Introduction","authors":["Michael Howard"],"editors":[],"isbns":["9780192803535"],"publisher":"Oxford University Press","place":"Oxford","year":2002,"subjects":[],"read_from":{"authors":"title_page","isbns":"copyright_page","publisher":"copyright_page","subtitle":"title_page","title":"title_page","year":"copyright_page"},"unsettled":["subjects"],"pages_read":16}
|
||||
```
|
||||
|
||||
A library at once, and the books that say nothing about themselves:
|
||||
|
||||
```sh
|
||||
colophon library/*.md | jq -r 'select(.title == null) | .path'
|
||||
```
|
||||
|
||||
An ISBN that only exists in print, looked up:
|
||||
|
||||
```sh
|
||||
colophon book.md | jq -r '.isbns[]' | idnorm | bibfetch --polite you@example.org
|
||||
```
|
||||
|
||||
## What it will not read
|
||||
|
||||
Two pages in a book's front matter look like a title page and are not, and
|
||||
both of them are how a catalogue ends up naming the wrong book. They are
|
||||
found and refused.
|
||||
|
||||
The **series advertisement** lists other titles in the same series in the
|
||||
same shape a title page uses — a work in capitals, a person beside it —
|
||||
sometimes for four pages, with only the first saying what it is. The
|
||||
**uniform title** in a translation's cataloguing block is the original
|
||||
title, printed in brackets above the real one as a filing heading.
|
||||
|
||||
## Asking a model about the rest
|
||||
|
||||
Roughly a third of books print nothing this can read: an extraction that
|
||||
starts at chapter one has no front matter in it at all. `--prompt` writes a
|
||||
question about exactly the fields left `unsettled`, and `--merge` folds the
|
||||
answer back in.
|
||||
|
||||
```sh
|
||||
colophon --prompt book.md | ollama run qwen3:8b > reply.json
|
||||
colophon --merge reply.json book.md
|
||||
```
|
||||
|
||||
Nothing is sent from here: the question goes to stdout, and the model is
|
||||
yours. What a model answers is marked `model` in `read_from`, so a record
|
||||
always says which of its fields were read off a page and which were
|
||||
answered.
|
||||
|
||||
The pages the question shows have had the advertising taken out of them
|
||||
first. Handing a model a series list is how a book becomes catalogued as
|
||||
whichever of two hundred other titles the model's eye fell on.
|
||||
|
||||
## Where this stops
|
||||
|
||||
It reports what the book printed, not what is true. `THE HUMAN CONDITION` is
|
||||
reported in the capitals it was set in, and straightening that is
|
||||
[`bibclean`](../bibclean/README.md)'s job. What a file's own metadata claims
|
||||
is [`bookmeta`](../bookmeta/README.md)'s. What a register says about the
|
||||
ISBN is [`bibfetch`](../bibfetch/README.md)'s. Which series a book belongs to
|
||||
is [`seriesof`](../seriesof/README.md)'s.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--pages <COUNT>` | How many pages in from the front to read. Default `16` |
|
||||
| `--prompt` | Write the question to put to a model about what the pages left unsettled |
|
||||
| `--merge <FILE>` | Fill in from a model's reply and write the completed record |
|
||||
| `--quiet` | Say nothing; the exit code is the answer |
|
||||
| `--man` | Write this page as a manual page, in roff |
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | The book printed something about itself |
|
||||
| `1` | It printed nothing this could read |
|
||||
| `65` | The input was not readable as text |
|
||||
| `74` | The file could not be read |
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The cataloguing-in-publication block, which is a catalogue record the
|
||||
//! publisher printed inside the book.
|
||||
//!
|
||||
//! Since the 1970s most English-language publishers have printed the record
|
||||
//! the Library of Congress made from the manuscript on the copyright page,
|
||||
//! and since 2013 they have printed it in labelled fields. Either way it
|
||||
//! names the author as a library files him, the title with its subtitle, the
|
||||
//! ISBN, and the subject headings the book was classified under — which is
|
||||
//! the one place in a book where its subjects are stated rather than
|
||||
//! inferred.
|
||||
//!
|
||||
//! It is a record, so it is read as one. Nothing here guesses.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// What the block said.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Cip {
|
||||
/// Title, without its subtitle.
|
||||
pub title: Option<String>,
|
||||
/// Subtitle, where the title statement had one.
|
||||
pub subtitle: Option<String>,
|
||||
/// Authors, in reading order rather than filing order.
|
||||
pub authors: Vec<String>,
|
||||
/// Editors, where the work is an edited one.
|
||||
pub editors: Vec<String>,
|
||||
/// ISBNs printed inside the block.
|
||||
pub isbns: Vec<String>,
|
||||
/// Subject headings the book was classified under.
|
||||
pub subjects: Vec<String>,
|
||||
}
|
||||
|
||||
impl Cip {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.title.is_none() && self.authors.is_empty() && self.subjects.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
static HEAD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)catalog(?:u)?ing[-\s]in[-\s]publication(?:\s+data)?").expect("a valid pattern")
|
||||
});
|
||||
/// `B105.C477D46 1991` / `126—dc20` — the call number, which ends the block.
|
||||
static CALL_NUMBER: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)^([A-Z]{1,3}\d{1,4}(\.\w+)*\s+\d{4}|\d{3}(\.\d+)?\s*[—–-]{1,2}\s*dc\d{2})")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// A name as a library files it: `Howard, Michael, 1922- author.`
|
||||
static FILED_NAME: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"^(?P<last>[A-Z][\p{L}'’\-]+(?:\s+[A-Z][\p{L}'’\-]+)?),\s+(?P<first>[A-Z][\p{L}'’\-\.]*(?:\s+[A-Z][\p{L}'’\-\.]*){0,3})\
|
||||
(?:,\s*(?:\d{4}\s*[-–]\s*\d{0,4}|b\.\s*\d{4}|d\.\s*\d{4}))?\
|
||||
(?:,?\s*(?:author|editor|translator|compiler)s?\.?)?\.?$",
|
||||
)
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// `[Sur la traduction. English]` — the uniform title of a translation,
|
||||
/// printed above the title it was translated into.
|
||||
static UNIFORM_TITLE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\\?\[.*\\?\]\.?$").expect("a valid pattern"));
|
||||
static LABEL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)^(names|title|other titles|description|identifiers|subjects|classification|uniform title)\s*:\s*(.*)$")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// `1. Consciousness. 2. Philosophy of mind. I. Title.`
|
||||
static NUMBERED_SUBJECTS: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\s*1\.\s+\S").expect("a valid pattern"));
|
||||
static SUBJECT_SPLIT: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\s*\b\d{1,2}\.\s+").expect("a valid pattern"));
|
||||
/// The added entries a subject list ends with: `I. Title. II. Series.`
|
||||
static ADDED_ENTRY: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\s+[IVX]+\.\s+.*$").expect("a valid pattern"));
|
||||
static ISBN_LINE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)isbn[:\s]*([0-9][0-9\s\-–]{8,20}[0-9Xx])").expect("a valid pattern")
|
||||
});
|
||||
/// The responsibility statement's separator, and what OCR makes of it.
|
||||
///
|
||||
/// A slash between the title and who wrote it is a solidus in a serif face,
|
||||
/// and page scanners read it as a capital I or a lowercase l about as often
|
||||
/// as they read it correctly.
|
||||
static RESPONSIBILITY: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\s+[/Il\\|]\s+").expect("a valid pattern"));
|
||||
static EDITION_TAIL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)\s*[.,]?\s*[—–-]{1,2}\s*(\d+(st|nd|rd|th)|first|second|third|new|rev\.?|revised)\b.*$")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
|
||||
fn tidy(s: &str) -> String {
|
||||
s.trim()
|
||||
.trim_matches(|c: char| c == '\\' || c == '"')
|
||||
.trim()
|
||||
.trim_end_matches(['.', ',', ';', ':', '/'])
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Split a statement of responsibility into who wrote and who edited.
|
||||
fn people(statement: &str) -> (Vec<String>, Vec<String>) {
|
||||
let mut authors = Vec::new();
|
||||
let mut editors = Vec::new();
|
||||
for part in statement.split(';') {
|
||||
let part = part.trim();
|
||||
let lower = part.to_lowercase();
|
||||
let (target, rest) = if let Some(rest) = lower.strip_prefix("edited by ") {
|
||||
(&mut editors, &part[part.len() - rest.len()..])
|
||||
} else if lower.starts_with("translated by ")
|
||||
|| lower.starts_with("with ")
|
||||
|| lower.starts_with("foreword by ")
|
||||
|| lower.starts_with("introduction by ")
|
||||
{
|
||||
continue;
|
||||
} else {
|
||||
(&mut authors, part)
|
||||
};
|
||||
for name in rest.split(" and ").flat_map(|n| n.split(',')) {
|
||||
let name = tidy(name);
|
||||
if name.len() >= 3 && name.split_whitespace().count() <= 5 {
|
||||
target.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
(authors, editors)
|
||||
}
|
||||
|
||||
fn filed_to_reading(line: &str) -> Option<String> {
|
||||
let caps = FILED_NAME.captures(line)?;
|
||||
Some(format!(
|
||||
"{} {}",
|
||||
tidy(caps.name("first")?.as_str()),
|
||||
tidy(caps.name("last")?.as_str())
|
||||
))
|
||||
}
|
||||
|
||||
fn split_title(statement: &str) -> (Option<String>, Option<String>, Vec<String>, Vec<String>) {
|
||||
let mut pieces = RESPONSIBILITY.splitn(statement, 2);
|
||||
let title_part = pieces.next().unwrap_or_default();
|
||||
let (authors, editors) = pieces.next().map(people).unwrap_or_default();
|
||||
let title_part = EDITION_TAIL.replace(title_part, "");
|
||||
let mut halves = title_part.splitn(2, " : ");
|
||||
let title = tidy(halves.next().unwrap_or_default());
|
||||
let subtitle = halves.next().map(tidy).filter(|s| !s.is_empty());
|
||||
(
|
||||
Some(title).filter(|t| t.len() >= 2),
|
||||
subtitle,
|
||||
authors,
|
||||
editors,
|
||||
)
|
||||
}
|
||||
|
||||
fn subjects_from(line: &str) -> Vec<String> {
|
||||
let trimmed = ADDED_ENTRY.replace(line, "");
|
||||
SUBJECT_SPLIT
|
||||
.split(&trimmed)
|
||||
.map(tidy)
|
||||
.filter(|s| s.len() >= 3 && s.split_whitespace().count() <= 12)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn isbns_from(line: &str) -> Vec<String> {
|
||||
ISBN_LINE
|
||||
.captures_iter(line)
|
||||
.filter_map(|c| {
|
||||
let digits = bibclean::isbn::digits(&c[1]);
|
||||
bibclean::isbn::valid(&digits).then_some(digits)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read the CIP block out of a book's front matter, if it printed one.
|
||||
pub fn read(front: &str) -> Option<Cip> {
|
||||
let start = HEAD.find(front)?.end();
|
||||
let mut block: Vec<&str> = Vec::new();
|
||||
for line in front[start..].lines().skip(1) {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
if block.len() > 2 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if CALL_NUMBER.is_match(line) {
|
||||
break;
|
||||
}
|
||||
block.push(line);
|
||||
if block.len() >= 24 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if block.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cip = Cip::default();
|
||||
let labelled = block.iter().any(|l| {
|
||||
LABEL
|
||||
.captures(l)
|
||||
.is_some_and(|c| matches!(c[1].to_lowercase().as_str(), "names" | "title" | "subjects"))
|
||||
});
|
||||
|
||||
if labelled {
|
||||
for line in &block {
|
||||
let Some(caps) = LABEL.captures(line) else {
|
||||
continue;
|
||||
};
|
||||
let value = caps[2].trim();
|
||||
match caps[1].to_lowercase().as_str() {
|
||||
"names" => {
|
||||
for name in value.split('|') {
|
||||
if let Some(read) = filed_to_reading(tidy(name).as_str()) {
|
||||
cip.authors.push(read);
|
||||
}
|
||||
}
|
||||
}
|
||||
"title" => {
|
||||
let (title, subtitle, authors, editors) = split_title(value);
|
||||
cip.title = title;
|
||||
cip.subtitle = subtitle;
|
||||
if cip.authors.is_empty() {
|
||||
cip.authors = authors;
|
||||
}
|
||||
cip.editors = editors;
|
||||
}
|
||||
"identifiers" => cip.isbns.extend(isbns_from(value)),
|
||||
"subjects" => {
|
||||
let value = value.trim_start_matches("LCSH:").trim();
|
||||
for subject in value.split('|') {
|
||||
let subject = tidy(subject);
|
||||
if subject.len() >= 3 {
|
||||
cip.subjects.push(subject);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut seen_title = false;
|
||||
for line in &block {
|
||||
if UNIFORM_TITLE.is_match(line) {
|
||||
continue;
|
||||
}
|
||||
if !seen_title {
|
||||
if let Some(name) = filed_to_reading(line) {
|
||||
cip.authors.push(name);
|
||||
continue;
|
||||
}
|
||||
if ISBN_LINE.is_match(line) || line.to_lowercase().starts_with("isbn") {
|
||||
cip.isbns.extend(isbns_from(line));
|
||||
continue;
|
||||
}
|
||||
if RESPONSIBILITY.is_match(line) || line.contains(" : ") {
|
||||
let (title, subtitle, authors, editors) = split_title(line);
|
||||
cip.title = title;
|
||||
cip.subtitle = subtitle;
|
||||
if cip.authors.is_empty() {
|
||||
cip.authors = authors;
|
||||
}
|
||||
cip.editors = editors;
|
||||
seen_title = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if NUMBERED_SUBJECTS.is_match(line) {
|
||||
cip.subjects = subjects_from(line);
|
||||
}
|
||||
cip.isbns.extend(isbns_from(line));
|
||||
}
|
||||
}
|
||||
|
||||
cip.isbns.sort();
|
||||
cip.isbns.dedup();
|
||||
if cip.is_empty() { None } else { Some(cip) }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The copyright page: who published it, where, when, and under what ISBN.
|
||||
//!
|
||||
//! Everything on this page is formula. The same dozen sentences appear in
|
||||
//! every book printed in English, which is what makes them worth matching
|
||||
//! exactly rather than interpreting.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// What the copyright page stated.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Imprint {
|
||||
/// The press.
|
||||
pub publisher: Option<String>,
|
||||
/// Where it publishes from, where the page named one place.
|
||||
pub place: Option<String>,
|
||||
/// The year of this edition, preferring first publication.
|
||||
pub year: Option<u16>,
|
||||
/// Every checksum-valid ISBN printed on the page, in the order printed.
|
||||
pub isbns: Vec<String>,
|
||||
}
|
||||
|
||||
static PUBLISHED_BY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?im)^\s*(?:first\s+)?published(?:\s+(?:in\s+)?(?:1[5-9]|20)\d{2})?\s+by\s+(?P<who>[^,.\n]{3,60})")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
static PRESS_LINE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?im)^\s*(?P<who>[A-Z][^\n]{2,58}?(?:University Press|Press|Publishers|Publishing|Books|Verlag))\s*[,.]?\s*$")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
static FIRST_PUBLISHED: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)first published[^.\n]{0,40}?((?:1[5-9]|20)\d{2})").expect("a valid pattern")
|
||||
});
|
||||
static COPYRIGHT_YEAR: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)(?:©|\(c\)|copyright)\s*(?:by\s+)?((?:1[5-9]|20)\d{2})")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
static ISBN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)isbn(?:[-\s]?1[03])?[:\s]*([0-9][0-9\s\-–—]{8,24}[0-9Xx])")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// `Oxford : Oxford University Press, 2007` — the labelled CIP's description,
|
||||
/// which states all three at once.
|
||||
static DESCRIPTION: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?im)^\s*Description\s*:\s*(?P<place>[^:|\n]{2,40}?)\s*:\s*(?P<who>[^,|\n]{3,60}?)\s*,\s*(?P<year>(?:1[5-9]|20)\d{2})")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
|
||||
fn tidy(s: &str) -> String {
|
||||
s.trim()
|
||||
.trim_end_matches([',', '.', ';'])
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Every checksum-valid ISBN announced in a piece of text.
|
||||
pub fn isbns(text: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for caps in ISBN.captures_iter(text) {
|
||||
let digits = bibclean::isbn::digits(&caps[1]);
|
||||
if matches!(digits.len(), 10 | 13)
|
||||
&& bibclean::isbn::valid(&digits)
|
||||
&& !out.contains(&digits)
|
||||
{
|
||||
out.push(digits);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the copyright page.
|
||||
pub fn read(text: &str) -> Imprint {
|
||||
let mut out = Imprint {
|
||||
isbns: isbns(text),
|
||||
..Imprint::default()
|
||||
};
|
||||
|
||||
if let Some(caps) = DESCRIPTION.captures(text) {
|
||||
out.place = Some(tidy(&caps["place"]));
|
||||
out.publisher = Some(tidy(&caps["who"]));
|
||||
out.year = caps["year"].parse().ok();
|
||||
return out;
|
||||
}
|
||||
|
||||
out.publisher = PUBLISHED_BY
|
||||
.captures(text)
|
||||
.or_else(|| PRESS_LINE.captures(text))
|
||||
.map(|caps| tidy(&caps["who"]));
|
||||
out.year = FIRST_PUBLISHED
|
||||
.captures(text)
|
||||
.or_else(|| COPYRIGHT_YEAR.captures(text))
|
||||
.and_then(|caps| caps[1].parse().ok());
|
||||
out.place = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| crate::title::PLACE.is_match(line) && line.split_whitespace().count() <= 4)
|
||||
.and_then(|line| {
|
||||
crate::title::PLACE
|
||||
.find(line)
|
||||
.map(|m| tidy(m.as_str().trim_end_matches(|c: char| !c.is_alphabetic())))
|
||||
});
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! The record a book prints in its own front matter.
|
||||
//!
|
||||
//! A book states its own title twice before the text starts, and states it
|
||||
//! properly: on the title page, set by the publisher, and again in the
|
||||
//! cataloguing block on the copyright page, where a librarian has already
|
||||
//! decided what the title is, who the author is, and what the book is about.
|
||||
//! A file's embedded metadata may say `Untitled` and a filename may say
|
||||
//! `Oxford`, but the printed page says *The First World War: A Very Short
|
||||
//! Introduction*, by Michael Howard.
|
||||
//!
|
||||
//! So this reads the pages, not the file. What comes back is what the book
|
||||
//! printed, with the page it was printed on named for every field, and
|
||||
//! nothing at all where the book printed nothing — a scan that starts at the
|
||||
//! preface has no title page, and the honest record of that is an empty one.
|
||||
//!
|
||||
//! Two traps are worth naming, because both of them are how a reader that
|
||||
//! does not know better ends up cataloguing the wrong book:
|
||||
//!
|
||||
//! - **The series advertisement.** A paperback's front matter lists two
|
||||
//! hundred other books in the same series, in the same shape a title page
|
||||
//! uses: a work in capitals, a person beside it. A title taken off that
|
||||
//! page belongs to somebody else. Those pages are found and refused,
|
||||
//! including the ones that continue the list without repeating its heading.
|
||||
//! - **The uniform title.** A translation's cataloguing block prints the
|
||||
//! original title in brackets above the real one. It is a heading, not the
|
||||
//! title of the book in your hand.
|
||||
//!
|
||||
//! What this does not do is decide whether what the book printed is any
|
||||
//! good. `THE HUMAN CONDITION` is reported in the capitals it was set in;
|
||||
//! straightening that is [`bibclean`]'s job. Nor does it look anything up:
|
||||
//! an ISBN read off the copyright page is a key, and [`bibfetch`] is what
|
||||
//! turns a key into a catalogue record.
|
||||
//!
|
||||
//! [`bibclean`]: https://git.pumpin.online/funkpower/catena
|
||||
//! [`bibfetch`]: https://git.pumpin.online/funkpower/catena
|
||||
|
||||
pub mod cip;
|
||||
pub mod imprint;
|
||||
pub mod page;
|
||||
pub mod prompt;
|
||||
pub mod title;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How many pages in from the front to read.
|
||||
///
|
||||
/// Front matter that has not finished by here is not front matter. Reading
|
||||
/// further costs nothing but finds chapter headings, which look enough like
|
||||
/// titles to be believed.
|
||||
pub const PAGES: usize = 16;
|
||||
|
||||
/// Which page a field was read off.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Source {
|
||||
/// The cataloguing-in-publication block: a catalogue record, printed.
|
||||
Cip,
|
||||
/// The title page, as the publisher set it.
|
||||
TitlePage,
|
||||
/// The copyright page, outside the cataloguing block.
|
||||
CopyrightPage,
|
||||
/// A model, answering about the pages this could not read. Never
|
||||
/// written by a read: only [`prompt::merge`] puts it on a record.
|
||||
Model,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// What it is called.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Source::Cip => "cip",
|
||||
Source::TitlePage => "title_page",
|
||||
Source::CopyrightPage => "copyright_page",
|
||||
Source::Model => "model",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one book printed about itself.
|
||||
///
|
||||
/// Every field is written on every record, empty where the book said
|
||||
/// nothing, and every field that was read names the page it came from in
|
||||
/// `read_from`. The fields a catalogue needs and this could not find are
|
||||
/// listed in `unsettled`, so an absence is legible rather than inferred from
|
||||
/// a missing key.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Printed {
|
||||
/// Record version.
|
||||
#[serde(default = "version")]
|
||||
pub v: u32,
|
||||
/// The file this was read from, where it was read from one.
|
||||
pub path: Option<String>,
|
||||
/// The title, without its subtitle.
|
||||
pub title: Option<String>,
|
||||
/// The subtitle, where the page set one.
|
||||
pub subtitle: Option<String>,
|
||||
/// Whoever the book says wrote it, in reading order.
|
||||
pub authors: Vec<String>,
|
||||
/// Whoever the book says edited it.
|
||||
pub editors: Vec<String>,
|
||||
/// Checksum-valid ISBNs printed in the front matter, as printed.
|
||||
pub isbns: Vec<String>,
|
||||
/// The press.
|
||||
pub publisher: Option<String>,
|
||||
/// Where it publishes from.
|
||||
pub place: Option<String>,
|
||||
/// The year of publication, preferring first publication.
|
||||
pub year: Option<u16>,
|
||||
/// The subject headings the cataloguing block filed it under.
|
||||
pub subjects: Vec<String>,
|
||||
/// Which page each field was read off.
|
||||
pub read_from: BTreeMap<String, Source>,
|
||||
/// The fields a catalogue wants that this book did not print.
|
||||
pub unsettled: Vec<String>,
|
||||
/// How many pages were read.
|
||||
pub pages_read: usize,
|
||||
}
|
||||
|
||||
fn version() -> u32 {
|
||||
corpus_core::V
|
||||
}
|
||||
|
||||
impl corpus_core::Record for Printed {
|
||||
fn version(&self) -> u32 {
|
||||
self.v
|
||||
}
|
||||
}
|
||||
|
||||
impl Printed {
|
||||
/// Did the book print anything at all?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.title.is_none()
|
||||
&& self.authors.is_empty()
|
||||
&& self.isbns.is_empty()
|
||||
&& self.subjects.is_empty()
|
||||
}
|
||||
|
||||
fn note(&mut self, field: &str, source: Source) {
|
||||
self.read_from.insert(field.to_string(), source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which page holds the title, given where the copyright page is.
|
||||
///
|
||||
/// The title page is the last page before the copyright page that is a page
|
||||
/// of set lines rather than a list or a contents — publishers have put it
|
||||
/// there for two centuries. Where there is no copyright page to count back
|
||||
/// from, the first such page before the text starts is all there is to go on.
|
||||
fn title_page(pages: &[page::Page], copyright: Option<usize>) -> Option<usize> {
|
||||
let usable = |i: usize| pages.get(i).is_some_and(|p| p.kind == page::Kind::Title);
|
||||
if let Some(c) = copyright {
|
||||
if let Some(found) = (0..c).rev().find(|i| usable(*i)) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
pages
|
||||
.iter()
|
||||
.position(|p| p.kind == page::Kind::Text)
|
||||
.map_or_else(
|
||||
|| (0..pages.len()).find(|i| usable(*i)),
|
||||
|end| (0..end).find(|i| usable(*i)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The longest printing of the same title anywhere in the front matter.
|
||||
///
|
||||
/// A title page breaks its title across lines and sets it in capitals; the
|
||||
/// half title two leaves earlier prints the whole of it on one line, with
|
||||
/// its subtitle, in the case the publisher meant. They are the same title,
|
||||
/// and the unbroken one is the one to keep.
|
||||
fn longest_printing<'a>(pages: &'a [page::Page], title: &str) -> Option<&'a str> {
|
||||
let wanted = squash(title);
|
||||
if wanted.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
pages
|
||||
.iter()
|
||||
.filter(|p| matches!(p.kind, page::Kind::Title | page::Kind::Copyright))
|
||||
.flat_map(|p| p.lines.iter())
|
||||
.filter(|line| {
|
||||
let candidate = squash(line);
|
||||
candidate.starts_with(&wanted) && candidate.len() > wanted.len()
|
||||
})
|
||||
.min_by_key(|line| line.len())
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// A string reduced to the letters and digits in it, for comparing two
|
||||
/// printings of one title across a change of case and punctuation.
|
||||
fn squash(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read a book's front matter.
|
||||
///
|
||||
/// `budget` is how many pages in to read; [`PAGES`] is the sensible default.
|
||||
pub fn read(text: &str, budget: usize) -> Printed {
|
||||
let pages = page::split(text, budget);
|
||||
let mut out = Printed {
|
||||
v: version(),
|
||||
path: None,
|
||||
title: None,
|
||||
subtitle: None,
|
||||
authors: Vec::new(),
|
||||
editors: Vec::new(),
|
||||
isbns: Vec::new(),
|
||||
publisher: None,
|
||||
place: None,
|
||||
year: None,
|
||||
subjects: Vec::new(),
|
||||
read_from: BTreeMap::new(),
|
||||
unsettled: Vec::new(),
|
||||
pages_read: pages.len(),
|
||||
};
|
||||
|
||||
let front: String = pages
|
||||
.iter()
|
||||
.map(|p| p.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let copyright = pages.iter().position(|p| p.kind == page::Kind::Copyright);
|
||||
|
||||
// The cataloguing block is a record somebody else already made, so it
|
||||
// outranks the title page wherever the two disagree.
|
||||
if let Some(found) = cip::read(&front) {
|
||||
if let Some(t) = found.title {
|
||||
out.title = Some(t);
|
||||
out.note("title", Source::Cip);
|
||||
}
|
||||
if let Some(s) = found.subtitle {
|
||||
out.subtitle = Some(s);
|
||||
out.note("subtitle", Source::Cip);
|
||||
}
|
||||
if !found.authors.is_empty() {
|
||||
out.authors = found.authors;
|
||||
out.note("authors", Source::Cip);
|
||||
}
|
||||
if !found.editors.is_empty() {
|
||||
out.editors = found.editors;
|
||||
out.note("editors", Source::Cip);
|
||||
}
|
||||
if !found.subjects.is_empty() {
|
||||
out.subjects = found.subjects;
|
||||
out.note("subjects", Source::Cip);
|
||||
}
|
||||
out.isbns = found.isbns;
|
||||
if !out.isbns.is_empty() {
|
||||
out.note("isbns", Source::Cip);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(page) = title_page(&pages, copyright).and_then(|i| title::read(&pages[i].lines)) {
|
||||
if out.title.is_none() {
|
||||
out.title = match page
|
||||
.title
|
||||
.as_deref()
|
||||
.and_then(|t| longest_printing(&pages, t))
|
||||
{
|
||||
Some(longer) => {
|
||||
let (title, subtitle) = longer
|
||||
.split_once(" : ")
|
||||
.or_else(|| longer.split_once(": "))
|
||||
.map_or((longer.to_string(), None), |(head, tail)| {
|
||||
(head.trim().to_string(), Some(tail.trim().to_string()))
|
||||
});
|
||||
out.subtitle = subtitle;
|
||||
Some(title)
|
||||
}
|
||||
None => page.title,
|
||||
};
|
||||
if out.title.is_some() {
|
||||
out.note("title", Source::TitlePage);
|
||||
}
|
||||
if out.subtitle.is_none() && page.subtitle.is_some() {
|
||||
out.subtitle = page.subtitle;
|
||||
out.note("subtitle", Source::TitlePage);
|
||||
}
|
||||
}
|
||||
if out.authors.is_empty() && !page.authors.is_empty() {
|
||||
out.authors = page.authors;
|
||||
out.note("authors", Source::TitlePage);
|
||||
}
|
||||
if out.editors.is_empty() && !page.editors.is_empty() {
|
||||
out.editors = page.editors;
|
||||
out.note("editors", Source::TitlePage);
|
||||
}
|
||||
}
|
||||
|
||||
let imprint_text = copyright.map_or(front.as_str(), |i| pages[i].text.as_str());
|
||||
let stated = imprint::read(imprint_text);
|
||||
for isbn in stated.isbns {
|
||||
if !out.isbns.contains(&isbn) {
|
||||
out.isbns.push(isbn);
|
||||
out.read_from
|
||||
.entry("isbns".into())
|
||||
.or_insert(Source::CopyrightPage);
|
||||
}
|
||||
}
|
||||
if let Some(publisher) = stated.publisher {
|
||||
out.publisher = Some(publisher);
|
||||
out.note("publisher", Source::CopyrightPage);
|
||||
}
|
||||
if let Some(place) = stated.place {
|
||||
out.place = Some(place);
|
||||
out.note("place", Source::CopyrightPage);
|
||||
}
|
||||
if let Some(year) = stated.year {
|
||||
out.year = Some(year);
|
||||
out.note("year", Source::CopyrightPage);
|
||||
}
|
||||
|
||||
for (field, missing) in [
|
||||
("title", out.title.is_none()),
|
||||
("authors", out.authors.is_empty() && out.editors.is_empty()),
|
||||
("isbns", out.isbns.is_empty()),
|
||||
("publisher", out.publisher.is_none()),
|
||||
("year", out.year.is_none()),
|
||||
("subjects", out.subjects.is_empty()),
|
||||
] {
|
||||
if missing {
|
||||
out.unsettled.push(field.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The record a book prints in its own front matter.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use corpus_core::exit;
|
||||
|
||||
/// Read what a book prints about itself in its opening pages.
|
||||
///
|
||||
/// The input is the book as markdown — the whole of it, or as much of the
|
||||
/// front as you care to pass. The title page and the cataloguing block on
|
||||
/// the copyright page are read, and what they state comes back as one
|
||||
/// record, with the page each field was read off named in `read_from`.
|
||||
///
|
||||
/// Nothing is inferred. A scan that starts at chapter one prints no title
|
||||
/// page, and what comes back then is a record with nothing in it and every
|
||||
/// field named in `unsettled` — which is the answer, and a usable one.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about,
|
||||
after_help = "\
|
||||
Exit codes: 0 the book printed something about itself, 1 it printed nothing
|
||||
this could read, 65 the input was not readable as text, 74 the file could not
|
||||
be read.
|
||||
|
||||
Example:
|
||||
|
||||
colophon book.md | jq -r '[.title, (.authors|join(\", \"))] | @tsv'
|
||||
colophon *.md | jq -r 'select(.unsettled | index(\"title\")) | .path'
|
||||
colophon book.md | jq -r '.isbns[]' | idnorm | bibfetch"
|
||||
)]
|
||||
struct Args {
|
||||
/// The books to read. Standard input when none are given.
|
||||
paths: Vec<PathBuf>,
|
||||
|
||||
/// Say nothing; the exit code is the answer.
|
||||
#[arg(long, short)]
|
||||
quiet: bool,
|
||||
|
||||
/// Write the question to put to a model about the fields these pages
|
||||
/// did not settle, instead of the record. Nothing is sent: it goes to
|
||||
/// stdout. One book at a time, because the pages differ.
|
||||
#[arg(long)]
|
||||
prompt: bool,
|
||||
|
||||
/// Fill in what the pages did not settle from a model's reply, written
|
||||
/// earlier by `--prompt`, and write the completed record.
|
||||
#[arg(long, value_name = "FILE")]
|
||||
merge: Option<PathBuf>,
|
||||
|
||||
/// How many pages in from the front to read.
|
||||
#[arg(long, value_name = "COUNT", default_value_t = colophon::PAGES)]
|
||||
pages: usize,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Args = match catena_cli::parse() {
|
||||
Ok(args) => args,
|
||||
Err(code) => return code,
|
||||
};
|
||||
if args.pages == 0 {
|
||||
eprintln!("colophon: --pages 0 would read nothing");
|
||||
return ExitCode::from(exit::USAGE);
|
||||
}
|
||||
if args.prompt && args.merge.is_some() {
|
||||
eprintln!("colophon: --prompt composes a question and --merge answers one");
|
||||
return ExitCode::from(exit::USAGE);
|
||||
}
|
||||
if (args.prompt || args.merge.is_some()) && args.paths.len() > 1 {
|
||||
eprintln!("colophon: one book at a time for --prompt and --merge");
|
||||
return ExitCode::from(exit::USAGE);
|
||||
}
|
||||
|
||||
let inputs: Vec<Option<PathBuf>> = if args.paths.is_empty() {
|
||||
vec![None]
|
||||
} else {
|
||||
args.paths.iter().cloned().map(Some).collect()
|
||||
};
|
||||
|
||||
let mut records = Vec::with_capacity(inputs.len());
|
||||
let mut last_text = String::new();
|
||||
for path in inputs {
|
||||
let text = match catena_cli::read_text(path.as_deref()) {
|
||||
Ok(text) => text,
|
||||
Err((code, why)) => {
|
||||
eprintln!("colophon: {why}");
|
||||
return ExitCode::from(code);
|
||||
}
|
||||
};
|
||||
let mut record = colophon::read(&text, args.pages);
|
||||
record.path = path.as_deref().map(|p| p.display().to_string());
|
||||
records.push(record);
|
||||
last_text = text;
|
||||
}
|
||||
|
||||
if args.prompt {
|
||||
let record = &records[0];
|
||||
return match colophon::prompt::compose(record, &last_text, args.pages) {
|
||||
// Nothing left to ask is not a failure, and a question nobody
|
||||
// needs would waste a model's time.
|
||||
None => ExitCode::from(exit::NO),
|
||||
Some(question) => catena_cli::text("colophon", &question),
|
||||
};
|
||||
}
|
||||
if let Some(path) = args.merge.as_deref() {
|
||||
let reply = match std::fs::read_to_string(path) {
|
||||
Ok(reply) => reply,
|
||||
Err(e) => {
|
||||
eprintln!("colophon: {}: {e}", path.display());
|
||||
return ExitCode::from(exit::IO);
|
||||
}
|
||||
};
|
||||
match colophon::prompt::merge(&mut records[0], &reply) {
|
||||
Err(why) => {
|
||||
eprintln!("colophon: {why}");
|
||||
return ExitCode::from(exit::DATA);
|
||||
}
|
||||
Ok(0) => eprintln!("colophon: the reply filled nothing in"),
|
||||
Ok(filled) => eprintln!("colophon: {filled} field(s) filled in from the reply"),
|
||||
}
|
||||
}
|
||||
|
||||
let anything = records.iter().any(|r| !r.is_empty());
|
||||
if args.quiet {
|
||||
return ExitCode::from(if anything { exit::OK } else { exit::NO });
|
||||
}
|
||||
if !anything {
|
||||
eprintln!("colophon: nothing in these pages says what the book is");
|
||||
for record in &records {
|
||||
let _ = catena_cli::stream("colophon", std::slice::from_ref(record));
|
||||
}
|
||||
return ExitCode::from(exit::NO);
|
||||
}
|
||||
catena_cli::stream("colophon", &records)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! A question to put to a model, for what the pages did not settle.
|
||||
//!
|
||||
//! Nothing here sends anything. The prompt is composed and printed, and what
|
||||
//! happens to it is the caller's business — their model, their machine,
|
||||
//! their GPU. The tool stays what every tool here is: the same output for
|
||||
//! the same input, on every machine, with no network and nothing installed.
|
||||
//!
|
||||
//! What the model is shown is not the front matter as it arrived. The pages
|
||||
//! that advertise other books are taken out first, because handing them to a
|
||||
//! model is how a book ends up catalogued as whichever of two hundred other
|
||||
//! titles the model's eye fell on. It is shown the pages that describe this
|
||||
//! book, told what was already read off them, and asked only for the rest.
|
||||
|
||||
use crate::{Printed, Source, page};
|
||||
|
||||
/// The version of the prompt below.
|
||||
///
|
||||
/// Bumped when the wording changes in a way that could change an answer, so
|
||||
/// a caller keeping results can tell which prompt produced them.
|
||||
pub const PROMPT_VERSION: u32 = 1;
|
||||
|
||||
/// How much of the front matter to show. Enough for a title page, a
|
||||
/// copyright page and a contents; not enough to wander into the text.
|
||||
const SHOWN: usize = 6000;
|
||||
|
||||
/// The fields a model may be asked for.
|
||||
const ASKABLE: &[&str] = &["title", "authors", "publisher", "year", "subjects"];
|
||||
|
||||
fn known(record: &Printed) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(title) = &record.title {
|
||||
out.push_str(&format!("title already read: {title}\n"));
|
||||
}
|
||||
if !record.authors.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"authors already read: {}\n",
|
||||
record.authors.join("; ")
|
||||
));
|
||||
}
|
||||
if !record.isbns.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"isbn printed in the book: {}\n",
|
||||
record.isbns.join(", ")
|
||||
));
|
||||
}
|
||||
if let Some(publisher) = &record.publisher {
|
||||
out.push_str(&format!("publisher already read: {publisher}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The front matter with the advertising taken out.
|
||||
pub fn readable_pages(text: &str, budget: usize) -> String {
|
||||
let mut out = String::new();
|
||||
for page in page::split(text, budget) {
|
||||
if matches!(page.kind, page::Kind::SeriesList | page::Kind::Blank) || page.lines.is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!("--- page {}\n", page.number));
|
||||
for line in &page.lines {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push('\n');
|
||||
if out.len() >= SHOWN {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.truncate(SHOWN);
|
||||
out
|
||||
}
|
||||
|
||||
/// Compose the question, or decline to.
|
||||
///
|
||||
/// `None` when the pages settled everything a model could help with, because
|
||||
/// there is nothing to ask and a call to a model would be waste.
|
||||
pub fn compose(record: &Printed, text: &str, budget: usize) -> Option<String> {
|
||||
let wanted: Vec<&str> = ASKABLE
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|field| record.unsettled.iter().any(|f| f == field))
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pages = readable_pages(text, budget);
|
||||
if pages.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = String::from(
|
||||
"Below are the opening pages of one book, as text. Pages advertising \
|
||||
other books have already been removed.\n\n",
|
||||
);
|
||||
out.push_str(&pages);
|
||||
out.push('\n');
|
||||
let already = known(record);
|
||||
if !already.is_empty() {
|
||||
out.push_str("Read off these pages already:\n");
|
||||
out.push_str(&already);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("wanted: {}\n\n", wanted.join(", ")));
|
||||
out.push_str(
|
||||
"Reply with one JSON object and nothing else, using only the keys \
|
||||
listed as wanted. `title` is the book's own title, without its \
|
||||
subtitle. `authors` is a list of the people who wrote it, as the \
|
||||
book writes their names. `publisher` is the press. `year` is a \
|
||||
four-digit number. `subjects` is a list of at most five subject \
|
||||
headings for what the book is about.\n\n\
|
||||
Answer only from the pages above. Use null for anything they do not \
|
||||
settle: a guess is worse than an absence, because nobody downstream \
|
||||
can tell them apart. A title listed beside other titles is another \
|
||||
book's, not this one's.\n",
|
||||
);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Fill in what the pages did not settle from a model's reply.
|
||||
///
|
||||
/// Only the fields that were unsettled are filled, and each one is marked as
|
||||
/// having come from a model — a record that cannot say which of its fields
|
||||
/// were read and which were answered is a record nobody can apply a policy
|
||||
/// to. Returns how many fields were filled.
|
||||
pub fn merge(record: &mut Printed, reply: &str) -> Result<usize, String> {
|
||||
let start = reply.find('{').ok_or("the reply holds no JSON object")?;
|
||||
let end = reply.rfind('}').ok_or("the reply holds no JSON object")?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&reply[start..=end])
|
||||
.map_err(|e| format!("the reply is not JSON: {e}"))?;
|
||||
let object = parsed.as_object().ok_or("the reply is not a JSON object")?;
|
||||
|
||||
let mut filled = 0;
|
||||
let wanted = |record: &Printed, field: &str| record.unsettled.iter().any(|f| f == field);
|
||||
|
||||
if wanted(record, "title") {
|
||||
if let Some(title) = object.get("title").and_then(|v| v.as_str()) {
|
||||
let title = title.trim();
|
||||
if !title.is_empty() {
|
||||
record.title = Some(title.to_string());
|
||||
record.read_from.insert("title".into(), Source::Model);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if wanted(record, "publisher") {
|
||||
if let Some(publisher) = object.get("publisher").and_then(|v| v.as_str()) {
|
||||
let publisher = publisher.trim();
|
||||
if !publisher.is_empty() {
|
||||
record.publisher = Some(publisher.to_string());
|
||||
record.read_from.insert("publisher".into(), Source::Model);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if wanted(record, "year") {
|
||||
if let Some(year) = object.get("year").and_then(serde_json::Value::as_u64) {
|
||||
if (1400..=2200).contains(&year) {
|
||||
record.year = Some(year as u16);
|
||||
record.read_from.insert("year".into(), Source::Model);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (field, into) in [("authors", true), ("subjects", false)] {
|
||||
if !wanted(record, field) {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = object.get(field).and_then(|v| v.as_array()) else {
|
||||
continue;
|
||||
};
|
||||
let values: Vec<String> = list
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if values.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if into {
|
||||
record.authors = values;
|
||||
} else {
|
||||
record.subjects = values;
|
||||
}
|
||||
record.read_from.insert(field.into(), Source::Model);
|
||||
filled += 1;
|
||||
}
|
||||
|
||||
record
|
||||
.unsettled
|
||||
.retain(|field| !record.read_from.contains_key(field));
|
||||
Ok(filled)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! The title page, read as the block it is.
|
||||
//!
|
||||
//! A title page is laid out rather than written: the title, sometimes broken
|
||||
//! across lines by nothing but the width of the page, then whoever wrote it,
|
||||
//! then the publisher at the foot. Reading it is a matter of knowing where
|
||||
//! the title stops, which is at the first line that is a person or a press.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::page::PERSON;
|
||||
|
||||
/// What a title page printed.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TitlePage {
|
||||
/// The title, with lines the typesetter broke put back together.
|
||||
pub title: Option<String>,
|
||||
/// What followed it, where the page gave a subtitle.
|
||||
pub subtitle: Option<String>,
|
||||
/// Whoever it says wrote the book.
|
||||
pub authors: Vec<String>,
|
||||
/// Whoever it says edited it.
|
||||
pub editors: Vec<String>,
|
||||
}
|
||||
|
||||
/// `(1714)`, `, 1922-2019` — when a title page dates its author.
|
||||
static LIFESPAN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"\s*[(,]\s*(?:1[0-9]|20)\d{2}\s*(?:[-\u{2013}]\s*(?:(?:1[0-9]|20)\d{2})?)?\s*\)?\s*$",
|
||||
)
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// `350 BC`, `1971` — a date alone on a line, which is not a title.
|
||||
static BARE_DATE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)^\d{1,4}\s*(bc|ad|bce|ce)?\.?$").expect("a valid pattern"));
|
||||
/// `Edited by`, with whoever it was set on the line beneath.
|
||||
static ROLE_ONLY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)^(edited|translated|written|selected|compiled)\s+by:?$")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
static BYLINE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)^(?P<role>by|edited by|written by|translated by|selected by|compiled by)\s+(?P<who>.{3,90})$")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// A press, an imprint, or the line that announces one. The foot of the page.
|
||||
pub static IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)\b(university press|[a-z]+ press\b|publish(?:er|ers|ing)\b|publications\b|\
|
||||
verlag\b|éditions\b|editions\b|books? (?:ltd|inc|group)\b|\
|
||||
routledge|blackwell|wiley|springer|palgrave|macmillan|penguin|\
|
||||
random house|harpercollins|w\.? ?w\.? norton|doubleday|bloomsbury|eerdmans|\
|
||||
brill\b|sage\b|vintage\b|knopf|faber|crossway|zondervan|clarendon|\
|
||||
foundation\b|institute\b|society\b|trust\b|distributed by|\
|
||||
cover (design|art|photograph)|jacket design|thank you for buying|\
|
||||
typeset|designed by|illustrated by|printed by|\
|
||||
copyright|all rights reserved|printed in)\b",
|
||||
)
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// A city a publisher puts on a title page, alone on its line.
|
||||
pub static PLACE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)^(oxford|cambridge|london|new york|new haven|chicago|boston|princeton|\
|
||||
berlin|paris|munich|rome|madrid|grand rapids|edinburgh|dublin|toronto|sydney|\
|
||||
melbourne|minneapolis|philadelphia|san francisco|los angeles|leiden|amsterdam|\
|
||||
copenhagen|stockholm|moscow|beijing|tokyo|delhi|nairobi)\b[\s,·•|and]*",
|
||||
)
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
/// Words that name a part of a book rather than the book.
|
||||
static STRUCTURAL: &[&str] = &[
|
||||
"abbreviations",
|
||||
"about the author",
|
||||
"acknowledgements",
|
||||
"acknowledgments",
|
||||
"afterword",
|
||||
"appendix",
|
||||
"bibliography",
|
||||
"contents",
|
||||
"dedication",
|
||||
"epigraph",
|
||||
"epilogue",
|
||||
"foreword",
|
||||
"frontispiece",
|
||||
"glossary",
|
||||
"half title",
|
||||
"illustrations",
|
||||
"index",
|
||||
"introduction",
|
||||
"list of contributors",
|
||||
"list of illustrations",
|
||||
"maps",
|
||||
"notes",
|
||||
"postscript",
|
||||
"preface",
|
||||
"prologue",
|
||||
"table of contents",
|
||||
"title page",
|
||||
"translator's note",
|
||||
];
|
||||
|
||||
fn is_structural(line: &str) -> bool {
|
||||
let norm: String = line
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
STRUCTURAL.binary_search(&norm.as_str()).is_ok()
|
||||
}
|
||||
|
||||
fn people(who: &str) -> Vec<String> {
|
||||
who.split(&[';', '&'][..])
|
||||
.flat_map(|part| part.split(" and "))
|
||||
.map(|name| name.trim().trim_end_matches(['.', ',']).trim().to_string())
|
||||
.filter(|name| name.len() >= 3 && name.split_whitespace().count() <= 5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Words a line ends on when the typesetter broke a title across it.
|
||||
///
|
||||
/// `Esotericism in` / `Western Culture` is one title set on two lines, and
|
||||
/// nothing but the dangling preposition says so.
|
||||
const CONTINUES: &[&str] = &[
|
||||
"a", "an", "and", "at", "between", "by", "for", "from", "in", "into", "of", "on", "or", "the",
|
||||
"to", "under", "with", "without",
|
||||
];
|
||||
|
||||
/// Endings no surname has and every abstract noun does.
|
||||
///
|
||||
/// Two capitalised words look exactly like a name — `Martin Cohen` and
|
||||
/// `Political Philosophy` are the same shape — and on a title page the
|
||||
/// second one is the top half of the title. English morphology is what
|
||||
/// tells them apart without a dictionary of names to consult.
|
||||
const NOT_A_NAME: &[&str] = &[
|
||||
"ance", "ence", "ics", "ism", "ity", "ment", "ness", "ography", "ology", "osophy", "sion",
|
||||
"tion", "ture",
|
||||
];
|
||||
|
||||
fn could_be_a_person(line: &str) -> bool {
|
||||
let line = LIFESPAN.replace(line, "");
|
||||
let line = line.trim().trim_end_matches('.');
|
||||
if !PERSON.is_match(line) {
|
||||
return false;
|
||||
}
|
||||
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||
if tokens.len() > 4 {
|
||||
return false;
|
||||
}
|
||||
!tokens.iter().any(|token| {
|
||||
let word = token.trim_end_matches('.').to_lowercase();
|
||||
word.len() > 4 && NOT_A_NAME.iter().any(|end| word.ends_with(end))
|
||||
})
|
||||
}
|
||||
|
||||
fn wraps(line: &str) -> bool {
|
||||
if line.ends_with('-') {
|
||||
return true;
|
||||
}
|
||||
line.rsplit_once(char::is_whitespace)
|
||||
.is_some_and(|(_, last)| CONTINUES.contains(&last.to_lowercase().as_str()))
|
||||
}
|
||||
|
||||
/// Put a title the page broke across lines back together.
|
||||
fn unwrap_lines(block: &[&str]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in block {
|
||||
match out.last_mut() {
|
||||
Some(previous) if wraps(previous) => {
|
||||
if previous.ends_with('-') {
|
||||
previous.pop();
|
||||
previous.push_str(line);
|
||||
} else {
|
||||
previous.push(' ');
|
||||
previous.push_str(line);
|
||||
}
|
||||
}
|
||||
_ => out.push((*line).to_string()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a title page, or decline to.
|
||||
///
|
||||
/// The page is taken as a block that ends at the imprint — a press, a place,
|
||||
/// or the first line of running prose. What is left is the title, whoever
|
||||
/// wrote it, and nothing else, because that is all a title page holds.
|
||||
///
|
||||
/// Where the byline sits is the publisher's choice and both choices are
|
||||
/// common, so it is looked for at the top of the block and at the bottom and
|
||||
/// nowhere between: a name in the middle of a title page is a title that
|
||||
/// happens to be two capitalised words.
|
||||
///
|
||||
/// Declines when the block is empty or runs past five lines, at which point
|
||||
/// this is a page of something else and a title taken off it would be a
|
||||
/// guess with a page number attached.
|
||||
pub fn read(lines: &[String]) -> Option<TitlePage> {
|
||||
let mut out = TitlePage::default();
|
||||
let mut block: Vec<&str> = Vec::new();
|
||||
let mut waiting: Option<String> = None;
|
||||
|
||||
for line in lines.iter().take(14) {
|
||||
// `Edited by` on its own line, with the names set under it.
|
||||
if let Some(role) = waiting.take() {
|
||||
let who = people(line);
|
||||
if !who.is_empty() {
|
||||
if role.starts_with("edited") {
|
||||
out.editors.extend(who);
|
||||
} else if !role.starts_with("translated") {
|
||||
out.authors.extend(who);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if is_structural(line) || BARE_DATE.is_match(line) {
|
||||
if block.is_empty() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Some(caps) = BYLINE.captures(line) {
|
||||
let role = caps["role"].to_lowercase();
|
||||
let who = people(&caps["who"]);
|
||||
if who.is_empty() {
|
||||
waiting = Some(role);
|
||||
continue;
|
||||
}
|
||||
if role.starts_with("edited") {
|
||||
out.editors.extend(who);
|
||||
} else if !role.starts_with("translated") {
|
||||
out.authors.extend(who);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ROLE_ONLY.is_match(line) {
|
||||
waiting = Some(line.to_lowercase());
|
||||
continue;
|
||||
}
|
||||
if IMPRINT.is_match(line) || PLACE.is_match(line) {
|
||||
if block.is_empty() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
block.push(line);
|
||||
if block.len() > 5 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if block.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The byline is above the title or below it, never inside it.
|
||||
if out.authors.is_empty() && out.editors.is_empty() && block.len() > 1 {
|
||||
if could_be_a_person(block[0]) {
|
||||
out.authors = people(block[0]);
|
||||
block.remove(0);
|
||||
} else if could_be_a_person(block[block.len() - 1]) {
|
||||
out.authors = people(block[block.len() - 1]);
|
||||
block.pop();
|
||||
}
|
||||
}
|
||||
|
||||
let units = unwrap_lines(&block);
|
||||
let mut title = units.first()?.clone();
|
||||
let mut subtitle = units.get(1).cloned();
|
||||
let split = title
|
||||
.split_once(" : ")
|
||||
.or_else(|| title.split_once(": "))
|
||||
.map(|(head, tail)| (head.trim().to_string(), tail.trim().to_string()));
|
||||
if let Some((head, tail)) = split {
|
||||
title = head;
|
||||
subtitle = Some(tail);
|
||||
}
|
||||
|
||||
// A fragment is not a title. Two letters of one, left behind by a font
|
||||
// the extractor could not read, is worse than nothing: it is a title
|
||||
// page's authority attached to noise.
|
||||
out.title = Some(title).filter(|t| {
|
||||
t.chars().filter(|c| c.is_alphabetic()).count() >= 4
|
||||
&& t.split_whitespace()
|
||||
.any(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 3)
|
||||
});
|
||||
out.subtitle = subtitle.filter(|s| s.len() >= 2);
|
||||
out.title.as_ref()?;
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! What real front matter looks like, and what has to come off it.
|
||||
//!
|
||||
//! Every case here is a shape taken from a book that was catalogued wrongly
|
||||
//! without it.
|
||||
|
||||
use colophon::{PAGES, Source, read};
|
||||
|
||||
/// A paperback series prints two hundred other titles before its own, in the
|
||||
/// same shape a title page uses. Catalogued off that page, this book is by
|
||||
/// whichever author the reader's eye landed on.
|
||||
#[test]
|
||||
fn a_series_advertisement_is_not_the_title_page() {
|
||||
let front = "\
|
||||
<!-- Page 2 -->
|
||||
|
||||
The First World War: A Very Short Introduction
|
||||
|
||||
<!-- Page 3 -->
|
||||
|
||||
Very Short Introductions available now:
|
||||
ANARCHISM Colin Ward
|
||||
ANCIENT EGYPT Ian Shaw
|
||||
ARISTOTLE Jonathan Barnes
|
||||
THE ELEMENTS Philip Ball
|
||||
CONSCIOUSNESS Susan Blackmore
|
||||
COSMOLOGY Peter Coles
|
||||
|
||||
<!-- Page 4 -->
|
||||
|
||||
CLASSICS Mary Beard
|
||||
DADA AND SURREALISM David Hopkins
|
||||
DARWIN Jonathan Howard
|
||||
THE DEAD SEA SCROLLS Timothy Lim
|
||||
RUSSIAN LITERATURE Catriona Kelly
|
||||
|
||||
<!-- Page 6 -->
|
||||
|
||||
Michael Howard
|
||||
|
||||
THE FIRST
|
||||
WORLD WAR
|
||||
|
||||
A Very Short Introduction
|
||||
|
||||
<!-- Page 7 -->
|
||||
|
||||
Great Clarendon Street, Oxford ox2 6dp
|
||||
Oxford University Press is a department of the University of Oxford.
|
||||
First published 2002
|
||||
All rights reserved
|
||||
ISBN 978-0-19-280353-5
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
// The title page breaks the title across two lines and sets it in
|
||||
// capitals; the half title prints the whole of it, cased as the
|
||||
// publisher meant. Both are this book, and the unbroken one is kept.
|
||||
assert_eq!(found.title.as_deref(), Some("The First World War"));
|
||||
assert_eq!(found.subtitle.as_deref(), Some("A Very Short Introduction"));
|
||||
assert_eq!(found.authors, vec!["Michael Howard"]);
|
||||
assert_eq!(found.isbns, vec!["9780192803535"]);
|
||||
assert_eq!(found.year, Some(2002));
|
||||
assert_eq!(found.read_from.get("title"), Some(&Source::TitlePage));
|
||||
}
|
||||
|
||||
/// The cataloguing block is a catalogue record somebody else already made.
|
||||
/// The solidus between the title and who wrote it is read as a capital I by
|
||||
/// every scanner ever built.
|
||||
#[test]
|
||||
fn a_cataloguing_block_outranks_the_page_it_is_printed_on() {
|
||||
let front = "\
|
||||
<!-- Page 4 -->
|
||||
|
||||
Consciousness Explained
|
||||
|
||||
Daniel C. Dennett
|
||||
|
||||
<!-- Page 5 -->
|
||||
|
||||
Copyright © 1991 by Daniel C. Dennett
|
||||
All rights reserved
|
||||
First published 1991
|
||||
Library of Congress Cataloging-in-Publication Data
|
||||
Dennett, Daniel C.
|
||||
Consciousness explained I Daniel C. Dennett. — 1st ed.
|
||||
p. cm.
|
||||
Includes bibliographical references and index.
|
||||
ISBN 0-316-18065-3
|
||||
1. Consciousness. 2. Philosophy of mind. I. Title.
|
||||
B105.C477D46 1991
|
||||
126—dc20
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert_eq!(found.title.as_deref(), Some("Consciousness explained"));
|
||||
assert_eq!(found.authors, vec!["Daniel C. Dennett"]);
|
||||
assert_eq!(found.read_from.get("title"), Some(&Source::Cip));
|
||||
assert_eq!(found.subjects, vec!["Consciousness", "Philosophy of mind"]);
|
||||
assert_eq!(found.isbns, vec!["0316180653"]);
|
||||
}
|
||||
|
||||
/// Since 2013 the block comes in labelled fields, and the subject headings
|
||||
/// with it — the one place a book states what it is about.
|
||||
#[test]
|
||||
fn the_labelled_block_gives_its_subjects() {
|
||||
let front = "\
|
||||
<!-- Page 4 -->
|
||||
|
||||
Library of Congress Cataloging-in-Publication Data
|
||||
Names: Howard, Michael, 1922- author.
|
||||
Title: The First World War : a very short introduction / Michael Howard.
|
||||
Description: Oxford : Oxford University Press, 2007.
|
||||
Identifiers: ISBN 9780192803535
|
||||
Subjects: LCSH: World War, 1914-1918. | Europe--History--1871-1918.
|
||||
All rights reserved
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert_eq!(found.title.as_deref(), Some("The First World War"));
|
||||
assert_eq!(found.subtitle.as_deref(), Some("a very short introduction"));
|
||||
assert_eq!(found.authors, vec!["Michael Howard"]);
|
||||
assert_eq!(found.publisher.as_deref(), Some("Oxford University Press"));
|
||||
assert_eq!(found.place.as_deref(), Some("Oxford"));
|
||||
assert_eq!(found.year, Some(2007));
|
||||
assert_eq!(found.subjects.len(), 2);
|
||||
}
|
||||
|
||||
/// A translation's block prints the original title in brackets above the
|
||||
/// real one. It is a heading, not the book in your hand.
|
||||
#[test]
|
||||
fn a_uniform_title_is_not_the_title() {
|
||||
let front = "\
|
||||
<!-- Page 4 -->
|
||||
|
||||
All rights reserved
|
||||
Library of Congress Cataloging-in-Publication Data
|
||||
Ricoeur, Paul.
|
||||
[Sur la traduction. English]
|
||||
On translation / Paul Ricoeur ; translated by Eileen Brennan.
|
||||
ISBN 0-415-35777-X
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert_eq!(found.title.as_deref(), Some("On translation"));
|
||||
assert_eq!(found.authors, vec!["Paul Ricoeur"]);
|
||||
}
|
||||
|
||||
/// A title broken across two lines by the width of the page, with the second
|
||||
/// half looking exactly like a person's name.
|
||||
#[test]
|
||||
fn a_wrapped_title_is_one_title() {
|
||||
let front = "\
|
||||
<!-- Page 4 -->
|
||||
|
||||
Esotericism in
|
||||
Western Culture
|
||||
|
||||
Counter-Normativity and
|
||||
Rejected Knowledge
|
||||
|
||||
Wouter J. Hanegraaff
|
||||
|
||||
<!-- Page 5 -->
|
||||
|
||||
BLOOMSBURY ACADEMIC
|
||||
First published 2012
|
||||
All rights reserved
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert_eq!(
|
||||
found.title.as_deref(),
|
||||
Some("Esotericism in Western Culture")
|
||||
);
|
||||
assert_eq!(
|
||||
found.subtitle.as_deref(),
|
||||
Some("Counter-Normativity and Rejected Knowledge")
|
||||
);
|
||||
assert_eq!(found.authors, vec!["Wouter J. Hanegraaff"]);
|
||||
}
|
||||
|
||||
/// Two capitalised words are a name or they are half a title, and English
|
||||
/// morphology is the only thing on the page that says which.
|
||||
#[test]
|
||||
fn an_abstract_noun_is_not_a_person() {
|
||||
let front = "\
|
||||
<!-- Page 1 -->
|
||||
|
||||
Political Philosophy
|
||||
From Plato to Mao
|
||||
Martin Cohen
|
||||
Pluto Press
|
||||
LONDON
|
||||
|
||||
<!-- Page 2 -->
|
||||
|
||||
First published 2001 by Pluto Press
|
||||
All rights reserved
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert_eq!(found.title.as_deref(), Some("Political Philosophy"));
|
||||
assert_eq!(found.subtitle.as_deref(), Some("From Plato to Mao"));
|
||||
assert_eq!(found.authors, vec!["Martin Cohen"]);
|
||||
assert_eq!(found.publisher.as_deref(), Some("Pluto Press"));
|
||||
}
|
||||
|
||||
/// A scan that starts at the preface prints no title page, and the honest
|
||||
/// record of that is an empty one.
|
||||
#[test]
|
||||
fn a_book_that_says_nothing_about_itself_is_reported_as_saying_nothing() {
|
||||
let front = "\
|
||||
<!-- Page 2 -->
|
||||
|
||||
Preface
|
||||
|
||||
Some mental states have qualitative phenomenal characters, and since the
|
||||
dawn of Behaviorism some philosophers have doubted the ability of any
|
||||
physicalist account of the mental to accommodate this fact.
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert!(found.is_empty());
|
||||
assert!(found.title.is_none());
|
||||
for field in ["title", "authors", "isbns", "publisher", "year", "subjects"] {
|
||||
assert!(
|
||||
found.unsettled.iter().any(|f| f == field),
|
||||
"{field} should be unsettled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An ISBN whose check digit disagrees is a mistyped one, and reporting it
|
||||
/// would hand the next stage a key that opens nothing.
|
||||
#[test]
|
||||
fn an_isbn_that_fails_its_check_digit_is_not_reported() {
|
||||
let front = "\
|
||||
<!-- Page 3 -->
|
||||
|
||||
All rights reserved
|
||||
First published 1999
|
||||
ISBN 978-0-19-280353-9
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
assert!(found.isbns.is_empty());
|
||||
}
|
||||
|
||||
/// What a model answers is filled in only where the pages said nothing, and
|
||||
/// is marked as having come from a model.
|
||||
#[test]
|
||||
fn a_reply_fills_only_what_the_pages_left() {
|
||||
let front = "\
|
||||
<!-- Page 4 -->
|
||||
|
||||
Esotericism in
|
||||
Western Culture
|
||||
|
||||
Wouter J. Hanegraaff
|
||||
|
||||
<!-- Page 5 -->
|
||||
|
||||
All rights reserved
|
||||
First published 2012
|
||||
";
|
||||
let mut found = read(front, PAGES);
|
||||
assert!(found.subjects.is_empty());
|
||||
let filled = colophon::prompt::merge(
|
||||
&mut found,
|
||||
"{\"title\": \"Something Else\", \"subjects\": [\"Occultism\", \"Europe\"]}",
|
||||
)
|
||||
.expect("a readable reply");
|
||||
assert_eq!(filled, 1);
|
||||
assert_eq!(
|
||||
found.title.as_deref(),
|
||||
Some("Esotericism in Western Culture")
|
||||
);
|
||||
assert_eq!(found.subjects, vec!["Occultism", "Europe"]);
|
||||
assert_eq!(found.read_from.get("subjects"), Some(&Source::Model));
|
||||
assert!(!found.unsettled.iter().any(|f| f == "subjects"));
|
||||
}
|
||||
|
||||
/// The question a model is asked never includes the pages that would
|
||||
/// mislead it.
|
||||
#[test]
|
||||
fn the_question_leaves_out_the_advertising() {
|
||||
let front = "\
|
||||
<!-- Page 3 -->
|
||||
|
||||
Very Short Introductions available now:
|
||||
ANARCHISM Colin Ward
|
||||
THE ELEMENTS Philip Ball
|
||||
CONSCIOUSNESS Susan Blackmore
|
||||
ARISTOTLE Jonathan Barnes
|
||||
COSMOLOGY Peter Coles
|
||||
|
||||
<!-- Page 6 -->
|
||||
|
||||
Some pages of a preface that names no title at all, running on for long
|
||||
enough that the classifier takes it for text rather than a title page.
|
||||
";
|
||||
let found = read(front, PAGES);
|
||||
let question = colophon::prompt::compose(&found, front, PAGES).expect("a question");
|
||||
assert!(!question.contains("THE ELEMENTS"));
|
||||
assert!(!question.contains("Philip Ball"));
|
||||
assert!(question.contains("wanted: title"));
|
||||
}
|
||||
Reference in New Issue
Block a user