Skip to main content

hwpforge_smithy_hwpx/encoder/
mod.rs

1//! HWPX encoder pipeline.
2//!
3//! Submodules handle individual stages:
4//! - `header` — [`HwpxStyleStore`] → `header.xml` serialization
5//! - `section` — Core `Section` → `section*.xml` serialization
6//! - `package` — ZIP assembly (mimetype, metadata, content files)
7//!
8//! The public entry point is [`HwpxEncoder`], which orchestrates
9//! the full pipeline: header → sections → ZIP packaging.
10
11pub(crate) mod chart;
12pub(crate) mod header;
13pub(crate) mod header_tabs;
14pub(crate) mod package;
15pub(crate) mod section;
16pub(crate) mod shapes;
17
18/// Escapes XML special characters in text content.
19///
20/// Handles `&`, `<`, `>`, and `"`. Single quotes (`'`) are **not** escaped
21/// because all HWPX attribute values produced by this encoder use double-quote
22/// delimiters. If a future caller places escaped values inside single-quoted
23/// XML attributes, `&apos;` escaping must be added.
24pub(crate) fn escape_xml(s: &str) -> String {
25    // Single-pass: only allocate when a special character is found.
26    let mut result = String::with_capacity(s.len());
27    for ch in s.chars() {
28        match ch {
29            '&' => result.push_str("&amp;"),
30            '<' => result.push_str("&lt;"),
31            '>' => result.push_str("&gt;"),
32            '"' => result.push_str("&quot;"),
33            _ => result.push(ch),
34        }
35    }
36    result
37}
38
39/// Returns `true` if the URL uses a safe scheme for hyperlinks.
40///
41/// Only `http://`, `https://`, `mailto:`, and empty URLs are accepted.
42/// Dangerous schemes like `javascript:`, `data:`, and `file:` are rejected
43/// to prevent XSS and local file access when the HWPX is rendered in a
44/// web-based viewer.
45pub(crate) fn is_safe_url(url: &str) -> bool {
46    let lower = url.to_ascii_lowercase();
47    lower.starts_with("http://")
48        || lower.starts_with("https://")
49        || lower.starts_with("mailto:")
50        || url.is_empty()
51}
52
53/// Sanitizes a filename for safe use as a ZIP archive entry.
54///
55/// Strips leading slashes and rejects `..` path components to prevent
56/// path traversal attacks (CWE-22) when the ZIP is extracted.
57pub(crate) fn sanitize_zip_entry_name(name: &str) -> String {
58    name.split('/').filter(|c| !c.is_empty() && *c != "..").collect::<Vec<_>>().join("/")
59}
60
61#[cfg(test)]
62mod escape_xml_tests {
63    use super::escape_xml;
64
65    #[test]
66    fn empty_string() {
67        assert_eq!(escape_xml(""), "");
68    }
69
70    #[test]
71    fn no_special_chars() {
72        let input = "Hello World 123";
73        assert_eq!(escape_xml(input), input);
74    }
75
76    #[test]
77    fn all_special_chars() {
78        assert_eq!(escape_xml("<>&\""), "&lt;&gt;&amp;&quot;");
79    }
80
81    #[test]
82    fn mixed_content() {
83        assert_eq!(escape_xml("a < b & c"), "a &lt; b &amp; c");
84    }
85
86    #[test]
87    fn ampersand_first() {
88        // Ampersand must be replaced first to avoid double-escaping
89        assert_eq!(escape_xml("&<"), "&amp;&lt;");
90    }
91
92    #[test]
93    fn korean_text_unchanged() {
94        let input = "안녕하세요 테스트";
95        assert_eq!(escape_xml(input), input);
96    }
97
98    #[test]
99    fn url_with_ampersand() {
100        assert_eq!(escape_xml("https://example.com?a=1&b=2"), "https://example.com?a=1&amp;b=2");
101    }
102}
103
104#[cfg(test)]
105mod is_safe_url_tests {
106    use super::is_safe_url;
107
108    #[test]
109    fn http_allowed() {
110        assert!(is_safe_url("http://example.com"));
111    }
112
113    #[test]
114    fn https_allowed() {
115        assert!(is_safe_url("https://example.com/path?q=1"));
116    }
117
118    #[test]
119    fn mailto_allowed() {
120        assert!(is_safe_url("mailto:user@example.com"));
121    }
122
123    #[test]
124    fn empty_allowed() {
125        assert!(is_safe_url(""));
126    }
127
128    #[test]
129    fn javascript_rejected() {
130        assert!(!is_safe_url("javascript:alert(1)"));
131    }
132
133    #[test]
134    fn javascript_mixed_case_rejected() {
135        assert!(!is_safe_url("JaVaScRiPt:alert(1)"));
136    }
137
138    #[test]
139    fn data_uri_rejected() {
140        assert!(!is_safe_url("data:text/html,<script>alert(1)</script>"));
141    }
142
143    #[test]
144    fn file_uri_rejected() {
145        assert!(!is_safe_url("file:///etc/passwd"));
146    }
147
148    #[test]
149    fn ftp_rejected() {
150        assert!(!is_safe_url("ftp://example.com"));
151    }
152
153    #[test]
154    fn bare_path_rejected() {
155        assert!(!is_safe_url("/etc/passwd"));
156    }
157}
158
159#[cfg(test)]
160mod sanitize_zip_tests {
161    use super::sanitize_zip_entry_name;
162
163    #[test]
164    fn normal_path_unchanged() {
165        assert_eq!(sanitize_zip_entry_name("BinData/logo.png"), "BinData/logo.png");
166    }
167
168    #[test]
169    fn strips_dotdot() {
170        assert_eq!(sanitize_zip_entry_name("../../../etc/passwd"), "etc/passwd");
171    }
172
173    #[test]
174    fn strips_leading_slash() {
175        assert_eq!(sanitize_zip_entry_name("/absolute/path.png"), "absolute/path.png");
176    }
177
178    #[test]
179    fn strips_empty_components() {
180        assert_eq!(sanitize_zip_entry_name("a//b///c"), "a/b/c");
181    }
182
183    #[test]
184    fn dotdot_in_middle() {
185        assert_eq!(sanitize_zip_entry_name("a/../b/file.txt"), "a/b/file.txt");
186    }
187
188    #[test]
189    fn single_filename() {
190        assert_eq!(sanitize_zip_entry_name("file.png"), "file.png");
191    }
192}
193
194use std::path::Path;
195
196use hwpforge_core::document::{Document, Validated};
197use hwpforge_core::image::ImageStore;
198
199use crate::error::{HwpxError, HwpxResult};
200use crate::style_store::HwpxStyleStore;
201
202use self::header::encode_header;
203use self::package::PackageWriter;
204use self::section::encode_section;
205
206// ── HwpxEncoder ─────────────────────────────────────────────────
207
208/// Encodes Core documents to HWPX format (ZIP + XML).
209///
210/// This is the reverse of [`crate::HwpxDecoder`]: it takes a validated
211/// document and an [`HwpxStyleStore`] and produces a valid HWPX archive.
212///
213/// # Round-trip
214///
215/// ```no_run
216/// use hwpforge_smithy_hwpx::{HwpxDecoder, HwpxEncoder};
217///
218/// let bytes = std::fs::read("input.hwpx").unwrap();
219/// let result = HwpxDecoder::decode(&bytes).unwrap();
220/// let validated = result.document.validate().unwrap();
221/// let output = HwpxEncoder::encode(&validated, &result.style_store, &result.image_store).unwrap();
222/// std::fs::write("output.hwpx", &output).unwrap();
223/// ```
224///
225/// # Image Binary Support
226///
227/// The encoder embeds binary image data from [`ImageStore`] into
228/// `BinData/` entries in the ZIP archive. Image paths in the document
229/// (e.g. `"BinData/image1.png"`) are matched against the store keys.
230/// Images not found in the store are silently skipped (XML reference
231/// only, no binary data).
232#[derive(Debug, Clone, Copy)]
233pub struct HwpxEncoder;
234
235impl HwpxEncoder {
236    /// Encodes a validated document with its style store and images to HWPX bytes.
237    ///
238    /// The returned bytes form a valid ZIP archive that can be written
239    /// to a `.hwpx` file or decoded back with [`crate::HwpxDecoder`].
240    ///
241    /// # Pipeline
242    ///
243    /// 1. Serialize `HwpxStyleStore` → `header.xml`
244    /// 2. Serialize each section → `section{N}.xml`
245    /// 3. Collect image binaries from `ImageStore`
246    /// 4. Package into ZIP with metadata files + BinData/
247    ///
248    /// # Errors
249    ///
250    /// - [`HwpxError::XmlSerialize`] if quick-xml serialization fails
251    /// - [`HwpxError::InvalidStructure`] if table nesting exceeds limits
252    /// - [`HwpxError::Zip`] if ZIP archive creation fails
253    pub fn encode(
254        document: &Document<Validated>,
255        style_store: &HwpxStyleStore,
256        image_store: &ImageStore,
257    ) -> HwpxResult<Vec<u8>> {
258        let sections = document.sections();
259        let sec_cnt = sections.len() as u32;
260
261        // Step 1: Encode header
262        let begin_num = sections.first().and_then(|s| s.begin_num.as_ref());
263        let header_xml = encode_header(style_store, sec_cnt, begin_num)?;
264
265        // Step 2: Encode sections (each produces XML + chart + masterpage entries)
266        // chart_offset and masterpage_offset track global indices across sections
267        // to avoid duplicate filenames in the ZIP archive.
268        let mut chart_offset = 0usize;
269        let mut masterpage_offset = 0usize;
270        let mut section_results = Vec::with_capacity(sections.len());
271        for (i, section) in sections.iter().enumerate() {
272            let result = encode_section(section, i, chart_offset, masterpage_offset)?;
273            chart_offset += result.charts.len();
274            masterpage_offset += result.master_pages.len();
275            section_results.push(result);
276        }
277
278        let section_xmls: Vec<String> = section_results.iter().map(|r| r.xml.clone()).collect();
279        let charts: Vec<(String, String)> =
280            section_results.iter().flat_map(|r| r.charts.clone()).collect();
281        let master_pages: Vec<(String, String)> =
282            section_results.into_iter().flat_map(|r| r.master_pages).collect();
283
284        // Step 3: Collect image binaries
285        let images: Vec<(String, Vec<u8>)> =
286            image_store.iter().map(|(key, data)| (key.to_string(), data.to_vec())).collect();
287
288        // Step 4: Package into ZIP with images, charts, and master pages
289        PackageWriter::write_hwpx(&header_xml, &section_xmls, &images, &charts, &master_pages)
290    }
291
292    /// Encodes a validated document and writes it to a file.
293    ///
294    /// Convenience wrapper around [`encode`](Self::encode) +
295    /// [`std::fs::write`].
296    ///
297    /// # Errors
298    ///
299    /// Returns [`HwpxError::Io`] if the file cannot be written, or any
300    /// error from [`encode`](Self::encode).
301    pub fn encode_file(
302        path: impl AsRef<Path>,
303        document: &Document<Validated>,
304        style_store: &HwpxStyleStore,
305        image_store: &ImageStore,
306    ) -> HwpxResult<()> {
307        let bytes = Self::encode(document, style_store, image_store)?;
308        std::fs::write(path.as_ref(), bytes).map_err(HwpxError::Io)
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::HwpxDecoder;
316    use hwpforge_core::image::ImageStore;
317    use hwpforge_core::paragraph::Paragraph;
318    use hwpforge_core::run::Run;
319    use hwpforge_core::section::Section;
320    use hwpforge_core::PageSettings;
321    use hwpforge_foundation::{
322        Alignment, CharShapeIndex, Color, EmbossType, EngraveType, FontIndex, HwpUnit,
323        LineSpacingType, OutlineType, ParaShapeIndex, ShadowType, StrikeoutShape, UnderlineType,
324        VerticalPosition,
325    };
326
327    use crate::style_store::{HwpxCharShape, HwpxFont, HwpxFontRef, HwpxParaShape};
328
329    /// Creates a minimal validated document + style store for testing.
330    fn minimal_doc_and_store() -> (Document<Validated>, HwpxStyleStore) {
331        let mut store = HwpxStyleStore::new();
332        store.push_font(HwpxFont {
333            id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
334        });
335        store.push_char_shape(HwpxCharShape {
336            font_ref: HwpxFontRef::default(),
337            height: HwpUnit::new(1000).unwrap(),
338            text_color: Color::BLACK,
339            shade_color: None,
340            bold: false,
341            italic: false,
342            underline_type: UnderlineType::None,
343            underline_color: None,
344            strikeout_shape: StrikeoutShape::None,
345            strikeout_color: None,
346            vertical_position: VerticalPosition::Normal,
347            outline_type: OutlineType::None,
348            shadow_type: ShadowType::None,
349            emboss_type: EmbossType::None,
350            engrave_type: EngraveType::None,
351            ..Default::default()
352        });
353        store.push_para_shape(HwpxParaShape {
354            alignment: Alignment::Left,
355            margin_left: HwpUnit::ZERO,
356            margin_right: HwpUnit::ZERO,
357            indent: HwpUnit::ZERO,
358            spacing_before: HwpUnit::ZERO,
359            spacing_after: HwpUnit::ZERO,
360            line_spacing: 160,
361            line_spacing_type: LineSpacingType::Percentage,
362            ..Default::default()
363        });
364
365        let mut doc = Document::new();
366        doc.add_section(Section::with_paragraphs(
367            vec![Paragraph::with_runs(
368                vec![Run::text("안녕하세요", CharShapeIndex::new(0))],
369                ParaShapeIndex::new(0),
370            )],
371            PageSettings::a4(),
372        ));
373        let validated = doc.validate().unwrap();
374        (validated, store)
375    }
376
377    // ── 1. Basic encode produces valid ZIP ──────────────────────
378
379    #[test]
380    fn encode_produces_valid_zip() {
381        let (doc, store) = minimal_doc_and_store();
382        let bytes = HwpxEncoder::encode(&doc, &store, &ImageStore::new()).unwrap();
383
384        // Must be a valid ZIP (starts with PK magic bytes)
385        assert_eq!(&bytes[0..2], b"PK", "output must be a ZIP archive");
386        assert!(bytes.len() > 100, "ZIP too small: {} bytes", bytes.len());
387    }
388
389    // ── 2. Full encode → decode roundtrip ──────────────────────
390
391    #[test]
392    fn encode_decode_roundtrip() {
393        let (doc, store) = minimal_doc_and_store();
394        let bytes = HwpxEncoder::encode(&doc, &store, &ImageStore::new()).unwrap();
395
396        // Decode the encoded output
397        let decoded = HwpxDecoder::decode(&bytes).unwrap();
398
399        // Document structure preserved
400        assert_eq!(decoded.document.sections().len(), 1);
401        let section = &decoded.document.sections()[0];
402        assert_eq!(section.paragraphs.len(), 1);
403        assert_eq!(section.paragraphs[0].runs[0].content.as_text(), Some("안녕하세요"),);
404
405        // Style store preserved (fonts expanded to 7 language groups: 1 × 7 = 7)
406        assert_eq!(decoded.style_store.font_count(), 7);
407        let font = decoded.style_store.font(FontIndex::new(0)).unwrap();
408        assert_eq!(font.face_name, "함초롬돋움");
409        assert_eq!(font.lang, "HANGUL");
410
411        assert_eq!(decoded.style_store.char_shape_count(), store.char_shape_count());
412        let cs = decoded.style_store.char_shape(CharShapeIndex::new(0)).unwrap();
413        assert_eq!(cs.height.as_i32(), 1000);
414        assert!(!cs.bold);
415
416        assert_eq!(decoded.style_store.para_shape_count(), store.para_shape_count());
417        let ps = decoded.style_store.para_shape(ParaShapeIndex::new(0)).unwrap();
418        assert_eq!(ps.alignment, Alignment::Left);
419        assert_eq!(ps.line_spacing, 160);
420    }
421
422    // ── 3. Multi-section roundtrip ─────────────────────────────
423
424    #[test]
425    fn multi_section_roundtrip() {
426        let (_, store) = minimal_doc_and_store();
427
428        let mut doc = Document::new();
429        for i in 0..3 {
430            doc.add_section(Section::with_paragraphs(
431                vec![Paragraph::with_runs(
432                    vec![Run::text(format!("Section {i}"), CharShapeIndex::new(0))],
433                    ParaShapeIndex::new(0),
434                )],
435                PageSettings::a4(),
436            ));
437        }
438        let validated = doc.validate().unwrap();
439
440        let bytes = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).unwrap();
441        let decoded = HwpxDecoder::decode(&bytes).unwrap();
442
443        assert_eq!(decoded.document.sections().len(), 3);
444        for i in 0..3 {
445            let text =
446                decoded.document.sections()[i].paragraphs[0].runs[0].content.as_text().unwrap();
447            assert_eq!(text, &format!("Section {i}"));
448        }
449    }
450
451    // ── 4. Page settings roundtrip ─────────────────────────────
452
453    #[test]
454    fn page_settings_roundtrip() {
455        let (_, store) = minimal_doc_and_store();
456
457        let custom_ps = PageSettings {
458            width: HwpUnit::new(59528).unwrap(),
459            height: HwpUnit::new(84188).unwrap(),
460            margin_left: HwpUnit::new(8504).unwrap(),
461            margin_right: HwpUnit::new(8504).unwrap(),
462            margin_top: HwpUnit::new(5668).unwrap(),
463            margin_bottom: HwpUnit::new(4252).unwrap(),
464            header_margin: HwpUnit::new(4252).unwrap(),
465            footer_margin: HwpUnit::new(4252).unwrap(),
466            ..PageSettings::a4()
467        };
468
469        let mut doc = Document::new();
470        doc.add_section(Section::with_paragraphs(
471            vec![Paragraph::with_runs(
472                vec![Run::text("Content", CharShapeIndex::new(0))],
473                ParaShapeIndex::new(0),
474            )],
475            custom_ps,
476        ));
477        let validated = doc.validate().unwrap();
478
479        let bytes = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).unwrap();
480        let decoded = HwpxDecoder::decode(&bytes).unwrap();
481
482        let decoded_ps = &decoded.document.sections()[0].page_settings;
483        assert_eq!(decoded_ps.width.as_i32(), 59528);
484        assert_eq!(decoded_ps.height.as_i32(), 84188);
485        assert_eq!(decoded_ps.margin_left.as_i32(), 8504);
486        assert_eq!(decoded_ps.margin_right.as_i32(), 8504);
487        assert_eq!(decoded_ps.margin_top.as_i32(), 5668);
488        assert_eq!(decoded_ps.margin_bottom.as_i32(), 4252);
489    }
490
491    // ── 5. Table roundtrip ─────────────────────────────────────
492
493    #[test]
494    fn table_roundtrip() {
495        use hwpforge_core::table::{Table, TableCell, TableRow};
496
497        let (_, store) = minimal_doc_and_store();
498
499        let cell1 = TableCell::new(
500            vec![Paragraph::with_runs(
501                vec![Run::text("A", CharShapeIndex::new(0))],
502                ParaShapeIndex::new(0),
503            )],
504            HwpUnit::new(5000).unwrap(),
505        );
506        let cell2 = TableCell::new(
507            vec![Paragraph::with_runs(
508                vec![Run::text("B", CharShapeIndex::new(0))],
509                ParaShapeIndex::new(0),
510            )],
511            HwpUnit::new(5000).unwrap(),
512        );
513        let table = Table::new(vec![TableRow::new(vec![cell1, cell2])]);
514
515        let mut doc = Document::new();
516        doc.add_section(Section::with_paragraphs(
517            vec![Paragraph::with_runs(
518                vec![Run::table(table, CharShapeIndex::new(0))],
519                ParaShapeIndex::new(0),
520            )],
521            PageSettings::a4(),
522        ));
523        let validated = doc.validate().unwrap();
524
525        let bytes = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).unwrap();
526        let decoded = HwpxDecoder::decode(&bytes).unwrap();
527
528        let run = &decoded.document.sections()[0].paragraphs[0].runs[0];
529        let t = run.content.as_table().unwrap();
530        assert_eq!(t.rows.len(), 1);
531        assert_eq!(t.rows[0].cells.len(), 2);
532        assert_eq!(t.rows[0].cells[0].paragraphs[0].runs[0].content.as_text(), Some("A"),);
533        assert_eq!(t.rows[0].cells[1].paragraphs[0].runs[0].content.as_text(), Some("B"),);
534    }
535
536    // ── 6. Rich styles roundtrip ───────────────────────────────
537
538    #[test]
539    fn rich_styles_roundtrip() {
540        let mut store = HwpxStyleStore::new();
541        store.push_font(HwpxFont {
542            id: 0, face_name: "함초롬돋움".into(), lang: "HANGUL".into()
543        });
544        store.push_font(HwpxFont { id: 0, face_name: "Arial".into(), lang: "LATIN".into() });
545        store.push_char_shape(HwpxCharShape {
546            font_ref: HwpxFontRef {
547                hangul: FontIndex::new(0),
548                latin: FontIndex::new(1),
549                ..Default::default()
550            },
551            height: HwpUnit::new(2400).unwrap(),
552            text_color: Color::from_rgb(255, 0, 0),
553            shade_color: None,
554            bold: true,
555            italic: true,
556            underline_type: UnderlineType::Bottom,
557            underline_color: None,
558            strikeout_shape: StrikeoutShape::None,
559            strikeout_color: None,
560            vertical_position: VerticalPosition::Normal,
561            outline_type: OutlineType::None,
562            shadow_type: ShadowType::None,
563            emboss_type: EmbossType::None,
564            engrave_type: EngraveType::None,
565            ..Default::default()
566        });
567        store.push_char_shape(HwpxCharShape::default());
568        store.push_para_shape(HwpxParaShape {
569            alignment: Alignment::Justify,
570            margin_left: HwpUnit::new(200).unwrap(),
571            margin_right: HwpUnit::new(100).unwrap(),
572            indent: HwpUnit::new(300).unwrap(),
573            spacing_before: HwpUnit::new(150).unwrap(),
574            spacing_after: HwpUnit::new(50).unwrap(),
575            line_spacing: 200,
576            line_spacing_type: LineSpacingType::Percentage,
577            ..Default::default()
578        });
579
580        let mut doc = Document::new();
581        doc.add_section(Section::with_paragraphs(
582            vec![Paragraph::with_runs(
583                vec![
584                    Run::text("Bold+Italic", CharShapeIndex::new(0)),
585                    Run::text("Normal", CharShapeIndex::new(1)),
586                ],
587                ParaShapeIndex::new(0),
588            )],
589            PageSettings::a4(),
590        ));
591        let validated = doc.validate().unwrap();
592
593        let bytes = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).unwrap();
594        let decoded = HwpxDecoder::decode(&bytes).unwrap();
595
596        // Fonts: expanded to 7 language groups (1+1+1×5 = 7)
597        assert_eq!(decoded.style_store.font_count(), 7);
598        assert_eq!(decoded.style_store.font(FontIndex::new(0)).unwrap().face_name, "함초롬돋움");
599        assert_eq!(decoded.style_store.font(FontIndex::new(1)).unwrap().face_name, "Arial");
600
601        // Rich char shape
602        let cs = decoded.style_store.char_shape(CharShapeIndex::new(0)).unwrap();
603        assert_eq!(cs.height.as_i32(), 2400);
604        assert_eq!(cs.text_color, Color::from_rgb(255, 0, 0));
605        assert!(cs.bold);
606        assert!(cs.italic);
607        assert_eq!(cs.underline_type, UnderlineType::Bottom);
608
609        // Para shape
610        let ps = decoded.style_store.para_shape(ParaShapeIndex::new(0)).unwrap();
611        assert_eq!(ps.alignment, Alignment::Justify);
612        assert_eq!(ps.margin_left.as_i32(), 200);
613        assert_eq!(ps.line_spacing, 200);
614    }
615
616    // ── 7. encode_file roundtrip ───────────────────────────────
617
618    #[test]
619    fn encode_file_roundtrip() {
620        let (doc, store) = minimal_doc_and_store();
621
622        let dir = std::env::temp_dir().join("hwpforge_test_encode_file");
623        std::fs::create_dir_all(&dir).unwrap();
624        let path = dir.join("test_output.hwpx");
625
626        HwpxEncoder::encode_file(&path, &doc, &store, &ImageStore::new()).unwrap();
627
628        // Decode the file
629        let decoded = HwpxDecoder::decode_file(&path).unwrap();
630        assert_eq!(decoded.document.sections().len(), 1);
631        assert_eq!(
632            decoded.document.sections()[0].paragraphs[0].runs[0].content.as_text(),
633            Some("안녕하세요"),
634        );
635
636        // Cleanup
637        let _ = std::fs::remove_dir_all(&dir);
638    }
639
640    // ── 8. encode_file error on bad path ───────────────────────
641
642    #[test]
643    fn encode_file_bad_path() {
644        let (doc, store) = minimal_doc_and_store();
645        let err = HwpxEncoder::encode_file(
646            "/nonexistent/dir/test.hwpx",
647            &doc,
648            &store,
649            &ImageStore::new(),
650        )
651        .unwrap_err();
652        assert!(matches!(err, HwpxError::Io(_)));
653    }
654
655    // ── 9. Empty style store produces valid output ─────────────
656
657    #[test]
658    fn empty_style_store_encode() {
659        let store = HwpxStyleStore::new();
660        let mut doc = Document::new();
661        doc.add_section(Section::with_paragraphs(
662            vec![Paragraph::with_runs(
663                vec![Run::text("text", CharShapeIndex::new(0))],
664                ParaShapeIndex::new(0),
665            )],
666            PageSettings::a4(),
667        ));
668        let validated = doc.validate().unwrap();
669
670        // Should still produce a valid ZIP (no style data, but valid structure)
671        let bytes = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).unwrap();
672        assert_eq!(&bytes[0..2], b"PK");
673    }
674
675    // ── 10. Encoded output is decodable ────────────────────────
676
677    #[test]
678    fn encoded_output_is_decodable_by_decoder() {
679        let (doc, store) = minimal_doc_and_store();
680        let bytes = HwpxEncoder::encode(&doc, &store, &ImageStore::new()).unwrap();
681
682        // The key test: the decoder accepts encoder output
683        let result = HwpxDecoder::decode(&bytes);
684        assert!(result.is_ok(), "Decoder failed on encoder output: {:?}", result.err());
685    }
686}