mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
A catalogue's worst records are the ones whose file says nothing true about itself: a PDF titled `Untitled` by an author called `Oxford`, which is a publisher, from a filename that was never anything but a filename. What those books do have is pages. The title page carries the title as the publisher set it; the copyright page carries the imprint, the year, the ISBN, and in most English books since the 1970s the cataloguing block, which is a catalogue record a librarian already made, subject headings and all. So this reads the pages. Every field names the page it came from, and a book that printed nothing comes back empty with its fields in `unsettled` rather than filled with something plausible — an extraction that starts at chapter one has no front matter in it, and saying so is the answer. Two pages look like a title page and are not, and both are how a catalogue comes to name the wrong book. A series advertisement lists other titles in the shape a title page uses, over as many as four pages with only the first announcing itself. A translation's cataloguing block prints the original title in brackets above the real one. Both are found and refused. Three things the pages give that no file does: the subject headings a librarian assigned, an ISBN that exists only in print — which turns a book with no identifier into one `bibfetch` can look up — and a title in the case the publisher meant, because a half title prints unbroken what the title page breaks across two lines and sets in capitals. `--prompt` and `--merge` are citefind's: a question about exactly the fields the pages left unsettled, composed here and answered wherever the caller keeps a model, with the advertising taken out of what the model is shown. A field a model answered is marked `model`, so a record always says which of it was read and which was inferred. Measured against 600 books of the home corpus: the cataloguing block answered for 21% of them and agreed with the catalogue 89% of the time, most of the residue being the catalogue's error rather than this one's. Title pages answered for another 41%. The remaining 38% print nothing readable, and are what the prompt is for.
275 lines
9.9 KiB
Rust
275 lines
9.9 KiB
Rust
//! 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) }
|
||
}
|