kopitiam-document 0.1.0

Structural document reconstruction (paragraphs, headings, tables, columns) for KOPITIAM's Document Engine.
Documentation
use super::Line;
use crate::Paragraph;

const PARAGRAPH_BREAK_GAP_RATIO: f32 = 1.8;

/// Merges consecutive lines into one paragraph, repairing end-of-line
/// hyphenation, until a vertical gap suggests a paragraph break (or the
/// slice runs out). Always consumes at least one line.
pub(super) fn consume_paragraph(lines: &[Line]) -> (Paragraph, usize) {
    let mut text = String::new();
    let mut consumed = 0;
    let mut prev_line: Option<&Line> = None;

    for line in lines {
        if let Some(prev) = prev_line {
            let gap = prev.y - line.y;
            if gap > prev.font_size * PARAGRAPH_BREAK_GAP_RATIO {
                break;
            }
        }

        append_line(&mut text, &line.text);
        consumed += 1;
        prev_line = Some(line);
    }

    (Paragraph { text }, consumed.max(1))
}

fn append_line(text: &mut String, line: &str) {
    let line = line.trim();
    let ends_with_hyphen = text.ends_with('-');
    let continues_word = line.chars().next().is_some_and(|c| c.is_lowercase());

    if ends_with_hyphen && continues_word {
        text.pop();
        text.push_str(line);
        return;
    }

    if !text.is_empty() {
        text.push(' ');
    }
    text.push_str(line);
}

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

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

    #[test]
    fn repairs_end_of_line_hyphenation() {
        let lines = vec![
            line("develop-", 100.0, 10.0),
            line("ment continues.", 88.0, 10.0),
        ];
        let (paragraph, consumed) = consume_paragraph(&lines);
        assert_eq!(paragraph.text, "development continues.");
        assert_eq!(consumed, 2);
    }

    #[test]
    fn does_not_dehyphenate_across_a_capitalized_word() {
        // A trailing hyphen followed by a capitalized word is a real hyphen
        // (e.g. a compound name), not a wrapped word.
        let lines = vec![
            line("Anglo-", 100.0, 10.0),
            line("Saxon history.", 88.0, 10.0),
        ];
        let (paragraph, _) = consume_paragraph(&lines);
        assert_eq!(paragraph.text, "Anglo- Saxon history.");
    }

    #[test]
    fn stops_at_a_large_vertical_gap() {
        let lines = vec![
            line("First paragraph.", 100.0, 10.0),
            line("Still first paragraph.", 88.0, 10.0),
            line("Second paragraph after a big gap.", 50.0, 10.0),
        ];
        let (paragraph, consumed) = consume_paragraph(&lines);
        assert_eq!(paragraph.text, "First paragraph. Still first paragraph.");
        assert_eq!(consumed, 2);
    }
}