Files
catena/crates/colophon/src/cip.rs
T
funkpower ba8ec81735 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.
2026-09-21 09:28:26 -04:00

275 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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) }
}