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