1pub(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
18pub(crate) fn escape_xml(s: &str) -> String {
25 let mut result = String::with_capacity(s.len());
27 for ch in s.chars() {
28 match ch {
29 '&' => result.push_str("&"),
30 '<' => result.push_str("<"),
31 '>' => result.push_str(">"),
32 '"' => result.push_str("""),
33 _ => result.push(ch),
34 }
35 }
36 result
37}
38
39pub(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
53pub(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("<>&\""), "<>&"");
79 }
80
81 #[test]
82 fn mixed_content() {
83 assert_eq!(escape_xml("a < b & c"), "a < b & c");
84 }
85
86 #[test]
87 fn ampersand_first() {
88 assert_eq!(escape_xml("&<"), "&<");
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&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#[derive(Debug, Clone, Copy)]
233pub struct HwpxEncoder;
234
235impl HwpxEncoder {
236 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 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 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 let images: Vec<(String, Vec<u8>)> =
286 image_store.iter().map(|(key, data)| (key.to_string(), data.to_vec())).collect();
287
288 PackageWriter::write_hwpx(&header_xml, §ion_xmls, &images, &charts, &master_pages)
290 }
291
292 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 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 #[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 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 #[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 let decoded = HwpxDecoder::decode(&bytes).unwrap();
398
399 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 let _ = std::fs::remove_dir_all(&dir);
638 }
639
640 #[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 #[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 let bytes = HwpxEncoder::encode(&validated, &store, &ImageStore::new()).unwrap();
672 assert_eq!(&bytes[0..2], b"PK");
673 }
674
675 #[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 let result = HwpxDecoder::decode(&bytes);
684 assert!(result.is_ok(), "Decoder failed on encoder output: {:?}", result.err());
685 }
686}