1use std::path::Path;
4
5use rdocx_opc::OpcPackage;
6use rdocx_opc::relationship::rel_types;
7use rdocx_oxml::document::{BodyContent, CT_Columns, CT_Document, CT_SectPr};
8use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, CT_Inline};
9use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
10use rdocx_oxml::numbering::CT_Numbering;
11use rdocx_oxml::properties::{CT_PPr, CT_RPr};
12use rdocx_oxml::shared::{ST_PageOrientation, ST_SectionType};
13use rdocx_oxml::styles::CT_Styles;
14use rdocx_oxml::table::CT_Tbl;
15use rdocx_oxml::text::{CT_P, CT_R, RunContent};
16
17use rdocx_oxml::core_properties::CoreProperties;
18
19use crate::Length;
20use crate::error::{Error, Result};
21use crate::paragraph::{Paragraph, ParagraphRef};
22use crate::style::{self, Style, StyleBuilder};
23use crate::table::{Table, TableRef};
24
25pub struct Document {
30 package: OpcPackage,
31 document: CT_Document,
32 styles: CT_Styles,
33 numbering: Option<CT_Numbering>,
34 core_properties: Option<CoreProperties>,
35 doc_part_name: String,
37 styles_part_name: String,
42 numbering_part_name: String,
44 image_counter: usize,
46}
47
48const DEFAULT_STYLES_PART: &str = "/word/styles.xml";
50const DEFAULT_NUMBERING_PART: &str = "/word/numbering.xml";
51const STYLES_CONTENT_TYPE: &str =
52 "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
53const NUMBERING_CONTENT_TYPE: &str =
54 "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
55
56impl Document {
57 pub fn new() -> Self {
59 let mut package = OpcPackage::new_docx();
60 let document = CT_Document::new();
61 let styles = CT_Styles::new_default();
62
63 package
65 .get_or_create_part_rels("/word/document.xml")
66 .add(rel_types::STYLES, "styles.xml");
67
68 Document {
69 package,
70 document,
71 styles,
72 numbering: None,
73 core_properties: None,
74 doc_part_name: "/word/document.xml".to_string(),
75 styles_part_name: DEFAULT_STYLES_PART.to_string(),
76 numbering_part_name: DEFAULT_NUMBERING_PART.to_string(),
77 image_counter: 0,
78 }
79 }
80
81 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
83 let package = OpcPackage::open(path)?;
84 Self::from_package(package)
85 }
86
87 pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
89 let cursor = std::io::Cursor::new(bytes);
90 let package = OpcPackage::from_reader(cursor)?;
91 Self::from_package(package)
92 }
93
94 fn from_package(package: OpcPackage) -> Result<Self> {
95 let doc_part_name = package.main_document_part().ok_or(Error::NoDocumentPart)?;
96
97 let doc_xml = package
98 .get_part(&doc_part_name)
99 .ok_or(Error::NoDocumentPart)?;
100 let document = CT_Document::from_xml(doc_xml)?;
101
102 let resolve_part = |rel_type: &str| -> Option<String> {
104 let rels = package.get_part_rels(&doc_part_name)?;
105 let rel = rels.get_by_type(rel_type)?;
106 Some(OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
107 };
108
109 let styles_part_name = resolve_part(rel_types::STYLES);
111 let styles = match styles_part_name
112 .as_deref()
113 .and_then(|p| package.get_part(p))
114 {
115 Some(styles_xml) => CT_Styles::from_xml(styles_xml)?,
116 None => CT_Styles::new_default(),
117 };
118
119 let numbering_part_name = resolve_part(rel_types::NUMBERING);
121 let numbering = match numbering_part_name
122 .as_deref()
123 .and_then(|p| package.get_part(p))
124 {
125 Some(num_xml) => Some(CT_Numbering::from_xml(num_xml)?),
126 None => None,
127 };
128
129 let core_properties = package
131 .get_part("/docProps/core.xml")
132 .and_then(|xml| CoreProperties::from_xml(xml).ok());
133
134 let image_counter = package
135 .parts
136 .keys()
137 .filter(|k| k.starts_with("/word/media/image"))
138 .count();
139
140 Ok(Document {
141 package,
142 document,
143 styles,
144 numbering,
145 core_properties,
146 doc_part_name,
147 styles_part_name: styles_part_name.unwrap_or_else(|| DEFAULT_STYLES_PART.to_string()),
148 numbering_part_name: numbering_part_name
149 .unwrap_or_else(|| DEFAULT_NUMBERING_PART.to_string()),
150 image_counter,
151 })
152 }
153
154 pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
156 self.flush_to_package()?;
157 self.package.save(path)?;
158 Ok(())
159 }
160
161 pub fn to_bytes(&mut self) -> Result<Vec<u8>> {
163 self.flush_to_package()?;
164 let mut buf = std::io::Cursor::new(Vec::new());
165 self.package.write_to(&mut buf)?;
166 Ok(buf.into_inner())
167 }
168
169 fn flush_to_package(&mut self) -> Result<()> {
171 let doc_xml = self.document.to_xml()?;
173 self.package.set_part(&self.doc_part_name, doc_xml);
174
175 let styles_xml = self.styles.to_xml()?;
179 let styles_part = self.styles_part_name.clone();
180 self.package.set_part(&styles_part, styles_xml);
181 self.ensure_part_relationship(&styles_part, rel_types::STYLES, STYLES_CONTENT_TYPE);
182
183 if let Some(ref numbering) = self.numbering {
185 let numbering_xml = numbering.to_xml()?;
186 let numbering_part = self.numbering_part_name.clone();
187 self.package.set_part(&numbering_part, numbering_xml);
188 self.ensure_part_relationship(
189 &numbering_part,
190 rel_types::NUMBERING,
191 NUMBERING_CONTENT_TYPE,
192 );
193 }
194
195 if let Some(ref props) = self.core_properties {
197 let core_xml = props.to_xml()?;
198 self.package.set_part("/docProps/core.xml", core_xml);
199 self.package.content_types.add_override(
200 "/docProps/core.xml",
201 "application/vnd.openxmlformats-package.core-properties+xml",
202 );
203 }
204
205 Ok(())
206 }
207
208 fn ensure_part_relationship(&mut self, part_name: &str, rel_type: &str, content_type: &str) {
211 self.package
212 .content_types
213 .add_override(part_name, content_type);
214
215 let doc_part_name = self.doc_part_name.clone();
216 let already_linked = self
217 .package
218 .get_part_rels(&doc_part_name)
219 .and_then(|rels| rels.get_by_type(rel_type))
220 .map(|rel| OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
221 .is_some_and(|target| target == part_name);
222 if already_linked {
223 return;
224 }
225
226 let target = relative_target(&doc_part_name, part_name);
228 self.package
229 .get_or_create_part_rels(&doc_part_name)
230 .add(rel_type, &target);
231 }
232
233 pub fn paragraphs(&self) -> Vec<ParagraphRef<'_>> {
237 self.document
238 .body
239 .paragraphs()
240 .map(|p| ParagraphRef { inner: p })
241 .collect()
242 }
243
244 pub fn add_paragraph(&mut self, text: &str) -> Paragraph<'_> {
246 let mut p = CT_P::new();
247 if !text.is_empty() {
248 p.add_run(text);
249 }
250 self.document.body.content.push(BodyContent::Paragraph(p));
251 match self.document.body.content.last_mut().unwrap() {
252 BodyContent::Paragraph(p) => Paragraph { inner: p },
253 _ => unreachable!(),
254 }
255 }
256
257 pub fn paragraph_count(&self) -> usize {
259 self.document.body.paragraphs().count()
260 }
261
262 pub fn paragraph_mut(&mut self, index: usize) -> Option<Paragraph<'_>> {
264 self.document
265 .body
266 .paragraphs_mut()
267 .nth(index)
268 .map(|p| Paragraph { inner: p })
269 }
270
271 pub fn tables(&self) -> Vec<TableRef<'_>> {
275 self.document
276 .body
277 .tables()
278 .map(|t| TableRef { inner: t })
279 .collect()
280 }
281
282 pub fn add_table(&mut self, rows: usize, cols: usize) -> Table<'_> {
285 use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
286 use rdocx_oxml::units::Twips;
287
288 let col_width = Twips(9360 / cols.max(1) as i32);
291
292 let grid = CT_TblGrid {
293 columns: (0..cols)
294 .map(|_| CT_TblGridCol { width: col_width })
295 .collect(),
296 };
297
298 let mut tbl = CT_Tbl::new();
299 tbl.properties = Some(CT_TblPr {
300 width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
301 ..Default::default()
302 });
303 tbl.grid = Some(grid);
304
305 for _ in 0..rows {
306 let mut row = CT_Row::new();
307 for _ in 0..cols {
308 row.cells.push(CT_Tc::new());
309 }
310 tbl.rows.push(row);
311 }
312
313 self.document.body.content.push(BodyContent::Table(tbl));
314 match self.document.body.content.last_mut().unwrap() {
315 BodyContent::Table(t) => Table { inner: t },
316 _ => unreachable!(),
317 }
318 }
319
320 pub fn table_count(&self) -> usize {
322 self.document.body.tables().count()
323 }
324
325 pub fn content_count(&self) -> usize {
329 self.document.body.content_count()
330 }
331
332 pub fn insert_paragraph(&mut self, index: usize, text: &str) -> Paragraph<'_> {
340 let mut p = CT_P::new();
341 if !text.is_empty() {
342 p.add_run(text);
343 }
344 self.document.body.insert_paragraph(index, p);
345 match &mut self.document.body.content[index] {
346 BodyContent::Paragraph(p) => Paragraph { inner: p },
347 _ => unreachable!(),
348 }
349 }
350
351 pub fn insert_table(&mut self, index: usize, rows: usize, cols: usize) -> Table<'_> {
361 use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
362 use rdocx_oxml::units::Twips;
363
364 let col_width = Twips(9360 / cols.max(1) as i32);
365 let grid = CT_TblGrid {
366 columns: (0..cols)
367 .map(|_| CT_TblGridCol { width: col_width })
368 .collect(),
369 };
370
371 let mut tbl = CT_Tbl::new();
372 tbl.properties = Some(CT_TblPr {
373 width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
374 ..Default::default()
375 });
376 tbl.grid = Some(grid);
377
378 for _ in 0..rows {
379 let mut row = CT_Row::new();
380 for _ in 0..cols {
381 row.cells.push(CT_Tc::new());
382 }
383 tbl.rows.push(row);
384 }
385
386 self.document.body.insert_table(index, tbl);
387 match &mut self.document.body.content[index] {
388 BodyContent::Table(t) => Table { inner: t },
389 _ => unreachable!(),
390 }
391 }
392
393 pub fn find_content_index(&self, text: &str) -> Option<usize> {
395 self.document.body.find_paragraph_index(text)
396 }
397
398 pub fn remove_content(&mut self, index: usize) -> bool {
402 self.document.body.remove(index).is_some()
403 }
404
405 pub fn add_picture(
415 &mut self,
416 image_data: &[u8],
417 image_filename: &str,
418 width: Length,
419 height: Length,
420 ) -> Paragraph<'_> {
421 let rel_id = self.embed_image(image_data, image_filename);
422
423 let inline = CT_Inline::new(&rel_id, width.to_emu(), height.to_emu());
424
425 let drawing = CT_Drawing::inline(inline);
426 let run = CT_R {
427 properties: None,
428 content: vec![RunContent::Drawing(drawing)],
429 extra_xml: Vec::new(),
430 };
431
432 let mut p = CT_P::new();
433 p.runs.push(run);
434 self.document.body.content.push(BodyContent::Paragraph(p));
435 match self.document.body.content.last_mut().unwrap() {
436 BodyContent::Paragraph(p) => Paragraph { inner: p },
437 _ => unreachable!(),
438 }
439 }
440
441 pub fn add_background_image(
448 &mut self,
449 image_data: &[u8],
450 image_filename: &str,
451 ) -> Paragraph<'_> {
452 let rel_id = self.embed_image(image_data, image_filename);
453
454 let sect = self
456 .document
457 .body
458 .sect_pr
459 .as_ref()
460 .cloned()
461 .unwrap_or_else(CT_SectPr::default_letter);
462 let page_width_emu = sect
463 .page_width
464 .unwrap_or(rdocx_oxml::units::Twips(12240))
465 .to_emu()
466 .0;
467 let page_height_emu = sect
468 .page_height
469 .unwrap_or(rdocx_oxml::units::Twips(15840))
470 .to_emu()
471 .0;
472
473 let anchor = CT_Anchor::background(&rel_id, page_width_emu, page_height_emu);
474 let drawing = CT_Drawing::anchor(anchor);
475 let run = CT_R {
476 properties: None,
477 content: vec![RunContent::Drawing(drawing)],
478 extra_xml: Vec::new(),
479 };
480
481 let mut p = CT_P::new();
482 p.runs.push(run);
483 self.document.body.insert_paragraph(0, p);
484 match &mut self.document.body.content[0] {
485 BodyContent::Paragraph(p) => Paragraph { inner: p },
486 _ => unreachable!(),
487 }
488 }
489
490 pub fn add_anchored_image(
495 &mut self,
496 image_data: &[u8],
497 image_filename: &str,
498 width: Length,
499 height: Length,
500 behind_text: bool,
501 ) -> Paragraph<'_> {
502 let rel_id = self.embed_image(image_data, image_filename);
503
504 let mut anchor = CT_Anchor::background(&rel_id, width.to_emu(), height.to_emu());
505 anchor.behind_doc = behind_text;
506
507 let drawing = CT_Drawing::anchor(anchor);
508 let run = CT_R {
509 properties: None,
510 content: vec![RunContent::Drawing(drawing)],
511 extra_xml: Vec::new(),
512 };
513
514 let mut p = CT_P::new();
515 p.runs.push(run);
516 self.document.body.insert_paragraph(0, p);
517 match &mut self.document.body.content[0] {
518 BodyContent::Paragraph(p) => Paragraph { inner: p },
519 _ => unreachable!(),
520 }
521 }
522
523 fn next_image_number(&mut self) -> usize {
525 self.image_counter += 1;
526 self.image_counter
527 }
528
529 fn store_image_part(&mut self, image_data: &[u8], filename: &str) -> String {
536 let ext = image_extension(filename);
537 let image_num = self.next_image_number();
538
539 self.package.set_part(
540 &format!("/word/media/image{image_num}.{ext}"),
541 image_data.to_vec(),
542 );
543 self.package
544 .content_types
545 .add_default(&ext, image_content_type(&ext));
546
547 format!("media/image{image_num}.{ext}")
548 }
549
550 pub fn embed_image(&mut self, image_data: &[u8], filename: &str) -> String {
555 let rel_target = self.store_image_part(image_data, filename);
556 self.package
557 .get_or_create_part_rels(&self.doc_part_name)
558 .add(rel_types::IMAGE, &rel_target)
559 }
560
561 pub fn set_header(&mut self, text: &str) {
568 self.set_header_footer_part(text, true, HdrFtrType::Default);
569 }
570
571 pub fn set_footer(&mut self, text: &str) {
573 self.set_header_footer_part(text, false, HdrFtrType::Default);
574 }
575
576 pub fn set_first_page_header(&mut self, text: &str) {
578 self.set_different_first_page(true);
579 self.set_header_footer_part(text, true, HdrFtrType::First);
580 }
581
582 pub fn set_first_page_footer(&mut self, text: &str) {
584 self.set_different_first_page(true);
585 self.set_header_footer_part(text, false, HdrFtrType::First);
586 }
587
588 pub fn header_text(&self) -> Option<String> {
590 self.get_header_footer_text(true, HdrFtrType::Default)
591 }
592
593 pub fn footer_text(&self) -> Option<String> {
595 self.get_header_footer_text(false, HdrFtrType::Default)
596 }
597
598 pub fn set_header_image(
603 &mut self,
604 image_data: &[u8],
605 image_filename: &str,
606 width: Length,
607 height: Length,
608 ) {
609 self.set_header_footer_image_part(
610 image_data,
611 image_filename,
612 width,
613 height,
614 true,
615 HdrFtrType::Default,
616 );
617 }
618
619 pub fn set_footer_image(
621 &mut self,
622 image_data: &[u8],
623 image_filename: &str,
624 width: Length,
625 height: Length,
626 ) {
627 self.set_header_footer_image_part(
628 image_data,
629 image_filename,
630 width,
631 height,
632 false,
633 HdrFtrType::Default,
634 );
635 }
636
637 pub fn set_raw_header_with_images(
648 &mut self,
649 header_xml: Vec<u8>,
650 images: &[(&str, &[u8], &str)],
651 hdr_type: HdrFtrType,
652 ) {
653 self.set_raw_hdr_ftr_with_images(header_xml, images, true, hdr_type);
654 }
655
656 pub fn set_raw_footer_with_images(
658 &mut self,
659 footer_xml: Vec<u8>,
660 images: &[(&str, &[u8], &str)],
661 hdr_type: HdrFtrType,
662 ) {
663 self.set_raw_hdr_ftr_with_images(footer_xml, images, false, hdr_type);
664 }
665
666 pub fn set_header_image_with_background(
672 &mut self,
673 image_data: &[u8],
674 image_filename: &str,
675 width: Length,
676 height: Length,
677 bg_color: &str,
678 ) {
679 self.set_header_footer_image_bg_part(
680 image_data,
681 image_filename,
682 width,
683 height,
684 Some(bg_color),
685 true,
686 HdrFtrType::Default,
687 );
688 }
689
690 pub fn set_first_page_header_image(
692 &mut self,
693 image_data: &[u8],
694 image_filename: &str,
695 width: Length,
696 height: Length,
697 ) {
698 self.set_different_first_page(true);
699 self.set_header_footer_image_part(
700 image_data,
701 image_filename,
702 width,
703 height,
704 true,
705 HdrFtrType::First,
706 );
707 }
708
709 fn hdr_ftr_slots(
719 is_header: bool,
720 hdr_type: HdrFtrType,
721 ) -> (String, &'static str, &'static str) {
722 let type_suffix = match hdr_type {
723 HdrFtrType::Default => "",
724 HdrFtrType::First => "First",
725 HdrFtrType::Even => "Even",
726 };
727 if is_header {
728 (
729 format!("/word/header{type_suffix}1.xml"),
730 rel_types::HEADER,
731 "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
732 )
733 } else {
734 (
735 format!("/word/footer{type_suffix}1.xml"),
736 rel_types::FOOTER,
737 "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
738 )
739 }
740 }
741
742 fn install_hdr_ftr_part(
747 &mut self,
748 xml: Vec<u8>,
749 is_header: bool,
750 hdr_type: HdrFtrType,
751 ) -> String {
752 let (part_name, rel_type, content_type) = Self::hdr_ftr_slots(is_header, hdr_type);
753
754 self.package.set_part(&part_name, xml);
755 self.package
756 .content_types
757 .add_override(&part_name, content_type);
758
759 let rel_target = relative_target(&self.doc_part_name, &part_name);
762 let rels = self.package.get_or_create_part_rels(&self.doc_part_name);
763 let rel_id = match rels
764 .items
765 .iter()
766 .find(|r| r.rel_type == rel_type && r.target == rel_target)
767 {
768 Some(existing) => existing.id.clone(),
769 None => rels.add(rel_type, &rel_target),
770 };
771
772 let sect = self.section_properties_mut();
773 let refs = if is_header {
774 &mut sect.header_refs
775 } else {
776 &mut sect.footer_refs
777 };
778 refs.retain(|r| r.hdr_ftr_type != hdr_type);
779 refs.push(HdrFtrRef {
780 hdr_ftr_type: hdr_type,
781 rel_id,
782 });
783
784 part_name
785 }
786
787 fn serialize_hdr_ftr(hdr_ftr: &CT_HdrFtr, is_header: bool) -> Result<Vec<u8>> {
789 let xml = if is_header {
790 hdr_ftr.to_xml_header()
791 } else {
792 hdr_ftr.to_xml_footer()
793 };
794 Ok(xml?)
795 }
796
797 fn set_header_footer_part(&mut self, text: &str, is_header: bool, hdr_type: HdrFtrType) {
798 let mut hdr_ftr = CT_HdrFtr::new();
799 let mut p = CT_P::new();
800 if !text.is_empty() {
801 p.add_run(text);
802 }
803 hdr_ftr.paragraphs.push(p);
804
805 let Ok(xml) = Self::serialize_hdr_ftr(&hdr_ftr, is_header) else {
806 return;
807 };
808 self.install_hdr_ftr_part(xml, is_header, hdr_type);
809 }
810
811 fn set_raw_hdr_ftr_with_images(
812 &mut self,
813 xml: Vec<u8>,
814 images: &[(&str, &[u8], &str)],
815 is_header: bool,
816 hdr_type: HdrFtrType,
817 ) {
818 let part_name = self.install_hdr_ftr_part(xml, is_header, hdr_type);
819
820 for &(rel_id, image_data, image_filename) in images {
823 let img_rel_target = self.store_image_part(image_data, image_filename);
824 self.package
825 .get_or_create_part_rels(&part_name)
826 .add_with_id(rel_id, rel_types::IMAGE, &img_rel_target);
827 }
828 }
829
830 fn set_header_footer_image_part(
831 &mut self,
832 image_data: &[u8],
833 image_filename: &str,
834 width: Length,
835 height: Length,
836 is_header: bool,
837 hdr_type: HdrFtrType,
838 ) {
839 self.set_header_footer_image_bg_part(
840 image_data,
841 image_filename,
842 width,
843 height,
844 None,
845 is_header,
846 hdr_type,
847 );
848 }
849
850 fn set_header_footer_image_bg_part(
851 &mut self,
852 image_data: &[u8],
853 image_filename: &str,
854 width: Length,
855 height: Length,
856 bg_color: Option<&str>,
857 is_header: bool,
858 hdr_type: HdrFtrType,
859 ) {
860 use rdocx_oxml::properties::CT_Shd;
861
862 let (part_name, _, _) = Self::hdr_ftr_slots(is_header, hdr_type);
863
864 let img_rel_target = self.store_image_part(image_data, image_filename);
867 let img_rel_id = self
868 .package
869 .get_or_create_part_rels(&part_name)
870 .add(rel_types::IMAGE, &img_rel_target);
871
872 let inline = CT_Inline::new(&img_rel_id, width.to_emu(), height.to_emu());
873 let run = CT_R {
874 properties: None,
875 content: vec![RunContent::Drawing(CT_Drawing::inline(inline))],
876 extra_xml: Vec::new(),
877 };
878
879 let mut p = CT_P::new();
880 p.runs.push(run);
881 if let Some(color) = bg_color {
882 p.properties = Some(CT_PPr {
883 shading: Some(CT_Shd {
884 val: "clear".to_string(),
885 color: Some("auto".to_string()),
886 fill: Some(color.to_string()),
887 }),
888 ..Default::default()
889 });
890 }
891
892 let mut hdr_ftr = CT_HdrFtr::new();
893 hdr_ftr.paragraphs.push(p);
894
895 let Ok(xml) = Self::serialize_hdr_ftr(&hdr_ftr, is_header) else {
896 return;
897 };
898 self.install_hdr_ftr_part(xml, is_header, hdr_type);
899 }
900
901 fn get_header_footer_text(&self, is_header: bool, hdr_type: HdrFtrType) -> Option<String> {
902 let sect = self.document.body.sect_pr.as_ref()?;
903 let refs = if is_header {
904 §.header_refs
905 } else {
906 §.footer_refs
907 };
908 let hdr_ref = refs.iter().find(|r| r.hdr_ftr_type == hdr_type)?;
909
910 let rels = self.package.get_part_rels(&self.doc_part_name)?;
912 let rel = rels.get_by_id(&hdr_ref.rel_id)?;
913 let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
914 let xml = self.package.get_part(&part_name)?;
915 let hdr_ftr = CT_HdrFtr::from_xml(xml).ok()?;
916 Some(hdr_ftr.text())
917 }
918
919 fn ensure_numbering(&mut self) -> &mut CT_Numbering {
927 self.numbering.get_or_insert_with(CT_Numbering::new)
928 }
929
930 pub fn add_bullet_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
935 let num_id = {
937 let numbering = self.ensure_numbering();
938 let existing = numbering.nums.iter().find(|n| {
940 numbering
941 .get_abstract_num_for(n.num_id)
942 .map(|a| {
943 a.levels.first().and_then(|l| l.num_fmt)
944 == Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet)
945 })
946 .unwrap_or(false)
947 });
948 if let Some(existing) = existing {
949 existing.num_id
950 } else {
951 numbering.add_bullet_list()
952 }
953 };
954
955 let mut p = CT_P::new();
956 if !text.is_empty() {
957 p.add_run(text);
958 }
959 let ppr = CT_PPr {
960 num_id: Some(num_id),
961 num_ilvl: Some(level),
962 ..Default::default()
963 };
964 p.properties = Some(ppr);
965
966 self.document.body.content.push(BodyContent::Paragraph(p));
967 match self.document.body.content.last_mut().unwrap() {
968 BodyContent::Paragraph(p) => Paragraph { inner: p },
969 _ => unreachable!(),
970 }
971 }
972
973 pub fn add_numbered_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
978 let num_id = {
980 let numbering = self.ensure_numbering();
981 let existing = numbering.nums.iter().find(|n| {
983 numbering
984 .get_abstract_num_for(n.num_id)
985 .map(|a| {
986 a.levels.first().and_then(|l| l.num_fmt)
987 == Some(rdocx_oxml::numbering::ST_NumberFormat::Decimal)
988 })
989 .unwrap_or(false)
990 });
991 if let Some(existing) = existing {
992 existing.num_id
993 } else {
994 numbering.add_numbered_list()
995 }
996 };
997
998 let mut p = CT_P::new();
999 if !text.is_empty() {
1000 p.add_run(text);
1001 }
1002 let ppr = CT_PPr {
1003 num_id: Some(num_id),
1004 num_ilvl: Some(level),
1005 ..Default::default()
1006 };
1007 p.properties = Some(ppr);
1008
1009 self.document.body.content.push(BodyContent::Paragraph(p));
1010 match self.document.body.content.last_mut().unwrap() {
1011 BodyContent::Paragraph(p) => Paragraph { inner: p },
1012 _ => unreachable!(),
1013 }
1014 }
1015
1016 pub fn styles(&self) -> Vec<Style<'_>> {
1020 self.styles
1021 .styles
1022 .iter()
1023 .map(|s| Style { inner: s })
1024 .collect()
1025 }
1026
1027 pub fn style(&self, style_id: &str) -> Option<Style<'_>> {
1029 self.styles.get_by_id(style_id).map(|s| Style { inner: s })
1030 }
1031
1032 pub fn add_style(&mut self, builder: StyleBuilder) {
1036 self.styles.styles.push(builder.build());
1037 }
1038
1039 pub fn resolve_paragraph_properties(&self, style_id: Option<&str>) -> CT_PPr {
1042 style::resolve_paragraph_properties(style_id, &self.styles)
1043 }
1044
1045 pub fn resolve_run_properties(
1048 &self,
1049 para_style_id: Option<&str>,
1050 run_style_id: Option<&str>,
1051 ) -> CT_RPr {
1052 style::resolve_run_properties(para_style_id, run_style_id, &self.styles)
1053 }
1054
1055 pub fn section_properties(&self) -> Option<&CT_SectPr> {
1059 self.document.body.sect_pr.as_ref()
1060 }
1061
1062 pub fn section_properties_mut(&mut self) -> &mut CT_SectPr {
1064 self.document
1065 .body
1066 .sect_pr
1067 .get_or_insert_with(CT_SectPr::default_letter)
1068 }
1069
1070 pub fn set_page_size(&mut self, width: Length, height: Length) {
1072 let sect = self.section_properties_mut();
1073 sect.page_width = Some(width.as_twips());
1074 sect.page_height = Some(height.as_twips());
1075 }
1076
1077 pub fn set_landscape(&mut self) {
1079 let sect = self.section_properties_mut();
1080 sect.orientation = Some(ST_PageOrientation::Landscape);
1081 if let (Some(w), Some(h)) = (sect.page_width, sect.page_height)
1083 && w.0 < h.0
1084 {
1085 sect.page_width = Some(h);
1086 sect.page_height = Some(w);
1087 }
1088 }
1089
1090 pub fn set_portrait(&mut self) {
1092 let sect = self.section_properties_mut();
1093 sect.orientation = Some(ST_PageOrientation::Portrait);
1094 if let (Some(w), Some(h)) = (sect.page_width, sect.page_height)
1096 && w.0 > h.0
1097 {
1098 sect.page_width = Some(h);
1099 sect.page_height = Some(w);
1100 }
1101 }
1102
1103 pub fn set_margins(&mut self, top: Length, right: Length, bottom: Length, left: Length) {
1105 let sect = self.section_properties_mut();
1106 sect.margin_top = Some(top.as_twips());
1107 sect.margin_right = Some(right.as_twips());
1108 sect.margin_bottom = Some(bottom.as_twips());
1109 sect.margin_left = Some(left.as_twips());
1110 }
1111
1112 pub fn set_columns(&mut self, num: u32, spacing: Length) {
1114 let sect = self.section_properties_mut();
1115 sect.columns = Some(CT_Columns {
1116 num: Some(num),
1117 space: Some(spacing.as_twips()),
1118 equal_width: Some(true),
1119 sep: None,
1120 columns: Vec::new(),
1121 });
1122 }
1123
1124 pub fn set_header_footer_distance(&mut self, header: Length, footer: Length) {
1126 let sect = self.section_properties_mut();
1127 sect.header_distance = Some(header.as_twips());
1128 sect.footer_distance = Some(footer.as_twips());
1129 }
1130
1131 pub fn set_gutter(&mut self, gutter: Length) {
1133 self.section_properties_mut().gutter = Some(gutter.as_twips());
1134 }
1135
1136 pub fn set_different_first_page(&mut self, val: bool) {
1138 self.section_properties_mut().title_pg = Some(val);
1139 }
1140
1141 pub fn title(&self) -> Option<&str> {
1145 self.core_properties.as_ref()?.title.as_deref()
1146 }
1147
1148 pub fn set_title(&mut self, title: &str) {
1150 self.ensure_core_properties().title = Some(title.to_string());
1151 }
1152
1153 pub fn author(&self) -> Option<&str> {
1155 self.core_properties.as_ref()?.creator.as_deref()
1156 }
1157
1158 pub fn set_author(&mut self, author: &str) {
1160 self.ensure_core_properties().creator = Some(author.to_string());
1161 }
1162
1163 pub fn subject(&self) -> Option<&str> {
1165 self.core_properties.as_ref()?.subject.as_deref()
1166 }
1167
1168 pub fn set_subject(&mut self, subject: &str) {
1170 self.ensure_core_properties().subject = Some(subject.to_string());
1171 }
1172
1173 pub fn keywords(&self) -> Option<&str> {
1175 self.core_properties.as_ref()?.keywords.as_deref()
1176 }
1177
1178 pub fn set_keywords(&mut self, keywords: &str) {
1180 self.ensure_core_properties().keywords = Some(keywords.to_string());
1181 }
1182
1183 fn ensure_core_properties(&mut self) -> &mut CoreProperties {
1184 self.core_properties
1185 .get_or_insert_with(CoreProperties::default)
1186 }
1187
1188 pub fn append(&mut self, other: &Document) {
1195 self.merge_styles(other);
1196
1197 let start_idx = self.document.body.content.len();
1198 for content in &other.document.body.content {
1199 self.document.body.content.push(content.clone());
1200 }
1201
1202 self.remap_merged_numbering(other, start_idx);
1203 }
1204
1205 pub fn append_with_break(&mut self, other: &Document, break_type: crate::SectionBreak) {
1207 let mut p = CT_P::new();
1209 let sect_pr = match break_type {
1210 crate::SectionBreak::NextPage => CT_SectPr::default_letter(),
1211 crate::SectionBreak::Continuous => {
1212 let mut sp = CT_SectPr::default_letter();
1213 sp.section_type = Some(ST_SectionType::Continuous);
1214 sp
1215 }
1216 crate::SectionBreak::EvenPage => {
1217 let mut sp = CT_SectPr::default_letter();
1218 sp.section_type = Some(ST_SectionType::EvenPage);
1219 sp
1220 }
1221 crate::SectionBreak::OddPage => {
1222 let mut sp = CT_SectPr::default_letter();
1223 sp.section_type = Some(ST_SectionType::OddPage);
1224 sp
1225 }
1226 };
1227 p.properties = Some(CT_PPr {
1228 sect_pr: Some(sect_pr),
1229 ..Default::default()
1230 });
1231 self.document.body.content.push(BodyContent::Paragraph(p));
1232
1233 self.append(other);
1234 }
1235
1236 pub fn insert_document(&mut self, index: usize, other: &Document) {
1240 self.merge_styles(other);
1241
1242 let insert_at = index.min(self.document.body.content.len());
1243 for (i, content) in other.document.body.content.iter().enumerate() {
1244 self.document
1245 .body
1246 .content
1247 .insert(insert_at + i, content.clone());
1248 }
1249
1250 self.remap_merged_numbering(other, insert_at);
1251 }
1252
1253 fn merge_styles(&mut self, other: &Document) {
1255 for style in &other.styles.styles {
1256 if self.styles.get_by_id(&style.style_id).is_none() {
1257 self.styles.styles.push(style.clone());
1258 }
1259 }
1260 }
1261
1262 fn remap_merged_numbering(&mut self, other: &Document, start_idx: usize) {
1265 let Some(other_numbering) = &other.numbering else {
1266 return;
1267 };
1268
1269 let numbering = self
1270 .numbering
1271 .get_or_insert_with(|| rdocx_oxml::numbering::CT_Numbering {
1272 abstract_nums: Vec::new(),
1273 nums: Vec::new(),
1274 });
1275
1276 let max_abstract_id = numbering
1278 .abstract_nums
1279 .iter()
1280 .map(|a| a.abstract_num_id)
1281 .max()
1282 .unwrap_or(0);
1283 let max_num_id = numbering.nums.iter().map(|n| n.num_id).max().unwrap_or(0);
1284
1285 let abstract_offset = max_abstract_id + 1;
1286 let num_offset = max_num_id + 1;
1287
1288 for abs_num in &other_numbering.abstract_nums {
1290 let mut new_abs = abs_num.clone();
1291 new_abs.abstract_num_id += abstract_offset;
1292 numbering.abstract_nums.push(new_abs);
1293 }
1294
1295 for num in &other_numbering.nums {
1297 let mut new_num = num.clone();
1298 new_num.num_id += num_offset;
1299 new_num.abstract_num_id += abstract_offset;
1300 numbering.nums.push(new_num);
1301 }
1302
1303 let incoming_count = other.document.body.content.len();
1305 for content in self.document.body.content[start_idx..start_idx + incoming_count].iter_mut()
1306 {
1307 Self::remap_num_ids(content, num_offset);
1308 }
1309 }
1310
1311 fn remap_num_ids(content: &mut BodyContent, offset: u32) {
1313 match content {
1314 BodyContent::Paragraph(p) => {
1315 Self::remap_paragraph_num_id(p, offset);
1316 }
1317 BodyContent::Table(tbl) => {
1318 Self::remap_table_num_ids(tbl, offset);
1319 }
1320 BodyContent::RawXml(_) => {}
1321 }
1322 }
1323
1324 fn remap_paragraph_num_id(p: &mut CT_P, offset: u32) {
1325 if let Some(ppr) = &mut p.properties
1326 && let Some(num_id) = &mut ppr.num_id
1327 && *num_id > 0
1328 {
1329 *num_id += offset;
1330 }
1331 }
1332
1333 fn remap_table_num_ids(tbl: &mut CT_Tbl, offset: u32) {
1334 for row in &mut tbl.rows {
1335 for cell in &mut row.cells {
1336 for cc in &mut cell.content {
1337 match cc {
1338 rdocx_oxml::table::CellContent::Paragraph(p) => {
1339 Self::remap_paragraph_num_id(p, offset);
1340 }
1341 rdocx_oxml::table::CellContent::Table(nested) => {
1342 Self::remap_table_num_ids(nested, offset);
1343 }
1344 }
1345 }
1346 }
1347 }
1348 }
1349
1350 pub fn insert_toc(&mut self, index: usize, max_level: u32) {
1362 use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
1363 use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
1364 use rdocx_oxml::text::HyperlinkSpan;
1365 use rdocx_oxml::units::Twips;
1366
1367 let max_level = max_level.clamp(1, 9);
1368
1369 struct HeadingInfo {
1371 content_index: usize,
1372 level: u32,
1373 text: String,
1374 bookmark_name: String,
1375 }
1376
1377 let mut toc_counter = self.highest_toc_bookmark();
1382 let mut bookmark_id = 100 + toc_counter;
1383
1384 let mut headings = Vec::new();
1385
1386 for (idx, content) in self.document.body.content.iter().enumerate() {
1387 if let BodyContent::Paragraph(p) = content
1388 && let Some(level) = Self::detect_heading_level_for_toc(p)
1389 && level <= max_level
1390 {
1391 let text = p.text();
1392 if !text.trim().is_empty() {
1393 toc_counter += 1;
1394 headings.push(HeadingInfo {
1395 content_index: idx,
1396 level,
1397 text,
1398 bookmark_name: format!("_Toc{toc_counter}"),
1399 });
1400 }
1401 }
1402 }
1403
1404 for heading in &headings {
1407 if let Some(BodyContent::Paragraph(p)) =
1408 self.document.body.content.get_mut(heading.content_index)
1409 {
1410 let bm_start = format!(
1411 "<w:bookmarkStart w:id=\"{bookmark_id}\" w:name=\"{}\"/>",
1412 heading.bookmark_name
1413 );
1414 let bm_end = format!("<w:bookmarkEnd w:id=\"{bookmark_id}\"/>");
1415 p.extra_xml.push((0, bm_start.into_bytes()));
1417 p.extra_xml.push((p.runs.len(), bm_end.into_bytes()));
1419 bookmark_id += 1;
1420 }
1421 }
1422
1423 let right_tab = CT_Tabs {
1427 tabs: vec![CT_TabStop {
1428 val: ST_TabJc::Right,
1429 pos: Twips(self.text_width_twips()),
1430 leader: Some(ST_TabLeader::Dot),
1431 }],
1432 };
1433
1434 let mut toc_paragraphs: Vec<CT_P> = Vec::new();
1435
1436 let mut title_p = CT_P::new();
1438 let mut title_r = CT_R::new("Table of Contents");
1439 title_r.properties = Some(CT_RPr {
1440 bold: Some(true),
1441 ..Default::default()
1442 });
1443 title_p.runs.push(title_r);
1444 title_p.properties = Some(CT_PPr {
1445 space_after: Some(Twips(120)),
1446 ..Default::default()
1447 });
1448 toc_paragraphs.push(title_p);
1449
1450 for heading in &headings {
1451 let mut p = CT_P::new();
1452
1453 let indent = Twips(360 * (heading.level as i32 - 1));
1455
1456 p.properties = Some(CT_PPr {
1457 tabs: Some(right_tab.clone()),
1458 ind_left: if indent.0 > 0 { Some(indent) } else { None },
1459 ..Default::default()
1460 });
1461
1462 let text_run = CT_R::new(&heading.text);
1464 p.runs.push(text_run);
1465
1466 p.runs.push(CT_R {
1468 properties: None,
1469 content: vec![rdocx_oxml::text::RunContent::Tab],
1470 extra_xml: Vec::new(),
1471 });
1472
1473 p.hyperlinks.push(HyperlinkSpan {
1475 rel_id: None,
1476 anchor: Some(heading.bookmark_name.clone()),
1477 run_start: 0,
1478 run_end: 1, });
1480
1481 toc_paragraphs.push(p);
1482 }
1483
1484 let insert_at = index.min(self.document.body.content.len());
1486 for (i, p) in toc_paragraphs.into_iter().enumerate() {
1487 self.document
1488 .body
1489 .content
1490 .insert(insert_at + i, BodyContent::Paragraph(p));
1491 }
1492 }
1493
1494 fn highest_toc_bookmark(&self) -> u32 {
1498 let mut highest = 0;
1499 for content in &self.document.body.content {
1500 let BodyContent::Paragraph(p) = content else {
1501 continue;
1502 };
1503 for (_, raw) in &p.extra_xml {
1504 let Ok(text) = std::str::from_utf8(raw) else {
1505 continue;
1506 };
1507 for (_, after) in text.match_indices("_Toc") {
1508 let digits: String = after
1509 .trim_start_matches("_Toc")
1510 .chars()
1511 .take_while(char::is_ascii_digit)
1512 .collect();
1513 if let Ok(n) = digits.parse::<u32>() {
1514 highest = highest.max(n);
1515 }
1516 }
1517 }
1518 }
1519 highest
1520 }
1521
1522 fn text_width_twips(&self) -> i32 {
1527 const DEFAULT_TEXT_WIDTH: i32 = 9360;
1528
1529 let Some(sect) = self.document.body.sect_pr.as_ref() else {
1530 return DEFAULT_TEXT_WIDTH;
1531 };
1532 let page_width = sect.page_width.map(|w| w.0).unwrap_or(12240);
1533 let left = sect.margin_left.map(|m| m.0).unwrap_or(1440);
1534 let right = sect.margin_right.map(|m| m.0).unwrap_or(1440);
1535
1536 let width = page_width - left - right;
1537 if width > 0 { width } else { DEFAULT_TEXT_WIDTH }
1538 }
1539
1540 fn detect_heading_level_for_toc(para: &CT_P) -> Option<u32> {
1542 let ppr = para.properties.as_ref()?;
1543 let style_id = ppr.style_id.as_deref()?;
1544 let rest = style_id.strip_prefix("Heading")?;
1545 rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n))
1546 }
1547
1548 pub fn replace_text(&mut self, placeholder: &str, replacement: &str) -> usize {
1559 self.replace_batch(&[(placeholder, replacement)])
1560 }
1561
1562 pub fn replace_all(&mut self, replacements: &std::collections::HashMap<&str, &str>) -> usize {
1568 let pairs: Vec<(&str, &str)> = replacements.iter().map(|(k, v)| (*k, *v)).collect();
1569 self.replace_batch(&pairs)
1570 }
1571
1572 fn replace_batch(&mut self, pairs: &[(&str, &str)]) -> usize {
1574 if pairs.is_empty() {
1575 return 0;
1576 }
1577
1578 let mut count = 0;
1579
1580 for (placeholder, replacement) in pairs {
1582 count += self.replace_in_body(placeholder, replacement);
1583 }
1584 count += self.replace_in_headers_footers(pairs);
1585
1586 if self.flush_to_package().is_ok() {
1589 count += self.replace_in_xml_parts(pairs);
1590 }
1591
1592 count
1593 }
1594
1595 fn replace_in_body(&mut self, placeholder: &str, replacement: &str) -> usize {
1597 use rdocx_oxml::placeholder;
1598
1599 let mut count = 0;
1600 for content in &mut self.document.body.content {
1601 match content {
1602 BodyContent::Paragraph(p) => {
1603 count += placeholder::replace_in_paragraph(p, placeholder, replacement);
1604 }
1605 BodyContent::Table(t) => {
1606 count += placeholder::replace_in_table(t, placeholder, replacement);
1607 }
1608 BodyContent::RawXml(_) => {}
1609 }
1610 }
1611 count
1612 }
1613
1614 fn replace_in_headers_footers(&mut self, pairs: &[(&str, &str)]) -> usize {
1616 use rdocx_oxml::placeholder;
1617
1618 let mut count = 0;
1619 for (rel_id, is_header) in self.header_footer_rel_ids() {
1620 let Some(mut hf) = self.load_header_footer(&rel_id) else {
1621 continue;
1622 };
1623 let mut part_count = 0;
1624 for (placeholder, replacement) in pairs {
1625 part_count +=
1626 placeholder::replace_in_header_footer(&mut hf, placeholder, replacement);
1627 }
1628 if part_count > 0 {
1629 self.save_header_footer(&rel_id, &hf, is_header);
1630 count += part_count;
1631 }
1632 }
1633 count
1634 }
1635
1636 fn header_footer_rel_ids(&self) -> Vec<(String, bool)> {
1639 let Some(sect_pr) = self.document.body.sect_pr.as_ref() else {
1640 return Vec::new();
1641 };
1642 sect_pr
1643 .header_refs
1644 .iter()
1645 .map(|r| (r.rel_id.clone(), true))
1646 .chain(
1647 sect_pr
1648 .footer_refs
1649 .iter()
1650 .map(|r| (r.rel_id.clone(), false)),
1651 )
1652 .collect()
1653 }
1654
1655 pub fn replace_regex(&mut self, pattern: &str, replacement: &str) -> Result<usize> {
1663 let re =
1664 regex::Regex::new(pattern).map_err(|e| Error::Other(format!("invalid regex: {e}")))?;
1665 Ok(self.replace_regex_compiled(&re, replacement))
1666 }
1667
1668 pub fn replace_all_regex(&mut self, patterns: &[(String, String)]) -> Result<usize> {
1670 let mut count = 0;
1671 for (pattern, replacement) in patterns {
1672 count += self.replace_regex(pattern, replacement)?;
1673 }
1674 Ok(count)
1675 }
1676
1677 fn replace_regex_compiled(&mut self, re: ®ex::Regex, replacement: &str) -> usize {
1679 use rdocx_oxml::placeholder;
1680
1681 let mut count = 0;
1682
1683 for content in &mut self.document.body.content {
1685 match content {
1686 BodyContent::Paragraph(p) => {
1687 count += placeholder::replace_regex_in_paragraph(p, re, replacement);
1688 }
1689 BodyContent::Table(t) => {
1690 count += placeholder::replace_regex_in_table(t, re, replacement);
1691 }
1692 BodyContent::RawXml(_) => {}
1693 }
1694 }
1695
1696 for (rel_id, is_header) in self.header_footer_rel_ids() {
1698 let Some(mut hf) = self.load_header_footer(&rel_id) else {
1699 continue;
1700 };
1701 let n = placeholder::replace_regex_in_header_footer(&mut hf, re, replacement);
1702 if n > 0 {
1703 self.save_header_footer(&rel_id, &hf, is_header);
1704 count += n;
1705 }
1706 }
1707
1708 if self.flush_to_package().is_ok() {
1712 count += self.replace_regex_in_xml_parts(re, replacement);
1713 }
1714
1715 count
1716 }
1717
1718 fn replace_regex_in_xml_parts(&mut self, re: ®ex::Regex, replacement: &str) -> usize {
1720 let mut count = 0;
1721
1722 for part_name in self.text_bearing_part_names() {
1723 let Some(xml) = self.package.get_part(&part_name).map(<[u8]>::to_vec) else {
1724 continue;
1725 };
1726 if let Ok((new_xml, n)) =
1727 rdocx_oxml::placeholder::replace_regex_in_xml_part(&xml, re, replacement)
1728 && n > 0
1729 {
1730 self.package.set_part(&part_name, new_xml);
1731 count += n;
1732 }
1733 }
1734
1735 if count > 0
1738 && let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
1739 && let Ok(doc) = CT_Document::from_xml(doc_xml)
1740 {
1741 self.document = doc;
1742 }
1743
1744 count
1745 }
1746
1747 fn text_bearing_part_names(&self) -> Vec<String> {
1750 let mut names = vec![self.doc_part_name.clone()];
1751 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
1752 for (rel_id, _) in self.header_footer_rel_ids() {
1753 if let Some(rel) = rels.get_by_id(&rel_id) {
1754 names.push(OpcPackage::resolve_rel_target(
1755 &self.doc_part_name,
1756 &rel.target,
1757 ));
1758 }
1759 }
1760 }
1761 names
1762 }
1763
1764 fn load_header_footer(&self, rel_id: &str) -> Option<CT_HdrFtr> {
1766 let rels = self.package.get_part_rels(&self.doc_part_name)?;
1767 let rel = rels.get_by_id(rel_id)?;
1768 let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1769 let xml = self.package.get_part(&part_name)?;
1770 CT_HdrFtr::from_xml(xml).ok()
1771 }
1772
1773 fn replace_in_xml_parts(&mut self, pairs: &[(&str, &str)]) -> usize {
1777 use rdocx_oxml::placeholder::{replace_many_in_chart_xml, replace_many_in_xml_part};
1778
1779 let mut count = 0;
1780
1781 let mut xml_parts: Vec<String> = vec![self.doc_part_name.clone()];
1783 if let Some(sect_pr) = self.document.body.sect_pr.as_ref()
1784 && let Some(rels) = self.package.get_part_rels(&self.doc_part_name)
1785 {
1786 for href in §_pr.header_refs {
1787 if let Some(rel) = rels.get_by_id(&href.rel_id) {
1788 xml_parts.push(OpcPackage::resolve_rel_target(
1789 &self.doc_part_name,
1790 &rel.target,
1791 ));
1792 }
1793 }
1794 for fref in §_pr.footer_refs {
1795 if let Some(rel) = rels.get_by_id(&fref.rel_id) {
1796 xml_parts.push(OpcPackage::resolve_rel_target(
1797 &self.doc_part_name,
1798 &rel.target,
1799 ));
1800 }
1801 }
1802 }
1803
1804 for part_name in xml_parts {
1805 if let Some(xml) = self.package.get_part(&part_name) {
1806 let xml = xml.to_vec();
1807 if let Ok((new_xml, n)) = replace_many_in_xml_part(&xml, pairs)
1808 && n > 0
1809 {
1810 self.package.set_part(&part_name, new_xml);
1811 count += n;
1812 }
1813 }
1814 }
1815
1816 let chart_parts: Vec<String> = self
1818 .package
1819 .get_part_rels(&self.doc_part_name)
1820 .map(|rels| {
1821 rels.get_all_by_type(rel_types::CHART)
1822 .iter()
1823 .map(|rel| OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target))
1824 .collect()
1825 })
1826 .unwrap_or_default();
1827
1828 for part_name in chart_parts {
1829 if let Some(xml) = self.package.get_part(&part_name) {
1830 let xml = xml.to_vec();
1831 if let Ok((new_xml, n)) = replace_many_in_chart_xml(&xml, pairs)
1832 && n > 0
1833 {
1834 self.package.set_part(&part_name, new_xml);
1835 count += n;
1836 }
1837 }
1838 }
1839
1840 if count > 0
1842 && let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
1843 && let Ok(doc) = CT_Document::from_xml(doc_xml)
1844 {
1845 self.document = doc;
1846 }
1847
1848 count
1849 }
1850
1851 pub fn to_pdf(&self) -> Result<Vec<u8>> {
1863 self.to_pdf_with_fonts(&[])
1864 }
1865
1866 pub fn to_pdf_with_fonts(&self, font_files: &[(&str, &[u8])]) -> Result<Vec<u8>> {
1879 let mut input = self.build_layout_input();
1880 for (family, data) in font_files {
1881 input.fonts.push(rdocx_layout::FontFile {
1882 family: family.to_string(),
1883 data: data.to_vec(),
1884 });
1885 }
1886 let layout = rdocx_layout::layout_document(&input)?;
1887 Ok(rdocx_pdf::render_to_pdf(&layout))
1888 }
1889
1890 pub fn save_pdf<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1892 let pdf_bytes = self.to_pdf()?;
1893 std::fs::write(path, pdf_bytes)?;
1894 Ok(())
1895 }
1896
1897 pub fn to_html(&self) -> String {
1899 let input = self.build_html_input();
1900 rdocx_html::to_html_document(&input, &rdocx_html::HtmlOptions::default())
1901 }
1902
1903 pub fn to_html_fragment(&self) -> String {
1905 let input = self.build_html_input();
1906 rdocx_html::to_html_fragment(&input, &rdocx_html::HtmlOptions::default())
1907 }
1908
1909 pub fn to_markdown(&self) -> String {
1911 let input = self.build_html_input();
1912 rdocx_html::to_markdown(&input)
1913 }
1914
1915 fn build_html_input(&self) -> rdocx_html::HtmlInput {
1917 use rdocx_opc::relationship::rel_types;
1918 use std::collections::HashMap;
1919
1920 let mut images: HashMap<String, rdocx_html::ImageData> = HashMap::new();
1921 let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
1922
1923 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
1924 for rel in &rels.items {
1925 match rel.rel_type.as_str() {
1926 t if t == rel_types::IMAGE => {
1927 let part_name =
1928 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1929 if let Some(data) = self.package.get_part(&part_name) {
1930 let content_type = guess_image_content_type(&part_name);
1931 images.insert(
1932 rel.id.clone(),
1933 rdocx_html::ImageData {
1934 data: data.to_vec(),
1935 content_type,
1936 },
1937 );
1938 }
1939 }
1940 t if t == rel_types::HYPERLINK
1941 && rel.target_mode.as_ref().is_some_and(|m| m == "External") =>
1942 {
1943 hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
1944 }
1945 _ => {}
1946 }
1947 }
1948 }
1949
1950 rdocx_html::HtmlInput {
1951 document: self.document.clone(),
1952 styles: self.styles.clone(),
1953 numbering: self.numbering.clone(),
1954 images,
1955 hyperlink_urls,
1956 }
1957 }
1958
1959 pub fn render_page_to_png(&self, page_index: usize, dpi: f64) -> Result<Option<Vec<u8>>> {
1965 let input = self.build_layout_input();
1966 let layout = rdocx_layout::layout_document(&input)?;
1967 Ok(rdocx_pdf::render_page_to_png(&layout, page_index, dpi))
1968 }
1969
1970 pub fn render_all_pages(&self, dpi: f64) -> Result<Vec<Vec<u8>>> {
1972 let input = self.build_layout_input();
1973 let layout = rdocx_layout::layout_document(&input)?;
1974 Ok(rdocx_pdf::render_all_pages(&layout, dpi))
1975 }
1976
1977 fn build_layout_input(&self) -> rdocx_layout::LayoutInput {
1979 use rdocx_layout::{ImageData, LayoutInput};
1980 use rdocx_opc::relationship::rel_types;
1981 use std::collections::HashMap;
1982
1983 let mut headers: HashMap<String, CT_HdrFtr> = HashMap::new();
1984 let mut footers: HashMap<String, CT_HdrFtr> = HashMap::new();
1985 let mut images: HashMap<String, ImageData> = HashMap::new();
1986 let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
1987 let mut footnotes = None;
1988 let mut endnotes = None;
1989
1990 let fonts = self.extract_embedded_fonts();
1992
1993 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
1994 for rel in &rels.items {
1995 match rel.rel_type.as_str() {
1996 t if t == rel_types::HEADER => {
1997 let part_name =
1998 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1999 if let Some(xml) = self.package.get_part(&part_name)
2000 && let Ok(hf) = CT_HdrFtr::from_xml(xml)
2001 {
2002 headers.insert(rel.id.clone(), hf);
2003 }
2004 }
2005 t if t == rel_types::FOOTER => {
2006 let part_name =
2007 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2008 if let Some(xml) = self.package.get_part(&part_name)
2009 && let Ok(hf) = CT_HdrFtr::from_xml(xml)
2010 {
2011 footers.insert(rel.id.clone(), hf);
2012 }
2013 }
2014 t if t == rel_types::IMAGE => {
2015 let part_name =
2016 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2017 if let Some(data) = self.package.get_part(&part_name) {
2018 let content_type = guess_image_content_type(&part_name);
2019 images.insert(
2020 rel.id.clone(),
2021 ImageData {
2022 data: data.to_vec(),
2023 content_type,
2024 },
2025 );
2026 }
2027 }
2028 t if t == rel_types::HYPERLINK => {
2029 if rel.target_mode.as_ref().is_some_and(|m| m == "External") {
2030 hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
2031 }
2032 }
2033 t if t == rel_types::FOOTNOTES => {
2034 let part_name =
2035 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2036 if let Some(xml) = self.package.get_part(&part_name) {
2037 footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
2038 }
2039 }
2040 t if t == rel_types::ENDNOTES => {
2041 let part_name =
2042 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2043 if let Some(xml) = self.package.get_part(&part_name) {
2044 endnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
2045 }
2046 }
2047 _ => {}
2048 }
2049 }
2050 }
2051
2052 let theme = self
2054 .package
2055 .get_part("/word/theme/theme1.xml")
2056 .and_then(|data| rdocx_oxml::theme::Theme::from_xml(data).ok());
2057
2058 LayoutInput {
2059 document: self.document.clone(),
2060 styles: self.styles.clone(),
2061 numbering: self.numbering.clone(),
2062 headers,
2063 footers,
2064 images,
2065 core_properties: self.core_properties.clone(),
2066 hyperlink_urls,
2067 footnotes,
2068 endnotes,
2069 theme,
2070 fonts,
2071 }
2072 }
2073
2074 fn extract_embedded_fonts(&self) -> Vec<rdocx_layout::FontFile> {
2080 let mut fonts = Vec::new();
2081
2082 for (part_name, data) in &self.package.parts {
2084 let lower = part_name.to_lowercase();
2085 if !lower.contains("/word/fonts/") && !lower.contains("/word/font") {
2086 continue;
2087 }
2088
2089 let file_name = part_name.rsplit('/').next().unwrap_or(part_name);
2091 let family = file_name.split('.').next().unwrap_or(file_name).to_string();
2092
2093 if lower.ends_with(".odttf") {
2094 if let Some(deobfuscated) = deobfuscate_odttf(data, file_name) {
2096 fonts.push(rdocx_layout::FontFile {
2097 family,
2098 data: deobfuscated,
2099 });
2100 }
2101 } else if lower.ends_with(".ttf") || lower.ends_with(".otf") || lower.ends_with(".ttc")
2102 {
2103 fonts.push(rdocx_layout::FontFile {
2104 family,
2105 data: data.clone(),
2106 });
2107 }
2108 }
2109
2110 fonts
2111 }
2112
2113 pub fn load_fonts_from_dir<P: AsRef<Path>>(dir: P) -> Vec<rdocx_layout::FontFile> {
2118 let mut fonts = Vec::new();
2119 let dir = dir.as_ref();
2120 if let Ok(entries) = std::fs::read_dir(dir) {
2121 for entry in entries.flatten() {
2122 let path = entry.path();
2123 let ext = path
2124 .extension()
2125 .and_then(|e| e.to_str())
2126 .unwrap_or("")
2127 .to_lowercase();
2128 if (ext == "ttf" || ext == "otf" || ext == "ttc")
2129 && let Ok(data) = std::fs::read(&path)
2130 {
2131 let family = path
2132 .file_stem()
2133 .and_then(|s| s.to_str())
2134 .unwrap_or("Unknown")
2135 .to_string();
2136 fonts.push(rdocx_layout::FontFile { family, data });
2137 }
2138 }
2139 }
2140 fonts
2141 }
2142
2143 fn save_header_footer(&mut self, rel_id: &str, hf: &CT_HdrFtr, is_header: bool) {
2145 let part_name = {
2146 let rels = self.package.get_part_rels(&self.doc_part_name);
2147 rels.and_then(|r| r.get_by_id(rel_id))
2148 .map(|rel| OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target))
2149 };
2150 if let Some(part_name) = part_name {
2151 let xml = if is_header {
2152 hf.to_xml_header()
2153 } else {
2154 hf.to_xml_footer()
2155 };
2156 if let Ok(xml) = xml {
2157 self.package.set_part(&part_name, xml);
2158 }
2159 }
2160 }
2161
2162 pub fn headings(&self) -> Vec<(u32, String)> {
2168 let mut result = Vec::new();
2169 for content in &self.document.body.content {
2170 if let BodyContent::Paragraph(p) = content
2171 && let Some(level) = Self::detect_heading_level_for_toc(p)
2172 {
2173 result.push((level, p.text()));
2174 }
2175 }
2176 result
2177 }
2178
2179 pub fn document_outline(&self) -> Vec<OutlineNode> {
2184 let headings = self.headings();
2185 build_outline_tree(&headings)
2186 }
2187
2188 pub fn images(&self) -> Vec<ImageInfo> {
2192 let mut result = Vec::new();
2193
2194 for content in &self.document.body.content {
2195 Self::collect_images_from_content(content, &mut result);
2196 }
2197 result
2198 }
2199
2200 fn collect_images_from_content(content: &BodyContent, result: &mut Vec<ImageInfo>) {
2201 match content {
2202 BodyContent::Paragraph(p) => Self::collect_images_from_paragraph(p, result),
2203 BodyContent::Table(tbl) => Self::collect_images_from_table(tbl, result),
2204 BodyContent::RawXml(_) => {}
2205 }
2206 }
2207
2208 fn collect_images_from_paragraph(p: &CT_P, result: &mut Vec<ImageInfo>) {
2209 for run in &p.runs {
2210 for rc in &run.content {
2211 let RunContent::Drawing(drawing) = rc else {
2212 continue;
2213 };
2214 if let Some(inline) = &drawing.inline {
2215 result.push(ImageInfo {
2216 embed_id: inline.embed_id.clone(),
2217 name: inline.name.clone(),
2218 description: inline.description.clone(),
2219 width_emu: inline.extent_cx.0,
2220 height_emu: inline.extent_cy.0,
2221 is_anchor: false,
2222 });
2223 }
2224 if let Some(anchor) = &drawing.anchor {
2225 result.push(ImageInfo {
2226 embed_id: anchor.embed_id.clone(),
2227 name: anchor.name.clone(),
2228 description: anchor.description.clone(),
2229 width_emu: anchor.extent_cx.0,
2230 height_emu: anchor.extent_cy.0,
2231 is_anchor: true,
2232 });
2233 }
2234 }
2235 }
2236 }
2237
2238 fn collect_images_from_table(tbl: &CT_Tbl, result: &mut Vec<ImageInfo>) {
2239 use rdocx_oxml::table::CellContent;
2240
2241 for row in &tbl.rows {
2242 for cell in &row.cells {
2243 for cc in &cell.content {
2244 match cc {
2245 CellContent::Paragraph(p) => Self::collect_images_from_paragraph(p, result),
2246 CellContent::Table(nested) => {
2247 Self::collect_images_from_table(nested, result)
2248 }
2249 }
2250 }
2251 }
2252 }
2253 }
2254
2255 pub fn links(&self) -> Vec<LinkInfo> {
2259 use rdocx_opc::relationship::rel_types;
2260
2261 let mut url_map = std::collections::HashMap::new();
2263 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
2264 for rel in &rels.items {
2265 if rel.rel_type == rel_types::HYPERLINK
2266 && rel.target_mode.as_ref().is_some_and(|m| m == "External")
2267 {
2268 url_map.insert(rel.id.clone(), rel.target.clone());
2269 }
2270 }
2271 }
2272
2273 let mut result = Vec::new();
2274 for content in &self.document.body.content {
2275 if let BodyContent::Paragraph(p) = content {
2276 for hl in &p.hyperlinks {
2277 let start = hl.run_start.min(p.runs.len());
2280 let end = hl.run_end.clamp(start, p.runs.len());
2281 let text: String = p.runs[start..end].iter().map(|r| r.text()).collect();
2282
2283 let url = hl.rel_id.as_ref().and_then(|id| url_map.get(id)).cloned();
2284
2285 result.push(LinkInfo {
2286 text,
2287 url,
2288 anchor: hl.anchor.clone(),
2289 rel_id: hl.rel_id.clone(),
2290 });
2291 }
2292 }
2293 }
2294 result
2295 }
2296
2297 pub fn word_count(&self) -> usize {
2302 let mut count = 0;
2303 for content in &self.document.body.content {
2304 count += Self::word_count_in_content(content);
2305 }
2306 count
2307 }
2308
2309 fn word_count_in_content(content: &BodyContent) -> usize {
2310 match content {
2311 BodyContent::Paragraph(p) => p.text().split_whitespace().count(),
2312 BodyContent::Table(tbl) => Self::word_count_in_table(tbl),
2313 BodyContent::RawXml(_) => 0,
2314 }
2315 }
2316
2317 fn word_count_in_table(tbl: &CT_Tbl) -> usize {
2318 use rdocx_oxml::table::CellContent;
2319
2320 let mut count = 0;
2321 for row in &tbl.rows {
2322 for cell in &row.cells {
2323 for cc in &cell.content {
2324 match cc {
2325 CellContent::Paragraph(p) => {
2326 count += p.text().split_whitespace().count();
2327 }
2328 CellContent::Table(nested) => {
2329 count += Self::word_count_in_table(nested);
2330 }
2331 }
2332 }
2333 }
2334 }
2335 count
2336 }
2337
2338 pub fn audit_accessibility(&self) -> Vec<AccessibilityIssue> {
2343 let mut issues = Vec::new();
2344
2345 if self.title().is_none() {
2347 issues.push(AccessibilityIssue {
2348 severity: IssueSeverity::Warning,
2349 message: "Document has no title".to_string(),
2350 });
2351 }
2352
2353 if self.author().is_none() {
2355 issues.push(AccessibilityIssue {
2356 severity: IssueSeverity::Info,
2357 message: "Document has no author".to_string(),
2358 });
2359 }
2360
2361 let images = self.images();
2363 for img in &images {
2364 let has_alt = img
2365 .description
2366 .as_ref()
2367 .is_some_and(|d| !d.is_empty() && d != "Background");
2368 if !has_alt {
2369 let name = img
2370 .name
2371 .as_deref()
2372 .or(Some(&img.embed_id))
2373 .unwrap_or("unknown");
2374 issues.push(AccessibilityIssue {
2375 severity: IssueSeverity::Error,
2376 message: format!("Image \"{name}\" has no alt text"),
2377 });
2378 }
2379 }
2380
2381 let headings = self.headings();
2383 let mut prev_level: Option<u32> = None;
2384 for (level, text) in &headings {
2385 if let Some(prev) = prev_level
2386 && *level > prev + 1
2387 {
2388 issues.push(AccessibilityIssue {
2389 severity: IssueSeverity::Warning,
2390 message: format!(
2391 "Heading level gap: h{prev} -> h{level} (\"{}\")",
2392 truncate_str(text, 40)
2393 ),
2394 });
2395 }
2396 prev_level = Some(*level);
2397 }
2398
2399 let mut consecutive_empty = 0u32;
2401 for content in &self.document.body.content {
2402 if let BodyContent::Paragraph(p) = content {
2403 if p.text().trim().is_empty() {
2404 consecutive_empty += 1;
2405 if consecutive_empty >= 3 {
2406 issues.push(AccessibilityIssue {
2407 severity: IssueSeverity::Info,
2408 message: format!(
2409 "{consecutive_empty} consecutive empty paragraphs (consider using spacing instead)"
2410 ),
2411 });
2412 }
2413 } else {
2414 consecutive_empty = 0;
2415 }
2416 } else {
2417 consecutive_empty = 0;
2418 }
2419 }
2420
2421 issues
2422 }
2423}
2424
2425impl Default for Document {
2426 fn default() -> Self {
2427 Self::new()
2428 }
2429}
2430
2431fn relative_target(source_part: &str, target_part: &str) -> String {
2436 let dir = match source_part.rfind('/') {
2437 Some(pos) => &source_part[..=pos],
2438 None => "/",
2439 };
2440 match target_part.strip_prefix(dir) {
2441 Some(rest) if !rest.contains('/') => rest.to_string(),
2442 _ => target_part.to_string(),
2443 }
2444}
2445
2446fn image_extension(filename: &str) -> String {
2448 match filename.rsplit_once('.') {
2449 Some((_, ext)) if !ext.is_empty() => ext.to_lowercase(),
2450 _ => "png".to_string(),
2451 }
2452}
2453
2454fn image_content_type(ext: &str) -> &'static str {
2460 match ext {
2461 "jpg" | "jpeg" => "image/jpeg",
2462 "gif" => "image/gif",
2463 "bmp" => "image/bmp",
2464 "tiff" | "tif" => "image/tiff",
2465 "svg" => "image/svg+xml",
2466 "webp" => "image/webp",
2467 _ => "image/png",
2469 }
2470}
2471
2472fn guess_image_content_type(part_name: &str) -> String {
2474 image_content_type(&image_extension(part_name)).to_string()
2475}
2476
2477#[derive(Debug, Clone, PartialEq)]
2479pub struct OutlineNode {
2480 pub level: u32,
2482 pub text: String,
2484 pub children: Vec<OutlineNode>,
2486}
2487
2488#[derive(Debug, Clone, PartialEq)]
2490pub struct ImageInfo {
2491 pub embed_id: String,
2493 pub name: Option<String>,
2495 pub description: Option<String>,
2497 pub width_emu: i64,
2499 pub height_emu: i64,
2501 pub is_anchor: bool,
2503}
2504
2505#[derive(Debug, Clone, PartialEq)]
2507pub struct LinkInfo {
2508 pub text: String,
2510 pub url: Option<String>,
2512 pub anchor: Option<String>,
2514 pub rel_id: Option<String>,
2516}
2517
2518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2520pub enum IssueSeverity {
2521 Info,
2523 Warning,
2525 Error,
2527}
2528
2529#[derive(Debug, Clone, PartialEq)]
2531pub struct AccessibilityIssue {
2532 pub severity: IssueSeverity,
2534 pub message: String,
2536}
2537
2538fn build_outline_tree(headings: &[(u32, String)]) -> Vec<OutlineNode> {
2540 let mut root: Vec<OutlineNode> = Vec::new();
2541 let mut stack: Vec<(u32, usize)> = Vec::new(); for (level, text) in headings {
2544 let node = OutlineNode {
2545 level: *level,
2546 text: text.clone(),
2547 children: Vec::new(),
2548 };
2549
2550 while let Some(&(stack_level, _)) = stack.last() {
2552 if stack_level >= *level {
2553 stack.pop();
2554 } else {
2555 break;
2556 }
2557 }
2558
2559 if stack.is_empty() {
2560 root.push(node);
2561 let idx = root.len() - 1;
2562 stack.push((*level, idx));
2563 } else {
2564 let target = get_outline_parent_mut(&mut root, &stack);
2566 target.children.push(node);
2567 let idx = target.children.len() - 1;
2568 stack.push((*level, idx));
2569 }
2570 }
2571
2572 root
2573}
2574
2575fn get_outline_parent_mut<'a>(
2577 root: &'a mut [OutlineNode],
2578 stack: &[(u32, usize)],
2579) -> &'a mut OutlineNode {
2580 let mut current = &mut root[stack[0].1];
2581 for &(_, idx) in &stack[1..] {
2582 current = &mut current.children[idx];
2583 }
2584 current
2585}
2586
2587fn truncate_str(s: &str, max_len: usize) -> String {
2593 if s.chars().count() <= max_len {
2594 return s.to_string();
2595 }
2596 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2597 format!("{truncated}...")
2598}
2599
2600fn deobfuscate_odttf(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
2614 if data.len() < 32 {
2615 return None;
2616 }
2617
2618 let name = file_name
2621 .split('.')
2622 .next()
2623 .unwrap_or("")
2624 .trim_start_matches('{')
2625 .trim_end_matches('}');
2626
2627 let hex: String = name.chars().filter(|c| c.is_ascii_hexdigit()).collect();
2629 if hex.len() != 32 {
2630 return None;
2631 }
2632
2633 let mut guid = [0u8; 16];
2634 for (i, byte) in guid.iter_mut().enumerate() {
2635 *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
2636 }
2637
2638 let candidates = odttf_key_candidates(&guid);
2639 let decoded: Vec<Vec<u8>> = candidates
2640 .iter()
2641 .map(|key| {
2642 let mut result = data.to_vec();
2643 for (i, byte) in result.iter_mut().take(32).enumerate() {
2645 *byte ^= key[i % 16];
2646 }
2647 result
2648 })
2649 .collect();
2650
2651 decoded
2656 .iter()
2657 .find(|d| has_consistent_sfnt_header(d))
2658 .or_else(|| decoded.iter().find(|d| looks_like_sfnt(d)))
2659 .cloned()
2660}
2661
2662fn odttf_key_candidates(guid: &[u8; 16]) -> [[u8; 16]; 2] {
2664 let mut plain_reversed = *guid;
2666 plain_reversed.reverse();
2667
2668 let dotnet = [
2671 guid[3], guid[2], guid[1], guid[0], guid[5], guid[4], guid[7], guid[6], guid[8], guid[9],
2672 guid[10], guid[11], guid[12], guid[13], guid[14], guid[15],
2673 ];
2674 let mut dotnet_reversed = dotnet;
2675 dotnet_reversed.reverse();
2676
2677 [plain_reversed, dotnet_reversed]
2678}
2679
2680fn looks_like_sfnt(data: &[u8]) -> bool {
2686 let Some(signature) = data.first_chunk::<4>() else {
2687 return false;
2688 };
2689 match signature {
2690 b"\x00\x01\x00\x00" | b"OTTO" | b"true" => {}
2691 b"ttcf" => return true,
2693 _ => return false,
2694 }
2695
2696 if data.len() < 32 {
2697 return false;
2698 }
2699
2700 let num_tables = u16::from_be_bytes([data[4], data[5]]);
2701 if num_tables == 0 || num_tables > 512 {
2702 return false;
2703 }
2704
2705 let is_tag = |tag: &[u8]| tag.iter().all(|b| (0x20..=0x7E).contains(b));
2709 is_tag(&data[12..16]) && (num_tables < 2 || is_tag(&data[28..32]))
2710}
2711
2712fn has_consistent_sfnt_header(data: &[u8]) -> bool {
2720 if !looks_like_sfnt(data) || data.len() < 12 {
2721 return false;
2722 }
2723 if data.first_chunk::<4>() == Some(b"ttcf") {
2724 return false; }
2726
2727 let num_tables = u16::from_be_bytes([data[4], data[5]]);
2728 let search_range = u16::from_be_bytes([data[6], data[7]]);
2729 let entry_selector = u16::from_be_bytes([data[8], data[9]]);
2730 let range_shift = u16::from_be_bytes([data[10], data[11]]);
2731
2732 let expected_selector = num_tables.ilog2() as u16;
2733 let expected_search_range = (1u16 << expected_selector) * 16;
2734 let expected_range_shift = num_tables
2735 .wrapping_mul(16)
2736 .wrapping_sub(expected_search_range);
2737
2738 search_range == expected_search_range
2739 && entry_selector == expected_selector
2740 && range_shift == expected_range_shift
2741}
2742
2743#[cfg(test)]
2744mod tests {
2745 use super::*;
2746 use crate::paragraph::Alignment;
2747 use rdocx_oxml::units::{HalfPoint, Twips};
2748
2749 #[test]
2750 fn create_new_document() {
2751 let doc = Document::new();
2752 assert_eq!(doc.paragraph_count(), 0);
2753 assert!(doc.section_properties().is_some());
2754 }
2755
2756 #[test]
2757 fn add_paragraphs() {
2758 let mut doc = Document::new();
2759 doc.add_paragraph("First paragraph");
2760 doc.add_paragraph("Second paragraph");
2761 assert_eq!(doc.paragraph_count(), 2);
2762
2763 let paras = doc.paragraphs();
2764 assert_eq!(paras[0].text(), "First paragraph");
2765 assert_eq!(paras[1].text(), "Second paragraph");
2766 }
2767
2768 #[test]
2769 fn paragraph_formatting() {
2770 let mut doc = Document::new();
2771 doc.add_paragraph("Centered").alignment(Alignment::Center);
2772
2773 let paras = doc.paragraphs();
2774 assert_eq!(paras[0].alignment(), Some(Alignment::Center));
2775 }
2776
2777 #[test]
2778 fn run_formatting() {
2779 let mut doc = Document::new();
2780 let mut para = doc.add_paragraph("");
2781 para.add_run("Bold text").bold(true).size(14.0);
2782
2783 let paras = doc.paragraphs();
2784 let runs: Vec<_> = paras[0].runs().collect();
2785 assert!(runs[0].is_bold());
2786 assert_eq!(runs[0].size(), Some(14.0));
2787 }
2788
2789 #[test]
2790 fn round_trip_in_memory() {
2791 let mut doc = Document::new();
2792 doc.add_paragraph("Hello, World!");
2793 doc.add_paragraph("Second paragraph")
2794 .alignment(Alignment::Center);
2795
2796 let bytes = doc.to_bytes().unwrap();
2797 let doc2 = Document::from_bytes(&bytes).unwrap();
2798
2799 assert_eq!(doc2.paragraph_count(), 2);
2800 let paras = doc2.paragraphs();
2801 assert_eq!(paras[0].text(), "Hello, World!");
2802 assert_eq!(paras[1].text(), "Second paragraph");
2803 assert_eq!(paras[1].alignment(), Some(Alignment::Center));
2804 }
2805
2806 #[test]
2807 fn styles_present() {
2808 let doc = Document::new();
2809 assert!(doc.style("Normal").is_some());
2810 assert!(doc.style("Heading1").is_some());
2811 }
2812
2813 #[test]
2814 fn paragraph_with_style() {
2815 let mut doc = Document::new();
2816 doc.add_paragraph("Title").style("Heading1");
2817
2818 let paras = doc.paragraphs();
2819 assert_eq!(paras[0].style_id(), Some("Heading1"));
2820 }
2821
2822 #[test]
2823 fn multiple_runs_in_paragraph() {
2824 let mut doc = Document::new();
2825 let mut para = doc.add_paragraph("");
2826 para.add_run("Normal ");
2827 para.add_run("bold ").bold(true);
2828 para.add_run("italic").italic(true);
2829
2830 let paras = doc.paragraphs();
2831 assert_eq!(paras[0].text(), "Normal bold italic");
2832 let runs: Vec<_> = paras[0].runs().collect();
2833 assert_eq!(runs.len(), 3);
2834 assert!(!runs[0].is_bold());
2835 assert!(runs[1].is_bold());
2836 assert!(runs[2].is_italic());
2837 }
2838
2839 #[test]
2840 fn add_custom_style() {
2841 let mut doc = Document::new();
2842 doc.add_style(StyleBuilder::paragraph("MyCustom", "My Custom Style").based_on("Normal"));
2843 assert!(doc.style("MyCustom").is_some());
2844 let s = doc.style("MyCustom").unwrap();
2845 assert_eq!(s.name(), Some("My Custom Style"));
2846 assert_eq!(s.based_on(), Some("Normal"));
2847 }
2848
2849 #[test]
2850 fn resolve_style_properties() {
2851 let doc = Document::new();
2852 let ppr = doc.resolve_paragraph_properties(Some("Heading1"));
2854 assert_eq!(ppr.keep_next, Some(true));
2855 assert_eq!(ppr.space_before, Some(Twips(240)));
2856
2857 let ppr = doc.resolve_paragraph_properties(None);
2859 assert_eq!(ppr.space_after, Some(Twips(160)));
2860 }
2861
2862 #[test]
2863 fn resolve_run_style_properties() {
2864 let doc = Document::new();
2865 let rpr = doc.resolve_run_properties(Some("Heading1"), None);
2866 assert_eq!(rpr.bold, Some(true));
2867 assert_eq!(rpr.sz, Some(HalfPoint(32)));
2868 assert_eq!(rpr.font_ascii, Some("Calibri".to_string()));
2869 }
2870
2871 #[test]
2872 fn set_landscape() {
2873 let mut doc = Document::new();
2874 doc.set_landscape();
2875 let sect = doc.section_properties().unwrap();
2876 assert_eq!(sect.orientation, Some(ST_PageOrientation::Landscape));
2877 assert!(sect.page_width.unwrap().0 > sect.page_height.unwrap().0);
2879 }
2880
2881 #[test]
2882 fn set_margins() {
2883 let mut doc = Document::new();
2884 doc.set_margins(
2885 Length::inches(0.5),
2886 Length::inches(0.75),
2887 Length::inches(0.5),
2888 Length::inches(0.75),
2889 );
2890 let sect = doc.section_properties().unwrap();
2891 assert_eq!(sect.margin_top, Some(Twips(720)));
2892 assert_eq!(sect.margin_right, Some(Twips(1080)));
2893 }
2894
2895 #[test]
2896 fn set_columns() {
2897 let mut doc = Document::new();
2898 doc.set_columns(2, Length::inches(0.5));
2899 let sect = doc.section_properties().unwrap();
2900 let cols = sect.columns.as_ref().unwrap();
2901 assert_eq!(cols.num, Some(2));
2902 assert_eq!(cols.space, Some(Twips(720)));
2903 assert_eq!(cols.equal_width, Some(true));
2904 }
2905
2906 #[test]
2907 fn set_page_size() {
2908 let mut doc = Document::new();
2909 doc.set_page_size(Length::cm(21.0), Length::cm(29.7));
2910 let sect = doc.section_properties().unwrap();
2911 let w = sect.page_width.unwrap().0;
2913 let h = sect.page_height.unwrap().0;
2914 assert!((w - 11906).abs() < 5);
2915 assert!((h - 16838).abs() < 5);
2916 }
2917
2918 #[test]
2919 fn set_different_first_page() {
2920 let mut doc = Document::new();
2921 doc.set_different_first_page(true);
2922 assert_eq!(doc.section_properties().unwrap().title_pg, Some(true));
2923 }
2924
2925 #[test]
2926 fn content_insertion_api() {
2927 let mut doc = Document::new();
2928 doc.add_paragraph("First");
2929 doc.add_paragraph("Third");
2930
2931 doc.insert_paragraph(1, "Second");
2933 assert_eq!(doc.content_count(), 3);
2934 let paras = doc.paragraphs();
2935 assert_eq!(paras[0].text(), "First");
2936 assert_eq!(paras[1].text(), "Second");
2937 assert_eq!(paras[2].text(), "Third");
2938
2939 doc.insert_paragraph(0, "Zeroth");
2941 assert_eq!(doc.content_count(), 4);
2942 assert_eq!(doc.paragraphs()[0].text(), "Zeroth");
2943 }
2944
2945 #[test]
2946 fn find_content_index_and_remove() {
2947 let mut doc = Document::new();
2948 doc.add_paragraph("Hello");
2949 doc.add_paragraph("{{PLACEHOLDER}}");
2950 doc.add_paragraph("World");
2951
2952 assert_eq!(doc.find_content_index("{{PLACEHOLDER}}"), Some(1));
2953 assert_eq!(doc.find_content_index("NONEXISTENT"), None);
2954
2955 assert!(doc.remove_content(1));
2956 assert_eq!(doc.content_count(), 2);
2957 assert_eq!(doc.paragraphs()[1].text(), "World");
2958
2959 assert!(!doc.remove_content(10));
2961 }
2962
2963 #[test]
2964 fn insert_table_at_index() {
2965 let mut doc = Document::new();
2966 doc.add_paragraph("Before");
2967 doc.add_paragraph("After");
2968
2969 doc.insert_table(1, 2, 3);
2970 assert_eq!(doc.content_count(), 3);
2971 assert_eq!(doc.table_count(), 1);
2972 let paras = doc.paragraphs();
2974 assert_eq!(paras[0].text(), "Before");
2975 assert_eq!(paras[1].text(), "After");
2976 }
2977
2978 #[test]
2979 fn replace_text_in_body() {
2980 let mut doc = Document::new();
2981 doc.add_paragraph("Hello {{name}}!");
2982 doc.add_paragraph("Welcome to {{company}}.");
2983
2984 let count = doc.replace_text("{{name}}", "Alice");
2985 assert_eq!(count, 1);
2986 assert_eq!(doc.paragraphs()[0].text(), "Hello Alice!");
2987
2988 let count = doc.replace_text("{{company}}", "Acme");
2989 assert_eq!(count, 1);
2990 assert_eq!(doc.paragraphs()[1].text(), "Welcome to Acme.");
2991 }
2992
2993 #[test]
2994 fn replace_text_in_header_and_footer() {
2995 let mut doc = Document::new();
2996 doc.set_header("Header: {{title}}");
2997 doc.set_footer("Footer: {{title}}");
2998 doc.add_paragraph("Body: {{title}}");
2999
3000 let count = doc.replace_text("{{title}}", "My Doc");
3001 assert_eq!(count, 3);
3002
3003 assert_eq!(doc.paragraphs()[0].text(), "Body: My Doc");
3004 assert_eq!(doc.header_text().unwrap(), "Header: My Doc");
3005 assert_eq!(doc.footer_text().unwrap(), "Footer: My Doc");
3006 }
3007
3008 #[test]
3009 fn replace_all_batch() {
3010 let mut doc = Document::new();
3011 doc.add_paragraph("{{a}} and {{b}}");
3012
3013 let mut map = std::collections::HashMap::new();
3014 map.insert("{{a}}", "X");
3015 map.insert("{{b}}", "Y");
3016 let count = doc.replace_all(&map);
3017 assert_eq!(count, 2);
3018 assert_eq!(doc.paragraphs()[0].text(), "X and Y");
3019 }
3020
3021 #[test]
3022 fn template_workflow_round_trip() {
3023 let mut doc = Document::new();
3024 doc.add_paragraph("Company: {{company}}");
3025 doc.add_paragraph("Date: {{date}}");
3026
3027 doc.replace_text("{{company}}", "Acme Corp");
3028 doc.replace_text("{{date}}", "2026-02-22");
3029
3030 let bytes = doc.to_bytes().unwrap();
3032 let doc2 = Document::from_bytes(&bytes).unwrap();
3033 assert_eq!(doc2.paragraphs()[0].text(), "Company: Acme Corp");
3034 assert_eq!(doc2.paragraphs()[1].text(), "Date: 2026-02-22");
3035 }
3036
3037 #[test]
3038 fn add_background_image_round_trip() {
3039 let png_data: Vec<u8> = vec![
3041 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49,
3045 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
3047 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
3049 ];
3050
3051 let mut doc = Document::new();
3052 doc.add_paragraph("Hello World");
3053 doc.add_background_image(&png_data, "bg.png");
3054
3055 assert_eq!(doc.content_count(), 2);
3057
3058 let bytes = doc.to_bytes().unwrap();
3060 let doc2 = Document::from_bytes(&bytes).unwrap();
3061
3062 assert_eq!(doc2.content_count(), 2);
3064 assert_eq!(doc2.paragraphs().last().unwrap().text(), "Hello World");
3066 }
3067
3068 #[test]
3069 fn add_anchored_image() {
3070 let png_data: Vec<u8> = vec![
3071 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
3072 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
3073 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08,
3074 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
3075 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
3076 ];
3077
3078 let mut doc = Document::new();
3079 doc.add_paragraph("Content");
3080 doc.add_anchored_image(
3081 &png_data,
3082 "overlay.png",
3083 Length::inches(4.0),
3084 Length::inches(3.0),
3085 false,
3086 );
3087 assert_eq!(doc.content_count(), 2);
3088 }
3089
3090 #[test]
3091 fn insert_toc_basic() {
3092 let mut doc = Document::new();
3093 doc.add_paragraph("Introduction");
3094 doc.add_paragraph("Chapter 1").style("Heading1");
3095 doc.add_paragraph("Some text in chapter 1.");
3096 doc.add_paragraph("Section 1.1").style("Heading2");
3097 doc.add_paragraph("Text in section 1.1.");
3098 doc.add_paragraph("Chapter 2").style("Heading1");
3099 doc.add_paragraph("Text in chapter 2.");
3100
3101 assert_eq!(doc.content_count(), 7);
3103
3104 doc.insert_toc(0, 2);
3106
3107 assert_eq!(doc.content_count(), 11);
3109
3110 let paras = doc.paragraphs();
3112 assert_eq!(paras[0].text(), "Table of Contents");
3113
3114 assert_eq!(paras[1].text(), "Chapter 1\t");
3116 assert_eq!(paras[2].text(), "Section 1.1\t");
3117 assert_eq!(paras[3].text(), "Chapter 2\t");
3118
3119 let bytes = doc.to_bytes().expect("should serialize");
3121 let doc2 = Document::from_bytes(&bytes).expect("should open");
3122 assert_eq!(doc2.content_count(), 11);
3123 let paras2 = doc2.paragraphs();
3124 assert_eq!(paras2[0].text(), "Table of Contents");
3125 }
3126
3127 #[test]
3128 fn append_documents() {
3129 let mut doc_a = Document::new();
3130 doc_a.add_paragraph("Paragraph A1");
3131 doc_a.add_paragraph("Paragraph A2");
3132
3133 let mut doc_b = Document::new();
3134 doc_b.add_paragraph("Paragraph B1");
3135 doc_b.add_paragraph("Paragraph B2");
3136 doc_b.add_paragraph("Paragraph B3");
3137
3138 assert_eq!(doc_a.content_count(), 2);
3139 doc_a.append(&doc_b);
3140 assert_eq!(doc_a.content_count(), 5);
3141
3142 let paras = doc_a.paragraphs();
3143 assert_eq!(paras[0].text(), "Paragraph A1");
3144 assert_eq!(paras[1].text(), "Paragraph A2");
3145 assert_eq!(paras[2].text(), "Paragraph B1");
3146 assert_eq!(paras[3].text(), "Paragraph B2");
3147 assert_eq!(paras[4].text(), "Paragraph B3");
3148
3149 let bytes = doc_a.to_bytes().expect("serialize");
3151 let reopened = Document::from_bytes(&bytes).expect("open");
3152 assert_eq!(reopened.content_count(), 5);
3153 }
3154
3155 #[test]
3156 fn append_with_section_break() {
3157 let mut doc_a = Document::new();
3158 doc_a.add_paragraph("A1");
3159
3160 let mut doc_b = Document::new();
3161 doc_b.add_paragraph("B1");
3162
3163 doc_a.append_with_break(&doc_b, crate::SectionBreak::Continuous);
3164 assert_eq!(doc_a.content_count(), 3);
3166 }
3167
3168 #[test]
3169 fn insert_document_at_index() {
3170 let mut doc_a = Document::new();
3171 doc_a.add_paragraph("First");
3172 doc_a.add_paragraph("Last");
3173
3174 let mut doc_b = Document::new();
3175 doc_b.add_paragraph("Middle 1");
3176 doc_b.add_paragraph("Middle 2");
3177
3178 doc_a.insert_document(1, &doc_b);
3179 assert_eq!(doc_a.content_count(), 4);
3180
3181 let paras = doc_a.paragraphs();
3182 assert_eq!(paras[0].text(), "First");
3183 assert_eq!(paras[1].text(), "Middle 1");
3184 assert_eq!(paras[2].text(), "Middle 2");
3185 assert_eq!(paras[3].text(), "Last");
3186 }
3187
3188 #[test]
3189 fn merge_deduplicates_styles() {
3190 let mut doc_a = Document::new();
3191 doc_a.add_paragraph("A").style("Heading1");
3192
3193 let mut doc_b = Document::new();
3194 doc_b.add_paragraph("B").style("Heading1");
3195 doc_b.add_style(
3196 crate::style::StyleBuilder::paragraph("CustomB", "Custom B").based_on("Normal"),
3197 );
3198 doc_b.add_paragraph("C").style("CustomB");
3199
3200 let styles_before = doc_a.styles.styles.len();
3201 doc_a.append(&doc_b);
3202 let styles_after = doc_a.styles.styles.len();
3203
3204 assert_eq!(styles_after, styles_before + 1);
3206 }
3207
3208 #[test]
3209 fn headings_and_outline() {
3210 let mut doc = Document::new();
3211 doc.add_paragraph("Intro");
3212 doc.add_paragraph("Chapter 1").style("Heading1");
3213 doc.add_paragraph("Section 1.1").style("Heading2");
3214 doc.add_paragraph("Section 1.2").style("Heading2");
3215 doc.add_paragraph("Chapter 2").style("Heading1");
3216 doc.add_paragraph("Section 2.1").style("Heading2");
3217 doc.add_paragraph("Sub 2.1.1").style("Heading3");
3218
3219 let headings = doc.headings();
3220 assert_eq!(headings.len(), 6);
3221 assert_eq!(headings[0], (1, "Chapter 1".to_string()));
3222 assert_eq!(headings[1], (2, "Section 1.1".to_string()));
3223 assert_eq!(headings[5], (3, "Sub 2.1.1".to_string()));
3224
3225 let outline = doc.document_outline();
3226 assert_eq!(outline.len(), 2); assert_eq!(outline[0].text, "Chapter 1");
3228 assert_eq!(outline[0].children.len(), 2); assert_eq!(outline[1].text, "Chapter 2");
3230 assert_eq!(outline[1].children.len(), 1); assert_eq!(outline[1].children[0].children.len(), 1); }
3233
3234 #[test]
3235 fn word_count_basic() {
3236 let mut doc = Document::new();
3237 doc.add_paragraph("Hello world");
3238 doc.add_paragraph("Three more words");
3239 assert_eq!(doc.word_count(), 5);
3240 }
3241
3242 #[test]
3243 fn audit_accessibility_missing_metadata() {
3244 let doc = Document::new();
3245 let issues = doc.audit_accessibility();
3246 assert!(issues.iter().any(|i| i.message.contains("no title")));
3248 assert!(issues.iter().any(|i| i.message.contains("no author")));
3249 }
3250
3251 #[test]
3252 fn audit_heading_level_gap() {
3253 let mut doc = Document::new();
3254 doc.set_title("Test");
3255 doc.set_author("Test");
3256 doc.add_paragraph("Ch 1").style("Heading1");
3257 doc.add_paragraph("Skip to 3").style("Heading3");
3258
3259 let issues = doc.audit_accessibility();
3260 assert!(
3261 issues
3262 .iter()
3263 .any(|i| i.message.contains("Heading level gap"))
3264 );
3265 }
3266
3267 #[test]
3268 fn links_returns_empty_for_no_hyperlinks() {
3269 let mut doc = Document::new();
3270 doc.add_paragraph("No links here.");
3271 assert!(doc.links().is_empty());
3272 }
3273
3274 #[test]
3275 fn images_returns_empty_for_text_only() {
3276 let mut doc = Document::new();
3277 doc.add_paragraph("Just text.");
3278 assert!(doc.images().is_empty());
3279 }
3280}
3281
3282#[cfg(test)]
3283mod hyperlink_span_tests {
3284 use super::*;
3285 use rdocx_oxml::text::HyperlinkSpan;
3286
3287 #[test]
3291 fn links_clamps_out_of_range_spans() {
3292 let mut doc = Document::new();
3293 {
3294 let mut para = doc.add_paragraph("");
3295 para.add_run("one");
3296 para.add_run("two");
3297 }
3298
3299 let BodyContent::Paragraph(p) = &mut doc.document.body.content[0] else {
3300 unreachable!("just added a paragraph")
3301 };
3302 p.hyperlinks.push(HyperlinkSpan {
3303 rel_id: None,
3304 anchor: Some("bookmark".to_string()),
3305 run_start: 1,
3306 run_end: 99,
3307 });
3308 p.hyperlinks.push(HyperlinkSpan {
3309 rel_id: None,
3310 anchor: Some("inverted".to_string()),
3311 run_start: 5,
3312 run_end: 1,
3313 });
3314
3315 let links = doc.links();
3316
3317 assert_eq!(links.len(), 2);
3318 assert_eq!(links[0].text, "two");
3319 assert_eq!(links[1].text, "");
3320 }
3321}
3322
3323#[cfg(test)]
3324mod odttf_tests {
3325 use super::*;
3326
3327 fn fake_font() -> Vec<u8> {
3329 let mut data = Vec::new();
3330 data.extend(b"\x00\x01\x00\x00"); data.extend(2u16.to_be_bytes()); data.extend(32u16.to_be_bytes()); data.extend(1u16.to_be_bytes()); data.extend(0u16.to_be_bytes()); for (tag, offset, length) in [(b"cmap", 96u32, 40u32), (b"head", 136, 54)] {
3336 data.extend(tag); data.extend(0u32.to_be_bytes()); data.extend(offset.to_be_bytes());
3339 data.extend(length.to_be_bytes());
3340 }
3341 data.extend((0u8..64).map(|i| i.wrapping_mul(7)));
3342 data
3343 }
3344
3345 fn obfuscate(font: &[u8], key: &[u8; 16]) -> Vec<u8> {
3346 let mut out = font.to_vec();
3347 for (i, byte) in out.iter_mut().take(32).enumerate() {
3348 *byte ^= key[i % 16];
3349 }
3350 out
3351 }
3352
3353 const GUID_HEX: &str = "00112233445566778899AABBCCDDEEFF";
3354
3355 fn guid_bytes() -> [u8; 16] {
3356 let mut g = [0u8; 16];
3357 for (i, b) in g.iter_mut().enumerate() {
3358 *b = u8::from_str_radix(&GUID_HEX[i * 2..i * 2 + 2], 16).unwrap();
3359 }
3360 g
3361 }
3362
3363 #[test]
3364 fn recovers_font_under_either_key_convention() {
3365 let font = fake_font();
3366 let name = format!("{GUID_HEX}.odttf");
3367 for key in odttf_key_candidates(&guid_bytes()) {
3368 let obfuscated = obfuscate(&font, &key);
3369 assert_eq!(
3370 deobfuscate_odttf(&obfuscated, &name).as_deref(),
3371 Some(font.as_slice()),
3372 "failed to recover font for key {key:02x?}",
3373 );
3374 }
3375 }
3376
3377 #[test]
3378 fn rejects_data_that_does_not_decode_to_a_font() {
3379 let junk = vec![0xAB; 64];
3381 let name = format!("{GUID_HEX}.odttf");
3382 assert_eq!(deobfuscate_odttf(&junk, &name), None);
3383 }
3384
3385 #[test]
3386 fn rejects_short_or_malformed_input() {
3387 assert_eq!(deobfuscate_odttf(&[0u8; 8], "abc.odttf"), None);
3388 assert_eq!(deobfuscate_odttf(&[0u8; 64], "not-a-guid.odttf"), None);
3389 }
3390
3391 #[test]
3392 fn accepts_braced_and_hyphenated_names() {
3393 let font = fake_font();
3394 let key = odttf_key_candidates(&guid_bytes())[0];
3395 let obfuscated = obfuscate(&font, &key);
3396 for name in [
3397 "{00112233-4455-6677-8899-AABBCCDDEEFF}.odttf",
3398 "00112233-4455-6677-8899-AABBCCDDEEFF.odttf",
3399 ] {
3400 assert_eq!(
3401 deobfuscate_odttf(&obfuscated, name).as_deref(),
3402 Some(font.as_slice()),
3403 "failed for {name}"
3404 );
3405 }
3406 }
3407}