1use std::path::Path;
4use std::sync::{Arc, Mutex};
5
6#[cfg(test)]
7use std::cell::Cell;
8
9use oxml_media::MediaNamer;
10use oxml_opc::OpcPackage;
11use oxml_opc::relationship::rel_types;
12use rdocx_oxml::document::{BodyContent, CT_Columns, CT_Document, CT_SectPr};
13use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, CT_Inline};
14use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
15use rdocx_oxml::numbering::{CT_Numbering, ST_NumberFormat};
16use rdocx_oxml::properties::{CT_PPr, CT_RPr};
17use rdocx_oxml::shared::{ST_PageOrientation, ST_SectionType};
18use rdocx_oxml::styles::CT_Styles;
19use rdocx_oxml::table::{CT_Tbl, CellContent};
20use rdocx_oxml::text::{CT_P, CT_R, RunContent};
21
22use rdocx_oxml::core_properties::CoreProperties;
23
24use crate::Length;
25use crate::error::{Error, Result};
26use crate::paragraph::{Paragraph, ParagraphRef};
27use crate::style::{self, Style, StyleBuilder};
28use crate::table::{Table, TableRef};
29
30pub struct Document {
35 package: OpcPackage,
36 document: CT_Document,
37 styles: CT_Styles,
38 numbering: Option<CT_Numbering>,
39 core_properties: Option<CoreProperties>,
40 core_properties_part_name: String,
42 doc_part_name: String,
44 styles_part_name: String,
49 numbering_part_name: String,
51 image_namer: MediaNamer,
53 footnotes: rdocx_oxml::footnotes::CT_Footnotes,
55 layout_cache: Mutex<Option<Arc<oxml_layout::LayoutResult>>>,
57 deterministic_layout_cache: Mutex<Option<Arc<oxml_layout::LayoutResult>>>,
59}
60
61const DEFAULT_STYLES_PART: &str = "/word/styles.xml";
63const DEFAULT_NUMBERING_PART: &str = "/word/numbering.xml";
64const DEFAULT_CORE_PROPERTIES_PART: &str = "/docProps/core.xml";
65const DOCUMENT_CONTENT_TYPE: &str =
66 "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
67const STYLES_CONTENT_TYPE: &str =
68 "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
69const NUMBERING_CONTENT_TYPE: &str =
70 "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
71const CORE_PROPERTIES_REL_TYPE: &str =
72 "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
73const CORE_PROPERTIES_CONTENT_TYPE: &str =
74 "application/vnd.openxmlformats-package.core-properties+xml";
75
76#[cfg(test)]
77thread_local! {
78 static LAYOUT_INVOCATIONS: Cell<usize> = const { Cell::new(0) };
79}
80
81#[cfg(test)]
82fn record_layout_invocation() {
83 LAYOUT_INVOCATIONS.set(LAYOUT_INVOCATIONS.get() + 1);
84}
85
86fn new_word_package() -> OpcPackage {
87 let mut package = OpcPackage::with_main_part("word/document.xml", DOCUMENT_CONTENT_TYPE);
88 package
89 .content_types
90 .add_override(DEFAULT_STYLES_PART, STYLES_CONTENT_TYPE);
91 package
92}
93
94impl Document {
95 pub fn new() -> Self {
97 let mut package = new_word_package();
98 let document = CT_Document::new();
99 let styles = CT_Styles::new_default();
100
101 package
103 .get_or_create_part_rels("/word/document.xml")
104 .add(rel_types::STYLES, "styles.xml");
105
106 Document {
107 package,
108 document,
109 styles,
110 numbering: None,
111 core_properties: None,
112 core_properties_part_name: DEFAULT_CORE_PROPERTIES_PART.to_string(),
113 doc_part_name: "/word/document.xml".to_string(),
114 styles_part_name: DEFAULT_STYLES_PART.to_string(),
115 numbering_part_name: DEFAULT_NUMBERING_PART.to_string(),
116 image_namer: MediaNamer::scan("/word/media", "image", std::iter::empty()),
117 footnotes: rdocx_oxml::footnotes::CT_Footnotes::new(),
118 layout_cache: Mutex::new(None),
119 deterministic_layout_cache: Mutex::new(None),
120 }
121 }
122
123 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
125 let package = OpcPackage::open(path)?;
126 Self::from_package(package)
127 }
128
129 pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
131 let cursor = std::io::Cursor::new(bytes);
132 let package = OpcPackage::from_reader(cursor)?;
133 Self::from_package(package)
134 }
135
136 fn from_package(package: OpcPackage) -> Result<Self> {
137 let doc_part_name = package.main_document_part().ok_or(Error::NoDocumentPart)?;
138
139 let doc_xml = package
140 .get_part(&doc_part_name)
141 .ok_or(Error::NoDocumentPart)?;
142 let document = CT_Document::from_xml(doc_xml)?;
143
144 let resolve_part = |rel_type: &str| -> Option<String> {
146 let rels = package.get_part_rels(&doc_part_name)?;
147 let rel = rels.get_by_type(rel_type)?;
148 Some(OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
149 };
150
151 let styles_part_name = resolve_part(rel_types::STYLES);
153 let styles = match styles_part_name
154 .as_deref()
155 .and_then(|p| package.get_part(p))
156 {
157 Some(styles_xml) => CT_Styles::from_xml(styles_xml)?,
158 None => CT_Styles::new_default(),
159 };
160
161 let numbering_part_name = resolve_part(rel_types::NUMBERING);
163 let numbering = match numbering_part_name
164 .as_deref()
165 .and_then(|p| package.get_part(p))
166 {
167 Some(num_xml) => Some(CT_Numbering::from_xml(num_xml)?),
168 None => None,
169 };
170
171 let core_properties_part_name = package
173 .package_rels
174 .get_by_type(CORE_PROPERTIES_REL_TYPE)
175 .map(|rel| OpcPackage::resolve_rel_target("/", &rel.target));
176 let core_properties = core_properties_part_name
177 .as_deref()
178 .and_then(|part| package.get_part(part))
179 .and_then(|xml| CoreProperties::from_xml(xml).ok());
180
181 let image_namer = MediaNamer::scan(
182 "/word/media",
183 "image",
184 package.parts.keys().map(String::as_str),
185 );
186
187 let footnotes = package
188 .get_part_rels(&doc_part_name)
189 .and_then(|rels| rels.get_by_type(rel_types::FOOTNOTES))
190 .map(|rel| OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
191 .and_then(|part| package.get_part(&part))
192 .and_then(|xml| rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok())
193 .unwrap_or_default();
194
195 Ok(Document {
196 package,
197 document,
198 styles,
199 numbering,
200 core_properties,
201 core_properties_part_name: core_properties_part_name
202 .unwrap_or_else(|| DEFAULT_CORE_PROPERTIES_PART.to_string()),
203 doc_part_name,
204 styles_part_name: styles_part_name.unwrap_or_else(|| DEFAULT_STYLES_PART.to_string()),
205 numbering_part_name: numbering_part_name
206 .unwrap_or_else(|| DEFAULT_NUMBERING_PART.to_string()),
207 image_namer,
208 footnotes,
209 layout_cache: Mutex::new(None),
210 deterministic_layout_cache: Mutex::new(None),
211 })
212 }
213
214 fn invalidate_layout(&mut self) {
216 self.layout_cache
217 .get_mut()
218 .unwrap_or_else(std::sync::PoisonError::into_inner)
219 .take();
220 self.deterministic_layout_cache
221 .get_mut()
222 .unwrap_or_else(std::sync::PoisonError::into_inner)
223 .take();
224 }
225
226 fn cached_layout(&self) -> Result<Arc<oxml_layout::LayoutResult>> {
228 let mut cache = self
229 .layout_cache
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner);
232 if let Some(layout) = cache.as_ref() {
233 return Ok(Arc::clone(layout));
234 }
235
236 let input = self.build_layout_input();
237 #[cfg(test)]
238 record_layout_invocation();
239 let layout = Arc::new(rdocx_layout::layout_document(&input)?);
240 *cache = Some(Arc::clone(&layout));
241 Ok(layout)
242 }
243
244 fn cached_deterministic_layout(&self) -> Result<Arc<oxml_layout::LayoutResult>> {
246 let mut cache = self
247 .deterministic_layout_cache
248 .lock()
249 .unwrap_or_else(std::sync::PoisonError::into_inner);
250 if let Some(layout) = cache.as_ref() {
251 return Ok(Arc::clone(layout));
252 }
253
254 let input = self.build_layout_input();
255 #[cfg(test)]
256 record_layout_invocation();
257 let layout = Arc::new(rdocx_layout::layout_document_deterministic(&input)?);
258 *cache = Some(Arc::clone(&layout));
259 Ok(layout)
260 }
261
262 pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
264 self.flush_to_package()?;
265 self.package.save(path)?;
266 Ok(())
267 }
268
269 pub fn to_bytes(&mut self) -> Result<Vec<u8>> {
271 self.flush_to_package()?;
272 let mut buf = std::io::Cursor::new(Vec::new());
273 self.package.write_to(&mut buf)?;
274 Ok(buf.into_inner())
275 }
276
277 fn flush_to_package(&mut self) -> Result<()> {
279 let doc_xml = self.document.to_xml()?;
281 self.package.set_part(&self.doc_part_name, doc_xml);
282
283 let styles_xml = self.styles.to_xml()?;
287 let styles_part = self.styles_part_name.clone();
288 self.package.set_part(&styles_part, styles_xml);
289 self.ensure_part_relationship(&styles_part, rel_types::STYLES, STYLES_CONTENT_TYPE);
290
291 if let Some(ref numbering) = self.numbering {
293 let numbering_xml = numbering.to_xml()?;
294 let numbering_part = self.numbering_part_name.clone();
295 self.package.set_part(&numbering_part, numbering_xml);
296 self.ensure_part_relationship(
297 &numbering_part,
298 rel_types::NUMBERING,
299 NUMBERING_CONTENT_TYPE,
300 );
301 }
302
303 if !self.footnotes.footnotes.is_empty() {
305 let fx = self.footnotes.to_xml_footnotes()?;
306 self.package.set_part("/word/footnotes.xml", fx);
307 self.package.content_types.add_override(
308 "/word/footnotes.xml",
309 "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
310 );
311 let rels = self
312 .package
313 .get_or_create_part_rels(&self.doc_part_name.clone());
314 if rels.get_by_type(rel_types::FOOTNOTES).is_none() {
315 rels.add(rel_types::FOOTNOTES, "footnotes.xml");
316 }
317 }
318
319 if let Some(ref props) = self.core_properties {
321 let core_xml = props.to_xml()?;
322 self.package
323 .set_part(&self.core_properties_part_name, core_xml);
324 self.package.content_types.add_override(
325 &self.core_properties_part_name,
326 CORE_PROPERTIES_CONTENT_TYPE,
327 );
328 if self
329 .package
330 .package_rels
331 .get_by_type(CORE_PROPERTIES_REL_TYPE)
332 .is_none()
333 {
334 let target = self
335 .core_properties_part_name
336 .strip_prefix('/')
337 .unwrap_or(&self.core_properties_part_name);
338 self.package
339 .package_rels
340 .add(CORE_PROPERTIES_REL_TYPE, target);
341 }
342 }
343
344 Ok(())
345 }
346
347 fn ensure_part_relationship(&mut self, part_name: &str, rel_type: &str, content_type: &str) {
350 self.package
351 .content_types
352 .add_override(part_name, content_type);
353
354 let doc_part_name = self.doc_part_name.clone();
355 let already_linked = self
356 .package
357 .get_part_rels(&doc_part_name)
358 .and_then(|rels| rels.get_by_type(rel_type))
359 .map(|rel| OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
360 .is_some_and(|target| target == part_name);
361 if already_linked {
362 return;
363 }
364
365 let target = relative_target(&doc_part_name, part_name);
367 self.package
368 .get_or_create_part_rels(&doc_part_name)
369 .add(rel_type, &target);
370 }
371
372 pub fn paragraphs(&self) -> Vec<ParagraphRef<'_>> {
376 self.document
377 .body
378 .paragraphs()
379 .map(|p| ParagraphRef { inner: p })
380 .collect()
381 }
382
383 pub fn paragraph(&self, index: usize) -> Option<ParagraphRef<'_>> {
385 self.document
386 .body
387 .paragraphs()
388 .nth(index)
389 .map(|p| ParagraphRef { inner: p })
390 }
391
392 pub fn footnotes(&self) -> Vec<(i32, String)> {
398 self.footnotes
399 .footnotes
400 .iter()
401 .filter(|f| f.note_type == rdocx_oxml::footnotes::NoteType::Normal)
402 .map(|f| {
403 let text = f
404 .paragraphs
405 .iter()
406 .map(|p| p.text())
407 .collect::<Vec<_>>()
408 .join("\n");
409 (f.id, text)
410 })
411 .collect()
412 }
413
414 pub fn add_footnote(&mut self, text: &str) -> i32 {
417 self.invalidate_layout();
418 use rdocx_oxml::footnotes::CT_Footnote;
419 use rdocx_oxml::text::CT_P;
420 let id = self
421 .footnotes
422 .footnotes
423 .iter()
424 .map(|f| f.id)
425 .max()
426 .unwrap_or(1)
427 + 1;
428 let mut p = CT_P::new();
429 p.add_run(text);
430 self.footnotes.footnotes.push(CT_Footnote {
431 id,
432 note_type: rdocx_oxml::footnotes::NoteType::Normal,
433 paragraphs: vec![p],
434 });
435 id
436 }
437
438 pub fn add_paragraph(&mut self, text: &str) -> Paragraph<'_> {
440 self.invalidate_layout();
441 let mut p = CT_P::new();
442 if !text.is_empty() {
443 p.add_run(text);
444 }
445 self.document.body.content.push(BodyContent::Paragraph(p));
446 match self.document.body.content.last_mut().unwrap() {
447 BodyContent::Paragraph(p) => Paragraph { inner: p },
448 _ => unreachable!(),
449 }
450 }
451
452 pub fn paragraph_count(&self) -> usize {
454 self.document.body.paragraphs().count()
455 }
456
457 pub fn text(&self) -> String {
459 let mut result = String::new();
460 for content in &self.document.body.content {
461 match content {
462 BodyContent::Paragraph(paragraph) => {
463 result.push_str(¶graph.text());
464 result.push('\n');
465 }
466 BodyContent::Table(table) => {
467 for row in &table.rows {
468 for cell in &row.cells {
469 for content in &cell.content {
470 if let CellContent::Paragraph(paragraph) = content {
471 result.push_str(¶graph.text());
472 result.push('\t');
473 }
474 }
475 }
476 result.push('\n');
477 }
478 }
479 BodyContent::RawXml(_) => {}
480 }
481 }
482 result
483 }
484
485 pub fn paragraph_mut(&mut self, index: usize) -> Option<Paragraph<'_>> {
487 self.invalidate_layout();
488 self.document
489 .body
490 .paragraphs_mut()
491 .nth(index)
492 .map(|p| Paragraph { inner: p })
493 }
494
495 pub fn tables(&self) -> Vec<TableRef<'_>> {
499 self.document
500 .body
501 .tables()
502 .map(|t| TableRef { inner: t })
503 .collect()
504 }
505
506 pub fn table(&self, index: usize) -> Option<TableRef<'_>> {
508 self.document
509 .body
510 .tables()
511 .nth(index)
512 .map(|inner| TableRef { inner })
513 }
514
515 pub fn table_mut(&mut self, index: usize) -> Option<Table<'_>> {
517 self.invalidate_layout();
518 self.document
519 .body
520 .tables_mut()
521 .nth(index)
522 .map(|inner| Table { inner })
523 }
524
525 pub fn add_table(&mut self, rows: usize, cols: usize) -> Table<'_> {
528 self.invalidate_layout();
529 use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
530 use rdocx_oxml::units::Twips;
531
532 let col_width = Twips(9360 / cols.max(1) as i32);
535
536 let grid = CT_TblGrid {
537 columns: (0..cols)
538 .map(|_| CT_TblGridCol { width: col_width })
539 .collect(),
540 };
541
542 let mut tbl = CT_Tbl::new();
543 tbl.properties = Some(CT_TblPr {
544 width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
545 ..Default::default()
546 });
547 tbl.grid = Some(grid);
548
549 for _ in 0..rows {
550 let mut row = CT_Row::new();
551 for _ in 0..cols {
552 row.cells.push(CT_Tc::new());
553 }
554 tbl.rows.push(row);
555 }
556
557 self.document.body.content.push(BodyContent::Table(tbl));
558 match self.document.body.content.last_mut().unwrap() {
559 BodyContent::Table(t) => Table { inner: t },
560 _ => unreachable!(),
561 }
562 }
563
564 pub fn table_count(&self) -> usize {
566 self.document.body.tables().count()
567 }
568
569 pub fn content_count(&self) -> usize {
573 self.document.body.content_count()
574 }
575
576 pub fn insert_paragraph(&mut self, index: usize, text: &str) -> Paragraph<'_> {
584 self.invalidate_layout();
585 let mut p = CT_P::new();
586 if !text.is_empty() {
587 p.add_run(text);
588 }
589 self.document.body.insert_paragraph(index, p);
590 match &mut self.document.body.content[index] {
591 BodyContent::Paragraph(p) => Paragraph { inner: p },
592 _ => unreachable!(),
593 }
594 }
595
596 pub fn insert_table(&mut self, index: usize, rows: usize, cols: usize) -> Table<'_> {
606 self.invalidate_layout();
607 use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
608 use rdocx_oxml::units::Twips;
609
610 let col_width = Twips(9360 / cols.max(1) as i32);
611 let grid = CT_TblGrid {
612 columns: (0..cols)
613 .map(|_| CT_TblGridCol { width: col_width })
614 .collect(),
615 };
616
617 let mut tbl = CT_Tbl::new();
618 tbl.properties = Some(CT_TblPr {
619 width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
620 ..Default::default()
621 });
622 tbl.grid = Some(grid);
623
624 for _ in 0..rows {
625 let mut row = CT_Row::new();
626 for _ in 0..cols {
627 row.cells.push(CT_Tc::new());
628 }
629 tbl.rows.push(row);
630 }
631
632 self.document.body.insert_table(index, tbl);
633 match &mut self.document.body.content[index] {
634 BodyContent::Table(t) => Table { inner: t },
635 _ => unreachable!(),
636 }
637 }
638
639 pub fn find_content_index(&self, text: &str) -> Option<usize> {
641 self.document.body.find_paragraph_index(text)
642 }
643
644 pub fn remove_content(&mut self, index: usize) -> bool {
648 self.invalidate_layout();
649 self.document.body.remove(index).is_some()
650 }
651
652 pub fn add_picture(
662 &mut self,
663 image_data: &[u8],
664 image_filename: &str,
665 width: Length,
666 height: Length,
667 ) -> Paragraph<'_> {
668 self.invalidate_layout();
669 let rel_id = self.embed_image(image_data, image_filename);
670
671 let inline = CT_Inline::new(&rel_id, width.to_emu(), height.to_emu());
672
673 let drawing = CT_Drawing::inline(inline);
674 let run = CT_R {
675 alt_drawings: Vec::new(),
676 properties: None,
677 content: vec![RunContent::Drawing(drawing)],
678 extra_xml: Vec::new(),
679 };
680
681 let mut p = CT_P::new();
682 p.runs.push(run);
683 self.document.body.content.push(BodyContent::Paragraph(p));
684 match self.document.body.content.last_mut().unwrap() {
685 BodyContent::Paragraph(p) => Paragraph { inner: p },
686 _ => unreachable!(),
687 }
688 }
689
690 pub fn add_picture_auto(
695 &mut self,
696 image_data: &[u8],
697 image_filename: &str,
698 ) -> Result<Paragraph<'_>> {
699 let native_size = oxml_media::probe(image_data)
700 .and_then(|info| info.native_size(72.0))
701 .ok_or_else(|| Error::UnavailableImageDimensions {
702 filename: image_filename.to_owned(),
703 })?;
704
705 Ok(self.add_picture(
706 image_data,
707 image_filename,
708 Length::emu(native_size.width_emu),
709 Length::emu(native_size.height_emu),
710 ))
711 }
712
713 pub fn add_background_image(
720 &mut self,
721 image_data: &[u8],
722 image_filename: &str,
723 ) -> Paragraph<'_> {
724 self.invalidate_layout();
725 let rel_id = self.embed_image(image_data, image_filename);
726
727 let sect = self
729 .document
730 .body
731 .sect_pr
732 .as_ref()
733 .cloned()
734 .unwrap_or_else(CT_SectPr::default_letter);
735 let page_width_emu = sect
736 .page_width
737 .unwrap_or(rdocx_oxml::units::Twips(12240))
738 .to_emu()
739 .0;
740 let page_height_emu = sect
741 .page_height
742 .unwrap_or(rdocx_oxml::units::Twips(15840))
743 .to_emu()
744 .0;
745
746 let anchor = CT_Anchor::background(&rel_id, page_width_emu, page_height_emu);
747 let drawing = CT_Drawing::anchor(anchor);
748 let run = CT_R {
749 alt_drawings: Vec::new(),
750 properties: None,
751 content: vec![RunContent::Drawing(drawing)],
752 extra_xml: Vec::new(),
753 };
754
755 let mut p = CT_P::new();
756 p.runs.push(run);
757 self.document.body.insert_paragraph(0, p);
758 match &mut self.document.body.content[0] {
759 BodyContent::Paragraph(p) => Paragraph { inner: p },
760 _ => unreachable!(),
761 }
762 }
763
764 pub fn add_anchored_image(
769 &mut self,
770 image_data: &[u8],
771 image_filename: &str,
772 width: Length,
773 height: Length,
774 behind_text: bool,
775 ) -> Paragraph<'_> {
776 self.invalidate_layout();
777 let rel_id = self.embed_image(image_data, image_filename);
778
779 let mut anchor = CT_Anchor::background(&rel_id, width.to_emu(), height.to_emu());
780 anchor.behind_doc = behind_text;
781
782 let drawing = CT_Drawing::anchor(anchor);
783 let run = CT_R {
784 alt_drawings: Vec::new(),
785 properties: None,
786 content: vec![RunContent::Drawing(drawing)],
787 extra_xml: Vec::new(),
788 };
789
790 let mut p = CT_P::new();
791 p.runs.push(run);
792 self.document.body.insert_paragraph(0, p);
793 match &mut self.document.body.content[0] {
794 BodyContent::Paragraph(p) => Paragraph { inner: p },
795 _ => unreachable!(),
796 }
797 }
798
799 fn store_image_part(&mut self, image_data: &[u8], filename: &str) -> String {
806 let format = oxml_media::resolve(image_data, filename);
807 let extension = format.extension();
808 let part_name = self.image_namer.next_part_name(extension);
809
810 self.package.set_part(&part_name, image_data.to_vec());
811 let content_type = format.content_type();
812 match self.package.content_types.content_type_for(&part_name) {
813 Some(existing) if existing == content_type => {}
814 Some(_) => self
815 .package
816 .content_types
817 .add_override(&part_name, content_type),
818 None => self
819 .package
820 .content_types
821 .add_default(extension, content_type),
822 }
823
824 part_name
825 .strip_prefix("/word/")
826 .unwrap_or(&part_name)
827 .to_owned()
828 }
829
830 pub fn embed_image(&mut self, image_data: &[u8], filename: &str) -> String {
835 self.invalidate_layout();
836 let rel_target = self.store_image_part(image_data, filename);
837 self.package
838 .get_or_create_part_rels(&self.doc_part_name)
839 .add(rel_types::IMAGE, &rel_target)
840 }
841
842 pub fn numbering_is_bullet(&self, num_id: u32) -> Option<bool> {
845 let numbering = self.numbering.as_ref()?;
846 let abstract_num = numbering.get_abstract_num_for(num_id)?;
847 let fmt = abstract_num.levels.first()?.num_fmt?;
848 Some(fmt == rdocx_oxml::numbering::ST_NumberFormat::Bullet)
849 }
850
851 pub fn append_hyperlink(&mut self, text: &str, url: &str) {
855 let rel_id = self.add_hyperlink_relationship(url);
856
857 if !matches!(
858 self.document.body.content.last(),
859 Some(BodyContent::Paragraph(_))
860 ) {
861 self.document
862 .body
863 .content
864 .push(BodyContent::Paragraph(CT_P::new()));
865 }
866 let Some(BodyContent::Paragraph(p)) = self.document.body.content.last_mut() else {
867 unreachable!();
868 };
869 crate::Paragraph { inner: p }.add_hyperlink(text, &rel_id);
870 }
871
872 pub fn add_hyperlink_relationship(&mut self, url: &str) -> String {
878 self.invalidate_layout();
879 self.package
880 .get_or_create_part_rels(&self.doc_part_name)
881 .add_external(rel_types::HYPERLINK, url)
882 }
883
884 pub fn last_paragraph_mut(&mut self) -> Option<Paragraph<'_>> {
887 self.invalidate_layout();
888 match self.document.body.content.last_mut() {
889 Some(BodyContent::Paragraph(p)) => Some(Paragraph { inner: p }),
890 _ => None,
891 }
892 }
893
894 pub fn image_data(&self, rel_id: &str) -> Option<Vec<u8>> {
896 let rels = self.package.get_part_rels(&self.doc_part_name)?;
897 let rel = rels.items.iter().find(|r| r.id == rel_id)?;
898 let target = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
899 self.package.get_part(&target).map(|b| b.to_vec())
900 }
901
902 pub fn hyperlink_url(&self, rel_id: &str) -> Option<String> {
904 use oxml_opc::relationship::rel_types;
905 let rels = self.package.get_part_rels(&self.doc_part_name)?;
906 rels.items
907 .iter()
908 .find(|r| r.id == rel_id && r.rel_type == rel_types::HYPERLINK)
909 .map(|r| r.target.clone())
910 }
911
912 pub fn set_header(&mut self, text: &str) {
919 self.invalidate_layout();
920 self.set_header_footer_part(text, true, HdrFtrType::Default);
921 }
922
923 pub fn set_footer(&mut self, text: &str) {
925 self.invalidate_layout();
926 self.set_header_footer_part(text, false, HdrFtrType::Default);
927 }
928
929 pub fn set_first_page_header(&mut self, text: &str) {
931 self.invalidate_layout();
932 self.set_different_first_page(true);
933 self.set_header_footer_part(text, true, HdrFtrType::First);
934 }
935
936 pub fn set_first_page_footer(&mut self, text: &str) {
938 self.invalidate_layout();
939 self.set_different_first_page(true);
940 self.set_header_footer_part(text, false, HdrFtrType::First);
941 }
942
943 pub fn header_text(&self) -> Option<String> {
945 self.get_header_footer_text(true, HdrFtrType::Default)
946 }
947
948 pub fn footer_text(&self) -> Option<String> {
950 self.get_header_footer_text(false, HdrFtrType::Default)
951 }
952
953 pub fn set_header_image(
958 &mut self,
959 image_data: &[u8],
960 image_filename: &str,
961 width: Length,
962 height: Length,
963 ) {
964 self.invalidate_layout();
965 self.set_header_footer_image_part(
966 image_data,
967 image_filename,
968 width,
969 height,
970 true,
971 HdrFtrType::Default,
972 );
973 }
974
975 pub fn set_footer_image(
977 &mut self,
978 image_data: &[u8],
979 image_filename: &str,
980 width: Length,
981 height: Length,
982 ) {
983 self.invalidate_layout();
984 self.set_header_footer_image_part(
985 image_data,
986 image_filename,
987 width,
988 height,
989 false,
990 HdrFtrType::Default,
991 );
992 }
993
994 pub fn set_raw_header_with_images(
1005 &mut self,
1006 header_xml: Vec<u8>,
1007 images: &[(&str, &[u8], &str)],
1008 hdr_type: HdrFtrType,
1009 ) {
1010 self.invalidate_layout();
1011 self.set_raw_hdr_ftr_with_images(header_xml, images, true, hdr_type);
1012 }
1013
1014 pub fn set_raw_footer_with_images(
1016 &mut self,
1017 footer_xml: Vec<u8>,
1018 images: &[(&str, &[u8], &str)],
1019 hdr_type: HdrFtrType,
1020 ) {
1021 self.invalidate_layout();
1022 self.set_raw_hdr_ftr_with_images(footer_xml, images, false, hdr_type);
1023 }
1024
1025 pub fn set_header_image_with_background(
1031 &mut self,
1032 image_data: &[u8],
1033 image_filename: &str,
1034 width: Length,
1035 height: Length,
1036 bg_color: &str,
1037 ) {
1038 self.invalidate_layout();
1039 self.set_header_footer_image_bg_part(
1040 image_data,
1041 image_filename,
1042 width,
1043 height,
1044 Some(bg_color),
1045 true,
1046 HdrFtrType::Default,
1047 );
1048 }
1049
1050 pub fn set_first_page_header_image(
1052 &mut self,
1053 image_data: &[u8],
1054 image_filename: &str,
1055 width: Length,
1056 height: Length,
1057 ) {
1058 self.invalidate_layout();
1059 self.set_different_first_page(true);
1060 self.set_header_footer_image_part(
1061 image_data,
1062 image_filename,
1063 width,
1064 height,
1065 true,
1066 HdrFtrType::First,
1067 );
1068 }
1069
1070 fn hdr_ftr_slots(
1080 is_header: bool,
1081 hdr_type: HdrFtrType,
1082 ) -> (String, &'static str, &'static str) {
1083 let type_suffix = match hdr_type {
1084 HdrFtrType::Default => "",
1085 HdrFtrType::First => "First",
1086 HdrFtrType::Even => "Even",
1087 };
1088 if is_header {
1089 (
1090 format!("/word/header{type_suffix}1.xml"),
1091 rel_types::HEADER,
1092 "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
1093 )
1094 } else {
1095 (
1096 format!("/word/footer{type_suffix}1.xml"),
1097 rel_types::FOOTER,
1098 "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
1099 )
1100 }
1101 }
1102
1103 fn install_hdr_ftr_part(
1108 &mut self,
1109 xml: Vec<u8>,
1110 is_header: bool,
1111 hdr_type: HdrFtrType,
1112 ) -> String {
1113 let (part_name, rel_type, content_type) = Self::hdr_ftr_slots(is_header, hdr_type);
1114
1115 self.package.set_part(&part_name, xml);
1116 self.package
1117 .content_types
1118 .add_override(&part_name, content_type);
1119
1120 let rel_target = relative_target(&self.doc_part_name, &part_name);
1123 let rels = self.package.get_or_create_part_rels(&self.doc_part_name);
1124 let rel_id = match rels
1125 .items
1126 .iter()
1127 .find(|r| r.rel_type == rel_type && r.target == rel_target)
1128 {
1129 Some(existing) => existing.id.clone(),
1130 None => rels.add(rel_type, &rel_target),
1131 };
1132
1133 let sect = self.section_properties_mut();
1134 let refs = if is_header {
1135 &mut sect.header_refs
1136 } else {
1137 &mut sect.footer_refs
1138 };
1139 refs.retain(|r| r.hdr_ftr_type != hdr_type);
1140 refs.push(HdrFtrRef {
1141 hdr_ftr_type: hdr_type,
1142 rel_id,
1143 });
1144
1145 part_name
1146 }
1147
1148 fn serialize_hdr_ftr(hdr_ftr: &CT_HdrFtr, is_header: bool) -> Result<Vec<u8>> {
1150 let xml = if is_header {
1151 hdr_ftr.to_xml_header()
1152 } else {
1153 hdr_ftr.to_xml_footer()
1154 };
1155 Ok(xml?)
1156 }
1157
1158 fn set_header_footer_part(&mut self, text: &str, is_header: bool, hdr_type: HdrFtrType) {
1159 let mut hdr_ftr = CT_HdrFtr::new();
1160 let mut p = CT_P::new();
1161 if !text.is_empty() {
1162 p.add_run(text);
1163 }
1164 hdr_ftr.paragraphs.push(p);
1165
1166 let Ok(xml) = Self::serialize_hdr_ftr(&hdr_ftr, is_header) else {
1167 return;
1168 };
1169 self.install_hdr_ftr_part(xml, is_header, hdr_type);
1170 }
1171
1172 fn set_raw_hdr_ftr_with_images(
1173 &mut self,
1174 xml: Vec<u8>,
1175 images: &[(&str, &[u8], &str)],
1176 is_header: bool,
1177 hdr_type: HdrFtrType,
1178 ) {
1179 let part_name = self.install_hdr_ftr_part(xml, is_header, hdr_type);
1180
1181 for &(rel_id, image_data, image_filename) in images {
1184 let img_rel_target = self.store_image_part(image_data, image_filename);
1185 self.package
1186 .get_or_create_part_rels(&part_name)
1187 .add_with_id(rel_id, rel_types::IMAGE, &img_rel_target);
1188 }
1189 }
1190
1191 fn set_header_footer_image_part(
1192 &mut self,
1193 image_data: &[u8],
1194 image_filename: &str,
1195 width: Length,
1196 height: Length,
1197 is_header: bool,
1198 hdr_type: HdrFtrType,
1199 ) {
1200 self.set_header_footer_image_bg_part(
1201 image_data,
1202 image_filename,
1203 width,
1204 height,
1205 None,
1206 is_header,
1207 hdr_type,
1208 );
1209 }
1210
1211 fn set_header_footer_image_bg_part(
1212 &mut self,
1213 image_data: &[u8],
1214 image_filename: &str,
1215 width: Length,
1216 height: Length,
1217 bg_color: Option<&str>,
1218 is_header: bool,
1219 hdr_type: HdrFtrType,
1220 ) {
1221 use rdocx_oxml::properties::CT_Shd;
1222
1223 let (part_name, _, _) = Self::hdr_ftr_slots(is_header, hdr_type);
1224
1225 let img_rel_target = self.store_image_part(image_data, image_filename);
1228 let img_rel_id = self
1229 .package
1230 .get_or_create_part_rels(&part_name)
1231 .add(rel_types::IMAGE, &img_rel_target);
1232
1233 let inline = CT_Inline::new(&img_rel_id, width.to_emu(), height.to_emu());
1234 let run = CT_R {
1235 alt_drawings: Vec::new(),
1236 properties: None,
1237 content: vec![RunContent::Drawing(CT_Drawing::inline(inline))],
1238 extra_xml: Vec::new(),
1239 };
1240
1241 let mut p = CT_P::new();
1242 p.runs.push(run);
1243 if let Some(color) = bg_color {
1244 p.properties = Some(CT_PPr {
1245 shading: Some(CT_Shd {
1246 val: "clear".to_string(),
1247 color: Some("auto".to_string()),
1248 fill: Some(color.to_string()),
1249 }),
1250 ..Default::default()
1251 });
1252 }
1253
1254 let mut hdr_ftr = CT_HdrFtr::new();
1255 hdr_ftr.paragraphs.push(p);
1256
1257 let Ok(xml) = Self::serialize_hdr_ftr(&hdr_ftr, is_header) else {
1258 return;
1259 };
1260 self.install_hdr_ftr_part(xml, is_header, hdr_type);
1261 }
1262
1263 fn get_header_footer_text(&self, is_header: bool, hdr_type: HdrFtrType) -> Option<String> {
1264 let sect = self.document.body.sect_pr.as_ref()?;
1265 let refs = if is_header {
1266 §.header_refs
1267 } else {
1268 §.footer_refs
1269 };
1270 let hdr_ref = refs.iter().find(|r| r.hdr_ftr_type == hdr_type)?;
1271
1272 let rels = self.package.get_part_rels(&self.doc_part_name)?;
1274 let rel = rels.get_by_id(&hdr_ref.rel_id)?;
1275 let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1276 let xml = self.package.get_part(&part_name)?;
1277 let hdr_ftr = CT_HdrFtr::from_xml(xml).ok()?;
1278 Some(hdr_ftr.text())
1279 }
1280
1281 fn ensure_numbering(&mut self) -> &mut CT_Numbering {
1289 self.numbering.get_or_insert_with(CT_Numbering::new)
1290 }
1291
1292 pub fn add_bullet_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
1297 self.invalidate_layout();
1298 let num_id = {
1300 let numbering = self.ensure_numbering();
1301 let existing = numbering.nums.iter().find(|n| {
1303 numbering
1304 .get_abstract_num_for(n.num_id)
1305 .map(|a| {
1306 a.levels.first().and_then(|l| l.num_fmt)
1307 == Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet)
1308 })
1309 .unwrap_or(false)
1310 });
1311 if let Some(existing) = existing {
1312 existing.num_id
1313 } else {
1314 numbering.add_bullet_list()
1315 }
1316 };
1317
1318 let mut p = CT_P::new();
1319 if !text.is_empty() {
1320 p.add_run(text);
1321 }
1322 let ppr = CT_PPr {
1323 num_id: Some(num_id),
1324 num_ilvl: Some(level),
1325 ..Default::default()
1326 };
1327 p.properties = Some(ppr);
1328
1329 self.document.body.content.push(BodyContent::Paragraph(p));
1330 match self.document.body.content.last_mut().unwrap() {
1331 BodyContent::Paragraph(p) => Paragraph { inner: p },
1332 _ => unreachable!(),
1333 }
1334 }
1335
1336 pub fn add_numbered_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
1341 self.invalidate_layout();
1342 let num_id = {
1344 let numbering = self.ensure_numbering();
1345 let existing = numbering.nums.iter().find(|n| {
1347 numbering
1348 .get_abstract_num_for(n.num_id)
1349 .map(|a| {
1350 a.levels.first().and_then(|l| l.num_fmt)
1351 == Some(rdocx_oxml::numbering::ST_NumberFormat::Decimal)
1352 })
1353 .unwrap_or(false)
1354 });
1355 if let Some(existing) = existing {
1356 existing.num_id
1357 } else {
1358 numbering.add_numbered_list()
1359 }
1360 };
1361
1362 let mut p = CT_P::new();
1363 if !text.is_empty() {
1364 p.add_run(text);
1365 }
1366 let ppr = CT_PPr {
1367 num_id: Some(num_id),
1368 num_ilvl: Some(level),
1369 ..Default::default()
1370 };
1371 p.properties = Some(ppr);
1372
1373 self.document.body.content.push(BodyContent::Paragraph(p));
1374 match self.document.body.content.last_mut().unwrap() {
1375 BodyContent::Paragraph(p) => Paragraph { inner: p },
1376 _ => unreachable!(),
1377 }
1378 }
1379
1380 pub fn add_list_definition(&mut self, levels: &[ListLevel]) -> u32 {
1407 self.invalidate_layout();
1408 let levels: Vec<(ST_NumberFormat, Option<u32>)> = levels
1409 .iter()
1410 .take(9)
1411 .map(|level| (level.format.to_st(), level.start))
1412 .collect();
1413 self.ensure_numbering().add_list(&levels)
1414 }
1415
1416 pub fn set_list_level(&mut self, num_id: u32, level: u32, spec: ListLevel) -> bool {
1421 let updated = self.numbering.as_mut().is_some_and(|numbering| {
1422 numbering.set_list_level(num_id, level, spec.format.to_st(), spec.start)
1423 });
1424 if updated {
1425 self.invalidate_layout();
1426 }
1427 updated
1428 }
1429
1430 pub fn styles(&self) -> Vec<Style<'_>> {
1434 self.styles
1435 .styles
1436 .iter()
1437 .map(|s| Style { inner: s })
1438 .collect()
1439 }
1440
1441 pub fn style(&self, style_id: &str) -> Option<Style<'_>> {
1443 self.styles.get_by_id(style_id).map(|s| Style { inner: s })
1444 }
1445
1446 pub fn add_style(&mut self, builder: StyleBuilder) {
1450 self.invalidate_layout();
1451 self.styles.styles.push(builder.build());
1452 }
1453
1454 pub fn resolve_paragraph_properties(&self, style_id: Option<&str>) -> CT_PPr {
1457 style::resolve_paragraph_properties(style_id, &self.styles)
1458 }
1459
1460 pub fn resolve_run_properties(
1463 &self,
1464 para_style_id: Option<&str>,
1465 run_style_id: Option<&str>,
1466 ) -> CT_RPr {
1467 style::resolve_run_properties(para_style_id, run_style_id, &self.styles)
1468 }
1469
1470 pub fn section_properties(&self) -> Option<&CT_SectPr> {
1474 self.document.body.sect_pr.as_ref()
1475 }
1476
1477 pub fn section_properties_mut(&mut self) -> &mut CT_SectPr {
1479 self.invalidate_layout();
1480 self.document
1481 .body
1482 .sect_pr
1483 .get_or_insert_with(CT_SectPr::default_letter)
1484 }
1485
1486 pub fn set_page_size(&mut self, width: Length, height: Length) {
1488 let sect = self.section_properties_mut();
1489 sect.page_width = Some(width.as_twips());
1490 sect.page_height = Some(height.as_twips());
1491 }
1492
1493 pub fn set_landscape(&mut self) {
1495 let sect = self.section_properties_mut();
1496 sect.orientation = Some(ST_PageOrientation::Landscape);
1497 if let (Some(w), Some(h)) = (sect.page_width, sect.page_height)
1499 && w.0 < h.0
1500 {
1501 sect.page_width = Some(h);
1502 sect.page_height = Some(w);
1503 }
1504 }
1505
1506 pub fn set_portrait(&mut self) {
1508 let sect = self.section_properties_mut();
1509 sect.orientation = Some(ST_PageOrientation::Portrait);
1510 if let (Some(w), Some(h)) = (sect.page_width, sect.page_height)
1512 && w.0 > h.0
1513 {
1514 sect.page_width = Some(h);
1515 sect.page_height = Some(w);
1516 }
1517 }
1518
1519 pub fn set_margins(&mut self, top: Length, right: Length, bottom: Length, left: Length) {
1521 let sect = self.section_properties_mut();
1522 sect.margin_top = Some(top.as_twips());
1523 sect.margin_right = Some(right.as_twips());
1524 sect.margin_bottom = Some(bottom.as_twips());
1525 sect.margin_left = Some(left.as_twips());
1526 }
1527
1528 pub fn set_columns(&mut self, num: u32, spacing: Length) {
1530 let sect = self.section_properties_mut();
1531 sect.columns = Some(CT_Columns {
1532 num: Some(num),
1533 space: Some(spacing.as_twips()),
1534 equal_width: Some(true),
1535 sep: None,
1536 columns: Vec::new(),
1537 });
1538 }
1539
1540 pub fn set_header_footer_distance(&mut self, header: Length, footer: Length) {
1542 let sect = self.section_properties_mut();
1543 sect.header_distance = Some(header.as_twips());
1544 sect.footer_distance = Some(footer.as_twips());
1545 }
1546
1547 pub fn set_gutter(&mut self, gutter: Length) {
1549 self.section_properties_mut().gutter = Some(gutter.as_twips());
1550 }
1551
1552 pub fn set_different_first_page(&mut self, val: bool) {
1554 self.section_properties_mut().title_pg = Some(val);
1555 }
1556
1557 pub fn title(&self) -> Option<&str> {
1561 self.core_properties.as_ref()?.title.as_deref()
1562 }
1563
1564 pub fn set_title(&mut self, title: &str) {
1566 self.invalidate_layout();
1567 self.ensure_core_properties().title = Some(title.to_string());
1568 }
1569
1570 pub fn author(&self) -> Option<&str> {
1572 self.core_properties.as_ref()?.creator.as_deref()
1573 }
1574
1575 pub fn set_author(&mut self, author: &str) {
1577 self.invalidate_layout();
1578 self.ensure_core_properties().creator = Some(author.to_string());
1579 }
1580
1581 pub fn subject(&self) -> Option<&str> {
1583 self.core_properties.as_ref()?.subject.as_deref()
1584 }
1585
1586 pub fn set_subject(&mut self, subject: &str) {
1588 self.invalidate_layout();
1589 self.ensure_core_properties().subject = Some(subject.to_string());
1590 }
1591
1592 pub fn keywords(&self) -> Option<&str> {
1594 self.core_properties.as_ref()?.keywords.as_deref()
1595 }
1596
1597 pub fn set_keywords(&mut self, keywords: &str) {
1599 self.invalidate_layout();
1600 self.ensure_core_properties().keywords = Some(keywords.to_string());
1601 }
1602
1603 fn ensure_core_properties(&mut self) -> &mut CoreProperties {
1604 self.core_properties
1605 .get_or_insert_with(CoreProperties::default)
1606 }
1607
1608 pub fn append(&mut self, other: &Document) {
1615 self.invalidate_layout();
1616 self.merge_styles(other);
1617
1618 let start_idx = self.document.body.content.len();
1619 for content in &other.document.body.content {
1620 self.document.body.content.push(content.clone());
1621 }
1622
1623 self.remap_merged_numbering(other, start_idx);
1624 }
1625
1626 pub fn append_with_break(&mut self, other: &Document, break_type: crate::SectionBreak) {
1628 self.invalidate_layout();
1629 let mut p = CT_P::new();
1631 let sect_pr = match break_type {
1632 crate::SectionBreak::NextPage => CT_SectPr::default_letter(),
1633 crate::SectionBreak::Continuous => {
1634 let mut sp = CT_SectPr::default_letter();
1635 sp.section_type = Some(ST_SectionType::Continuous);
1636 sp
1637 }
1638 crate::SectionBreak::EvenPage => {
1639 let mut sp = CT_SectPr::default_letter();
1640 sp.section_type = Some(ST_SectionType::EvenPage);
1641 sp
1642 }
1643 crate::SectionBreak::OddPage => {
1644 let mut sp = CT_SectPr::default_letter();
1645 sp.section_type = Some(ST_SectionType::OddPage);
1646 sp
1647 }
1648 };
1649 p.properties = Some(CT_PPr {
1650 sect_pr: Some(sect_pr),
1651 ..Default::default()
1652 });
1653 self.document.body.content.push(BodyContent::Paragraph(p));
1654
1655 self.append(other);
1656 }
1657
1658 pub fn insert_document(&mut self, index: usize, other: &Document) {
1662 self.invalidate_layout();
1663 self.merge_styles(other);
1664
1665 let insert_at = index.min(self.document.body.content.len());
1666 for (i, content) in other.document.body.content.iter().enumerate() {
1667 self.document
1668 .body
1669 .content
1670 .insert(insert_at + i, content.clone());
1671 }
1672
1673 self.remap_merged_numbering(other, insert_at);
1674 }
1675
1676 fn merge_styles(&mut self, other: &Document) {
1678 for style in &other.styles.styles {
1679 if self.styles.get_by_id(&style.style_id).is_none() {
1680 self.styles.styles.push(style.clone());
1681 }
1682 }
1683 }
1684
1685 fn remap_merged_numbering(&mut self, other: &Document, start_idx: usize) {
1688 let Some(other_numbering) = &other.numbering else {
1689 return;
1690 };
1691
1692 let numbering = self
1693 .numbering
1694 .get_or_insert_with(|| rdocx_oxml::numbering::CT_Numbering {
1695 abstract_nums: Vec::new(),
1696 nums: Vec::new(),
1697 root_attributes: Vec::new(),
1698 extra_xml: Vec::new(),
1699 });
1700
1701 let max_abstract_id = numbering
1703 .abstract_nums
1704 .iter()
1705 .map(|a| a.abstract_num_id)
1706 .max()
1707 .unwrap_or(0);
1708 let max_num_id = numbering.nums.iter().map(|n| n.num_id).max().unwrap_or(0);
1709
1710 let abstract_offset = max_abstract_id + 1;
1711 let num_offset = max_num_id + 1;
1712
1713 for abs_num in &other_numbering.abstract_nums {
1715 let mut new_abs = abs_num.clone();
1716 new_abs.abstract_num_id += abstract_offset;
1717 numbering.abstract_nums.push(new_abs);
1718 }
1719
1720 for num in &other_numbering.nums {
1722 let mut new_num = num.clone();
1723 new_num.num_id += num_offset;
1724 new_num.abstract_num_id += abstract_offset;
1725 numbering.nums.push(new_num);
1726 }
1727
1728 let incoming_count = other.document.body.content.len();
1730 for content in self.document.body.content[start_idx..start_idx + incoming_count].iter_mut()
1731 {
1732 Self::remap_num_ids(content, num_offset);
1733 }
1734 }
1735
1736 fn remap_num_ids(content: &mut BodyContent, offset: u32) {
1738 match content {
1739 BodyContent::Paragraph(p) => {
1740 Self::remap_paragraph_num_id(p, offset);
1741 }
1742 BodyContent::Table(tbl) => {
1743 Self::remap_table_num_ids(tbl, offset);
1744 }
1745 BodyContent::RawXml(_) => {}
1746 }
1747 }
1748
1749 fn remap_paragraph_num_id(p: &mut CT_P, offset: u32) {
1750 if let Some(ppr) = &mut p.properties
1751 && let Some(num_id) = &mut ppr.num_id
1752 && *num_id > 0
1753 {
1754 *num_id += offset;
1755 }
1756 }
1757
1758 fn remap_table_num_ids(tbl: &mut CT_Tbl, offset: u32) {
1759 for row in &mut tbl.rows {
1760 for cell in &mut row.cells {
1761 for cc in &mut cell.content {
1762 match cc {
1763 rdocx_oxml::table::CellContent::Paragraph(p) => {
1764 Self::remap_paragraph_num_id(p, offset);
1765 }
1766 rdocx_oxml::table::CellContent::Table(nested) => {
1767 Self::remap_table_num_ids(nested, offset);
1768 }
1769 }
1770 }
1771 }
1772 }
1773 }
1774
1775 pub fn insert_toc(&mut self, index: usize, max_level: u32) {
1787 self.invalidate_layout();
1788 use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
1789 use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
1790 use rdocx_oxml::text::HyperlinkSpan;
1791 use rdocx_oxml::units::Twips;
1792
1793 let max_level = max_level.clamp(1, 9);
1794
1795 struct HeadingInfo {
1797 content_index: usize,
1798 level: u32,
1799 text: String,
1800 bookmark_name: String,
1801 }
1802
1803 let mut toc_counter = self.highest_toc_bookmark();
1808 let mut bookmark_id = 100 + toc_counter;
1809
1810 let mut headings = Vec::new();
1811
1812 for (idx, content) in self.document.body.content.iter().enumerate() {
1813 if let BodyContent::Paragraph(p) = content
1814 && let Some(level) = Self::detect_heading_level_for_toc(p)
1815 && level <= max_level
1816 {
1817 let text = p.text();
1818 if !text.trim().is_empty() {
1819 toc_counter += 1;
1820 headings.push(HeadingInfo {
1821 content_index: idx,
1822 level,
1823 text,
1824 bookmark_name: format!("_Toc{toc_counter}"),
1825 });
1826 }
1827 }
1828 }
1829
1830 for heading in &headings {
1833 if let Some(BodyContent::Paragraph(p)) =
1834 self.document.body.content.get_mut(heading.content_index)
1835 {
1836 let bm_start = format!(
1837 "<w:bookmarkStart w:id=\"{bookmark_id}\" w:name=\"{}\"/>",
1838 heading.bookmark_name
1839 );
1840 let bm_end = format!("<w:bookmarkEnd w:id=\"{bookmark_id}\"/>");
1841 p.extra_xml.push((0, bm_start.into_bytes()));
1843 p.extra_xml.push((p.runs.len(), bm_end.into_bytes()));
1845 bookmark_id += 1;
1846 }
1847 }
1848
1849 let right_tab = CT_Tabs {
1853 tabs: vec![CT_TabStop {
1854 val: ST_TabJc::Right,
1855 pos: Twips(self.text_width_twips()),
1856 leader: Some(ST_TabLeader::Dot),
1857 source_occurrence: None,
1858 }],
1859 };
1860
1861 let mut toc_paragraphs: Vec<CT_P> = Vec::new();
1862
1863 let mut title_p = CT_P::new();
1865 let mut title_r = CT_R::new("Table of Contents");
1866 title_r.properties = Some(CT_RPr {
1867 bold: Some(true),
1868 ..Default::default()
1869 });
1870 title_p.runs.push(title_r);
1871 title_p.properties = Some(CT_PPr {
1872 space_after: Some(Twips(120)),
1873 ..Default::default()
1874 });
1875 toc_paragraphs.push(title_p);
1876
1877 for heading in &headings {
1878 let mut p = CT_P::new();
1879
1880 let indent = Twips(360 * (heading.level as i32 - 1));
1882
1883 p.properties = Some(CT_PPr {
1884 tabs: Some(right_tab.clone()),
1885 ind_left: if indent.0 > 0 { Some(indent) } else { None },
1886 ..Default::default()
1887 });
1888
1889 let text_run = CT_R::new(&heading.text);
1891 p.runs.push(text_run);
1892
1893 p.runs.push(CT_R {
1895 alt_drawings: Vec::new(),
1896 properties: None,
1897 content: vec![rdocx_oxml::text::RunContent::Tab],
1898 extra_xml: Vec::new(),
1899 });
1900
1901 p.hyperlinks.push(HyperlinkSpan {
1903 rel_id: None,
1904 anchor: Some(heading.bookmark_name.clone()),
1905 run_start: 0,
1906 run_end: 1, });
1908
1909 toc_paragraphs.push(p);
1910 }
1911
1912 let insert_at = index.min(self.document.body.content.len());
1914 for (i, p) in toc_paragraphs.into_iter().enumerate() {
1915 self.document
1916 .body
1917 .content
1918 .insert(insert_at + i, BodyContent::Paragraph(p));
1919 }
1920 }
1921
1922 fn highest_toc_bookmark(&self) -> u32 {
1926 let mut highest = 0;
1927 for content in &self.document.body.content {
1928 let BodyContent::Paragraph(p) = content else {
1929 continue;
1930 };
1931 for (_, raw) in &p.extra_xml {
1932 let Ok(text) = std::str::from_utf8(raw) else {
1933 continue;
1934 };
1935 for (_, after) in text.match_indices("_Toc") {
1936 let digits: String = after
1937 .trim_start_matches("_Toc")
1938 .chars()
1939 .take_while(char::is_ascii_digit)
1940 .collect();
1941 if let Ok(n) = digits.parse::<u32>() {
1942 highest = highest.max(n);
1943 }
1944 }
1945 }
1946 }
1947 highest
1948 }
1949
1950 fn text_width_twips(&self) -> i32 {
1955 const DEFAULT_TEXT_WIDTH: i32 = 9360;
1956
1957 let Some(sect) = self.document.body.sect_pr.as_ref() else {
1958 return DEFAULT_TEXT_WIDTH;
1959 };
1960 let page_width = sect.page_width.map(|w| w.0).unwrap_or(12240);
1961 let left = sect.margin_left.map(|m| m.0).unwrap_or(1440);
1962 let right = sect.margin_right.map(|m| m.0).unwrap_or(1440);
1963
1964 let width = page_width - left - right;
1965 if width > 0 { width } else { DEFAULT_TEXT_WIDTH }
1966 }
1967
1968 fn detect_heading_level_for_toc(para: &CT_P) -> Option<u32> {
1970 let ppr = para.properties.as_ref()?;
1971 let style_id = ppr.style_id.as_deref()?;
1972 let rest = style_id.strip_prefix("Heading")?;
1973 rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n))
1974 }
1975
1976 pub fn replace_text(&mut self, placeholder: &str, replacement: &str) -> usize {
1987 self.invalidate_layout();
1988 self.replace_batch(&[(placeholder, replacement)])
1989 }
1990
1991 pub fn replace_all(&mut self, replacements: &std::collections::HashMap<&str, &str>) -> usize {
1997 self.invalidate_layout();
1998 let pairs: Vec<(&str, &str)> = replacements.iter().map(|(k, v)| (*k, *v)).collect();
1999 self.replace_batch(&pairs)
2000 }
2001
2002 fn replace_batch(&mut self, pairs: &[(&str, &str)]) -> usize {
2004 if pairs.is_empty() {
2005 return 0;
2006 }
2007
2008 let mut count = 0;
2009
2010 for (placeholder, replacement) in pairs {
2012 count += self.replace_in_body(placeholder, replacement);
2013 }
2014 count += self.replace_in_headers_footers(pairs);
2015
2016 if self.flush_to_package().is_ok() {
2019 count += self.replace_in_xml_parts(pairs);
2020 }
2021
2022 count
2023 }
2024
2025 fn replace_in_body(&mut self, placeholder: &str, replacement: &str) -> usize {
2027 use rdocx_oxml::placeholder;
2028
2029 let mut count = 0;
2030 for content in &mut self.document.body.content {
2031 match content {
2032 BodyContent::Paragraph(p) => {
2033 count += placeholder::replace_in_paragraph(p, placeholder, replacement);
2034 }
2035 BodyContent::Table(t) => {
2036 count += placeholder::replace_in_table(t, placeholder, replacement);
2037 }
2038 BodyContent::RawXml(_) => {}
2039 }
2040 }
2041 count
2042 }
2043
2044 fn replace_in_headers_footers(&mut self, pairs: &[(&str, &str)]) -> usize {
2046 use rdocx_oxml::placeholder;
2047
2048 let mut count = 0;
2049 for (rel_id, is_header) in self.header_footer_rel_ids() {
2050 let Some(mut hf) = self.load_header_footer(&rel_id) else {
2051 continue;
2052 };
2053 let mut part_count = 0;
2054 for (placeholder, replacement) in pairs {
2055 part_count +=
2056 placeholder::replace_in_header_footer(&mut hf, placeholder, replacement);
2057 }
2058 if part_count > 0 {
2059 self.save_header_footer(&rel_id, &hf, is_header);
2060 count += part_count;
2061 }
2062 }
2063 count
2064 }
2065
2066 fn header_footer_rel_ids(&self) -> Vec<(String, bool)> {
2069 let Some(sect_pr) = self.document.body.sect_pr.as_ref() else {
2070 return Vec::new();
2071 };
2072 sect_pr
2073 .header_refs
2074 .iter()
2075 .map(|r| (r.rel_id.clone(), true))
2076 .chain(
2077 sect_pr
2078 .footer_refs
2079 .iter()
2080 .map(|r| (r.rel_id.clone(), false)),
2081 )
2082 .collect()
2083 }
2084
2085 pub fn replace_regex(&mut self, pattern: &str, replacement: &str) -> Result<usize> {
2093 self.invalidate_layout();
2094 let re =
2095 regex::Regex::new(pattern).map_err(|e| Error::Other(format!("invalid regex: {e}")))?;
2096 Ok(self.replace_regex_compiled(&re, replacement))
2097 }
2098
2099 pub fn replace_all_regex(&mut self, patterns: &[(String, String)]) -> Result<usize> {
2101 self.invalidate_layout();
2102 let mut count = 0;
2103 for (pattern, replacement) in patterns {
2104 count += self.replace_regex(pattern, replacement)?;
2105 }
2106 Ok(count)
2107 }
2108
2109 fn replace_regex_compiled(&mut self, re: ®ex::Regex, replacement: &str) -> usize {
2111 use rdocx_oxml::placeholder;
2112
2113 let mut count = 0;
2114
2115 for content in &mut self.document.body.content {
2117 match content {
2118 BodyContent::Paragraph(p) => {
2119 count += placeholder::replace_regex_in_paragraph(p, re, replacement);
2120 }
2121 BodyContent::Table(t) => {
2122 count += placeholder::replace_regex_in_table(t, re, replacement);
2123 }
2124 BodyContent::RawXml(_) => {}
2125 }
2126 }
2127
2128 for (rel_id, is_header) in self.header_footer_rel_ids() {
2130 let Some(mut hf) = self.load_header_footer(&rel_id) else {
2131 continue;
2132 };
2133 let n = placeholder::replace_regex_in_header_footer(&mut hf, re, replacement);
2134 if n > 0 {
2135 self.save_header_footer(&rel_id, &hf, is_header);
2136 count += n;
2137 }
2138 }
2139
2140 if self.flush_to_package().is_ok() {
2144 count += self.replace_regex_in_xml_parts(re, replacement);
2145 }
2146
2147 count
2148 }
2149
2150 fn replace_regex_in_xml_parts(&mut self, re: ®ex::Regex, replacement: &str) -> usize {
2152 let mut count = 0;
2153
2154 for part_name in self.text_bearing_part_names() {
2155 let Some(xml) = self.package.get_part(&part_name).map(<[u8]>::to_vec) else {
2156 continue;
2157 };
2158 if let Ok((new_xml, n)) =
2159 rdocx_oxml::placeholder::replace_regex_in_xml_part(&xml, re, replacement)
2160 && n > 0
2161 {
2162 self.package.set_part(&part_name, new_xml);
2163 count += n;
2164 }
2165 }
2166
2167 if count > 0
2170 && let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
2171 && let Ok(doc) = CT_Document::from_xml(doc_xml)
2172 {
2173 self.document = doc;
2174 }
2175
2176 count
2177 }
2178
2179 fn text_bearing_part_names(&self) -> Vec<String> {
2182 let mut names = vec![self.doc_part_name.clone()];
2183 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
2184 for (rel_id, _) in self.header_footer_rel_ids() {
2185 if let Some(rel) = rels.get_by_id(&rel_id) {
2186 names.push(OpcPackage::resolve_rel_target(
2187 &self.doc_part_name,
2188 &rel.target,
2189 ));
2190 }
2191 }
2192 }
2193 names
2194 }
2195
2196 fn load_header_footer(&self, rel_id: &str) -> Option<CT_HdrFtr> {
2198 let rels = self.package.get_part_rels(&self.doc_part_name)?;
2199 let rel = rels.get_by_id(rel_id)?;
2200 let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2201 let xml = self.package.get_part(&part_name)?;
2202 CT_HdrFtr::from_xml(xml).ok()
2203 }
2204
2205 fn replace_in_xml_parts(&mut self, pairs: &[(&str, &str)]) -> usize {
2209 use rdocx_oxml::placeholder::{replace_many_in_chart_xml, replace_many_in_xml_part};
2210
2211 let mut count = 0;
2212
2213 let mut xml_parts: Vec<String> = vec![self.doc_part_name.clone()];
2215 if let Some(sect_pr) = self.document.body.sect_pr.as_ref()
2216 && let Some(rels) = self.package.get_part_rels(&self.doc_part_name)
2217 {
2218 for href in §_pr.header_refs {
2219 if let Some(rel) = rels.get_by_id(&href.rel_id) {
2220 xml_parts.push(OpcPackage::resolve_rel_target(
2221 &self.doc_part_name,
2222 &rel.target,
2223 ));
2224 }
2225 }
2226 for fref in §_pr.footer_refs {
2227 if let Some(rel) = rels.get_by_id(&fref.rel_id) {
2228 xml_parts.push(OpcPackage::resolve_rel_target(
2229 &self.doc_part_name,
2230 &rel.target,
2231 ));
2232 }
2233 }
2234 }
2235
2236 for part_name in xml_parts {
2237 if let Some(xml) = self.package.get_part(&part_name) {
2238 let xml = xml.to_vec();
2239 if let Ok((new_xml, n)) = replace_many_in_xml_part(&xml, pairs)
2240 && n > 0
2241 {
2242 self.package.set_part(&part_name, new_xml);
2243 count += n;
2244 }
2245 }
2246 }
2247
2248 let chart_parts: Vec<String> = self
2250 .package
2251 .get_part_rels(&self.doc_part_name)
2252 .map(|rels| {
2253 rels.get_all_by_type(rel_types::CHART)
2254 .iter()
2255 .map(|rel| OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target))
2256 .collect()
2257 })
2258 .unwrap_or_default();
2259
2260 for part_name in chart_parts {
2261 if let Some(xml) = self.package.get_part(&part_name) {
2262 let xml = xml.to_vec();
2263 if let Ok((new_xml, n)) = replace_many_in_chart_xml(&xml, pairs)
2264 && n > 0
2265 {
2266 self.package.set_part(&part_name, new_xml);
2267 count += n;
2268 }
2269 }
2270 }
2271
2272 if count > 0
2274 && let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
2275 && let Ok(doc) = CT_Document::from_xml(doc_xml)
2276 {
2277 self.document = doc;
2278 }
2279
2280 count
2281 }
2282
2283 pub fn to_pdf(&self) -> Result<Vec<u8>> {
2295 let layout = self.cached_layout()?;
2296 Ok(oxml_pdf::render_to_pdf(&layout))
2297 }
2298
2299 pub fn to_pdf_deterministic(&self) -> Result<Vec<u8>> {
2305 let layout = self.cached_deterministic_layout()?;
2306 Ok(oxml_pdf::render_to_pdf(&layout))
2307 }
2308
2309 pub fn to_pdf_with_fonts(&self, font_files: &[(&str, &[u8])]) -> Result<Vec<u8>> {
2322 let mut input = self.build_layout_input();
2323 for (family, data) in font_files {
2324 input.fonts.push(rdocx_layout::FontFile {
2325 family: family.to_string(),
2326 data: data.to_vec(),
2327 });
2328 }
2329 #[cfg(test)]
2330 record_layout_invocation();
2331 let layout = rdocx_layout::layout_document(&input)?;
2332 Ok(oxml_pdf::render_to_pdf(&layout))
2333 }
2334
2335 pub fn save_pdf<P: AsRef<Path>>(&self, path: P) -> Result<()> {
2337 let pdf_bytes = self.to_pdf()?;
2338 std::fs::write(path, pdf_bytes)?;
2339 Ok(())
2340 }
2341
2342 pub fn to_html(&self) -> String {
2344 let input = self.build_html_input();
2345 rdocx_html::to_html_document(&input, &rdocx_html::HtmlOptions::default())
2346 }
2347
2348 pub fn to_html_fragment(&self) -> String {
2350 let input = self.build_html_input();
2351 rdocx_html::to_html_fragment(&input, &rdocx_html::HtmlOptions::default())
2352 }
2353
2354 pub fn to_markdown(&self) -> String {
2356 let input = self.build_html_input();
2357 rdocx_html::to_markdown(&input)
2358 }
2359
2360 fn build_html_input(&self) -> rdocx_html::HtmlInput {
2362 use oxml_opc::relationship::rel_types;
2363 use std::collections::HashMap;
2364
2365 let mut images: HashMap<String, rdocx_html::ImageData> = HashMap::new();
2366 let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
2367
2368 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
2369 for rel in &rels.items {
2370 match rel.rel_type.as_str() {
2371 t if t == rel_types::IMAGE => {
2372 let part_name =
2373 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2374 if let Some(data) = self.package.get_part(&part_name) {
2375 let content_type = oxml_media::resolve(data, &part_name)
2376 .content_type()
2377 .to_owned();
2378 images.insert(
2379 rel.id.clone(),
2380 rdocx_html::ImageData {
2381 data: data.to_vec(),
2382 content_type,
2383 },
2384 );
2385 }
2386 }
2387 t if t == rel_types::HYPERLINK
2388 && rel.target_mode.as_ref().is_some_and(|m| m == "External") =>
2389 {
2390 hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
2391 }
2392 _ => {}
2393 }
2394 }
2395 }
2396
2397 rdocx_html::HtmlInput {
2398 document: self.document.clone(),
2399 styles: self.styles.clone(),
2400 numbering: self.numbering.clone(),
2401 images,
2402 hyperlink_urls,
2403 }
2404 }
2405
2406 pub fn render_page_to_png(&self, page_index: usize, dpi: f64) -> Result<Option<Vec<u8>>> {
2412 let layout = self.cached_layout()?;
2413 Ok(oxml_pdf::render_page_to_png(&layout, page_index, dpi))
2414 }
2415
2416 pub fn render_page_to_png_deterministic(
2423 &self,
2424 page_index: usize,
2425 dpi: f64,
2426 ) -> Result<Option<Vec<u8>>> {
2427 let layout = self.cached_deterministic_layout()?;
2428 Ok(oxml_pdf::render_page_to_png(&layout, page_index, dpi))
2429 }
2430
2431 pub fn render_all_pages(&self, dpi: f64) -> Result<Vec<Vec<u8>>> {
2433 let layout = self.cached_layout()?;
2434 Ok(oxml_pdf::render_all_pages(&layout, dpi))
2435 }
2436
2437 pub fn layout_page(&self, page_index: usize) -> Result<Option<oxml_layout::PageFrame>> {
2441 let layout = self.cached_layout()?;
2442 Ok(layout.pages.get(page_index).cloned())
2443 }
2444
2445 fn build_layout_input(&self) -> rdocx_layout::LayoutInput {
2447 use oxml_opc::relationship::rel_types;
2448 use rdocx_layout::{ImageData, LayoutInput};
2449 use std::collections::HashMap;
2450
2451 let mut headers: HashMap<String, CT_HdrFtr> = HashMap::new();
2452 let mut footers: HashMap<String, CT_HdrFtr> = HashMap::new();
2453 let mut images: HashMap<String, ImageData> = HashMap::new();
2454 let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
2455 let mut footnotes = None;
2456 let mut endnotes = None;
2457
2458 let fonts = self.extract_embedded_fonts();
2460
2461 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
2462 for rel in &rels.items {
2463 match rel.rel_type.as_str() {
2464 t if t == rel_types::HEADER => {
2465 let part_name =
2466 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2467 if let Some(xml) = self.package.get_part(&part_name)
2468 && let Ok(hf) = CT_HdrFtr::from_xml(xml)
2469 {
2470 headers.insert(rel.id.clone(), hf);
2471 }
2472 }
2473 t if t == rel_types::FOOTER => {
2474 let part_name =
2475 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2476 if let Some(xml) = self.package.get_part(&part_name)
2477 && let Ok(hf) = CT_HdrFtr::from_xml(xml)
2478 {
2479 footers.insert(rel.id.clone(), hf);
2480 }
2481 }
2482 t if t == rel_types::IMAGE => {
2483 let part_name =
2484 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2485 if let Some(data) = self.package.get_part(&part_name) {
2486 let content_type = oxml_media::resolve(data, &part_name)
2487 .content_type()
2488 .to_owned();
2489 images.insert(
2490 rel.id.clone(),
2491 ImageData {
2492 data: data.to_vec(),
2493 content_type,
2494 },
2495 );
2496 }
2497 }
2498 t if t == rel_types::HYPERLINK => {
2499 if rel.target_mode.as_ref().is_some_and(|m| m == "External") {
2500 hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
2501 }
2502 }
2503 t if t == rel_types::FOOTNOTES => {
2504 let part_name =
2505 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2506 if let Some(xml) = self.package.get_part(&part_name) {
2507 footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
2508 }
2509 }
2510 t if t == rel_types::ENDNOTES => {
2511 let part_name =
2512 OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2513 if let Some(xml) = self.package.get_part(&part_name) {
2514 endnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
2515 }
2516 }
2517 _ => {}
2518 }
2519 }
2520 }
2521
2522 let theme = self
2524 .package
2525 .get_part("/word/theme/theme1.xml")
2526 .and_then(|data| rdocx_oxml::theme::Theme::from_xml(data).ok());
2527
2528 LayoutInput {
2529 document: self.document.clone(),
2530 styles: self.styles.clone(),
2531 numbering: self.numbering.clone(),
2532 headers,
2533 footers,
2534 images,
2535 core_properties: self.core_properties.clone(),
2536 hyperlink_urls,
2537 footnotes,
2538 endnotes,
2539 theme,
2540 fonts,
2541 }
2542 }
2543
2544 fn extract_embedded_fonts(&self) -> Vec<rdocx_layout::FontFile> {
2550 let mut fonts = Vec::new();
2551
2552 for (part_name, data) in &self.package.parts {
2554 let lower = part_name.to_lowercase();
2555 if !lower.contains("/word/fonts/") && !lower.contains("/word/font") {
2556 continue;
2557 }
2558
2559 let file_name = part_name.rsplit('/').next().unwrap_or(part_name);
2561 let family = file_name.split('.').next().unwrap_or(file_name).to_string();
2562
2563 if lower.ends_with(".odttf") {
2564 if let Some(deobfuscated) = deobfuscate_odttf(data, file_name) {
2566 fonts.push(rdocx_layout::FontFile {
2567 family,
2568 data: deobfuscated,
2569 });
2570 }
2571 } else if lower.ends_with(".ttf") || lower.ends_with(".otf") || lower.ends_with(".ttc")
2572 {
2573 fonts.push(rdocx_layout::FontFile {
2574 family,
2575 data: data.clone(),
2576 });
2577 }
2578 }
2579
2580 fonts
2581 }
2582
2583 pub fn load_fonts_from_dir<P: AsRef<Path>>(dir: P) -> Vec<rdocx_layout::FontFile> {
2588 let mut fonts = Vec::new();
2589 let dir = dir.as_ref();
2590 if let Ok(entries) = std::fs::read_dir(dir) {
2591 for entry in entries.flatten() {
2592 let path = entry.path();
2593 let ext = path
2594 .extension()
2595 .and_then(|e| e.to_str())
2596 .unwrap_or("")
2597 .to_lowercase();
2598 if (ext == "ttf" || ext == "otf" || ext == "ttc")
2599 && let Ok(data) = std::fs::read(&path)
2600 {
2601 let family = path
2602 .file_stem()
2603 .and_then(|s| s.to_str())
2604 .unwrap_or("Unknown")
2605 .to_string();
2606 fonts.push(rdocx_layout::FontFile { family, data });
2607 }
2608 }
2609 }
2610 fonts
2611 }
2612
2613 fn save_header_footer(&mut self, rel_id: &str, hf: &CT_HdrFtr, is_header: bool) {
2615 let part_name = {
2616 let rels = self.package.get_part_rels(&self.doc_part_name);
2617 rels.and_then(|r| r.get_by_id(rel_id))
2618 .map(|rel| OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target))
2619 };
2620 if let Some(part_name) = part_name {
2621 let xml = if is_header {
2622 hf.to_xml_header()
2623 } else {
2624 hf.to_xml_footer()
2625 };
2626 if let Ok(xml) = xml {
2627 self.package.set_part(&part_name, xml);
2628 }
2629 }
2630 }
2631
2632 pub fn headings(&self) -> Vec<(u32, String)> {
2638 let mut result = Vec::new();
2639 for content in &self.document.body.content {
2640 if let BodyContent::Paragraph(p) = content
2641 && let Some(level) = Self::detect_heading_level_for_toc(p)
2642 {
2643 result.push((level, p.text()));
2644 }
2645 }
2646 result
2647 }
2648
2649 pub fn document_outline(&self) -> Vec<OutlineNode> {
2654 let headings = self.headings();
2655 build_outline_tree(&headings)
2656 }
2657
2658 pub fn images(&self) -> Vec<ImageInfo> {
2662 let mut result = Vec::new();
2663
2664 for content in &self.document.body.content {
2665 Self::collect_images_from_content(content, &mut result);
2666 }
2667 result
2668 }
2669
2670 fn collect_images_from_content(content: &BodyContent, result: &mut Vec<ImageInfo>) {
2671 match content {
2672 BodyContent::Paragraph(p) => Self::collect_images_from_paragraph(p, result),
2673 BodyContent::Table(tbl) => Self::collect_images_from_table(tbl, result),
2674 BodyContent::RawXml(_) => {}
2675 }
2676 }
2677
2678 fn collect_images_from_paragraph(p: &CT_P, result: &mut Vec<ImageInfo>) {
2679 for run in &p.runs {
2680 for rc in &run.content {
2681 let RunContent::Drawing(drawing) = rc else {
2682 continue;
2683 };
2684 if let Some(inline) = &drawing.inline {
2685 result.push(ImageInfo {
2686 embed_id: inline.embed_id.clone(),
2687 name: inline.name.clone(),
2688 description: inline.description.clone(),
2689 width_emu: inline.extent_cx.0,
2690 height_emu: inline.extent_cy.0,
2691 is_anchor: false,
2692 });
2693 }
2694 if let Some(anchor) = &drawing.anchor {
2695 result.push(ImageInfo {
2696 embed_id: anchor.embed_id.clone(),
2697 name: anchor.name.clone(),
2698 description: anchor.description.clone(),
2699 width_emu: anchor.extent_cx.0,
2700 height_emu: anchor.extent_cy.0,
2701 is_anchor: true,
2702 });
2703 }
2704 }
2705 }
2706 }
2707
2708 fn collect_images_from_table(tbl: &CT_Tbl, result: &mut Vec<ImageInfo>) {
2709 use rdocx_oxml::table::CellContent;
2710
2711 for row in &tbl.rows {
2712 for cell in &row.cells {
2713 for cc in &cell.content {
2714 match cc {
2715 CellContent::Paragraph(p) => Self::collect_images_from_paragraph(p, result),
2716 CellContent::Table(nested) => {
2717 Self::collect_images_from_table(nested, result)
2718 }
2719 }
2720 }
2721 }
2722 }
2723 }
2724
2725 pub fn links(&self) -> Vec<LinkInfo> {
2729 use oxml_opc::relationship::rel_types;
2730
2731 let mut url_map = std::collections::HashMap::new();
2733 if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
2734 for rel in &rels.items {
2735 if rel.rel_type == rel_types::HYPERLINK
2736 && rel.target_mode.as_ref().is_some_and(|m| m == "External")
2737 {
2738 url_map.insert(rel.id.clone(), rel.target.clone());
2739 }
2740 }
2741 }
2742
2743 let mut result = Vec::new();
2744 for content in &self.document.body.content {
2745 if let BodyContent::Paragraph(p) = content {
2746 for hl in &p.hyperlinks {
2747 let start = hl.run_start.min(p.runs.len());
2750 let end = hl.run_end.clamp(start, p.runs.len());
2751 let text: String = p.runs[start..end].iter().map(|r| r.text()).collect();
2752
2753 let url = hl.rel_id.as_ref().and_then(|id| url_map.get(id)).cloned();
2754
2755 result.push(LinkInfo {
2756 text,
2757 url,
2758 anchor: hl.anchor.clone(),
2759 rel_id: hl.rel_id.clone(),
2760 });
2761 }
2762 }
2763 }
2764 result
2765 }
2766
2767 pub fn word_count(&self) -> usize {
2772 let mut count = 0;
2773 for content in &self.document.body.content {
2774 count += Self::word_count_in_content(content);
2775 }
2776 count
2777 }
2778
2779 fn word_count_in_content(content: &BodyContent) -> usize {
2780 match content {
2781 BodyContent::Paragraph(p) => p.text().split_whitespace().count(),
2782 BodyContent::Table(tbl) => Self::word_count_in_table(tbl),
2783 BodyContent::RawXml(_) => 0,
2784 }
2785 }
2786
2787 fn word_count_in_table(tbl: &CT_Tbl) -> usize {
2788 use rdocx_oxml::table::CellContent;
2789
2790 let mut count = 0;
2791 for row in &tbl.rows {
2792 for cell in &row.cells {
2793 for cc in &cell.content {
2794 match cc {
2795 CellContent::Paragraph(p) => {
2796 count += p.text().split_whitespace().count();
2797 }
2798 CellContent::Table(nested) => {
2799 count += Self::word_count_in_table(nested);
2800 }
2801 }
2802 }
2803 }
2804 }
2805 count
2806 }
2807
2808 pub fn audit_accessibility(&self) -> Vec<AccessibilityIssue> {
2813 let mut issues = Vec::new();
2814
2815 if self.title().is_none() {
2817 issues.push(AccessibilityIssue {
2818 severity: IssueSeverity::Warning,
2819 message: "Document has no title".to_string(),
2820 });
2821 }
2822
2823 if self.author().is_none() {
2825 issues.push(AccessibilityIssue {
2826 severity: IssueSeverity::Info,
2827 message: "Document has no author".to_string(),
2828 });
2829 }
2830
2831 let images = self.images();
2833 for img in &images {
2834 let has_alt = img
2835 .description
2836 .as_ref()
2837 .is_some_and(|d| !d.is_empty() && d != "Background");
2838 if !has_alt {
2839 let name = img
2840 .name
2841 .as_deref()
2842 .or(Some(&img.embed_id))
2843 .unwrap_or("unknown");
2844 issues.push(AccessibilityIssue {
2845 severity: IssueSeverity::Error,
2846 message: format!("Image \"{name}\" has no alt text"),
2847 });
2848 }
2849 }
2850
2851 let headings = self.headings();
2853 let mut prev_level: Option<u32> = None;
2854 for (level, text) in &headings {
2855 if let Some(prev) = prev_level
2856 && *level > prev + 1
2857 {
2858 issues.push(AccessibilityIssue {
2859 severity: IssueSeverity::Warning,
2860 message: format!(
2861 "Heading level gap: h{prev} -> h{level} (\"{}\")",
2862 truncate_str(text, 40)
2863 ),
2864 });
2865 }
2866 prev_level = Some(*level);
2867 }
2868
2869 let mut consecutive_empty = 0u32;
2871 for content in &self.document.body.content {
2872 if let BodyContent::Paragraph(p) = content {
2873 if p.text().trim().is_empty() {
2874 consecutive_empty += 1;
2875 if consecutive_empty >= 3 {
2876 issues.push(AccessibilityIssue {
2877 severity: IssueSeverity::Info,
2878 message: format!(
2879 "{consecutive_empty} consecutive empty paragraphs (consider using spacing instead)"
2880 ),
2881 });
2882 }
2883 } else {
2884 consecutive_empty = 0;
2885 }
2886 } else {
2887 consecutive_empty = 0;
2888 }
2889 }
2890
2891 issues
2892 }
2893}
2894
2895impl Default for Document {
2896 fn default() -> Self {
2897 Self::new()
2898 }
2899}
2900
2901fn relative_target(source_part: &str, target_part: &str) -> String {
2906 let dir = match source_part.rfind('/') {
2907 Some(pos) => &source_part[..=pos],
2908 None => "/",
2909 };
2910 match target_part.strip_prefix(dir) {
2911 Some(rest) if !rest.contains('/') => rest.to_string(),
2912 _ => target_part.to_string(),
2913 }
2914}
2915
2916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2918pub enum ListNumberFormat {
2919 Bullet,
2920 Decimal,
2921 LowerLetter,
2922 UpperLetter,
2923 LowerRoman,
2924 UpperRoman,
2925 Ordinal,
2926}
2927
2928impl ListNumberFormat {
2929 fn to_st(self) -> ST_NumberFormat {
2930 match self {
2931 Self::Bullet => ST_NumberFormat::Bullet,
2932 Self::Decimal => ST_NumberFormat::Decimal,
2933 Self::LowerLetter => ST_NumberFormat::LowerLetter,
2934 Self::UpperLetter => ST_NumberFormat::UpperLetter,
2935 Self::LowerRoman => ST_NumberFormat::LowerRoman,
2936 Self::UpperRoman => ST_NumberFormat::UpperRoman,
2937 Self::Ordinal => ST_NumberFormat::Ordinal,
2938 }
2939 }
2940}
2941
2942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2944pub struct ListLevel {
2945 pub format: ListNumberFormat,
2947 pub start: Option<u32>,
2949}
2950
2951impl ListLevel {
2952 pub fn new(format: ListNumberFormat) -> Self {
2954 ListLevel {
2955 format,
2956 start: None,
2957 }
2958 }
2959
2960 pub fn bullet() -> Self {
2962 Self::new(ListNumberFormat::Bullet)
2963 }
2964
2965 pub fn decimal() -> Self {
2967 Self::new(ListNumberFormat::Decimal)
2968 }
2969
2970 pub fn start(mut self, start: u32) -> Self {
2972 self.start = Some(start);
2973 self
2974 }
2975}
2976
2977#[derive(Debug, Clone, PartialEq)]
2979pub struct OutlineNode {
2980 pub level: u32,
2982 pub text: String,
2984 pub children: Vec<OutlineNode>,
2986}
2987
2988#[derive(Debug, Clone, PartialEq)]
2990pub struct ImageInfo {
2991 pub embed_id: String,
2993 pub name: Option<String>,
2995 pub description: Option<String>,
2997 pub width_emu: i64,
2999 pub height_emu: i64,
3001 pub is_anchor: bool,
3003}
3004
3005#[derive(Debug, Clone, PartialEq)]
3007pub struct LinkInfo {
3008 pub text: String,
3010 pub url: Option<String>,
3012 pub anchor: Option<String>,
3014 pub rel_id: Option<String>,
3016}
3017
3018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3020pub enum IssueSeverity {
3021 Info,
3023 Warning,
3025 Error,
3027}
3028
3029#[derive(Debug, Clone, PartialEq)]
3031pub struct AccessibilityIssue {
3032 pub severity: IssueSeverity,
3034 pub message: String,
3036}
3037
3038fn build_outline_tree(headings: &[(u32, String)]) -> Vec<OutlineNode> {
3040 let mut root: Vec<OutlineNode> = Vec::new();
3041 let mut stack: Vec<(u32, usize)> = Vec::new(); for (level, text) in headings {
3044 let node = OutlineNode {
3045 level: *level,
3046 text: text.clone(),
3047 children: Vec::new(),
3048 };
3049
3050 while let Some(&(stack_level, _)) = stack.last() {
3052 if stack_level >= *level {
3053 stack.pop();
3054 } else {
3055 break;
3056 }
3057 }
3058
3059 if stack.is_empty() {
3060 root.push(node);
3061 let idx = root.len() - 1;
3062 stack.push((*level, idx));
3063 } else {
3064 let target = get_outline_parent_mut(&mut root, &stack);
3066 target.children.push(node);
3067 let idx = target.children.len() - 1;
3068 stack.push((*level, idx));
3069 }
3070 }
3071
3072 root
3073}
3074
3075fn get_outline_parent_mut<'a>(
3077 root: &'a mut [OutlineNode],
3078 stack: &[(u32, usize)],
3079) -> &'a mut OutlineNode {
3080 let mut current = &mut root[stack[0].1];
3081 for &(_, idx) in &stack[1..] {
3082 current = &mut current.children[idx];
3083 }
3084 current
3085}
3086
3087fn truncate_str(s: &str, max_len: usize) -> String {
3093 if s.chars().count() <= max_len {
3094 return s.to_string();
3095 }
3096 let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
3097 format!("{truncated}...")
3098}
3099
3100fn deobfuscate_odttf(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
3114 if data.len() < 32 {
3115 return None;
3116 }
3117
3118 let name = file_name
3121 .split('.')
3122 .next()
3123 .unwrap_or("")
3124 .trim_start_matches('{')
3125 .trim_end_matches('}');
3126
3127 let hex: String = name.chars().filter(|c| c.is_ascii_hexdigit()).collect();
3129 if hex.len() != 32 {
3130 return None;
3131 }
3132
3133 let mut guid = [0u8; 16];
3134 for (i, byte) in guid.iter_mut().enumerate() {
3135 *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
3136 }
3137
3138 let candidates = odttf_key_candidates(&guid);
3139 let decoded: Vec<Vec<u8>> = candidates
3140 .iter()
3141 .map(|key| {
3142 let mut result = data.to_vec();
3143 for (i, byte) in result.iter_mut().take(32).enumerate() {
3145 *byte ^= key[i % 16];
3146 }
3147 result
3148 })
3149 .collect();
3150
3151 decoded
3156 .iter()
3157 .find(|d| has_consistent_sfnt_header(d))
3158 .or_else(|| decoded.iter().find(|d| looks_like_sfnt(d)))
3159 .cloned()
3160}
3161
3162fn odttf_key_candidates(guid: &[u8; 16]) -> [[u8; 16]; 2] {
3164 let mut plain_reversed = *guid;
3166 plain_reversed.reverse();
3167
3168 let dotnet = [
3171 guid[3], guid[2], guid[1], guid[0], guid[5], guid[4], guid[7], guid[6], guid[8], guid[9],
3172 guid[10], guid[11], guid[12], guid[13], guid[14], guid[15],
3173 ];
3174 let mut dotnet_reversed = dotnet;
3175 dotnet_reversed.reverse();
3176
3177 [plain_reversed, dotnet_reversed]
3178}
3179
3180fn looks_like_sfnt(data: &[u8]) -> bool {
3186 let Some(signature) = data.first_chunk::<4>() else {
3187 return false;
3188 };
3189 match signature {
3190 b"\x00\x01\x00\x00" | b"OTTO" | b"true" => {}
3191 b"ttcf" => return true,
3193 _ => return false,
3194 }
3195
3196 if data.len() < 32 {
3197 return false;
3198 }
3199
3200 let num_tables = u16::from_be_bytes([data[4], data[5]]);
3201 if num_tables == 0 || num_tables > 512 {
3202 return false;
3203 }
3204
3205 let is_tag = |tag: &[u8]| tag.iter().all(|b| (0x20..=0x7E).contains(b));
3209 is_tag(&data[12..16]) && (num_tables < 2 || is_tag(&data[28..32]))
3210}
3211
3212fn has_consistent_sfnt_header(data: &[u8]) -> bool {
3220 if !looks_like_sfnt(data) || data.len() < 12 {
3221 return false;
3222 }
3223 if data.first_chunk::<4>() == Some(b"ttcf") {
3224 return false; }
3226
3227 let num_tables = u16::from_be_bytes([data[4], data[5]]);
3228 let search_range = u16::from_be_bytes([data[6], data[7]]);
3229 let entry_selector = u16::from_be_bytes([data[8], data[9]]);
3230 let range_shift = u16::from_be_bytes([data[10], data[11]]);
3231
3232 let expected_selector = num_tables.ilog2() as u16;
3233 let expected_search_range = (1u16 << expected_selector) * 16;
3234 let expected_range_shift = num_tables
3235 .wrapping_mul(16)
3236 .wrapping_sub(expected_search_range);
3237
3238 search_range == expected_search_range
3239 && entry_selector == expected_selector
3240 && range_shift == expected_range_shift
3241}
3242
3243#[cfg(test)]
3244mod tests {
3245 use super::*;
3246 use crate::paragraph::Alignment;
3247 use rdocx_oxml::units::{HalfPoint, Twips};
3248
3249 fn reset_layout_invocations() {
3250 LAYOUT_INVOCATIONS.set(0);
3251 }
3252
3253 fn layout_invocations() -> usize {
3254 LAYOUT_INVOCATIONS.get()
3255 }
3256
3257 #[test]
3258 fn rendering_all_pages_performs_one_layout() {
3259 let mut doc = Document::new();
3260 doc.add_paragraph("Page 1");
3261 for page in 2..=20 {
3262 doc.add_paragraph(&format!("Page {page}"))
3263 .page_break_before(true);
3264 }
3265
3266 reset_layout_invocations();
3267 for page_index in 0..20 {
3268 assert!(
3269 doc.render_page_to_png_deterministic(page_index, 1.0)
3270 .expect("deterministic layout should succeed")
3271 .is_some(),
3272 "page {page_index} should exist"
3273 );
3274 }
3275
3276 assert_eq!(layout_invocations(), 1);
3277 }
3278
3279 #[test]
3280 fn document_mutation_invalidates_cached_layout() {
3281 let mut doc = Document::new();
3282 doc.add_paragraph("Before mutation");
3283
3284 reset_layout_invocations();
3285 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3286 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3287 assert_eq!(layout_invocations(), 1);
3288
3289 doc.add_paragraph("After mutation");
3290 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3291 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3292 assert_eq!(layout_invocations(), 2);
3293 }
3294
3295 #[test]
3296 fn mutable_accessor_invalidates_cached_layout() {
3297 let mut doc = Document::new();
3298 doc.add_paragraph("Before wrapper mutation");
3299
3300 reset_layout_invocations();
3301 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3302 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3303 assert_eq!(layout_invocations(), 1);
3304
3305 doc.paragraph_mut(0)
3306 .expect("paragraph should exist")
3307 .add_run(" changed");
3308 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3309 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3310 assert_eq!(layout_invocations(), 2);
3311
3312 let mut table = doc.add_table(1, 1);
3313 table
3314 .cell(0, 0)
3315 .expect("cell should exist")
3316 .set_text("table mutation");
3317 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3318 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3319 assert_eq!(layout_invocations(), 3);
3320 }
3321
3322 #[test]
3323 fn immutable_run_accessors_preserve_cached_layout() {
3324 let mut doc = Document::new();
3325 doc.add_paragraph("Before immutable access")
3326 .add_run(" remains cached");
3327
3328 reset_layout_invocations();
3329 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3330 assert_eq!(layout_invocations(), 1);
3331
3332 let paragraph = doc.paragraph(0).expect("paragraph should exist");
3333 assert_eq!(paragraph.run_count(), 2);
3334 assert_eq!(
3335 paragraph.run(1).expect("run should exist").text(),
3336 " remains cached"
3337 );
3338 assert!(paragraph.run(2).is_none());
3339
3340 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3341 assert_eq!(layout_invocations(), 1);
3342 }
3343
3344 #[test]
3345 fn font_modes_use_isolated_layout_caches() {
3346 let mut doc = Document::new();
3347 doc.add_paragraph("Font mode isolation");
3348
3349 reset_layout_invocations();
3350 doc.render_page_to_png(0, 1.0).unwrap();
3351 doc.render_page_to_png(0, 1.0).unwrap();
3352 assert!(doc.layout_page(0).unwrap().is_some());
3353 assert!(doc.layout_page(usize::MAX).unwrap().is_none());
3354 assert_eq!(doc.render_all_pages(1.0).unwrap().len(), 1);
3355 assert!(!doc.to_pdf().unwrap().is_empty());
3356 assert_eq!(layout_invocations(), 1);
3357
3358 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3359 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3360 assert_eq!(layout_invocations(), 2);
3361
3362 doc.render_page_to_png(0, 1.0).unwrap();
3363 doc.render_page_to_png_deterministic(0, 1.0).unwrap();
3364 assert_eq!(layout_invocations(), 2);
3365
3366 let (family, font_data) = oxml_layout::bundled_fonts::bundled_font_data()[0];
3367 doc.to_pdf_with_fonts(&[(family, font_data)]).unwrap();
3368 doc.to_pdf_with_fonts(&[(family, font_data)]).unwrap();
3369 assert_eq!(layout_invocations(), 4);
3370 }
3371
3372 #[test]
3373 fn document_remains_send_and_sync() {
3374 fn assert_send_and_sync<T: Send + Sync>() {}
3375 assert_send_and_sync::<Document>();
3376 }
3377
3378 #[test]
3379 fn html_and_layout_media_use_sniffed_content_type() {
3380 let jpeg = [0xff, 0xd8, 0xff, 0xd9];
3381 let mut document = Document::new();
3382 document
3383 .package
3384 .set_part("/word/media/misleading.png", jpeg.to_vec());
3385 let relationship_id = document
3386 .package
3387 .get_or_create_part_rels("/word/document.xml")
3388 .add(rel_types::IMAGE, "media/misleading.png");
3389
3390 let html_input = document.build_html_input();
3391 let layout_input = document.build_layout_input();
3392
3393 assert_eq!(
3394 html_input.images[&relationship_id].content_type,
3395 "image/jpeg"
3396 );
3397 assert_eq!(
3398 layout_input.images[&relationship_id].content_type,
3399 "image/jpeg"
3400 );
3401 }
3402
3403 #[test]
3404 fn deterministic_render_is_independent_of_system_fonts() {
3405 let mut doc = Document::new();
3406 doc.add_paragraph("Deterministic rendering");
3407
3408 let input = doc.build_layout_input();
3409 let layout = rdocx_layout::layout_document_deterministic(&input)
3410 .expect("deterministic layout should succeed");
3411 let bundled_fonts = oxml_layout::bundled_fonts::bundled_font_data();
3412
3413 assert!(!layout.fonts.is_empty());
3414 for font in &layout.fonts {
3415 assert!(!font.data.is_empty());
3416 assert!(
3417 bundled_fonts
3418 .iter()
3419 .any(|(_family, data)| *data == font.data.as_slice()),
3420 "resolved font '{}' did not come from the bundled font set",
3421 font.family
3422 );
3423 }
3424
3425 let inspected = oxml_pdf::render_page_to_png(&layout, 0, 150.0)
3426 .expect("document should have a first page");
3427 let facade = doc
3428 .render_page_to_png_deterministic(0, 150.0)
3429 .expect("deterministic layout should succeed")
3430 .expect("document should have a first page");
3431
3432 assert!(!inspected.is_empty());
3433 assert_eq!(facade, inspected);
3434 }
3435
3436 #[test]
3437 fn deterministic_pdf_facade_reuses_bundled_font_layout() {
3438 let mut doc = Document::new();
3439 doc.add_paragraph("Deterministic PDF rendering");
3440
3441 reset_layout_invocations();
3442 let first = doc
3443 .to_pdf_deterministic()
3444 .expect("deterministic PDF rendering should succeed");
3445 let second = doc
3446 .to_pdf_deterministic()
3447 .expect("cached deterministic PDF rendering should succeed");
3448
3449 assert!(first.starts_with(b"%PDF-"));
3450 assert!(second.starts_with(b"%PDF-"));
3451 assert_eq!(layout_invocations(), 1);
3452 }
3453
3454 #[test]
3455 fn create_new_document() {
3456 let doc = Document::new();
3457 assert_eq!(doc.paragraph_count(), 0);
3458 assert!(doc.section_properties().is_some());
3459 }
3460
3461 #[test]
3462 fn add_paragraphs() {
3463 let mut doc = Document::new();
3464 doc.add_paragraph("First paragraph");
3465 doc.add_paragraph("Second paragraph");
3466 assert_eq!(doc.paragraph_count(), 2);
3467
3468 let paras = doc.paragraphs();
3469 assert_eq!(paras[0].text(), "First paragraph");
3470 assert_eq!(paras[1].text(), "Second paragraph");
3471 }
3472
3473 #[test]
3474 fn document_text_preserves_body_and_table_order() {
3475 let mut doc = Document::new();
3476 doc.add_paragraph("Before");
3477 let mut table = doc.add_table(1, 2);
3478 table.cell(0, 0).unwrap().set_text("Left");
3479 table.cell(0, 1).unwrap().set_text("Right");
3480 doc.add_paragraph("After");
3481
3482 assert_eq!(doc.text(), "Before\nLeft\tRight\t\nAfter\n");
3483 }
3484
3485 #[test]
3486 fn paragraph_formatting() {
3487 let mut doc = Document::new();
3488 doc.add_paragraph("Centered").alignment(Alignment::Center);
3489
3490 let paras = doc.paragraphs();
3491 assert_eq!(paras[0].alignment(), Some(Alignment::Center));
3492 }
3493
3494 #[test]
3495 fn run_formatting() {
3496 let mut doc = Document::new();
3497 let mut para = doc.add_paragraph("");
3498 para.add_run("Bold text").bold(true).size(14.0);
3499
3500 let paras = doc.paragraphs();
3501 let runs: Vec<_> = paras[0].runs().collect();
3502 assert!(runs[0].is_bold());
3503 assert_eq!(runs[0].size(), Some(14.0));
3504 }
3505
3506 #[test]
3507 fn round_trip_in_memory() {
3508 let mut doc = Document::new();
3509 doc.add_paragraph("Hello, World!");
3510 doc.add_paragraph("Second paragraph")
3511 .alignment(Alignment::Center);
3512
3513 let bytes = doc.to_bytes().unwrap();
3514 let doc2 = Document::from_bytes(&bytes).unwrap();
3515
3516 assert_eq!(doc2.paragraph_count(), 2);
3517 let paras = doc2.paragraphs();
3518 assert_eq!(paras[0].text(), "Hello, World!");
3519 assert_eq!(paras[1].text(), "Second paragraph");
3520 assert_eq!(paras[1].alignment(), Some(Alignment::Center));
3521 }
3522
3523 #[test]
3524 fn styles_present() {
3525 let doc = Document::new();
3526 assert!(doc.style("Normal").is_some());
3527 assert!(doc.style("Heading1").is_some());
3528 }
3529
3530 #[test]
3531 fn paragraph_with_style() {
3532 let mut doc = Document::new();
3533 doc.add_paragraph("Title").style("Heading1");
3534
3535 let paras = doc.paragraphs();
3536 assert_eq!(paras[0].style_id(), Some("Heading1"));
3537 }
3538
3539 #[test]
3540 fn multiple_runs_in_paragraph() {
3541 let mut doc = Document::new();
3542 let mut para = doc.add_paragraph("");
3543 para.add_run("Normal ");
3544 para.add_run("bold ").bold(true);
3545 para.add_run("italic").italic(true);
3546
3547 let paras = doc.paragraphs();
3548 assert_eq!(paras[0].text(), "Normal bold italic");
3549 let runs: Vec<_> = paras[0].runs().collect();
3550 assert_eq!(runs.len(), 3);
3551 assert!(!runs[0].is_bold());
3552 assert!(runs[1].is_bold());
3553 assert!(runs[2].is_italic());
3554 }
3555
3556 #[test]
3557 fn add_custom_style() {
3558 let mut doc = Document::new();
3559 doc.add_style(StyleBuilder::paragraph("MyCustom", "My Custom Style").based_on("Normal"));
3560 assert!(doc.style("MyCustom").is_some());
3561 let s = doc.style("MyCustom").unwrap();
3562 assert_eq!(s.name(), Some("My Custom Style"));
3563 assert_eq!(s.based_on(), Some("Normal"));
3564 }
3565
3566 #[test]
3567 fn resolve_style_properties() {
3568 let doc = Document::new();
3569 let ppr = doc.resolve_paragraph_properties(Some("Heading1"));
3571 assert_eq!(ppr.keep_next, Some(true));
3572 assert_eq!(ppr.space_before, Some(Twips(240)));
3573
3574 let ppr = doc.resolve_paragraph_properties(None);
3576 assert_eq!(ppr.space_after, Some(Twips(160)));
3577 }
3578
3579 #[test]
3580 fn resolve_run_style_properties() {
3581 let doc = Document::new();
3582 let rpr = doc.resolve_run_properties(Some("Heading1"), None);
3583 assert_eq!(rpr.bold, Some(true));
3584 assert_eq!(rpr.sz, Some(HalfPoint(32)));
3585 assert_eq!(rpr.font_ascii, Some("Calibri".to_string()));
3586 }
3587
3588 #[test]
3589 fn set_landscape() {
3590 let mut doc = Document::new();
3591 doc.set_landscape();
3592 let sect = doc.section_properties().unwrap();
3593 assert_eq!(sect.orientation, Some(ST_PageOrientation::Landscape));
3594 assert!(sect.page_width.unwrap().0 > sect.page_height.unwrap().0);
3596 }
3597
3598 #[test]
3599 fn set_margins() {
3600 let mut doc = Document::new();
3601 doc.set_margins(
3602 Length::inches(0.5),
3603 Length::inches(0.75),
3604 Length::inches(0.5),
3605 Length::inches(0.75),
3606 );
3607 let sect = doc.section_properties().unwrap();
3608 assert_eq!(sect.margin_top, Some(Twips(720)));
3609 assert_eq!(sect.margin_right, Some(Twips(1080)));
3610 }
3611
3612 #[test]
3613 fn set_columns() {
3614 let mut doc = Document::new();
3615 doc.set_columns(2, Length::inches(0.5));
3616 let sect = doc.section_properties().unwrap();
3617 let cols = sect.columns.as_ref().unwrap();
3618 assert_eq!(cols.num, Some(2));
3619 assert_eq!(cols.space, Some(Twips(720)));
3620 assert_eq!(cols.equal_width, Some(true));
3621 }
3622
3623 #[test]
3624 fn set_page_size() {
3625 let mut doc = Document::new();
3626 doc.set_page_size(Length::cm(21.0), Length::cm(29.7));
3627 let sect = doc.section_properties().unwrap();
3628 let w = sect.page_width.unwrap().0;
3630 let h = sect.page_height.unwrap().0;
3631 assert!((w - 11906).abs() < 5);
3632 assert!((h - 16838).abs() < 5);
3633 }
3634
3635 #[test]
3636 fn set_different_first_page() {
3637 let mut doc = Document::new();
3638 doc.set_different_first_page(true);
3639 assert_eq!(doc.section_properties().unwrap().title_pg, Some(true));
3640 }
3641
3642 #[test]
3643 fn content_insertion_api() {
3644 let mut doc = Document::new();
3645 doc.add_paragraph("First");
3646 doc.add_paragraph("Third");
3647
3648 doc.insert_paragraph(1, "Second");
3650 assert_eq!(doc.content_count(), 3);
3651 let paras = doc.paragraphs();
3652 assert_eq!(paras[0].text(), "First");
3653 assert_eq!(paras[1].text(), "Second");
3654 assert_eq!(paras[2].text(), "Third");
3655
3656 doc.insert_paragraph(0, "Zeroth");
3658 assert_eq!(doc.content_count(), 4);
3659 assert_eq!(doc.paragraphs()[0].text(), "Zeroth");
3660 }
3661
3662 #[test]
3663 fn find_content_index_and_remove() {
3664 let mut doc = Document::new();
3665 doc.add_paragraph("Hello");
3666 doc.add_paragraph("{{PLACEHOLDER}}");
3667 doc.add_paragraph("World");
3668
3669 assert_eq!(doc.find_content_index("{{PLACEHOLDER}}"), Some(1));
3670 assert_eq!(doc.find_content_index("NONEXISTENT"), None);
3671
3672 assert!(doc.remove_content(1));
3673 assert_eq!(doc.content_count(), 2);
3674 assert_eq!(doc.paragraphs()[1].text(), "World");
3675
3676 assert!(!doc.remove_content(10));
3678 }
3679
3680 #[test]
3681 fn insert_table_at_index() {
3682 let mut doc = Document::new();
3683 doc.add_paragraph("Before");
3684 doc.add_paragraph("After");
3685
3686 doc.insert_table(1, 2, 3);
3687 assert_eq!(doc.content_count(), 3);
3688 assert_eq!(doc.table_count(), 1);
3689 let paras = doc.paragraphs();
3691 assert_eq!(paras[0].text(), "Before");
3692 assert_eq!(paras[1].text(), "After");
3693 }
3694
3695 #[test]
3696 fn replace_text_in_body() {
3697 let mut doc = Document::new();
3698 doc.add_paragraph("Hello {{name}}!");
3699 doc.add_paragraph("Welcome to {{company}}.");
3700
3701 let count = doc.replace_text("{{name}}", "Alice");
3702 assert_eq!(count, 1);
3703 assert_eq!(doc.paragraphs()[0].text(), "Hello Alice!");
3704
3705 let count = doc.replace_text("{{company}}", "Acme");
3706 assert_eq!(count, 1);
3707 assert_eq!(doc.paragraphs()[1].text(), "Welcome to Acme.");
3708 }
3709
3710 #[test]
3711 fn replace_text_in_header_and_footer() {
3712 let mut doc = Document::new();
3713 doc.set_header("Header: {{title}}");
3714 doc.set_footer("Footer: {{title}}");
3715 doc.add_paragraph("Body: {{title}}");
3716
3717 let count = doc.replace_text("{{title}}", "My Doc");
3718 assert_eq!(count, 3);
3719
3720 assert_eq!(doc.paragraphs()[0].text(), "Body: My Doc");
3721 assert_eq!(doc.header_text().unwrap(), "Header: My Doc");
3722 assert_eq!(doc.footer_text().unwrap(), "Footer: My Doc");
3723 }
3724
3725 #[test]
3726 fn replace_all_batch() {
3727 let mut doc = Document::new();
3728 doc.add_paragraph("{{a}} and {{b}}");
3729
3730 let mut map = std::collections::HashMap::new();
3731 map.insert("{{a}}", "X");
3732 map.insert("{{b}}", "Y");
3733 let count = doc.replace_all(&map);
3734 assert_eq!(count, 2);
3735 assert_eq!(doc.paragraphs()[0].text(), "X and Y");
3736 }
3737
3738 #[test]
3739 fn template_workflow_round_trip() {
3740 let mut doc = Document::new();
3741 doc.add_paragraph("Company: {{company}}");
3742 doc.add_paragraph("Date: {{date}}");
3743
3744 doc.replace_text("{{company}}", "Acme Corp");
3745 doc.replace_text("{{date}}", "2026-02-22");
3746
3747 let bytes = doc.to_bytes().unwrap();
3749 let doc2 = Document::from_bytes(&bytes).unwrap();
3750 assert_eq!(doc2.paragraphs()[0].text(), "Company: Acme Corp");
3751 assert_eq!(doc2.paragraphs()[1].text(), "Date: 2026-02-22");
3752 }
3753
3754 #[test]
3755 fn add_background_image_round_trip() {
3756 let png_data: Vec<u8> = vec![
3758 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,
3762 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
3764 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
3766 ];
3767
3768 let mut doc = Document::new();
3769 doc.add_paragraph("Hello World");
3770 doc.add_background_image(&png_data, "bg.png");
3771
3772 assert_eq!(doc.content_count(), 2);
3774
3775 let bytes = doc.to_bytes().unwrap();
3777 let doc2 = Document::from_bytes(&bytes).unwrap();
3778
3779 assert_eq!(doc2.content_count(), 2);
3781 assert_eq!(doc2.paragraphs().last().unwrap().text(), "Hello World");
3783 }
3784
3785 #[test]
3786 fn add_anchored_image() {
3787 let png_data: Vec<u8> = vec![
3788 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
3789 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
3790 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08,
3791 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
3792 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
3793 ];
3794
3795 let mut doc = Document::new();
3796 doc.add_paragraph("Content");
3797 doc.add_anchored_image(
3798 &png_data,
3799 "overlay.png",
3800 Length::inches(4.0),
3801 Length::inches(3.0),
3802 false,
3803 );
3804 assert_eq!(doc.content_count(), 2);
3805 }
3806
3807 #[test]
3808 fn insert_toc_basic() {
3809 let mut doc = Document::new();
3810 doc.add_paragraph("Introduction");
3811 doc.add_paragraph("Chapter 1").style("Heading1");
3812 doc.add_paragraph("Some text in chapter 1.");
3813 doc.add_paragraph("Section 1.1").style("Heading2");
3814 doc.add_paragraph("Text in section 1.1.");
3815 doc.add_paragraph("Chapter 2").style("Heading1");
3816 doc.add_paragraph("Text in chapter 2.");
3817
3818 assert_eq!(doc.content_count(), 7);
3820
3821 doc.insert_toc(0, 2);
3823
3824 assert_eq!(doc.content_count(), 11);
3826
3827 let paras = doc.paragraphs();
3829 assert_eq!(paras[0].text(), "Table of Contents");
3830
3831 assert_eq!(paras[1].text(), "Chapter 1\t");
3833 assert_eq!(paras[2].text(), "Section 1.1\t");
3834 assert_eq!(paras[3].text(), "Chapter 2\t");
3835
3836 let bytes = doc.to_bytes().expect("should serialize");
3838 let doc2 = Document::from_bytes(&bytes).expect("should open");
3839 assert_eq!(doc2.content_count(), 11);
3840 let paras2 = doc2.paragraphs();
3841 assert_eq!(paras2[0].text(), "Table of Contents");
3842 }
3843
3844 #[test]
3845 fn append_documents() {
3846 let mut doc_a = Document::new();
3847 doc_a.add_paragraph("Paragraph A1");
3848 doc_a.add_paragraph("Paragraph A2");
3849
3850 let mut doc_b = Document::new();
3851 doc_b.add_paragraph("Paragraph B1");
3852 doc_b.add_paragraph("Paragraph B2");
3853 doc_b.add_paragraph("Paragraph B3");
3854
3855 assert_eq!(doc_a.content_count(), 2);
3856 doc_a.append(&doc_b);
3857 assert_eq!(doc_a.content_count(), 5);
3858
3859 let paras = doc_a.paragraphs();
3860 assert_eq!(paras[0].text(), "Paragraph A1");
3861 assert_eq!(paras[1].text(), "Paragraph A2");
3862 assert_eq!(paras[2].text(), "Paragraph B1");
3863 assert_eq!(paras[3].text(), "Paragraph B2");
3864 assert_eq!(paras[4].text(), "Paragraph B3");
3865
3866 let bytes = doc_a.to_bytes().expect("serialize");
3868 let reopened = Document::from_bytes(&bytes).expect("open");
3869 assert_eq!(reopened.content_count(), 5);
3870 }
3871
3872 #[test]
3873 fn append_with_section_break() {
3874 let mut doc_a = Document::new();
3875 doc_a.add_paragraph("A1");
3876
3877 let mut doc_b = Document::new();
3878 doc_b.add_paragraph("B1");
3879
3880 doc_a.append_with_break(&doc_b, crate::SectionBreak::Continuous);
3881 assert_eq!(doc_a.content_count(), 3);
3883 }
3884
3885 #[test]
3886 fn insert_document_at_index() {
3887 let mut doc_a = Document::new();
3888 doc_a.add_paragraph("First");
3889 doc_a.add_paragraph("Last");
3890
3891 let mut doc_b = Document::new();
3892 doc_b.add_paragraph("Middle 1");
3893 doc_b.add_paragraph("Middle 2");
3894
3895 doc_a.insert_document(1, &doc_b);
3896 assert_eq!(doc_a.content_count(), 4);
3897
3898 let paras = doc_a.paragraphs();
3899 assert_eq!(paras[0].text(), "First");
3900 assert_eq!(paras[1].text(), "Middle 1");
3901 assert_eq!(paras[2].text(), "Middle 2");
3902 assert_eq!(paras[3].text(), "Last");
3903 }
3904
3905 #[test]
3906 fn merge_deduplicates_styles() {
3907 let mut doc_a = Document::new();
3908 doc_a.add_paragraph("A").style("Heading1");
3909
3910 let mut doc_b = Document::new();
3911 doc_b.add_paragraph("B").style("Heading1");
3912 doc_b.add_style(
3913 crate::style::StyleBuilder::paragraph("CustomB", "Custom B").based_on("Normal"),
3914 );
3915 doc_b.add_paragraph("C").style("CustomB");
3916
3917 let styles_before = doc_a.styles.styles.len();
3918 doc_a.append(&doc_b);
3919 let styles_after = doc_a.styles.styles.len();
3920
3921 assert_eq!(styles_after, styles_before + 1);
3923 }
3924
3925 #[test]
3926 fn headings_and_outline() {
3927 let mut doc = Document::new();
3928 doc.add_paragraph("Intro");
3929 doc.add_paragraph("Chapter 1").style("Heading1");
3930 doc.add_paragraph("Section 1.1").style("Heading2");
3931 doc.add_paragraph("Section 1.2").style("Heading2");
3932 doc.add_paragraph("Chapter 2").style("Heading1");
3933 doc.add_paragraph("Section 2.1").style("Heading2");
3934 doc.add_paragraph("Sub 2.1.1").style("Heading3");
3935
3936 let headings = doc.headings();
3937 assert_eq!(headings.len(), 6);
3938 assert_eq!(headings[0], (1, "Chapter 1".to_string()));
3939 assert_eq!(headings[1], (2, "Section 1.1".to_string()));
3940 assert_eq!(headings[5], (3, "Sub 2.1.1".to_string()));
3941
3942 let outline = doc.document_outline();
3943 assert_eq!(outline.len(), 2); assert_eq!(outline[0].text, "Chapter 1");
3945 assert_eq!(outline[0].children.len(), 2); assert_eq!(outline[1].text, "Chapter 2");
3947 assert_eq!(outline[1].children.len(), 1); assert_eq!(outline[1].children[0].children.len(), 1); }
3950
3951 #[test]
3952 fn word_count_basic() {
3953 let mut doc = Document::new();
3954 doc.add_paragraph("Hello world");
3955 doc.add_paragraph("Three more words");
3956 assert_eq!(doc.word_count(), 5);
3957 }
3958
3959 #[test]
3960 fn audit_accessibility_missing_metadata() {
3961 let doc = Document::new();
3962 let issues = doc.audit_accessibility();
3963 assert!(issues.iter().any(|i| i.message.contains("no title")));
3965 assert!(issues.iter().any(|i| i.message.contains("no author")));
3966 }
3967
3968 #[test]
3969 fn audit_heading_level_gap() {
3970 let mut doc = Document::new();
3971 doc.set_title("Test");
3972 doc.set_author("Test");
3973 doc.add_paragraph("Ch 1").style("Heading1");
3974 doc.add_paragraph("Skip to 3").style("Heading3");
3975
3976 let issues = doc.audit_accessibility();
3977 assert!(
3978 issues
3979 .iter()
3980 .any(|i| i.message.contains("Heading level gap"))
3981 );
3982 }
3983
3984 #[test]
3985 fn links_returns_empty_for_no_hyperlinks() {
3986 let mut doc = Document::new();
3987 doc.add_paragraph("No links here.");
3988 assert!(doc.links().is_empty());
3989 }
3990
3991 #[test]
3992 fn images_returns_empty_for_text_only() {
3993 let mut doc = Document::new();
3994 doc.add_paragraph("Just text.");
3995 assert!(doc.images().is_empty());
3996 }
3997
3998 #[test]
3999 fn numbering_getter_round_trips() {
4000 let mut doc = Document::new();
4001 doc.add_bullet_list_item("bullet item", 0);
4002 doc.add_numbered_list_item("numbered item", 0);
4003 doc.add_paragraph("plain");
4004
4005 let bytes = doc.to_bytes().unwrap();
4006 let doc2 = Document::from_bytes(&bytes).unwrap();
4007 let paras = doc2.paragraphs();
4008
4009 let (bullet_id, bullet_lvl) = paras[0].numbering().expect("bullet numbering");
4010 assert_eq!(bullet_lvl, 0);
4011 assert_eq!(doc2.numbering_is_bullet(bullet_id), Some(true));
4012
4013 let (num_id, _) = paras[1].numbering().expect("numbered numbering");
4014 assert_eq!(doc2.numbering_is_bullet(num_id), Some(false));
4015
4016 assert!(paras[2].numbering().is_none());
4017 }
4018
4019 #[test]
4020 fn highlight_getter_round_trips() {
4021 let mut doc = Document::new();
4022 {
4023 let mut p = doc.add_paragraph("");
4024 let mut r = p.add_run("glowing");
4025 r = r.highlight("yellow");
4026 let _ = r;
4027 }
4028
4029 let bytes = doc.to_bytes().unwrap();
4030 let doc2 = Document::from_bytes(&bytes).unwrap();
4031 let paras = doc2.paragraphs();
4032 let run = paras[0].runs().next().expect("run");
4033 assert_eq!(run.highlight().as_deref(), Some("yellow"));
4034 }
4035
4036 #[test]
4037 fn run_style_id_getter_round_trips() {
4038 let mut doc = Document::new();
4039 {
4040 let mut p = doc.add_paragraph("");
4041 let mut r = p.add_run("code text");
4042 r = r.style("SourceText");
4043 let _ = r;
4044 }
4045
4046 let bytes = doc.to_bytes().unwrap();
4047 let doc2 = Document::from_bytes(&bytes).unwrap();
4048 let paras = doc2.paragraphs();
4049 let run = paras[0].runs().next().expect("run");
4050 assert_eq!(run.style_id(), Some("SourceText"));
4051 }
4052
4053 #[test]
4054 fn append_hyperlink_round_trips() {
4055 let mut doc = Document::new();
4056 doc.add_paragraph("visit ");
4057 doc.append_hyperlink("GNOME", "https://gnome.org");
4058
4059 let bytes = doc.to_bytes().unwrap();
4060 let doc2 = Document::from_bytes(&bytes).unwrap();
4061
4062 let links = doc2.links();
4063 assert_eq!(links.len(), 1);
4064 assert_eq!(links[0].text, "GNOME");
4065 assert_eq!(links[0].url.as_deref(), Some("https://gnome.org"));
4066 assert_eq!(doc2.paragraphs()[0].text(), "visit GNOME");
4067
4068 let paras = doc2.paragraphs();
4069 let spans = paras[0].hyperlink_spans();
4070 assert_eq!(spans.len(), 1);
4071 let (start, end, rel_id) = (spans[0].0, spans[0].1, spans[0].2);
4072 assert_eq!(end - start, 1);
4073 let url = doc2.hyperlink_url(rel_id.expect("rel id"));
4074 assert_eq!(url.as_deref(), Some("https://gnome.org"));
4075 }
4076
4077 #[test]
4078 fn paragraph_hard_break_and_table_cell_hyperlink_round_trip() {
4079 let mut doc = Document::new();
4080 let relationship_id = doc.add_hyperlink_relationship("https://example.com/table");
4081
4082 let mut paragraph = doc.add_paragraph("");
4083 paragraph.add_run("before");
4084 paragraph.add_line_break();
4085 paragraph.add_run("after");
4086
4087 let mut table = doc.add_table(1, 1);
4088 let mut cell = table.cell(0, 0).expect("cell");
4089 cell.remove_first_empty_paragraph();
4090 cell.add_paragraph("")
4091 .add_hyperlink("table link", &relationship_id)
4092 .bold(true);
4093
4094 let bytes = doc.to_bytes().unwrap();
4095 let reopened = Document::from_bytes(&bytes).unwrap();
4096
4097 assert_eq!(reopened.paragraphs()[0].text(), "before\nafter");
4098 let tables = reopened.tables();
4099 let cell = tables[0].cell(0, 0).expect("cell");
4100 let paragraph = cell.paragraphs().next().expect("paragraph");
4101 assert_eq!(paragraph.text(), "table link");
4102 assert!(paragraph.runs().next().expect("run").is_bold());
4103 let spans = paragraph.hyperlink_spans();
4104 assert_eq!(spans.len(), 1);
4105 assert_eq!(
4106 reopened.hyperlink_url(spans[0].2.expect("relationship id")),
4107 Some("https://example.com/table".to_string())
4108 );
4109 }
4110
4111 #[test]
4112 fn rejected_list_level_update_does_not_materialize_numbering() {
4113 let mut doc = Document::new();
4114 assert!(doc.numbering.is_none());
4115
4116 assert!(!doc.set_list_level(999, 1, ListLevel::decimal()));
4117
4118 assert!(
4119 doc.numbering.is_none(),
4120 "a rejected setter must not add an empty numbering part"
4121 );
4122 }
4123
4124 #[test]
4125 fn custom_list_and_paragraph_numbering_enforce_the_nine_level_contract() {
4126 let mut doc = Document::new();
4127 let levels = vec![ListLevel::decimal(); 10];
4128 let num_id = doc.add_list_definition(&levels);
4129 assert_eq!(
4130 doc.numbering.as_ref().unwrap().abstract_nums[0]
4131 .levels
4132 .len(),
4133 9
4134 );
4135
4136 let mut paragraph = doc.add_paragraph("item");
4137 assert!(!paragraph.set_numbering(num_id, 9));
4138 assert_eq!(
4139 paragraph.inner.properties.as_ref().and_then(|p| p.num_id),
4140 None
4141 );
4142 assert!(paragraph.set_numbering(num_id, 8));
4143 assert_eq!(
4144 paragraph.inner.properties.as_ref().unwrap().num_ilvl,
4145 Some(8)
4146 );
4147 }
4148
4149 #[test]
4150 fn picture_round_trips() {
4151 let png: &[u8] = &[
4153 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
4154 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
4155 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
4156 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x9E, 0xDD, 0x22,
4157 0x71, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
4158 ];
4159 let mut doc = Document::new();
4160 doc.add_paragraph("before");
4161 doc.add_picture(png, "dot.png", Length::inches(1.0), Length::inches(1.0));
4162
4163 let bytes = doc.to_bytes().unwrap();
4164 let doc2 = Document::from_bytes(&bytes).unwrap();
4165 let paras = doc2.paragraphs();
4166 let mut found = None;
4167 for p in ¶s {
4168 for r in p.runs() {
4169 if let Some((rel, _alt)) = r.inline_image() {
4170 found = Some(rel.to_string());
4171 }
4172 }
4173 }
4174 let rel = found.expect("no inline image found on read");
4175 let data = doc2.image_data(&rel).expect("image bytes missing");
4176 assert_eq!(data, png);
4177 }
4178
4179 #[test]
4180 fn layout_resolves_relationship_images_to_shared_media() {
4181 let png: &[u8] = &[
4182 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
4183 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
4184 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
4185 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x9E, 0xDD, 0x22,
4186 0x71, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
4187 ];
4188 let mut document = Document::new();
4189 document.add_picture(png, "first.png", Length::inches(1.0), Length::inches(1.0));
4190 document.add_picture(png, "second.png", Length::inches(1.0), Length::inches(1.0));
4191
4192 let page = document
4193 .layout_page(0)
4194 .expect("layout should succeed")
4195 .expect("document should have a first page");
4196 let images = page
4197 .elements
4198 .iter()
4199 .filter_map(|element| match element {
4200 oxml_layout::PositionedElement::Image {
4201 data,
4202 content_type,
4203 media_id,
4204 ..
4205 } => Some((data, content_type, media_id)),
4206 _ => None,
4207 })
4208 .collect::<Vec<_>>();
4209
4210 assert_eq!(images.len(), 2);
4211 assert!(images.iter().all(|(data, _, _)| data.as_slice() == png));
4212 assert!(
4213 images
4214 .iter()
4215 .all(|(_, content_type, _)| *content_type == "image/png")
4216 );
4217 assert_eq!(*images[0].2, oxml_layout::MediaId::from_bytes(png));
4218 assert_eq!(images[0].2, images[1].2);
4219 }
4220}
4221
4222#[cfg(test)]
4223mod hyperlink_span_tests {
4224 use super::*;
4225 use rdocx_oxml::text::HyperlinkSpan;
4226
4227 #[test]
4231 fn links_clamps_out_of_range_spans() {
4232 let mut doc = Document::new();
4233 {
4234 let mut para = doc.add_paragraph("");
4235 para.add_run("one");
4236 para.add_run("two");
4237 }
4238
4239 let BodyContent::Paragraph(p) = &mut doc.document.body.content[0] else {
4240 unreachable!("just added a paragraph")
4241 };
4242 p.hyperlinks.push(HyperlinkSpan {
4243 rel_id: None,
4244 anchor: Some("bookmark".to_string()),
4245 run_start: 1,
4246 run_end: 99,
4247 });
4248 p.hyperlinks.push(HyperlinkSpan {
4249 rel_id: None,
4250 anchor: Some("inverted".to_string()),
4251 run_start: 5,
4252 run_end: 1,
4253 });
4254
4255 let links = doc.links();
4256
4257 assert_eq!(links.len(), 2);
4258 assert_eq!(links[0].text, "two");
4259 assert_eq!(links[1].text, "");
4260 }
4261}
4262
4263#[cfg(test)]
4264mod odttf_tests {
4265 use super::*;
4266
4267 fn fake_font() -> Vec<u8> {
4269 let mut data = Vec::new();
4270 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)] {
4276 data.extend(tag); data.extend(0u32.to_be_bytes()); data.extend(offset.to_be_bytes());
4279 data.extend(length.to_be_bytes());
4280 }
4281 data.extend((0u8..64).map(|i| i.wrapping_mul(7)));
4282 data
4283 }
4284
4285 fn obfuscate(font: &[u8], key: &[u8; 16]) -> Vec<u8> {
4286 let mut out = font.to_vec();
4287 for (i, byte) in out.iter_mut().take(32).enumerate() {
4288 *byte ^= key[i % 16];
4289 }
4290 out
4291 }
4292
4293 const GUID_HEX: &str = "00112233445566778899AABBCCDDEEFF";
4294
4295 fn guid_bytes() -> [u8; 16] {
4296 let mut g = [0u8; 16];
4297 for (i, b) in g.iter_mut().enumerate() {
4298 *b = u8::from_str_radix(&GUID_HEX[i * 2..i * 2 + 2], 16).unwrap();
4299 }
4300 g
4301 }
4302
4303 #[test]
4304 fn recovers_font_under_either_key_convention() {
4305 let font = fake_font();
4306 let name = format!("{GUID_HEX}.odttf");
4307 for key in odttf_key_candidates(&guid_bytes()) {
4308 let obfuscated = obfuscate(&font, &key);
4309 assert_eq!(
4310 deobfuscate_odttf(&obfuscated, &name).as_deref(),
4311 Some(font.as_slice()),
4312 "failed to recover font for key {key:02x?}",
4313 );
4314 }
4315 }
4316
4317 #[test]
4318 fn rejects_data_that_does_not_decode_to_a_font() {
4319 let junk = vec![0xAB; 64];
4321 let name = format!("{GUID_HEX}.odttf");
4322 assert_eq!(deobfuscate_odttf(&junk, &name), None);
4323 }
4324
4325 #[test]
4326 fn rejects_short_or_malformed_input() {
4327 assert_eq!(deobfuscate_odttf(&[0u8; 8], "abc.odttf"), None);
4328 assert_eq!(deobfuscate_odttf(&[0u8; 64], "not-a-guid.odttf"), None);
4329 }
4330
4331 #[test]
4332 fn accepts_braced_and_hyphenated_names() {
4333 let font = fake_font();
4334 let key = odttf_key_candidates(&guid_bytes())[0];
4335 let obfuscated = obfuscate(&font, &key);
4336 for name in [
4337 "{00112233-4455-6677-8899-AABBCCDDEEFF}.odttf",
4338 "00112233-4455-6677-8899-AABBCCDDEEFF.odttf",
4339 ] {
4340 assert_eq!(
4341 deobfuscate_odttf(&obfuscated, name).as_deref(),
4342 Some(font.as_slice()),
4343 "failed for {name}"
4344 );
4345 }
4346 }
4347}