use std::collections::HashSet;
use crate::types::ProjectedLine;
pub(super) const LIST_INDENT_STEP_PT: f32 = 12.0;
const BULLET_CHARS: &[char] = &['•', '·', '◦', '▪', '▸', '▶', '●', '○', '■', '□', '\u{f0b7}'];
pub(super) fn parse_list_marker(text: &str) -> Option<(bool, String, &str)> {
let trimmed = text.trim_start();
if trimmed.is_empty() {
return None;
}
let mut chars = trimmed.chars();
let first = chars.next()?;
if BULLET_CHARS.contains(&first) {
let rest = chars.as_str();
if let Some(rest_trim) = rest.strip_prefix(|c: char| c.is_whitespace()) {
return Some((false, first.to_string(), rest_trim.trim_start()));
}
}
if first.is_ascii_digit() {
let mut digit_end = 1;
for c in trimmed[1..].chars() {
if c.is_ascii_digit() {
digit_end += c.len_utf8();
} else {
break;
}
}
if digit_end <= 3 {
let after_digits = &trimmed[digit_end..];
let mut after_iter = after_digits.chars();
if let Some(punct) = after_iter.next()
&& (punct == '.' || punct == ')')
{
let after_punct = after_iter.as_str();
if let Some(rest_trim) = after_punct.strip_prefix(|c: char| c.is_whitespace()) {
let marker = format!("{}{}", &trimmed[..digit_end], punct);
return Some((true, marker, rest_trim.trim_start()));
}
}
}
}
None
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MarkerKind {
AlphaLower,
AlphaUpper,
RomanLower,
RomanUpper,
}
const ORDERED_RUN_MAX_LINE_GAP: usize = 12;
const ORDERED_RUN_INDENT_TOL_PT: f32 = 8.0;
fn roman_value(s: &str) -> Option<u32> {
if s.is_empty() {
return None;
}
let mut total: i64 = 0;
let mut prev: i64 = 0;
for c in s.chars().rev() {
let v: i64 = match c {
'i' => 1,
'v' => 5,
'x' => 10,
'l' => 50,
'c' => 100,
'd' => 500,
'm' => 1000,
_ => return None,
};
if v < prev {
total -= v;
} else {
total += v;
prev = v;
}
}
if total > 0 { Some(total as u32) } else { None }
}
fn marker_interpretations(body: &str) -> Vec<(MarkerKind, u32)> {
let mut out = Vec::new();
let chars: Vec<char> = body.chars().collect();
if chars.len() == 1 && chars[0].is_ascii_alphabetic() {
let c = chars[0];
if c.is_ascii_lowercase() {
out.push((MarkerKind::AlphaLower, (c as u32) - ('a' as u32) + 1));
if let Some(v) = roman_value(&c.to_string()) {
out.push((MarkerKind::RomanLower, v));
}
} else {
out.push((MarkerKind::AlphaUpper, (c as u32) - ('A' as u32) + 1));
if let Some(v) = roman_value(&c.to_ascii_lowercase().to_string()) {
out.push((MarkerKind::RomanUpper, v));
}
}
return out;
}
if chars.iter().all(|c| c.is_ascii_lowercase()) {
if let Some(v) = roman_value(body) {
out.push((MarkerKind::RomanLower, v));
}
} else if chars.iter().all(|c| c.is_ascii_uppercase()) {
if let Some(v) = roman_value(&body.to_ascii_lowercase()) {
out.push((MarkerKind::RomanUpper, v));
}
}
out
}
fn split_ordered_marker(text: &str) -> Option<(&str, String, &str)> {
let t = text.trim_start();
let chars: Vec<char> = t.chars().collect();
if chars.is_empty() {
return None;
}
if chars[0] == '(' {
let close = chars.iter().position(|&c| c == ')')?;
if close < 2 {
return None; }
let body_len: usize = chars[1..close].iter().map(|c| c.len_utf8()).sum();
let after_paren = &t[1 + body_len + 1..];
let rest = after_paren.strip_prefix(|c: char| c.is_whitespace())?;
let rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let body = &t[1..1 + body_len];
let marker = format!("({body})");
return Some((body, marker, rest));
}
let letter_chars = chars.iter().take_while(|c| c.is_ascii_alphabetic()).count();
if letter_chars == 0 || letter_chars > 7 {
return None;
}
let body_len: usize = chars[..letter_chars].iter().map(|c| c.len_utf8()).sum();
let punct = chars.get(letter_chars).copied()?;
if punct != '.' && punct != ')' {
return None;
}
let after_punct = &t[body_len + punct.len_utf8()..];
let rest = after_punct.strip_prefix(|c: char| c.is_whitespace())?;
let rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let body = &t[..body_len];
let marker = format!("{body}{punct}");
Some((body, marker, rest))
}
struct OrderedCandidate {
idx: usize,
indent: f32,
interps: Vec<(MarkerKind, u32)>,
}
struct OrderedRun {
kind: MarkerKind,
next: u32,
indent: f32,
last_idx: usize,
members: Vec<usize>,
}
pub(super) fn detect_ordered_list_lines(
lines: &[ProjectedLine],
table_covered: &HashSet<usize>,
) -> HashSet<usize> {
let candidates: Vec<OrderedCandidate> = lines
.iter()
.enumerate()
.filter_map(|(i, l)| {
if table_covered.contains(&i) {
return None;
}
let (body, _marker, _rest) = split_ordered_marker(l.text.trim())?;
let interps = marker_interpretations(body);
if interps.is_empty() {
None
} else {
Some(OrderedCandidate {
idx: i,
indent: l.indent_x,
interps,
})
}
})
.collect();
let mut runs: Vec<OrderedRun> = Vec::new();
for c in &candidates {
let mut extended = false;
for run in runs.iter_mut().rev() {
if c.idx - run.last_idx <= ORDERED_RUN_MAX_LINE_GAP
&& (run.indent - c.indent).abs() <= ORDERED_RUN_INDENT_TOL_PT
&& c.interps
.iter()
.any(|(k, v)| *k == run.kind && *v == run.next)
{
run.members.push(c.idx);
run.next += 1;
run.last_idx = c.idx;
extended = true;
break;
}
}
if extended {
continue;
}
if let Some(&(kind, _)) = c.interps.iter().find(|(_, v)| *v == 1) {
runs.push(OrderedRun {
kind,
next: 2,
indent: c.indent,
last_idx: c.idx,
members: vec![c.idx],
});
}
}
let mut confirmed = HashSet::new();
for run in runs {
if run.members.len() >= 2 {
confirmed.extend(run.members);
}
}
confirmed
}
pub(super) fn split_ordered_marker_for_emit(text: &str) -> Option<(String, String)> {
let (_body, marker, rest) = split_ordered_marker(text)?;
Some((marker, rest.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_list_marker_bullets() {
let (ordered, marker, rest) = parse_list_marker("• item one").unwrap();
assert!(!ordered);
assert_eq!(marker, "•");
assert_eq!(rest, "item one");
}
#[test]
fn parse_list_marker_decimal() {
let (ordered, marker, rest) = parse_list_marker("1. first").unwrap();
assert!(ordered);
assert_eq!(marker, "1.");
assert_eq!(rest, "first");
let (ordered, marker, rest) = parse_list_marker("12) twelfth").unwrap();
assert!(ordered);
assert_eq!(marker, "12)");
assert_eq!(rest, "twelfth");
}
#[test]
fn parse_list_marker_rejects_prose() {
assert!(parse_list_marker("This sentence.").is_none());
assert!(parse_list_marker("2023 was a year").is_none());
assert!(parse_list_marker("1.5x growth").is_none());
}
fn ll(text: &str, x: f32, y: f32) -> ProjectedLine {
super::super::test_helpers::line(text, x, y, 11.0, 10.0)
}
#[test]
fn roman_value_parses() {
assert_eq!(roman_value("i"), Some(1));
assert_eq!(roman_value("iv"), Some(4));
assert_eq!(roman_value("ix"), Some(9));
assert_eq!(roman_value("xiv"), Some(14));
assert_eq!(roman_value("q"), None);
}
#[test]
fn split_ordered_marker_forms() {
assert_eq!(
split_ordered_marker("a. hello"),
Some(("a", "a.".to_string(), "hello"))
);
assert_eq!(
split_ordered_marker("iv) hello"),
Some(("iv", "iv)".to_string(), "hello"))
);
assert_eq!(
split_ordered_marker("(a) hello"),
Some(("a", "(a)".to_string(), "hello"))
);
assert!(split_ordered_marker("i.e. therefore").is_none());
assert!(split_ordered_marker("a.").is_none());
}
#[test]
fn detects_lettered_and_roman_sequences() {
let lines = vec![
ll("a. first item", 20.0, 100.0),
ll("b. second item", 20.0, 120.0),
ll("c. third item", 20.0, 140.0),
ll("i. sub one", 40.0, 160.0),
ll("ii. sub two", 40.0, 180.0),
ll("iii. sub three", 40.0, 200.0),
];
let got = detect_ordered_list_lines(&lines, &HashSet::new());
assert_eq!(got.len(), 6, "all six markers should be confirmed");
assert!((0..6).all(|i| got.contains(&i)));
}
#[test]
fn lone_marker_is_not_a_list() {
let lines = vec![
ll("A. Background", 20.0, 100.0),
ll(
"Some prose about the background of the matter at hand.",
20.0,
120.0,
),
];
assert!(detect_ordered_list_lines(&lines, &HashSet::new()).is_empty());
}
#[test]
fn initials_are_not_a_list() {
let lines = vec![
ll(
"J. Smith reported the findings to the committee.",
20.0,
100.0,
),
ll(
"M. Jones seconded the motion without objection.",
20.0,
120.0,
),
];
assert!(detect_ordered_list_lines(&lines, &HashSet::new()).is_empty());
}
#[test]
fn does_not_chain_across_large_gaps() {
let mut lines = vec![ll("a. opening clause", 20.0, 100.0)];
for k in 0..14 {
lines.push(ll(
"ordinary body prose line here",
20.0,
120.0 + k as f32 * 20.0,
));
}
lines.push(ll("b. much later clause", 20.0, 420.0));
assert!(detect_ordered_list_lines(&lines, &HashSet::new()).is_empty());
}
#[test]
fn table_covered_markers_are_excluded() {
let lines = vec![
ll(
"(a) first footnote of the table below the divider rule",
20.0,
100.0,
),
ll(
"(b) second footnote of the table below the divider rule",
20.0,
120.0,
),
];
assert_eq!(detect_ordered_list_lines(&lines, &HashSet::new()).len(), 2);
let covered: HashSet<usize> = [0usize, 1].into_iter().collect();
assert!(detect_ordered_list_lines(&lines, &covered).is_empty());
}
}