use std::path::Path;
use serde::Serialize;
use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxStyleLookup};
use hwpforge_smithy_md::MdEncoder;
use crate::output::{read_file_bytes, write_output_file, ToolErrorInfo};
#[derive(Debug, Serialize)]
pub struct ToMdData {
pub markdown_path: String,
pub image_paths: Vec<String>,
pub size_bytes: u64,
pub image_count: usize,
}
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 hwpx_doc = HwpxDecoder::decode(&bytes).map_err(|e| {
ToolErrorInfo::new(
"DECODE_ERROR",
format!("HWPX decode failed: {e}"),
"Check that the file is a valid HWPX document.",
)
})?;
let validated = hwpx_doc.document.validate().map_err(|e| {
ToolErrorInfo::new(
"VALIDATION_ERROR",
format!("Document validation failed: {e}"),
"The HWPX document structure is invalid.",
)
})?;
let lookup = HwpxStyleLookup::new(&hwpx_doc.style_store, &hwpx_doc.image_store);
let md_output = MdEncoder::encode_styled(&validated, &lookup);
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 = md_output.markdown.as_bytes();
write_output_file(&md_path, md_bytes)?;
let mut image_paths: Vec<String> = Vec::new();
if !md_output.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 &md_output.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 })
}
#[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}");
}
#[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");
}
}