use hwpforge_core::NumberingDef;
use hwpforge_foundation::{
Alignment, BreakType, Color, EmbossType, EmphasisType, EngraveType, HwpUnit, LineSpacingType,
NumberFormatType, OutlineType, ShadowType, StrikeoutShape, UnderlineType, VerticalPosition,
WordBreakType,
};
use super::{escape_xml, header_tabs::build_tab_properties_xml};
use crate::error::{HwpxError, HwpxResult};
use crate::list_bridge::{bullet_def_to_hwpx, wire_parts_to_heading};
use crate::schema::header::{
HxAlign, HxAutoSpacing, HxBorder, HxBreakSetting, HxBullet, HxBulletParaHead, HxCharPr,
HxCharProperties, HxFont, HxFontFaceGroup, HxFontFaces, HxFontRef, HxHead, HxLangValues,
HxLineSpacing, HxMargin, HxOutline, HxParaPr, HxParaProperties, HxPresence, HxRefList,
HxShadow, HxStrikeout, HxStyle, HxStyles, HxSwitch, HxSwitchCase, HxSwitchDefault, HxTypeInfo,
HxUnderline, HxUnitValue,
};
use crate::style_store::{
ActiveBorderFillBrush, HwpxBorderFill, HwpxBorderLine, HwpxCharShape, HwpxDiagonalLine,
HwpxFont, HwpxGradientFill, HwpxImageFill, HwpxParaShape, HwpxStyle, HwpxStyleStore,
};
pub(crate) fn encode_header(
store: &HwpxStyleStore,
sec_cnt: u32,
begin_num: Option<&hwpforge_core::section::BeginNum>,
) -> HwpxResult<String> {
let head = build_head(store, sec_cnt);
let head_xml = quick_xml::se::to_string(&head)
.map_err(|e| HwpxError::XmlSerialize { detail: e.to_string() })?;
let inner = extract_inner_content(&head_xml);
wrap_header_xml(inner, sec_cnt, store, begin_num)
}
const POST_REFLIST_XML: &str = concat!(
r#"<hh:compatibleDocument targetProgram="HWP201X">"#,
r#"<hh:layoutCompatibility/>"#,
r#"</hh:compatibleDocument>"#,
r#"<hh:docOption>"#,
r#"<hh:linkinfo path="" pageInherit="0" footnoteInherit="0"/>"#,
r#"</hh:docOption>"#,
r#"<hh:trackchageConfig flags="56"/>"#,
);
fn wrap_header_xml(
inner_xml: &str,
sec_cnt: u32,
store: &HwpxStyleStore,
begin_num: Option<&hwpforge_core::section::BeginNum>,
) -> HwpxResult<String> {
let enriched = enrich_ref_list(inner_xml, store)?;
let begin_num_xml = build_begin_num_xml(begin_num);
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hh:head{xmlns} version="1.4" secCnt="{sec_cnt}">{begin_num_xml}{enriched}{post_reflist}</hh:head>"#,
xmlns = crate::encoder::package::XMLNS_DECLS,
post_reflist = POST_REFLIST_XML,
))
}
fn build_begin_num_xml(begin_num: Option<&hwpforge_core::section::BeginNum>) -> String {
let bn = begin_num.copied().unwrap_or_default();
format!(
r#"<hh:beginNum page="{}" footnote="{}" endnote="{}" pic="{}" tbl="{}" equation="{}"/>"#,
bn.page, bn.footnote, bn.endnote, bn.pic, bn.tbl, bn.equation,
)
}
fn build_border_fills_xml(store: &HwpxStyleStore) -> String {
if store.border_fill_count() == 0 {
let page = HwpxBorderFill::default_page_border();
let char_bg = HwpxBorderFill::default_char_background();
let table = HwpxBorderFill::default_table_border();
let count = 3u32;
let mut xml = format!(r##"<hh:borderFills itemCnt="{count}">"##);
for bf in [&page, &char_bg, &table] {
xml.push_str(&build_border_fill_xml(bf));
}
xml.push_str("</hh:borderFills>");
return xml;
}
let count = store.border_fill_count();
let mut xml = format!(r##"<hh:borderFills itemCnt="{count}">"##);
for bf in store.iter_border_fills() {
xml.push_str(&build_border_fill_xml(bf));
}
xml.push_str("</hh:borderFills>");
xml
}
fn build_border_fill_xml(bf: &HwpxBorderFill) -> String {
let three_d = u32::from(bf.three_d);
let shadow = u32::from(bf.shadow);
let mut xml = format!(
r##"<hh:borderFill id="{}" threeD="{three_d}" shadow="{shadow}" centerLine="{}" breakCellSeparateLine="0">"##,
bf.id, bf.center_line,
);
xml.push_str(&build_diagonal_xml("hh:slash", &bf.slash, bf.effective_slash_type()));
xml.push_str(&build_diagonal_xml(
"hh:backSlash",
&bf.back_slash,
bf.effective_back_slash_type(),
));
xml.push_str(&build_border_line_xml("hh:leftBorder", &bf.left));
xml.push_str(&build_border_line_xml("hh:rightBorder", &bf.right));
xml.push_str(&build_border_line_xml("hh:topBorder", &bf.top));
xml.push_str(&build_border_line_xml("hh:bottomBorder", &bf.bottom));
if let Some(diag) = &bf.diagonal {
xml.push_str(&build_border_line_xml("hh:diagonal", diag));
}
if has_fill_brush(bf) {
xml.push_str(&build_fill_brush_xml(bf));
}
xml.push_str("</hh:borderFill>");
xml
}
fn build_diagonal_xml(tag: &str, diagonal: &HwpxDiagonalLine, legacy_border_type: &str) -> String {
let border_type = if diagonal.border_type == "NONE" && legacy_border_type != "NONE" {
legacy_border_type
} else {
diagonal.border_type.as_str()
};
format!(
r##"<{tag} type="{border_type}" Crooked="{crooked}" isCounter="{is_counter}"/>"##,
border_type = border_type,
crooked = u32::from(diagonal.crooked),
is_counter = u32::from(diagonal.is_counter),
)
}
fn build_border_line_xml(tag: &str, line: &HwpxBorderLine) -> String {
format!(r##"<{tag} type="{}" width="{}" color="{}"/>"##, line.line_type, line.width, line.color,)
}
fn build_fill_brush_xml(border_fill: &HwpxBorderFill) -> String {
match border_fill.active_fill_brush() {
ActiveBorderFillBrush::Image(fill) => build_image_fill_brush_xml(fill),
ActiveBorderFillBrush::Gradient(fill) => build_gradient_fill_brush_xml(fill),
ActiveBorderFillBrush::WinBrush { face_color, hatch_color, hatch_style, alpha } => {
let hatch_style_attr =
hatch_style.map(|style| format!(r#" hatchStyle="{style}""#)).unwrap_or_default();
format!(
r##"<hc:fillBrush><hc:winBrush faceColor="{face_color}" hatchColor="{hatch_color}"{hatch_style_attr} alpha="{alpha}"/></hc:fillBrush>"##
)
}
ActiveBorderFillBrush::None => {
unreachable!("fill brush must exist when serializing border fill")
}
}
}
fn has_fill_brush(border_fill: &HwpxBorderFill) -> bool {
!matches!(border_fill.active_fill_brush(), ActiveBorderFillBrush::None)
}
fn build_gradient_fill_brush_xml(fill: &HwpxGradientFill) -> String {
let colors = fill
.colors
.iter()
.map(|color| format!(r##"<hc:color value="{}"/>"##, color.to_hex_rgb()))
.collect::<String>();
format!(
r##"<hc:fillBrush><hc:gradation type="{gradient_type}" angle="{angle}" centerX="{center_x}" centerY="{center_y}" step="{step}" colorNum="{color_num}" stepCenter="{step_center}" alpha="{alpha}">{colors}</hc:gradation></hc:fillBrush>"##,
gradient_type = fill.gradient_type,
angle = fill.angle,
center_x = fill.center_x,
center_y = fill.center_y,
step = fill.step,
color_num = fill.colors.len(),
step_center = fill.step_center,
alpha = fill.alpha,
colors = colors,
)
}
fn build_image_fill_brush_xml(fill: &HwpxImageFill) -> String {
format!(
r##"<hc:fillBrush><hc:imgBrush mode="{mode}"><hc:img binaryItemIDRef="{binary_item_id_ref}" bright="{bright}" contrast="{contrast}" effect="{effect}" alpha="{alpha}"/></hc:imgBrush></hc:fillBrush>"##,
mode = fill.mode,
binary_item_id_ref = fill.binary_item_id_ref,
bright = fill.bright,
contrast = fill.contrast,
effect = fill.effect,
alpha = fill.alpha,
)
}
fn build_numberings_xml(store: &HwpxStyleStore) -> String {
let numberings: Vec<NumberingDef> = if store.numbering_count() == 0 {
vec![NumberingDef::default_outline()]
} else {
store.iter_numberings().cloned().collect()
};
let count = numberings.len();
let mut xml = format!(r#"<hh:numberings itemCnt="{count}">"#);
for ndef in &numberings {
xml.push_str(&format!(r#"<hh:numbering id="{}" start="{}">"#, ndef.id, ndef.start));
for lvl in &ndef.levels {
let num_format = number_format_to_hwpx(lvl.num_format);
let checkable = u32::from(lvl.checkable);
if lvl.text.is_empty() {
xml.push_str(&format!(
r#"<hh:paraHead start="{}" level="{}" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="{num_format}" charPrIDRef="4294967295" checkable="{checkable}"/>"#,
lvl.start, lvl.level,
));
} else {
xml.push_str(&format!(
r#"<hh:paraHead start="{}" level="{}" align="LEFT" useInstWidth="1" autoIndent="1" widthAdjust="0" textOffsetType="PERCENT" textOffset="50" numFormat="{num_format}" charPrIDRef="4294967295" checkable="{checkable}">{}</hh:paraHead>"#,
lvl.start,
lvl.level,
escape_xml(&lvl.text),
));
}
}
xml.push_str("</hh:numbering>");
}
xml.push_str("</hh:numberings>");
xml
}
fn build_bullets_xml(store: &HwpxStyleStore) -> String {
if store.bullet_count() == 0 {
return String::new();
}
let items: Vec<HxBullet> = store.iter_bullets().map(bullet_def_to_hwpx).collect();
let count = items.len() as u32;
let mut xml = format!(r#"<hh:bullets itemCnt="{count}">"#);
for bullet in &items {
xml.push_str(&build_bullet_xml(bullet));
}
xml.push_str("</hh:bullets>");
xml
}
fn build_bullet_xml(bullet: &HxBullet) -> String {
let checked_char_attr = bullet
.checked_char
.as_ref()
.map(|checked_char| format!(r#" checkedChar="{}""#, escape_xml(checked_char)))
.unwrap_or_default();
let mut xml = format!(
r#"<hh:bullet id="{}" char="{}"{} useImage="{use_image}">"#,
bullet.id,
escape_xml(&bullet.bullet_char),
checked_char_attr,
use_image = bullet.use_image,
);
for para_head in &bullet.para_heads {
xml.push_str(&build_bullet_para_head_xml(para_head));
}
xml.push_str("</hh:bullet>");
xml
}
fn build_bullet_para_head_xml(para_head: &HxBulletParaHead) -> String {
if para_head.text.is_empty() {
return format!(
r#"<hh:paraHead level="{}" align="{}" useInstWidth="{}" autoIndent="{}" widthAdjust="{}" textOffsetType="{}" textOffset="{}" numFormat="{}" charPrIDRef="{}" checkable="{}"/>"#,
para_head.level,
para_head.align,
para_head.use_inst_width,
para_head.auto_indent,
para_head.width_adjust,
para_head.text_offset_type,
para_head.text_offset,
para_head.num_format,
para_head.char_pr_id_ref,
para_head.checkable,
);
}
let mut xml = format!(
r#"<hh:paraHead level="{}" align="{}" useInstWidth="{}" autoIndent="{}" widthAdjust="{}" textOffsetType="{}" textOffset="{}" numFormat="{}" charPrIDRef="{}" checkable="{}">"#,
para_head.level,
para_head.align,
para_head.use_inst_width,
para_head.auto_indent,
para_head.width_adjust,
para_head.text_offset_type,
para_head.text_offset,
para_head.num_format,
para_head.char_pr_id_ref,
para_head.checkable,
);
xml.push_str(&escape_xml(¶_head.text));
xml.push_str("</hh:paraHead>");
xml
}
fn number_format_to_hwpx(nf: NumberFormatType) -> &'static str {
match nf {
NumberFormatType::Digit => "DIGIT",
NumberFormatType::CircledDigit => "CIRCLED_DIGIT",
NumberFormatType::RomanCapital => "ROMAN_CAPITAL",
NumberFormatType::RomanSmall => "ROMAN_SMALL",
NumberFormatType::LatinCapital => "LATIN_CAPITAL",
NumberFormatType::LatinSmall => "LATIN_SMALL",
NumberFormatType::CircledLatinSmall => "CIRCLED_LATIN_SMALL",
NumberFormatType::HangulSyllable => "HANGUL_SYLLABLE",
NumberFormatType::HangulJamo => "HANGUL_JAMO",
NumberFormatType::HanjaDigit => "HANJA_DIGIT",
NumberFormatType::CircledHangulSyllable => "CIRCLED_HANGUL_SYLLABLE",
_ => "DIGIT",
}
}
fn enrich_ref_list(inner_xml: &str, store: &HwpxStyleStore) -> HwpxResult<String> {
let border_fills_xml = build_border_fills_xml(store);
let tab_properties_xml = build_tab_properties_xml(store)?;
let numberings_xml = build_numberings_xml(store);
let bullets_xml = build_bullets_xml(store);
if !inner_xml.contains("<hh:refList>") {
return Ok(format!(
"<hh:refList>{border_fills_xml}{tab_properties_xml}{numberings_xml}{bullets_xml}</hh:refList>{inner_xml}"
));
}
let extra_len = border_fills_xml.len()
+ tab_properties_xml.len()
+ numberings_xml.len()
+ bullets_xml.len();
let mut result = String::with_capacity(inner_xml.len() + extra_len);
let ref_open = "<hh:refList>";
let ref_open_pos =
inner_xml.find(ref_open).expect("refList was confirmed present by contains() check above");
let after_ref_open = ref_open_pos + ref_open.len();
result.push_str(&inner_xml[..after_ref_open]);
let rest = &inner_xml[after_ref_open..];
if let Some(cp_pos) = rest.find("<hh:charProperties") {
result.push_str(&rest[..cp_pos]);
result.push_str(&border_fills_xml);
let rest2 = &rest[cp_pos..];
if let Some(insert_pos) =
rest2.find("<hh:paraProperties").or_else(|| rest2.find("<hh:styles"))
{
result.push_str(&rest2[..insert_pos]);
result.push_str(&tab_properties_xml);
result.push_str(&numberings_xml);
result.push_str(&bullets_xml);
result.push_str(&rest2[insert_pos..]);
} else {
result.push_str(rest2);
result.push_str(&tab_properties_xml);
result.push_str(&numberings_xml);
result.push_str(&bullets_xml);
}
} else {
result.push_str(&border_fills_xml);
result.push_str(&tab_properties_xml);
result.push_str(&numberings_xml);
result.push_str(&bullets_xml);
result.push_str(rest);
}
Ok(result)
}
fn build_head(store: &HwpxStyleStore, sec_cnt: u32) -> HxHead {
let ref_list = build_ref_list(store);
let has_content = ref_list.fontfaces.is_some()
|| ref_list.char_properties.is_some()
|| ref_list.para_properties.is_some()
|| ref_list.styles.is_some();
HxHead {
version: "1.4".into(),
sec_cnt,
begin_num: None, ref_list: if has_content { Some(ref_list) } else { None },
}
}
fn extract_inner_content(xml: &str) -> &str {
let open_end = xml.find('>').map(|i| i + 1).unwrap_or(0);
let close_start = xml.rfind("</head>").unwrap_or(xml.len());
&xml[open_end..close_start]
}
fn build_ref_list(store: &HwpxStyleStore) -> HxRefList {
let fontfaces = build_fontfaces(store);
let char_properties = build_char_properties(store);
let para_properties = build_para_properties(store);
let styles = build_styles(store);
HxRefList {
fontfaces: if fontfaces.groups.is_empty() { None } else { Some(fontfaces) },
border_fills: None,
char_properties: if char_properties.items.is_empty() {
None
} else {
Some(char_properties)
},
tab_properties: None,
numberings: None,
bullets: None,
para_properties: if para_properties.items.is_empty() {
None
} else {
Some(para_properties)
},
styles: if styles.items.is_empty() { None } else { Some(styles) },
}
}
fn build_fontfaces(store: &HwpxStyleStore) -> HxFontFaces {
let mut groups = group_fonts_by_lang(store);
const REQUIRED_LANGS: &[&str] =
&["HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER"];
if !groups.is_empty() {
let fallback_group = groups[0].clone();
for &lang in REQUIRED_LANGS {
if !groups.iter().any(|g| g.lang == lang) {
let mut cloned = fallback_group.clone();
cloned.lang = lang.to_string();
groups.push(cloned);
}
}
groups.sort_by_key(|g| {
REQUIRED_LANGS.iter().position(|&l| l == g.lang).unwrap_or(usize::MAX)
});
}
let item_cnt = groups.len() as u32;
HxFontFaces { item_cnt, groups }
}
fn group_fonts_by_lang(store: &HwpxStyleStore) -> Vec<HxFontFaceGroup> {
let mut langs: Vec<String> = Vec::new();
let mut groups: Vec<Vec<&HwpxFont>> = Vec::new();
for font in store.iter_fonts() {
if let Some(pos) = langs.iter().position(|l| l == &font.lang) {
groups[pos].push(font);
} else {
langs.push(font.lang.clone());
groups.push(vec![font]);
}
}
langs
.into_iter()
.zip(groups)
.map(|(lang, fonts)| {
let font_cnt = fonts.len() as u32;
let hx_fonts: Vec<HxFont> = fonts
.into_iter()
.map(|f| HxFont {
id: f.id,
face: f.face_name.clone(),
font_type: "TTF".into(),
is_embedded: 0,
type_info: Some(default_type_info()),
})
.collect();
HxFontFaceGroup { lang, font_cnt, fonts: hx_fonts }
})
.collect()
}
fn build_char_properties(store: &HwpxStyleStore) -> HxCharProperties {
let items: Vec<HxCharPr> = store
.iter_char_shapes()
.enumerate()
.map(|(idx, cs)| build_char_pr(idx as u32, cs))
.collect();
let item_cnt = items.len() as u32;
HxCharProperties { item_cnt, items }
}
fn build_char_pr(id: u32, cs: &HwpxCharShape) -> HxCharPr {
let fr = &cs.font_ref;
HxCharPr {
id,
height: cs.height.as_i32().max(0) as u32,
text_color: cs.text_color.to_hex_rgb(),
shade_color: shade_color_to_str(cs.shade_color.as_ref()),
use_font_space: u32::from(cs.use_font_space),
use_kerning: u32::from(cs.use_kerning),
sym_mark: emphasis_type_to_hwpx(cs.emphasis).into(),
border_fill_id_ref: cs.border_fill_id.unwrap_or(2),
font_ref: Some(HxFontRef {
hangul: fr.hangul.get() as u32,
latin: fr.latin.get() as u32,
hanja: fr.hanja.get() as u32,
japanese: fr.japanese.get() as u32,
other: fr.other.get() as u32,
symbol: fr.symbol.get() as u32,
user: fr.user.get() as u32,
}),
ratio: Some(lang_values_all(cs.ratio)),
spacing: Some(lang_values_all(cs.spacing)),
rel_sz: Some(lang_values_all(cs.rel_sz)),
offset: Some(lang_values_all(cs.char_offset)),
bold: if cs.bold { Some(HxPresence) } else { None },
italic: if cs.italic { Some(HxPresence) } else { None },
underline: Some(HxUnderline {
underline_type: underline_type_to_hwpx(cs.underline_type).into(),
shape: "SOLID".into(),
color: cs.underline_color.as_ref().map_or_else(|| "#000000".into(), |c| c.to_hex_rgb()),
}),
strikeout: Some(HxStrikeout {
shape: strikeout_shape_to_hwpx(cs.strikeout_shape).into(),
color: cs.strikeout_color.as_ref().map_or_else(|| "#000000".into(), |c| c.to_hex_rgb()),
}),
outline: Some(HxOutline { outline_type: outline_type_to_hwpx(cs.outline_type).into() }),
emboss: if cs.emboss_type == EmbossType::Emboss { Some(HxPresence) } else { None },
engrave: if cs.engrave_type == EngraveType::Engrave { Some(HxPresence) } else { None },
supscript: if cs.vertical_position == VerticalPosition::Superscript {
Some(HxPresence)
} else {
None
},
subscript: if cs.vertical_position == VerticalPosition::Subscript {
Some(HxPresence)
} else {
None
},
shadow: Some(HxShadow {
shadow_type: shadow_type_to_hwpx(cs.shadow_type).into(),
color: "#B2B2B2".into(),
offset_x: 10,
offset_y: 10,
}),
}
}
fn lang_values_all(v: i32) -> HxLangValues {
HxLangValues { hangul: v, latin: v, hanja: v, japanese: v, other: v, symbol: v, user: v }
}
fn emphasis_type_to_hwpx(e: EmphasisType) -> &'static str {
match e {
EmphasisType::None => "NONE",
EmphasisType::DotAbove => "DOT_ABOVE",
EmphasisType::RingAbove => "RING_ABOVE",
EmphasisType::Tilde => "TILDE",
EmphasisType::Caron => "CARON",
EmphasisType::Side => "SIDE",
EmphasisType::Colon => "COLON",
EmphasisType::GraveAccent => "GRAVE_ACCENT",
EmphasisType::AcuteAccent => "ACUTE_ACCENT",
EmphasisType::Circumflex => "CIRCUMFLEX",
EmphasisType::Macron => "MACRON",
EmphasisType::HookAbove => "HOOK_ABOVE",
EmphasisType::DotBelow => "DOT_BELOW",
_ => "NONE",
}
}
fn default_type_info() -> HxTypeInfo {
HxTypeInfo {
family_type: "FCAT_GOTHIC".into(),
weight: 6,
proportion: 0,
contrast: 0,
stroke_variation: 1,
arm_style: 1,
letterform: 1,
midline: 1,
x_height: 1,
}
}
fn build_para_properties(store: &HwpxStyleStore) -> HxParaProperties {
let items: Vec<HxParaPr> = store
.iter_para_shapes()
.enumerate()
.map(|(idx, ps)| build_para_pr(idx as u32, ps))
.collect();
let item_cnt = items.len() as u32;
HxParaProperties { item_cnt, items }
}
fn build_para_pr(id: u32, ps: &HwpxParaShape) -> HxParaPr {
let border = ps.border_fill_id.map(|border_fill_id| HxBorder {
border_fill_id_ref: border_fill_id.get() as u32,
offset_left: 0,
offset_right: 0,
offset_top: 0,
offset_bottom: 0,
connect: 0,
ignore_margin: 0,
});
HxParaPr {
id,
tab_pr_id_ref: ps.tab_pr_id_ref,
condense: ps.condense,
font_line_height: 0,
snap_to_grid: Some(u32::from(ps.snap_to_grid)),
suppress_line_numbers: 0,
checked: u32::from(ps.checked),
align: Some(HxAlign {
horizontal: alignment_to_hwpx(ps.alignment).into(),
vertical: "BASELINE".into(),
}),
heading: Some(wire_parts_to_heading(ps.heading_type, ps.heading_id_ref, ps.heading_level)),
break_setting: Some(HxBreakSetting {
break_latin_word: latin_word_break_type_to_hwpx(ps.break_latin_word).into(),
break_non_latin_word: non_latin_word_break_type_to_hwpx(ps.break_non_latin_word).into(),
widow_orphan: u32::from(ps.widow_orphan),
keep_with_next: u32::from(ps.keep_with_next),
keep_lines: u32::from(ps.keep_lines_together),
page_break_before: u32::from(matches!(ps.break_type, BreakType::Page)),
line_wrap: ps.line_wrap.clone(),
}),
auto_spacing: Some(HxAutoSpacing { e_asian_eng: 0, e_asian_num: 0 }),
switches: vec![build_margin_switch(ps)],
border,
}
}
fn build_margin_switch(ps: &HwpxParaShape) -> HxSwitch {
HxSwitch {
case: Some(HxSwitchCase {
required_namespace: "http://www.hancom.co.kr/hwpml/2016/HwpUnitChar".into(),
margin: Some(build_margin_case(ps)),
line_spacing: Some(build_line_spacing_case(ps)),
}),
default: Some(HxSwitchDefault {
margin: Some(build_margin(ps)),
line_spacing: Some(build_line_spacing(ps)),
}),
}
}
fn build_margin(ps: &HwpxParaShape) -> HxMargin {
HxMargin {
indent: Some(hwpunit_value(ps.indent)),
left: Some(hwpunit_value(ps.margin_left)),
right: Some(hwpunit_value(ps.margin_right)),
prev: Some(hwpunit_value(ps.spacing_before)),
next: Some(hwpunit_value(ps.spacing_after)),
}
}
fn build_margin_case(ps: &HwpxParaShape) -> HxMargin {
HxMargin {
indent: Some(hwpunit_char_case_value(ps.indent)),
left: Some(hwpunit_char_case_value(ps.margin_left)),
right: Some(hwpunit_char_case_value(ps.margin_right)),
prev: Some(hwpunit_char_case_value(ps.spacing_before)),
next: Some(hwpunit_char_case_value(ps.spacing_after)),
}
}
fn build_line_spacing(ps: &HwpxParaShape) -> HxLineSpacing {
HxLineSpacing {
spacing_type: line_spacing_type_to_hwpx(ps.line_spacing_type).into(),
value: ps.line_spacing.max(0) as u32,
unit: "HWPUNIT".into(),
}
}
fn build_line_spacing_case(ps: &HwpxParaShape) -> HxLineSpacing {
let value = match ps.line_spacing_type {
LineSpacingType::Percentage => ps.line_spacing.max(0) as u32,
_ => (ps.line_spacing.max(0) / 2) as u32,
};
HxLineSpacing {
spacing_type: line_spacing_type_to_hwpx(ps.line_spacing_type).into(),
value,
unit: "HWPUNIT".into(),
}
}
fn hwpunit_value(u: HwpUnit) -> HxUnitValue {
HxUnitValue { value: u.as_i32(), unit: "HWPUNIT".into() }
}
fn hwpunit_char_case_value(u: HwpUnit) -> HxUnitValue {
HxUnitValue { value: u.as_i32() / 2, unit: "HWPUNIT".into() }
}
fn shade_color_to_str(c: Option<&Color>) -> String {
match c {
None => "none".to_string(),
Some(color) if *color == Color::BLACK => "none".to_string(),
Some(color) => color.to_hex_rgb(),
}
}
fn alignment_to_hwpx(a: Alignment) -> &'static str {
match a {
Alignment::Left => "LEFT",
Alignment::Center => "CENTER",
Alignment::Right => "RIGHT",
Alignment::Justify => "JUSTIFY",
Alignment::Distribute => "DISTRIBUTE",
Alignment::DistributeFlush => "DISTRIBUTE_SPACE",
_ => "LEFT",
}
}
fn latin_word_break_type_to_hwpx(word_break: WordBreakType) -> &'static str {
match word_break {
WordBreakType::KeepWord => "KEEP_WORD",
WordBreakType::BreakWord => "BREAK_WORD",
_ => "KEEP_WORD",
}
}
fn non_latin_word_break_type_to_hwpx(word_break: WordBreakType) -> &'static str {
match word_break {
WordBreakType::KeepWord => "BREAK_WORD",
WordBreakType::BreakWord => "KEEP_WORD",
_ => "BREAK_WORD",
}
}
fn underline_type_to_hwpx(ut: UnderlineType) -> &'static str {
match ut {
UnderlineType::None => "NONE",
UnderlineType::Bottom => "BOTTOM",
UnderlineType::Center => "CENTER",
UnderlineType::Top => "TOP",
_ => "NONE",
}
}
fn strikeout_shape_to_hwpx(ss: StrikeoutShape) -> &'static str {
match ss {
StrikeoutShape::None => "NONE",
StrikeoutShape::Continuous => "SOLID",
StrikeoutShape::Dash => "DASH",
StrikeoutShape::Dot => "DOT",
StrikeoutShape::DashDot => "DASH_DOT",
StrikeoutShape::DashDotDot => "DASH_DOT_DOT",
_ => "NONE",
}
}
fn line_spacing_type_to_hwpx(lst: LineSpacingType) -> &'static str {
match lst {
LineSpacingType::Percentage => "PERCENT",
LineSpacingType::Fixed => "FIXED",
LineSpacingType::BetweenLines => "BETWEEN_LINES",
_ => "PERCENT",
}
}
fn outline_type_to_hwpx(ot: OutlineType) -> &'static str {
match ot {
OutlineType::None => "NONE",
OutlineType::Solid => "SOLID",
_ => "NONE",
}
}
fn shadow_type_to_hwpx(st: ShadowType) -> &'static str {
match st {
ShadowType::None => "NONE",
ShadowType::Drop => "DROP",
_ => "NONE",
}
}
fn build_styles(store: &HwpxStyleStore) -> HxStyles {
let mut items: Vec<HxStyle> = store.iter_styles().map(build_style).collect();
if items.is_empty() {
items.push(HxStyle {
id: 0,
style_type: "PARA".into(),
name: "바탕글".into(),
eng_name: "Normal".into(),
para_pr_id_ref: 0,
char_pr_id_ref: 0,
next_style_id_ref: 0,
lang_id: 1042,
lock_form: 0,
});
}
let item_cnt = items.len() as u32;
HxStyles { item_cnt, items }
}
fn build_style(s: &HwpxStyle) -> HxStyle {
HxStyle {
id: s.id,
style_type: s.style_type.clone(),
name: s.name.clone(),
eng_name: s.eng_name.clone(),
para_pr_id_ref: s.para_pr_id_ref,
char_pr_id_ref: s.char_pr_id_ref,
next_style_id_ref: s.next_style_id_ref,
lang_id: s.lang_id,
lock_form: s.lock_form,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style_store::HwpxFill;
use hwpforge_foundation::{
CharShapeIndex, EmbossType, EngraveType, FontIndex, ParaShapeIndex, VerticalPosition,
};
fn minimal_store() -> HwpxStyleStore {
let mut store = HwpxStyleStore::new();
store.push_font(HwpxFont {
id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
});
store.push_char_shape(HwpxCharShape {
height: HwpUnit::new(1000).unwrap(),
..Default::default()
});
store.push_para_shape(HwpxParaShape::default());
store.push_border_fill(HwpxBorderFill::default_page_border());
store.push_border_fill(HwpxBorderFill::default_char_background());
store.push_border_fill(HwpxBorderFill::default_table_border());
store
}
#[test]
fn test_encode_minimal_store() {
let store = minimal_store();
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>"#));
assert!(xml.contains("<hh:head"));
assert!(xml.contains("</hh:head>"));
assert!(xml.contains(r#"version="1.4""#));
assert!(xml.contains(r#"secCnt="1""#));
assert!(xml.contains("함초롬돋움"));
assert!(xml.contains(r#"lang="HANGUL""#));
assert!(xml.contains(r#"height="1000""#));
assert!(xml.contains(r##"textColor="#000000""##));
assert!(xml.contains(r#"horizontal="LEFT""#));
assert!(xml.contains(r#"vertical="BASELINE""#));
}
#[test]
fn test_encode_header_roundtrip() {
let store = minimal_store();
let xml = encode_header(&store, 1, None).unwrap();
let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
assert_eq!(decoded.font_count(), 7);
let f = decoded.font(FontIndex::new(0)).unwrap();
assert_eq!(f.face_name, "함초롬돋움");
assert_eq!(f.lang, "HANGUL");
assert_eq!(decoded.char_shape_count(), store.char_shape_count());
let cs = decoded.char_shape(CharShapeIndex::new(0)).unwrap();
assert_eq!(cs.height.as_i32(), 1000);
assert_eq!(cs.text_color, Color::BLACK);
assert!(!cs.bold);
assert!(!cs.italic);
assert_eq!(decoded.para_shape_count(), store.para_shape_count());
let ps = decoded.para_shape(ParaShapeIndex::new(0)).unwrap();
assert_eq!(ps.alignment, Alignment::Left);
assert_eq!(ps.line_spacing, 160);
assert_eq!(ps.line_spacing_type, LineSpacingType::Percentage);
}
#[test]
fn test_with_default_fonts_emit_group_local_zero_ids() {
let mut store = HwpxStyleStore::with_default_fonts("함초롬돋움");
store.push_char_shape(HwpxCharShape::default());
store.push_para_shape(HwpxParaShape::default());
store.push_border_fill(HwpxBorderFill::default_page_border());
store.push_border_fill(HwpxBorderFill::default_char_background());
store.push_border_fill(HwpxBorderFill::default_table_border());
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains(r#"<hh:fontface lang="HANGUL" fontCnt="1"><hh:font id="0""#));
assert!(xml.contains(r#"<hh:fontface lang="LATIN" fontCnt="1"><hh:font id="0""#));
assert!(xml.contains(
r#"<hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>"#
));
}
#[test]
fn test_bold_italic_presence() {
let mut store = HwpxStyleStore::new();
store.push_char_shape(HwpxCharShape { bold: true, italic: false, ..Default::default() });
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains("<hh:bold"), "bold element must be present");
assert!(!xml.contains("<hh:italic"), "italic element must be absent");
}
#[test]
fn test_shade_color_none() {
assert_eq!(shade_color_to_str(None), "none");
assert_eq!(shade_color_to_str(Some(&Color::BLACK)), "none");
assert_eq!(shade_color_to_str(Some(&Color::from_rgb(0, 255, 0))), "#00FF00");
assert_eq!(shade_color_to_str(Some(&Color::WHITE)), "#FFFFFF");
}
#[test]
fn test_alignment_to_hwpx() {
assert_eq!(alignment_to_hwpx(Alignment::Left), "LEFT");
assert_eq!(alignment_to_hwpx(Alignment::Center), "CENTER");
assert_eq!(alignment_to_hwpx(Alignment::Right), "RIGHT");
assert_eq!(alignment_to_hwpx(Alignment::Justify), "JUSTIFY");
assert_eq!(alignment_to_hwpx(Alignment::Distribute), "DISTRIBUTE");
assert_eq!(alignment_to_hwpx(Alignment::DistributeFlush), "DISTRIBUTE_SPACE");
}
#[test]
fn test_font_grouping() {
let mut store = HwpxStyleStore::new();
store.push_font(HwpxFont {
id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
});
store.push_font(HwpxFont {
id: 1, face_name: "함초롬바탕".into(), lang: "HANGUL".into()
});
store.push_font(HwpxFont { id: 0, face_name: "Arial".into(), lang: "LATIN".into() });
let groups = group_fonts_by_lang(&store);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].lang, "HANGUL");
assert_eq!(groups[0].font_cnt, 2);
assert_eq!(groups[0].fonts.len(), 2);
assert_eq!(groups[0].fonts[0].face, "함초롬돋움");
assert_eq!(groups[0].fonts[1].face, "함초롬바탕");
assert_eq!(groups[1].lang, "LATIN");
assert_eq!(groups[1].font_cnt, 1);
assert_eq!(groups[1].fonts[0].face, "Arial");
}
#[test]
fn test_margin_switch_structure() {
let ps = HwpxParaShape {
alignment: Alignment::Justify,
margin_left: HwpUnit::new(100).unwrap(),
margin_right: HwpUnit::new(50).unwrap(),
indent: HwpUnit::new(200).unwrap(),
spacing_before: HwpUnit::new(300).unwrap(),
spacing_after: HwpUnit::new(150).unwrap(),
line_spacing: 200,
line_spacing_type: LineSpacingType::Percentage,
..Default::default()
};
let switch = build_margin_switch(&ps);
let case = switch.case.as_ref().expect("case must be present");
assert_eq!(case.required_namespace, "http://www.hancom.co.kr/hwpml/2016/HwpUnitChar");
let case_margin = case.margin.as_ref().unwrap();
assert_eq!(case_margin.left.as_ref().unwrap().value, 50);
assert_eq!(case_margin.right.as_ref().unwrap().value, 25);
assert_eq!(case_margin.indent.as_ref().unwrap().value, 100);
assert_eq!(case_margin.prev.as_ref().unwrap().value, 150);
assert_eq!(case_margin.next.as_ref().unwrap().value, 75);
let case_ls = case.line_spacing.as_ref().unwrap();
assert_eq!(case_ls.value, 200);
assert_eq!(case_ls.spacing_type, "PERCENT");
let default = switch.default.as_ref().expect("default must be present");
let def_margin = default.margin.as_ref().unwrap();
assert_eq!(def_margin.left.as_ref().unwrap().value, 100);
assert_eq!(def_margin.indent.as_ref().unwrap().value, 200);
let def_ls = default.line_spacing.as_ref().unwrap();
assert_eq!(def_ls.value, 200);
}
#[test]
fn test_margin_switch_halves_non_percentage_case_line_spacing() {
let ps = HwpxParaShape {
line_spacing: 4000,
line_spacing_type: LineSpacingType::Fixed,
..Default::default()
};
let switch = build_margin_switch(&ps);
let case_ls = switch.case.as_ref().unwrap().line_spacing.as_ref().unwrap();
let def_ls = switch.default.as_ref().unwrap().line_spacing.as_ref().unwrap();
assert_eq!(case_ls.spacing_type, "FIXED");
assert_eq!(case_ls.value, 2000);
assert_eq!(def_ls.value, 4000);
}
#[test]
fn test_bullet_roundtrip_emits_bullets_section() {
let mut store = HwpxStyleStore::new();
store.push_font(HwpxFont::new(0, "함초롬돋움", "HANGUL"));
store.push_char_shape(HwpxCharShape::default());
store.push_para_shape(HwpxParaShape::default());
store.push_border_fill(HwpxBorderFill::default_page_border());
store.push_border_fill(HwpxBorderFill::default_char_background());
store.push_border_fill(HwpxBorderFill::default_table_border());
store.push_bullet(hwpforge_core::BulletDef {
id: 1,
bullet_char: "".into(),
checked_char: None,
use_image: false,
para_head: hwpforge_core::ParaHead {
start: 0,
level: 1,
num_format: NumberFormatType::Digit,
text: String::new(),
checkable: false,
},
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains(r#"<hh:bullets itemCnt="1">"#));
assert!(xml.contains(r#"char="""#));
assert!(xml.contains(r#"useImage="0""#));
assert!(xml.contains(r#"<hh:paraHead level="0""#));
let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
assert_eq!(decoded.bullet_count(), 1);
let bullet = decoded.iter_bullets().next().unwrap();
assert_eq!(bullet.id, 1);
assert_eq!(bullet.bullet_char, "");
assert!(!bullet.use_image);
assert_eq!(bullet.para_head.level, 1);
}
#[test]
fn test_list_text_is_xml_escaped_and_roundtrips() {
let mut store = HwpxStyleStore::new();
store.push_font(HwpxFont::new(0, "함초롬돋움", "HANGUL"));
store.push_char_shape(HwpxCharShape::default());
store.push_para_shape(HwpxParaShape::default());
store.push_border_fill(HwpxBorderFill::default_page_border());
store.push_border_fill(HwpxBorderFill::default_char_background());
store.push_border_fill(HwpxBorderFill::default_table_border());
store.push_numbering(NumberingDef {
id: 9,
start: 0,
levels: vec![hwpforge_core::ParaHead {
start: 1,
level: 1,
num_format: NumberFormatType::Digit,
text: r#"A&B<"C""#.into(),
checkable: false,
}],
});
store.push_bullet(hwpforge_core::BulletDef {
id: 1,
bullet_char: r#"<"&"#.into(),
checked_char: None,
use_image: false,
para_head: hwpforge_core::ParaHead {
start: 0,
level: 1,
num_format: NumberFormatType::Digit,
text: r#"X&Y<"Z""#.into(),
checkable: false,
},
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains(r#"char="<"&""#));
assert!(xml.contains(r#"A&B<"C""#));
assert!(xml.contains(r#"X&Y<"Z""#));
let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
let numbering = decoded.iter_numberings().find(|numbering| numbering.id == 9).unwrap();
assert_eq!(numbering.levels[0].text, r#"A&B<"C""#);
let bullet = decoded.iter_bullets().next().unwrap();
assert_eq!(bullet.bullet_char, r#"<"&"#);
assert_eq!(bullet.para_head.text, r#"X&Y<"Z""#);
}
#[test]
fn test_empty_store() {
let store = HwpxStyleStore::new();
let xml = encode_header(&store, 0, None).unwrap();
assert!(xml.contains("<hh:head"));
assert!(xml.contains(r#"secCnt="0""#));
assert!(xml.contains("</hh:head>"));
}
#[test]
fn test_roundtrip_rich_data() {
let mut store = HwpxStyleStore::new();
store.push_font(HwpxFont {
id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
});
store.push_font(HwpxFont {
id: 1, face_name: "함초롬바탕".into(), lang: "HANGUL".into()
});
store.push_font(HwpxFont {
id: 0,
face_name: "Times New Roman".into(),
lang: "LATIN".into(),
});
store.push_char_shape(HwpxCharShape {
font_ref: crate::style_store::HwpxFontRef {
hangul: FontIndex::new(1),
latin: FontIndex::new(2),
..Default::default()
},
height: HwpUnit::new(2500).unwrap(),
text_color: Color::from_rgb(255, 0, 0),
shade_color: Some(Color::from_rgb(0, 255, 0)),
bold: true,
italic: true,
underline_type: UnderlineType::Bottom,
strikeout_shape: StrikeoutShape::Continuous,
vertical_position: VerticalPosition::Superscript,
emboss_type: EmbossType::Emboss,
..Default::default()
});
store.push_para_shape(HwpxParaShape {
alignment: Alignment::Justify,
margin_left: HwpUnit::new(100).unwrap(),
margin_right: HwpUnit::new(50).unwrap(),
indent: HwpUnit::new(200).unwrap(),
spacing_before: HwpUnit::new(300).unwrap(),
spacing_after: HwpUnit::new(150).unwrap(),
line_spacing: 200,
line_spacing_type: LineSpacingType::Percentage,
snap_to_grid: false,
line_wrap: "SQUEEZE".into(),
..Default::default()
});
let xml = encode_header(&store, 1, None).unwrap();
let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
assert_eq!(decoded.font_count(), 13);
assert_eq!(decoded.font(FontIndex::new(0)).unwrap().face_name, "함초롬돋움");
assert_eq!(decoded.font(FontIndex::new(1)).unwrap().face_name, "함초롬바탕");
assert_eq!(decoded.font(FontIndex::new(2)).unwrap().face_name, "Times New Roman");
let cs = decoded.char_shape(CharShapeIndex::new(0)).unwrap();
assert_eq!(cs.height.as_i32(), 2500);
assert_eq!(cs.text_color, Color::from_rgb(255, 0, 0));
assert_eq!(cs.shade_color, Some(Color::from_rgb(0, 255, 0)));
assert!(cs.bold);
assert!(cs.italic);
assert_eq!(cs.font_ref.hangul.get(), 1);
assert_eq!(cs.font_ref.latin.get(), 2);
assert_eq!(cs.underline_type, UnderlineType::Bottom);
assert_eq!(cs.strikeout_shape, StrikeoutShape::Continuous);
assert_eq!(cs.vertical_position, VerticalPosition::Superscript);
assert_eq!(cs.emboss_type, EmbossType::Emboss);
assert_eq!(cs.engrave_type, EngraveType::None);
let ps = decoded.para_shape(ParaShapeIndex::new(0)).unwrap();
assert_eq!(ps.alignment, Alignment::Justify);
assert_eq!(ps.margin_left.as_i32(), 100);
assert_eq!(ps.margin_right.as_i32(), 50);
assert_eq!(ps.indent.as_i32(), 200);
assert_eq!(ps.spacing_before.as_i32(), 300);
assert_eq!(ps.spacing_after.as_i32(), 150);
assert_eq!(ps.line_spacing, 200);
assert!(!ps.snap_to_grid);
assert_eq!(ps.line_wrap, "SQUEEZE");
}
#[test]
fn test_sec_cnt_in_output() {
let store = HwpxStyleStore::new();
let xml = encode_header(&store, 42, None).unwrap();
assert!(xml.contains(r#"secCnt="42""#));
}
#[test]
fn test_multiple_char_shapes_ids() {
let mut store = HwpxStyleStore::new();
store.push_char_shape(HwpxCharShape { bold: true, ..Default::default() });
store.push_char_shape(HwpxCharShape { italic: true, ..Default::default() });
store.push_char_shape(HwpxCharShape::default());
let xml = encode_header(&store, 1, None).unwrap();
let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
assert_eq!(decoded.char_shape_count(), 3);
assert!(decoded.char_shape(CharShapeIndex::new(0)).unwrap().bold);
assert!(decoded.char_shape(CharShapeIndex::new(1)).unwrap().italic);
assert!(!decoded.char_shape(CharShapeIndex::new(2)).unwrap().bold);
assert!(!decoded.char_shape(CharShapeIndex::new(2)).unwrap().italic);
}
#[test]
fn build_char_pr_serializes_relief_and_vertical_position() {
let mut store = HwpxStyleStore::new();
store.push_char_shape(HwpxCharShape {
vertical_position: VerticalPosition::Subscript,
engrave_type: EngraveType::Engrave,
..Default::default()
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains("<hh:engrave/>"));
assert!(xml.contains("<hh:subscript/>"));
assert!(!xml.contains("<hh:emboss/>"));
assert!(!xml.contains("<hh:supscript/>"));
}
#[test]
fn build_char_pr_serializes_both_relief_effects_when_requested() {
let mut store = HwpxStyleStore::new();
store.push_char_shape(HwpxCharShape {
emboss_type: EmbossType::Emboss,
engrave_type: EngraveType::Engrave,
..Default::default()
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains("<hh:emboss/>"));
assert!(xml.contains("<hh:engrave/>"));
}
#[test]
fn test_styles_roundtrip() {
let mut store = HwpxStyleStore::new();
store.push_style(crate::style_store::HwpxStyle {
id: 0,
style_type: "PARA".into(),
name: "바탕글".into(),
eng_name: "Normal".into(),
para_pr_id_ref: 0,
char_pr_id_ref: 0,
next_style_id_ref: 0,
lang_id: 1042,
lock_form: 0,
});
store.push_style(crate::style_store::HwpxStyle {
id: 1,
style_type: "CHAR".into(),
name: "본문".into(),
eng_name: "Body".into(),
para_pr_id_ref: 1,
char_pr_id_ref: 1,
next_style_id_ref: 1,
lang_id: 1042,
lock_form: 0,
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains("바탕글"));
assert!(xml.contains("Normal"));
assert!(xml.contains("본문"));
assert!(xml.contains("Body"));
let decoded = crate::decoder::header::parse_header(&xml).unwrap().style_store;
assert_eq!(decoded.style_count(), 2);
let s0 = decoded.style(0).unwrap();
assert_eq!(s0.name, "바탕글");
assert_eq!(s0.eng_name, "Normal");
assert_eq!(s0.style_type, "PARA");
assert_eq!(s0.lang_id, 1042);
let s1 = decoded.style(1).unwrap();
assert_eq!(s1.name, "본문");
assert_eq!(s1.eng_name, "Body");
assert_eq!(s1.style_type, "CHAR");
}
#[test]
fn test_empty_store_gets_default_style() {
let store = HwpxStyleStore::new();
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains("<hh:styles"), "default 바탕글 style should be injected");
assert!(xml.contains("바탕글"), "바탕글 style name must be present");
assert!(xml.contains("Normal"), "Normal eng_name must be present");
}
#[test]
fn test_encoder_improvements_all_present() {
let store = minimal_store();
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains("<hh:ratio hangul=\"100\""), "charPr must have ratio");
assert!(xml.contains("<hh:spacing hangul=\"0\""), "charPr must have spacing");
assert!(xml.contains("<hh:relSz hangul=\"100\""), "charPr must have relSz");
assert!(xml.contains("<hh:offset hangul=\"0\""), "charPr must have offset");
assert!(xml.contains("<hh:heading type=\"NONE\""), "paraPr must have heading");
assert!(
xml.contains("<hh:breakSetting breakLatinWord=\"KEEP_WORD\""),
"paraPr must have breakSetting"
);
assert!(xml.contains("<hh:autoSpacing eAsianEng=\"0\""), "paraPr must have autoSpacing");
assert!(
!xml.contains("<hh:border borderFillIDRef=\"2\""),
"default paraPr must omit border"
);
assert!(xml.contains("<hc:fillBrush>"), "borderFill id=2 must have fillBrush");
assert!(xml.contains("<hc:winBrush faceColor=\"none\""), "fillBrush must have winBrush");
assert!(xml.contains("Crooked=\"0\""), "slash/backSlash must have Crooked attr");
assert!(xml.contains("isCounter=\"0\""), "slash/backSlash must have isCounter attr");
assert!(
xml.contains("<hh:tabProperties itemCnt=\"3\""),
"tabProperties must have 3 entries"
);
assert!(xml.contains("<hh:tabPr id=\"1\" autoTabLeft=\"1\""), "tabPr id=1 must exist");
assert!(
xml.contains("<hh:tabPr id=\"2\" autoTabLeft=\"0\" autoTabRight=\"1\""),
"tabPr id=2 must exist"
);
assert!(xml.contains("<hh:numberings itemCnt=\"1\""), "numberings must exist");
assert!(
xml.contains("<hh:paraHead start=\"1\" level=\"1\""),
"numbering must have paraHead levels with start attr"
);
assert!(xml.contains("numFormat=\"DIGIT\""), "paraHead must have numFormat");
assert!(
xml.contains("charPrIDRef=\"4294967295\""),
"paraHead must use u32::MAX for no override"
);
assert!(xml.contains("widthAdjust=\"0\""), "paraHead must have widthAdjust attr");
assert!(xml.contains("checkable=\"0\""), "paraHead must have checkable attr");
}
#[test]
fn build_border_fills_xml_matches_expected_format() {
let mut store = HwpxStyleStore::new();
store.push_border_fill(HwpxBorderFill::default_page_border());
store.push_border_fill(HwpxBorderFill::default_char_background());
store.push_border_fill(HwpxBorderFill::default_table_border());
let xml = build_border_fills_xml(&store);
assert!(xml.starts_with(r##"<hh:borderFills itemCnt="3">"##));
assert!(xml.ends_with("</hh:borderFills>"));
assert!(xml.contains(r##"<hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE""##));
assert!(xml.contains(r##"<hh:borderFill id="2" threeD="0" shadow="0" centerLine="NONE""##));
assert!(xml.contains("<hc:fillBrush>"));
assert!(xml.contains(r##"faceColor="none""##));
assert!(xml.contains(r##"hatchColor="#FF000000""##));
assert!(!xml.contains(r##"hatchStyle=""##));
assert!(xml.contains(r##"<hh:borderFill id="3""##));
assert!(xml.contains(r##"<hh:leftBorder type="SOLID" width="0.12 mm""##));
assert!(xml.contains(r##"<hh:diagonal type="SOLID" width="0.1 mm""##));
assert!(xml.contains(r##"<hh:slash type="NONE" Crooked="0" isCounter="0"/>"##));
assert!(xml.contains(r##"<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>"##));
}
#[test]
fn build_border_fills_xml_empty_store_emits_defaults() {
let store = HwpxStyleStore::new();
let xml = build_border_fills_xml(&store);
assert!(xml.contains(r##"<hh:borderFills itemCnt="3">"##));
assert!(xml.contains(r##"<hh:borderFill id="1""##));
assert!(xml.contains(r##"<hh:borderFill id="2""##));
assert!(xml.contains(r##"<hh:borderFill id="3""##));
}
#[test]
fn test_para_pr_emits_supported_break_flags_and_optional_border() {
let mut store = HwpxStyleStore::new();
store.push_para_shape(HwpxParaShape {
break_type: BreakType::Page,
keep_with_next: true,
keep_lines_together: true,
widow_orphan: true,
break_latin_word: hwpforge_foundation::WordBreakType::BreakWord,
break_non_latin_word: hwpforge_foundation::WordBreakType::BreakWord,
border_fill_id: Some(hwpforge_foundation::BorderFillIndex::new(5)),
condense: 20,
..Default::default()
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains(
r#"<hh:breakSetting breakLatinWord="BREAK_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="1" keepWithNext="1" keepLines="1" pageBreakBefore="1""#
));
assert!(xml.contains(r#"<hh:border borderFillIDRef="5""#));
assert!(xml.contains(r#"condense="20""#));
}
#[test]
fn test_para_pr_emits_hancom_non_latin_break_wire_labels() {
let mut store = HwpxStyleStore::new();
store.push_para_shape(HwpxParaShape {
break_latin_word: hwpforge_foundation::WordBreakType::KeepWord,
break_non_latin_word: hwpforge_foundation::WordBreakType::KeepWord,
..Default::default()
});
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains(
r#"<hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="BREAK_WORD""#
));
}
#[test]
fn encoded_header_contains_dynamic_border_fills() {
let store = minimal_store();
let xml = encode_header(&store, 1, None).unwrap();
assert!(xml.contains(r##"<hh:borderFills itemCnt="3">"##));
assert!(xml.contains(r##"borderFillIDRef="2""##)); }
#[test]
fn build_border_fill_xml_emits_hatch_style_when_present() {
let xml = build_border_fill_xml(&HwpxBorderFill {
id: 4,
three_d: false,
shadow: false,
center_line: "NONE".into(),
left: HwpxBorderLine::default(),
right: HwpxBorderLine::default(),
top: HwpxBorderLine::default(),
bottom: HwpxBorderLine::default(),
diagonal: Some(HwpxBorderLine::default()),
slash_type: "NONE".into(),
back_slash_type: "NONE".into(),
slash: HwpxDiagonalLine::default(),
back_slash: HwpxDiagonalLine::default(),
fill: Some(HwpxFill::WinBrush {
face_color: "#FFD700".into(),
hatch_color: "#000000".into(),
alpha: "0".into(),
}),
fill_hatch_style: Some("HORIZONTAL".into()),
gradient_fill: None,
image_fill: None,
});
assert!(xml.contains(r##"hatchStyle="HORIZONTAL""##));
}
#[test]
fn build_border_fill_xml_emits_gradient_fill() {
let xml = build_border_fill_xml(&HwpxBorderFill {
id: 4,
three_d: false,
shadow: false,
center_line: "NONE".into(),
left: HwpxBorderLine::default(),
right: HwpxBorderLine::default(),
top: HwpxBorderLine::default(),
bottom: HwpxBorderLine::default(),
diagonal: Some(HwpxBorderLine::default()),
slash_type: "NONE".into(),
back_slash_type: "NONE".into(),
slash: HwpxDiagonalLine::default(),
back_slash: HwpxDiagonalLine::default(),
fill: None,
fill_hatch_style: None,
gradient_fill: Some(HwpxGradientFill {
gradient_type: hwpforge_foundation::GradientType::Linear,
angle: 90,
center_x: 0,
center_y: 0,
step: 255,
step_center: 50,
alpha: 0,
colors: vec![Color::from_rgb(255, 0, 0), Color::from_rgb(0, 255, 0)],
}),
image_fill: None,
});
assert!(xml.contains(r##"<hc:gradation type="LINEAR" angle="90" centerX="0" centerY="0" step="255" colorNum="2" stepCenter="50" alpha="0">"##));
assert!(xml.contains(r##"<hc:color value="#FF0000"/>"##));
assert!(xml.contains(r##"<hc:color value="#00FF00"/>"##));
}
#[test]
fn build_border_fill_xml_emits_image_fill() {
let xml = build_border_fill_xml(&HwpxBorderFill {
id: 4,
three_d: false,
shadow: false,
center_line: "NONE".into(),
left: HwpxBorderLine::default(),
right: HwpxBorderLine::default(),
top: HwpxBorderLine::default(),
bottom: HwpxBorderLine::default(),
diagonal: Some(HwpxBorderLine::default()),
slash_type: "NONE".into(),
back_slash_type: "NONE".into(),
slash: HwpxDiagonalLine::default(),
back_slash: HwpxDiagonalLine::default(),
fill: None,
fill_hatch_style: None,
gradient_fill: None,
image_fill: Some(HwpxImageFill {
mode: "TOTAL".into(),
binary_item_id_ref: "BIN0001".into(),
bright: 0,
contrast: 0,
effect: "REAL_PIC".into(),
alpha: 0,
}),
});
assert!(xml.contains(r##"<hc:imgBrush mode="TOTAL">"##));
assert!(xml.contains(r##"<hc:img binaryItemIDRef="BIN0001" bright="0" contrast="0" effect="REAL_PIC" alpha="0"/>"##));
}
#[test]
fn build_border_fill_xml_preserves_diagonal_flags() {
let xml = build_border_fill_xml(&HwpxBorderFill {
id: 4,
three_d: false,
shadow: false,
center_line: "NONE".into(),
left: HwpxBorderLine::default(),
right: HwpxBorderLine::default(),
top: HwpxBorderLine::default(),
bottom: HwpxBorderLine::default(),
diagonal: Some(HwpxBorderLine::default()),
slash_type: "CENTER_BELOW".into(),
back_slash_type: "ALL".into(),
slash: HwpxDiagonalLine {
border_type: "CENTER_BELOW".into(),
crooked: true,
is_counter: false,
},
back_slash: HwpxDiagonalLine {
border_type: "ALL".into(),
crooked: false,
is_counter: true,
},
fill: None,
fill_hatch_style: None,
gradient_fill: None,
image_fill: None,
});
assert!(xml.contains(r##"<hh:slash type="CENTER_BELOW" Crooked="1" isCounter="0"/>"##));
assert!(xml.contains(r##"<hh:backSlash type="ALL" Crooked="0" isCounter="1"/>"##));
}
#[test]
fn build_border_fill_xml_falls_back_to_legacy_diagonal_type_fields() {
let xml = build_border_fill_xml(&HwpxBorderFill {
id: 4,
three_d: false,
shadow: false,
center_line: "NONE".into(),
left: HwpxBorderLine::default(),
right: HwpxBorderLine::default(),
top: HwpxBorderLine::default(),
bottom: HwpxBorderLine::default(),
diagonal: Some(HwpxBorderLine::default()),
slash_type: "CENTER".into(),
back_slash_type: "ALL".into(),
slash: HwpxDiagonalLine::default(),
back_slash: HwpxDiagonalLine::default(),
fill: None,
fill_hatch_style: None,
gradient_fill: None,
image_fill: None,
});
assert!(xml.contains(r##"<hh:slash type="CENTER" Crooked="0" isCounter="0"/>"##));
assert!(xml.contains(r##"<hh:backSlash type="ALL" Crooked="0" isCounter="0"/>"##));
}
}