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)
}
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")
}
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",
],
);
assert!(md.contains("| Name | Qty | Price |"), "docx GFM header:\n{md}");
assert!(md.contains("Fixture inventory"), "docx caption:\n{md}");
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",
],
);
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",
],
);
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");
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}");
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() {
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");
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");
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"
}
#[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() {
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");
}