Skip to main content

rdocx/
document.rs

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