use hwpforge_core::{NumberingDef, TabDef};
use hwpforge_foundation::{
Alignment, Color, EmphasisType, HwpUnit, LineSpacingType, NumberFormatType, OutlineType,
ShadowType, StrikeoutShape, UnderlineType,
};
use crate::error::{HwpxError, HwpxResult};
use crate::schema::header::{
HxAlign, HxAutoSpacing, HxBorder, HxBreakSetting, HxCharPr, HxCharProperties, HxFont,
HxFontFaceGroup, HxFontFaces, HxFontRef, HxHead, HxHeading, HxLangValues, HxLineSpacing,
HxMargin, HxOutline, HxParaPr, HxParaProperties, HxPresence, HxRefList, HxShadow, HxStrikeout,
HxStyle, HxStyles, HxSwitch, HxSwitchCase, HxSwitchDefault, HxTypeInfo, HxUnderline,
HxUnitValue,
};
use crate::style_store::{
HwpxBorderFill, HwpxBorderLine, HwpxCharShape, HwpxFill, HwpxFont, 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);
Ok(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>,
) -> String {
let enriched = enrich_ref_list(inner_xml, store);
let begin_num_xml = build_begin_num_xml(begin_num);
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_type));
xml.push_str(&build_diagonal_xml("hh:backSlash", &bf.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));
xml.push_str(&build_border_line_xml("hh:diagonal", &bf.diagonal));
if let Some(fill) = &bf.fill {
xml.push_str(&build_fill_brush_xml(fill));
}
xml.push_str("</hh:borderFill>");
xml
}
fn build_diagonal_xml(tag: &str, border_type: &str) -> String {
format!(r##"<{tag} type="{border_type}" Crooked="0" isCounter="0"/>"##)
}
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(fill: &HwpxFill) -> String {
match fill {
HwpxFill::WinBrush { face_color, hatch_color, alpha } => format!(
r##"<hc:fillBrush><hc:winBrush faceColor="{face_color}" hatchColor="{hatch_color}" alpha="{alpha}"/></hc:fillBrush>"##
),
}
}
fn build_tab_properties_xml(store: &HwpxStyleStore) -> String {
let tabs: Vec<TabDef> = if store.tab_count() == 0 {
TabDef::defaults().to_vec()
} else {
store.iter_tabs().cloned().collect()
};
let count = tabs.len();
let mut xml = format!(r#"<hh:tabProperties itemCnt="{count}">"#);
for tab in &tabs {
let atl = u32::from(tab.auto_tab_left);
let atr = u32::from(tab.auto_tab_right);
xml.push_str(&format!(
r#"<hh:tabPr id="{}" autoTabLeft="{atl}" autoTabRight="{atr}"/>"#,
tab.id,
));
}
xml.push_str("</hh:tabProperties>");
xml
}
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, lvl.text,
));
}
}
xml.push_str("</hh:numbering>");
}
xml.push_str("</hh:numberings>");
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::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) -> 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);
if !inner_xml.contains("<hh:refList>") {
return format!(
"<hh:refList>{border_fills_xml}{tab_properties_xml}{numberings_xml}</hh:refList>{inner_xml}"
);
}
let extra_len = border_fills_xml.len() + tab_properties_xml.len() + numberings_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(pp_pos) = rest2.find("<hh:paraProperties") {
result.push_str(&rest2[..pp_pos]);
result.push_str(&tab_properties_xml);
result.push_str(&numberings_xml);
result.push_str(&rest2[pp_pos..]);
} else {
result.push_str(rest2);
result.push_str(&tab_properties_xml);
result.push_str(&numberings_xml);
}
} else {
result.push_str(&border_fills_xml);
result.push_str(&tab_properties_xml);
result.push_str(&numberings_xml);
result.push_str(rest);
}
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,
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() }),
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 {
HxParaPr {
id,
tab_pr_id_ref: ps.tab_pr_id_ref,
condense: ps.condense,
font_line_height: 0,
snap_to_grid: 1,
suppress_line_numbers: 0,
checked: 0,
align: Some(HxAlign {
horizontal: alignment_to_str(ps.alignment).into(),
vertical: "BASELINE".into(),
}),
heading: Some(HxHeading {
heading_type: ps.heading_type.to_hwpx_str().into(),
id_ref: ps.heading_id_ref,
level: ps.heading_level,
}),
break_setting: Some(HxBreakSetting {
break_latin_word: ps.break_latin_word.to_string(),
break_non_latin_word: ps.break_non_latin_word.to_string(),
widow_orphan: 0,
keep_with_next: 0,
keep_lines: 0,
page_break_before: 0,
line_wrap: "BREAK".into(),
}),
auto_spacing: Some(HxAutoSpacing { e_asian_eng: 0, e_asian_num: 0 }),
switches: vec![build_margin_switch(ps)],
border: Some(HxBorder {
border_fill_id_ref: 2,
offset_left: 0,
offset_right: 0,
offset_top: 0,
offset_bottom: 0,
connect: 0,
ignore_margin: 0,
}),
}
}
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(ps)),
line_spacing: Some(build_line_spacing(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_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 hwpunit_value(u: HwpUnit) -> HxUnitValue {
HxUnitValue { value: u.as_i32(), 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_str(a: Alignment) -> &'static str {
match a {
Alignment::Left => "LEFT",
Alignment::Center => "CENTER",
Alignment::Right => "RIGHT",
Alignment::Justify => "JUSTIFY",
Alignment::Distribute => "DISTRIBUTE",
Alignment::DistributeFlush => "DISTRIBUTE_FLUSH",
_ => "LEFT",
}
}
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 => "SLASH",
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: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use hwpforge_foundation::{CharShapeIndex, FontIndex, ParaShapeIndex};
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_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_str() {
assert_eq!(alignment_to_str(Alignment::Left), "LEFT");
assert_eq!(alignment_to_str(Alignment::Center), "CENTER");
assert_eq!(alignment_to_str(Alignment::Right), "RIGHT");
assert_eq!(alignment_to_str(Alignment::Justify), "JUSTIFY");
assert_eq!(alignment_to_str(Alignment::Distribute), "DISTRIBUTE");
assert_eq!(alignment_to_str(Alignment::DistributeFlush), "DISTRIBUTE_FLUSH");
}
#[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, 100);
assert_eq!(case_margin.right.as_ref().unwrap().value, 50);
assert_eq!(case_margin.indent.as_ref().unwrap().value, 200);
assert_eq!(case_margin.prev.as_ref().unwrap().value, 300);
assert_eq!(case_margin.next.as_ref().unwrap().value, 150);
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_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,
..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,
..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);
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);
}
#[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 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,
});
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,
});
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\""), "paraPr must have 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##"<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 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""##)); }
}