use super::*;
fn roundtrip_doc(inlines: Vec<Inline>) -> Document {
Document {
metadata: Metadata::default(),
sections: vec![Section {
blocks: vec![Block::Paragraph { inlines }],
}],
assets: Vec::new(),
}
}
fn roundtrip_inlines(inlines: Vec<Inline>) -> Vec<Inline> {
let tmp = tempfile::NamedTempFile::new().expect("tmp file");
let doc = roundtrip_doc(inlines);
write_hwpx(&doc, tmp.path(), None).expect("write_hwpx");
let read_back = read_hwpx(tmp.path()).expect("read_hwpx");
read_back
.sections
.into_iter()
.flat_map(|s| s.blocks)
.filter_map(|b| match b {
Block::Paragraph { inlines } => Some(inlines),
_ => None,
})
.next()
.unwrap_or_default()
}
#[test]
fn roundtrip_bold_text_preserved() {
let result = roundtrip_inlines(vec![bold_inline("bold text")]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "bold text");
assert!(result[0].bold, "bold flag must survive roundtrip: {result:?}");
}
#[test]
fn roundtrip_italic_text_preserved() {
let result = roundtrip_inlines(vec![italic_inline("italic text")]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "italic text");
assert!(
result[0].italic,
"italic flag must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_bold_italic_combined_preserved() {
let input = Inline {
text: "bold italic".into(),
bold: true,
italic: true,
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "bold italic");
assert!(result[0].bold, "bold must survive roundtrip: {result:?}");
assert!(result[0].italic, "italic must survive roundtrip: {result:?}");
}
#[test]
fn roundtrip_underline_text_preserved() {
let result = roundtrip_inlines(vec![underline_inline("underlined")]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "underlined");
assert!(
result[0].underline,
"underline flag must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_strikethrough_text_preserved() {
let input = Inline {
text: "struck".into(),
strikethrough: true,
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "struck");
assert!(
result[0].strikethrough,
"strikethrough flag must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_superscript_text_preserved() {
let input = Inline {
text: "sup".into(),
superscript: true,
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "sup");
assert!(
result[0].superscript,
"superscript flag must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_subscript_text_preserved() {
let input = Inline {
text: "sub".into(),
subscript: true,
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "sub");
assert!(
result[0].subscript,
"subscript flag must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_color_text_preserved() {
let input = Inline {
text: "red".into(),
color: Some("#FF0000".into()),
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "red");
assert_eq!(
result[0].color.as_deref(),
Some("#FF0000"),
"color must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_mixed_plain_and_bold_preserved() {
let result = roundtrip_inlines(vec![inline("normal "), bold_inline("bold")]);
assert_eq!(result.len(), 2, "expected 2 inlines: {result:?}");
assert_eq!(result[0].text, "normal ");
assert!(!result[0].bold, "first inline must not be bold: {result:?}");
assert_eq!(result[1].text, "bold");
assert!(result[1].bold, "second inline must be bold: {result:?}");
}
#[test]
fn roundtrip_bold_italic_underline_strike_color_combined() {
let input = Inline {
text: "all".into(),
bold: true,
italic: true,
underline: true,
strikethrough: true,
color: Some("#00FF00".into()),
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
let r = &result[0];
assert!(r.bold, "bold: {result:?}");
assert!(r.italic, "italic: {result:?}");
assert!(r.underline, "underline: {result:?}");
assert!(r.strikethrough, "strikethrough: {result:?}");
assert_eq!(r.color.as_deref(), Some("#00FF00"), "color: {result:?}");
}
#[test]
fn section_xml_bold_inline_emits_inline_charpr() {
let xml = section_xml(vec![Block::Paragraph {
inlines: vec![bold_inline("hello")],
}]);
assert!(
xml.contains(r#"bold="true""#),
"section XML must contain inline charPr with bold=\"true\": {xml}"
);
}
#[test]
fn section_xml_italic_inline_emits_inline_charpr() {
let xml = section_xml(vec![Block::Paragraph {
inlines: vec![italic_inline("hello")],
}]);
assert!(
xml.contains(r#"italic="true""#),
"section XML must contain inline charPr with italic=\"true\": {xml}"
);
}
#[test]
fn section_xml_underline_inline_emits_inline_charpr() {
let xml = section_xml(vec![Block::Paragraph {
inlines: vec![underline_inline("hello")],
}]);
assert!(
xml.contains(r#"underline="true""#),
"section XML must contain inline charPr with underline=\"true\": {xml}"
);
}
#[test]
fn section_xml_strikethrough_inline_emits_strikeout() {
let input = Inline {
text: "hello".into(),
strikethrough: true,
..Inline::default()
};
let xml = section_xml(vec![Block::Paragraph {
inlines: vec![input],
}]);
assert!(
xml.contains(r#"strikeout="true""#),
"section XML must contain inline charPr with strikeout=\"true\": {xml}"
);
}
#[test]
fn section_xml_color_inline_emits_color_without_hash() {
let input = Inline {
text: "hello".into(),
color: Some("#FF0000".into()),
..Inline::default()
};
let xml = section_xml(vec![Block::Paragraph {
inlines: vec![input],
}]);
assert!(
xml.contains(r#"color="FF0000""#),
"section XML must contain inline charPr with color=\"FF0000\" (no hash): {xml}"
);
assert!(
!xml.contains(r##"color="#FF0000""##),
"color must not have leading # in OWPML: {xml}"
);
}
#[test]
fn section_xml_plain_inline_no_charpr_element() {
let xml = section_xml(vec![Block::Paragraph {
inlines: vec![inline("plain")],
}]);
assert!(
!xml.contains("<hp:charPr "),
"plain inline must NOT emit inline <hp:charPr> element: {xml}"
);
}
#[test]
fn roundtrip_font_name_preserved() {
let input = Inline {
text: "styled".into(),
font_name: Some("Malgun Gothic".into()),
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "styled");
assert_eq!(
result[0].font_name.as_deref(),
Some("Malgun Gothic"),
"font_name must survive roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_font_name_with_bold_preserved() {
let input = Inline {
text: "bold styled".into(),
bold: true,
font_name: Some("Malgun Gothic".into()),
..Inline::default()
};
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "bold styled");
assert!(result[0].bold, "bold must survive roundtrip: {result:?}");
assert_eq!(
result[0].font_name.as_deref(),
Some("Malgun Gothic"),
"font_name must survive roundtrip with bold: {result:?}"
);
}
#[test]
fn section_xml_font_name_emits_face_name_id_ref() {
let input = Inline {
text: "hello".into(),
font_name: Some("Malgun Gothic".into()),
..Inline::default()
};
let doc = doc_with_section(vec![Block::Paragraph {
inlines: vec![input],
}]);
let tables = RefTables::build(&doc);
let sec = &doc.sections[0];
let xml = generate_section_xml(sec, 0, &tables).expect("generate_section_xml failed");
assert!(
xml.contains("faceNameIDRef="),
"section XML must contain faceNameIDRef for font_name inline: {xml}"
);
let expected_idx = tables
.font_names
.iter()
.position(|f| f == "Malgun Gothic")
.expect("Malgun Gothic must be in font_names");
assert_eq!(
expected_idx, 1,
"Malgun Gothic should be at index 1 (바탕 is 0): {:?}",
tables.font_names
);
let expected_attr = format!("faceNameIDRef=\"{expected_idx}\"");
assert!(
xml.contains(&expected_attr),
"section XML must contain {expected_attr}: {xml}"
);
}
#[test]
fn header_xml_font_name_registered_in_fontface() {
let input = Inline {
text: "hello".into(),
font_name: Some("Malgun Gothic".into()),
..Inline::default()
};
let doc = doc_with_section(vec![Block::Paragraph {
inlines: vec![input],
}]);
let tables = RefTables::build(&doc);
let header =
super::header::generate_header_xml(&doc, &tables).expect("generate_header_xml failed");
assert!(
header.contains("Malgun Gothic"),
"header XML must contain the registered font name: {header}"
);
}
#[test]
fn roundtrip_default_font_no_font_name_preserved() {
let input = Inline {
text: "default font text".into(),
bold: true,
..Inline::default()
};
assert!(
input.font_name.is_none(),
"precondition: input must have no font_name"
);
let result = roundtrip_inlines(vec![input]);
assert_eq!(result.len(), 1, "expected 1 inline: {result:?}");
assert_eq!(result[0].text, "default font text");
assert!(result[0].bold, "bold must survive roundtrip: {result:?}");
assert!(
result[0].font_name.is_none(),
"font_name must remain None for default font after roundtrip: {result:?}"
);
}
#[test]
fn roundtrip_unknown_font_name_not_in_table() {
let doc = doc_with_section(vec![Block::Paragraph {
inlines: vec![Inline {
text: "known font".into(),
font_name: Some("Malgun Gothic".into()),
..Inline::default()
}],
}]);
let tables = RefTables::build(&doc);
assert!(
!tables.font_names.iter().any(|f| f == "Comic Sans MS"),
"precondition: Comic Sans MS must not be in font_names: {:?}",
tables.font_names
);
let rogue_section = Section {
blocks: vec![Block::Paragraph {
inlines: vec![Inline {
text: "rogue".into(),
font_name: Some("Comic Sans MS".into()),
..Inline::default()
}],
}],
};
let xml =
generate_section_xml(&rogue_section, 0, &tables).expect("generate_section_xml failed");
assert!(
!xml.contains("faceNameIDRef="),
"unknown font must not produce faceNameIDRef in section XML: {xml}"
);
assert!(
xml.contains("<hp:charPr"),
"charPr element should still be emitted for the font_name inline: {xml}"
);
}
#[test]
fn write_hwpx_image_roundtrip_preserves_asset() {
let png_bytes = vec![0x89u8, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
let tmp = tempfile::NamedTempFile::new().expect("tmp file");
let original = Document {
metadata: Metadata::default(),
sections: vec![Section {
blocks: vec![Block::Image {
src: "photo.png".into(),
alt: "a photo".into(),
}],
}],
assets: vec![Asset {
name: "photo.png".into(),
data: png_bytes.clone(),
mime_type: "image/png".into(),
}],
};
write_hwpx(&original, tmp.path(), None).expect("write");
let read_back = read_hwpx(tmp.path()).expect("read_hwpx");
let has_image = read_back
.sections
.iter()
.flat_map(|s| &s.blocks)
.any(|b| matches!(b, Block::Image { src, .. } if src.contains("photo")));
assert!(
has_image,
"image block must survive HWPX roundtrip; sections: {:?}",
read_back.sections
);
assert_eq!(
read_back.assets.len(),
1,
"one asset expected after roundtrip"
);
assert_eq!(
read_back.assets[0].data, png_bytes,
"asset binary content must be preserved through roundtrip"
);
assert_eq!(
read_back.assets[0].mime_type, "image/png",
"asset MIME type must be preserved"
);
}
#[test]
fn golden_comprehensive_document_structure() {
use std::io::Read as _;
let doc = Document {
metadata: Metadata {
title: Some("Golden Test Doc".into()),
author: Some("Test Author".into()),
..Metadata::default()
},
sections: vec![Section {
blocks: vec![
Block::Heading {
level: 1,
inlines: vec![Inline::plain("Main Title")],
},
Block::Paragraph {
inlines: vec![
inline("Normal text "),
bold_inline("bold text"),
],
},
Block::Paragraph {
inlines: vec![italic_inline("italic text")],
},
Block::Table {
rows: vec![
TableRow {
cells: vec![
TableCell {
blocks: vec![Block::Paragraph {
inlines: vec![inline("Cell A1")],
}],
colspan: 1,
rowspan: 1,
},
TableCell {
blocks: vec![Block::Paragraph {
inlines: vec![inline("Cell B1")],
}],
colspan: 1,
rowspan: 1,
},
],
is_header: true,
},
TableRow {
cells: vec![
TableCell {
blocks: vec![Block::Paragraph {
inlines: vec![inline("Cell A2")],
}],
colspan: 1,
rowspan: 1,
},
TableCell {
blocks: vec![Block::Paragraph {
inlines: vec![inline("Cell B2")],
}],
colspan: 1,
rowspan: 1,
},
],
is_header: false,
},
],
col_count: 2,
},
Block::CodeBlock {
language: Some("rust".into()),
code: "fn main() {}".into(),
},
Block::HorizontalRule,
Block::BlockQuote {
blocks: vec![Block::Paragraph {
inlines: vec![inline("quoted text")],
}],
},
Block::List {
ordered: false,
start: 1,
items: vec![
ListItem {
blocks: vec![Block::Paragraph {
inlines: vec![inline("List item one")],
}],
children: Vec::new(),
},
ListItem {
blocks: vec![Block::Paragraph {
inlines: vec![inline("List item two")],
}],
children: Vec::new(),
},
],
},
],
}],
assets: Vec::new(),
};
let tmp = tempfile::NamedTempFile::new().expect("tmp file");
write_hwpx(&doc, tmp.path(), None).expect("write_hwpx");
let file = std::fs::File::open(tmp.path()).expect("open zip");
let mut archive = zip::ZipArchive::new(file).expect("parse zip");
let section_xml = {
let mut entry = archive
.by_name("Contents/section0.xml")
.expect("section0.xml must exist in HWPX");
let mut buf = String::new();
entry.read_to_string(&mut buf).expect("read section0.xml");
buf
};
assert!(
section_xml.contains(r#"hp:styleIDRef="1""#),
"H1 heading must have hp:styleIDRef=\"1\" in section XML:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>Main Title</hp:t>"),
"heading text 'Main Title' must appear in <hp:t>:\n{section_xml}"
);
assert!(
section_xml.contains(r#"bold="true""#),
"bold inline must emit charPr with bold=\"true\":\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>bold text</hp:t>"),
"bold text content must appear in <hp:t>:\n{section_xml}"
);
assert!(
section_xml.contains(r#"italic="true""#),
"italic inline must emit charPr with italic=\"true\":\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>italic text</hp:t>"),
"italic text content must appear in <hp:t>:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>Normal text </hp:t>"),
"plain text must appear in <hp:t>:\n{section_xml}"
);
assert!(
section_xml.contains(r#"<hp:tbl"#),
"table must emit <hp:tbl> element:\n{section_xml}"
);
assert!(
section_xml.contains(r#"rowCnt="2""#),
"table must have rowCnt=\"2\":\n{section_xml}"
);
assert!(
section_xml.contains(r#"colCnt="2""#),
"table must have colCnt=\"2\":\n{section_xml}"
);
assert!(
section_xml.contains("<hp:tr>"),
"table must contain <hp:tr> rows:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:tc>"),
"table must contain <hp:tc> cells:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>Cell A1</hp:t>"),
"table cell text 'Cell A1' must appear:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>Cell B2</hp:t>"),
"table cell text 'Cell B2' must appear:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>List item one</hp:t>"),
"list item text 'List item one' must appear:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>List item two</hp:t>"),
"list item text 'List item two' must appear:\n{section_xml}"
);
assert!(
section_xml.contains("xmlns:hs="),
"section XML must declare hs namespace:\n{section_xml}"
);
assert!(
section_xml.contains("xmlns:hp="),
"section XML must declare hp namespace:\n{section_xml}"
);
let charpr_count = section_xml.matches("<hp:charPr ").count();
assert!(
charpr_count >= 2,
"at least 2 inline <hp:charPr> elements expected (bold + italic), found {charpr_count}:\n{section_xml}"
);
let content_hpf = {
let mut entry = archive
.by_name("Contents/content.hpf")
.expect("content.hpf must exist in HWPX");
let mut buf = String::new();
entry.read_to_string(&mut buf).expect("read content.hpf");
buf
};
assert!(
content_hpf.contains("section0.xml"),
"content.hpf must reference section0.xml:\n{content_hpf}"
);
assert!(
content_hpf.contains("<hp:title>Golden Test Doc</hp:title>"),
"content.hpf must contain document title:\n{content_hpf}"
);
assert!(
content_hpf.contains("<hp:author>Test Author</hp:author>"),
"content.hpf must contain document author:\n{content_hpf}"
);
let header_xml = {
let mut entry = archive
.by_name("Contents/header.xml")
.expect("header.xml must exist in HWPX");
let mut buf = String::new();
entry.read_to_string(&mut buf).expect("read header.xml");
buf
};
assert!(
header_xml.contains("hh:fontface"),
"header.xml must contain fontface declarations:\n{header_xml}"
);
assert!(
header_xml.contains("hh:charPr"),
"header.xml must contain charPr entries:\n{header_xml}"
);
assert!(
header_xml.contains("hh:style"),
"header.xml must contain style entries:\n{header_xml}"
);
assert!(
section_xml.contains("<hp:t>fn main() {}</hp:t>"),
"code block text must appear in <hp:t>:\n{section_xml}"
);
assert!(
section_xml.contains("\u{2500}"),
"horizontal rule must emit box-drawing characters:\n{section_xml}"
);
assert!(
section_xml.contains("<hp:t>quoted text</hp:t>"),
"block quote text must appear in <hp:t>:\n{section_xml}"
);
let mimetype = {
let mut entry = archive
.by_name("mimetype")
.expect("mimetype must exist in HWPX");
let mut buf = String::new();
entry.read_to_string(&mut buf).expect("read mimetype");
buf
};
assert_eq!(
mimetype, "application/hwp+zip",
"mimetype must be exactly 'application/hwp+zip'"
);
archive
.by_name("version.xml")
.expect("version.xml must exist in HWPX");
let container_xml = {
let mut entry = archive
.by_name("META-INF/container.xml")
.expect("META-INF/container.xml must exist in HWPX");
let mut buf = String::new();
entry.read_to_string(&mut buf).expect("read container.xml");
buf
};
assert!(
container_xml.contains("content.hpf"),
"container.xml must reference content.hpf:\n{container_xml}"
);
}