kopitiam-document 0.2.2

Structural document reconstruction (paragraphs, headings, tables, columns) for KOPITIAM's Document Engine.
Documentation
use std::sync::LazyLock;

use regex::Regex;

use super::Line;
use crate::List;

static UNORDERED_MARKER: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^[•\-\*\x{2022}\x{2023}\x{25E6}\x{2043}]\s+(.*)$").unwrap());
static ORDERED_MARKER: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^(?:\d+|[a-zA-Z])[.)]\s+(.*)$").unwrap());

enum Marker {
    Ordered(String),
    Unordered(String),
}

fn marker(text: &str) -> Option<Marker> {
    let text = text.trim();
    if let Some(caps) = UNORDERED_MARKER.captures(text) {
        return Some(Marker::Unordered(caps[1].to_string()));
    }
    if let Some(caps) = ORDERED_MARKER.captures(text) {
        return Some(Marker::Ordered(caps[1].to_string()));
    }
    None
}

pub(super) fn try_list(lines: &[Line]) -> Option<(List, usize)> {
    let ordered = matches!(marker(&lines[0].text)?, Marker::Ordered(_));
    let mut items = Vec::new();
    let mut consumed = 0;

    for line in lines {
        match marker(&line.text) {
            Some(Marker::Ordered(text)) if ordered => items.push(text),
            Some(Marker::Unordered(text)) if !ordered => items.push(text),
            _ => break,
        }
        consumed += 1;
    }

    if items.is_empty() {
        None
    } else {
        Some((List { ordered, items }, consumed))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line(text: &str) -> Line {
        Line {
            text: text.to_string(),
            y: 0.0,
            font_size: 10.0,
            cells: Vec::new(),
        }
    }

    #[test]
    fn unordered_bullets_become_an_unordered_list() {
        let lines = vec![line("- Item A"), line("- Item B"), line("Not a bullet")];
        let (list, consumed) = try_list(&lines).unwrap();
        assert!(!list.ordered);
        assert_eq!(list.items, vec!["Item A", "Item B"]);
        assert_eq!(consumed, 2);
    }

    #[test]
    fn numbered_items_become_an_ordered_list() {
        let lines = vec![line("1. First"), line("2. Second")];
        let (list, consumed) = try_list(&lines).unwrap();
        assert!(list.ordered);
        assert_eq!(list.items, vec!["First", "Second"]);
        assert_eq!(consumed, 2);
    }

    #[test]
    fn plain_prose_is_not_a_list() {
        let lines = vec![line("This is a normal paragraph.")];
        assert!(try_list(&lines).is_none());
    }
}