use serde::Serialize;
use hwpforge::ops;
use hwpforge::ops::CellSpec;
use hwpforge_smithy_hwpx::SetCellResult;
use crate::compat::{self, Tool};
use crate::output::{read_file_bytes, write_output_file, ToolErrorInfo, ToolWarningInfo};
#[derive(Debug, Serialize)]
pub struct SetCellData {
pub output_path: String,
pub cells: Vec<SetCellResult>,
pub size_bytes: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ToolWarningInfo>,
}
pub fn run_set_cell(
file_path: &str,
specs: &[CellSpec],
output_path: &str,
) -> Result<SetCellData, ToolErrorInfo> {
if !output_path.ends_with(".hwpx") {
return Err(ToolErrorInfo::new(
"INVALID_EXTENSION",
format!("Output path must end with .hwpx: {output_path}"),
"Use a .hwpx extension for the output file.",
));
}
if specs.is_empty() {
return Err(ToolErrorInfo::new(
"INVALID_SET_CELL_MAP",
"specs is empty",
"Pass at least one CellSpec: {table, at|right_of|below, text}.",
));
}
let bytes = read_file_bytes(file_path)?;
let opts = ops::SetCellOptions::default().with_specs(specs.to_vec());
let outcome = ops::set_cell(&bytes, &opts).map_err(|e| compat::tool_error(Tool::SetCell, e))?;
write_output_file(output_path, &outcome.bytes)?;
let warnings: Vec<ToolWarningInfo> = outcome.warnings.iter().map(compat::warning).collect();
Ok(SetCellData {
output_path: output_path.to_string(),
cells: outcome.results,
size_bytes: outcome.bytes.len() as u64,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
use hwpforge_core::table::grid::GridCoord;
use hwpforge_smithy_hwpx::CellTarget;
fn spec(table: usize, target: CellTarget, text: &str) -> CellSpec {
CellSpec { table, target, text: text.to_string() }
}
fn label_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| TableCell::new(vec![text_para(t)], HwpUnit::new(8000).unwrap());
let table = Table::new(vec![
TableRow::new(vec![cell("성명"), cell("")]),
TableRow::new(vec![cell("주소"), cell("")]),
]);
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("label_form.hwpx");
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("hwpforge_mcp_set_cell_{}", std::process::id()))
.join(name);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn set_cell_edits_by_label_and_reports_resolution() {
let dir = temp_dir("edit");
let input = label_form_hwpx(&dir);
let output = dir.join("edited.hwpx").to_string_lossy().into_owned();
let data = run_set_cell(
&input,
&[
spec(0, CellTarget::RightOf("성명".into()), "홍길동"),
spec(0, CellTarget::At(GridCoord::new(1, 1)), "서울"),
],
&output,
)
.unwrap();
assert_eq!(data.cells.len(), 2);
assert!(std::path::Path::new(&output).exists());
assert!(data.warnings.is_empty(), "a clean input must not warn: {:?}", data.warnings);
let decoded =
hwpforge_smithy_hwpx::HwpxDecoder::decode(&std::fs::read(&output).unwrap()).unwrap();
let table = decoded.document.sections()[0].paragraphs[0]
.runs
.iter()
.find_map(|r| r.content.as_table())
.unwrap();
assert_eq!(table.rows[0].cells[1].paragraphs[0].text_content(), "홍길동");
assert_eq!(table.rows[1].cells[1].paragraphs[0].text_content(), "서울");
}
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 set_cell_surfaces_decode_warnings() {
let path = fixture("layout/stale-line-cache.hwpx");
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out.hwpx").to_string_lossy().into_owned();
let data = run_set_cell(&path, &[spec(0, CellTarget::At(GridCoord::new(0, 0)), "x")], &out)
.unwrap();
assert!(
data.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"set_cell must surface the admission 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 set_cell_rejects_bad_targets_with_codes() {
let dir = temp_dir("reject");
let input = label_form_hwpx(&dir);
let output = dir.join("never.hwpx").to_string_lossy().into_owned();
let err =
run_set_cell(&input, &[spec(9, CellTarget::At(GridCoord::new(0, 0)), "x")], &output)
.unwrap_err();
assert_eq!(err.code, "TABLE_NOT_FOUND");
let err =
run_set_cell(&input, &[spec(0, CellTarget::RightOf("연락처".into()), "x")], &output)
.unwrap_err();
assert_eq!(err.code, "CELL_NOT_FOUND");
let err = run_set_cell(&input, &[], &output).unwrap_err();
assert_eq!(err.code, "INVALID_SET_CELL_MAP");
assert!(!std::path::Path::new(&output).exists(), "no output on rejection");
}
}