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