use serde::Serialize;
use hwpforge_smithy_hwpx::presets::builtin_presets;
use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxEncoder};
use crate::output::{read_file_bytes, write_output_file, ToolErrorInfo};
#[derive(Debug, Serialize)]
pub struct RestyleData {
pub output_path: String,
pub applied_preset: String,
pub size_bytes: u64,
pub sections: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
pub fn run_restyle(
file_path: &str,
preset: &str,
output_path: &str,
) -> Result<RestyleData, 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.",
));
}
let presets = builtin_presets();
let preset_info = presets.iter().find(|p| p.name == preset).ok_or_else(|| {
ToolErrorInfo::new(
"PRESET_NOT_FOUND",
format!("Preset '{preset}' not found"),
"Use hwpforge_templates to see available presets.",
)
})?;
let preset_font = preset_info.font.clone();
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 mut style_store = hwpx_doc.style_store;
let original_base: Option<String> =
style_store.iter_fonts().next().map(|f| f.face_name.clone());
match original_base {
Some(ref base) => style_store.replace_font(base, &preset_font),
None => {
return Err(ToolErrorInfo::new(
"NO_FONTS",
"Document has no fonts to restyle",
"The HWPX file may be malformed. Use hwpforge_validate to check.",
));
}
}
let validated = hwpx_doc.document.validate().map_err(|e| {
ToolErrorInfo::new(
"VALIDATION_ERROR",
format!("Document validation failed: {e}"),
"Check document structure.",
)
})?;
let section_count = validated.section_count();
let outcome = HwpxEncoder::encode_with_diagnostics(
&validated,
&style_store,
&hwpx_doc.image_store,
hwpforge_smithy_hwpx::EncodeOptions::default(),
)
.map_err(|e| {
ToolErrorInfo::new(
"ENCODE_ERROR",
format!("HWPX encoding failed: {e}"),
"This may be a bug. Please report at https://github.com/ai-screams/HwpForge/issues",
)
})?;
let semantic: Vec<String> = outcome
.warnings
.iter()
.filter(|w| w.is_semantic_loss())
.map(std::string::ToString::to_string)
.collect();
if !semantic.is_empty() {
return Err(ToolErrorInfo::new(
"ENCODE_SEMANTIC_LOSS",
semantic.join("; "),
"The restyled document would lose footnote/endnote numbering or TOC marks; fix the \
source document or restyle a document without those constructs.",
));
}
let output_bytes = outcome.bytes;
let warnings: Vec<String> =
outcome.warnings.iter().map(std::string::ToString::to_string).collect();
write_output_file(output_path, &output_bytes)?;
let size_bytes = output_bytes.len() as u64;
Ok(RestyleData {
output_path: output_path.to_string(),
applied_preset: preset.to_string(),
size_bytes,
sections: section_count,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restyle_invalid_extension() {
let err = run_restyle("/tmp/doc.hwpx", "default", "/tmp/out.txt").unwrap_err();
assert_eq!(err.code, "INVALID_EXTENSION");
}
#[test]
fn restyle_unknown_preset() {
let err = run_restyle("/tmp/doc.hwpx", "unknown", "/tmp/out.hwpx").unwrap_err();
assert_eq!(err.code, "PRESET_NOT_FOUND");
}
#[test]
fn restyle_missing_file() {
let err = run_restyle("/nonexistent/file.hwpx", "modern", "/tmp/out.hwpx").unwrap_err();
assert_eq!(err.code, "FILE_NOT_FOUND");
}
#[test]
fn restyle_happy_path() {
let dir = tempfile::tempdir().unwrap();
let hwpx_path = dir.path().join("source.hwpx");
crate::tools::convert::run_convert(
"# Test\n\nSome content.",
false,
hwpx_path.to_str().unwrap(),
"default",
)
.unwrap();
let out_path = dir.path().join("restyled.hwpx");
let data =
run_restyle(hwpx_path.to_str().unwrap(), "modern", out_path.to_str().unwrap()).unwrap();
assert!(out_path.exists());
assert_eq!(data.applied_preset, "modern");
assert!(data.size_bytes > 0);
assert!(data.sections >= 1);
}
#[test]
fn restyle_fails_closed_on_note_head_skip() {
use hwpforge_core::control::Control;
use hwpforge_core::image::ImageStore;
use hwpforge_core::page::PageSettings;
use hwpforge_core::run::Run;
use hwpforge_core::section::Section;
use hwpforge_core::{Document, Paragraph};
use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
use hwpforge_smithy_hwpx::style_store::{
HwpxCharShape, HwpxFont, HwpxParaShape, HwpxStyleStore,
};
let mut store = HwpxStyleStore::new();
for &lang in &["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"] {
store.push_font(HwpxFont::new(0, "함초롬돋움", lang));
}
store.push_char_shape(HwpxCharShape::default());
store.push_para_shape(HwpxParaShape::default());
let mut heading_body = Paragraph::with_runs(
vec![Run::text("제목 각주", CharShapeIndex::new(0))],
ParaShapeIndex::new(0),
);
heading_body.heading_level = Some(1);
let mut doc = Document::new();
doc.add_section(Section::with_paragraphs(
vec![Paragraph::with_runs(
vec![Run::control(Control::footnote(vec![heading_body]), CharShapeIndex::new(0))],
ParaShapeIndex::new(0),
)],
PageSettings::a4(),
));
let validated = doc.validate().expect("validate");
let base = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).expect("encode");
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("titlemark-note.hwpx");
std::fs::write(&src, &base).unwrap();
let out = dir.path().join("restyled.hwpx");
let err = run_restyle(src.to_str().unwrap(), "modern", out.to_str().unwrap())
.expect_err("의미 손상(번호 머리 생략)을 무음 통과시키면 안 된다");
assert_eq!(err.code, "ENCODE_SEMANTIC_LOSS", "{err:?}");
assert!(
err.message.contains("note number head skipped"),
"메시지에 경고 Display 가 실려야 한다: {}",
err.message
);
assert!(!out.exists(), "fail-closed 인데 산출 파일이 생성됐다");
}
#[test]
fn restyle_preserves_all_shape_indices_with_complex_doc() {
let dir = tempfile::tempdir().unwrap();
let hwpx_path = dir.path().join("complex.hwpx");
let md = "# Heading\n\nBody text.\n\n```rust\nfn main() {}\n```\n\n> Blockquote\n";
crate::tools::convert::run_convert(md, false, hwpx_path.to_str().unwrap(), "default")
.unwrap();
let out_path = dir.path().join("restyled.hwpx");
let data = run_restyle(hwpx_path.to_str().unwrap(), "classic", out_path.to_str().unwrap())
.unwrap();
assert!(out_path.exists());
assert_eq!(data.applied_preset, "classic");
assert!(data.size_bytes > 0);
let restyled_bytes = std::fs::read(&out_path).unwrap();
let restyled_doc = HwpxDecoder::decode(&restyled_bytes).unwrap();
assert!(!restyled_doc.document.sections().is_empty());
}
}