df-ocr-switcher 0.1.0

Document OCR pipeline in pure Rust: scanned PDF / multi-page TIFF / image → Markdown. PaddleOCR PP-OCRv6 and Tesseract 5.5 as interchangeable engines, PP-DocLayoutV3 layout for both.
Documentation
//! Generatore Markdown da `OcrPageResult`.
//!
//! Strategia:
//! - Per ogni `layout_box` in `reading_order`, raccoglie i `blocks` associati
//!   (`layout_idx` corrispondente) e li concatena.
//! - La formattazione dipende da `SemanticClass`:
//!   - `Title`    → `# testo`
//!   - `Text`     → paragrafo
//!   - `List`     → paragrafo (le righe sono già in `text`)
//!   - `Table`    → GFM table da `result.tables` se disponibile, altrimenti testo OCR
//!   - `Equation` → `$$latex$$` da `result.formulas` se disponibile, altrimenti testo OCR
//!   - `Figure`   → `![figure]()`
//!   - `Header`/`Footer` → skip (non contribuiscono al testo principale)
//! - I blocchi "orfani" (layout_idx == -1) sono emessi per ultimi come testo
//!   semplice, ordinati per Y.

use ppocr_rs::SemanticClass;

use crate::engine::{OcrBlock, OcrFormula, OcrPageResult, OcrTable};

/// Converte una singola pagina OCR in stringa Markdown.
pub fn page_to_markdown(result: &OcrPageResult) -> String {
    let mut out = String::new();

    if result.layout_boxes.is_empty() {
        // Nessun layout: emetti tutto il testo in ordine di blocchi
        for blk in &result.blocks {
            push_text(&mut out, blk.text.trim());
        }
        return out;
    }

    // ── Layout-aware output ────────────────────────────────────────────────
    // Costruiamo una mappa layout_idx → blocchi ordinati per Y
    let mut by_layout: Vec<Vec<&OcrBlock>> = vec![Vec::new(); result.layout_boxes.len()];
    let mut orphans: Vec<&OcrBlock> = Vec::new();

    for blk in &result.blocks {
        if blk.layout_idx >= 0 && (blk.layout_idx as usize) < by_layout.len() {
            by_layout[blk.layout_idx as usize].push(blk);
        } else {
            orphans.push(blk);
        }
    }

    // Ordina blocchi all'interno di ogni regione per Y poi X
    for group in &mut by_layout {
        group.sort_by_key(|b| (b.y1, b.x1));
    }

    // ── Emetti ogni regione layout in reading_order ────────────────────────
    // Costruiamo un vettore ordinato di (reading_order, idx)
    let mut order: Vec<(i32, usize)> = result.layout_boxes.iter()
        .enumerate()
        .map(|(i, lb)| (lb.reading_order, i))
        .collect();
    // reading_order == -1 (non disponibile) va in fondo
    order.sort_by_key(|&(ro, _)| if ro < 0 { i32::MAX } else { ro });

    for (_, idx) in &order {
        let lb    = &result.layout_boxes[*idx];
        let group = &by_layout[*idx];
        if group.is_empty() { continue; }

        let text = concat_blocks(group);
        if text.trim().is_empty() { continue; }

        match lb.semantic {
            SemanticClass::Title => {
                out.push_str("# ");
                out.push_str(text.trim());
                out.push_str("\n\n");
            }
            SemanticClass::Text | SemanticClass::List => {
                push_text(&mut out, text.trim());
            }
            SemanticClass::Table => {
                push_table(&mut out, *idx, &result.tables, text.trim());
            }
            SemanticClass::Equation => {
                push_formula(&mut out, *idx, &result.formulas, text.trim());
            }
            SemanticClass::Figure => {
                out.push_str("![figure]()\n\n");
            }
            SemanticClass::Header | SemanticClass::Footer => {
                // skip: non contribuiscono al testo principale
            }
        }
    }

    // ── Orfani (nessuna regione layout assegnata) ──────────────────────────
    orphans.sort_by_key(|b| (b.y1, b.x1));
    for blk in orphans {
        push_text(&mut out, blk.text.trim());
    }

    out
}

/// Converte più pagine in Markdown, separandole con `---`.
pub fn to_markdown(pages: &[OcrPageResult]) -> String {
    pages.iter()
        .enumerate()
        .map(|(i, p)| {
            let header = if pages.len() > 1 {
                format!("## Page {}\n\n", i + 1)
            } else {
                String::new()
            };
            header + &page_to_markdown(p)
        })
        .collect::<Vec<_>>()
        .join("\n---\n\n")
}

// ─── Helper ──────────────────────────────────────────────────────────────────

fn concat_blocks(blocks: &[&OcrBlock]) -> String {
    blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>().join(" ")
}

fn push_text(out: &mut String, text: &str) {
    if !text.is_empty() {
        out.push_str(text);
        out.push_str("\n\n");
    }
}

/// Emette la tabella GFM da `tables` (se trovata e non vuota) oppure il testo OCR raw.
fn push_table(out: &mut String, layout_idx: usize, tables: &[OcrTable], fallback: &str) {
    if let Some(t) = tables.iter().find(|t| t.layout_idx == layout_idx as i32) {
        if !t.gfm.is_empty() {
            out.push_str(&t.gfm);
            out.push('\n');
            return;
        }
    }
    // Fallback: testo OCR grezzo (nessun modello tabella disponibile)
    push_text(out, fallback);
}

/// Emette la formula LaTeX da `formulas` (se trovata e non vuota) oppure il testo OCR raw.
fn push_formula(out: &mut String, layout_idx: usize, formulas: &[OcrFormula], fallback: &str) {
    if let Some(f) = formulas.iter().find(|f| f.layout_idx == layout_idx as i32) {
        if !f.latex.is_empty() {
            out.push_str("$$\n");
            out.push_str(f.latex.trim());
            out.push_str("\n$$\n\n");
            return;
        }
    }
    // Fallback: testo OCR grezzo
    push_text(out, fallback);
}