use serde::Serialize;
use hwpforge::ops::{restyle as ops_restyle, OpsError, RestyleOptions};
use hwpforge_smithy_hwpx::presets::builtin_presets;
use crate::compat::{self, Tool};
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.",
));
}
if !builtin_presets().iter().any(|p| p.name == preset) {
return Err(compat::tool_error(
Tool::Restyle,
OpsError::PresetNotFound { name: preset.to_string() },
));
}
let bytes = read_file_bytes(file_path)?;
let out = ops_restyle(&bytes, &RestyleOptions::default().with_preset(preset))
.map_err(|e| compat::tool_error(Tool::Restyle, e))?;
let warnings: Vec<String> = out.warnings.iter().map(|w| compat::warning(w).message).collect();
write_output_file(output_path, &out.bytes)?;
let size_bytes = out.bytes.len() as u64;
Ok(RestyleData {
output_path: output_path.to_string(),
applied_preset: preset.to_string(),
size_bytes,
sections: out.sections,
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,
};
use hwpforge_smithy_hwpx::HwpxEncoder;
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_surfaces_the_decode_warning_from_a_stale_layout_cache() {
let src = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/layout/stale-line-cache.hwpx"
);
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("restyled.hwpx");
let data = run_restyle(src, "modern", out.to_str().unwrap()).unwrap();
let value = serde_json::to_value(&data).unwrap();
let warnings = value["warnings"].as_array().expect("warnings array");
assert_eq!(warnings.len(), 1, "{value}");
let message = warnings[0].as_str().expect("warnings entries are strings");
assert!(!message.is_empty());
assert!(message.contains("layout cache dropped"), "{message}");
}
#[test]
fn restyle_on_a_clean_fixture_has_no_warnings_key() {
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();
let value = serde_json::to_value(&data).unwrap();
assert!(value.get("warnings").is_none(), "{value}");
}
#[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);
use hwpforge_smithy_hwpx::HwpxDecoder;
let restyled_bytes = std::fs::read(&out_path).unwrap();
let restyled_doc = HwpxDecoder::decode(&restyled_bytes).unwrap();
assert!(!restyled_doc.document.sections().is_empty());
}
}