//! Telling body text from the apparatus around it. //! //! Retrieval wants body text. An index, a table of contents and a //! bibliography all match a query beautifully and answer nothing — they are //! lists of the words a reader is searching for. Marking them lets a search //! exclude them while keeping them addressable for the consumers that do want //! them: citation extraction reads bibliographies on purpose. //! //! Two signals, in order. The chapter title, when the document's headings //! said what the section is; then the shape of the content, for back-matter //! buried inside a body chapter — common in EPUBs whose bibliography heading //! is not at the level the chapter map used. use std::sync::LazyLock; use corpus_core::Role; use fancy_regex::Regex as Boundaried; use regex::Regex; use textkit::pattern::{SPACE, WORD, WORD_END, WORD_START}; fn pattern(source: &str) -> Regex { Regex::new(source).expect("a valid pattern") } /// A pattern that needs Python's word boundary, which is a lookaround here /// because Rust's own `\b` counts combining marks as word characters. fn bounded(source: &str) -> Boundaried { Boundaried::new(source).expect("a valid pattern") } /// Chapter titles that give the section away, tried in this order. static BY_TITLE: LazyLock> = LazyLock::new(|| { vec![ ( Role::Bibliography, pattern(&format!( r"(?i)(bibliograph|works{SPACE}+cited|literature{SPACE}+cited|further{SPACE}+reading|^{SPACE}*references{SPACE}*$|^{SPACE}*(end)?notes{SPACE}*$)" )), ), ( Role::Index, pattern(&format!( r"(?i)^{SPACE}*index({SPACE}+of{SPACE}+{WORD}+)?{SPACE}*$" )), ), ( Role::Toc, pattern(&format!( r"(?i)^{SPACE}*(table{SPACE}+of{SPACE}+)?contents{SPACE}*$" )), ), ( Role::Frontmatter, pattern(&format!( r"(?i)(copyright|title{SPACE}*page|colophon|^{SPACE}*dedication{SPACE}*$|^{SPACE}*epigraph{SPACE}*$|about{SPACE}+the{SPACE}+(author|publisher)|acknowledg)" )), ), ] }); /// A standalone bibliography heading inside the chunk's own text — /// "Bibliography", "## Works Cited", "Selected Bibliography". static INTEXT_BIBLIO_HEADING: LazyLock = LazyLock::new(|| { pattern(&format!( r"(?im)^{SPACE}*#{{0,6}}{SPACE}*(select(?:ed)?{SPACE}+)?(bibliography|works{SPACE}+cited|references|sources){SPACE}*$" )) }); /// One bibliography entry: "Lastname, First…", or the dash run that stands /// for "the same author again". static BIBLIO_ENTRY: LazyLock = LazyLock::new(|| { pattern(&format!( r"^(?:[A-Z][\p{{L}}\p{{N}}_'’\-]+,{SPACE}+\S|[—–-]{{2,3}}{SPACE}*[.,])" )) }); /// The continuation of a wrapped entry: "New York: Springer, 2013." static BIBLIO_CONTINUATION: LazyLock = LazyLock::new(|| { bounded(&format!( r"{WORD_START}(University|Press|Publish|Books|Verlag|Editions|& Co|Inc\.){WORD_END}" )) }); static YEAR: LazyLock = LazyLock::new(|| bounded(&format!(r"{WORD_START}(1[5-9]\d{{2}}|20\d{{2}}){WORD_END}"))); /// An index line: a short lead, then a list of page numbers. static INDEX_LINE: LazyLock = LazyLock::new(|| { pattern(&format!( r"^.{{1,70}}?,{SPACE}+[ivxlc\d]{{1,4}}(?:[,–-]{SPACE}*[ivxlc\d]{{1,4}})*{SPACE}*$" )) }); /// A contents line: dot leaders to a page number, or a link to an internal /// anchor, which is what a converted EPUB's table of contents becomes. static TOC_LINE: LazyLock = LazyLock::new(|| pattern(&format!(r"(\.{{3,}}{SPACE}*\d{{1,4}}{SPACE}*$|\]\(#)"))); /// A wrapped entry long enough that its publisher name is probably a /// coincidence rather than a citation, in characters. const CONTINUATION_MAX: usize = 200; /// How many entry-shaped paragraphs make a bibliography, and what share of /// the paragraphs they have to be. const BIBLIO_MIN_ENTRIES: usize = 3; const BIBLIO_SHARE: f64 = 0.55; /// The same, for an in-text bibliography heading, which is evidence on its /// own and so needs less corroboration. const BIBLIO_MIN_ENTRIES_WITH_HEADING: usize = 2; /// How many list-shaped lines make an index or a table of contents. const LIST_MIN_LINES: usize = 5; const LIST_SHARE: f64 = 0.5; /// Classify one chunk. pub fn classify(text: &str, chapter: Option<&str>) -> Role { if let Some(chapter) = chapter.filter(|title| !title.is_empty()) { for (role, pattern) in BY_TITLE.iter() { if pattern.is_match(chapter) { return *role; } } } // Content-shape fallbacks. Cheap line scans; a chunk is a thousand // tokens at the outside. let lines: Vec<&str> = text .split('\n') .map(trim) .filter(|line| !line.is_empty()) .collect(); if lines.is_empty() { return Role::Body; } let paragraphs = textkit::paragraphs(text); let entryish = paragraphs .iter() .filter(|paragraph| looks_like_entry(paragraph)) .count(); let enough_entries = entryish >= BIBLIO_MIN_ENTRIES && entryish as f64 >= BIBLIO_SHARE * paragraphs.len() as f64; let headed = INTEXT_BIBLIO_HEADING.is_match(text) && entryish >= BIBLIO_MIN_ENTRIES_WITH_HEADING; if enough_entries || headed { return Role::Bibliography; } if dominates( lines .iter() .filter(|line| INDEX_LINE.is_match(line)) .count(), lines.len(), ) { return Role::Index; } if dominates( lines.iter().filter(|line| TOC_LINE.is_match(line)).count(), lines.len(), ) { return Role::Toc; } Role::Body } fn looks_like_entry(paragraph: &str) -> bool { let year = YEAR.is_match(paragraph).unwrap_or(false); let publisher = BIBLIO_CONTINUATION.is_match(paragraph).unwrap_or(false); if BIBLIO_ENTRY.is_match(paragraph) && (year || publisher) { return true; } publisher && year && paragraph.chars().count() < CONTINUATION_MAX } fn dominates(matching: usize, total: usize) -> bool { matching >= LIST_MIN_LINES && matching as f64 >= LIST_SHARE * total as f64 } /// Fix classification stragglers. /// /// A run of at most two body chunks sandwiched between chunks of the same /// non-body role inherits that role. This catches the boundary chunks of a /// bibliography or an index, whose own content shape is ambiguous because /// the entries there are URL-heavy, or split across the seam. pub fn smooth(roles: &mut [Role]) { const MAX_RUN: usize = 2; let mut at = 0; while at < roles.len() { if roles[at] != Role::Body { at += 1; continue; } let mut end = at; while end < roles.len() && roles[end] == Role::Body { end += 1; } let before = at.checked_sub(1).map(|before| roles[before]); let after = roles.get(end).copied(); if let Some(role) = before && end - at <= MAX_RUN && before == after && role != Role::Body { roles[at..end].fill(role); } at = end; } } fn trim(text: &str) -> &str { text.trim_matches(textkit::is_space) } #[cfg(test)] mod tests { use super::*; #[test] fn a_chapter_title_decides_when_it_can() { for (title, role) in [ ("Selected Bibliography", Role::Bibliography), ("Works Cited", Role::Bibliography), ("References", Role::Bibliography), ("Notes", Role::Bibliography), ("Endnotes", Role::Bibliography), ("Index", Role::Index), ("Index of Names", Role::Index), ("Table of Contents", Role::Toc), ("Contents", Role::Toc), ("Copyright", Role::Frontmatter), ("Acknowledgements", Role::Frontmatter), ("About the Author", Role::Frontmatter), ("III. The Second Estate", Role::Body), ] { assert_eq!( classify("ordinary text", Some(title)), role, "for {title:?}" ); } } #[test] fn a_title_that_merely_mentions_references_in_a_sentence_is_not_a_bibliography() { assert_eq!( classify("text", Some("What the references tell us")), Role::Body ); } #[test] fn entry_shaped_paragraphs_make_a_bibliography_without_a_title() { let text = "Arendt, Hannah. The Human Condition. Chicago: University Press, 1958.\n\n\ Bloch, Marc. Feudal Society. London: Routledge, 1961.\n\n\ Cassirer, Ernst. The Myth of the State. New Haven: Yale Press, 1946."; assert_eq!(classify(text, None), Role::Bibliography); } #[test] fn an_in_text_heading_lowers_the_bar_to_two_entries() { let text = "## Works Cited\n\n\ Arendt, Hannah. The Human Condition. Chicago: University Press, 1958.\n\n\ Bloch, Marc. Feudal Society. London: Routledge, 1961."; assert_eq!(classify(text, None), Role::Bibliography); } #[test] fn page_number_lists_make_an_index() { let text = "agriculture, 12, 34, 102\n\ bishops, 7\n\ charters, 88, 91\n\ demesne, 14-16\n\ estates, 3, 44\n\ fealty, 201"; assert_eq!(classify(text, None), Role::Index); } #[test] fn dot_leaders_make_a_table_of_contents() { let text = "One. The Setting......1\n\ Two. The Quarrel......22\n\ Three. The Break......45\n\ Four. The Peace......70\n\ Five. After......99"; assert_eq!(classify(text, None), Role::Toc); } #[test] fn ordinary_prose_is_body() { let text = "The second estate held its land in exchange for service, and \ the service was military. That much is agreed; what follows is not."; assert_eq!(classify(text, None), Role::Body); } #[test] fn empty_text_is_body() { assert_eq!(classify(" \n ", None), Role::Body); } #[test] fn a_short_body_run_inside_a_bibliography_is_absorbed() { let mut roles = vec![ Role::Bibliography, Role::Body, Role::Body, Role::Bibliography, ]; smooth(&mut roles); assert_eq!(roles, [Role::Bibliography; 4]); } #[test] fn a_long_body_run_is_left_alone() { let mut roles = vec![ Role::Bibliography, Role::Body, Role::Body, Role::Body, Role::Bibliography, ]; let before = roles.clone(); smooth(&mut roles); assert_eq!(roles, before); } #[test] fn a_run_between_different_roles_is_left_alone() { let mut roles = vec![Role::Bibliography, Role::Body, Role::Index]; let before = roles.clone(); smooth(&mut roles); assert_eq!(roles, before); } #[test] fn body_at_either_end_is_left_alone() { let mut roles = vec![Role::Body, Role::Index, Role::Body]; let before = roles.clone(); smooth(&mut roles); assert_eq!(roles, before); } }