use std::path::Path;
use serde::Serialize;
use hwpforge::ops::{to_md as ops_to_md, MdExportOptions};
use crate::compat::{self, Tool};
use crate::output::{read_file_bytes, write_output_file, ToolErrorInfo, ToolWarningInfo};
#[derive(Debug, Serialize)]
pub struct ToMdData {
pub markdown_path: String,
pub image_paths: Vec<String>,
pub size_bytes: u64,
pub image_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ToolWarningInfo>,
}
pub fn run_to_md(file_path: &str, output_dir: Option<&str>) -> Result<ToMdData, ToolErrorInfo> {
let ext_ok = Path::new(file_path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("hwpx"))
.unwrap_or(false);
if !ext_ok {
return Err(ToolErrorInfo::new(
"INVALID_INPUT",
format!("Expected a .hwpx file, got: {file_path}"),
"Provide a path to a valid .hwpx document.",
));
}
let bytes = read_file_bytes(file_path)?;
let out = ops_to_md(&bytes, &MdExportOptions::default())
.map_err(|e| compat::tool_error(Tool::ToMd, e))?;
let warnings: Vec<ToolWarningInfo> = out.warnings.iter().map(compat::warning).collect();
let base_stem = Path::new(file_path).file_stem().and_then(|s| s.to_str()).unwrap_or("output");
let out_dir: String = if let Some(dir) = output_dir {
dir.to_string()
} else {
Path::new(file_path).parent().and_then(|p| p.to_str()).unwrap_or(".").to_string()
};
let md_filename = format!("{base_stem}.md");
let md_path = Path::new(&out_dir).join(&md_filename).to_string_lossy().into_owned();
let md_bytes = out.markdown.as_bytes();
write_output_file(&md_path, md_bytes)?;
let mut image_paths: Vec<String> = Vec::new();
if !out.images.is_empty() {
let images_dir = Path::new(&out_dir).join("images");
let images_dir_str = images_dir.to_string_lossy().into_owned();
std::fs::create_dir_all(&images_dir).map_err(|e| {
ToolErrorInfo::new(
"DIR_CREATE_ERROR",
format!("Cannot create images directory '{}': {e}", images_dir_str),
"Check write permissions for the output directory.",
)
})?;
for (rel_name, data) in &out.images {
let img_filename = Path::new(rel_name.as_str())
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image");
let img_path = images_dir.join(img_filename);
let img_path_str = img_path.to_string_lossy().into_owned();
write_output_file(&img_path_str, data)?;
image_paths.push(img_path_str);
}
}
image_paths.sort();
let size_bytes = md_bytes.len() as u64;
let image_count = image_paths.len();
Ok(ToMdData { markdown_path: md_path, image_paths, size_bytes, image_count, warnings })
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("hwpforge_mcp_to_md_{}", std::process::id()))
.join(name);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn merged_form_hwpx(dir: &std::path::Path) -> String {
use hwpforge_core::page::PageSettings;
use hwpforge_core::run::Run;
use hwpforge_core::table::{Table, TableCell, TableRow};
use hwpforge_core::{Document, Paragraph, Section};
use hwpforge_foundation::{CharShapeIndex, HwpUnit, ParaShapeIndex};
use hwpforge_smithy_hwpx::style_store::{HwpxCharShape, HwpxParaShape, HwpxStyleStore};
use hwpforge_smithy_hwpx::HwpxEncoder;
let text_para = |t: &str| {
Paragraph::with_runs(vec![Run::text(t, CharShapeIndex::new(0))], ParaShapeIndex::new(0))
};
let cell = |t: &str, rs: u16| {
TableCell::with_span(vec![text_para(t)], HwpUnit::new(8000).unwrap(), 1, rs)
};
let table = Table::new(vec![
TableRow::new(vec![cell("병합", 2), cell("우측", 1)]),
TableRow::new(vec![cell("아래", 1)]),
]);
let mut host = Paragraph::new(ParaShapeIndex::new(0));
host.add_run(Run::table(table, CharShapeIndex::new(0)));
let mut doc = Document::new();
doc.add_section(Section::with_paragraphs(vec![host], PageSettings::default()));
let mut styles = HwpxStyleStore::with_default_fonts("함초롬돋움");
styles.push_char_shape(HwpxCharShape::default());
styles.push_para_shape(HwpxParaShape::default());
let bytes = HwpxEncoder::encode(
&doc.validate().unwrap(),
&styles,
&hwpforge_core::image::ImageStore::new(),
)
.unwrap();
let path = dir.join("merged.hwpx");
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn to_md_converts_merged_table_as_html_spans() {
let dir = temp_dir("convert");
let input = merged_form_hwpx(&dir);
let data = run_to_md(&input, Some(dir.to_str().unwrap())).unwrap();
assert!(std::path::Path::new(&data.markdown_path).exists());
assert_eq!(data.image_count, 0);
let md = std::fs::read_to_string(&data.markdown_path).unwrap();
assert!(md.contains("rowspan=\"2\""), "{md}");
assert!(data.warnings.is_empty(), "a clean document must not warn: {:?}", data.warnings);
}
fn fixture(rel: &str) -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(rel)
.to_str()
.unwrap()
.to_string()
}
#[test]
fn to_md_surfaces_decode_warnings() {
let dir = temp_dir("warnings");
let path = fixture("layout/stale-line-cache.hwpx");
let data = run_to_md(&path, Some(dir.to_str().unwrap())).unwrap();
assert!(
data.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"to_md must surface the decode warning: {:?}",
data.warnings
);
let value = serde_json::to_value(&data).unwrap();
assert_eq!(value["warnings"][0]["code"], "LAYOUT_CACHE_DROPPED");
assert!(!value["warnings"][0]["message"].as_str().unwrap_or_default().is_empty());
}
#[test]
fn to_md_rejects_non_hwpx_extension() {
let dir = temp_dir("reject");
let bogus = dir.join("doc.txt");
std::fs::write(&bogus, b"x").unwrap();
let err = run_to_md(bogus.to_str().unwrap(), None).unwrap_err();
assert_eq!(err.code, "INVALID_INPUT");
}
}