mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +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:
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user