const MAX_ORDINAL_DIGITS: usize = 3;
const MAX_ROMAN_LETTERS: usize = 6;
const MAX_OUTLINE_DEPTH: usize = 3;
const DOT_LEADER: usize = 4;
pub(crate) const MAX_LIST_LEVEL: u8 = 4;
const SYMBOL_FONTS: [&str; 5] = [
"zapfdingbats",
"dingbats",
"wingdings",
"webdings",
"symbol",
];
pub(crate) const BULLET_CHARS: [char; 20] = [
'\u{2022}', '\u{2023}', '\u{2043}', '\u{2219}', '\u{00B7}', '\u{25AA}', '\u{25AB}', '\u{25A0}', '\u{25A1}', '\u{25CF}', '\u{25CB}', '\u{25E6}', '\u{25C6}', '\u{25C7}', '\u{2756}', '\u{2713}', '\u{2714}', '-', '\u{2013}', '\u{2014}', ];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ordinal {
Arabic(u32),
Letter(char),
Roman(u32),
Outline { value: u32, depth: u8, roman: bool },
Section(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Marker {
kind: MarkerKind,
pub(crate) len: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MarkerKind {
Bullet,
Ordered(Ordinal),
Glyph(char),
}
impl Marker {
fn agrees_with(&self, other: &Marker) -> bool {
match (self.kind, other.kind) {
(MarkerKind::Bullet, MarkerKind::Bullet) => true,
(MarkerKind::Glyph(a), MarkerKind::Glyph(b)) => a == b,
(MarkerKind::Ordered(a), MarkerKind::Ordered(b)) => {
matches!(
(a, b),
(Ordinal::Arabic(_), Ordinal::Arabic(_))
| (
Ordinal::Letter(_) | Ordinal::Roman(_),
Ordinal::Letter(_) | Ordinal::Roman(_)
)
) || matches!(
(a, b),
(
Ordinal::Outline { depth: d, roman: r, .. },
Ordinal::Outline { depth: e, roman: s, .. },
) if d == e && r == s
) || matches!((a, b), (Ordinal::Section(_), Ordinal::Section(_)))
}
_ => false,
}
}
pub(crate) fn value(&self, roman: bool) -> Option<u32> {
match self.kind {
MarkerKind::Bullet | MarkerKind::Glyph(_) => None,
MarkerKind::Ordered(
Ordinal::Arabic(n)
| Ordinal::Roman(n)
| Ordinal::Outline { value: n, .. }
| Ordinal::Section(n),
) => Some(n),
MarkerKind::Ordered(Ordinal::Letter(c)) => {
if roman {
roman_value(&c.to_string())
} else {
Some(letter_value(c))
}
}
}
}
pub(crate) fn is_ordered(&self) -> bool {
matches!(self.kind, MarkerKind::Ordered(_))
}
pub(crate) fn is_outline(&self) -> bool {
matches!(self.kind, MarkerKind::Ordered(Ordinal::Outline { .. }))
}
pub(crate) fn keeps_its_text(&self) -> bool {
matches!(
self.kind,
MarkerKind::Ordered(Ordinal::Outline { .. } | Ordinal::Section(_))
)
}
fn is_certain_roman(&self) -> bool {
matches!(self.kind, MarkerKind::Ordered(Ordinal::Roman(_)))
}
}
fn letter_value(c: char) -> u32 {
let lower = c.to_ascii_lowercase();
(lower as u32).saturating_sub('a' as u32) + 1
}
pub(crate) fn opens_like_an_item(marker: char, rest: &str) -> bool {
let word = rest.trim_start().chars().next();
if is_private_use(marker) {
rest.starts_with(char::is_whitespace) || word.is_some_and(char::is_uppercase)
} else {
word.is_some_and(char::is_uppercase)
}
}
fn is_private_use(c: char) -> bool {
matches!(c, '\u{e000}'..='\u{f8ff}' | '\u{f0000}'..='\u{ffffd}' | '\u{100000}'..='\u{10fffd}')
}
pub(crate) fn is_a_pointer_or_a_picture(c: char) -> bool {
matches!(c, '>' | '\u{203a}' | '\u{bb}') || is_private_use(c)
}
pub(crate) fn roman_value(text: &str) -> Option<u32> {
let digit = |c: char| -> Option<i64> {
match c.to_ascii_lowercase() {
'i' => Some(1),
'v' => Some(5),
'x' => Some(10),
'l' => Some(50),
'c' => Some(100),
'd' => Some(500),
'm' => Some(1000),
_ => None,
}
};
let values: Vec<i64> = text.chars().map(digit).collect::<Option<Vec<i64>>>()?;
if values.is_empty() {
return None;
}
let mut total: i64 = 0;
for (i, value) in values.iter().enumerate() {
match values[i + 1..].iter().max() {
Some(later) if later > value => total -= value,
_ => total += value,
}
}
u32::try_from(total).ok()
}
pub(crate) fn is_symbol_font(name: Option<&str>) -> bool {
let Some(name) = name else { return false };
let lower = name.to_ascii_lowercase();
SYMBOL_FONTS.iter().any(|family| lower.contains(family))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Lead {
pub(crate) symbol: Option<usize>,
pub(crate) raised: Option<usize>,
pub(crate) apart: Option<usize>,
}
fn is_a_figure_sign(c: char) -> bool {
c.is_ascii_digit()
|| matches!(
c,
'$' | '\u{20ac}' | '\u{a3}' | '\u{a5}' | '\u{a2}' | '%' | '\u{2030}' | '#' | '+' | '='
)
}
pub(crate) fn marker_at(text: &str, lead: Lead) -> Option<Marker> {
let indent = text.len() - text.trim_start().len();
let trimmed = &text[indent..];
let first = trimmed.chars().next()?;
if let Some(prefix) = lead.symbol {
if prefix > indent && text.is_char_boundary(prefix) {
return finish(text, prefix, MarkerKind::Bullet, false);
}
}
if BULLET_CHARS.contains(&first) {
let needs_space = matches!(first, '-' | '\u{2013}' | '\u{2014}');
return finish(
text,
indent + first.len_utf8(),
MarkerKind::Bullet,
needs_space,
);
}
if is_a_pointer_or_a_picture(first) && opens_like_an_item(first, &trimmed[first.len_utf8()..]) {
{
return finish(text, indent + first.len_utf8(), MarkerKind::Bullet, false);
}
}
if let Some(marker) = ordinal_at(text, indent, trimmed, first) {
return Some(marker);
}
if let Some(marker) = outline_at(text, indent, trimmed) {
return Some(marker);
}
if let Some(marker) = section_at(text, indent, trimmed) {
return Some(marker);
}
if let Some(prefix) = lead.raised {
if prefix > indent && text.is_char_boundary(prefix) {
let figure = text[indent..prefix].trim();
if !figure.is_empty()
&& figure.len() <= MAX_ORDINAL_DIGITS
&& figure.chars().all(|c| c.is_ascii_digit())
{
if let Ok(value) = figure.parse() {
return finish(
text,
prefix,
MarkerKind::Ordered(Ordinal::Arabic(value)),
false,
);
}
}
}
}
if let Some(prefix) = lead.apart {
if prefix > indent && text.is_char_boundary(prefix) {
let mut glyph = text[indent..prefix].trim_end().chars();
if let (Some(c), None) = (glyph.next(), glyph.next()) {
if !c.is_whitespace() && !is_a_figure_sign(c) {
return finish(text, prefix, MarkerKind::Glyph(c), false);
}
}
}
}
None
}
fn ordinal_at(text: &str, indent: usize, trimmed: &str, first: char) -> Option<Marker> {
let closing = match first {
'(' => Some(')'),
'[' => Some(']'),
_ => None,
};
let body = if closing.is_some() {
&trimmed[first.len_utf8()..]
} else {
trimmed
};
let word: String = body
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect();
let delimiter = body[word.len()..].chars().next()?;
match closing {
Some(closing) => {
if delimiter != closing {
return None;
}
}
None => {
if delimiter != '.' && delimiter != ')' {
return None;
}
}
}
let ordinal = if word.chars().all(|c| c.is_ascii_digit()) {
if word.is_empty() || word.len() > MAX_ORDINAL_DIGITS {
return None;
}
Ordinal::Arabic(word.parse().ok()?)
} else if word.chars().all(|c| c.is_ascii_alphabetic()) {
match word.chars().count() {
1 => Ordinal::Letter(word.chars().next()?),
n if n <= MAX_ROMAN_LETTERS => Ordinal::Roman(roman_value(&word)?),
_ => return None,
}
} else {
return None;
};
if closing.is_none()
&& word.len() == 1
&& word.chars().all(|c| c.is_ascii_uppercase())
&& (opens_with_an_initial(&body[word.len() + delimiter.len_utf8()..])
|| opens_with_a_surname_then_an_initial(&body[word.len() + delimiter.len_utf8()..]))
{
return None;
}
let opening = if closing.is_some() {
first.len_utf8()
} else {
0
};
let len = indent + opening + word.len() + delimiter.len_utf8();
finish(text, len, MarkerKind::Ordered(ordinal), true)
}
fn outline_at(text: &str, indent: usize, trimmed: &str) -> Option<Marker> {
let token = trimmed.split(char::is_whitespace).next()?;
let body = token.strip_suffix('.').unwrap_or(token);
let mut parts = body.split('.');
let head = parts.next()?;
let (head, roman) = if !head.is_empty() && head.chars().all(|c| c.is_ascii_digit()) {
if head.len() > MAX_ORDINAL_DIGITS {
return None;
}
(head.parse::<u32>().ok()?, false)
} else if !head.is_empty()
&& head.len() <= MAX_ROMAN_LETTERS
&& head.chars().all(|c| c.is_ascii_uppercase())
{
(roman_value(head)?, true)
} else {
return None;
};
let rest: Vec<&str> = parts.collect();
if rest.is_empty() || rest.len() > MAX_OUTLINE_DEPTH - 1 {
return None;
}
let mut value = head;
for part in &rest {
if part.is_empty()
|| part.len() > MAX_ORDINAL_DIGITS
|| !part.chars().all(|c| c.is_ascii_digit())
{
return None;
}
value = value.checked_mul(1000)?.checked_add(part.parse().ok()?)?;
}
let after = trimmed[token.len()..].trim_start();
if !after.chars().next().is_some_and(char::is_uppercase) {
return None;
}
if has_dot_leader(after) {
return None;
}
let depth = u8::try_from(rest.len() + 1).ok()?;
finish(
text,
indent + token.len(),
MarkerKind::Ordered(Ordinal::Outline {
value,
depth,
roman,
}),
true,
)
}
fn section_at(text: &str, indent: usize, trimmed: &str) -> Option<Marker> {
let body = trimmed.strip_prefix('\u{a7}')?;
let number = body.trim_start();
let digits = number.chars().take_while(char::is_ascii_digit).count();
if !(1..=MAX_SECTION_DIGITS).contains(&digits) {
return None;
}
let mut value = number[..digits].parse::<u32>().ok()?.checked_mul(27)?;
let mut len = trimmed.len() - number.len() + digits;
if let Some(letter) = number[digits..]
.chars()
.next()
.filter(char::is_ascii_lowercase)
{
value += letter as u32 - 'a' as u32 + 1;
len += 1;
}
let after = trimmed[len..].trim_start();
if !after.chars().next().is_some_and(char::is_uppercase) {
return None;
}
finish(
text,
indent + len,
MarkerKind::Ordered(Ordinal::Section(value)),
true,
)
}
const MAX_SECTION_DIGITS: usize = 4;
fn has_dot_leader(text: &str) -> bool {
let mut dots = 0;
for c in text.chars() {
match c {
'.' => dots += 1,
'\u{2026}' => dots += 3,
c if c.is_whitespace() => {}
_ => dots = 0,
}
if dots >= DOT_LEADER {
return true;
}
}
false
}
pub(crate) fn opens_with_an_initial(rest: &str) -> bool {
let trimmed = rest.trim_start();
if trimmed.len() == rest.len() {
return false;
}
let mut chars = trimmed.chars();
let (Some(letter), Some(stop)) = (chars.next(), chars.next()) else {
return false;
};
letter.is_uppercase()
&& (stop == '.' || stop == '-')
&& chars.next().is_none_or(|c| c.is_whitespace() || c == '-')
}
pub(crate) fn opens_with_a_surname_then_an_initial(rest: &str) -> bool {
let trimmed = rest.trim_start();
if trimmed.len() == rest.len() {
return false;
}
let mut words = trimmed.split_whitespace();
let Some(surname) = words.next() else {
return false;
};
let (name, comma) = match surname.strip_suffix(',') {
Some(name) => (name, true),
None => (surname, false),
};
let capitalised = name.chars().next().is_some_and(char::is_uppercase)
&& name.chars().count() >= 2
&& name
.chars()
.all(|c| c.is_alphabetic() || c == '-' || c == '\'')
&& name.chars().skip(1).any(char::is_lowercase);
if !capitalised {
return false;
}
let next = if comma {
words.next()
} else {
match words.next() {
Some("and") | Some("&") => words.next(),
_ => return false,
}
};
next.is_some_and(|w| opens_with_an_initial(&format!(" {w}")))
}
fn finish(text: &str, len: usize, kind: MarkerKind, needs_space: bool) -> Option<Marker> {
let rest = &text[len..];
let spaced = rest.starts_with(char::is_whitespace);
if needs_space && !spaced {
return None;
}
let gap = rest.len() - rest.trim_start().len();
if rest[gap..].trim().is_empty() {
return None;
}
Some(Marker {
kind,
len: len + gap,
})
}
pub(crate) fn is_roman(markers: &[Marker]) -> bool {
markers.iter().any(Marker::is_certain_roman)
}
pub(crate) fn is_a_run(markers: &[Marker]) -> bool {
if markers.len() < 2 {
return false;
}
let first = markers[0];
if !markers.iter().all(|m| m.agrees_with(&first)) {
return false;
}
if !first.is_ordered() {
return true;
}
let roman = is_roman(markers);
let lettered = !roman
&& markers
.iter()
.all(|m| matches!(m.kind, MarkerKind::Ordered(Ordinal::Letter(_))));
let outline = markers
.iter()
.all(|m| matches!(m.kind, MarkerKind::Ordered(Ordinal::Outline { .. })));
let mut previous: Option<u32> = None;
for marker in markers {
let Some(value) = marker.value(roman) else {
return false;
};
if previous.is_some_and(|last| value <= last || (lettered && value - last > 2)) {
return false;
}
if outline && previous.is_some_and(|last| last / 1000 != value / 1000 && value % 1000 > 1) {
return false;
}
previous = Some(value);
}
true
}
#[cfg(test)]
mod tests {
use super::*;
fn marker(text: &str) -> Option<Marker> {
marker_at(text, Lead::default())
}
#[test]
fn bullet_glyphs_open_an_item() {
for text in ["\u{2022} apples", "\u{25AA} pears", "\u{25CF}quinces"] {
assert!(marker(text).is_some(), "{text:?} opens no item");
}
}
#[test]
fn a_pointer_or_a_private_use_picture_is_a_bullet() {
for text in [
">Our market share in retail securities increased",
"> Our market share",
"\u{203a} Capital transactions",
"\u{f0b7} the consolidated balance sheet",
"\u{f0000} the supplementary private use area",
"\u{100000} and the other one",
] {
let m = marker(text).unwrap_or_else(|| panic!("{text:?} opens no item"));
assert!(!m.is_ordered());
}
for text in ["> 3 of the cases", ">= the threshold", "->Our"] {
assert!(marker(text).is_none(), "{text:?} was read as a bullet");
}
}
#[test]
fn a_dash_needs_the_space_after_it() {
assert!(marker("- apples").is_some());
assert!(
marker("-apples").is_none(),
"a hyphenated word is not an item"
);
}
#[test]
fn a_number_that_is_not_a_marker() {
for text in [
"2.1 shall report to the members",
"1.5 million tonnes",
"1984 was a year",
"1234. too many",
"1.2.3.4 Too deep",
"ii.3 Lower-case head",
] {
assert!(marker(text).is_none(), "{text:?} was read as a marker");
}
}
#[test]
fn a_statute_numbers_its_sections() {
let run = |texts: &[&str]| {
let found: Vec<Marker> = texts.iter().filter_map(|t| marker(t)).collect();
found.len() == texts.len() && is_a_run(&found)
};
assert!(run(&[
"\u{a7} 74 Entscheidung \u{fc}ber die Rechtsbeschwerde",
"\u{a7} 74a Zur\u{fc}ckweisungsbeschluss",
"\u{a7} 75 Sprungrechtsbeschwerde",
]));
let section = marker("\u{a7} 70 Statthaftigkeit der Rechtsbeschwerde").expect("a section");
assert!(
section.keeps_its_text(),
"a section keeps its number in the item's text"
);
assert!(marker("\u{a7} 5 abs. 2 gilt entsprechend").is_none());
assert!(marker("\u{a7} 12345 Too many").is_none());
assert!(!run(&["1. apples", "\u{a7} 2 Pears"]));
}
#[test]
fn an_outline_numbers_its_clauses() {
let run = |texts: &[&str]| {
let found: Vec<Marker> = texts.iter().filter_map(|t| marker(t)).collect();
found.len() == texts.len() && is_a_run(&found)
};
assert!(run(&[
"II.4.6 At the contracting authority's reasoned request",
"II.4.7 The contractor must record and report",
"II.6.1 The contracting authority is not liable",
]));
assert!(run(&[
"10.2 Director Equity Plan",
"10.3 Standard form",
"10.6 Omnicom Group"
]));
assert!(run(&["II.22.1. If an amount", "II.22.2. The contractor"]));
assert!(!run(&["3.5 Million tonnes", "12.7 Percent of it"]));
assert!(!run(&["II.4.1 The first", "3.4.2 The second"]));
assert!(!run(&["2.4 The first", "2.4.1 The second"]));
assert!(!run(&["1. apples", "1.2 Pears"]));
for entry in [
"I.4.1. Price of the contract ........ 6",
"I.4.1. Price of the contract . . . . . 6",
"I.4.1. Price of the contract \u{2026}\u{2026} 6",
] {
assert!(marker(entry).is_none(), "{entry:?} was read as a clause");
}
assert!(marker("II.4.1 The contractor... must comply").is_some());
let clause = marker("II.4.1 The contractor").expect("an outline clause");
assert!(clause.is_outline());
assert_eq!(clause.len, "II.4.1 ".len());
}
#[test]
fn numbering_styles() {
assert_eq!(marker("1. apples").and_then(|m| m.value(false)), Some(1));
assert_eq!(marker("7) pears").and_then(|m| m.value(false)), Some(7));
assert_eq!(marker("(c) quinces").and_then(|m| m.value(false)), Some(3));
assert_eq!(marker("iv. medlars").and_then(|m| m.value(false)), Some(4));
assert_eq!(marker("ix. sloes").and_then(|m| m.value(false)), Some(9));
}
#[test]
fn letters_that_jump_are_initials_not_a_list() {
let run = |texts: &[&str]| {
let found: Vec<Marker> = texts.iter().filter_map(|t| marker(t)).collect();
found.len() == texts.len() && is_a_run(&found)
};
assert!(!run(&[
"T. Fujiwara, E. Yamamoto, Y. Haga",
"Y. Onuki, J. Phys. Soc. Jpn."
]));
assert!(run(&["a. apples", "b. pears", "c. quinces"]));
assert!(run(&["A. Scope", "C. Definitions"]));
assert!(run(&["1. apples", "4. pears"]));
}
#[test]
fn an_initial_before_an_initial_is_a_name() {
for text in [
"A. G. Gavriliuk, I. A. Trojan, JETP Lett. 100, 688 (2005).",
"I. S. Lyubutin, S. G. Ovchinnikov, A. G. Gavriliuk, and",
"V. V. Struzhkin, Phys. Rev. B 79, 085125 (2009).",
"J. J.-P. Bouchaud and M. Potters, Theory of Financial Risk",
] {
assert!(marker(text).is_none(), "{text:?} was read as a marker");
}
for text in [
"L. Lamata and E. Solano, Phys. Rev. Lett. 103, 070503 (2009).",
"A. Cucchieri and T. Mendes, Phys. Rev. D 78, 094503 (2008)",
"K. Makris, R. El-Ganainy, D. N. Christodoulides and",
"H. Graafsma, C. Henriquet, R. Verbeni, J. Synch. Rad.",
] {
assert!(marker(text).is_none(), "{text:?} was read as a marker");
}
for text in [
"A. Scope of the works",
"B. U.S. dollar amounts",
"IV. Definitions",
"(A) G. Gavriliuk and others",
"II. A. Smith, the second witness",
"IV. C. Brown, the fourth witness",
"A. Introduction and scope",
"B. Revenue, costs and margins",
"C. Smith and Jones, the third party",
] {
assert!(marker(text).is_some(), "{text:?} opens no item");
}
}
#[test]
fn a_marker_with_no_text_after_it_is_not_one() {
assert!(marker("\u{2022}").is_none());
assert!(marker("1. ").is_none());
}
#[test]
fn a_bare_letter_is_read_by_its_neighbours() {
let roman: Vec<Marker> = ["i. one", "ii. two", "iii. three"]
.iter()
.filter_map(|t| marker(t))
.collect();
assert!(is_a_run(&roman), "i, ii, iii is not a run");
assert_eq!(roman[0].value(is_roman(&roman)), Some(1));
let latin: Vec<Marker> = ["g. one", "h. two", "i. three"]
.iter()
.filter_map(|t| marker(t))
.collect();
assert!(is_a_run(&latin), "g, h, i is not a run");
assert_eq!(latin[2].value(is_roman(&latin)), Some(9));
}
#[test]
fn a_run_climbs() {
let repeated: Vec<Marker> = ["(a) first mention", "(a) second mention"]
.iter()
.filter_map(|t| marker(t))
.collect();
assert!(
!is_a_run(&repeated),
"a clause reference repeated is not a list"
);
let letters_and_numerals: Vec<Marker> = ["a. one", "iv. two"]
.iter()
.filter_map(|t| marker(t))
.collect();
assert!(
!is_a_run(&letters_and_numerals),
"a letter that is no roman digit joined a roman run"
);
let mixed: Vec<Marker> = ["1. one", "\u{2022} two"]
.iter()
.filter_map(|t| marker(t))
.collect();
assert!(!is_a_run(&mixed), "a number and a bullet are not one list");
}
#[test]
fn a_picture_font_marks_with_whatever_it_decodes_to() {
assert!(
marker_at(
"l apples",
Lead {
symbol: Some(1),
..Lead::default()
}
)
.is_some(),
"a dingbat bullet is a bullet"
);
assert!(
marker_at("l apples", Lead::default()).is_none(),
"the same letter in a text font is not"
);
}
#[test]
fn a_bibliography_is_numbered_in_brackets() {
assert_eq!(
marker("[3] S. Khalil, J. Phys. G 35, 055001 (2008).").and_then(|m| m.value(false)),
Some(3),
"a bracketed reference number is not a marker"
);
assert!(
marker("[3x] not a number").is_none(),
"the closing bracket is required where the opening one was written"
);
assert!(
marker("[3) mismatched").is_none(),
"a square bracket is not closed by a round one"
);
}
#[test]
fn a_raised_figure_numbers_a_reference() {
let raised = Lead {
raised: Some(2),
..Lead::default()
};
assert_eq!(
marker_at("12 E. B. Sonin, arXiv:0909.3156v1 (2009).", raised)
.and_then(|m| m.value(false)),
Some(12),
"a raised reference number is not a marker"
);
assert!(
marker("12 E. B. Sonin, arXiv:0909.3156v1 (2009).").is_none(),
"the same figure set in the line is not a marker"
);
assert!(
marker_at(
"1984 was a year",
Lead {
raised: Some(4),
..Lead::default()
}
)
.is_none(),
"a year is longer than a marker"
);
}
#[test]
fn a_glyph_set_apart_pairs_only_with_itself() {
let apart = Lead {
apart: Some(1),
..Lead::default()
};
let bullets: Vec<Marker> = ["t apples", "t pears"]
.iter()
.filter_map(|t| marker_at(t, apart))
.collect();
assert_eq!(bullets.len(), 2);
assert!(is_a_run(&bullets), "the same glyph twice is not a run");
let letters: Vec<Marker> = ["A Introduction", "B Method"]
.iter()
.filter_map(|t| marker_at(t, apart))
.collect();
assert!(
!is_a_run(&letters),
"two different letters were read as one bulleted list"
);
assert!(
marker("t apples").is_none(),
"a letter the page did not set apart is not a marker"
);
}
#[test]
fn a_currency_sign_set_apart_is_a_column_of_figures() {
let apart = Lead {
apart: Some(1),
..Lead::default()
};
for row in ["$ 5,091 $ 2,927", "% 12", "7 apples"] {
assert!(
marker_at(row, apart).is_none(),
"{row:?} was read as a glyph bullet"
);
}
}
#[test]
fn roman_numerals_add_and_subtract() {
assert_eq!(roman_value("i"), Some(1));
assert_eq!(roman_value("iv"), Some(4));
assert_eq!(roman_value("xiv"), Some(14));
assert_eq!(roman_value("mcmxciv"), Some(1994));
assert_eq!(roman_value("apples"), None);
}
}