use office_oxide::ir::{DocumentIR, Element, ImageFormat};
use crate::assets::asset_id;
use crate::error::Result;
#[derive(Debug, Clone, PartialEq)]
pub struct OfficeImage {
pub alt: String,
pub ext: String,
pub data: Vec<u8>,
}
fn format_ext(f: Option<&ImageFormat>) -> String {
match f {
Some(ImageFormat::Png) => "png",
Some(ImageFormat::Jpeg) => "jpg",
Some(ImageFormat::Gif) => "gif",
Some(ImageFormat::Tiff) => "tif",
Some(ImageFormat::Bmp) => "bmp",
Some(ImageFormat::Emf) => "emf",
Some(ImageFormat::Wmf) => "wmf",
None => "bin",
}
.to_string()
}
pub fn collect_office_images(ir: &DocumentIR) -> Vec<OfficeImage> {
let mut out = Vec::new();
for section in &ir.sections {
collect_from_elements(§ion.elements, &mut out);
}
out
}
fn collect_from_elements(elements: &[Element], out: &mut Vec<OfficeImage>) {
for el in elements {
match el {
Element::Image(img) => {
if img.decorative {
continue;
}
if let Some(data) = &img.data {
out.push(OfficeImage {
alt: img.alt_text.clone().unwrap_or_default(),
ext: format_ext(img.format.as_ref()),
data: data.clone(),
});
}
}
Element::Table(t) => {
for row in &t.rows {
for cell in &row.cells {
collect_from_elements(&cell.content, out);
}
}
}
Element::TextBox(tb) => collect_from_elements(&tb.content, out),
Element::Footnote(n) | Element::Endnote(n) => {
collect_from_elements(&n.content, out);
}
_ => {}
}
}
}
pub fn collect_package_images(path: &std::path::Path) -> Vec<OfficeImage> {
const IMG_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "tif", "tiff", "bmp", "emf", "wmf"];
let mut opc = match office_oxide::core::opc::OpcReader::open(path) {
Ok(o) => o,
Err(_) => return Vec::new(),
};
let mut names: Vec<String> = opc
.part_names()
.iter()
.map(|p| p.as_str().to_string())
.filter(|n| {
let lower = n.to_lowercase();
lower.contains("/media/")
&& lower.rsplit('.').next().is_some_and(|e| IMG_EXTS.contains(&e))
})
.collect();
names.sort();
let mut out = Vec::new();
for name in names {
let part = match office_oxide::core::opc::PartName::new(&name) {
Ok(p) => p,
Err(_) => continue,
};
let data = match opc.read_part(&part) {
Ok(d) => d,
Err(_) => continue,
};
let lower = name.to_lowercase();
let raw_ext = lower.rsplit('.').next().unwrap_or("bin");
let ext = if raw_ext == "jpeg" { "jpg".to_string() } else { raw_ext.to_string() };
let stem = name
.rsplit('/')
.next()
.unwrap_or("image")
.rsplit_once('.')
.map(|(s, _)| s.to_string())
.unwrap_or_else(|| "image".to_string());
out.push(OfficeImage { alt: stem, ext, data });
}
out
}
pub fn splice_office_images(
md: &str,
images: &[OfficeImage],
dest_dir: &std::path::Path,
stem: &str,
) -> Result<String> {
if images.is_empty() {
return Ok(md.to_string());
}
std::fs::create_dir_all(dest_dir).map_err(crate::error::BobineError::Io)?;
let scope = dest_dir
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("");
let dirname = dest_dir.file_name().and_then(|n| n.to_str()).unwrap_or("assets");
let mut staged: Vec<(String, String)> = Vec::new(); for (k, img) in images.iter().enumerate() {
let name = format!("{}.{}", asset_id(stem, k + 1, &img.data), img.ext);
std::fs::write(dest_dir.join(&name), &img.data).map_err(crate::error::BobineError::Io)?;
let rel = if scope.is_empty() {
format!("{dirname}/{name}")
} else {
format!("{scope}/{dirname}/{name}")
};
staged.push((img.alt.clone(), rel));
}
let re = regex::Regex::new(r"!\[(?P<alt>.*?)\]\((?P<src>.*?)\)").expect("static regex");
let mut cursor = 0usize;
let new_md = re
.replace_all(md, |caps: ®ex::Captures| {
let alt = caps.name("alt").map(|m| m.as_str()).unwrap_or("");
let src = caps.name("src").map(|m| m.as_str()).unwrap_or("");
let target = src.split_whitespace().next().unwrap_or("");
let lower = target.to_lowercase();
let external = target.is_empty()
|| lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("data:")
|| lower.starts_with("okf-asset://")
|| lower.starts_with("_assets/");
if external || cursor >= staged.len() {
return caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string();
}
let (_, rel) = &staged[cursor];
cursor += 1;
let keep_alt = if alt.is_empty() {
staged[cursor - 1].0.clone()
} else {
alt.to_string()
};
format!("")
})
.into_owned();
let mut out = new_md;
let mut gallery = String::new();
for (alt, rel) in staged.iter().skip(cursor) {
if out.contains(&format!("({rel})")) {
continue;
}
gallery.push_str(&format!("\n"));
}
if !gallery.is_empty() {
out.push_str(&format!("\n\n{gallery}"));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use office_oxide::ir::{Image, Note};
fn img(alt: &str) -> Element {
Element::Image(Image {
alt_text: Some(alt.to_string()),
data: Some(vec![1, 2, 3]),
format: Some(ImageFormat::Png),
..Default::default()
})
}
#[test]
fn collect_skips_decorative_and_link_only() {
let deco = Image {
alt_text: None,
data: Some(vec![9]),
format: Some(ImageFormat::Jpeg),
decorative: true,
..Default::default()
};
let link_only = Image {
alt_text: Some("remote".to_string()),
data: None,
format: None,
..Default::default()
};
let ir = DocumentIR {
sections: vec![office_oxide::ir::Section {
elements: vec![
img("a"),
Element::Image(deco),
Element::Image(link_only),
Element::Table(office_oxide::ir::Table {
rows: vec![office_oxide::ir::TableRow {
cells: vec![office_oxide::ir::TableCell {
content: vec![img("nested")],
col_span: 1,
row_span: 1,
..Default::default()
}],
is_header: false,
height_twips: None,
allow_break: true,
repeat_as_header: false,
}],
..Default::default()
}),
Element::Footnote(Note {
id: 1,
content: vec![img("note")],
marker: None,
}),
],
..Default::default()
}],
..Default::default()
};
let got = collect_office_images(&ir);
assert_eq!(got.len(), 3);
assert_eq!(got[0].alt, "a");
assert_eq!(got[0].ext, "png");
assert_eq!(got[1].alt, "nested");
assert_eq!(got[2].alt, "note");
assert_eq!(format_ext(None), "bin");
assert_eq!(format_ext(Some(&ImageFormat::Wmf)), "wmf");
}
#[test]
fn splice_rewrites_positionally_and_galleries_the_rest() {
let dir = std::env::temp_dir().join("bobine_test").join("office_splice");
let _ = std::fs::remove_dir_all(&dir);
let dir = dir.join("scope").join("office_splice");
let images = vec![
OfficeImage { alt: "first".into(), ext: "png".into(), data: vec![1] },
OfficeImage { alt: "second".into(), ext: "jpg".into(), data: vec![2] },
];
let md = "See  and  end.";
let out = splice_office_images(md, &images, &dir, "stem").unwrap();
assert!(out.contains(", "{out}");
assert!(out.contains(""), "{out}");
assert!(out.contains(", "{out}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
}
}