Files
catena/crates/chunkmd/src/role.rs
T

347 lines
11 KiB
Rust
Raw Normal View History

//! 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<Vec<(Role, Regex)>> = 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<Regex> = 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<Regex> = 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<Boundaried> = LazyLock::new(|| {
bounded(&format!(
r"{WORD_START}(University|Press|Publish|Books|Verlag|Editions|& Co|Inc\.){WORD_END}"
))
});
static YEAR: LazyLock<Boundaried> =
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<Regex> = 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<Regex> =
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);
}
}