Skip to main content

rdocx/
document.rs

1//! The main Document type — entry point for the rdocx API.
2
3use std::path::Path;
4
5use rdocx_opc::OpcPackage;
6use rdocx_opc::relationship::rel_types;
7use rdocx_oxml::document::{BodyContent, CT_Columns, CT_Document, CT_SectPr};
8use rdocx_oxml::drawing::{CT_Anchor, CT_Drawing, CT_Inline};
9use rdocx_oxml::header_footer::{CT_HdrFtr, HdrFtrRef, HdrFtrType};
10use rdocx_oxml::numbering::CT_Numbering;
11use rdocx_oxml::properties::{CT_PPr, CT_RPr};
12use rdocx_oxml::shared::{ST_PageOrientation, ST_SectionType};
13use rdocx_oxml::styles::CT_Styles;
14use rdocx_oxml::table::CT_Tbl;
15use rdocx_oxml::text::{CT_P, CT_R, RunContent};
16
17use rdocx_oxml::core_properties::CoreProperties;
18
19use crate::Length;
20use crate::error::{Error, Result};
21use crate::paragraph::{Paragraph, ParagraphRef};
22use crate::style::{self, Style, StyleBuilder};
23use crate::table::{Table, TableRef};
24
25/// A Word document (.docx file).
26///
27/// This is the main entry point for reading, creating, and modifying
28/// DOCX documents.
29pub struct Document {
30    package: OpcPackage,
31    document: CT_Document,
32    styles: CT_Styles,
33    numbering: Option<CT_Numbering>,
34    core_properties: Option<CoreProperties>,
35    /// Part name for the main document
36    doc_part_name: String,
37    /// Part name the styles were loaded from, and where they are written back.
38    /// Resolved through the relationship rather than assumed, so a document
39    /// that keeps its styles somewhere other than `/word/styles.xml` is
40    /// updated in place instead of gaining an orphaned second part.
41    styles_part_name: String,
42    /// Part name for numbering definitions, resolved the same way.
43    numbering_part_name: String,
44    /// Cached count of image media parts (avoids rescanning parts on each embed).
45    image_counter: usize,
46}
47
48/// Fallback part names used when a document does not already declare one.
49const DEFAULT_STYLES_PART: &str = "/word/styles.xml";
50const DEFAULT_NUMBERING_PART: &str = "/word/numbering.xml";
51const STYLES_CONTENT_TYPE: &str =
52    "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
53const NUMBERING_CONTENT_TYPE: &str =
54    "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
55
56impl Document {
57    /// Create a new, empty document with default page setup and styles.
58    pub fn new() -> Self {
59        let mut package = OpcPackage::new_docx();
60        let document = CT_Document::new();
61        let styles = CT_Styles::new_default();
62
63        // Set up styles relationship
64        package
65            .get_or_create_part_rels("/word/document.xml")
66            .add(rel_types::STYLES, "styles.xml");
67
68        Document {
69            package,
70            document,
71            styles,
72            numbering: None,
73            core_properties: None,
74            doc_part_name: "/word/document.xml".to_string(),
75            styles_part_name: DEFAULT_STYLES_PART.to_string(),
76            numbering_part_name: DEFAULT_NUMBERING_PART.to_string(),
77            image_counter: 0,
78        }
79    }
80
81    /// Open a document from a file path.
82    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
83        let package = OpcPackage::open(path)?;
84        Self::from_package(package)
85    }
86
87    /// Open a document from bytes.
88    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
89        let cursor = std::io::Cursor::new(bytes);
90        let package = OpcPackage::from_reader(cursor)?;
91        Self::from_package(package)
92    }
93
94    fn from_package(package: OpcPackage) -> Result<Self> {
95        let doc_part_name = package.main_document_part().ok_or(Error::NoDocumentPart)?;
96
97        let doc_xml = package
98            .get_part(&doc_part_name)
99            .ok_or(Error::NoDocumentPart)?;
100        let document = CT_Document::from_xml(doc_xml)?;
101
102        // Resolve the part a relationship of the given type points at.
103        let resolve_part = |rel_type: &str| -> Option<String> {
104            let rels = package.get_part_rels(&doc_part_name)?;
105            let rel = rels.get_by_type(rel_type)?;
106            Some(OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
107        };
108
109        // Try to load styles, remembering where they came from.
110        let styles_part_name = resolve_part(rel_types::STYLES);
111        let styles = match styles_part_name
112            .as_deref()
113            .and_then(|p| package.get_part(p))
114        {
115            Some(styles_xml) => CT_Styles::from_xml(styles_xml)?,
116            None => CT_Styles::new_default(),
117        };
118
119        // Try to load numbering definitions
120        let numbering_part_name = resolve_part(rel_types::NUMBERING);
121        let numbering = match numbering_part_name
122            .as_deref()
123            .and_then(|p| package.get_part(p))
124        {
125            Some(num_xml) => Some(CT_Numbering::from_xml(num_xml)?),
126            None => None,
127        };
128
129        // Try to load core properties from docProps/core.xml
130        let core_properties = package
131            .get_part("/docProps/core.xml")
132            .and_then(|xml| CoreProperties::from_xml(xml).ok());
133
134        let image_counter = package
135            .parts
136            .keys()
137            .filter(|k| k.starts_with("/word/media/image"))
138            .count();
139
140        Ok(Document {
141            package,
142            document,
143            styles,
144            numbering,
145            core_properties,
146            doc_part_name,
147            styles_part_name: styles_part_name.unwrap_or_else(|| DEFAULT_STYLES_PART.to_string()),
148            numbering_part_name: numbering_part_name
149                .unwrap_or_else(|| DEFAULT_NUMBERING_PART.to_string()),
150            image_counter,
151        })
152    }
153
154    /// Save the document to a file path.
155    pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
156        self.flush_to_package()?;
157        self.package.save(path)?;
158        Ok(())
159    }
160
161    /// Save the document to a byte vector.
162    pub fn to_bytes(&mut self) -> Result<Vec<u8>> {
163        self.flush_to_package()?;
164        let mut buf = std::io::Cursor::new(Vec::new());
165        self.package.write_to(&mut buf)?;
166        Ok(buf.into_inner())
167    }
168
169    /// Write the in-memory document/styles back into the OPC package parts.
170    fn flush_to_package(&mut self) -> Result<()> {
171        // Serialize document.xml
172        let doc_xml = self.document.to_xml()?;
173        self.package.set_part(&self.doc_part_name, doc_xml);
174
175        // Serialize the styles part. A document opened without one still gets
176        // rdocx's defaults written out, so make sure it is reachable: an
177        // unreferenced, untyped part would simply be ignored by Word.
178        let styles_xml = self.styles.to_xml()?;
179        let styles_part = self.styles_part_name.clone();
180        self.package.set_part(&styles_part, styles_xml);
181        self.ensure_part_relationship(&styles_part, rel_types::STYLES, STYLES_CONTENT_TYPE);
182
183        // Serialize numbering definitions if we have any
184        if let Some(ref numbering) = self.numbering {
185            let numbering_xml = numbering.to_xml()?;
186            let numbering_part = self.numbering_part_name.clone();
187            self.package.set_part(&numbering_part, numbering_xml);
188            self.ensure_part_relationship(
189                &numbering_part,
190                rel_types::NUMBERING,
191                NUMBERING_CONTENT_TYPE,
192            );
193        }
194
195        // Serialize docProps/core.xml if we have metadata
196        if let Some(ref props) = self.core_properties {
197            let core_xml = props.to_xml()?;
198            self.package.set_part("/docProps/core.xml", core_xml);
199            self.package.content_types.add_override(
200                "/docProps/core.xml",
201                "application/vnd.openxmlformats-package.core-properties+xml",
202            );
203        }
204
205        Ok(())
206    }
207
208    /// Make sure `part_name` is reachable from the main document: it needs a
209    /// relationship of `rel_type` and a content-type override.
210    fn ensure_part_relationship(&mut self, part_name: &str, rel_type: &str, content_type: &str) {
211        self.package
212            .content_types
213            .add_override(part_name, content_type);
214
215        let doc_part_name = self.doc_part_name.clone();
216        let already_linked = self
217            .package
218            .get_part_rels(&doc_part_name)
219            .and_then(|rels| rels.get_by_type(rel_type))
220            .map(|rel| OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
221            .is_some_and(|target| target == part_name);
222        if already_linked {
223            return;
224        }
225
226        // Relationship targets are relative to the source part's directory.
227        let target = relative_target(&doc_part_name, part_name);
228        self.package
229            .get_or_create_part_rels(&doc_part_name)
230            .add(rel_type, &target);
231    }
232
233    // ---- Paragraph access ----
234
235    /// Get immutable references to all paragraphs.
236    pub fn paragraphs(&self) -> Vec<ParagraphRef<'_>> {
237        self.document
238            .body
239            .paragraphs()
240            .map(|p| ParagraphRef { inner: p })
241            .collect()
242    }
243
244    /// Add a paragraph with the given text and return a mutable reference.
245    pub fn add_paragraph(&mut self, text: &str) -> Paragraph<'_> {
246        let mut p = CT_P::new();
247        if !text.is_empty() {
248            p.add_run(text);
249        }
250        self.document.body.content.push(BodyContent::Paragraph(p));
251        match self.document.body.content.last_mut().unwrap() {
252            BodyContent::Paragraph(p) => Paragraph { inner: p },
253            _ => unreachable!(),
254        }
255    }
256
257    /// Get the number of paragraphs.
258    pub fn paragraph_count(&self) -> usize {
259        self.document.body.paragraphs().count()
260    }
261
262    /// Get a mutable reference to a paragraph by index (among paragraphs only).
263    pub fn paragraph_mut(&mut self, index: usize) -> Option<Paragraph<'_>> {
264        self.document
265            .body
266            .paragraphs_mut()
267            .nth(index)
268            .map(|p| Paragraph { inner: p })
269    }
270
271    // ---- Table access ----
272
273    /// Get immutable references to all tables.
274    pub fn tables(&self) -> Vec<TableRef<'_>> {
275        self.document
276            .body
277            .tables()
278            .map(|t| TableRef { inner: t })
279            .collect()
280    }
281
282    /// Add a table with the specified number of rows and columns.
283    /// Returns a mutable reference for further configuration.
284    pub fn add_table(&mut self, rows: usize, cols: usize) -> Table<'_> {
285        use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
286        use rdocx_oxml::units::Twips;
287
288        // Default column width: divide 9360tw (6.5" printable at 1" margins) evenly.
289        // A zero-column table has no grid to divide; clamp so this cannot divide by zero.
290        let col_width = Twips(9360 / cols.max(1) as i32);
291
292        let grid = CT_TblGrid {
293            columns: (0..cols)
294                .map(|_| CT_TblGridCol { width: col_width })
295                .collect(),
296        };
297
298        let mut tbl = CT_Tbl::new();
299        tbl.properties = Some(CT_TblPr {
300            width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
301            ..Default::default()
302        });
303        tbl.grid = Some(grid);
304
305        for _ in 0..rows {
306            let mut row = CT_Row::new();
307            for _ in 0..cols {
308                row.cells.push(CT_Tc::new());
309            }
310            tbl.rows.push(row);
311        }
312
313        self.document.body.content.push(BodyContent::Table(tbl));
314        match self.document.body.content.last_mut().unwrap() {
315            BodyContent::Table(t) => Table { inner: t },
316            _ => unreachable!(),
317        }
318    }
319
320    /// Get the number of tables.
321    pub fn table_count(&self) -> usize {
322        self.document.body.tables().count()
323    }
324
325    // ---- Content insertion ----
326
327    /// Get the number of body content elements (paragraphs + tables).
328    pub fn content_count(&self) -> usize {
329        self.document.body.content_count()
330    }
331
332    /// Insert a paragraph at the given body index.
333    ///
334    /// Returns a mutable `Paragraph` for further configuration.
335    /// # Panics
336    ///
337    /// Panics if `index > content_count()`. (Unlike [`Self::insert_document`]
338    /// and [`Self::insert_toc`], which clamp an out-of-range index to the end.)
339    pub fn insert_paragraph(&mut self, index: usize, text: &str) -> Paragraph<'_> {
340        let mut p = CT_P::new();
341        if !text.is_empty() {
342            p.add_run(text);
343        }
344        self.document.body.insert_paragraph(index, p);
345        match &mut self.document.body.content[index] {
346            BodyContent::Paragraph(p) => Paragraph { inner: p },
347            _ => unreachable!(),
348        }
349    }
350
351    /// Insert a table at the given body index.
352    ///
353    /// Returns a mutable `Table` for further configuration.
354    /// A `cols` of 0 produces a table with no columns rather than panicking.
355    ///
356    /// # Panics
357    ///
358    /// Panics if `index > content_count()`. (Unlike [`Self::insert_document`]
359    /// and [`Self::insert_toc`], which clamp an out-of-range index to the end.)
360    pub fn insert_table(&mut self, index: usize, rows: usize, cols: usize) -> Table<'_> {
361        use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
362        use rdocx_oxml::units::Twips;
363
364        let col_width = Twips(9360 / cols.max(1) as i32);
365        let grid = CT_TblGrid {
366            columns: (0..cols)
367                .map(|_| CT_TblGridCol { width: col_width })
368                .collect(),
369        };
370
371        let mut tbl = CT_Tbl::new();
372        tbl.properties = Some(CT_TblPr {
373            width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
374            ..Default::default()
375        });
376        tbl.grid = Some(grid);
377
378        for _ in 0..rows {
379            let mut row = CT_Row::new();
380            for _ in 0..cols {
381                row.cells.push(CT_Tc::new());
382            }
383            tbl.rows.push(row);
384        }
385
386        self.document.body.insert_table(index, tbl);
387        match &mut self.document.body.content[index] {
388            BodyContent::Table(t) => Table { inner: t },
389            _ => unreachable!(),
390        }
391    }
392
393    /// Find the body content index of the first paragraph containing the given text.
394    pub fn find_content_index(&self, text: &str) -> Option<usize> {
395        self.document.body.find_paragraph_index(text)
396    }
397
398    /// Remove the content at the given body index.
399    ///
400    /// Returns `true` if an element was removed, `false` if the index was out of bounds.
401    pub fn remove_content(&mut self, index: usize) -> bool {
402        self.document.body.remove(index).is_some()
403    }
404
405    // ---- Image support ----
406
407    /// Add an inline image to the document.
408    ///
409    /// Embeds the image data (PNG, JPEG, etc.) into the package and adds a
410    /// paragraph containing the image. Returns a mutable reference to the
411    /// paragraph for further configuration.
412    ///
413    /// `width` and `height` specify the display size.
414    pub fn add_picture(
415        &mut self,
416        image_data: &[u8],
417        image_filename: &str,
418        width: Length,
419        height: Length,
420    ) -> Paragraph<'_> {
421        let rel_id = self.embed_image(image_data, image_filename);
422
423        let inline = CT_Inline::new(&rel_id, width.to_emu(), height.to_emu());
424
425        let drawing = CT_Drawing::inline(inline);
426        let run = CT_R {
427            properties: None,
428            content: vec![RunContent::Drawing(drawing)],
429            extra_xml: Vec::new(),
430        };
431
432        let mut p = CT_P::new();
433        p.runs.push(run);
434        self.document.body.content.push(BodyContent::Paragraph(p));
435        match self.document.body.content.last_mut().unwrap() {
436            BodyContent::Paragraph(p) => Paragraph { inner: p },
437            _ => unreachable!(),
438        }
439    }
440
441    /// Add a full-page background image behind text.
442    ///
443    /// The image is placed at position (0,0) relative to the page with
444    /// dimensions matching the page size from section properties.
445    /// It is inserted at the beginning of the document body so it renders
446    /// behind all other content.
447    pub fn add_background_image(
448        &mut self,
449        image_data: &[u8],
450        image_filename: &str,
451    ) -> Paragraph<'_> {
452        let rel_id = self.embed_image(image_data, image_filename);
453
454        // Get page dimensions from section properties (default US Letter)
455        let sect = self
456            .document
457            .body
458            .sect_pr
459            .as_ref()
460            .cloned()
461            .unwrap_or_else(CT_SectPr::default_letter);
462        let page_width_emu = sect
463            .page_width
464            .unwrap_or(rdocx_oxml::units::Twips(12240))
465            .to_emu()
466            .0;
467        let page_height_emu = sect
468            .page_height
469            .unwrap_or(rdocx_oxml::units::Twips(15840))
470            .to_emu()
471            .0;
472
473        let anchor = CT_Anchor::background(&rel_id, page_width_emu, page_height_emu);
474        let drawing = CT_Drawing::anchor(anchor);
475        let run = CT_R {
476            properties: None,
477            content: vec![RunContent::Drawing(drawing)],
478            extra_xml: Vec::new(),
479        };
480
481        let mut p = CT_P::new();
482        p.runs.push(run);
483        self.document.body.insert_paragraph(0, p);
484        match &mut self.document.body.content[0] {
485            BodyContent::Paragraph(p) => Paragraph { inner: p },
486            _ => unreachable!(),
487        }
488    }
489
490    /// Add an anchored (floating) image to the document.
491    ///
492    /// If `behind_text` is true, the image renders behind text content.
493    /// The image is inserted at the beginning of the document body.
494    pub fn add_anchored_image(
495        &mut self,
496        image_data: &[u8],
497        image_filename: &str,
498        width: Length,
499        height: Length,
500        behind_text: bool,
501    ) -> Paragraph<'_> {
502        let rel_id = self.embed_image(image_data, image_filename);
503
504        let mut anchor = CT_Anchor::background(&rel_id, width.to_emu(), height.to_emu());
505        anchor.behind_doc = behind_text;
506
507        let drawing = CT_Drawing::anchor(anchor);
508        let run = CT_R {
509            properties: None,
510            content: vec![RunContent::Drawing(drawing)],
511            extra_xml: Vec::new(),
512        };
513
514        let mut p = CT_P::new();
515        p.runs.push(run);
516        self.document.body.insert_paragraph(0, p);
517        match &mut self.document.body.content[0] {
518            BodyContent::Paragraph(p) => Paragraph { inner: p },
519            _ => unreachable!(),
520        }
521    }
522
523    /// Return the next unique image number and bump the counter.
524    fn next_image_number(&mut self) -> usize {
525        self.image_counter += 1;
526        self.image_counter
527    }
528
529    /// Store image bytes as a new media part and declare its content type.
530    ///
531    /// Returns the relationship target to use when referencing it, e.g.
532    /// `media/image3.png`. No relationship is created here: an image referenced
533    /// from a header or footer must be related to *that* part, not the
534    /// document, so the caller decides where it is attached.
535    fn store_image_part(&mut self, image_data: &[u8], filename: &str) -> String {
536        let ext = image_extension(filename);
537        let image_num = self.next_image_number();
538
539        self.package.set_part(
540            &format!("/word/media/image{image_num}.{ext}"),
541            image_data.to_vec(),
542        );
543        self.package
544            .content_types
545            .add_default(&ext, image_content_type(&ext));
546
547        format!("media/image{image_num}.{ext}")
548    }
549
550    /// Embed an image into the OPC package and return the relationship ID.
551    ///
552    /// Public so callers can pre-embed an image and then pass the returned
553    /// `rel_id` to [`crate::Cell::add_picture`] for inline cell images.
554    pub fn embed_image(&mut self, image_data: &[u8], filename: &str) -> String {
555        let rel_target = self.store_image_part(image_data, filename);
556        self.package
557            .get_or_create_part_rels(&self.doc_part_name)
558            .add(rel_types::IMAGE, &rel_target)
559    }
560
561    // ---- Header/Footer ----
562
563    /// Set the default header text.
564    ///
565    /// Creates a header part with the given text and references it from
566    /// the section properties.
567    pub fn set_header(&mut self, text: &str) {
568        self.set_header_footer_part(text, true, HdrFtrType::Default);
569    }
570
571    /// Set the default footer text.
572    pub fn set_footer(&mut self, text: &str) {
573        self.set_header_footer_part(text, false, HdrFtrType::Default);
574    }
575
576    /// Set the first-page header text.
577    pub fn set_first_page_header(&mut self, text: &str) {
578        self.set_different_first_page(true);
579        self.set_header_footer_part(text, true, HdrFtrType::First);
580    }
581
582    /// Set the first-page footer text.
583    pub fn set_first_page_footer(&mut self, text: &str) {
584        self.set_different_first_page(true);
585        self.set_header_footer_part(text, false, HdrFtrType::First);
586    }
587
588    /// Get the default header text, if set.
589    pub fn header_text(&self) -> Option<String> {
590        self.get_header_footer_text(true, HdrFtrType::Default)
591    }
592
593    /// Get the default footer text, if set.
594    pub fn footer_text(&self) -> Option<String> {
595        self.get_header_footer_text(false, HdrFtrType::Default)
596    }
597
598    /// Set the default header to an inline image.
599    ///
600    /// Creates a header part with an image paragraph. The image is embedded
601    /// in the header part's relationships.
602    pub fn set_header_image(
603        &mut self,
604        image_data: &[u8],
605        image_filename: &str,
606        width: Length,
607        height: Length,
608    ) {
609        self.set_header_footer_image_part(
610            image_data,
611            image_filename,
612            width,
613            height,
614            true,
615            HdrFtrType::Default,
616        );
617    }
618
619    /// Set the default footer to an inline image.
620    pub fn set_footer_image(
621        &mut self,
622        image_data: &[u8],
623        image_filename: &str,
624        width: Length,
625        height: Length,
626    ) {
627        self.set_header_footer_image_part(
628            image_data,
629            image_filename,
630            width,
631            height,
632            false,
633            HdrFtrType::Default,
634        );
635    }
636
637    /// Set a header from raw XML bytes with associated images.
638    ///
639    /// This is useful for copying complex headers from template documents
640    /// that contain grouped shapes, VML, or other elements not easily
641    /// recreated through the high-level API.
642    ///
643    /// Each entry in `images` is `(rel_id, image_data, image_filename)`:
644    /// - `rel_id`: the relationship ID referenced in the header XML (e.g. "rId1")
645    /// - `image_data`: the raw image bytes
646    /// - `image_filename`: used to derive the part name and content type (e.g. "image5.png")
647    pub fn set_raw_header_with_images(
648        &mut self,
649        header_xml: Vec<u8>,
650        images: &[(&str, &[u8], &str)],
651        hdr_type: HdrFtrType,
652    ) {
653        self.set_raw_hdr_ftr_with_images(header_xml, images, true, hdr_type);
654    }
655
656    /// Set a footer from raw XML bytes with associated images.
657    pub fn set_raw_footer_with_images(
658        &mut self,
659        footer_xml: Vec<u8>,
660        images: &[(&str, &[u8], &str)],
661        hdr_type: HdrFtrType,
662    ) {
663        self.set_raw_hdr_ftr_with_images(footer_xml, images, false, hdr_type);
664    }
665
666    /// Set the default header to an inline image with a colored background.
667    ///
668    /// Creates a header part where the paragraph has shading fill set to
669    /// `bg_color` (hex string, e.g. "000000" for black) and contains the
670    /// inline image.
671    pub fn set_header_image_with_background(
672        &mut self,
673        image_data: &[u8],
674        image_filename: &str,
675        width: Length,
676        height: Length,
677        bg_color: &str,
678    ) {
679        self.set_header_footer_image_bg_part(
680            image_data,
681            image_filename,
682            width,
683            height,
684            Some(bg_color),
685            true,
686            HdrFtrType::Default,
687        );
688    }
689
690    /// Set the first-page header to an inline image.
691    pub fn set_first_page_header_image(
692        &mut self,
693        image_data: &[u8],
694        image_filename: &str,
695        width: Length,
696        height: Length,
697    ) {
698        self.set_different_first_page(true);
699        self.set_header_footer_image_part(
700            image_data,
701            image_filename,
702            width,
703            height,
704            true,
705            HdrFtrType::First,
706        );
707    }
708
709    /// Where a header/footer of this kind lives, and how to declare it.
710    ///
711    /// All four public entry points differ only in what goes *inside* the part;
712    /// the surrounding bookkeeping — part name, content type, relationship,
713    /// section reference — is identical, and lives here.
714    ///
715    /// Note the fixed `1` in the part name: rdocx manages one header and one
716    /// footer per [`HdrFtrType`] for the document's single section. Setting a
717    /// header of the same type again replaces the existing part.
718    fn hdr_ftr_slots(
719        is_header: bool,
720        hdr_type: HdrFtrType,
721    ) -> (String, &'static str, &'static str) {
722        let type_suffix = match hdr_type {
723            HdrFtrType::Default => "",
724            HdrFtrType::First => "First",
725            HdrFtrType::Even => "Even",
726        };
727        if is_header {
728            (
729                format!("/word/header{type_suffix}1.xml"),
730                rel_types::HEADER,
731                "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
732            )
733        } else {
734            (
735                format!("/word/footer{type_suffix}1.xml"),
736                rel_types::FOOTER,
737                "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
738            )
739        }
740    }
741
742    /// Install a header/footer part: store its bytes, declare the content type,
743    /// relate it to the document, and point the section properties at it.
744    ///
745    /// Any previous reference of the same [`HdrFtrType`] is replaced.
746    fn install_hdr_ftr_part(
747        &mut self,
748        xml: Vec<u8>,
749        is_header: bool,
750        hdr_type: HdrFtrType,
751    ) -> String {
752        let (part_name, rel_type, content_type) = Self::hdr_ftr_slots(is_header, hdr_type);
753
754        self.package.set_part(&part_name, xml);
755        self.package
756            .content_types
757            .add_override(&part_name, content_type);
758
759        // Setting the same header twice must not leave the first relationship
760        // behind pointing at the same part.
761        let rel_target = relative_target(&self.doc_part_name, &part_name);
762        let rels = self.package.get_or_create_part_rels(&self.doc_part_name);
763        let rel_id = match rels
764            .items
765            .iter()
766            .find(|r| r.rel_type == rel_type && r.target == rel_target)
767        {
768            Some(existing) => existing.id.clone(),
769            None => rels.add(rel_type, &rel_target),
770        };
771
772        let sect = self.section_properties_mut();
773        let refs = if is_header {
774            &mut sect.header_refs
775        } else {
776            &mut sect.footer_refs
777        };
778        refs.retain(|r| r.hdr_ftr_type != hdr_type);
779        refs.push(HdrFtrRef {
780            hdr_ftr_type: hdr_type,
781            rel_id,
782        });
783
784        part_name
785    }
786
787    /// Serialize a header/footer body, choosing the right root element.
788    fn serialize_hdr_ftr(hdr_ftr: &CT_HdrFtr, is_header: bool) -> Result<Vec<u8>> {
789        let xml = if is_header {
790            hdr_ftr.to_xml_header()
791        } else {
792            hdr_ftr.to_xml_footer()
793        };
794        Ok(xml?)
795    }
796
797    fn set_header_footer_part(&mut self, text: &str, is_header: bool, hdr_type: HdrFtrType) {
798        let mut hdr_ftr = CT_HdrFtr::new();
799        let mut p = CT_P::new();
800        if !text.is_empty() {
801            p.add_run(text);
802        }
803        hdr_ftr.paragraphs.push(p);
804
805        let Ok(xml) = Self::serialize_hdr_ftr(&hdr_ftr, is_header) else {
806            return;
807        };
808        self.install_hdr_ftr_part(xml, is_header, hdr_type);
809    }
810
811    fn set_raw_hdr_ftr_with_images(
812        &mut self,
813        xml: Vec<u8>,
814        images: &[(&str, &[u8], &str)],
815        is_header: bool,
816        hdr_type: HdrFtrType,
817    ) {
818        let part_name = self.install_hdr_ftr_part(xml, is_header, hdr_type);
819
820        // The supplied markup already references these images by ID, so each
821        // relationship has to be created with that exact ID.
822        for &(rel_id, image_data, image_filename) in images {
823            let img_rel_target = self.store_image_part(image_data, image_filename);
824            self.package
825                .get_or_create_part_rels(&part_name)
826                .add_with_id(rel_id, rel_types::IMAGE, &img_rel_target);
827        }
828    }
829
830    fn set_header_footer_image_part(
831        &mut self,
832        image_data: &[u8],
833        image_filename: &str,
834        width: Length,
835        height: Length,
836        is_header: bool,
837        hdr_type: HdrFtrType,
838    ) {
839        self.set_header_footer_image_bg_part(
840            image_data,
841            image_filename,
842            width,
843            height,
844            None,
845            is_header,
846            hdr_type,
847        );
848    }
849
850    fn set_header_footer_image_bg_part(
851        &mut self,
852        image_data: &[u8],
853        image_filename: &str,
854        width: Length,
855        height: Length,
856        bg_color: Option<&str>,
857        is_header: bool,
858        hdr_type: HdrFtrType,
859    ) {
860        use rdocx_oxml::properties::CT_Shd;
861
862        let (part_name, _, _) = Self::hdr_ftr_slots(is_header, hdr_type);
863
864        // The image relationship belongs to the header/footer part, not the
865        // document, because that is where the drawing referencing it lives.
866        let img_rel_target = self.store_image_part(image_data, image_filename);
867        let img_rel_id = self
868            .package
869            .get_or_create_part_rels(&part_name)
870            .add(rel_types::IMAGE, &img_rel_target);
871
872        let inline = CT_Inline::new(&img_rel_id, width.to_emu(), height.to_emu());
873        let run = CT_R {
874            properties: None,
875            content: vec![RunContent::Drawing(CT_Drawing::inline(inline))],
876            extra_xml: Vec::new(),
877        };
878
879        let mut p = CT_P::new();
880        p.runs.push(run);
881        if let Some(color) = bg_color {
882            p.properties = Some(CT_PPr {
883                shading: Some(CT_Shd {
884                    val: "clear".to_string(),
885                    color: Some("auto".to_string()),
886                    fill: Some(color.to_string()),
887                }),
888                ..Default::default()
889            });
890        }
891
892        let mut hdr_ftr = CT_HdrFtr::new();
893        hdr_ftr.paragraphs.push(p);
894
895        let Ok(xml) = Self::serialize_hdr_ftr(&hdr_ftr, is_header) else {
896            return;
897        };
898        self.install_hdr_ftr_part(xml, is_header, hdr_type);
899    }
900
901    fn get_header_footer_text(&self, is_header: bool, hdr_type: HdrFtrType) -> Option<String> {
902        let sect = self.document.body.sect_pr.as_ref()?;
903        let refs = if is_header {
904            &sect.header_refs
905        } else {
906            &sect.footer_refs
907        };
908        let hdr_ref = refs.iter().find(|r| r.hdr_ftr_type == hdr_type)?;
909
910        // Resolve the part
911        let rels = self.package.get_part_rels(&self.doc_part_name)?;
912        let rel = rels.get_by_id(&hdr_ref.rel_id)?;
913        let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
914        let xml = self.package.get_part(&part_name)?;
915        let hdr_ftr = CT_HdrFtr::from_xml(xml).ok()?;
916        Some(hdr_ftr.text())
917    }
918
919    // ---- Numbering/Lists ----
920
921    /// Ensure a numbering part exists.
922    ///
923    /// The relationship and content-type override are added by
924    /// [`Self::flush_to_package`], which knows the resolved part name and will
925    /// not create a second numbering relationship if one already exists.
926    fn ensure_numbering(&mut self) -> &mut CT_Numbering {
927        self.numbering.get_or_insert_with(CT_Numbering::new)
928    }
929
930    /// Add a bullet list item at the given indentation level (0-based).
931    ///
932    /// If no bullet list definition exists yet, one is created automatically.
933    /// Returns a mutable `Paragraph` for further configuration.
934    pub fn add_bullet_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
935        // Find or create a bullet list numId
936        let num_id = {
937            let numbering = self.ensure_numbering();
938            // Look for an existing bullet list
939            let existing = numbering.nums.iter().find(|n| {
940                numbering
941                    .get_abstract_num_for(n.num_id)
942                    .map(|a| {
943                        a.levels.first().and_then(|l| l.num_fmt)
944                            == Some(rdocx_oxml::numbering::ST_NumberFormat::Bullet)
945                    })
946                    .unwrap_or(false)
947            });
948            if let Some(existing) = existing {
949                existing.num_id
950            } else {
951                numbering.add_bullet_list()
952            }
953        };
954
955        let mut p = CT_P::new();
956        if !text.is_empty() {
957            p.add_run(text);
958        }
959        let ppr = CT_PPr {
960            num_id: Some(num_id),
961            num_ilvl: Some(level),
962            ..Default::default()
963        };
964        p.properties = Some(ppr);
965
966        self.document.body.content.push(BodyContent::Paragraph(p));
967        match self.document.body.content.last_mut().unwrap() {
968            BodyContent::Paragraph(p) => Paragraph { inner: p },
969            _ => unreachable!(),
970        }
971    }
972
973    /// Add a numbered list item at the given indentation level (0-based).
974    ///
975    /// If no numbered list definition exists yet, one is created automatically.
976    /// Returns a mutable `Paragraph` for further configuration.
977    pub fn add_numbered_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
978        // Find or create a numbered list numId
979        let num_id = {
980            let numbering = self.ensure_numbering();
981            // Look for an existing numbered list
982            let existing = numbering.nums.iter().find(|n| {
983                numbering
984                    .get_abstract_num_for(n.num_id)
985                    .map(|a| {
986                        a.levels.first().and_then(|l| l.num_fmt)
987                            == Some(rdocx_oxml::numbering::ST_NumberFormat::Decimal)
988                    })
989                    .unwrap_or(false)
990            });
991            if let Some(existing) = existing {
992                existing.num_id
993            } else {
994                numbering.add_numbered_list()
995            }
996        };
997
998        let mut p = CT_P::new();
999        if !text.is_empty() {
1000            p.add_run(text);
1001        }
1002        let ppr = CT_PPr {
1003            num_id: Some(num_id),
1004            num_ilvl: Some(level),
1005            ..Default::default()
1006        };
1007        p.properties = Some(ppr);
1008
1009        self.document.body.content.push(BodyContent::Paragraph(p));
1010        match self.document.body.content.last_mut().unwrap() {
1011            BodyContent::Paragraph(p) => Paragraph { inner: p },
1012            _ => unreachable!(),
1013        }
1014    }
1015
1016    // ---- Style access ----
1017
1018    /// Get all styles.
1019    pub fn styles(&self) -> Vec<Style<'_>> {
1020        self.styles
1021            .styles
1022            .iter()
1023            .map(|s| Style { inner: s })
1024            .collect()
1025    }
1026
1027    /// Find a style by its ID.
1028    pub fn style(&self, style_id: &str) -> Option<Style<'_>> {
1029        self.styles.get_by_id(style_id).map(|s| Style { inner: s })
1030    }
1031
1032    // ---- Style manipulation ----
1033
1034    /// Add a custom style to the document.
1035    pub fn add_style(&mut self, builder: StyleBuilder) {
1036        self.styles.styles.push(builder.build());
1037    }
1038
1039    /// Resolve the effective paragraph properties for a given style ID,
1040    /// walking the full inheritance chain (docDefaults → basedOn → ...).
1041    pub fn resolve_paragraph_properties(&self, style_id: Option<&str>) -> CT_PPr {
1042        style::resolve_paragraph_properties(style_id, &self.styles)
1043    }
1044
1045    /// Resolve the effective run properties for the given paragraph and character styles,
1046    /// walking the full inheritance chain.
1047    pub fn resolve_run_properties(
1048        &self,
1049        para_style_id: Option<&str>,
1050        run_style_id: Option<&str>,
1051    ) -> CT_RPr {
1052        style::resolve_run_properties(para_style_id, run_style_id, &self.styles)
1053    }
1054
1055    // ---- Section/Page setup ----
1056
1057    /// Get the section properties (page size, margins).
1058    pub fn section_properties(&self) -> Option<&CT_SectPr> {
1059        self.document.body.sect_pr.as_ref()
1060    }
1061
1062    /// Get a mutable reference to section properties, creating defaults if needed.
1063    pub fn section_properties_mut(&mut self) -> &mut CT_SectPr {
1064        self.document
1065            .body
1066            .sect_pr
1067            .get_or_insert_with(CT_SectPr::default_letter)
1068    }
1069
1070    /// Set page size.
1071    pub fn set_page_size(&mut self, width: Length, height: Length) {
1072        let sect = self.section_properties_mut();
1073        sect.page_width = Some(width.as_twips());
1074        sect.page_height = Some(height.as_twips());
1075    }
1076
1077    /// Set page orientation to landscape (swaps width and height if needed).
1078    pub fn set_landscape(&mut self) {
1079        let sect = self.section_properties_mut();
1080        sect.orientation = Some(ST_PageOrientation::Landscape);
1081        // Swap width/height if portrait dimensions
1082        if let (Some(w), Some(h)) = (sect.page_width, sect.page_height)
1083            && w.0 < h.0
1084        {
1085            sect.page_width = Some(h);
1086            sect.page_height = Some(w);
1087        }
1088    }
1089
1090    /// Set page orientation to portrait (swaps width and height if needed).
1091    pub fn set_portrait(&mut self) {
1092        let sect = self.section_properties_mut();
1093        sect.orientation = Some(ST_PageOrientation::Portrait);
1094        // Swap width/height if landscape dimensions
1095        if let (Some(w), Some(h)) = (sect.page_width, sect.page_height)
1096            && w.0 > h.0
1097        {
1098            sect.page_width = Some(h);
1099            sect.page_height = Some(w);
1100        }
1101    }
1102
1103    /// Set all page margins.
1104    pub fn set_margins(&mut self, top: Length, right: Length, bottom: Length, left: Length) {
1105        let sect = self.section_properties_mut();
1106        sect.margin_top = Some(top.as_twips());
1107        sect.margin_right = Some(right.as_twips());
1108        sect.margin_bottom = Some(bottom.as_twips());
1109        sect.margin_left = Some(left.as_twips());
1110    }
1111
1112    /// Set equal-width column layout.
1113    pub fn set_columns(&mut self, num: u32, spacing: Length) {
1114        let sect = self.section_properties_mut();
1115        sect.columns = Some(CT_Columns {
1116            num: Some(num),
1117            space: Some(spacing.as_twips()),
1118            equal_width: Some(true),
1119            sep: None,
1120            columns: Vec::new(),
1121        });
1122    }
1123
1124    /// Set header and footer distances from page edges.
1125    pub fn set_header_footer_distance(&mut self, header: Length, footer: Length) {
1126        let sect = self.section_properties_mut();
1127        sect.header_distance = Some(header.as_twips());
1128        sect.footer_distance = Some(footer.as_twips());
1129    }
1130
1131    /// Set the gutter margin.
1132    pub fn set_gutter(&mut self, gutter: Length) {
1133        self.section_properties_mut().gutter = Some(gutter.as_twips());
1134    }
1135
1136    /// Enable or disable different first page header/footer.
1137    pub fn set_different_first_page(&mut self, val: bool) {
1138        self.section_properties_mut().title_pg = Some(val);
1139    }
1140
1141    // ---- Metadata access ----
1142
1143    /// Get the document title.
1144    pub fn title(&self) -> Option<&str> {
1145        self.core_properties.as_ref()?.title.as_deref()
1146    }
1147
1148    /// Set the document title.
1149    pub fn set_title(&mut self, title: &str) {
1150        self.ensure_core_properties().title = Some(title.to_string());
1151    }
1152
1153    /// Get the document author/creator.
1154    pub fn author(&self) -> Option<&str> {
1155        self.core_properties.as_ref()?.creator.as_deref()
1156    }
1157
1158    /// Set the document author/creator.
1159    pub fn set_author(&mut self, author: &str) {
1160        self.ensure_core_properties().creator = Some(author.to_string());
1161    }
1162
1163    /// Get the document subject.
1164    pub fn subject(&self) -> Option<&str> {
1165        self.core_properties.as_ref()?.subject.as_deref()
1166    }
1167
1168    /// Set the document subject.
1169    pub fn set_subject(&mut self, subject: &str) {
1170        self.ensure_core_properties().subject = Some(subject.to_string());
1171    }
1172
1173    /// Get the document keywords.
1174    pub fn keywords(&self) -> Option<&str> {
1175        self.core_properties.as_ref()?.keywords.as_deref()
1176    }
1177
1178    /// Set the document keywords.
1179    pub fn set_keywords(&mut self, keywords: &str) {
1180        self.ensure_core_properties().keywords = Some(keywords.to_string());
1181    }
1182
1183    fn ensure_core_properties(&mut self) -> &mut CoreProperties {
1184        self.core_properties
1185            .get_or_insert_with(CoreProperties::default)
1186    }
1187
1188    // ---- Document Merging ----
1189
1190    /// Append the content of another document to this document.
1191    ///
1192    /// Copies all body content (paragraphs and tables) from the other document.
1193    /// Handles style deduplication and numbering remapping.
1194    pub fn append(&mut self, other: &Document) {
1195        self.merge_styles(other);
1196
1197        let start_idx = self.document.body.content.len();
1198        for content in &other.document.body.content {
1199            self.document.body.content.push(content.clone());
1200        }
1201
1202        self.remap_merged_numbering(other, start_idx);
1203    }
1204
1205    /// Append the content of another document with a section break.
1206    pub fn append_with_break(&mut self, other: &Document, break_type: crate::SectionBreak) {
1207        // Insert a section break paragraph before the merged content
1208        let mut p = CT_P::new();
1209        let sect_pr = match break_type {
1210            crate::SectionBreak::NextPage => CT_SectPr::default_letter(),
1211            crate::SectionBreak::Continuous => {
1212                let mut sp = CT_SectPr::default_letter();
1213                sp.section_type = Some(ST_SectionType::Continuous);
1214                sp
1215            }
1216            crate::SectionBreak::EvenPage => {
1217                let mut sp = CT_SectPr::default_letter();
1218                sp.section_type = Some(ST_SectionType::EvenPage);
1219                sp
1220            }
1221            crate::SectionBreak::OddPage => {
1222                let mut sp = CT_SectPr::default_letter();
1223                sp.section_type = Some(ST_SectionType::OddPage);
1224                sp
1225            }
1226        };
1227        p.properties = Some(CT_PPr {
1228            sect_pr: Some(sect_pr),
1229            ..Default::default()
1230        });
1231        self.document.body.content.push(BodyContent::Paragraph(p));
1232
1233        self.append(other);
1234    }
1235
1236    /// Insert the content of another document at a specified body index.
1237    ///
1238    /// An `index` past the end is clamped to the end rather than panicking.
1239    pub fn insert_document(&mut self, index: usize, other: &Document) {
1240        self.merge_styles(other);
1241
1242        let insert_at = index.min(self.document.body.content.len());
1243        for (i, content) in other.document.body.content.iter().enumerate() {
1244            self.document
1245                .body
1246                .content
1247                .insert(insert_at + i, content.clone());
1248        }
1249
1250        self.remap_merged_numbering(other, insert_at);
1251    }
1252
1253    /// Merge styles from another document, avoiding duplicates.
1254    fn merge_styles(&mut self, other: &Document) {
1255        for style in &other.styles.styles {
1256            if self.styles.get_by_id(&style.style_id).is_none() {
1257                self.styles.styles.push(style.clone());
1258            }
1259        }
1260    }
1261
1262    /// Merge numbering from another document and remap IDs in the merged content.
1263    /// `start_idx` is the index where the other document's content starts in self.
1264    fn remap_merged_numbering(&mut self, other: &Document, start_idx: usize) {
1265        let Some(other_numbering) = &other.numbering else {
1266            return;
1267        };
1268
1269        let numbering = self
1270            .numbering
1271            .get_or_insert_with(|| rdocx_oxml::numbering::CT_Numbering {
1272                abstract_nums: Vec::new(),
1273                nums: Vec::new(),
1274            });
1275
1276        // Find max existing IDs to avoid collision
1277        let max_abstract_id = numbering
1278            .abstract_nums
1279            .iter()
1280            .map(|a| a.abstract_num_id)
1281            .max()
1282            .unwrap_or(0);
1283        let max_num_id = numbering.nums.iter().map(|n| n.num_id).max().unwrap_or(0);
1284
1285        let abstract_offset = max_abstract_id + 1;
1286        let num_offset = max_num_id + 1;
1287
1288        // Copy abstract nums with remapped IDs
1289        for abs_num in &other_numbering.abstract_nums {
1290            let mut new_abs = abs_num.clone();
1291            new_abs.abstract_num_id += abstract_offset;
1292            numbering.abstract_nums.push(new_abs);
1293        }
1294
1295        // Copy num instances with remapped IDs
1296        for num in &other_numbering.nums {
1297            let mut new_num = num.clone();
1298            new_num.num_id += num_offset;
1299            new_num.abstract_num_id += abstract_offset;
1300            numbering.nums.push(new_num);
1301        }
1302
1303        // Remap numId references in the merged content
1304        let incoming_count = other.document.body.content.len();
1305        for content in self.document.body.content[start_idx..start_idx + incoming_count].iter_mut()
1306        {
1307            Self::remap_num_ids(content, num_offset);
1308        }
1309    }
1310
1311    /// Remap numId references in body content by adding an offset.
1312    fn remap_num_ids(content: &mut BodyContent, offset: u32) {
1313        match content {
1314            BodyContent::Paragraph(p) => {
1315                Self::remap_paragraph_num_id(p, offset);
1316            }
1317            BodyContent::Table(tbl) => {
1318                Self::remap_table_num_ids(tbl, offset);
1319            }
1320            BodyContent::RawXml(_) => {}
1321        }
1322    }
1323
1324    fn remap_paragraph_num_id(p: &mut CT_P, offset: u32) {
1325        if let Some(ppr) = &mut p.properties
1326            && let Some(num_id) = &mut ppr.num_id
1327            && *num_id > 0
1328        {
1329            *num_id += offset;
1330        }
1331    }
1332
1333    fn remap_table_num_ids(tbl: &mut CT_Tbl, offset: u32) {
1334        for row in &mut tbl.rows {
1335            for cell in &mut row.cells {
1336                for cc in &mut cell.content {
1337                    match cc {
1338                        rdocx_oxml::table::CellContent::Paragraph(p) => {
1339                            Self::remap_paragraph_num_id(p, offset);
1340                        }
1341                        rdocx_oxml::table::CellContent::Table(nested) => {
1342                            Self::remap_table_num_ids(nested, offset);
1343                        }
1344                    }
1345                }
1346            }
1347        }
1348    }
1349
1350    // ---- Table of Contents ----
1351
1352    /// Insert a Table of Contents at the given body content index.
1353    ///
1354    /// Scans the document for heading paragraphs (Heading1..HeadingN where N <= max_level),
1355    /// inserts bookmark markers at each heading, and generates TOC entry paragraphs
1356    /// with internal hyperlinks and dot-leader tab stops.
1357    ///
1358    /// # Arguments
1359    /// * `index` - Body content index at which to insert the TOC
1360    /// * `max_level` - Maximum heading level to include (1-9, typically 3)
1361    pub fn insert_toc(&mut self, index: usize, max_level: u32) {
1362        use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
1363        use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
1364        use rdocx_oxml::text::HyperlinkSpan;
1365        use rdocx_oxml::units::Twips;
1366
1367        let max_level = max_level.clamp(1, 9);
1368
1369        // Step 1: Collect heading info from the document body
1370        struct HeadingInfo {
1371            content_index: usize,
1372            level: u32,
1373            text: String,
1374            bookmark_name: String,
1375        }
1376
1377        // Calling insert_toc twice must not mint bookmarks that collide with
1378        // the ones the first call left behind — duplicate `w:name` values make
1379        // the internal links ambiguous. Continue numbering past whatever is
1380        // already there.
1381        let mut toc_counter = self.highest_toc_bookmark();
1382        let mut bookmark_id = 100 + toc_counter;
1383
1384        let mut headings = Vec::new();
1385
1386        for (idx, content) in self.document.body.content.iter().enumerate() {
1387            if let BodyContent::Paragraph(p) = content
1388                && let Some(level) = Self::detect_heading_level_for_toc(p)
1389                && level <= max_level
1390            {
1391                let text = p.text();
1392                if !text.trim().is_empty() {
1393                    toc_counter += 1;
1394                    headings.push(HeadingInfo {
1395                        content_index: idx,
1396                        level,
1397                        text,
1398                        bookmark_name: format!("_Toc{toc_counter}"),
1399                    });
1400                }
1401            }
1402        }
1403
1404        // Step 2: Insert bookmark markers at each heading paragraph (as raw XML in extra_xml)
1405        // We insert bookmarkStart/bookmarkEnd as extra_xml at position 0 in the paragraph.
1406        for heading in &headings {
1407            if let Some(BodyContent::Paragraph(p)) =
1408                self.document.body.content.get_mut(heading.content_index)
1409            {
1410                let bm_start = format!(
1411                    "<w:bookmarkStart w:id=\"{bookmark_id}\" w:name=\"{}\"/>",
1412                    heading.bookmark_name
1413                );
1414                let bm_end = format!("<w:bookmarkEnd w:id=\"{bookmark_id}\"/>");
1415                // Insert at position 0 (before runs)
1416                p.extra_xml.push((0, bm_start.into_bytes()));
1417                // Insert at end (after runs)
1418                p.extra_xml.push((p.runs.len(), bm_end.into_bytes()));
1419                bookmark_id += 1;
1420            }
1421        }
1422
1423        // Step 3: Build TOC entry paragraphs.
1424        // The dot leader runs to the right text margin, which depends on the
1425        // section's page size and margins rather than being a fixed 6.5".
1426        let right_tab = CT_Tabs {
1427            tabs: vec![CT_TabStop {
1428                val: ST_TabJc::Right,
1429                pos: Twips(self.text_width_twips()),
1430                leader: Some(ST_TabLeader::Dot),
1431            }],
1432        };
1433
1434        let mut toc_paragraphs: Vec<CT_P> = Vec::new();
1435
1436        // TOC title
1437        let mut title_p = CT_P::new();
1438        let mut title_r = CT_R::new("Table of Contents");
1439        title_r.properties = Some(CT_RPr {
1440            bold: Some(true),
1441            ..Default::default()
1442        });
1443        title_p.runs.push(title_r);
1444        title_p.properties = Some(CT_PPr {
1445            space_after: Some(Twips(120)),
1446            ..Default::default()
1447        });
1448        toc_paragraphs.push(title_p);
1449
1450        for heading in &headings {
1451            let mut p = CT_P::new();
1452
1453            // Indentation based on heading level (each level indented 360 twips = 0.25")
1454            let indent = Twips(360 * (heading.level as i32 - 1));
1455
1456            p.properties = Some(CT_PPr {
1457                tabs: Some(right_tab.clone()),
1458                ind_left: if indent.0 > 0 { Some(indent) } else { None },
1459                ..Default::default()
1460            });
1461
1462            // Run with heading text
1463            let text_run = CT_R::new(&heading.text);
1464            p.runs.push(text_run);
1465
1466            // Tab run (separates text from page number)
1467            p.runs.push(CT_R {
1468                properties: None,
1469                content: vec![rdocx_oxml::text::RunContent::Tab],
1470                extra_xml: Vec::new(),
1471            });
1472
1473            // Wrap the text run in a hyperlink to the bookmark
1474            p.hyperlinks.push(HyperlinkSpan {
1475                rel_id: None,
1476                anchor: Some(heading.bookmark_name.clone()),
1477                run_start: 0,
1478                run_end: 1, // Just the text run, not the tab
1479            });
1480
1481            toc_paragraphs.push(p);
1482        }
1483
1484        // Step 4: Insert TOC paragraphs at the specified index
1485        let insert_at = index.min(self.document.body.content.len());
1486        for (i, p) in toc_paragraphs.into_iter().enumerate() {
1487            self.document
1488                .body
1489                .content
1490                .insert(insert_at + i, BodyContent::Paragraph(p));
1491        }
1492    }
1493
1494    /// The highest `_TocN` bookmark number already present in the body.
1495    ///
1496    /// Returns 0 when there are none, so the next bookmark is `_Toc1`.
1497    fn highest_toc_bookmark(&self) -> u32 {
1498        let mut highest = 0;
1499        for content in &self.document.body.content {
1500            let BodyContent::Paragraph(p) = content else {
1501                continue;
1502            };
1503            for (_, raw) in &p.extra_xml {
1504                let Ok(text) = std::str::from_utf8(raw) else {
1505                    continue;
1506                };
1507                for (_, after) in text.match_indices("_Toc") {
1508                    let digits: String = after
1509                        .trim_start_matches("_Toc")
1510                        .chars()
1511                        .take_while(char::is_ascii_digit)
1512                        .collect();
1513                    if let Ok(n) = digits.parse::<u32>() {
1514                        highest = highest.max(n);
1515                    }
1516                }
1517            }
1518        }
1519        highest
1520    }
1521
1522    /// Width of the text column in twips: page width less both side margins.
1523    ///
1524    /// Falls back to the US Letter default (6.5") when the section does not
1525    /// specify a size, and never returns a non-positive width.
1526    fn text_width_twips(&self) -> i32 {
1527        const DEFAULT_TEXT_WIDTH: i32 = 9360;
1528
1529        let Some(sect) = self.document.body.sect_pr.as_ref() else {
1530            return DEFAULT_TEXT_WIDTH;
1531        };
1532        let page_width = sect.page_width.map(|w| w.0).unwrap_or(12240);
1533        let left = sect.margin_left.map(|m| m.0).unwrap_or(1440);
1534        let right = sect.margin_right.map(|m| m.0).unwrap_or(1440);
1535
1536        let width = page_width - left - right;
1537        if width > 0 { width } else { DEFAULT_TEXT_WIDTH }
1538    }
1539
1540    /// Detect heading level from a paragraph's style ID.
1541    fn detect_heading_level_for_toc(para: &CT_P) -> Option<u32> {
1542        let ppr = para.properties.as_ref()?;
1543        let style_id = ppr.style_id.as_deref()?;
1544        let rest = style_id.strip_prefix("Heading")?;
1545        rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n))
1546    }
1547
1548    // ---- Placeholder replacement ----
1549
1550    /// Replace all occurrences of `placeholder` with `replacement` throughout the document.
1551    ///
1552    /// Searches body paragraphs, tables (including nested), headers, footers,
1553    /// text boxes and chart labels. Handles placeholders split across multiple
1554    /// runs. Returns the total number of replacements made.
1555    ///
1556    /// A `replacement` that contains `placeholder` is substituted once, not
1557    /// repeatedly.
1558    pub fn replace_text(&mut self, placeholder: &str, replacement: &str) -> usize {
1559        self.replace_batch(&[(placeholder, replacement)])
1560    }
1561
1562    /// Replace multiple placeholders at once. Returns total replacements.
1563    ///
1564    /// Cheaper than calling [`Self::replace_text`] per entry: the document is
1565    /// serialised and re-parsed once for the whole batch rather than once per
1566    /// placeholder.
1567    pub fn replace_all(&mut self, replacements: &std::collections::HashMap<&str, &str>) -> usize {
1568        let pairs: Vec<(&str, &str)> = replacements.iter().map(|(k, v)| (*k, *v)).collect();
1569        self.replace_batch(&pairs)
1570    }
1571
1572    /// Apply a batch of literal replacements across the whole document.
1573    fn replace_batch(&mut self, pairs: &[(&str, &str)]) -> usize {
1574        if pairs.is_empty() {
1575            return 0;
1576        }
1577
1578        let mut count = 0;
1579
1580        // Typed model: body content, then headers and footers.
1581        for (placeholder, replacement) in pairs {
1582            count += self.replace_in_body(placeholder, replacement);
1583        }
1584        count += self.replace_in_headers_footers(pairs);
1585
1586        // Raw XML: text boxes, shapes and charts live in markup the typed model
1587        // does not cover, so flush first and work on the serialised parts.
1588        if self.flush_to_package().is_ok() {
1589            count += self.replace_in_xml_parts(pairs);
1590        }
1591
1592        count
1593    }
1594
1595    /// Run the typed replacement over body paragraphs and tables.
1596    fn replace_in_body(&mut self, placeholder: &str, replacement: &str) -> usize {
1597        use rdocx_oxml::placeholder;
1598
1599        let mut count = 0;
1600        for content in &mut self.document.body.content {
1601            match content {
1602                BodyContent::Paragraph(p) => {
1603                    count += placeholder::replace_in_paragraph(p, placeholder, replacement);
1604                }
1605                BodyContent::Table(t) => {
1606                    count += placeholder::replace_in_table(t, placeholder, replacement);
1607                }
1608                BodyContent::RawXml(_) => {}
1609            }
1610        }
1611        count
1612    }
1613
1614    /// Run the typed replacement over every referenced header and footer part.
1615    fn replace_in_headers_footers(&mut self, pairs: &[(&str, &str)]) -> usize {
1616        use rdocx_oxml::placeholder;
1617
1618        let mut count = 0;
1619        for (rel_id, is_header) in self.header_footer_rel_ids() {
1620            let Some(mut hf) = self.load_header_footer(&rel_id) else {
1621                continue;
1622            };
1623            let mut part_count = 0;
1624            for (placeholder, replacement) in pairs {
1625                part_count +=
1626                    placeholder::replace_in_header_footer(&mut hf, placeholder, replacement);
1627            }
1628            if part_count > 0 {
1629                self.save_header_footer(&rel_id, &hf, is_header);
1630                count += part_count;
1631            }
1632        }
1633        count
1634    }
1635
1636    /// Relationship IDs of the section's headers and footers, with a flag
1637    /// saying which kind each one is.
1638    fn header_footer_rel_ids(&self) -> Vec<(String, bool)> {
1639        let Some(sect_pr) = self.document.body.sect_pr.as_ref() else {
1640            return Vec::new();
1641        };
1642        sect_pr
1643            .header_refs
1644            .iter()
1645            .map(|r| (r.rel_id.clone(), true))
1646            .chain(
1647                sect_pr
1648                    .footer_refs
1649                    .iter()
1650                    .map(|r| (r.rel_id.clone(), false)),
1651            )
1652            .collect()
1653    }
1654
1655    // ---- Regex replacement ----
1656
1657    /// Replace all regex matches with `replacement` throughout the document.
1658    ///
1659    /// The `replacement` string supports capture groups: `$1`, `$2`, etc.
1660    /// Searches body paragraphs, tables (including nested), headers, and footers.
1661    /// Returns the total number of replacements made, or an error if the regex is invalid.
1662    pub fn replace_regex(&mut self, pattern: &str, replacement: &str) -> Result<usize> {
1663        let re =
1664            regex::Regex::new(pattern).map_err(|e| Error::Other(format!("invalid regex: {e}")))?;
1665        Ok(self.replace_regex_compiled(&re, replacement))
1666    }
1667
1668    /// Replace multiple regex patterns at once. Returns total replacements.
1669    pub fn replace_all_regex(&mut self, patterns: &[(String, String)]) -> Result<usize> {
1670        let mut count = 0;
1671        for (pattern, replacement) in patterns {
1672            count += self.replace_regex(pattern, replacement)?;
1673        }
1674        Ok(count)
1675    }
1676
1677    /// Internal: replace using a pre-compiled regex.
1678    fn replace_regex_compiled(&mut self, re: &regex::Regex, replacement: &str) -> usize {
1679        use rdocx_oxml::placeholder;
1680
1681        let mut count = 0;
1682
1683        // Replace in body paragraphs and tables
1684        for content in &mut self.document.body.content {
1685            match content {
1686                BodyContent::Paragraph(p) => {
1687                    count += placeholder::replace_regex_in_paragraph(p, re, replacement);
1688                }
1689                BodyContent::Table(t) => {
1690                    count += placeholder::replace_regex_in_table(t, re, replacement);
1691                }
1692                BodyContent::RawXml(_) => {}
1693            }
1694        }
1695
1696        // Replace in headers and footers
1697        for (rel_id, is_header) in self.header_footer_rel_ids() {
1698            let Some(mut hf) = self.load_header_footer(&rel_id) else {
1699                continue;
1700            };
1701            let n = placeholder::replace_regex_in_header_footer(&mut hf, re, replacement);
1702            if n > 0 {
1703                self.save_header_footer(&rel_id, &hf, is_header);
1704                count += n;
1705            }
1706        }
1707
1708        // Text boxes and shapes live in raw markup the typed model does not
1709        // reach. `replace_text` has always covered them; do the same here so
1710        // the two entry points search the same places.
1711        if self.flush_to_package().is_ok() {
1712            count += self.replace_regex_in_xml_parts(re, replacement);
1713        }
1714
1715        count
1716    }
1717
1718    /// Apply a regex replacement to the text-box content of the raw XML parts.
1719    fn replace_regex_in_xml_parts(&mut self, re: &regex::Regex, replacement: &str) -> usize {
1720        let mut count = 0;
1721
1722        for part_name in self.text_bearing_part_names() {
1723            let Some(xml) = self.package.get_part(&part_name).map(<[u8]>::to_vec) else {
1724                continue;
1725            };
1726            if let Ok((new_xml, n)) =
1727                rdocx_oxml::placeholder::replace_regex_in_xml_part(&xml, re, replacement)
1728                && n > 0
1729            {
1730                self.package.set_part(&part_name, new_xml);
1731                count += n;
1732            }
1733        }
1734
1735        // Re-parse so the in-memory model reflects the edited markup; otherwise
1736        // the next flush would write the pre-replacement document back out.
1737        if count > 0
1738            && let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
1739            && let Ok(doc) = CT_Document::from_xml(doc_xml)
1740        {
1741            self.document = doc;
1742        }
1743
1744        count
1745    }
1746
1747    /// The main document part plus every header and footer part: everywhere
1748    /// text boxes and shapes with replaceable text can appear.
1749    fn text_bearing_part_names(&self) -> Vec<String> {
1750        let mut names = vec![self.doc_part_name.clone()];
1751        if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
1752            for (rel_id, _) in self.header_footer_rel_ids() {
1753                if let Some(rel) = rels.get_by_id(&rel_id) {
1754                    names.push(OpcPackage::resolve_rel_target(
1755                        &self.doc_part_name,
1756                        &rel.target,
1757                    ));
1758                }
1759            }
1760        }
1761        names
1762    }
1763
1764    /// Load a header/footer part by its relationship ID.
1765    fn load_header_footer(&self, rel_id: &str) -> Option<CT_HdrFtr> {
1766        let rels = self.package.get_part_rels(&self.doc_part_name)?;
1767        let rel = rels.get_by_id(rel_id)?;
1768        let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1769        let xml = self.package.get_part(&part_name)?;
1770        CT_HdrFtr::from_xml(xml).ok()
1771    }
1772
1773    /// Run raw XML replacement on all XML parts (for text boxes, shapes, charts, etc.).
1774    ///
1775    /// This is called after the typed-model replacement and flush_to_package.
1776    fn replace_in_xml_parts(&mut self, pairs: &[(&str, &str)]) -> usize {
1777        use rdocx_oxml::placeholder::{replace_many_in_chart_xml, replace_many_in_xml_part};
1778
1779        let mut count = 0;
1780
1781        // Collect part names for XML parts to process (text boxes/shapes)
1782        let mut xml_parts: Vec<String> = vec![self.doc_part_name.clone()];
1783        if let Some(sect_pr) = self.document.body.sect_pr.as_ref()
1784            && let Some(rels) = self.package.get_part_rels(&self.doc_part_name)
1785        {
1786            for href in &sect_pr.header_refs {
1787                if let Some(rel) = rels.get_by_id(&href.rel_id) {
1788                    xml_parts.push(OpcPackage::resolve_rel_target(
1789                        &self.doc_part_name,
1790                        &rel.target,
1791                    ));
1792                }
1793            }
1794            for fref in &sect_pr.footer_refs {
1795                if let Some(rel) = rels.get_by_id(&fref.rel_id) {
1796                    xml_parts.push(OpcPackage::resolve_rel_target(
1797                        &self.doc_part_name,
1798                        &rel.target,
1799                    ));
1800                }
1801            }
1802        }
1803
1804        for part_name in xml_parts {
1805            if let Some(xml) = self.package.get_part(&part_name) {
1806                let xml = xml.to_vec();
1807                if let Ok((new_xml, n)) = replace_many_in_xml_part(&xml, pairs)
1808                    && n > 0
1809                {
1810                    self.package.set_part(&part_name, new_xml);
1811                    count += n;
1812                }
1813            }
1814        }
1815
1816        // Collect chart part names
1817        let chart_parts: Vec<String> = self
1818            .package
1819            .get_part_rels(&self.doc_part_name)
1820            .map(|rels| {
1821                rels.get_all_by_type(rel_types::CHART)
1822                    .iter()
1823                    .map(|rel| OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target))
1824                    .collect()
1825            })
1826            .unwrap_or_default();
1827
1828        for part_name in chart_parts {
1829            if let Some(xml) = self.package.get_part(&part_name) {
1830                let xml = xml.to_vec();
1831                if let Ok((new_xml, n)) = replace_many_in_chart_xml(&xml, pairs)
1832                    && n > 0
1833                {
1834                    self.package.set_part(&part_name, new_xml);
1835                    count += n;
1836                }
1837            }
1838        }
1839
1840        // Re-parse document from the (possibly modified) package XML
1841        if count > 0
1842            && let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
1843            && let Ok(doc) = CT_Document::from_xml(doc_xml)
1844        {
1845            self.document = doc;
1846        }
1847
1848        count
1849    }
1850
1851    // ---- PDF conversion ----
1852
1853    /// Render the document to PDF bytes.
1854    ///
1855    /// This performs a full layout pass (font shaping, line breaking, pagination)
1856    /// and then renders the result to a PDF document.
1857    ///
1858    /// Font resolution order:
1859    /// 1. Fonts embedded in the DOCX file (word/fonts/)
1860    /// 2. System fonts
1861    /// 3. Bundled fonts (if `bundled-fonts` feature is enabled)
1862    pub fn to_pdf(&self) -> Result<Vec<u8>> {
1863        self.to_pdf_with_fonts(&[])
1864    }
1865
1866    /// Render the document to PDF bytes with user-provided font files.
1867    ///
1868    /// User-provided fonts take highest priority in font resolution.
1869    ///
1870    /// # Arguments
1871    /// * `font_files` - Additional font files to use. Each entry is `(family_name, font_bytes)`.
1872    ///
1873    /// Font resolution order:
1874    /// 1. User-provided fonts (this parameter)
1875    /// 2. Fonts embedded in the DOCX file (word/fonts/)
1876    /// 3. System fonts
1877    /// 4. Bundled fonts (if `bundled-fonts` feature is enabled)
1878    pub fn to_pdf_with_fonts(&self, font_files: &[(&str, &[u8])]) -> Result<Vec<u8>> {
1879        let mut input = self.build_layout_input();
1880        for (family, data) in font_files {
1881            input.fonts.push(rdocx_layout::FontFile {
1882                family: family.to_string(),
1883                data: data.to_vec(),
1884            });
1885        }
1886        let layout = rdocx_layout::layout_document(&input)?;
1887        Ok(rdocx_pdf::render_to_pdf(&layout))
1888    }
1889
1890    /// Save the document as a PDF file.
1891    pub fn save_pdf<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1892        let pdf_bytes = self.to_pdf()?;
1893        std::fs::write(path, pdf_bytes)?;
1894        Ok(())
1895    }
1896
1897    /// Convert the document to a complete HTML document string.
1898    pub fn to_html(&self) -> String {
1899        let input = self.build_html_input();
1900        rdocx_html::to_html_document(&input, &rdocx_html::HtmlOptions::default())
1901    }
1902
1903    /// Convert the document to an HTML fragment (body content only, no `<html>` wrapper).
1904    pub fn to_html_fragment(&self) -> String {
1905        let input = self.build_html_input();
1906        rdocx_html::to_html_fragment(&input, &rdocx_html::HtmlOptions::default())
1907    }
1908
1909    /// Convert the document to Markdown.
1910    pub fn to_markdown(&self) -> String {
1911        let input = self.build_html_input();
1912        rdocx_html::to_markdown(&input)
1913    }
1914
1915    /// Build an HtmlInput from the document's current state.
1916    fn build_html_input(&self) -> rdocx_html::HtmlInput {
1917        use rdocx_opc::relationship::rel_types;
1918        use std::collections::HashMap;
1919
1920        let mut images: HashMap<String, rdocx_html::ImageData> = HashMap::new();
1921        let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
1922
1923        if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
1924            for rel in &rels.items {
1925                match rel.rel_type.as_str() {
1926                    t if t == rel_types::IMAGE => {
1927                        let part_name =
1928                            OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1929                        if let Some(data) = self.package.get_part(&part_name) {
1930                            let content_type = guess_image_content_type(&part_name);
1931                            images.insert(
1932                                rel.id.clone(),
1933                                rdocx_html::ImageData {
1934                                    data: data.to_vec(),
1935                                    content_type,
1936                                },
1937                            );
1938                        }
1939                    }
1940                    t if t == rel_types::HYPERLINK
1941                        && rel.target_mode.as_ref().is_some_and(|m| m == "External") =>
1942                    {
1943                        hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
1944                    }
1945                    _ => {}
1946                }
1947            }
1948        }
1949
1950        rdocx_html::HtmlInput {
1951            document: self.document.clone(),
1952            styles: self.styles.clone(),
1953            numbering: self.numbering.clone(),
1954            images,
1955            hyperlink_urls,
1956        }
1957    }
1958
1959    /// Render a single page of the document to PNG bytes.
1960    ///
1961    /// # Arguments
1962    /// * `page_index` - 0-based page index
1963    /// * `dpi` - Resolution (72 = 1:1, 150 = standard, 300 = high quality)
1964    pub fn render_page_to_png(&self, page_index: usize, dpi: f64) -> Result<Option<Vec<u8>>> {
1965        let input = self.build_layout_input();
1966        let layout = rdocx_layout::layout_document(&input)?;
1967        Ok(rdocx_pdf::render_page_to_png(&layout, page_index, dpi))
1968    }
1969
1970    /// Render all pages of the document to PNG bytes.
1971    pub fn render_all_pages(&self, dpi: f64) -> Result<Vec<Vec<u8>>> {
1972        let input = self.build_layout_input();
1973        let layout = rdocx_layout::layout_document(&input)?;
1974        Ok(rdocx_pdf::render_all_pages(&layout, dpi))
1975    }
1976
1977    /// Build a LayoutInput from the document's current state.
1978    fn build_layout_input(&self) -> rdocx_layout::LayoutInput {
1979        use rdocx_layout::{ImageData, LayoutInput};
1980        use rdocx_opc::relationship::rel_types;
1981        use std::collections::HashMap;
1982
1983        let mut headers: HashMap<String, CT_HdrFtr> = HashMap::new();
1984        let mut footers: HashMap<String, CT_HdrFtr> = HashMap::new();
1985        let mut images: HashMap<String, ImageData> = HashMap::new();
1986        let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
1987        let mut footnotes = None;
1988        let mut endnotes = None;
1989
1990        // Extract embedded fonts from the DOCX package
1991        let fonts = self.extract_embedded_fonts();
1992
1993        if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
1994            for rel in &rels.items {
1995                match rel.rel_type.as_str() {
1996                    t if t == rel_types::HEADER => {
1997                        let part_name =
1998                            OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
1999                        if let Some(xml) = self.package.get_part(&part_name)
2000                            && let Ok(hf) = CT_HdrFtr::from_xml(xml)
2001                        {
2002                            headers.insert(rel.id.clone(), hf);
2003                        }
2004                    }
2005                    t if t == rel_types::FOOTER => {
2006                        let part_name =
2007                            OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2008                        if let Some(xml) = self.package.get_part(&part_name)
2009                            && let Ok(hf) = CT_HdrFtr::from_xml(xml)
2010                        {
2011                            footers.insert(rel.id.clone(), hf);
2012                        }
2013                    }
2014                    t if t == rel_types::IMAGE => {
2015                        let part_name =
2016                            OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2017                        if let Some(data) = self.package.get_part(&part_name) {
2018                            let content_type = guess_image_content_type(&part_name);
2019                            images.insert(
2020                                rel.id.clone(),
2021                                ImageData {
2022                                    data: data.to_vec(),
2023                                    content_type,
2024                                },
2025                            );
2026                        }
2027                    }
2028                    t if t == rel_types::HYPERLINK => {
2029                        if rel.target_mode.as_ref().is_some_and(|m| m == "External") {
2030                            hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
2031                        }
2032                    }
2033                    t if t == rel_types::FOOTNOTES => {
2034                        let part_name =
2035                            OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2036                        if let Some(xml) = self.package.get_part(&part_name) {
2037                            footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
2038                        }
2039                    }
2040                    t if t == rel_types::ENDNOTES => {
2041                        let part_name =
2042                            OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
2043                        if let Some(xml) = self.package.get_part(&part_name) {
2044                            endnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
2045                        }
2046                    }
2047                    _ => {}
2048                }
2049            }
2050        }
2051
2052        // Parse theme if available
2053        let theme = self
2054            .package
2055            .get_part("/word/theme/theme1.xml")
2056            .and_then(|data| rdocx_oxml::theme::Theme::from_xml(data).ok());
2057
2058        LayoutInput {
2059            document: self.document.clone(),
2060            styles: self.styles.clone(),
2061            numbering: self.numbering.clone(),
2062            headers,
2063            footers,
2064            images,
2065            core_properties: self.core_properties.clone(),
2066            hyperlink_urls,
2067            footnotes,
2068            endnotes,
2069            theme,
2070            fonts,
2071        }
2072    }
2073
2074    /// Extract embedded fonts from the DOCX package.
2075    ///
2076    /// Word can embed fonts as `.odttf` (obfuscated TrueType) or regular `.ttf`/`.otf`
2077    /// files in the `word/fonts/` directory. ODTTF files have the first 32 bytes
2078    /// XOR'd with a 16-byte GUID derived from the font's relationship ID.
2079    fn extract_embedded_fonts(&self) -> Vec<rdocx_layout::FontFile> {
2080        let mut fonts = Vec::new();
2081
2082        // Look for font parts in word/fonts/ directory
2083        for (part_name, data) in &self.package.parts {
2084            let lower = part_name.to_lowercase();
2085            if !lower.contains("/word/fonts/") && !lower.contains("/word/font") {
2086                continue;
2087            }
2088
2089            // Determine font family name from the file name
2090            let file_name = part_name.rsplit('/').next().unwrap_or(part_name);
2091            let family = file_name.split('.').next().unwrap_or(file_name).to_string();
2092
2093            if lower.ends_with(".odttf") {
2094                // Deobfuscate ODTTF: XOR first 32 bytes with GUID from the file name
2095                if let Some(deobfuscated) = deobfuscate_odttf(data, file_name) {
2096                    fonts.push(rdocx_layout::FontFile {
2097                        family,
2098                        data: deobfuscated,
2099                    });
2100                }
2101            } else if lower.ends_with(".ttf") || lower.ends_with(".otf") || lower.ends_with(".ttc")
2102            {
2103                fonts.push(rdocx_layout::FontFile {
2104                    family,
2105                    data: data.clone(),
2106                });
2107            }
2108        }
2109
2110        fonts
2111    }
2112
2113    /// Load font files from a directory and return them as FontFile entries.
2114    ///
2115    /// This is useful for CLI tools that accept a `--font-dir` argument.
2116    /// Supports `.ttf`, `.otf`, and `.ttc` files.
2117    pub fn load_fonts_from_dir<P: AsRef<Path>>(dir: P) -> Vec<rdocx_layout::FontFile> {
2118        let mut fonts = Vec::new();
2119        let dir = dir.as_ref();
2120        if let Ok(entries) = std::fs::read_dir(dir) {
2121            for entry in entries.flatten() {
2122                let path = entry.path();
2123                let ext = path
2124                    .extension()
2125                    .and_then(|e| e.to_str())
2126                    .unwrap_or("")
2127                    .to_lowercase();
2128                if (ext == "ttf" || ext == "otf" || ext == "ttc")
2129                    && let Ok(data) = std::fs::read(&path)
2130                {
2131                    let family = path
2132                        .file_stem()
2133                        .and_then(|s| s.to_str())
2134                        .unwrap_or("Unknown")
2135                        .to_string();
2136                    fonts.push(rdocx_layout::FontFile { family, data });
2137                }
2138            }
2139        }
2140        fonts
2141    }
2142
2143    /// Save a header/footer part back to the OPC package.
2144    fn save_header_footer(&mut self, rel_id: &str, hf: &CT_HdrFtr, is_header: bool) {
2145        let part_name = {
2146            let rels = self.package.get_part_rels(&self.doc_part_name);
2147            rels.and_then(|r| r.get_by_id(rel_id))
2148                .map(|rel| OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target))
2149        };
2150        if let Some(part_name) = part_name {
2151            let xml = if is_header {
2152                hf.to_xml_header()
2153            } else {
2154                hf.to_xml_footer()
2155            };
2156            if let Ok(xml) = xml {
2157                self.package.set_part(&part_name, xml);
2158            }
2159        }
2160    }
2161
2162    // ---- Document Intelligence API ----
2163
2164    /// Get all headings in the document as (level, text) pairs.
2165    ///
2166    /// Detects heading paragraphs by their style ID (e.g. "Heading1", "Heading2").
2167    pub fn headings(&self) -> Vec<(u32, String)> {
2168        let mut result = Vec::new();
2169        for content in &self.document.body.content {
2170            if let BodyContent::Paragraph(p) = content
2171                && let Some(level) = Self::detect_heading_level_for_toc(p)
2172            {
2173                result.push((level, p.text()));
2174            }
2175        }
2176        result
2177    }
2178
2179    /// Get a hierarchical outline of the document headings.
2180    ///
2181    /// Returns a tree structure where each node contains the heading level,
2182    /// text, and children (sub-headings).
2183    pub fn document_outline(&self) -> Vec<OutlineNode> {
2184        let headings = self.headings();
2185        build_outline_tree(&headings)
2186    }
2187
2188    /// Get information about all images in the document.
2189    ///
2190    /// Returns metadata for each inline and anchored image found in body paragraphs.
2191    pub fn images(&self) -> Vec<ImageInfo> {
2192        let mut result = Vec::new();
2193
2194        for content in &self.document.body.content {
2195            Self::collect_images_from_content(content, &mut result);
2196        }
2197        result
2198    }
2199
2200    fn collect_images_from_content(content: &BodyContent, result: &mut Vec<ImageInfo>) {
2201        match content {
2202            BodyContent::Paragraph(p) => Self::collect_images_from_paragraph(p, result),
2203            BodyContent::Table(tbl) => Self::collect_images_from_table(tbl, result),
2204            BodyContent::RawXml(_) => {}
2205        }
2206    }
2207
2208    fn collect_images_from_paragraph(p: &CT_P, result: &mut Vec<ImageInfo>) {
2209        for run in &p.runs {
2210            for rc in &run.content {
2211                let RunContent::Drawing(drawing) = rc else {
2212                    continue;
2213                };
2214                if let Some(inline) = &drawing.inline {
2215                    result.push(ImageInfo {
2216                        embed_id: inline.embed_id.clone(),
2217                        name: inline.name.clone(),
2218                        description: inline.description.clone(),
2219                        width_emu: inline.extent_cx.0,
2220                        height_emu: inline.extent_cy.0,
2221                        is_anchor: false,
2222                    });
2223                }
2224                if let Some(anchor) = &drawing.anchor {
2225                    result.push(ImageInfo {
2226                        embed_id: anchor.embed_id.clone(),
2227                        name: anchor.name.clone(),
2228                        description: anchor.description.clone(),
2229                        width_emu: anchor.extent_cx.0,
2230                        height_emu: anchor.extent_cy.0,
2231                        is_anchor: true,
2232                    });
2233                }
2234            }
2235        }
2236    }
2237
2238    fn collect_images_from_table(tbl: &CT_Tbl, result: &mut Vec<ImageInfo>) {
2239        use rdocx_oxml::table::CellContent;
2240
2241        for row in &tbl.rows {
2242            for cell in &row.cells {
2243                for cc in &cell.content {
2244                    match cc {
2245                        CellContent::Paragraph(p) => Self::collect_images_from_paragraph(p, result),
2246                        CellContent::Table(nested) => {
2247                            Self::collect_images_from_table(nested, result)
2248                        }
2249                    }
2250                }
2251            }
2252        }
2253    }
2254
2255    /// Get information about all hyperlinks in the document.
2256    ///
2257    /// Resolves hyperlink relationship IDs to their target URLs where possible.
2258    pub fn links(&self) -> Vec<LinkInfo> {
2259        use rdocx_opc::relationship::rel_types;
2260
2261        // Build a map of hyperlink rel_id -> target URL
2262        let mut url_map = std::collections::HashMap::new();
2263        if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
2264            for rel in &rels.items {
2265                if rel.rel_type == rel_types::HYPERLINK
2266                    && rel.target_mode.as_ref().is_some_and(|m| m == "External")
2267                {
2268                    url_map.insert(rel.id.clone(), rel.target.clone());
2269                }
2270            }
2271        }
2272
2273        let mut result = Vec::new();
2274        for content in &self.document.body.content {
2275            if let BodyContent::Paragraph(p) = content {
2276                for hl in &p.hyperlinks {
2277                    // `HyperlinkSpan`'s bounds are public and can be set by
2278                    // hand, so clamp rather than slice-panic on a bad range.
2279                    let start = hl.run_start.min(p.runs.len());
2280                    let end = hl.run_end.clamp(start, p.runs.len());
2281                    let text: String = p.runs[start..end].iter().map(|r| r.text()).collect();
2282
2283                    let url = hl.rel_id.as_ref().and_then(|id| url_map.get(id)).cloned();
2284
2285                    result.push(LinkInfo {
2286                        text,
2287                        url,
2288                        anchor: hl.anchor.clone(),
2289                        rel_id: hl.rel_id.clone(),
2290                    });
2291                }
2292            }
2293        }
2294        result
2295    }
2296
2297    /// Count the number of words in the document.
2298    ///
2299    /// Counts whitespace-separated tokens across all paragraphs (including
2300    /// paragraphs inside table cells).
2301    pub fn word_count(&self) -> usize {
2302        let mut count = 0;
2303        for content in &self.document.body.content {
2304            count += Self::word_count_in_content(content);
2305        }
2306        count
2307    }
2308
2309    fn word_count_in_content(content: &BodyContent) -> usize {
2310        match content {
2311            BodyContent::Paragraph(p) => p.text().split_whitespace().count(),
2312            BodyContent::Table(tbl) => Self::word_count_in_table(tbl),
2313            BodyContent::RawXml(_) => 0,
2314        }
2315    }
2316
2317    fn word_count_in_table(tbl: &CT_Tbl) -> usize {
2318        use rdocx_oxml::table::CellContent;
2319
2320        let mut count = 0;
2321        for row in &tbl.rows {
2322            for cell in &row.cells {
2323                for cc in &cell.content {
2324                    match cc {
2325                        CellContent::Paragraph(p) => {
2326                            count += p.text().split_whitespace().count();
2327                        }
2328                        CellContent::Table(nested) => {
2329                            count += Self::word_count_in_table(nested);
2330                        }
2331                    }
2332                }
2333            }
2334        }
2335        count
2336    }
2337
2338    /// Audit the document for accessibility issues.
2339    ///
2340    /// Checks for common problems: missing image alt text, heading level gaps,
2341    /// empty paragraphs, missing document metadata.
2342    pub fn audit_accessibility(&self) -> Vec<AccessibilityIssue> {
2343        let mut issues = Vec::new();
2344
2345        // Check: missing document title
2346        if self.title().is_none() {
2347            issues.push(AccessibilityIssue {
2348                severity: IssueSeverity::Warning,
2349                message: "Document has no title".to_string(),
2350            });
2351        }
2352
2353        // Check: missing document language (author as a proxy for basic metadata)
2354        if self.author().is_none() {
2355            issues.push(AccessibilityIssue {
2356                severity: IssueSeverity::Info,
2357                message: "Document has no author".to_string(),
2358            });
2359        }
2360
2361        // Check: images without alt text
2362        let images = self.images();
2363        for img in &images {
2364            let has_alt = img
2365                .description
2366                .as_ref()
2367                .is_some_and(|d| !d.is_empty() && d != "Background");
2368            if !has_alt {
2369                let name = img
2370                    .name
2371                    .as_deref()
2372                    .or(Some(&img.embed_id))
2373                    .unwrap_or("unknown");
2374                issues.push(AccessibilityIssue {
2375                    severity: IssueSeverity::Error,
2376                    message: format!("Image \"{name}\" has no alt text"),
2377                });
2378            }
2379        }
2380
2381        // Check: heading level gaps
2382        let headings = self.headings();
2383        let mut prev_level: Option<u32> = None;
2384        for (level, text) in &headings {
2385            if let Some(prev) = prev_level
2386                && *level > prev + 1
2387            {
2388                issues.push(AccessibilityIssue {
2389                    severity: IssueSeverity::Warning,
2390                    message: format!(
2391                        "Heading level gap: h{prev} -> h{level} (\"{}\")",
2392                        truncate_str(text, 40)
2393                    ),
2394                });
2395            }
2396            prev_level = Some(*level);
2397        }
2398
2399        // Check: excessive empty paragraphs
2400        let mut consecutive_empty = 0u32;
2401        for content in &self.document.body.content {
2402            if let BodyContent::Paragraph(p) = content {
2403                if p.text().trim().is_empty() {
2404                    consecutive_empty += 1;
2405                    if consecutive_empty >= 3 {
2406                        issues.push(AccessibilityIssue {
2407                            severity: IssueSeverity::Info,
2408                            message: format!(
2409                                "{consecutive_empty} consecutive empty paragraphs (consider using spacing instead)"
2410                            ),
2411                        });
2412                    }
2413                } else {
2414                    consecutive_empty = 0;
2415                }
2416            } else {
2417                consecutive_empty = 0;
2418            }
2419        }
2420
2421        issues
2422    }
2423}
2424
2425impl Default for Document {
2426    fn default() -> Self {
2427        Self::new()
2428    }
2429}
2430
2431/// Express `target_part` relative to the directory holding `source_part`.
2432///
2433/// Falls back to the absolute part name when the two live in different
2434/// directories, which OPC also permits.
2435fn relative_target(source_part: &str, target_part: &str) -> String {
2436    let dir = match source_part.rfind('/') {
2437        Some(pos) => &source_part[..=pos],
2438        None => "/",
2439    };
2440    match target_part.strip_prefix(dir) {
2441        Some(rest) if !rest.contains('/') => rest.to_string(),
2442        _ => target_part.to_string(),
2443    }
2444}
2445
2446/// The lower-cased file extension of `filename`, defaulting to `png`.
2447fn image_extension(filename: &str) -> String {
2448    match filename.rsplit_once('.') {
2449        Some((_, ext)) if !ext.is_empty() => ext.to_lowercase(),
2450        _ => "png".to_string(),
2451    }
2452}
2453
2454/// Map an image file extension to its MIME type.
2455///
2456/// This is the single place the mapping lives; header, footer, body and
2457/// raw-XML image paths all go through it, so they cannot drift apart and
2458/// start disagreeing about, say, whether GIF is supported.
2459fn image_content_type(ext: &str) -> &'static str {
2460    match ext {
2461        "jpg" | "jpeg" => "image/jpeg",
2462        "gif" => "image/gif",
2463        "bmp" => "image/bmp",
2464        "tiff" | "tif" => "image/tiff",
2465        "svg" => "image/svg+xml",
2466        "webp" => "image/webp",
2467        // PNG is both the common case and a safe default for unknown types.
2468        _ => "image/png",
2469    }
2470}
2471
2472/// Guess image content type from the part name extension.
2473fn guess_image_content_type(part_name: &str) -> String {
2474    image_content_type(&image_extension(part_name)).to_string()
2475}
2476
2477/// A node in the document outline tree.
2478#[derive(Debug, Clone, PartialEq)]
2479pub struct OutlineNode {
2480    /// The heading level (1-9).
2481    pub level: u32,
2482    /// The heading text.
2483    pub text: String,
2484    /// Child headings (sub-headings).
2485    pub children: Vec<OutlineNode>,
2486}
2487
2488/// Information about an image in the document.
2489#[derive(Debug, Clone, PartialEq)]
2490pub struct ImageInfo {
2491    /// The relationship ID for the embedded image.
2492    pub embed_id: String,
2493    /// Optional name attribute.
2494    pub name: Option<String>,
2495    /// Optional description (alt text).
2496    pub description: Option<String>,
2497    /// Width in EMUs (English Metric Units, 914400 EMU = 1 inch).
2498    pub width_emu: i64,
2499    /// Height in EMUs.
2500    pub height_emu: i64,
2501    /// Whether this is an anchored (floating) image vs inline.
2502    pub is_anchor: bool,
2503}
2504
2505/// Information about a hyperlink in the document.
2506#[derive(Debug, Clone, PartialEq)]
2507pub struct LinkInfo {
2508    /// The display text of the hyperlink.
2509    pub text: String,
2510    /// The resolved target URL (if external).
2511    pub url: Option<String>,
2512    /// Internal document anchor (if any).
2513    pub anchor: Option<String>,
2514    /// The relationship ID.
2515    pub rel_id: Option<String>,
2516}
2517
2518/// Severity level for accessibility issues.
2519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2520pub enum IssueSeverity {
2521    /// Informational suggestion.
2522    Info,
2523    /// Potential problem.
2524    Warning,
2525    /// Definite accessibility barrier.
2526    Error,
2527}
2528
2529/// An accessibility issue found during audit.
2530#[derive(Debug, Clone, PartialEq)]
2531pub struct AccessibilityIssue {
2532    /// How severe the issue is.
2533    pub severity: IssueSeverity,
2534    /// Human-readable description of the issue.
2535    pub message: String,
2536}
2537
2538/// Build a hierarchical outline tree from a flat list of (level, text) headings.
2539fn build_outline_tree(headings: &[(u32, String)]) -> Vec<OutlineNode> {
2540    let mut root: Vec<OutlineNode> = Vec::new();
2541    let mut stack: Vec<(u32, usize)> = Vec::new(); // (level, index in parent's children)
2542
2543    for (level, text) in headings {
2544        let node = OutlineNode {
2545            level: *level,
2546            text: text.clone(),
2547            children: Vec::new(),
2548        };
2549
2550        // Pop stack until we find a parent with a lower level
2551        while let Some(&(stack_level, _)) = stack.last() {
2552            if stack_level >= *level {
2553                stack.pop();
2554            } else {
2555                break;
2556            }
2557        }
2558
2559        if stack.is_empty() {
2560            root.push(node);
2561            let idx = root.len() - 1;
2562            stack.push((*level, idx));
2563        } else {
2564            // Navigate to the correct parent in the tree
2565            let target = get_outline_parent_mut(&mut root, &stack);
2566            target.children.push(node);
2567            let idx = target.children.len() - 1;
2568            stack.push((*level, idx));
2569        }
2570    }
2571
2572    root
2573}
2574
2575/// Navigate to the parent node indicated by the stack.
2576fn get_outline_parent_mut<'a>(
2577    root: &'a mut [OutlineNode],
2578    stack: &[(u32, usize)],
2579) -> &'a mut OutlineNode {
2580    let mut current = &mut root[stack[0].1];
2581    for &(_, idx) in &stack[1..] {
2582        current = &mut current.children[idx];
2583    }
2584    current
2585}
2586
2587/// Truncate a string to at most `max_len` characters, appending "..." if it
2588/// was cut short.
2589///
2590/// Both the comparison and the cut are in characters; mixing byte length with
2591/// character counts would truncate non-ASCII text earlier than asked.
2592fn truncate_str(s: &str, max_len: usize) -> String {
2593    if s.chars().count() <= max_len {
2594        return s.to_string();
2595    }
2596    let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
2597    format!("{truncated}...")
2598}
2599
2600/// Deobfuscate an ODTTF (obfuscated TrueType) font file.
2601///
2602/// Word embeds fonts as `.odttf` files whose first 32 bytes are XOR'd with a
2603/// 16-byte key derived from the GUID in the part name (ECMA-376 Part 1,
2604/// "Embedded Font Obfuscation"). The GUID hex is read into the key *backwards*,
2605/// but implementations differ in whether they reverse the raw hex string or the
2606/// mixed-endian layout .NET's `Guid.ToByteArray` produces — the two agree on
2607/// the first eight key bytes and disagree on the rest.
2608///
2609/// Rather than pick one and hope, both orders are tried and the result is only
2610/// accepted if it starts with a recognised sfnt version. A wrong key yields
2611/// bytes that no font parser can use, so validating here means a bad guess
2612/// degrades to "font not embedded" instead of feeding garbage downstream.
2613fn deobfuscate_odttf(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
2614    if data.len() < 32 {
2615        return None;
2616    }
2617
2618    // Extract GUID from file name: "00112233-4455-6677-8899-AABBCCDDEEFF.odttf"
2619    // or "{00112233-4455-6677-8899-AABBCCDDEEFF}.odttf"
2620    let name = file_name
2621        .split('.')
2622        .next()
2623        .unwrap_or("")
2624        .trim_start_matches('{')
2625        .trim_end_matches('}');
2626
2627    // Remove hyphens and parse as hex bytes
2628    let hex: String = name.chars().filter(|c| c.is_ascii_hexdigit()).collect();
2629    if hex.len() != 32 {
2630        return None;
2631    }
2632
2633    let mut guid = [0u8; 16];
2634    for (i, byte) in guid.iter_mut().enumerate() {
2635        *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
2636    }
2637
2638    let candidates = odttf_key_candidates(&guid);
2639    let decoded: Vec<Vec<u8>> = candidates
2640        .iter()
2641        .map(|key| {
2642            let mut result = data.to_vec();
2643            // XOR the first 32 bytes with the 16-byte key, applied twice.
2644            for (i, byte) in result.iter_mut().take(32).enumerate() {
2645                *byte ^= key[i % 16];
2646            }
2647            result
2648        })
2649        .collect();
2650
2651    // A well-formed table directory pins down which key was used. Fall back to
2652    // the weaker signature check for fonts whose header arithmetic is wrong —
2653    // subsetting tools do emit those — so they still load rather than being
2654    // dropped entirely.
2655    decoded
2656        .iter()
2657        .find(|d| has_consistent_sfnt_header(d))
2658        .or_else(|| decoded.iter().find(|d| looks_like_sfnt(d)))
2659        .cloned()
2660}
2661
2662/// The two candidate XOR keys for ODTTF deobfuscation, most likely first.
2663fn odttf_key_candidates(guid: &[u8; 16]) -> [[u8; 16]; 2] {
2664    // Read the hex string end-first, as the spec prose describes.
2665    let mut plain_reversed = *guid;
2666    plain_reversed.reverse();
2667
2668    // The .NET route: `Guid.ToByteArray` byte-swaps the first three groups,
2669    // and the whole array is then reversed.
2670    let dotnet = [
2671        guid[3], guid[2], guid[1], guid[0], guid[5], guid[4], guid[7], guid[6], guid[8], guid[9],
2672        guid[10], guid[11], guid[12], guid[13], guid[14], guid[15],
2673    ];
2674    let mut dotnet_reversed = dotnet;
2675    dotnet_reversed.reverse();
2676
2677    [plain_reversed, dotnet_reversed]
2678}
2679
2680/// Check that `data` opens with a plausible sfnt (TrueType/OpenType) header.
2681///
2682/// This is the weak test: signature plus printable table tags. It cannot always
2683/// tell the two ODTTF key conventions apart, since they produce identical
2684/// output for the first eight bytes.
2685fn looks_like_sfnt(data: &[u8]) -> bool {
2686    let Some(signature) = data.first_chunk::<4>() else {
2687        return false;
2688    };
2689    match signature {
2690        b"\x00\x01\x00\x00" | b"OTTO" | b"true" => {}
2691        // A collection header has a different layout; take it on signature.
2692        b"ttcf" => return true,
2693        _ => return false,
2694    }
2695
2696    if data.len() < 32 {
2697        return false;
2698    }
2699
2700    let num_tables = u16::from_be_bytes([data[4], data[5]]);
2701    if num_tables == 0 || num_tables > 512 {
2702        return false;
2703    }
2704
2705    // Table records begin at offset 12 and are 16 bytes each, so the first
2706    // record's tag is at 12..16 and the second record's tag at 28..32 — both
2707    // inside the 32 bytes the obfuscation touches.
2708    let is_tag = |tag: &[u8]| tag.iter().all(|b| (0x20..=0x7E).contains(b));
2709    is_tag(&data[12..16]) && (num_tables < 2 || is_tag(&data[28..32]))
2710}
2711
2712/// The strong test: the sfnt header's binary-search hints must agree with the
2713/// table count.
2714///
2715/// `searchRange`, `entrySelector` and `rangeShift` are all derived from
2716/// `numTables`, and `entrySelector`/`rangeShift` sit in the byte range where
2717/// the two ODTTF key conventions differ — so this identifies the right key
2718/// outright whenever the font's header is spec-conformant.
2719fn has_consistent_sfnt_header(data: &[u8]) -> bool {
2720    if !looks_like_sfnt(data) || data.len() < 12 {
2721        return false;
2722    }
2723    if data.first_chunk::<4>() == Some(b"ttcf") {
2724        return false; // no table directory at this offset
2725    }
2726
2727    let num_tables = u16::from_be_bytes([data[4], data[5]]);
2728    let search_range = u16::from_be_bytes([data[6], data[7]]);
2729    let entry_selector = u16::from_be_bytes([data[8], data[9]]);
2730    let range_shift = u16::from_be_bytes([data[10], data[11]]);
2731
2732    let expected_selector = num_tables.ilog2() as u16;
2733    let expected_search_range = (1u16 << expected_selector) * 16;
2734    let expected_range_shift = num_tables
2735        .wrapping_mul(16)
2736        .wrapping_sub(expected_search_range);
2737
2738    search_range == expected_search_range
2739        && entry_selector == expected_selector
2740        && range_shift == expected_range_shift
2741}
2742
2743#[cfg(test)]
2744mod tests {
2745    use super::*;
2746    use crate::paragraph::Alignment;
2747    use rdocx_oxml::units::{HalfPoint, Twips};
2748
2749    #[test]
2750    fn create_new_document() {
2751        let doc = Document::new();
2752        assert_eq!(doc.paragraph_count(), 0);
2753        assert!(doc.section_properties().is_some());
2754    }
2755
2756    #[test]
2757    fn add_paragraphs() {
2758        let mut doc = Document::new();
2759        doc.add_paragraph("First paragraph");
2760        doc.add_paragraph("Second paragraph");
2761        assert_eq!(doc.paragraph_count(), 2);
2762
2763        let paras = doc.paragraphs();
2764        assert_eq!(paras[0].text(), "First paragraph");
2765        assert_eq!(paras[1].text(), "Second paragraph");
2766    }
2767
2768    #[test]
2769    fn paragraph_formatting() {
2770        let mut doc = Document::new();
2771        doc.add_paragraph("Centered").alignment(Alignment::Center);
2772
2773        let paras = doc.paragraphs();
2774        assert_eq!(paras[0].alignment(), Some(Alignment::Center));
2775    }
2776
2777    #[test]
2778    fn run_formatting() {
2779        let mut doc = Document::new();
2780        let mut para = doc.add_paragraph("");
2781        para.add_run("Bold text").bold(true).size(14.0);
2782
2783        let paras = doc.paragraphs();
2784        let runs: Vec<_> = paras[0].runs().collect();
2785        assert!(runs[0].is_bold());
2786        assert_eq!(runs[0].size(), Some(14.0));
2787    }
2788
2789    #[test]
2790    fn round_trip_in_memory() {
2791        let mut doc = Document::new();
2792        doc.add_paragraph("Hello, World!");
2793        doc.add_paragraph("Second paragraph")
2794            .alignment(Alignment::Center);
2795
2796        let bytes = doc.to_bytes().unwrap();
2797        let doc2 = Document::from_bytes(&bytes).unwrap();
2798
2799        assert_eq!(doc2.paragraph_count(), 2);
2800        let paras = doc2.paragraphs();
2801        assert_eq!(paras[0].text(), "Hello, World!");
2802        assert_eq!(paras[1].text(), "Second paragraph");
2803        assert_eq!(paras[1].alignment(), Some(Alignment::Center));
2804    }
2805
2806    #[test]
2807    fn styles_present() {
2808        let doc = Document::new();
2809        assert!(doc.style("Normal").is_some());
2810        assert!(doc.style("Heading1").is_some());
2811    }
2812
2813    #[test]
2814    fn paragraph_with_style() {
2815        let mut doc = Document::new();
2816        doc.add_paragraph("Title").style("Heading1");
2817
2818        let paras = doc.paragraphs();
2819        assert_eq!(paras[0].style_id(), Some("Heading1"));
2820    }
2821
2822    #[test]
2823    fn multiple_runs_in_paragraph() {
2824        let mut doc = Document::new();
2825        let mut para = doc.add_paragraph("");
2826        para.add_run("Normal ");
2827        para.add_run("bold ").bold(true);
2828        para.add_run("italic").italic(true);
2829
2830        let paras = doc.paragraphs();
2831        assert_eq!(paras[0].text(), "Normal bold italic");
2832        let runs: Vec<_> = paras[0].runs().collect();
2833        assert_eq!(runs.len(), 3);
2834        assert!(!runs[0].is_bold());
2835        assert!(runs[1].is_bold());
2836        assert!(runs[2].is_italic());
2837    }
2838
2839    #[test]
2840    fn add_custom_style() {
2841        let mut doc = Document::new();
2842        doc.add_style(StyleBuilder::paragraph("MyCustom", "My Custom Style").based_on("Normal"));
2843        assert!(doc.style("MyCustom").is_some());
2844        let s = doc.style("MyCustom").unwrap();
2845        assert_eq!(s.name(), Some("My Custom Style"));
2846        assert_eq!(s.based_on(), Some("Normal"));
2847    }
2848
2849    #[test]
2850    fn resolve_style_properties() {
2851        let doc = Document::new();
2852        // Heading1 should inherit from docDefaults and have its own overrides
2853        let ppr = doc.resolve_paragraph_properties(Some("Heading1"));
2854        assert_eq!(ppr.keep_next, Some(true));
2855        assert_eq!(ppr.space_before, Some(Twips(240)));
2856
2857        // Default (None) should apply Normal style
2858        let ppr = doc.resolve_paragraph_properties(None);
2859        assert_eq!(ppr.space_after, Some(Twips(160)));
2860    }
2861
2862    #[test]
2863    fn resolve_run_style_properties() {
2864        let doc = Document::new();
2865        let rpr = doc.resolve_run_properties(Some("Heading1"), None);
2866        assert_eq!(rpr.bold, Some(true));
2867        assert_eq!(rpr.sz, Some(HalfPoint(32)));
2868        assert_eq!(rpr.font_ascii, Some("Calibri".to_string()));
2869    }
2870
2871    #[test]
2872    fn set_landscape() {
2873        let mut doc = Document::new();
2874        doc.set_landscape();
2875        let sect = doc.section_properties().unwrap();
2876        assert_eq!(sect.orientation, Some(ST_PageOrientation::Landscape));
2877        // Width should be > height in landscape
2878        assert!(sect.page_width.unwrap().0 > sect.page_height.unwrap().0);
2879    }
2880
2881    #[test]
2882    fn set_margins() {
2883        let mut doc = Document::new();
2884        doc.set_margins(
2885            Length::inches(0.5),
2886            Length::inches(0.75),
2887            Length::inches(0.5),
2888            Length::inches(0.75),
2889        );
2890        let sect = doc.section_properties().unwrap();
2891        assert_eq!(sect.margin_top, Some(Twips(720)));
2892        assert_eq!(sect.margin_right, Some(Twips(1080)));
2893    }
2894
2895    #[test]
2896    fn set_columns() {
2897        let mut doc = Document::new();
2898        doc.set_columns(2, Length::inches(0.5));
2899        let sect = doc.section_properties().unwrap();
2900        let cols = sect.columns.as_ref().unwrap();
2901        assert_eq!(cols.num, Some(2));
2902        assert_eq!(cols.space, Some(Twips(720)));
2903        assert_eq!(cols.equal_width, Some(true));
2904    }
2905
2906    #[test]
2907    fn set_page_size() {
2908        let mut doc = Document::new();
2909        doc.set_page_size(Length::cm(21.0), Length::cm(29.7));
2910        let sect = doc.section_properties().unwrap();
2911        // A4: ~11906tw x ~16838tw
2912        let w = sect.page_width.unwrap().0;
2913        let h = sect.page_height.unwrap().0;
2914        assert!((w - 11906).abs() < 5);
2915        assert!((h - 16838).abs() < 5);
2916    }
2917
2918    #[test]
2919    fn set_different_first_page() {
2920        let mut doc = Document::new();
2921        doc.set_different_first_page(true);
2922        assert_eq!(doc.section_properties().unwrap().title_pg, Some(true));
2923    }
2924
2925    #[test]
2926    fn content_insertion_api() {
2927        let mut doc = Document::new();
2928        doc.add_paragraph("First");
2929        doc.add_paragraph("Third");
2930
2931        // Insert in middle
2932        doc.insert_paragraph(1, "Second");
2933        assert_eq!(doc.content_count(), 3);
2934        let paras = doc.paragraphs();
2935        assert_eq!(paras[0].text(), "First");
2936        assert_eq!(paras[1].text(), "Second");
2937        assert_eq!(paras[2].text(), "Third");
2938
2939        // Insert at beginning
2940        doc.insert_paragraph(0, "Zeroth");
2941        assert_eq!(doc.content_count(), 4);
2942        assert_eq!(doc.paragraphs()[0].text(), "Zeroth");
2943    }
2944
2945    #[test]
2946    fn find_content_index_and_remove() {
2947        let mut doc = Document::new();
2948        doc.add_paragraph("Hello");
2949        doc.add_paragraph("{{PLACEHOLDER}}");
2950        doc.add_paragraph("World");
2951
2952        assert_eq!(doc.find_content_index("{{PLACEHOLDER}}"), Some(1));
2953        assert_eq!(doc.find_content_index("NONEXISTENT"), None);
2954
2955        assert!(doc.remove_content(1));
2956        assert_eq!(doc.content_count(), 2);
2957        assert_eq!(doc.paragraphs()[1].text(), "World");
2958
2959        // Out of bounds
2960        assert!(!doc.remove_content(10));
2961    }
2962
2963    #[test]
2964    fn insert_table_at_index() {
2965        let mut doc = Document::new();
2966        doc.add_paragraph("Before");
2967        doc.add_paragraph("After");
2968
2969        doc.insert_table(1, 2, 3);
2970        assert_eq!(doc.content_count(), 3);
2971        assert_eq!(doc.table_count(), 1);
2972        // Paragraphs are still in correct order
2973        let paras = doc.paragraphs();
2974        assert_eq!(paras[0].text(), "Before");
2975        assert_eq!(paras[1].text(), "After");
2976    }
2977
2978    #[test]
2979    fn replace_text_in_body() {
2980        let mut doc = Document::new();
2981        doc.add_paragraph("Hello {{name}}!");
2982        doc.add_paragraph("Welcome to {{company}}.");
2983
2984        let count = doc.replace_text("{{name}}", "Alice");
2985        assert_eq!(count, 1);
2986        assert_eq!(doc.paragraphs()[0].text(), "Hello Alice!");
2987
2988        let count = doc.replace_text("{{company}}", "Acme");
2989        assert_eq!(count, 1);
2990        assert_eq!(doc.paragraphs()[1].text(), "Welcome to Acme.");
2991    }
2992
2993    #[test]
2994    fn replace_text_in_header_and_footer() {
2995        let mut doc = Document::new();
2996        doc.set_header("Header: {{title}}");
2997        doc.set_footer("Footer: {{title}}");
2998        doc.add_paragraph("Body: {{title}}");
2999
3000        let count = doc.replace_text("{{title}}", "My Doc");
3001        assert_eq!(count, 3);
3002
3003        assert_eq!(doc.paragraphs()[0].text(), "Body: My Doc");
3004        assert_eq!(doc.header_text().unwrap(), "Header: My Doc");
3005        assert_eq!(doc.footer_text().unwrap(), "Footer: My Doc");
3006    }
3007
3008    #[test]
3009    fn replace_all_batch() {
3010        let mut doc = Document::new();
3011        doc.add_paragraph("{{a}} and {{b}}");
3012
3013        let mut map = std::collections::HashMap::new();
3014        map.insert("{{a}}", "X");
3015        map.insert("{{b}}", "Y");
3016        let count = doc.replace_all(&map);
3017        assert_eq!(count, 2);
3018        assert_eq!(doc.paragraphs()[0].text(), "X and Y");
3019    }
3020
3021    #[test]
3022    fn template_workflow_round_trip() {
3023        let mut doc = Document::new();
3024        doc.add_paragraph("Company: {{company}}");
3025        doc.add_paragraph("Date: {{date}}");
3026
3027        doc.replace_text("{{company}}", "Acme Corp");
3028        doc.replace_text("{{date}}", "2026-02-22");
3029
3030        // Round-trip
3031        let bytes = doc.to_bytes().unwrap();
3032        let doc2 = Document::from_bytes(&bytes).unwrap();
3033        assert_eq!(doc2.paragraphs()[0].text(), "Company: Acme Corp");
3034        assert_eq!(doc2.paragraphs()[1].text(), "Date: 2026-02-22");
3035    }
3036
3037    #[test]
3038    fn add_background_image_round_trip() {
3039        // Create a minimal 1x1 PNG
3040        let png_data: Vec<u8> = vec![
3041            0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
3042            0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
3043            0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
3044            0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49,
3045            0x44, 0x41, 0x54, // IDAT chunk
3046            0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
3047            0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, // IEND chunk
3048            0xae, 0x42, 0x60, 0x82,
3049        ];
3050
3051        let mut doc = Document::new();
3052        doc.add_paragraph("Hello World");
3053        doc.add_background_image(&png_data, "bg.png");
3054
3055        // Background image paragraph should be at index 0
3056        assert_eq!(doc.content_count(), 2);
3057
3058        // Round-trip
3059        let bytes = doc.to_bytes().unwrap();
3060        let doc2 = Document::from_bytes(&bytes).unwrap();
3061
3062        // Should still have 2 content items
3063        assert_eq!(doc2.content_count(), 2);
3064        // The second paragraph should have our text
3065        assert_eq!(doc2.paragraphs().last().unwrap().text(), "Hello World");
3066    }
3067
3068    #[test]
3069    fn add_anchored_image() {
3070        let png_data: Vec<u8> = vec![
3071            0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
3072            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
3073            0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08,
3074            0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
3075            0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
3076        ];
3077
3078        let mut doc = Document::new();
3079        doc.add_paragraph("Content");
3080        doc.add_anchored_image(
3081            &png_data,
3082            "overlay.png",
3083            Length::inches(4.0),
3084            Length::inches(3.0),
3085            false,
3086        );
3087        assert_eq!(doc.content_count(), 2);
3088    }
3089
3090    #[test]
3091    fn insert_toc_basic() {
3092        let mut doc = Document::new();
3093        doc.add_paragraph("Introduction");
3094        doc.add_paragraph("Chapter 1").style("Heading1");
3095        doc.add_paragraph("Some text in chapter 1.");
3096        doc.add_paragraph("Section 1.1").style("Heading2");
3097        doc.add_paragraph("Text in section 1.1.");
3098        doc.add_paragraph("Chapter 2").style("Heading1");
3099        doc.add_paragraph("Text in chapter 2.");
3100
3101        // Before TOC: 7 content elements
3102        assert_eq!(doc.content_count(), 7);
3103
3104        // Insert TOC at index 0 with max_level 2
3105        doc.insert_toc(0, 2);
3106
3107        // TOC adds: 1 title + 3 heading entries (Ch1, Sec1.1, Ch2) = 4 paragraphs
3108        assert_eq!(doc.content_count(), 11);
3109
3110        // Verify TOC title
3111        let paras = doc.paragraphs();
3112        assert_eq!(paras[0].text(), "Table of Contents");
3113
3114        // Verify TOC entries contain heading text
3115        assert_eq!(paras[1].text(), "Chapter 1\t");
3116        assert_eq!(paras[2].text(), "Section 1.1\t");
3117        assert_eq!(paras[3].text(), "Chapter 2\t");
3118
3119        // Verify round-trip: save and re-open
3120        let bytes = doc.to_bytes().expect("should serialize");
3121        let doc2 = Document::from_bytes(&bytes).expect("should open");
3122        assert_eq!(doc2.content_count(), 11);
3123        let paras2 = doc2.paragraphs();
3124        assert_eq!(paras2[0].text(), "Table of Contents");
3125    }
3126
3127    #[test]
3128    fn append_documents() {
3129        let mut doc_a = Document::new();
3130        doc_a.add_paragraph("Paragraph A1");
3131        doc_a.add_paragraph("Paragraph A2");
3132
3133        let mut doc_b = Document::new();
3134        doc_b.add_paragraph("Paragraph B1");
3135        doc_b.add_paragraph("Paragraph B2");
3136        doc_b.add_paragraph("Paragraph B3");
3137
3138        assert_eq!(doc_a.content_count(), 2);
3139        doc_a.append(&doc_b);
3140        assert_eq!(doc_a.content_count(), 5);
3141
3142        let paras = doc_a.paragraphs();
3143        assert_eq!(paras[0].text(), "Paragraph A1");
3144        assert_eq!(paras[1].text(), "Paragraph A2");
3145        assert_eq!(paras[2].text(), "Paragraph B1");
3146        assert_eq!(paras[3].text(), "Paragraph B2");
3147        assert_eq!(paras[4].text(), "Paragraph B3");
3148
3149        // Verify round-trip
3150        let bytes = doc_a.to_bytes().expect("serialize");
3151        let reopened = Document::from_bytes(&bytes).expect("open");
3152        assert_eq!(reopened.content_count(), 5);
3153    }
3154
3155    #[test]
3156    fn append_with_section_break() {
3157        let mut doc_a = Document::new();
3158        doc_a.add_paragraph("A1");
3159
3160        let mut doc_b = Document::new();
3161        doc_b.add_paragraph("B1");
3162
3163        doc_a.append_with_break(&doc_b, crate::SectionBreak::Continuous);
3164        // 1 original + 1 section break paragraph + 1 merged = 3
3165        assert_eq!(doc_a.content_count(), 3);
3166    }
3167
3168    #[test]
3169    fn insert_document_at_index() {
3170        let mut doc_a = Document::new();
3171        doc_a.add_paragraph("First");
3172        doc_a.add_paragraph("Last");
3173
3174        let mut doc_b = Document::new();
3175        doc_b.add_paragraph("Middle 1");
3176        doc_b.add_paragraph("Middle 2");
3177
3178        doc_a.insert_document(1, &doc_b);
3179        assert_eq!(doc_a.content_count(), 4);
3180
3181        let paras = doc_a.paragraphs();
3182        assert_eq!(paras[0].text(), "First");
3183        assert_eq!(paras[1].text(), "Middle 1");
3184        assert_eq!(paras[2].text(), "Middle 2");
3185        assert_eq!(paras[3].text(), "Last");
3186    }
3187
3188    #[test]
3189    fn merge_deduplicates_styles() {
3190        let mut doc_a = Document::new();
3191        doc_a.add_paragraph("A").style("Heading1");
3192
3193        let mut doc_b = Document::new();
3194        doc_b.add_paragraph("B").style("Heading1");
3195        doc_b.add_style(
3196            crate::style::StyleBuilder::paragraph("CustomB", "Custom B").based_on("Normal"),
3197        );
3198        doc_b.add_paragraph("C").style("CustomB");
3199
3200        let styles_before = doc_a.styles.styles.len();
3201        doc_a.append(&doc_b);
3202        let styles_after = doc_a.styles.styles.len();
3203
3204        // Heading1 already existed, so only CustomB should be added
3205        assert_eq!(styles_after, styles_before + 1);
3206    }
3207
3208    #[test]
3209    fn headings_and_outline() {
3210        let mut doc = Document::new();
3211        doc.add_paragraph("Intro");
3212        doc.add_paragraph("Chapter 1").style("Heading1");
3213        doc.add_paragraph("Section 1.1").style("Heading2");
3214        doc.add_paragraph("Section 1.2").style("Heading2");
3215        doc.add_paragraph("Chapter 2").style("Heading1");
3216        doc.add_paragraph("Section 2.1").style("Heading2");
3217        doc.add_paragraph("Sub 2.1.1").style("Heading3");
3218
3219        let headings = doc.headings();
3220        assert_eq!(headings.len(), 6);
3221        assert_eq!(headings[0], (1, "Chapter 1".to_string()));
3222        assert_eq!(headings[1], (2, "Section 1.1".to_string()));
3223        assert_eq!(headings[5], (3, "Sub 2.1.1".to_string()));
3224
3225        let outline = doc.document_outline();
3226        assert_eq!(outline.len(), 2); // Two h1 nodes
3227        assert_eq!(outline[0].text, "Chapter 1");
3228        assert_eq!(outline[0].children.len(), 2); // 1.1 and 1.2
3229        assert_eq!(outline[1].text, "Chapter 2");
3230        assert_eq!(outline[1].children.len(), 1); // 2.1
3231        assert_eq!(outline[1].children[0].children.len(), 1); // 2.1.1
3232    }
3233
3234    #[test]
3235    fn word_count_basic() {
3236        let mut doc = Document::new();
3237        doc.add_paragraph("Hello world");
3238        doc.add_paragraph("Three more words");
3239        assert_eq!(doc.word_count(), 5);
3240    }
3241
3242    #[test]
3243    fn audit_accessibility_missing_metadata() {
3244        let doc = Document::new();
3245        let issues = doc.audit_accessibility();
3246        // New document has no title or author
3247        assert!(issues.iter().any(|i| i.message.contains("no title")));
3248        assert!(issues.iter().any(|i| i.message.contains("no author")));
3249    }
3250
3251    #[test]
3252    fn audit_heading_level_gap() {
3253        let mut doc = Document::new();
3254        doc.set_title("Test");
3255        doc.set_author("Test");
3256        doc.add_paragraph("Ch 1").style("Heading1");
3257        doc.add_paragraph("Skip to 3").style("Heading3");
3258
3259        let issues = doc.audit_accessibility();
3260        assert!(
3261            issues
3262                .iter()
3263                .any(|i| i.message.contains("Heading level gap"))
3264        );
3265    }
3266
3267    #[test]
3268    fn links_returns_empty_for_no_hyperlinks() {
3269        let mut doc = Document::new();
3270        doc.add_paragraph("No links here.");
3271        assert!(doc.links().is_empty());
3272    }
3273
3274    #[test]
3275    fn images_returns_empty_for_text_only() {
3276        let mut doc = Document::new();
3277        doc.add_paragraph("Just text.");
3278        assert!(doc.images().is_empty());
3279    }
3280}
3281
3282#[cfg(test)]
3283mod hyperlink_span_tests {
3284    use super::*;
3285    use rdocx_oxml::text::HyperlinkSpan;
3286
3287    /// `HyperlinkSpan`'s bounds are public, so a caller building the OXML model
3288    /// by hand can hand us a range past the end of `runs`. `links()` used to
3289    /// slice with it and panic.
3290    #[test]
3291    fn links_clamps_out_of_range_spans() {
3292        let mut doc = Document::new();
3293        {
3294            let mut para = doc.add_paragraph("");
3295            para.add_run("one");
3296            para.add_run("two");
3297        }
3298
3299        let BodyContent::Paragraph(p) = &mut doc.document.body.content[0] else {
3300            unreachable!("just added a paragraph")
3301        };
3302        p.hyperlinks.push(HyperlinkSpan {
3303            rel_id: None,
3304            anchor: Some("bookmark".to_string()),
3305            run_start: 1,
3306            run_end: 99,
3307        });
3308        p.hyperlinks.push(HyperlinkSpan {
3309            rel_id: None,
3310            anchor: Some("inverted".to_string()),
3311            run_start: 5,
3312            run_end: 1,
3313        });
3314
3315        let links = doc.links();
3316
3317        assert_eq!(links.len(), 2);
3318        assert_eq!(links[0].text, "two");
3319        assert_eq!(links[1].text, "");
3320    }
3321}
3322
3323#[cfg(test)]
3324mod odttf_tests {
3325    use super::*;
3326
3327    /// Build a TrueType header with a two-entry table directory.
3328    fn fake_font() -> Vec<u8> {
3329        let mut data = Vec::new();
3330        data.extend(b"\x00\x01\x00\x00"); // sfntVersion
3331        data.extend(2u16.to_be_bytes()); // numTables
3332        data.extend(32u16.to_be_bytes()); // searchRange
3333        data.extend(1u16.to_be_bytes()); // entrySelector
3334        data.extend(0u16.to_be_bytes()); // rangeShift
3335        for (tag, offset, length) in [(b"cmap", 96u32, 40u32), (b"head", 136, 54)] {
3336            data.extend(tag); // tag
3337            data.extend(0u32.to_be_bytes()); // checksum
3338            data.extend(offset.to_be_bytes());
3339            data.extend(length.to_be_bytes());
3340        }
3341        data.extend((0u8..64).map(|i| i.wrapping_mul(7)));
3342        data
3343    }
3344
3345    fn obfuscate(font: &[u8], key: &[u8; 16]) -> Vec<u8> {
3346        let mut out = font.to_vec();
3347        for (i, byte) in out.iter_mut().take(32).enumerate() {
3348            *byte ^= key[i % 16];
3349        }
3350        out
3351    }
3352
3353    const GUID_HEX: &str = "00112233445566778899AABBCCDDEEFF";
3354
3355    fn guid_bytes() -> [u8; 16] {
3356        let mut g = [0u8; 16];
3357        for (i, b) in g.iter_mut().enumerate() {
3358            *b = u8::from_str_radix(&GUID_HEX[i * 2..i * 2 + 2], 16).unwrap();
3359        }
3360        g
3361    }
3362
3363    #[test]
3364    fn recovers_font_under_either_key_convention() {
3365        let font = fake_font();
3366        let name = format!("{GUID_HEX}.odttf");
3367        for key in odttf_key_candidates(&guid_bytes()) {
3368            let obfuscated = obfuscate(&font, &key);
3369            assert_eq!(
3370                deobfuscate_odttf(&obfuscated, &name).as_deref(),
3371                Some(font.as_slice()),
3372                "failed to recover font for key {key:02x?}",
3373            );
3374        }
3375    }
3376
3377    #[test]
3378    fn rejects_data_that_does_not_decode_to_a_font() {
3379        // A GUID that matches nothing in the payload must not yield garbage.
3380        let junk = vec![0xAB; 64];
3381        let name = format!("{GUID_HEX}.odttf");
3382        assert_eq!(deobfuscate_odttf(&junk, &name), None);
3383    }
3384
3385    #[test]
3386    fn rejects_short_or_malformed_input() {
3387        assert_eq!(deobfuscate_odttf(&[0u8; 8], "abc.odttf"), None);
3388        assert_eq!(deobfuscate_odttf(&[0u8; 64], "not-a-guid.odttf"), None);
3389    }
3390
3391    #[test]
3392    fn accepts_braced_and_hyphenated_names() {
3393        let font = fake_font();
3394        let key = odttf_key_candidates(&guid_bytes())[0];
3395        let obfuscated = obfuscate(&font, &key);
3396        for name in [
3397            "{00112233-4455-6677-8899-AABBCCDDEEFF}.odttf",
3398            "00112233-4455-6677-8899-AABBCCDDEEFF.odttf",
3399        ] {
3400            assert_eq!(
3401                deobfuscate_odttf(&obfuscated, name).as_deref(),
3402                Some(font.as_slice()),
3403                "failed for {name}"
3404            );
3405        }
3406    }
3407}