ocr 0.1.0

A minimalist OCR library for Rust — from scratch, no external engine
Documentation
#[derive(Debug, Clone, Copy)]
pub struct LineInfo {
    pub y_start: usize,
    pub y_end: usize,
}

#[derive(Debug, Clone, Copy)]
pub struct CharSeg {
    pub x: usize,
    pub width: usize,
}

pub fn horizontal_projection(grid: &[Vec<bool>], height: usize, width: usize) -> Vec<usize> {
    let mut proj = vec![0; height];
    for y in 0..height {
        for x in 0..width {
            if grid[y][x] {
                proj[y] += 1;
            }
        }
    }
    proj
}

fn find_lines_from_projection(proj: &[usize], height: usize) -> Vec<LineInfo> {
    let mut lines = Vec::new();
    let mut in_line = false;
    let mut start = 0;
    let min_height = 2;

    for (y, &count) in proj.iter().enumerate() {
        if count > 0 && !in_line {
            start = y;
            in_line = true;
        } else if count == 0 && in_line {
            if y.saturating_sub(start) >= min_height {
                lines.push(LineInfo { y_start: start, y_end: y });
            }
            in_line = false;
        }
    }
    if in_line && height.saturating_sub(start) >= min_height {
        lines.push(LineInfo { y_start: start, y_end: height });
    }
    lines
}

/// Join lines separated by a thin band of empty rows (e.g. `i`/`j` dot vs body). Uses image
/// height so tiny synthetic grids are unchanged while real renders still merge.
fn merge_fragmented_lines(lines: Vec<LineInfo>, height: usize) -> Vec<LineInfo> {
    if lines.len() < 2 {
        return lines;
    }
    let max_gap = (height / 8).min(20);
    if max_gap < 2 {
        return lines;
    }
    let mut out = vec![lines[0]];
    for next in lines.into_iter().skip(1) {
        let gap = next.y_start.saturating_sub(out.last().unwrap().y_end);
        if gap <= max_gap {
            out.last_mut().unwrap().y_end = next.y_end;
        } else {
            out.push(next);
        }
    }
    out
}

pub fn vertical_projection(
    grid: &[Vec<bool>],
    y_start: usize,
    y_end: usize,
    width: usize,
) -> Vec<usize> {
    let mut proj = vec![0; width];
    for y in y_start..y_end {
        for x in 0..width {
            if grid[y][x] {
                proj[x] += 1;
            }
        }
    }
    proj
}

pub fn find_lines(grid: &[Vec<bool>], width: usize, height: usize) -> Vec<LineInfo> {
    let proj = horizontal_projection(grid, height, width);
    let lines = find_lines_from_projection(&proj, height);
    merge_fragmented_lines(lines, height)
}

pub fn find_chars(grid: &[Vec<bool>], line: &LineInfo, width: usize) -> Vec<CharSeg> {
    let proj = vertical_projection(grid, line.y_start, line.y_end, width);
    let mut chars = Vec::new();
    let mut in_char = false;
    let mut start = 0;
    let min_width = 2;

    for (x, &count) in proj.iter().enumerate() {
        if count > 0 && !in_char {
            start = x;
            in_char = true;
        } else if count == 0 && in_char {
            if x.saturating_sub(start) >= min_width {
                chars.push(CharSeg { x: start, width: x - start });
            }
            in_char = false;
        }
    }
    if in_char && width.saturating_sub(start) >= min_width {
        chars.push(CharSeg { x: start, width: width - start });
    }
    chars
}

pub fn compute_gaps(chars: &[CharSeg]) -> Vec<usize> {
    chars.windows(2).map(|w| {
        w[1].x.saturating_sub(w[0].x + w[0].width)
    }).collect()
}

pub fn average_gap(gaps: &[usize]) -> f64 {
    if gaps.is_empty() { return 0.0; }
    gaps.iter().sum::<usize>() as f64 / gaps.len() as f64
}

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

    fn make_grid(patterns: &[&str]) -> Vec<Vec<bool>> {
        patterns.iter().map(|s| s.chars().map(|c| c == '#').collect()).collect()
    }

    #[test]
    fn test_find_lines_single() {
        let grid = make_grid(&[
            ".....",
            ".##..",
            ".##..",
            ".....",
            ".....",
        ]);
        let lines = find_lines(&grid, 5, 5);
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0].y_start, 1);
        assert_eq!(lines[0].y_end, 3);
    }

    #[test]
    fn test_find_lines_two() {
        let grid = make_grid(&[
            ".##..",
            ".##..",
            ".....",
            ".##..",
            ".##..",
        ]);
        let lines = find_lines(&grid, 5, 5);
        assert_eq!(lines.len(), 2);
    }

    #[test]
    fn test_find_chars_two() {
        let grid = make_grid(&[
            ".##.",
            ".##.",
            "....",
            "..#.",
            "..#.",
        ]);
        let line = LineInfo { y_start: 0, y_end: 2 };
        let chars = find_chars(&grid, &line, 4);
        assert_eq!(chars.len(), 1);
        assert_eq!(chars[0].x, 1);
        assert_eq!(chars[0].width, 2);
    }

    #[test]
    fn test_compute_gaps() {
        let chars = vec![
            CharSeg { x: 2, width: 3 },
            CharSeg { x: 8, width: 2 },
            CharSeg { x: 14, width: 3 },
        ];
        let gaps = compute_gaps(&chars);
        assert_eq!(gaps, vec![3, 4]);
    }

    #[test]
    fn test_horizontal_projection() {
        let grid = make_grid(&[
            ".#.#.",
            "..#..",
        ]);
        let proj = horizontal_projection(&grid, 2, 5);
        assert_eq!(proj[0], 2);
        assert_eq!(proj[1], 1);
    }

    #[test]
    fn test_find_lines_empty() {
        let grid = make_grid(&[".....", ".....", "....."]);
        let lines = find_lines(&grid, 5, 3);
        assert!(lines.is_empty());
    }
}