//! 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, /// Subtitle, where the title statement had one. pub subtitle: Option, /// Authors, in reading order rather than filing order. pub authors: Vec, /// Editors, where the work is an edited one. pub editors: Vec, /// ISBNs printed inside the block. pub isbns: Vec, /// Subject headings the book was classified under. pub subjects: Vec, } impl Cip { fn is_empty(&self) -> bool { self.title.is_none() && self.authors.is_empty() && self.subjects.is_empty() } } static HEAD: LazyLock = 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 = 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 = LazyLock::new(|| { Regex::new( r"^(?P[A-Z][\p{L}'’\-]+(?:\s+[A-Z][\p{L}'’\-]+)?),\s+(?P[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 = LazyLock::new(|| Regex::new(r"^\\?\[.*\\?\]\.?$").expect("a valid pattern")); static LABEL: LazyLock = 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 = LazyLock::new(|| Regex::new(r"^\s*1\.\s+\S").expect("a valid pattern")); static SUBJECT_SPLIT: LazyLock = 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 = LazyLock::new(|| Regex::new(r"\s+[IVX]+\.\s+.*$").expect("a valid pattern")); static ISBN_LINE: LazyLock = 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 = LazyLock::new(|| Regex::new(r"\s+[/Il\\|]\s+").expect("a valid pattern")); static EDITION_TAIL: LazyLock = 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, Vec) { 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 { 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, Option, Vec, Vec) { 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 { 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 { 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 { 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) } }