use regex::Regex;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
static EXPLICIT_CHAPTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^\s*(?:chapter|chapitre|kapitel|cap[ií]tulo|capitolo|hoofdstuk|ch\.?)\s*(?:(\d{1,2})|(?P<roman>[IVXLCDMivxlcdm]{1,7}))\b(?P<rest>.*)$",
)
.unwrap()
});
static HEADING_TAIL: LazyLock<Regex> =
LazyLock::new(|| Regex::new("^\\s*$|^\\s*[.:\\-—–]|^\\s+[A-ZÀ-Þ0-9\"“(]").unwrap());
static ROMAN_HEAD: LazyLock<Regex> =
LazyLock::new(|| Regex::new("^\\s*([IVXLCDM]+)\\s*[:.]\\s+[A-ZÀ-Þ0-9\"“(]").unwrap());
static LC_MD_ROMAN: LazyLock<Regex> =
LazyLock::new(|| Regex::new("^\\s*#{1,6}\\s+([ivxlcdm]+)\\s*[:.]\\s+[A-Za-zÀ-Þ\"“(]").unwrap());
static MD_HEADING_PREFIX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:#{1,6}|={1,6})\s+").unwrap());
static CN_CHAPTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\s*第\s*([0-90-9〇零一二两三四五六七八九十百千]+)\s*[章回卷节篇讲]").unwrap()
});
static MD_CN_HEADING: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^#{1,6}\s+第?\s*([0-90-9〇零一二两三四五六七八九十百千]+)\s*[·、.::章回卷节篇讲]",
)
.unwrap()
});
static TH_CHAPTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\s*(?:#{1,6}\s+)?(?:บทที่|ตอนที่|ภาคที่|บท|ตอน|ภาค)\s*([0-9๐-๙]+)\b").unwrap()
});
static KO_CHAPTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^\s*(?:#{1,6}\s+)?제\s*([0-9]+)\s*[장편절관](?:\s*의\s*[0-9]+)?(?:\s*$|[.:\-]|\s+\S)",
)
.unwrap()
});
static TOC_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?im)^\s*(?:table of contents|contents|índice|sumário|table des matières|inhaltsverzeichnis|indice|sommario|inhoudsopgave|目[ \t\u{3000}]*(?:录|錄|次))\s*$",
)
.unwrap()
});
static ATX_HEADING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(#{1,6}|={1,6})\s+(.+?)\s*#*$").unwrap());
static SETEXT_UNDERLINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:={2,}|-{2,})$").unwrap());
const ROMAN_VALUES: &[(char, u32)] = &[
('I', 1),
('V', 5),
('X', 10),
('L', 50),
('C', 100),
('D', 500),
('M', 1000),
];
fn int_to_roman(mut n: u32) -> String {
const TABLE: &[(u32, &str)] = &[
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
let mut out = String::new();
for (val, sym) in TABLE {
while n >= *val {
out.push_str(sym);
n -= val;
}
}
out
}
fn roman_to_int(s: &str) -> Option<u32> {
let upper = s.to_uppercase();
let mut total: i64 = 0;
let mut prev: i64 = 0;
for ch in upper.chars().rev() {
let v = ROMAN_VALUES.iter().find(|(c, _)| *c == ch)?.1 as i64;
total += if v < prev { -v } else { v };
prev = prev.max(v);
}
if total <= 0 || total > 200 {
return None;
}
let total = total as u32;
(int_to_roman(total) == upper).then_some(total)
}
fn cn_numeral_to_int(s: &str) -> Option<u32> {
let normalized: String = s
.chars()
.map(|c| match c as u32 {
0xFF10..=0xFF19 => char::from_u32(c as u32 - 0xFF10 + '0' as u32).unwrap(),
_ => c,
})
.collect();
if !normalized.is_empty() && normalized.chars().all(|c| c.is_ascii_digit()) {
let n: u32 = normalized.parse().ok()?;
return (1..=999).contains(&n).then_some(n);
}
let value_of = |c: char| -> Option<u32> {
Some(match c {
'〇' | '零' => 0,
'一' => 1,
'二' | '两' => 2,
'三' => 3,
'四' => 4,
'五' => 5,
'六' => 6,
'七' => 7,
'八' => 8,
'九' => 9,
_ => return None,
})
};
let unit_of = |c: char| -> Option<u32> {
Some(match c {
'十' => 10,
'百' => 100,
'千' => 1000,
_ => return None,
})
};
let mut section: u32 = 0;
let mut current: u32 = 0;
for ch in normalized.chars() {
match (value_of(ch), unit_of(ch)) {
(Some(v), _) => current = v,
(_, Some(u)) => {
section += if current == 0 { 1 } else { current } * u;
current = 0;
}
_ => return None,
}
}
let total = section + current;
(1..=999).contains(&total).then_some(total)
}
fn thai_digits_to_int(s: &str) -> Option<u32> {
let mapped: String = s
.chars()
.map(|c| match c as u32 {
0x0E50..=0x0E59 => char::from_u32(c as u32 - 0x0E50 + '0' as u32).unwrap(),
_ => c,
})
.collect();
mapped.parse().ok()
}
fn match_chapter_number(line: &str) -> Option<u32> {
let s = line.trim();
if s.chars().count() > 80 {
return None;
}
if let Some(c) = EXPLICIT_CHAPTER.captures(s) {
let rest = c.name("rest").map(|m| m.as_str()).unwrap_or("");
if HEADING_TAIL.is_match(rest) {
if let Some(arabic) = c.get(1) {
return arabic.as_str().parse().ok();
}
if let Some(roman) = c.name("roman") {
return roman_to_int(roman.as_str());
}
}
}
if let Some(c) = ROMAN_HEAD.captures(s).or_else(|| LC_MD_ROMAN.captures(s)) {
return roman_to_int(&c[1]);
}
if let Some(c) = CN_CHAPTER.captures(s).or_else(|| MD_CN_HEADING.captures(s)) {
return cn_numeral_to_int(&c[1]);
}
if let Some(c) = TH_CHAPTER.captures(s) {
return thai_digits_to_int(&c[1]);
}
if let Some(c) = KO_CHAPTER.captures(s) {
return c[1].parse().ok();
}
None
}
pub fn chapter_number(line: &str) -> Option<u32> {
if let Some(n) = match_chapter_number(line) {
return Some(n);
}
let s = line.trim();
let m = MD_HEADING_PREFIX.find(s)?;
match_chapter_number(&s[m.end()..])
}
fn structural_chapter_count(text: &str) -> usize {
let mut levels: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
let mut in_fence = false;
let mut prev = String::new();
for line in text.lines() {
let s = line.trim();
if s.starts_with("```") || s.starts_with("~~~") {
in_fence = !in_fence;
prev.clear();
continue;
}
if in_fence {
prev.clear();
continue;
}
if SETEXT_UNDERLINE.is_match(s)
&& !prev.is_empty()
&& !SETEXT_UNDERLINE.is_match(&prev)
&& s.chars().count() >= prev.chars().count()
{
let depth = if s.starts_with('=') { 1 } else { 2 };
levels.entry(depth).or_default().insert(prev.to_lowercase());
prev.clear();
continue;
}
if let Some(c) = ATX_HEADING.captures(s) {
let title = c[2].trim().to_lowercase();
let first_is_digit = title.chars().next().is_some_and(|ch| ch.is_ascii_digit());
let has_word = title.chars().any(|ch| ch.is_alphanumeric() || ch == '_');
if !title.is_empty() && !first_is_digit && has_word {
levels.entry(c[1].len()).or_default().insert(title);
}
prev.clear();
continue;
}
prev = s.to_string();
}
if levels.is_empty() {
return 0;
}
for titles in levels.values() {
if titles.len() >= 2 {
return titles.len();
}
}
levels.values().map(|t| t.len()).sum()
}
#[derive(Debug, Serialize)]
pub struct Structure {
pub chapters_detected: usize,
pub chapter_headings_sample: Vec<String>,
pub has_toc: bool,
}
pub fn detect(text: &str) -> Structure {
let mut headings: Vec<String> = Vec::new();
let mut numbers: BTreeSet<u32> = BTreeSet::new();
for line in text.lines() {
if let Some(n) = chapter_number(line) {
numbers.insert(n);
headings.push(line.trim().to_string());
}
}
let chapters_detected = if numbers.is_empty() {
structural_chapter_count(text)
} else {
numbers.len()
};
let head_end = text
.char_indices()
.nth(30_000)
.map_or(text.len(), |(i, _)| i);
let has_toc = TOC_PATTERN.is_match(&text[..head_end]);
headings.truncate(10);
Structure {
chapters_detected,
chapter_headings_sample: headings,
has_toc,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arabic_and_localized_chapter_words() {
assert_eq!(chapter_number("Chapter 5: Beginnings"), Some(5));
assert_eq!(chapter_number("Capítulo 3. Inicio"), Some(3));
assert_eq!(chapter_number("Kapitel 7 — Überblick"), Some(7));
assert_eq!(chapter_number("## Chapter 12"), Some(12));
}
#[test]
fn rejects_prose_cross_references() {
assert_eq!(chapter_number("Chapter 6 explores the tradeoffs"), None);
assert_eq!(chapter_number("Chapter 2025."), None);
}
#[test]
fn roman_numerals() {
assert_eq!(chapter_number("I: Loomings"), Some(1));
assert_eq!(chapter_number("II. The Carpet-Bag"), Some(2));
assert_eq!(chapter_number("## iv. introduction"), Some(4));
assert_eq!(chapter_number("vi: the editor"), None);
assert_eq!(roman_to_int("IIII"), None);
assert_eq!(roman_to_int("XIV"), Some(14));
}
#[test]
fn chinese_chapters() {
assert_eq!(chapter_number("第三章 引言"), Some(3));
assert_eq!(chapter_number("第十二节"), Some(12));
assert_eq!(chapter_number("第一讲"), Some(1));
assert_eq!(chapter_number("## 一 · 缘起"), Some(1));
assert_eq!(chapter_number("第1章"), Some(1));
assert_eq!(cn_numeral_to_int("二十三"), Some(23));
assert_eq!(cn_numeral_to_int("一百零八"), Some(108));
}
#[test]
fn thai_and_korean_chapters() {
assert_eq!(chapter_number("บทที่ 3"), Some(3));
assert_eq!(chapter_number("## บทที่ ๑๒"), Some(12));
assert_eq!(chapter_number("제1장 총칙"), Some(1));
assert_eq!(chapter_number("## 제4장 근로시간과 휴식"), Some(4));
assert_eq!(chapter_number("제5장에서"), None);
}
#[test]
fn counts_distinct_chapters_not_lines() {
let text = "Contents\nChapter 1: A\nChapter 2: B\n\nChapter 1: A\nbody\nChapter 2: B\n";
let s = detect(text);
assert_eq!(s.chapters_detected, 2);
assert!(s.has_toc);
}
#[test]
fn structural_fallback_picks_the_chapter_level() {
let text = "# Book Title\n\n## Alpha\nbody\n\n## Beta\nbody\n\n## Gamma\nbody\n";
assert_eq!(detect(text).chapters_detected, 3);
}
#[test]
fn ignores_headings_inside_code_fences() {
let text =
"# Book\n\n```\n## not a heading\n## also not\n```\n\n## Real One\n## Real Two\n";
assert_eq!(detect(text).chapters_detected, 2);
}
#[test]
fn detects_cjk_toc_header() {
assert!(detect("目 录\n\n第一章 开始\n").has_toc);
assert!(detect("Inhaltsverzeichnis\n\nKapitel 1: Start\n").has_toc);
}
}