jw-hwp-core 0.1.0

Read-only parser for Hancom HWP 5.0 (binary CFB) and HWPX (OWPML) documents
Documentation
//! Serialize HwpDocument -> HWPX ZIP file on disk.

use crate::error::Error;
use crate::hwpx::xml_gen;
use crate::model::HwpDocument;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use zip::write::FileOptions;
use zip::CompressionMethod;
use zip::ZipWriter;

pub fn write_hwpx(doc: &HwpDocument, path: &Path) -> Result<u64, Error> {
    let file = File::create(path).map_err(Error::Io)?;
    let mut zip = ZipWriter::new(file);

    // mimetype MUST be first entry, stored (not compressed)
    let opts_stored = FileOptions::default().compression_method(CompressionMethod::Stored);
    let opts_deflate = FileOptions::default().compression_method(CompressionMethod::Deflated);

    zip.start_file("mimetype", opts_stored)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(&xml_gen::gen_mimetype()).map_err(Error::Io)?;

    zip.start_file("version.xml", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_version_xml().as_bytes())
        .map_err(Error::Io)?;

    zip.start_file("settings.xml", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_settings_xml().as_bytes())
        .map_err(Error::Io)?;

    zip.start_file("Contents/content.hpf", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_content_hpf(doc).as_bytes())
        .map_err(Error::Io)?;

    zip.start_file("Contents/header.xml", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_header_xml(doc).as_bytes())
        .map_err(Error::Io)?;

    zip.start_file("Contents/section0.xml", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_section_xml(doc).as_bytes())
        .map_err(Error::Io)?;

    zip.start_file("META-INF/container.xml", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_container_xml().as_bytes())
        .map_err(Error::Io)?;

    zip.start_file("META-INF/manifest.xml", opts_deflate)
        .map_err(|e| Error::Container(e.to_string()))?;
    zip.write_all(xml_gen::gen_manifest_xml().as_bytes())
        .map_err(Error::Io)?;

    zip.finish().map_err(|e| Error::Container(e.to_string()))?;

    let size = std::fs::metadata(path).map_err(Error::Io)?.len();
    Ok(size)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trip_write_then_read() {
        let mut doc = HwpDocument::new_for_writing(Some("Test Doc".into()));
        doc.add_paragraph("Hello world", 0, 0, None);
        doc.add_paragraph("Second paragraph", 0, 0, None);

        let dir = std::env::temp_dir().join("hwp_mcp_test_write");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("test_roundtrip.hwpx");
        write_hwpx(&doc, &path).unwrap();

        // Read it back with the existing HWPX reader
        let read_doc = crate::open(&path).expect("should read back the generated HWPX");
        assert!(read_doc.sections[0]
            .paragraphs
            .iter()
            .any(|p| p.contains("Hello world")));
        assert!(read_doc.sections[0]
            .paragraphs
            .iter()
            .any(|p| p.contains("Second paragraph")));

        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn round_trip_with_formatting() {
        let mut doc = HwpDocument::new_for_writing(None);
        let bold_id = doc.ensure_char_shape(true, false, false, 14.0, 0);
        doc.add_paragraph("Bold text", bold_id, 0, None);

        let dir = std::env::temp_dir().join("hwp_mcp_test_fmt");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("test_fmt.hwpx");
        write_hwpx(&doc, &path).unwrap();

        let read_doc = crate::open(&path).unwrap();
        // Verify the char shape was preserved
        let detail = &read_doc.sections[0].paragraph_details[0];
        let csid = detail.runs.first().map(|(_, id)| *id).unwrap_or(0);
        let cs = read_doc.shapes.char_shapes.get(&csid);
        assert!(cs.map(|s| s.bold).unwrap_or(false));

        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn round_trip_with_table() {
        let mut doc = HwpDocument::new_for_writing(None);
        doc.add_table(
            2,
            2,
            Some(vec![
                vec!["A1".into(), "B1".into()],
                vec!["A2".into(), "B2".into()],
            ]),
            None,
        );

        let dir = std::env::temp_dir().join("hwp_mcp_test_tbl");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("test_tbl.hwpx");
        write_hwpx(&doc, &path).unwrap();

        let read_doc = crate::open(&path).unwrap();
        assert!(!read_doc.sections[0].tables.is_empty());

        std::fs::remove_dir_all(&dir).ok();
    }
}