use std::io::{Cursor, Write};
use zip::write::SimpleFileOptions;
use zip::CompressionMethod;
use zip::ZipWriter;
use crate::error::{HwpxError, HwpxResult};
const MIMETYPE: &[u8] = b"application/hwp+zip";
pub(crate) const XMLNS_DECLS: &str = concat!(
r#" xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app""#,
r#" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph""#,
r#" xmlns:hp10="http://www.hancom.co.kr/hwpml/2016/paragraph""#,
r#" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section""#,
r#" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core""#,
r#" xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head""#,
r#" xmlns:hhs="http://www.hancom.co.kr/hwpml/2011/history""#,
r#" xmlns:hm="http://www.hancom.co.kr/hwpml/2011/master-page""#,
r#" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf""#,
r#" xmlns:dc="http://purl.org/dc/elements/1.1/""#,
r#" xmlns:opf="http://www.idpf.org/2007/opf/""#,
r#" xmlns:ooxmlchart="http://www.hancom.co.kr/hwpml/2016/ooxmlchart""#,
r#" xmlns:hwpunitchar="http://www.hancom.co.kr/hwpml/2016/HwpUnitChar""#,
r#" xmlns:epub="http://www.idpf.org/2007/ops""#,
r#" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0""#,
);
const VERSION_XML: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" tagetApplication="WORDPROCESSOR" major="5" minor="1" micro="1" buildNumber="0" os="10" xmlVersion="1.5" application="Hancom Office Hangul" appVersion="12.0.0.0"/>"##;
const CONTAINER_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf"><ocf:rootfiles><ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/></ocf:rootfiles></ocf:container>"#;
const MANIFEST_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>"#;
const SETTINGS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:config="urn:oasis:names:tc:opendocument:xmlns:config:1.0"><ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/></ha:HWPApplicationSetting>"#;
fn generate_content_hpf(
section_count: usize,
image_paths: &[String],
chart_paths: &[String],
masterpage_paths: &[String],
) -> String {
let mut manifest_items = String::from(
r#"<opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>"#,
);
let mut spine_refs = String::from(r#"<opf:itemref idref="header" linear="yes"/>"#);
for i in 0..section_count {
use std::fmt::Write as _;
write!(
manifest_items,
r#"<opf:item id="section{i}" href="Contents/section{i}.xml" media-type="application/xml"/>"#,
)
.expect("write to String is infallible");
write!(spine_refs, r#"<opf:itemref idref="section{i}" linear="yes"/>"#)
.expect("write to String is infallible");
}
manifest_items
.push_str(r#"<opf:item id="settings" href="settings.xml" media-type="application/xml"/>"#);
for path in image_paths {
use std::fmt::Write as _;
let media_type = guess_image_media_type(path);
let stem = match path.rfind('.') {
Some(pos) => &path[..pos],
None => path.as_str(),
};
write!(
manifest_items,
r#"<opf:item id="{stem}" href="BinData/{path}" media-type="{media_type}" isEmbeded="1"/>"#,
)
.expect("write to String is infallible");
}
for path in masterpage_paths {
use std::fmt::Write as _;
let stem =
path.strip_prefix("Contents/").unwrap_or(path).strip_suffix(".xml").unwrap_or(path);
write!(
manifest_items,
r#"<opf:item id="{stem}" href="{path}" media-type="application/xml"/>"#,
)
.expect("write to String is infallible");
write!(spine_refs, r#"<opf:itemref idref="{stem}" linear="yes"/>"#)
.expect("write to String is infallible");
}
let _ = chart_paths;
format!(
concat!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>"#,
r#"<opf:package{xmlns} version="" unique-identifier="" id="">"#,
r#"<opf:metadata>"#,
r#"<opf:title/>"#,
r#"<opf:language>ko</opf:language>"#,
r#"</opf:metadata>"#,
r#"<opf:manifest>{manifest_items}</opf:manifest>"#,
r#"<opf:spine>{spine_refs}</opf:spine>"#,
r#"</opf:package>"#,
),
xmlns = XMLNS_DECLS,
manifest_items = manifest_items,
spine_refs = spine_refs,
)
}
fn guess_image_media_type(path: &str) -> &'static str {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".png") {
"image/png"
} else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
"image/jpeg"
} else if lower.ends_with(".gif") {
"image/gif"
} else if lower.ends_with(".bmp") {
"image/bmp"
} else if lower.ends_with(".wmf") {
"image/x-wmf"
} else if lower.ends_with(".emf") {
"image/x-emf"
} else {
"application/octet-stream"
}
}
pub(crate) struct PackageWriter;
impl PackageWriter {
pub fn write_hwpx(
header_xml: &str,
section_xmls: &[String],
images: &[(String, Vec<u8>)],
charts: &[(String, String)],
master_pages: &[(String, String)],
) -> HwpxResult<Vec<u8>> {
let buf: Vec<u8> = Vec::new();
let cursor = Cursor::new(buf);
let mut zip = ZipWriter::new(cursor);
let stored_opts =
SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
let deflate_opts =
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file("mimetype", stored_opts).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(MIMETYPE).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.start_file("version.xml", deflate_opts).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(VERSION_XML.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.start_file("META-INF/container.xml", deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(CONTAINER_XML.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.start_file("META-INF/manifest.xml", deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(MANIFEST_XML.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.start_file("Contents/content.hpf", deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
let image_paths: Vec<String> = images.iter().map(|(path, _)| path.clone()).collect();
let chart_paths: Vec<String> = charts.iter().map(|(path, _)| path.clone()).collect();
let mp_paths: Vec<String> = master_pages.iter().map(|(path, _)| path.clone()).collect();
let content_hpf =
generate_content_hpf(section_xmls.len(), &image_paths, &chart_paths, &mp_paths);
zip.write_all(content_hpf.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.start_file("settings.xml", deflate_opts).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(SETTINGS_XML.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.start_file("Contents/header.xml", deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(header_xml.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
for (i, section_xml) in section_xmls.iter().enumerate() {
zip.start_file(format!("Contents/section{i}.xml"), deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(section_xml.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
}
for (path, data) in images {
let safe_path = super::sanitize_zip_entry_name(path);
zip.start_file(format!("BinData/{safe_path}"), stored_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(data).map_err(|e| HwpxError::Zip(e.to_string()))?;
}
for (path, xml) in master_pages {
zip.start_file(path.as_str(), deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(xml.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
}
for (path, xml) in charts {
zip.start_file(path.as_str(), deflate_opts)
.map_err(|e| HwpxError::Zip(e.to_string()))?;
zip.write_all(xml.as_bytes()).map_err(|e| HwpxError::Zip(e.to_string()))?;
}
let cursor = zip.finish().map_err(|e| HwpxError::Zip(e.to_string()))?;
Ok(cursor.into_inner())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use zip::ZipArchive;
const MINIMAL_HEADER: &str =
r#"<?xml version="1.0" encoding="UTF-8"?><head version="1.4" secCnt="1"></head>"#;
const MINIMAL_SECTION: &str = r#"<?xml version="1.0" encoding="UTF-8"?><sec></sec>"#;
fn write_minimal(sections: &[String]) -> Vec<u8> {
PackageWriter::write_hwpx(MINIMAL_HEADER, sections, &[], &[], &[]).unwrap()
}
fn open_zip(bytes: &[u8]) -> ZipArchive<Cursor<&[u8]>> {
ZipArchive::new(Cursor::new(bytes)).unwrap()
}
#[test]
fn mimetype_is_first_stored_entry() {
let sections = vec![MINIMAL_SECTION.to_string()];
let bytes = write_minimal(§ions);
let mut archive = open_zip(&bytes);
let entry = archive.by_index(0).unwrap();
assert_eq!(entry.name(), "mimetype");
assert_eq!(
entry.compression(),
CompressionMethod::Stored,
"mimetype must be STORED, not DEFLATED"
);
}
#[test]
fn all_required_files_exist_in_zip() {
let sections = vec![MINIMAL_SECTION.to_string()];
let bytes = write_minimal(§ions);
let archive = open_zip(&bytes);
let names: Vec<&str> = archive.file_names().collect();
let required = [
"mimetype",
"version.xml",
"META-INF/container.xml",
"META-INF/manifest.xml",
"Contents/content.hpf",
"settings.xml",
"Contents/header.xml",
"Contents/section0.xml",
];
for path in &required {
assert!(names.contains(path), "missing required entry: {path}");
}
}
#[test]
fn version_xml_has_taget_typo() {
assert!(
VERSION_XML.contains("tagetApplication"),
"must preserve intentional typo 'tagetApplication'"
);
assert!(!VERSION_XML.contains("targetApplication"), "must NOT contain corrected spelling");
}
#[test]
fn content_hpf_lists_all_sections() {
let hpf1 = generate_content_hpf(1, &[], &[], &[]);
assert!(hpf1.contains(r#"id="section0""#));
assert!(hpf1.contains(r#"idref="section0""#));
assert!(!hpf1.contains(r#"id="section1""#));
let hpf3 = generate_content_hpf(3, &[], &[], &[]);
for i in 0..3 {
assert!(hpf3.contains(&format!(r#"id="section{i}""#)), "manifest missing section{i}");
assert!(hpf3.contains(&format!(r#"idref="section{i}""#)), "spine missing section{i}");
}
assert!(!hpf3.contains(r#"id="section3""#));
}
#[test]
fn content_hpf_includes_images() {
let images = vec!["photo.jpg".to_string(), "logo.png".to_string()];
let hpf = generate_content_hpf(1, &images, &[], &[]);
assert!(hpf.contains(r#"id="photo""#), "missing photo manifest entry");
assert!(hpf.contains(r#"href="BinData/photo.jpg""#), "missing image href");
assert!(hpf.contains(r#"media-type="image/jpeg""#), "missing jpeg media type");
assert!(hpf.contains(r#"isEmbeded="1""#), "missing isEmbeded attribute");
assert!(hpf.contains(r#"id="logo""#), "missing logo manifest entry");
assert!(hpf.contains(r#"href="BinData/logo.png""#), "missing image href");
assert!(hpf.contains(r#"media-type="image/png""#), "missing png media type");
assert!(!hpf.contains(r#"idref="image0""#), "images should not be in spine");
}
#[test]
fn write_empty_header_succeeds() {
let result = PackageWriter::write_hwpx("", &[MINIMAL_SECTION.to_string()], &[], &[], &[]);
assert!(result.is_ok());
let bytes = result.unwrap();
let archive = open_zip(&bytes);
assert!(archive.file_names().any(|n| n == "Contents/header.xml"));
}
#[test]
fn multi_section_creates_multiple_entries() {
let sections: Vec<String> = (0..3).map(|i| format!(r#"<sec>section{i}</sec>"#)).collect();
let bytes = PackageWriter::write_hwpx(MINIMAL_HEADER, §ions, &[], &[], &[]).unwrap();
let mut archive = open_zip(&bytes);
for i in 0..3 {
let path = format!("Contents/section{i}.xml");
let mut entry = archive.by_name(&path).unwrap();
let mut content = String::new();
entry.read_to_string(&mut content).unwrap();
assert!(content.contains(&format!("section{i}")), "section{i} content mismatch");
}
}
#[test]
fn generated_zip_is_decodable() {
use crate::decoder::package::PackageReader;
let sections = vec![MINIMAL_SECTION.to_string()];
let bytes = write_minimal(§ions);
let mut reader = PackageReader::new(&bytes).unwrap();
assert_eq!(reader.section_count(), 1);
let header = reader.read_header_xml().unwrap();
assert_eq!(header, MINIMAL_HEADER);
let section = reader.read_section_xml(0).unwrap();
assert_eq!(section, MINIMAL_SECTION);
}
#[test]
fn write_zero_sections_succeeds() {
let result = PackageWriter::write_hwpx(MINIMAL_HEADER, &[], &[], &[], &[]);
assert!(result.is_ok());
let bytes = result.unwrap();
let archive = open_zip(&bytes);
let names: Vec<&str> = archive.file_names().collect();
assert!(!names.iter().any(|n| n.starts_with("Contents/section")));
}
#[test]
fn large_section_count() {
let sections: Vec<String> = (0..100).map(|i| format!(r#"<sec>s{i}</sec>"#)).collect();
let bytes = PackageWriter::write_hwpx(MINIMAL_HEADER, §ions, &[], &[], &[]).unwrap();
let archive = open_zip(&bytes);
let section_entries = archive
.file_names()
.filter(|n| n.starts_with("Contents/section") && n.ends_with(".xml"))
.count();
assert_eq!(section_entries, 100);
}
#[test]
fn xmlns_decls_has_all_15_namespaces() {
let expected = [
r#"xmlns:ha="#,
r#"xmlns:hp="#,
r#"xmlns:hp10="#,
r#"xmlns:hs="#,
r#"xmlns:hc="#,
r#"xmlns:hh="#,
r#"xmlns:hhs="#,
r#"xmlns:hm="#,
r#"xmlns:hpf="#,
r#"xmlns:dc="#,
r#"xmlns:opf="#,
r#"xmlns:ooxmlchart="#,
r#"xmlns:hwpunitchar="#,
r#"xmlns:epub="#,
r#"xmlns:config="#,
];
for ns in &expected {
assert!(XMLNS_DECLS.contains(ns), "missing namespace declaration: {ns}");
}
}
#[test]
fn images_written_to_bindata() {
let image_data = vec![0xFFu8, 0xD8, 0xFF, 0xE0]; let images = vec![("photo.jpg".to_string(), image_data.clone())];
let bytes = PackageWriter::write_hwpx(
MINIMAL_HEADER,
&[MINIMAL_SECTION.to_string()],
&images,
&[],
&[],
)
.unwrap();
let mut archive = open_zip(&bytes);
let mut entry = archive.by_name("BinData/photo.jpg").unwrap();
assert_eq!(entry.compression(), CompressionMethod::Stored, "images should be STORED");
let mut buf = Vec::new();
entry.read_to_end(&mut buf).unwrap();
assert_eq!(buf, image_data);
}
}