bobine 0.5.11

PDF / Office / text → Markdown ingestion engine with ONNX formula OCR (TexTeller), layout analysis and OCR
Documentation
//! Office conversion tests — real fixtures, no mocks.
//!
//! Fixtures live in `tests/fixtures/office/` and are generated by
//! `cargo run --example gen_office_fixtures` (office_oxide `create` API,
//! IR → OOXML). Regenerate after touching the generator; keep sizes small.
//! Legacy doc/xls/ppt have no creation API — no fixtures yet (see
//! IMPLEMENTATION_PLAN_office.md Phase 0 outcome).

use std::path::PathBuf;

fn fixture(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("office")
        .join(name)
}

fn work_dir(name: &str) -> PathBuf {
    std::env::temp_dir().join("bobine_test").join("office").join(name)
}

/// Convert via the real extension dispatcher (same path users hit).
fn convert_office(name: &str, work_name: &str) -> String {
    let work = work_dir(work_name);
    std::fs::create_dir_all(&work).expect("work dir");
    let cfg = bobine::ConverterConfig::default();
    let mut conv = bobine::HybridConverter::new(cfg, &work.join("cache"));
    conv.convert(&fixture(name), &work).expect("office convert")
}

/// Assert every needle appears in `md`, in order.
fn assert_contains_in_order(md: &str, needles: &[&str]) {
    let mut pos = 0;
    for needle in needles {
        match md[pos..].find(needle) {
            Some(off) => pos += off + needle.len(),
            None => panic!("needle {needle:?} missing after offset {pos} in:\n{md}"),
        }
    }
}

#[test]
fn office_fixtures_exist_and_are_small() {
    for name in ["basic.docx", "types.xlsx", "deck.pptx"] {
        let meta = std::fs::metadata(fixture(name)).expect("fixture present");
        assert!(meta.len() < 200 * 1024, "{name} too big: {}", meta.len());
    }
}

#[test]
fn docx_converts_with_structure_in_order() {
    let md = convert_office("basic.docx", "docx");
    assert_contains_in_order(
        &md,
        &[
            "Bobine Office Fixture",
            "Styled paragraph",
            "bold",
            "italic",
            "hyperlink",
            "alpha item",
            "first step",
            "Apples",
            "Merged across two columns",
            "A claim needing citation",
        ],
    );
    // GFM pipe table with header row + caption text.
    assert!(md.contains("| Name | Qty | Price |"), "docx GFM header:\n{md}");
    assert!(md.contains("Fixture inventory"), "docx caption:\n{md}");
    // Embedded picture keeps its alt text (Phase 3 stages the bytes).
    assert!(
        md.contains("red-green checkerboard fixture"),
        "docx image alt:\n{md}"
    );
}

#[test]
fn xlsx_converts_all_sheets_with_typed_cells() {
    let md = convert_office("types.xlsx", "xlsx");
    assert_contains_in_order(
        &md,
        &[
            "Data",
            "Inventory",
            "Item",
            "Apples",
            "12",
            "2024-01-01",
            "Summary",
            "Sheets",
        ],
    );
    // Sheet boundaries + typed cells (date / percent render formatted).
    assert!(md.contains("## Data"), "xlsx Data sheet:\n{md}");
    assert!(md.contains("## Summary"), "xlsx Summary sheet:\n{md}");
    assert!(
        md.contains("| Item | Count | Price | In stock |"),
        "xlsx GFM header:\n{md}"
    );
    assert!(md.contains("38%"), "xlsx percent:\n{md}");
}

#[test]
fn pptx_converts_slides_in_order() {
    let md = convert_office("deck.pptx", "pptx");
    assert_contains_in_order(
        &md,
        &[
            "Title slide",
            "Bobine Deck Fixture",
            "Bullets and picture",
            "Agenda",
            "Convert",
            "Numbers",
            "Quarterly",
            "Q1",
        ],
    );
    // Slide boundaries; bullets/table keep text (markers are upstream style).
    assert!(md.contains("## Title slide"), "pptx slide 1:\n{md}");
    assert!(md.contains("## Bullets and picture"), "pptx slide 2:\n{md}");
    assert!(md.contains("## Numbers"), "pptx slide 3:\n{md}");
    assert!(md.contains("Ship"), "pptx bullets:\n{md}");
    assert!(md.contains("Revenue"), "pptx table:\n{md}");
}

#[test]
fn docx_pictures_stage_into_work_dir() {
    let work = work_dir("docx_img");
    let _ = std::fs::remove_dir_all(&work);
    std::fs::create_dir_all(&work).expect("work dir");
    let cfg = bobine::ConverterConfig::default();
    let mut conv = bobine::HybridConverter::new(cfg, &work.join("cache"));
    let md = conv.convert(&fixture("basic.docx"), &work).expect("convert");
    // Relationship-id link rewritten to the staged file.
    assert!(!md.contains("(rId"), "rId link remains:\n{md}");
    assert!(md.contains("assets/office/img_"), "staged link:\n{md}");
    assert!(md.contains("red-green checkerboard fixture"), "alt kept:\n{md}");
    // Staged bytes decode to the 16×16 checkerboard the generator wrote.
    let staged: Vec<_> = std::fs::read_dir(work.join("assets").join("office"))
        .expect("office asset dir")
        .collect::<Result<_, _>>()
        .expect("read dir");
    assert_eq!(staged.len(), 1);
    let bytes = std::fs::read(staged[0].path()).expect("read staged");
    let img = image::load_from_memory(&bytes).expect("staged decodes");
    assert_eq!((img.width(), img.height()), (16, 16));
    let rgb = img.to_rgb8();
    assert_eq!(*rgb.get_pixel(0, 0), image::Rgb([200, 30, 30]));
    assert_eq!(*rgb.get_pixel(1, 0), image::Rgb([30, 160, 30]));
}

#[test]
fn pptx_and_xlsx_pictures_land_in_gallery() {
    // Both formats drop pictures from their markdown; staging appends them.
    // (pptx IR images carry no alt text upstream — gallery links may be
    // bare; xlsx falls back to the package media stem.)
    for (name, work_name) in [("deck.pptx", "pptx_img"), ("types.xlsx", "xlsx_img")] {
        let work = work_dir(work_name);
        let _ = std::fs::remove_dir_all(&work);
        std::fs::create_dir_all(&work).expect("work dir");
        let cfg = bobine::ConverterConfig::default();
        let mut conv = bobine::HybridConverter::new(cfg, &work.join("cache"));
        let md = conv.convert(&fixture(name), &work).expect("convert");
        assert!(md.contains("assets/office/img_"), "{name} gallery:\n{md}");
        let staged: Vec<_> = std::fs::read_dir(work.join("assets").join("office"))
            .expect("office asset dir")
            .collect::<Result<_, _>>()
            .expect("read dir");
        assert_eq!(staged.len(), 1, "{name} staged count");
        // Staged bytes are the generator's PNG (PNG magic + 16×16).
        let bytes = std::fs::read(staged[0].path()).expect("read staged");
        assert_eq!(&bytes[..8], &[137, 80, 78, 71, 13, 10, 26, 10], "{name} png magic");
        let img = image::load_from_memory(&bytes).expect("staged decodes");
        assert_eq!((img.width(), img.height()), (16, 16), "{name} dims");
    }
}

#[test]
fn office_images_respect_extract_images_off() {
    let work = work_dir("docx_noimg");
    let _ = std::fs::remove_dir_all(&work);
    std::fs::create_dir_all(&work).expect("work dir");
    let mut cfg = bobine::ConverterConfig::default();
    cfg.render.extract_images = false;
    let mut conv = bobine::HybridConverter::new(cfg, &work.join("cache"));
    let md = conv.convert(&fixture("basic.docx"), &work).expect("convert");
    // Upstream markdown untouched: rId link stays, nothing staged.
    assert!(md.contains("(rId"), "untouched:\n{md}");
    assert!(!work.join("assets").exists(), "no asset dir");
}

fn normalize(md: &str) -> String {
    md.replace("\r\n", "\n")
        .lines()
        .map(|l| l.trim_end())
        .collect::<Vec<_>>()
        .join("\n")
        .trim()
        .to_string()
        + "\n"
}

/// Golden regression for the three OOXML fixtures (deterministic: no models,
/// content-hash staged image links). Regenerate after an *intentional* output
/// change: `BOBINE_UPDATE_GOLDENS=1 cargo test --test test_office office_golden`.
#[test]
fn office_golden_files() {
    let golden_dir =
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("golden").join("office");
    std::fs::create_dir_all(&golden_dir).expect("golden dir");
    let update = std::env::var("BOBINE_UPDATE_GOLDENS").is_ok();
    for (name, stem) in [("basic.docx", "basic"), ("types.xlsx", "types"), ("deck.pptx", "deck")] {
        let md = normalize(&convert_office(name, &format!("golden_{stem}")));
        let golden_path = golden_dir.join(format!("{stem}.golden.md"));
        if update || !golden_path.exists() {
            std::fs::write(&golden_path, &md).expect("write golden");
            continue;
        }
        let expected = normalize(&std::fs::read_to_string(&golden_path).expect("read golden"));
        assert_eq!(expected, md, "{name} golden mismatch");
    }
}

#[test]
fn ingest_promotes_office_images_to_asset_store() {
    let out = std::env::temp_dir().join("bobine_test").join("office_ingest");
    let _ = std::fs::remove_dir_all(&out);
    let doc = bobine::pipeline::ingest_document(
        &fixture("basic.docx"),
        &out,
        None,
        None,
        &bobine::ProgressHooks::default(),
    )
    .expect("ingest docx");
    assert_eq!(doc.image_count, 1, "one staged picture");
    assert!(doc.md_text.contains("okf-asset://"), "promoted:\n{}", doc.md_text);
    assert!(doc.md_text.contains("red-green checkerboard fixture"), "alt kept");
}

#[test]
fn office_convert_never_panics_on_empty_docx() {
    // Minimal one-section, zero-element .docx must convert, not panic.
    let dir = work_dir("empty");
    std::fs::create_dir_all(&dir).expect("tmp");
    let path = dir.join("empty.docx");
    let ir = office_oxide::DocumentIR {
        metadata: office_oxide::ir::Metadata {
            format: office_oxide::DocumentFormat::Docx,
            ..Default::default()
        },
        sections: vec![office_oxide::ir::Section::default()],
    };
    office_oxide::create::create_from_ir(&ir, office_oxide::DocumentFormat::Docx, &path)
        .expect("write empty docx");
    bobine::HybridConverter::convert_office(&path).expect("empty docx converts");
}