Skip to main content

ooxml_wml/
ext.rs

1//! Extension traits for generated WML types.
2//!
3//! This module provides convenience methods for the generated types via extension traits,
4//! following the same pattern as SML's `ext.rs`. See ADR-003 for architectural rationale.
5//!
6//! # Design
7//!
8//! Extension traits are split into two categories:
9//!
10//! - **Pure traits** (`RunPropertiesExt`, `RunExt`, `ParagraphExt`, etc.): Methods that
11//!   don't need external context
12//! - **Resolve traits** (`RunResolveExt`): Methods that need `StyleContext` for
13//!   style chain walking
14//!
15//! # Example
16//!
17//! ```ignore
18//! use ooxml_wml::ext::{DocumentExt, BodyExt, ParagraphExt, RunExt};
19//!
20//! let doc: &types::Document = /* ... */;
21//! if let Some(body) = doc.body() {
22//!     for para in body.paragraphs() {
23//!         println!("{}", para.text());
24//!     }
25//! }
26//! ```
27
28use crate::parsers::{FromXml, ParseError};
29use crate::types;
30use quick_xml::Reader;
31use quick_xml::events::Event;
32use std::io::Cursor;
33
34// =============================================================================
35// Helpers (private)
36// =============================================================================
37
38/// Check if a `OnOffElement` field represents "on" (ECMA-376 §17.17.4).
39///
40/// An omitted `val` attribute means "true" (the element's presence is the toggle).
41/// Explicit values: "1", "true", "on" → true; "0", "false", "off" → false.
42#[cfg_attr(
43    not(any(feature = "wml-styling", feature = "wml-layout")),
44    allow(dead_code)
45)]
46fn is_on(field: &Option<Box<types::OnOffElement>>) -> bool {
47    match field {
48        None => false,
49        Some(ct) => match &ct.value {
50            None => true, // element present with no val → on
51            Some(v) => matches!(v.as_str(), "1" | "true" | "on"),
52        },
53    }
54}
55
56/// Tri-state check for style resolution: `None` = not specified, `Some(true/false)` = explicit.
57#[cfg_attr(not(feature = "wml-styling"), allow(dead_code))]
58fn check_toggle(field: &Option<Box<types::OnOffElement>>) -> Option<bool> {
59    field.as_ref().map(|ct| match &ct.value {
60        None => true,
61        Some(v) => matches!(v.as_str(), "1" | "true" | "on"),
62    })
63}
64
65/// Parse a half-point measurement string (e.g., "24" → 24 half-points = 12pt).
66#[cfg_attr(not(feature = "wml-styling"), allow(dead_code))]
67fn parse_half_points(s: &str) -> Option<u32> {
68    s.parse::<u32>().ok()
69}
70
71/// Parse a twips measurement string (signed or unsigned).
72#[cfg(feature = "wml-styling")]
73fn parse_twips(s: &str) -> Option<i64> {
74    s.parse::<i64>().ok()
75}
76
77// =============================================================================
78// DocumentExt
79// =============================================================================
80
81/// Extension methods for `Document`.
82pub trait DocumentExt {
83    /// Get the document body (if present).
84    fn body(&self) -> Option<&types::Body>;
85}
86
87impl DocumentExt for types::Document {
88    fn body(&self) -> Option<&types::Body> {
89        self.body.as_deref()
90    }
91}
92
93// =============================================================================
94// Table of Contents types
95// =============================================================================
96
97/// A single entry in a Table of Contents (ECMA-376 §17.12).
98///
99/// TOC entries use paragraph styles "TOC 1" through "TOC 9" (or "toc1"–"toc9").
100/// The `level` is derived from the numeral in the style name.
101#[derive(Debug, Clone, PartialEq)]
102pub struct TocEntry {
103    /// Heading level (1–9), derived from the paragraph style name.
104    pub level: u8,
105    /// Display text of the entry, extracted from paragraph runs.
106    pub text: String,
107    /// Page number, if present as the last numeric token in the paragraph.
108    /// May be `None` or `0` for unsaved or newly-created documents.
109    pub page: Option<u32>,
110    /// Bookmark name if the entry is hyperlinked to a heading.
111    /// Found in `ParagraphContent::BookmarkStart` inside the paragraph.
112    pub bookmark: Option<String>,
113}
114
115/// A parsed Table of Contents (ECMA-376 §17.12.1).
116///
117/// Returned by [`BodyExt::table_of_contents`].
118#[derive(Debug, Clone, PartialEq)]
119pub struct TableOfContents {
120    /// Ordered list of entries extracted from TOC-style paragraphs.
121    pub entries: Vec<TocEntry>,
122}
123
124// =============================================================================
125// BodyExt
126// =============================================================================
127
128/// Extension methods for `Body`.
129pub trait BodyExt {
130    /// Get all paragraphs in the body.
131    fn paragraphs(&self) -> Vec<&types::Paragraph>;
132
133    /// Get all tables in the body.
134    fn tables(&self) -> Vec<&types::Table>;
135
136    /// Extract all text content from the body.
137    fn text(&self) -> String;
138
139    /// Get the document-level section properties (layout info).
140    #[cfg(feature = "wml-layout")]
141    fn section_properties(&self) -> Option<&types::SectionProperties>;
142
143    /// Extract all Tables of Contents from this body.
144    ///
145    /// Scans the body for both SDT-wrapped and field-based TOCs.
146    /// Each group of contiguous TOC-style paragraphs (or paragraphs inside
147    /// an SDT that contains TOC entries) is returned as a separate
148    /// [`TableOfContents`].
149    ///
150    /// TOC paragraphs use styles "TOC 1"–"TOC 9" or "toc1"–"toc9"
151    /// (ECMA-376 §17.12.1).  Requires the `wml-styling` feature to
152    /// detect paragraph styles; without it this always returns an empty vec.
153    #[cfg(feature = "wml-styling")]
154    fn table_of_contents(&self) -> Vec<TableOfContents>;
155
156    /// Extract all form fields from this body (ECMA-376 §17.5.2).
157    ///
158    /// Walks all block content recursively (including table cells) and
159    /// collects every SDT that has a recognisable form-field type.
160    /// Requires the `wml-settings` feature; without it always returns an
161    /// empty vec.
162    #[cfg(feature = "wml-settings")]
163    fn form_fields(&self) -> Vec<FormField>;
164}
165
166impl BodyExt for types::Body {
167    fn paragraphs(&self) -> Vec<&types::Paragraph> {
168        self.block_content
169            .iter()
170            .filter_map(|elt| match elt {
171                types::BlockContent::P(p) => Some(p.as_ref()),
172                _ => None,
173            })
174            .collect()
175    }
176
177    fn tables(&self) -> Vec<&types::Table> {
178        self.block_content
179            .iter()
180            .filter_map(|elt| match elt {
181                types::BlockContent::Tbl(t) => Some(t.as_ref()),
182                _ => None,
183            })
184            .collect()
185    }
186
187    fn text(&self) -> String {
188        let texts: Vec<String> = self
189            .block_content
190            .iter()
191            .filter_map(|elt| match elt {
192                types::BlockContent::P(p) => Some(p.text()),
193                types::BlockContent::Tbl(t) => Some(t.text()),
194                _ => None,
195            })
196            .collect();
197        texts.join("\n")
198    }
199
200    #[cfg(feature = "wml-layout")]
201    fn section_properties(&self) -> Option<&types::SectionProperties> {
202        self.sect_pr.as_deref()
203    }
204
205    #[cfg(feature = "wml-styling")]
206    fn table_of_contents(&self) -> Vec<TableOfContents> {
207        collect_tocs_from_block_content(&self.block_content)
208    }
209
210    #[cfg(feature = "wml-settings")]
211    fn form_fields(&self) -> Vec<FormField> {
212        collect_form_fields_from_block_content(&self.block_content)
213    }
214}
215
216// =============================================================================
217// TOC helpers (private)
218// =============================================================================
219
220/// Return the TOC level (1–9) for a paragraph style name, or `None` if not a
221/// TOC style.
222///
223/// Recognises both display names ("TOC 1"–"TOC 9") and style IDs
224/// ("toc1"–"toc9"), case-insensitively.
225#[cfg(feature = "wml-styling")]
226fn toc_style_level(style: &str) -> Option<u8> {
227    let s = style.trim();
228
229    // "TOC 1" … "TOC 9"  (display name, space-separated)
230    if let Some(rest) = s.strip_prefix("TOC ").or_else(|| s.strip_prefix("toc "))
231        && let Ok(n) = rest.trim().parse::<u8>()
232        && (1..=9).contains(&n)
233    {
234        return Some(n);
235    }
236
237    // "toc1" … "toc9"  (style ID, no space)
238    if let Some(rest) = s
239        .strip_prefix("TOC")
240        .or_else(|| s.strip_prefix("toc"))
241        .filter(|r| r.len() == 1)
242        && let Ok(n) = rest.parse::<u8>()
243        && (1..=9).contains(&n)
244    {
245        return Some(n);
246    }
247
248    None
249}
250
251/// Return the TOC level for a paragraph, or `None` if it is not a TOC entry.
252#[cfg(feature = "wml-styling")]
253fn paragraph_toc_level(para: &types::Paragraph) -> Option<u8> {
254    let style = para.p_pr.as_ref()?.paragraph_style.as_ref()?.value.as_str();
255    toc_style_level(style)
256}
257
258/// Extract the text of a paragraph, stripping the trailing page number.
259///
260/// TOC paragraphs typically look like:  "Heading text\t42"
261/// The page number is the last tab-separated token if it parses as a number.
262/// Returns the trimmed text before the page number and the page number itself.
263#[cfg(feature = "wml-styling")]
264fn extract_toc_text_and_page(para: &types::Paragraph) -> (String, Option<u32>) {
265    // Collect all run text first.
266    let mut full = String::new();
267    for content in &para.paragraph_content {
268        collect_text_from_paragraph_content(content, &mut full);
269    }
270
271    // Split on the last tab and try to parse the tail as a page number.
272    if let Some(tab_pos) = full.rfind('\t') {
273        let tail = full[tab_pos + 1..].trim();
274        if let Ok(page) = tail.parse::<u32>() {
275            let text = full[..tab_pos].trim().to_string();
276            return (text, Some(page));
277        }
278    }
279
280    (full.trim().to_string(), None)
281}
282
283/// Find the first bookmark name embedded in a paragraph's content.
284///
285/// Hyperlinked TOC entries wrap their content in a `<w:hyperlink>` whose
286/// anchor points to a bookmark on the heading.  The bookmark name is stored
287/// in a `BookmarkStart` item at the paragraph level.
288#[cfg(feature = "wml-styling")]
289fn paragraph_bookmark(para: &types::Paragraph) -> Option<String> {
290    for content in &para.paragraph_content {
291        if let types::ParagraphContent::BookmarkStart(bm) = content {
292            let name = bm.name.clone();
293            if !name.is_empty() {
294                return Some(name);
295            }
296        }
297    }
298    None
299}
300
301/// Convert a paragraph with a TOC style into a [`TocEntry`].
302#[cfg(feature = "wml-styling")]
303fn paragraph_to_toc_entry(para: &types::Paragraph, level: u8) -> TocEntry {
304    let (text, page) = extract_toc_text_and_page(para);
305    let bookmark = paragraph_bookmark(para);
306    TocEntry {
307        level,
308        text,
309        page,
310        bookmark,
311    }
312}
313
314/// Collect all TOC entries from a flat slice of [`BlockContent`] items.
315///
316/// Both SDT-wrapped and bare (field-based) TOC entries are detected.
317/// Contiguous runs of TOC paragraphs (or SDTs containing TOC paragraphs) are
318/// each returned as a separate [`TableOfContents`].
319#[cfg(feature = "wml-styling")]
320fn collect_tocs_from_block_content(blocks: &[types::BlockContent]) -> Vec<TableOfContents> {
321    let mut result: Vec<TableOfContents> = Vec::new();
322    // Accumulator for the current run of bare (non-SDT) TOC paragraphs.
323    let mut current_entries: Vec<TocEntry> = Vec::new();
324
325    for block in blocks {
326        match block {
327            types::BlockContent::P(para) => {
328                if let Some(level) = paragraph_toc_level(para) {
329                    current_entries.push(paragraph_to_toc_entry(para, level));
330                } else {
331                    // Non-TOC paragraph: flush any accumulated entries.
332                    flush_toc(&mut current_entries, &mut result);
333                }
334            }
335            types::BlockContent::Sdt(sdt) => {
336                // Flush bare entries before handling the SDT.
337                flush_toc(&mut current_entries, &mut result);
338
339                // Extract TOC entries from the SDT content.
340                let sdt_entries = collect_toc_entries_from_sdt(sdt);
341                if !sdt_entries.is_empty() {
342                    result.push(TableOfContents {
343                        entries: sdt_entries,
344                    });
345                }
346            }
347            _ => {
348                // Any other block (table, custom XML, …) ends a bare TOC run.
349                flush_toc(&mut current_entries, &mut result);
350            }
351        }
352    }
353
354    // Flush any trailing bare entries.
355    flush_toc(&mut current_entries, &mut result);
356
357    result
358}
359
360/// Flush accumulated TOC entries into the result list.
361#[cfg(feature = "wml-styling")]
362fn flush_toc(entries: &mut Vec<TocEntry>, result: &mut Vec<TableOfContents>) {
363    if !entries.is_empty() {
364        result.push(TableOfContents {
365            entries: std::mem::take(entries),
366        });
367    }
368}
369
370/// Collect all TOC entries from the content of an SDT block.
371///
372/// The `sdt_content` field holds [`BlockContentChoice`] items.  We walk those,
373/// extracting paragraphs with TOC styles.
374#[cfg(feature = "wml-styling")]
375fn collect_toc_entries_from_sdt(sdt: &types::CTSdtBlock) -> Vec<TocEntry> {
376    let content = match &sdt.sdt_content {
377        Some(c) => c,
378        None => return Vec::new(),
379    };
380
381    content
382        .block_content
383        .iter()
384        .filter_map(|bc| match bc {
385            types::BlockContentChoice::P(para) => {
386                paragraph_toc_level(para).map(|lvl| paragraph_to_toc_entry(para, lvl))
387            }
388            _ => None,
389        })
390        .collect()
391}
392
393// =============================================================================
394// ParagraphExt
395// =============================================================================
396
397/// Extension methods for `Paragraph`.
398pub trait ParagraphExt {
399    /// Get all runs in this paragraph (including runs inside hyperlinks and simple fields).
400    fn runs(&self) -> Vec<&types::Run>;
401
402    /// Extract all text from this paragraph.
403    fn text(&self) -> String;
404
405    /// Get hyperlinks in this paragraph.
406    fn hyperlinks(&self) -> Vec<&types::Hyperlink>;
407
408    /// Get paragraph properties.
409    #[cfg(feature = "wml-styling")]
410    fn properties(&self) -> Option<&types::ParagraphProperties>;
411
412    /// Get paragraph alignment (justification). ECMA-376 §17.3.1.13.
413    #[cfg(feature = "wml-styling")]
414    fn alignment(&self) -> Option<types::STJc>;
415
416    /// Get left indent in twips. ECMA-376 §17.3.1.12.
417    ///
418    /// Prefers `w:start` (OOXML) and falls back to `w:left` (compatibility).
419    #[cfg(feature = "wml-styling")]
420    fn indent_left(&self) -> Option<i64>;
421
422    /// Get right indent in twips.
423    ///
424    /// Prefers `w:end` (OOXML) and falls back to `w:right` (compatibility).
425    #[cfg(feature = "wml-styling")]
426    fn indent_right(&self) -> Option<i64>;
427
428    /// Get first-line additional indent in twips (positive = first line further right).
429    ///
430    /// Mutually exclusive with [`indent_hanging`].
431    #[cfg(feature = "wml-styling")]
432    fn indent_first_line(&self) -> Option<i64>;
433
434    /// Get hanging indent in twips (positive = first line is that many twips to the *left* of the rest).
435    ///
436    /// Mutually exclusive with [`indent_first_line`].
437    #[cfg(feature = "wml-styling")]
438    fn indent_hanging(&self) -> Option<i64>;
439
440    /// Get space before paragraph in twips. ECMA-376 §17.3.1.33.
441    #[cfg(feature = "wml-styling")]
442    fn space_before(&self) -> Option<i64>;
443
444    /// Get space after paragraph in twips.
445    #[cfg(feature = "wml-styling")]
446    fn space_after(&self) -> Option<i64>;
447
448    /// Get line spacing value in twips (240 = single, 360 = 1.5×, 480 = double for `auto` rule).
449    #[cfg(feature = "wml-styling")]
450    fn line_spacing(&self) -> Option<i64>;
451
452    /// Get the line spacing rule, if set.
453    #[cfg(feature = "wml-styling")]
454    fn line_spacing_rule(&self) -> Option<types::STLineSpacingRule>;
455
456    /// Get numbering (list) properties as `(num_id, ilvl)`. ECMA-376 §17.9.
457    ///
458    /// Returns `None` if this paragraph is not part of a list.
459    #[cfg(feature = "wml-numbering")]
460    fn numbering(&self) -> Option<(i64, i64)>;
461}
462
463impl ParagraphExt for types::Paragraph {
464    fn runs(&self) -> Vec<&types::Run> {
465        collect_runs_from_paragraph_content(&self.paragraph_content)
466    }
467
468    fn text(&self) -> String {
469        let mut out = String::new();
470        for content in &self.paragraph_content {
471            collect_text_from_paragraph_content(content, &mut out);
472        }
473        out
474    }
475
476    fn hyperlinks(&self) -> Vec<&types::Hyperlink> {
477        self.paragraph_content
478            .iter()
479            .filter_map(|c| match c {
480                types::ParagraphContent::Hyperlink(h) => Some(h.as_ref()),
481                _ => None,
482            })
483            .collect()
484    }
485
486    #[cfg(feature = "wml-styling")]
487    fn properties(&self) -> Option<&types::ParagraphProperties> {
488        self.p_pr.as_deref()
489    }
490
491    #[cfg(feature = "wml-styling")]
492    fn alignment(&self) -> Option<types::STJc> {
493        self.p_pr
494            .as_deref()?
495            .justification
496            .as_deref()
497            .map(|j| j.value)
498    }
499
500    #[cfg(feature = "wml-styling")]
501    fn indent_left(&self) -> Option<i64> {
502        let ind = self.p_pr.as_deref()?.indentation.as_deref()?;
503        ind.start
504            .as_deref()
505            .or(ind.left.as_deref())
506            .and_then(parse_twips)
507    }
508
509    #[cfg(feature = "wml-styling")]
510    fn indent_right(&self) -> Option<i64> {
511        let ind = self.p_pr.as_deref()?.indentation.as_deref()?;
512        ind.end
513            .as_deref()
514            .or(ind.right.as_deref())
515            .and_then(parse_twips)
516    }
517
518    #[cfg(feature = "wml-styling")]
519    fn indent_first_line(&self) -> Option<i64> {
520        let ind = self.p_pr.as_deref()?.indentation.as_deref()?;
521        ind.first_line.as_deref().and_then(parse_twips)
522    }
523
524    #[cfg(feature = "wml-styling")]
525    fn indent_hanging(&self) -> Option<i64> {
526        let ind = self.p_pr.as_deref()?.indentation.as_deref()?;
527        ind.hanging.as_deref().and_then(parse_twips)
528    }
529
530    #[cfg(feature = "wml-styling")]
531    fn space_before(&self) -> Option<i64> {
532        let spacing = self.p_pr.as_deref()?.spacing.as_deref()?;
533        spacing.before.as_deref().and_then(parse_twips)
534    }
535
536    #[cfg(feature = "wml-styling")]
537    fn space_after(&self) -> Option<i64> {
538        let spacing = self.p_pr.as_deref()?.spacing.as_deref()?;
539        spacing.after.as_deref().and_then(parse_twips)
540    }
541
542    #[cfg(feature = "wml-styling")]
543    fn line_spacing(&self) -> Option<i64> {
544        let spacing = self.p_pr.as_deref()?.spacing.as_deref()?;
545        spacing.line.as_deref().and_then(parse_twips)
546    }
547
548    #[cfg(feature = "wml-styling")]
549    fn line_spacing_rule(&self) -> Option<types::STLineSpacingRule> {
550        self.p_pr.as_deref()?.spacing.as_deref()?.line_rule
551    }
552
553    #[cfg(feature = "wml-numbering")]
554    fn numbering(&self) -> Option<(i64, i64)> {
555        let num_pr = self.p_pr.as_deref()?.num_pr.as_deref()?;
556        let num_id = num_pr.num_id.as_deref()?.value;
557        let ilvl = num_pr.ilvl.as_deref()?.value;
558        Some((num_id, ilvl))
559    }
560}
561
562/// Collect runs from paragraph content, including nested runs in hyperlinks and simple fields.
563fn collect_runs_from_paragraph_content(content: &[types::ParagraphContent]) -> Vec<&types::Run> {
564    let mut runs = Vec::new();
565    for item in content {
566        match item {
567            types::ParagraphContent::R(r) => runs.push(r.as_ref()),
568            types::ParagraphContent::Hyperlink(h) => {
569                runs.extend(collect_runs_from_paragraph_content(&h.paragraph_content));
570            }
571            types::ParagraphContent::FldSimple(f) => {
572                runs.extend(collect_runs_from_paragraph_content(&f.paragraph_content));
573            }
574            _ => {}
575        }
576    }
577    runs
578}
579
580/// Collect text from a single paragraph content item.
581fn collect_text_from_paragraph_content(content: &types::ParagraphContent, out: &mut String) {
582    match content {
583        types::ParagraphContent::R(r) => out.push_str(&r.text()),
584        types::ParagraphContent::Hyperlink(h) => {
585            for item in &h.paragraph_content {
586                collect_text_from_paragraph_content(item, out);
587            }
588        }
589        types::ParagraphContent::FldSimple(f) => {
590            for item in &f.paragraph_content {
591                collect_text_from_paragraph_content(item, out);
592            }
593        }
594        _ => {}
595    }
596}
597
598// =============================================================================
599// RunExt
600// =============================================================================
601
602/// Extension methods for `Run`.
603pub trait RunExt {
604    /// Extract text from this run.
605    ///
606    /// Collects `T` (text), `Tab` (→ `\t`), `Cr`/`Br`(non-page) (→ `\n`).
607    fn text(&self) -> String;
608
609    /// Get run properties.
610    #[cfg(feature = "wml-styling")]
611    fn properties(&self) -> Option<&types::RunProperties>;
612
613    /// Check if this run contains a page break.
614    fn has_page_break(&self) -> bool;
615
616    /// Get all drawings in this run.
617    #[cfg(feature = "wml-drawings")]
618    fn drawings(&self) -> Vec<&types::CTDrawing>;
619
620    /// Convenience: check if bold (delegates to properties).
621    #[cfg(feature = "wml-styling")]
622    fn is_bold(&self) -> bool;
623
624    /// Convenience: check if italic (delegates to properties).
625    #[cfg(feature = "wml-styling")]
626    fn is_italic(&self) -> bool;
627
628    /// Convenience: check if underlined (delegates to properties).
629    #[cfg(feature = "wml-styling")]
630    fn is_underline(&self) -> bool;
631
632    /// Convenience: check if strikethrough (delegates to properties).
633    #[cfg(feature = "wml-styling")]
634    fn is_strikethrough(&self) -> bool;
635
636    /// Check if this run contains any drawing elements (images).
637    #[cfg(feature = "wml-drawings")]
638    fn has_images(&self) -> bool;
639
640    /// Get the footnote reference in this run, if any.
641    fn footnote_ref(&self) -> Option<&types::FootnoteEndnoteRef>;
642
643    /// Get the endnote reference in this run, if any.
644    fn endnote_ref(&self) -> Option<&types::FootnoteEndnoteRef>;
645}
646
647impl RunExt for types::Run {
648    fn text(&self) -> String {
649        let mut out = String::new();
650        for item in &self.run_content {
651            match item {
652                types::RunContent::T(t) => {
653                    if let Some(ref text) = t.text {
654                        out.push_str(text);
655                    }
656                }
657                types::RunContent::Tab(_) => out.push('\t'),
658                types::RunContent::Cr(_) => out.push('\n'),
659                types::RunContent::Br(br) => {
660                    // Page/column breaks aren't text; only text-wrapping breaks produce newlines
661                    if !matches!(
662                        br.r#type,
663                        Some(types::STBrType::Page) | Some(types::STBrType::Column)
664                    ) {
665                        out.push('\n');
666                    }
667                }
668                _ => {}
669            }
670        }
671        out
672    }
673
674    #[cfg(feature = "wml-styling")]
675    fn properties(&self) -> Option<&types::RunProperties> {
676        self.r_pr.as_deref()
677    }
678
679    fn has_page_break(&self) -> bool {
680        self.run_content.iter().any(|item| {
681            matches!(
682                item,
683                types::RunContent::Br(br) if br.r#type == Some(types::STBrType::Page)
684            )
685        })
686    }
687
688    #[cfg(feature = "wml-drawings")]
689    fn drawings(&self) -> Vec<&types::CTDrawing> {
690        self.run_content
691            .iter()
692            .filter_map(|item| match item {
693                types::RunContent::Drawing(d) => Some(d.as_ref()),
694                _ => None,
695            })
696            .collect()
697    }
698
699    #[cfg(feature = "wml-styling")]
700    fn is_bold(&self) -> bool {
701        self.properties().is_some_and(|p| p.is_bold())
702    }
703
704    #[cfg(feature = "wml-styling")]
705    fn is_italic(&self) -> bool {
706        self.properties().is_some_and(|p| p.is_italic())
707    }
708
709    #[cfg(feature = "wml-styling")]
710    fn is_underline(&self) -> bool {
711        self.properties().is_some_and(|p| p.is_underline())
712    }
713
714    #[cfg(feature = "wml-styling")]
715    fn is_strikethrough(&self) -> bool {
716        self.properties().is_some_and(|p| p.is_strikethrough())
717    }
718
719    #[cfg(feature = "wml-drawings")]
720    fn has_images(&self) -> bool {
721        self.run_content
722            .iter()
723            .any(|item| matches!(item, types::RunContent::Drawing(_)))
724    }
725
726    fn footnote_ref(&self) -> Option<&types::FootnoteEndnoteRef> {
727        self.run_content.iter().find_map(|item| match item {
728            types::RunContent::FootnoteReference(r) => Some(r.as_ref()),
729            _ => None,
730        })
731    }
732
733    fn endnote_ref(&self) -> Option<&types::FootnoteEndnoteRef> {
734        self.run_content.iter().find_map(|item| match item {
735            types::RunContent::EndnoteReference(r) => Some(r.as_ref()),
736            _ => None,
737        })
738    }
739}
740
741// =============================================================================
742// DrawingExt
743// =============================================================================
744
745/// Extension methods for `CTDrawing` — extract image relationship IDs from raw XML.
746///
747/// Since `CTDrawing` captures its children as raw XML, this trait walks the tree
748/// to find `<a:blip r:embed="..."/>` inside `<wp:inline>` and `<wp:anchor>` elements.
749#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
750pub trait DrawingExt {
751    /// Get relationship IDs for inline images (`<wp:inline>` → `<a:blip r:embed="rId"/>`).
752    fn inline_image_rel_ids(&self) -> Vec<&str>;
753
754    /// Get relationship IDs for anchored images (`<wp:anchor>` → `<a:blip r:embed="rId"/>`).
755    fn anchored_image_rel_ids(&self) -> Vec<&str>;
756
757    /// Get all image relationship IDs (inline + anchored).
758    fn all_image_rel_ids(&self) -> Vec<&str>;
759}
760
761#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
762impl DrawingExt for types::CTDrawing {
763    fn inline_image_rel_ids(&self) -> Vec<&str> {
764        let mut ids = Vec::new();
765        for child in &self.extra_children {
766            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node
767                && local_name_of(&elem.name) == "inline"
768            {
769                collect_blip_rel_ids(elem, &mut ids);
770            }
771        }
772        ids
773    }
774
775    fn anchored_image_rel_ids(&self) -> Vec<&str> {
776        let mut ids = Vec::new();
777        for child in &self.extra_children {
778            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node
779                && local_name_of(&elem.name) == "anchor"
780            {
781                collect_blip_rel_ids(elem, &mut ids);
782            }
783        }
784        ids
785    }
786
787    fn all_image_rel_ids(&self) -> Vec<&str> {
788        let mut ids = self.inline_image_rel_ids();
789        ids.extend(self.anchored_image_rel_ids());
790        ids
791    }
792}
793
794/// Extract the local name from a possibly-namespaced XML element name.
795/// e.g. "wp:inline" → "inline", "a:blip" → "blip", "blip" → "blip".
796#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
797fn local_name_of(name: &str) -> &str {
798    name.rsplit(':').next().unwrap_or(name)
799}
800
801/// Recursively walk a raw XML element tree and collect `r:embed` attribute values
802/// from `<a:blip>` elements.
803#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
804fn collect_blip_rel_ids<'a>(elem: &'a ooxml_xml::RawXmlElement, ids: &mut Vec<&'a str>) {
805    if local_name_of(&elem.name) == "blip" {
806        for (attr_name, attr_val) in &elem.attributes {
807            if attr_name == "r:embed" || local_name_of(attr_name) == "embed" {
808                ids.push(attr_val.as_str());
809            }
810        }
811    }
812    for child in &elem.children {
813        if let ooxml_xml::RawXmlNode::Element(child_elem) = child {
814            collect_blip_rel_ids(child_elem, ids);
815        }
816    }
817}
818
819// =============================================================================
820// DrawingChartExt — extract chart relationship IDs from CTDrawing
821// =============================================================================
822
823/// Extension methods for `CTDrawing` — extract chart relationship IDs from raw XML.
824///
825/// Charts in DOCX appear in `<wp:inline>` or `<wp:anchor>` elements inside
826/// `<a:graphic>` → `<a:graphicData>` → `<c:chart r:id="rId..."/>`.
827///
828/// ECMA-376 Part 1, §20.4.2.8 (inline), §20.4.2.3 (anchor), §21.2.2.27 (chart).
829#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
830pub trait DrawingChartExt {
831    /// Get relationship IDs for charts in inline drawings (`<wp:inline>`).
832    fn inline_chart_rel_ids(&self) -> Vec<&str>;
833
834    /// Get relationship IDs for charts in anchored drawings (`<wp:anchor>`).
835    fn anchored_chart_rel_ids(&self) -> Vec<&str>;
836
837    /// Get all chart relationship IDs (inline + anchored).
838    fn all_chart_rel_ids(&self) -> Vec<&str>;
839}
840
841#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
842impl DrawingChartExt for types::CTDrawing {
843    fn inline_chart_rel_ids(&self) -> Vec<&str> {
844        let mut ids = Vec::new();
845        for child in &self.extra_children {
846            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node
847                && local_name_of(&elem.name) == "inline"
848            {
849                collect_chart_rel_ids(elem, &mut ids);
850            }
851        }
852        ids
853    }
854
855    fn anchored_chart_rel_ids(&self) -> Vec<&str> {
856        let mut ids = Vec::new();
857        for child in &self.extra_children {
858            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node
859                && local_name_of(&elem.name) == "anchor"
860            {
861                collect_chart_rel_ids(elem, &mut ids);
862            }
863        }
864        ids
865    }
866
867    fn all_chart_rel_ids(&self) -> Vec<&str> {
868        let mut ids = self.inline_chart_rel_ids();
869        ids.extend(self.anchored_chart_rel_ids());
870        ids
871    }
872}
873
874/// Recursively walk a raw XML element tree and collect `r:id` attribute values
875/// from `<c:chart>` elements.
876#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
877fn collect_chart_rel_ids<'a>(elem: &'a ooxml_xml::RawXmlElement, ids: &mut Vec<&'a str>) {
878    if local_name_of(&elem.name) == "chart" {
879        for (attr_name, attr_val) in &elem.attributes {
880            if attr_name == "r:id" || local_name_of(attr_name) == "id" {
881                ids.push(attr_val.as_str());
882            }
883        }
884    }
885    for child in &elem.children {
886        if let ooxml_xml::RawXmlNode::Element(child_elem) = child {
887            collect_chart_rel_ids(child_elem, ids);
888        }
889    }
890}
891
892// =============================================================================
893// TextBoxExt (DrawingML — modern text boxes)
894// =============================================================================
895
896/// Extension methods for `CTDrawing` — extract text from DrawingML text boxes.
897///
898/// Modern DOCX text boxes live inside `<wp:anchor>` elements within a `<w:drawing>`.
899/// The content path is:
900/// `<w:drawing>` → `<wp:anchor>` → `<a:graphic>` → `<a:graphicData>` →
901/// `<wps:wsp>` → `<wps:txbx>` → `<w:txbxContent>` → paragraphs
902///
903/// Since `CTDrawing` captures all children as raw XML (`extra_children`), this trait
904/// walks the tree recursively to find `w:txbxContent` elements and parses them.
905///
906/// ECMA-376 Part 1, §20.4.2.3 (anchor) and §20.1.2.2.19 (graphic).
907#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
908pub trait DrawingTextBoxExt {
909    /// Extract the plain text of all text boxes in this drawing.
910    ///
911    /// Returns one `String` per text box found (anchored or inline).
912    /// Each string contains the text of all paragraphs in that text box,
913    /// joined with newlines.
914    fn text_box_texts(&self) -> Vec<String>;
915}
916
917#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
918impl DrawingTextBoxExt for types::CTDrawing {
919    fn text_box_texts(&self) -> Vec<String> {
920        let mut results = Vec::new();
921        for child in &self.extra_children {
922            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node {
923                collect_txbx_texts_from_raw(elem, &mut results);
924            }
925        }
926        results
927    }
928}
929
930/// Recursively walk a raw XML element tree and collect text from every
931/// `w:txbxContent` element found anywhere in the subtree.
932#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
933fn collect_txbx_texts_from_raw(elem: &ooxml_xml::RawXmlElement, out: &mut Vec<String>) {
934    if local_name_of(&elem.name) == "txbxContent" {
935        // Found a text box content element — parse it and extract text.
936        match elem.parse_as::<types::CTTxbxContent>() {
937            Ok(content) => {
938                let text = txbx_content_text(&content);
939                out.push(text);
940            }
941            Err(_) => {
942                // Parsing failed; skip this element silently.
943            }
944        }
945        // Don't recurse into txbxContent children — we already parsed the whole subtree.
946        return;
947    }
948
949    for child in &elem.children {
950        if let ooxml_xml::RawXmlNode::Element(child_elem) = child {
951            collect_txbx_texts_from_raw(child_elem, out);
952        }
953    }
954}
955
956/// Extract plain text from a `CTTxbxContent` by walking its block content.
957#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
958fn txbx_content_text(content: &types::CTTxbxContent) -> String {
959    use crate::ext::{ParagraphExt, TableExt};
960    let parts: Vec<String> = content
961        .block_content
962        .iter()
963        .filter_map(|bc| match bc {
964            types::BlockContent::P(p) => Some(p.text()),
965            types::BlockContent::Tbl(t) => Some(t.text()),
966            _ => None,
967        })
968        .collect();
969    parts.join("\n")
970}
971
972// =============================================================================
973// PictExt (VML — legacy text boxes)
974// =============================================================================
975
976/// Extension methods for `CTPicture` — extract text from VML text boxes.
977///
978/// Legacy DOCX text boxes (VML) appear as:
979/// `<w:pict>` → `<v:shape>` → `<v:textbox>` → `<w:txbxContent>` → paragraphs
980///
981/// Since `CTPicture` captures all children as raw XML (`extra_children`), this
982/// trait walks the tree to find `w:txbxContent` and parses it.
983///
984/// ECMA-376 Part 1, §17.3.3.21 (pict).
985#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
986pub trait PictExt {
987    /// Extract the plain text of the first text box inside this picture element.
988    ///
989    /// VML picture elements typically contain at most one text box.
990    /// Returns `None` if no text box content is found.
991    fn text_box_text(&self) -> Option<String>;
992
993    /// Extract the plain text of all text boxes inside this picture element.
994    fn text_box_texts(&self) -> Vec<String>;
995}
996
997#[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
998impl PictExt for types::CTPicture {
999    fn text_box_text(&self) -> Option<String> {
1000        self.text_box_texts().into_iter().next()
1001    }
1002
1003    fn text_box_texts(&self) -> Vec<String> {
1004        let mut results = Vec::new();
1005        for child in &self.extra_children {
1006            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node {
1007                collect_txbx_texts_from_raw(elem, &mut results);
1008            }
1009        }
1010        results
1011    }
1012}
1013
1014// =============================================================================
1015// MathExt (OMML — Office Math Markup Language)
1016// =============================================================================
1017
1018/// A math expression extracted from an `<m:oMath>` element (ECMA-376 Part 1 §22.1).
1019///
1020#[cfg(feature = "extra-children")]
1021#[derive(Debug, Clone)]
1022pub struct MathExpression {
1023    /// `true` for display (block) math (`<m:oMathPara>`), `false` for inline (`<m:oMath>`).
1024    pub is_display: bool,
1025    /// The parsed OMML math zone containing the structured math content.
1026    #[cfg(feature = "wml-math")]
1027    pub zone: ooxml_omml::MathZone,
1028}
1029
1030#[cfg(all(feature = "extra-children", feature = "wml-math"))]
1031impl MathExpression {
1032    /// Extract plain text representation of the math content.
1033    pub fn text(&self) -> String {
1034        self.zone.text()
1035    }
1036}
1037
1038/// Extension methods for types that may contain OMML math (ECMA-376 Part 1 §22.1).
1039///
1040/// Math in DOCX uses `<m:oMath>` (inline) or `<m:oMathPara>` (display/block)
1041/// elements from the namespace
1042/// `http://schemas.openxmlformats.org/officeDocument/2006/math`.
1043/// Because the OMML namespace is separate from the WordprocessingML namespace,
1044/// the generated parser stores these elements in `extra_children` on
1045/// `Paragraph`.  This trait walks those raw nodes to detect and extract math.
1046#[cfg(feature = "extra-children")]
1047pub trait MathExt {
1048    /// Return all math expressions contained in this element.
1049    fn math_expressions(&self) -> Vec<MathExpression>;
1050
1051    /// Return `true` if this element contains at least one math expression.
1052    fn has_math(&self) -> bool {
1053        !self.math_expressions().is_empty()
1054    }
1055}
1056
1057#[cfg(feature = "extra-children")]
1058impl MathExt for types::Paragraph {
1059    fn math_expressions(&self) -> Vec<MathExpression> {
1060        let mut out = Vec::new();
1061        for child in &self.extra_children {
1062            if let ooxml_xml::RawXmlNode::Element(elem) = &child.node {
1063                collect_math_from_raw(elem, &mut out);
1064            }
1065        }
1066        out
1067    }
1068}
1069
1070#[cfg(feature = "extra-children")]
1071impl MathExt for types::Body {
1072    fn math_expressions(&self) -> Vec<MathExpression> {
1073        let mut out = Vec::new();
1074        for item in &self.block_content {
1075            if let types::BlockContent::P(p) = item {
1076                out.extend(p.math_expressions());
1077            }
1078        }
1079        out
1080    }
1081}
1082
1083/// Walk a raw XML element and, when an `<m:oMath>` or `<m:oMathPara>` root is
1084/// found, collect a `MathExpression` and append it to `out`.
1085///
1086/// The function does **not** recurse into `<m:oMath>` children once the root
1087/// is identified — instead it hands the entire subtree to
1088/// [`collect_math_text`] to gather text leaves.
1089#[cfg(all(feature = "extra-children", feature = "wml-math"))]
1090fn parse_math_zone_from_element(elem: &ooxml_xml::RawXmlElement) -> ooxml_omml::MathZone {
1091    elem.parse_as::<ooxml_omml::MathZone>().unwrap_or_default()
1092}
1093
1094#[cfg(feature = "extra-children")]
1095fn collect_math_from_raw(elem: &ooxml_xml::RawXmlElement, out: &mut Vec<MathExpression>) {
1096    let local = math_local_name(&elem.name);
1097    match local {
1098        "oMathPara" => {
1099            out.push(MathExpression {
1100                is_display: true,
1101                #[cfg(feature = "wml-math")]
1102                zone: {
1103                    // Display math: find the inner oMath child and parse it.
1104                    elem.children
1105                        .iter()
1106                        .filter_map(|c| match c {
1107                            ooxml_xml::RawXmlNode::Element(e)
1108                                if math_local_name(&e.name) == "oMath" =>
1109                            {
1110                                Some(parse_math_zone_from_element(e))
1111                            }
1112                            _ => None,
1113                        })
1114                        .next()
1115                        .unwrap_or_default()
1116                },
1117            });
1118        }
1119        "oMath" => {
1120            out.push(MathExpression {
1121                is_display: false,
1122                #[cfg(feature = "wml-math")]
1123                zone: parse_math_zone_from_element(elem),
1124            });
1125        }
1126        _ => {
1127            // Recurse into unrecognised wrapper elements.
1128            for child in &elem.children {
1129                if let ooxml_xml::RawXmlNode::Element(child_elem) = child {
1130                    collect_math_from_raw(child_elem, out);
1131                }
1132            }
1133        }
1134    }
1135}
1136
1137/// Return the local name of a possibly-namespaced element name.
1138///
1139/// Handles both `m:oMath` (→ `"oMath"`) and bare `oMath`.  Strips any
1140/// namespace prefix; does **not** validate that the prefix is actually `m:`.
1141#[cfg(feature = "extra-children")]
1142#[inline]
1143fn math_local_name(name: &str) -> &str {
1144    name.rsplit(':').next().unwrap_or(name)
1145}
1146
1147// =============================================================================
1148// RunPropertiesExt
1149// =============================================================================
1150
1151/// Extension methods for `RunProperties` (ECMA-376 §17.3.2).
1152///
1153/// All toggle property checks follow the OOXML convention: element present
1154/// without `val` attribute means "on"; explicit `val` of "1"/"true"/"on" means on.
1155#[cfg(feature = "wml-styling")]
1156pub trait RunPropertiesExt {
1157    /// Check if bold is enabled.
1158    fn is_bold(&self) -> bool;
1159
1160    /// Check if italic is enabled.
1161    fn is_italic(&self) -> bool;
1162
1163    /// Check if any underline is set (not `none`).
1164    fn is_underline(&self) -> bool;
1165
1166    /// Get the underline style.
1167    fn underline_style(&self) -> Option<&types::STUnderline>;
1168
1169    /// Check if single strikethrough is enabled.
1170    fn is_strikethrough(&self) -> bool;
1171
1172    /// Check if double strikethrough is enabled.
1173    fn is_double_strikethrough(&self) -> bool;
1174
1175    /// Check if all-caps is enabled.
1176    fn is_all_caps(&self) -> bool;
1177
1178    /// Check if small-caps is enabled.
1179    fn is_small_caps(&self) -> bool;
1180
1181    /// Check if text is hidden (`<w:vanish/>`).
1182    fn is_hidden(&self) -> bool;
1183
1184    /// Get highlight color.
1185    fn highlight_color(&self) -> Option<&types::STHighlightColor>;
1186
1187    /// Get vertical alignment (superscript/subscript/baseline).
1188    fn vertical_alignment(&self) -> Option<&types::STVerticalAlignRun>;
1189
1190    /// Check if superscript.
1191    fn is_superscript(&self) -> bool;
1192
1193    /// Check if subscript.
1194    fn is_subscript(&self) -> bool;
1195
1196    /// Get font size in half-points (e.g., 24 = 12pt).
1197    fn font_size_half_points(&self) -> Option<u32>;
1198
1199    /// Get font size in points (e.g., 12.0).
1200    fn font_size_points(&self) -> Option<f64>;
1201
1202    /// Get text color as hex string (e.g., "FF0000").
1203    fn color_hex(&self) -> Option<&str>;
1204
1205    /// Get the referenced character style ID.
1206    fn style_id(&self) -> Option<&str>;
1207
1208    /// Get the ASCII font name.
1209    fn font_ascii(&self) -> Option<&str>;
1210
1211    /// Check if right-to-left text.
1212    fn is_rtl(&self) -> bool;
1213
1214    /// Check if outline text effect is enabled. ECMA-376 §17.3.2.23.
1215    fn is_outline(&self) -> bool;
1216
1217    /// Check if shadow text effect is enabled. ECMA-376 §17.3.2.31.
1218    fn is_shadow(&self) -> bool;
1219
1220    /// Check if emboss text effect is enabled. ECMA-376 §17.3.2.13.
1221    fn is_emboss(&self) -> bool;
1222
1223    /// Check if imprint (engrave) text effect is enabled. ECMA-376 §17.3.2.18.
1224    fn is_imprint(&self) -> bool;
1225
1226    /// Check if spell/grammar check is suppressed for this run. ECMA-376 §17.3.2.22.
1227    fn is_no_proof(&self) -> bool;
1228
1229    /// Check if content snaps to document grid. ECMA-376 §17.3.2.34.
1230    fn is_snap_to_grid(&self) -> bool;
1231
1232    /// Check if text is hidden when rendered for the web view. ECMA-376 §17.3.2.44.
1233    fn is_web_hidden(&self) -> bool;
1234
1235    /// Get character spacing adjustment in twips (positive = expand, negative = condense). ECMA-376 §17.3.2.35.
1236    fn character_spacing(&self) -> Option<i64>;
1237
1238    /// Get text scale as a percentage (100 = normal width). ECMA-376 §17.3.2.43.
1239    fn text_scale_percent(&self) -> Option<u32>;
1240
1241    /// Get kerning threshold in half-points. ECMA-376 §17.3.2.19.
1242    fn kerning(&self) -> Option<u32>;
1243
1244    /// Get baseline shift in half-points (positive = raise, negative = lower). ECMA-376 §17.3.2.28.
1245    fn baseline_shift(&self) -> Option<i64>;
1246
1247    /// Get language tag for this run. ECMA-376 §17.3.2.20.
1248    fn language(&self) -> Option<&types::LanguageElement>;
1249}
1250
1251#[cfg(feature = "wml-styling")]
1252impl RunPropertiesExt for types::RunProperties {
1253    fn is_bold(&self) -> bool {
1254        is_on(&self.bold)
1255    }
1256
1257    fn is_italic(&self) -> bool {
1258        is_on(&self.italic)
1259    }
1260
1261    fn is_underline(&self) -> bool {
1262        self.underline
1263            .as_ref()
1264            .is_some_and(|u| !matches!(u.value, Some(types::STUnderline::None)))
1265    }
1266
1267    fn underline_style(&self) -> Option<&types::STUnderline> {
1268        self.underline.as_ref().and_then(|u| u.value.as_ref())
1269    }
1270
1271    fn is_strikethrough(&self) -> bool {
1272        is_on(&self.strikethrough)
1273    }
1274
1275    fn is_double_strikethrough(&self) -> bool {
1276        is_on(&self.dstrike)
1277    }
1278
1279    fn is_all_caps(&self) -> bool {
1280        is_on(&self.caps)
1281    }
1282
1283    fn is_small_caps(&self) -> bool {
1284        is_on(&self.small_caps)
1285    }
1286
1287    fn is_hidden(&self) -> bool {
1288        is_on(&self.vanish)
1289    }
1290
1291    fn highlight_color(&self) -> Option<&types::STHighlightColor> {
1292        self.highlight.as_ref().map(|h| &h.value)
1293    }
1294
1295    fn vertical_alignment(&self) -> Option<&types::STVerticalAlignRun> {
1296        self.vert_align.as_ref().map(|va| &va.value)
1297    }
1298
1299    fn is_superscript(&self) -> bool {
1300        matches!(
1301            self.vert_align.as_ref().map(|va| &va.value),
1302            Some(types::STVerticalAlignRun::Superscript)
1303        )
1304    }
1305
1306    fn is_subscript(&self) -> bool {
1307        matches!(
1308            self.vert_align.as_ref().map(|va| &va.value),
1309            Some(types::STVerticalAlignRun::Subscript)
1310        )
1311    }
1312
1313    fn font_size_half_points(&self) -> Option<u32> {
1314        self.size
1315            .as_ref()
1316            .and_then(|sz| parse_half_points(&sz.value))
1317    }
1318
1319    fn font_size_points(&self) -> Option<f64> {
1320        self.font_size_half_points().map(|hp| hp as f64 / 2.0)
1321    }
1322
1323    fn color_hex(&self) -> Option<&str> {
1324        self.color.as_ref().map(|c| c.value.as_str())
1325    }
1326
1327    fn style_id(&self) -> Option<&str> {
1328        self.run_style.as_ref().map(|s| s.value.as_str())
1329    }
1330
1331    fn font_ascii(&self) -> Option<&str> {
1332        self.fonts.as_ref().and_then(|f| f.ascii.as_deref())
1333    }
1334
1335    fn is_rtl(&self) -> bool {
1336        is_on(&self.rtl)
1337    }
1338
1339    fn is_outline(&self) -> bool {
1340        is_on(&self.outline)
1341    }
1342
1343    fn is_shadow(&self) -> bool {
1344        is_on(&self.shadow)
1345    }
1346
1347    fn is_emboss(&self) -> bool {
1348        is_on(&self.emboss)
1349    }
1350
1351    fn is_imprint(&self) -> bool {
1352        is_on(&self.imprint)
1353    }
1354
1355    fn is_no_proof(&self) -> bool {
1356        is_on(&self.no_proof)
1357    }
1358
1359    fn is_snap_to_grid(&self) -> bool {
1360        is_on(&self.snap_to_grid)
1361    }
1362
1363    fn is_web_hidden(&self) -> bool {
1364        is_on(&self.web_hidden)
1365    }
1366
1367    fn character_spacing(&self) -> Option<i64> {
1368        self.spacing
1369            .as_ref()
1370            .and_then(|s| s.value.parse::<i64>().ok())
1371    }
1372
1373    fn text_scale_percent(&self) -> Option<u32> {
1374        self.width.as_ref()?.value.as_deref()?.parse::<u32>().ok()
1375    }
1376
1377    fn kerning(&self) -> Option<u32> {
1378        self.kern.as_ref().and_then(|k| parse_half_points(&k.value))
1379    }
1380
1381    fn baseline_shift(&self) -> Option<i64> {
1382        self.position
1383            .as_ref()
1384            .and_then(|p| p.value.parse::<i64>().ok())
1385    }
1386
1387    fn language(&self) -> Option<&types::LanguageElement> {
1388        self.lang.as_deref()
1389    }
1390}
1391
1392// =============================================================================
1393// HyperlinkExt
1394// =============================================================================
1395
1396/// Extension methods for `Hyperlink`.
1397pub trait HyperlinkExt {
1398    /// Get runs contained in this hyperlink.
1399    fn runs(&self) -> Vec<&types::Run>;
1400
1401    /// Extract text from this hyperlink.
1402    fn text(&self) -> String;
1403
1404    /// Get the anchor string (in-document bookmark reference).
1405    fn anchor_str(&self) -> Option<&str>;
1406
1407    /// Get the relationship ID (`r:id` attribute) for external hyperlinks.
1408    fn rel_id(&self) -> Option<&str>;
1409
1410    /// Check if this is an external hyperlink (has a relationship ID).
1411    fn is_external(&self) -> bool;
1412}
1413
1414impl HyperlinkExt for types::Hyperlink {
1415    fn runs(&self) -> Vec<&types::Run> {
1416        collect_runs_from_paragraph_content(&self.paragraph_content)
1417    }
1418
1419    fn text(&self) -> String {
1420        let mut out = String::new();
1421        for item in &self.paragraph_content {
1422            collect_text_from_paragraph_content(item, &mut out);
1423        }
1424        out
1425    }
1426
1427    fn anchor_str(&self) -> Option<&str> {
1428        #[cfg(feature = "wml-hyperlinks")]
1429        {
1430            self.anchor.as_deref()
1431        }
1432        #[cfg(not(feature = "wml-hyperlinks"))]
1433        {
1434            None
1435        }
1436    }
1437
1438    fn rel_id(&self) -> Option<&str> {
1439        #[cfg(feature = "wml-hyperlinks")]
1440        {
1441            self.id.as_deref()
1442        }
1443        #[cfg(not(feature = "wml-hyperlinks"))]
1444        {
1445            None
1446        }
1447    }
1448
1449    fn is_external(&self) -> bool {
1450        #[cfg(feature = "wml-hyperlinks")]
1451        {
1452            self.id.is_some()
1453        }
1454        #[cfg(not(feature = "wml-hyperlinks"))]
1455        {
1456            false
1457        }
1458    }
1459}
1460
1461// =============================================================================
1462// TableExt
1463// =============================================================================
1464
1465/// Extension methods for `Table`.
1466pub trait TableExt {
1467    /// Get all rows in this table.
1468    fn rows(&self) -> Vec<&types::CTRow>;
1469
1470    /// Get the number of rows.
1471    fn row_count(&self) -> usize;
1472
1473    /// Get table properties.
1474    fn properties(&self) -> &types::TableProperties;
1475
1476    /// Extract all text from the table.
1477    fn text(&self) -> String;
1478}
1479
1480impl TableExt for types::Table {
1481    fn rows(&self) -> Vec<&types::CTRow> {
1482        self.rows
1483            .iter()
1484            .filter_map(|c| match c {
1485                types::RowContent::Tr(row) => Some(row.as_ref()),
1486                _ => None,
1487            })
1488            .collect()
1489    }
1490
1491    fn row_count(&self) -> usize {
1492        self.rows().len()
1493    }
1494
1495    fn properties(&self) -> &types::TableProperties {
1496        &self.table_properties
1497    }
1498
1499    fn text(&self) -> String {
1500        let row_texts: Vec<String> = self.rows().iter().map(|r| r.text()).collect();
1501        row_texts.join("\n")
1502    }
1503}
1504
1505// =============================================================================
1506// RowExt
1507// =============================================================================
1508
1509/// Extension methods for `CTRow`.
1510pub trait RowExt {
1511    /// Get all cells in this row.
1512    fn cells(&self) -> Vec<&types::TableCell>;
1513
1514    /// Get row properties.
1515    #[cfg(feature = "wml-tables")]
1516    fn properties(&self) -> Option<&types::TableRowProperties>;
1517
1518    /// Extract all text from the row.
1519    fn text(&self) -> String;
1520}
1521
1522impl RowExt for types::CTRow {
1523    fn cells(&self) -> Vec<&types::TableCell> {
1524        self.cells
1525            .iter()
1526            .filter_map(|c| match c {
1527                types::CellContent::Tc(cell) => Some(cell.as_ref()),
1528                _ => None,
1529            })
1530            .collect()
1531    }
1532
1533    #[cfg(feature = "wml-tables")]
1534    fn properties(&self) -> Option<&types::TableRowProperties> {
1535        self.row_properties.as_deref()
1536    }
1537
1538    fn text(&self) -> String {
1539        let cell_texts: Vec<String> = self.cells().iter().map(|c| c.text()).collect();
1540        cell_texts.join("\t")
1541    }
1542}
1543
1544// =============================================================================
1545// CellExt
1546// =============================================================================
1547
1548/// Extension methods for `TableCell`.
1549pub trait CellExt {
1550    /// Get all paragraphs in this cell.
1551    fn paragraphs(&self) -> Vec<&types::Paragraph>;
1552
1553    /// Get cell properties.
1554    #[cfg(feature = "wml-tables")]
1555    fn properties(&self) -> Option<&types::TableCellProperties>;
1556
1557    /// Extract all text from the cell.
1558    fn text(&self) -> String;
1559}
1560
1561impl CellExt for types::TableCell {
1562    fn paragraphs(&self) -> Vec<&types::Paragraph> {
1563        self.block_content
1564            .iter()
1565            .filter_map(|elt| match elt {
1566                types::BlockContent::P(p) => Some(p.as_ref()),
1567                _ => None,
1568            })
1569            .collect()
1570    }
1571
1572    #[cfg(feature = "wml-tables")]
1573    fn properties(&self) -> Option<&types::TableCellProperties> {
1574        self.cell_properties.as_deref()
1575    }
1576
1577    fn text(&self) -> String {
1578        let texts: Vec<String> = self.paragraphs().iter().map(|p| p.text()).collect();
1579        texts.join("\n")
1580    }
1581}
1582
1583// =============================================================================
1584// SectionPropertiesExt
1585// =============================================================================
1586
1587/// Extension methods for `SectionProperties` (ECMA-376 §17.6.17).
1588#[cfg(feature = "wml-layout")]
1589pub trait SectionPropertiesExt {
1590    /// Get the page size element.
1591    fn page_size(&self) -> Option<&types::PageSize>;
1592
1593    /// Get the page margins element.
1594    fn page_margins(&self) -> Option<&types::PageMargins>;
1595
1596    /// Get page width in twips.
1597    fn page_width_twips(&self) -> Option<u64>;
1598
1599    /// Get page height in twips.
1600    fn page_height_twips(&self) -> Option<u64>;
1601
1602    /// Get page orientation.
1603    fn page_orientation(&self) -> Option<&types::STPageOrientation>;
1604
1605    /// Check if the section has a distinct title (first) page.
1606    fn has_title_page(&self) -> bool;
1607
1608    /// Get header references (type + relationship ID from extra_attrs).
1609    #[cfg(feature = "extra-attrs")]
1610    fn header_references(&self) -> Vec<(&types::STHdrFtr, &str)>;
1611
1612    /// Get footer references (type + relationship ID from extra_attrs).
1613    #[cfg(feature = "extra-attrs")]
1614    fn footer_references(&self) -> Vec<(&types::STHdrFtr, &str)>;
1615}
1616
1617#[cfg(feature = "wml-layout")]
1618impl SectionPropertiesExt for types::SectionProperties {
1619    fn page_size(&self) -> Option<&types::PageSize> {
1620        self.pg_sz.as_deref()
1621    }
1622
1623    fn page_margins(&self) -> Option<&types::PageMargins> {
1624        self.pg_mar.as_deref()
1625    }
1626
1627    fn page_width_twips(&self) -> Option<u64> {
1628        self.pg_sz
1629            .as_ref()
1630            .and_then(|sz| sz.width.as_ref())
1631            .and_then(|w| w.parse::<u64>().ok())
1632    }
1633
1634    fn page_height_twips(&self) -> Option<u64> {
1635        self.pg_sz
1636            .as_ref()
1637            .and_then(|sz| sz.height.as_ref())
1638            .and_then(|h| h.parse::<u64>().ok())
1639    }
1640
1641    fn page_orientation(&self) -> Option<&types::STPageOrientation> {
1642        self.pg_sz.as_ref().and_then(|sz| sz.orient.as_ref())
1643    }
1644
1645    fn has_title_page(&self) -> bool {
1646        is_on(&self.title_pg)
1647    }
1648
1649    #[cfg(feature = "extra-attrs")]
1650    fn header_references(&self) -> Vec<(&types::STHdrFtr, &str)> {
1651        self.header_footer_refs
1652            .iter()
1653            .filter_map(|r| match r {
1654                types::HeaderFooterRef::HeaderReference(h) => {
1655                    h.extra_attrs.get("r:id").map(|id| (&h.r#type, id.as_str()))
1656                }
1657                _ => None,
1658            })
1659            .collect()
1660    }
1661
1662    #[cfg(feature = "extra-attrs")]
1663    fn footer_references(&self) -> Vec<(&types::STHdrFtr, &str)> {
1664        self.header_footer_refs
1665            .iter()
1666            .filter_map(|r| match r {
1667                types::HeaderFooterRef::FooterReference(f) => {
1668                    f.extra_attrs.get("r:id").map(|id| (&f.r#type, id.as_str()))
1669                }
1670                _ => None,
1671            })
1672            .collect()
1673    }
1674}
1675
1676// =============================================================================
1677// Style Resolution
1678// =============================================================================
1679
1680/// Context for resolving run properties through the style inheritance chain.
1681///
1682/// OOXML styles form a `basedOn` chain. Resolution order (ECMA-376 §17.7.2):
1683/// 1. Direct run properties on the run
1684/// 2. Character style (referenced by `rPr/rStyle`)
1685/// 3. Walk the `basedOn` chain of the character style
1686/// 4. Document defaults (`docDefaults/rPrDefault/rPr`)
1687#[cfg(feature = "wml-styling")]
1688#[derive(Debug, Clone, Default)]
1689pub struct StyleContext {
1690    /// Styles indexed by styleId.
1691    pub styles: std::collections::HashMap<String, types::Style>,
1692    /// Default run properties from `docDefaults`.
1693    pub default_run_properties: Option<types::RunProperties>,
1694}
1695
1696#[cfg(feature = "wml-styling")]
1697impl StyleContext {
1698    /// Build a `StyleContext` from a parsed `Styles` document.
1699    pub fn from_styles(styles_doc: &types::Styles) -> Self {
1700        let mut styles = std::collections::HashMap::new();
1701        for style in &styles_doc.style {
1702            if let Some(ref id) = style.style_id {
1703                styles.insert(id.clone(), style.clone());
1704            }
1705        }
1706
1707        let default_run_properties = styles_doc
1708            .doc_defaults
1709            .as_ref()
1710            .and_then(|dd| dd.r_pr_default.as_ref())
1711            .and_then(|rpd| rpd.r_pr.as_ref())
1712            .map(|rp| rp.as_ref().clone());
1713
1714        Self {
1715            styles,
1716            default_run_properties,
1717        }
1718    }
1719
1720    /// Look up a style by its ID.
1721    pub fn style(&self, id: &str) -> Option<&types::Style> {
1722        self.styles.get(id)
1723    }
1724
1725    /// Walk the `basedOn` chain for a style, collecting run properties.
1726    /// Returns properties in order from most derived to least derived.
1727    /// Depth-limited to 20 to prevent infinite loops.
1728    fn collect_style_chain_rpr(&self, style_id: &str) -> Vec<&types::RunProperties> {
1729        let mut result = Vec::new();
1730        let mut current_id = Some(style_id.to_string());
1731        let mut depth = 0;
1732
1733        while let Some(ref id) = current_id {
1734            if depth >= 20 {
1735                break;
1736            }
1737            if let Some(style) = self.styles.get(id) {
1738                if let Some(ref rpr) = style.r_pr {
1739                    result.push(rpr.as_ref());
1740                }
1741                current_id = style.based_on.as_ref().map(|b| b.value.clone());
1742            } else {
1743                break;
1744            }
1745            depth += 1;
1746        }
1747        result
1748    }
1749}
1750
1751/// Extension methods for `Run` that resolve formatting through the style chain.
1752#[cfg(feature = "wml-styling")]
1753pub trait RunResolveExt {
1754    /// Resolve bold through direct → style chain → defaults.
1755    fn resolved_is_bold(&self, ctx: &StyleContext) -> bool;
1756
1757    /// Resolve italic through direct → style chain → defaults.
1758    fn resolved_is_italic(&self, ctx: &StyleContext) -> bool;
1759
1760    /// Resolve font size in half-points through direct → style chain → defaults.
1761    fn resolved_font_size_half_points(&self, ctx: &StyleContext) -> Option<u32>;
1762
1763    /// Resolve ASCII font name through direct → style chain → defaults.
1764    fn resolved_font_ascii(&self, ctx: &StyleContext) -> Option<String>;
1765
1766    /// Resolve text color hex through direct → style chain → defaults.
1767    fn resolved_color_hex(&self, ctx: &StyleContext) -> Option<String>;
1768
1769    /// Resolve underline through direct → style chain → defaults.
1770    fn resolved_is_underline(&self, ctx: &StyleContext) -> bool;
1771
1772    /// Resolve single strikethrough through direct → style chain → defaults.
1773    fn resolved_is_strikethrough(&self, ctx: &StyleContext) -> bool;
1774
1775    /// Resolve double strikethrough through direct → style chain → defaults.
1776    fn resolved_is_double_strikethrough(&self, ctx: &StyleContext) -> bool;
1777
1778    /// Resolve all-caps through direct → style chain → defaults.
1779    fn resolved_is_all_caps(&self, ctx: &StyleContext) -> bool;
1780
1781    /// Resolve small-caps through direct → style chain → defaults.
1782    fn resolved_is_small_caps(&self, ctx: &StyleContext) -> bool;
1783
1784    /// Resolve hidden (`<w:vanish>`) through direct → style chain → defaults.
1785    fn resolved_is_hidden(&self, ctx: &StyleContext) -> bool;
1786
1787    /// Resolve highlight color through direct → style chain → defaults.
1788    fn resolved_highlight_color(&self, ctx: &StyleContext) -> Option<types::STHighlightColor>;
1789
1790    /// Resolve vertical alignment through direct → style chain → defaults.
1791    fn resolved_vertical_alignment(&self, ctx: &StyleContext) -> Option<types::STVerticalAlignRun>;
1792}
1793
1794#[cfg(feature = "wml-styling")]
1795impl RunResolveExt for types::Run {
1796    fn resolved_is_bold(&self, ctx: &StyleContext) -> bool {
1797        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.bold)
1798    }
1799
1800    fn resolved_is_italic(&self, ctx: &StyleContext) -> bool {
1801        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.italic)
1802    }
1803
1804    fn resolved_font_size_half_points(&self, ctx: &StyleContext) -> Option<u32> {
1805        resolve_option(&self.r_pr, ctx, |rpr| {
1806            rpr.size
1807                .as_ref()
1808                .and_then(|sz| parse_half_points(&sz.value))
1809        })
1810    }
1811
1812    fn resolved_font_ascii(&self, ctx: &StyleContext) -> Option<String> {
1813        resolve_option(&self.r_pr, ctx, |rpr| {
1814            rpr.fonts.as_ref().and_then(|f| f.ascii.clone())
1815        })
1816    }
1817
1818    fn resolved_color_hex(&self, ctx: &StyleContext) -> Option<String> {
1819        resolve_option(&self.r_pr, ctx, |rpr| {
1820            rpr.color.as_ref().map(|c| c.value.clone())
1821        })
1822    }
1823
1824    fn resolved_is_underline(&self, ctx: &StyleContext) -> bool {
1825        resolve_option(&self.r_pr, ctx, |rpr| {
1826            rpr.underline
1827                .as_ref()
1828                .map(|u| !matches!(u.value, Some(types::STUnderline::None)))
1829        })
1830        .unwrap_or(false)
1831    }
1832
1833    fn resolved_is_strikethrough(&self, ctx: &StyleContext) -> bool {
1834        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.strikethrough)
1835    }
1836
1837    fn resolved_is_double_strikethrough(&self, ctx: &StyleContext) -> bool {
1838        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.dstrike)
1839    }
1840
1841    fn resolved_is_all_caps(&self, ctx: &StyleContext) -> bool {
1842        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.caps)
1843    }
1844
1845    fn resolved_is_small_caps(&self, ctx: &StyleContext) -> bool {
1846        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.small_caps)
1847    }
1848
1849    fn resolved_is_hidden(&self, ctx: &StyleContext) -> bool {
1850        resolve_toggle(&self.r_pr, ctx, |rpr| &rpr.vanish)
1851    }
1852
1853    fn resolved_highlight_color(&self, ctx: &StyleContext) -> Option<types::STHighlightColor> {
1854        resolve_option(&self.r_pr, ctx, |rpr| {
1855            rpr.highlight.as_ref().map(|h| h.value)
1856        })
1857    }
1858
1859    fn resolved_vertical_alignment(&self, ctx: &StyleContext) -> Option<types::STVerticalAlignRun> {
1860        resolve_option(&self.r_pr, ctx, |rpr| {
1861            rpr.vert_align.as_ref().map(|va| va.value)
1862        })
1863    }
1864}
1865
1866/// Resolve a toggle property through the style chain.
1867#[cfg(feature = "wml-styling")]
1868fn resolve_toggle(
1869    direct_rpr: &Option<Box<types::RunProperties>>,
1870    ctx: &StyleContext,
1871    accessor: impl Fn(&types::RunProperties) -> &Option<Box<types::OnOffElement>>,
1872) -> bool {
1873    // 1. Direct run properties
1874    if let Some(rpr) = direct_rpr {
1875        if let Some(val) = check_toggle(accessor(rpr)) {
1876            return val;
1877        }
1878
1879        // 2. Style chain via rStyle
1880        if let Some(style_ref) = &rpr.run_style {
1881            for chain_rpr in ctx.collect_style_chain_rpr(&style_ref.value) {
1882                if let Some(val) = check_toggle(accessor(chain_rpr)) {
1883                    return val;
1884                }
1885            }
1886        }
1887    }
1888
1889    // 3. Document defaults
1890    if let Some(defaults) = &ctx.default_run_properties
1891        && let Some(val) = check_toggle(accessor(defaults))
1892    {
1893        return val;
1894    }
1895
1896    false
1897}
1898
1899/// Resolve an optional property through the style chain.
1900#[cfg(feature = "wml-styling")]
1901fn resolve_option<T>(
1902    direct_rpr: &Option<Box<types::RunProperties>>,
1903    ctx: &StyleContext,
1904    accessor: impl Fn(&types::RunProperties) -> Option<T>,
1905) -> Option<T> {
1906    // 1. Direct run properties
1907    if let Some(rpr) = direct_rpr {
1908        if let val @ Some(_) = accessor(rpr) {
1909            return val;
1910        }
1911
1912        // 2. Style chain via rStyle
1913        if let Some(style_ref) = &rpr.run_style {
1914            for chain_rpr in ctx.collect_style_chain_rpr(&style_ref.value) {
1915                if let val @ Some(_) = accessor(chain_rpr) {
1916                    return val;
1917                }
1918            }
1919        }
1920    }
1921
1922    // 3. Document defaults
1923    if let Some(defaults) = &ctx.default_run_properties
1924        && let val @ Some(_) = accessor(defaults)
1925    {
1926        return val;
1927    }
1928
1929    None
1930}
1931
1932// =============================================================================
1933// Parsing Functions
1934// =============================================================================
1935
1936/// Parse a `Document` from XML bytes using the generated `FromXml` parser.
1937///
1938/// This is the recommended way to parse document.xml content.
1939pub fn parse_document(xml: &[u8]) -> Result<types::Document, ParseError> {
1940    let mut reader = Reader::from_reader(Cursor::new(xml));
1941    let mut buf = Vec::new();
1942
1943    loop {
1944        match reader.read_event_into(&mut buf) {
1945            Ok(Event::Start(e)) => return types::Document::from_xml(&mut reader, &e, false),
1946            Ok(Event::Empty(e)) => return types::Document::from_xml(&mut reader, &e, true),
1947            Ok(Event::Eof) => break,
1948            Err(e) => return Err(ParseError::Xml(e)),
1949            _ => {}
1950        }
1951        buf.clear();
1952    }
1953    Err(ParseError::UnexpectedElement(
1954        "no document element found".to_string(),
1955    ))
1956}
1957
1958/// Parse a `Styles` document from XML bytes using the generated `FromXml` parser.
1959///
1960/// This is the recommended way to parse styles.xml content.
1961pub fn parse_styles(xml: &[u8]) -> Result<types::Styles, ParseError> {
1962    let mut reader = Reader::from_reader(Cursor::new(xml));
1963    let mut buf = Vec::new();
1964
1965    loop {
1966        match reader.read_event_into(&mut buf) {
1967            Ok(Event::Start(e)) => return types::Styles::from_xml(&mut reader, &e, false),
1968            Ok(Event::Empty(e)) => return types::Styles::from_xml(&mut reader, &e, true),
1969            Ok(Event::Eof) => break,
1970            Err(e) => return Err(ParseError::Xml(e)),
1971            _ => {}
1972        }
1973        buf.clear();
1974    }
1975    Err(ParseError::UnexpectedElement(
1976        "no styles element found".to_string(),
1977    ))
1978}
1979
1980/// Parse a header or footer from XML bytes using the generated `FromXml` parser.
1981pub fn parse_hdr_ftr(xml: &[u8]) -> Result<types::HeaderFooter, ParseError> {
1982    let mut reader = Reader::from_reader(Cursor::new(xml));
1983    let mut buf = Vec::new();
1984
1985    loop {
1986        match reader.read_event_into(&mut buf) {
1987            Ok(Event::Start(e)) => return types::HeaderFooter::from_xml(&mut reader, &e, false),
1988            Ok(Event::Empty(e)) => return types::HeaderFooter::from_xml(&mut reader, &e, true),
1989            Ok(Event::Eof) => break,
1990            Err(e) => return Err(ParseError::Xml(e)),
1991            _ => {}
1992        }
1993        buf.clear();
1994    }
1995    Err(ParseError::UnexpectedElement(
1996        "no header/footer element found".to_string(),
1997    ))
1998}
1999
2000/// Parse footnotes from XML bytes using the generated `FromXml` parser.
2001pub fn parse_footnotes(xml: &[u8]) -> Result<types::Footnotes, ParseError> {
2002    let mut reader = Reader::from_reader(Cursor::new(xml));
2003    let mut buf = Vec::new();
2004
2005    loop {
2006        match reader.read_event_into(&mut buf) {
2007            Ok(Event::Start(e)) => return types::Footnotes::from_xml(&mut reader, &e, false),
2008            Ok(Event::Empty(e)) => return types::Footnotes::from_xml(&mut reader, &e, true),
2009            Ok(Event::Eof) => break,
2010            Err(e) => return Err(ParseError::Xml(e)),
2011            _ => {}
2012        }
2013        buf.clear();
2014    }
2015    Err(ParseError::UnexpectedElement(
2016        "no footnotes element found".to_string(),
2017    ))
2018}
2019
2020/// Parse endnotes from XML bytes using the generated `FromXml` parser.
2021pub fn parse_endnotes(xml: &[u8]) -> Result<types::Endnotes, ParseError> {
2022    let mut reader = Reader::from_reader(Cursor::new(xml));
2023    let mut buf = Vec::new();
2024
2025    loop {
2026        match reader.read_event_into(&mut buf) {
2027            Ok(Event::Start(e)) => return types::Endnotes::from_xml(&mut reader, &e, false),
2028            Ok(Event::Empty(e)) => return types::Endnotes::from_xml(&mut reader, &e, true),
2029            Ok(Event::Eof) => break,
2030            Err(e) => return Err(ParseError::Xml(e)),
2031            _ => {}
2032        }
2033        buf.clear();
2034    }
2035    Err(ParseError::UnexpectedElement(
2036        "no endnotes element found".to_string(),
2037    ))
2038}
2039
2040/// Parse comments from XML bytes using the generated `FromXml` parser.
2041pub fn parse_comments(xml: &[u8]) -> Result<types::Comments, ParseError> {
2042    let mut reader = Reader::from_reader(Cursor::new(xml));
2043    let mut buf = Vec::new();
2044
2045    loop {
2046        match reader.read_event_into(&mut buf) {
2047            Ok(Event::Start(e)) => return types::Comments::from_xml(&mut reader, &e, false),
2048            Ok(Event::Empty(e)) => return types::Comments::from_xml(&mut reader, &e, true),
2049            Ok(Event::Eof) => break,
2050            Err(e) => return Err(ParseError::Xml(e)),
2051            _ => {}
2052        }
2053        buf.clear();
2054    }
2055    Err(ParseError::UnexpectedElement(
2056        "no comments element found".to_string(),
2057    ))
2058}
2059
2060/// Parse a chart part from XML bytes using the `ooxml_dml` generated `FromXml` parser.
2061///
2062/// This is used by `Document::get_chart()` to parse `word/charts/chartN.xml` parts.
2063/// Requires the `wml-charts` feature.
2064///
2065/// ECMA-376 Part 1, §21.2.2.27 (chartSpace).
2066#[cfg(feature = "wml-charts")]
2067pub(crate) fn parse_chart(xml: &[u8]) -> Result<ooxml_dml::types::ChartSpace, ParseError> {
2068    use ooxml_dml::parsers::FromXml as DmlFromXml;
2069    let mut reader = Reader::from_reader(Cursor::new(xml));
2070    let mut buf = Vec::new();
2071
2072    loop {
2073        match reader.read_event_into(&mut buf) {
2074            Ok(Event::Start(e)) => {
2075                return ooxml_dml::types::ChartSpace::from_xml(&mut reader, &e, false)
2076                    .map_err(|e| ParseError::UnexpectedElement(e.to_string()));
2077            }
2078            Ok(Event::Empty(e)) => {
2079                return ooxml_dml::types::ChartSpace::from_xml(&mut reader, &e, true)
2080                    .map_err(|e| ParseError::UnexpectedElement(e.to_string()));
2081            }
2082            Ok(Event::Eof) => break,
2083            Err(e) => return Err(ParseError::Xml(e)),
2084            _ => {}
2085        }
2086        buf.clear();
2087    }
2088    Err(ParseError::UnexpectedElement(
2089        "no chartSpace element found".to_string(),
2090    ))
2091}
2092
2093// =============================================================================
2094// ResolvedDocument
2095// =============================================================================
2096
2097/// A document with bound style context for convenient resolved access.
2098///
2099/// Wraps a generated `types::Document` and provides methods that automatically
2100/// resolve formatting through the style chain.
2101///
2102/// # Example
2103///
2104/// ```ignore
2105/// use ooxml_wml::ext::{ResolvedDocument, parse_document, parse_styles};
2106///
2107/// let doc = parse_document(doc_xml)?;
2108/// let styles = parse_styles(styles_xml)?;
2109/// let resolved = ResolvedDocument::new(doc, styles);
2110///
2111/// if let Some(body) = resolved.body() {
2112///     for para in body.paragraphs() {
2113///         println!("{}", para.text());
2114///     }
2115/// }
2116/// ```
2117#[cfg(feature = "wml-styling")]
2118pub struct ResolvedDocument {
2119    document: types::Document,
2120    context: StyleContext,
2121}
2122
2123#[cfg(feature = "wml-styling")]
2124impl ResolvedDocument {
2125    /// Create a new resolved document from a parsed document and styles.
2126    pub fn new(document: types::Document, styles: types::Styles) -> Self {
2127        let context = StyleContext::from_styles(&styles);
2128        Self { document, context }
2129    }
2130
2131    /// Create from a document with an existing style context.
2132    pub fn with_context(document: types::Document, context: StyleContext) -> Self {
2133        Self { document, context }
2134    }
2135
2136    /// Get the underlying document.
2137    pub fn document(&self) -> &types::Document {
2138        &self.document
2139    }
2140
2141    /// Get the style context.
2142    pub fn context(&self) -> &StyleContext {
2143        &self.context
2144    }
2145
2146    /// Get the document body.
2147    pub fn body(&self) -> Option<&types::Body> {
2148        self.document.body()
2149    }
2150
2151    /// Extract all text from the document.
2152    pub fn text(&self) -> String {
2153        self.document.body().map(|b| b.text()).unwrap_or_default()
2154    }
2155
2156    /// Check if a run is bold (resolved through style chain).
2157    pub fn is_bold(&self, run: &types::Run) -> bool {
2158        run.resolved_is_bold(&self.context)
2159    }
2160
2161    /// Check if a run is italic (resolved through style chain).
2162    pub fn is_italic(&self, run: &types::Run) -> bool {
2163        run.resolved_is_italic(&self.context)
2164    }
2165
2166    /// Get resolved font size in half-points.
2167    pub fn font_size_half_points(&self, run: &types::Run) -> Option<u32> {
2168        run.resolved_font_size_half_points(&self.context)
2169    }
2170
2171    /// Get resolved ASCII font name.
2172    pub fn font_ascii(&self, run: &types::Run) -> Option<String> {
2173        run.resolved_font_ascii(&self.context)
2174    }
2175
2176    /// Get resolved text color hex.
2177    pub fn color_hex(&self, run: &types::Run) -> Option<String> {
2178        run.resolved_color_hex(&self.context)
2179    }
2180
2181    /// Check if a run is underlined (resolved through style chain).
2182    pub fn is_underline(&self, run: &types::Run) -> bool {
2183        run.resolved_is_underline(&self.context)
2184    }
2185
2186    /// Check if a run is struck through (resolved through style chain).
2187    pub fn is_strikethrough(&self, run: &types::Run) -> bool {
2188        run.resolved_is_strikethrough(&self.context)
2189    }
2190
2191    /// Check if a run is double struck through (resolved through style chain).
2192    pub fn is_double_strikethrough(&self, run: &types::Run) -> bool {
2193        run.resolved_is_double_strikethrough(&self.context)
2194    }
2195
2196    /// Check if a run is all-caps (resolved through style chain).
2197    pub fn is_all_caps(&self, run: &types::Run) -> bool {
2198        run.resolved_is_all_caps(&self.context)
2199    }
2200
2201    /// Check if a run is small-caps (resolved through style chain).
2202    pub fn is_small_caps(&self, run: &types::Run) -> bool {
2203        run.resolved_is_small_caps(&self.context)
2204    }
2205
2206    /// Check if a run is hidden (resolved through style chain).
2207    pub fn is_hidden(&self, run: &types::Run) -> bool {
2208        run.resolved_is_hidden(&self.context)
2209    }
2210
2211    /// Get resolved highlight color.
2212    pub fn highlight_color(&self, run: &types::Run) -> Option<types::STHighlightColor> {
2213        run.resolved_highlight_color(&self.context)
2214    }
2215
2216    /// Get resolved vertical alignment.
2217    pub fn vertical_alignment(&self, run: &types::Run) -> Option<types::STVerticalAlignRun> {
2218        run.resolved_vertical_alignment(&self.context)
2219    }
2220}
2221
2222// =============================================================================
2223// RevisionExt / BodyRevisionExt
2224// =============================================================================
2225
2226/// The type of a tracked change (ECMA-376 §17.13).
2227#[cfg(feature = "wml-track-changes")]
2228#[derive(Debug, Clone, PartialEq, Eq)]
2229pub enum TrackChangeType {
2230    /// Content that was inserted (`<w:ins>`).
2231    Insertion,
2232    /// Content that was deleted (`<w:del>`).
2233    Deletion,
2234    /// Content that was moved away from this location (`<w:moveFrom>`).
2235    MoveFrom,
2236    /// Content that was moved to this location (`<w:moveTo>`).
2237    MoveTo,
2238}
2239
2240/// A single tracked change in a paragraph (ECMA-376 §17.13.5).
2241#[cfg(feature = "wml-track-changes")]
2242#[derive(Debug, Clone)]
2243pub struct TrackChange {
2244    /// Revision ID (`w:id` attribute).
2245    pub id: i64,
2246    /// Author string (`w:author` attribute).
2247    pub author: String,
2248    /// Optional ISO 8601 date/time string (`w:date` attribute).
2249    pub date: Option<String>,
2250    /// The kind of change.
2251    pub change_type: TrackChangeType,
2252    /// Plain text extracted from the run content inside the change.
2253    pub text: String,
2254}
2255
2256/// Extension methods for reading tracked changes from a paragraph (ECMA-376 §17.13).
2257#[cfg(feature = "wml-track-changes")]
2258pub trait RevisionExt {
2259    /// All tracked changes in this paragraph.
2260    fn track_changes(&self) -> Vec<TrackChange>;
2261
2262    /// Text produced by accepting all tracked changes: insertions are kept,
2263    /// deletions are removed, normal runs are kept.
2264    fn accepted_text(&self) -> String;
2265
2266    /// Text produced by rejecting all tracked changes: insertions are removed,
2267    /// deletions are restored, normal runs are kept.
2268    fn rejected_text(&self) -> String;
2269
2270    /// Whether this paragraph contains any tracked changes.
2271    fn has_track_changes(&self) -> bool;
2272}
2273
2274/// Extract plain text from a `CTRunTrackChange`'s `run_content` field.
2275#[cfg(feature = "wml-track-changes")]
2276fn text_from_run_track_change(tc: &types::CTRunTrackChange) -> String {
2277    let mut out = String::new();
2278    for item in &tc.run_content {
2279        if let types::RunContentChoice::R(run) = item {
2280            for rc in &run.run_content {
2281                match rc {
2282                    types::RunContent::T(t) => {
2283                        if let Some(ref s) = t.text {
2284                            out.push_str(s);
2285                        }
2286                    }
2287                    types::RunContent::Tab(_) => out.push('\t'),
2288                    types::RunContent::Cr(_) => out.push('\n'),
2289                    types::RunContent::Br(br) => {
2290                        if !matches!(
2291                            br.r#type,
2292                            Some(types::STBrType::Page) | Some(types::STBrType::Column)
2293                        ) {
2294                            out.push('\n');
2295                        }
2296                    }
2297                    // Also capture del-text for deletion change content
2298                    types::RunContent::DelText(t) => {
2299                        if let Some(ref s) = t.text {
2300                            out.push_str(s);
2301                        }
2302                    }
2303                    _ => {}
2304                }
2305            }
2306        }
2307    }
2308    out
2309}
2310
2311#[cfg(feature = "wml-track-changes")]
2312impl RevisionExt for types::Paragraph {
2313    fn track_changes(&self) -> Vec<TrackChange> {
2314        let mut result = Vec::new();
2315        for item in &self.paragraph_content {
2316            let (tc, change_type) = match item {
2317                types::ParagraphContent::Ins(tc) => (tc.as_ref(), TrackChangeType::Insertion),
2318                types::ParagraphContent::Del(tc) => (tc.as_ref(), TrackChangeType::Deletion),
2319                types::ParagraphContent::MoveFrom(tc) => (tc.as_ref(), TrackChangeType::MoveFrom),
2320                types::ParagraphContent::MoveTo(tc) => (tc.as_ref(), TrackChangeType::MoveTo),
2321                _ => continue,
2322            };
2323            result.push(TrackChange {
2324                id: tc.id,
2325                author: tc.author.clone(),
2326                date: tc.date.clone(),
2327                change_type,
2328                text: text_from_run_track_change(tc),
2329            });
2330        }
2331        result
2332    }
2333
2334    fn accepted_text(&self) -> String {
2335        let mut out = String::new();
2336        for item in &self.paragraph_content {
2337            match item {
2338                // Normal runs always included
2339                types::ParagraphContent::R(r) => {
2340                    out.push_str(&r.text());
2341                }
2342                // Insertions accepted → include text
2343                types::ParagraphContent::Ins(tc) | types::ParagraphContent::MoveTo(tc) => {
2344                    out.push_str(&text_from_run_track_change(tc));
2345                }
2346                // Deletions rejected → skip
2347                types::ParagraphContent::Del(_) | types::ParagraphContent::MoveFrom(_) => {}
2348                // Hyperlinks and simple fields: walk their paragraph_content
2349                types::ParagraphContent::Hyperlink(h) => {
2350                    for inner in &h.paragraph_content {
2351                        collect_text_from_paragraph_content(inner, &mut out);
2352                    }
2353                }
2354                types::ParagraphContent::FldSimple(f) => {
2355                    for inner in &f.paragraph_content {
2356                        collect_text_from_paragraph_content(inner, &mut out);
2357                    }
2358                }
2359                _ => {}
2360            }
2361        }
2362        out
2363    }
2364
2365    fn rejected_text(&self) -> String {
2366        let mut out = String::new();
2367        for item in &self.paragraph_content {
2368            match item {
2369                // Normal runs always included
2370                types::ParagraphContent::R(r) => {
2371                    out.push_str(&r.text());
2372                }
2373                // Insertions rejected → skip
2374                types::ParagraphContent::Ins(_) | types::ParagraphContent::MoveTo(_) => {}
2375                // Deletions restored → include text
2376                types::ParagraphContent::Del(tc) | types::ParagraphContent::MoveFrom(tc) => {
2377                    out.push_str(&text_from_run_track_change(tc));
2378                }
2379                // Hyperlinks and simple fields: walk their paragraph_content
2380                types::ParagraphContent::Hyperlink(h) => {
2381                    for inner in &h.paragraph_content {
2382                        collect_text_from_paragraph_content(inner, &mut out);
2383                    }
2384                }
2385                types::ParagraphContent::FldSimple(f) => {
2386                    for inner in &f.paragraph_content {
2387                        collect_text_from_paragraph_content(inner, &mut out);
2388                    }
2389                }
2390                _ => {}
2391            }
2392        }
2393        out
2394    }
2395
2396    fn has_track_changes(&self) -> bool {
2397        self.paragraph_content.iter().any(|item| {
2398            matches!(
2399                item,
2400                types::ParagraphContent::Ins(_)
2401                    | types::ParagraphContent::Del(_)
2402                    | types::ParagraphContent::MoveFrom(_)
2403                    | types::ParagraphContent::MoveTo(_)
2404            )
2405        })
2406    }
2407}
2408
2409/// Extension methods for reading tracked changes from a document body (ECMA-376 §17.13).
2410#[cfg(feature = "wml-track-changes")]
2411pub trait BodyRevisionExt {
2412    /// All tracked changes in the document body across all paragraphs.
2413    fn all_track_changes(&self) -> Vec<TrackChange>;
2414
2415    /// Full document text with all insertions accepted and deletions removed.
2416    fn accepted_text(&self) -> String;
2417
2418    /// Full document text with all insertions rejected and deletions restored.
2419    fn rejected_text(&self) -> String;
2420}
2421
2422/// Collect paragraphs from `BlockContent` items recursively (handles SDTs, custom XML, etc.).
2423#[cfg(feature = "wml-track-changes")]
2424fn paragraphs_from_block_content(blocks: &[types::BlockContent]) -> Vec<&types::Paragraph> {
2425    let mut result = Vec::new();
2426    for block in blocks {
2427        match block {
2428            types::BlockContent::P(p) => result.push(p.as_ref()),
2429            types::BlockContent::Tbl(t) => {
2430                for row in &t.rows {
2431                    if let types::RowContent::Tr(tr) = row {
2432                        for cell in &tr.cells {
2433                            if let types::CellContent::Tc(tc) = cell {
2434                                result.extend(paragraphs_from_block_content(&tc.block_content));
2435                            }
2436                        }
2437                    }
2438                }
2439            }
2440            types::BlockContent::Sdt(sdt) => {
2441                if let Some(content) = &sdt.sdt_content {
2442                    for inner in &content.block_content {
2443                        if let types::BlockContentChoice::P(p) = inner {
2444                            result.push(p.as_ref());
2445                        }
2446                    }
2447                }
2448            }
2449            _ => {}
2450        }
2451    }
2452    result
2453}
2454
2455#[cfg(feature = "wml-track-changes")]
2456impl BodyRevisionExt for types::Body {
2457    fn all_track_changes(&self) -> Vec<TrackChange> {
2458        paragraphs_from_block_content(&self.block_content)
2459            .into_iter()
2460            .flat_map(|p| p.track_changes())
2461            .collect()
2462    }
2463
2464    fn accepted_text(&self) -> String {
2465        let paras = paragraphs_from_block_content(&self.block_content);
2466        paras
2467            .iter()
2468            .map(|p| p.accepted_text())
2469            .collect::<Vec<_>>()
2470            .join("\n")
2471    }
2472
2473    fn rejected_text(&self) -> String {
2474        let paras = paragraphs_from_block_content(&self.block_content);
2475        paras
2476            .iter()
2477            .map(|p| p.rejected_text())
2478            .collect::<Vec<_>>()
2479            .join("\n")
2480    }
2481}
2482
2483// =============================================================================
2484// Form Fields (ECMA-376 §17.5.2)
2485// =============================================================================
2486
2487/// The kind of a Structured Document Tag form control (ECMA-376 §17.5.2).
2488///
2489/// Determined by which child element is present inside `<w:sdtPr>`.
2490#[cfg(feature = "wml-settings")]
2491#[derive(Debug, Clone, PartialEq)]
2492pub enum FormFieldType {
2493    /// Plain-text input control (`<w:text>`).
2494    ///
2495    /// `multi_line` is `true` when `w:multiLine` is "1", "true", or "on".
2496    PlainText { multi_line: bool },
2497    /// Rich-text area (`<w:richText>`).
2498    RichText,
2499    /// Combo box with a fixed list of choices (`<w:comboBox>`).
2500    ComboBox { choices: Vec<String> },
2501    /// Drop-down list (`<w:dropDownList>`).
2502    DropDownList { choices: Vec<String> },
2503    /// Date picker (`<w:date>`).
2504    DatePicker { format: Option<String> },
2505    /// SDT whose type was not recognised by this library.
2506    Unknown,
2507}
2508
2509/// A form field extracted from a Structured Document Tag (ECMA-376 §17.5.2).
2510///
2511/// Returned by [`FormFieldExt::form_field`] and collected by
2512/// [`BodyExt::form_fields`].
2513#[cfg(feature = "wml-settings")]
2514#[derive(Debug, Clone)]
2515pub struct FormField {
2516    /// Human-readable label (`<w:alias w:val="…"/>`).
2517    pub alias: Option<String>,
2518    /// Machine-readable tag (`<w:tag w:val="…"/>`).
2519    pub tag: Option<String>,
2520    /// The kind of control inferred from `<w:sdtPr>`.
2521    pub field_type: FormFieldType,
2522    /// Current text content of the SDT, extracted from its `sdtContent`.
2523    pub current_value: String,
2524}
2525
2526/// Extract a [`FormField`] from an SDT properties block and its content text.
2527///
2528/// The `sdt_pr` argument must already be `Some`; call sites gate on that.
2529#[cfg(feature = "wml-settings")]
2530fn sdt_pr_to_form_field(sdt_pr: &types::CTSdtPr, current_value: String) -> FormField {
2531    let alias = sdt_pr.alias.as_ref().map(|a| a.value.clone());
2532    let tag = sdt_pr.tag.as_ref().map(|t| t.value.clone());
2533
2534    let field_type = if let Some(text_elem) = &sdt_pr.text {
2535        let multi_line = text_elem
2536            .multi_line
2537            .as_deref()
2538            .map(|v| matches!(v, "1" | "true" | "on"))
2539            .unwrap_or(false);
2540        FormFieldType::PlainText { multi_line }
2541    } else if sdt_pr.rich_text.is_some() {
2542        FormFieldType::RichText
2543    } else if let Some(cb) = &sdt_pr.combo_box {
2544        let choices = cb
2545            .list_item
2546            .iter()
2547            .map(|item| {
2548                item.display_text
2549                    .clone()
2550                    .or_else(|| item.value.clone())
2551                    .unwrap_or_default()
2552            })
2553            .collect();
2554        FormFieldType::ComboBox { choices }
2555    } else if let Some(dd) = &sdt_pr.drop_down_list {
2556        let choices = dd
2557            .list_item
2558            .iter()
2559            .map(|item| {
2560                item.display_text
2561                    .clone()
2562                    .or_else(|| item.value.clone())
2563                    .unwrap_or_default()
2564            })
2565            .collect();
2566        FormFieldType::DropDownList { choices }
2567    } else if let Some(date) = &sdt_pr.date {
2568        let format = date.date_format.as_ref().map(|df| df.value.clone());
2569        FormFieldType::DatePicker { format }
2570    } else {
2571        FormFieldType::Unknown
2572    };
2573
2574    FormField {
2575        alias,
2576        tag,
2577        field_type,
2578        current_value,
2579    }
2580}
2581
2582/// Extension trait for extracting a [`FormField`] from an SDT element.
2583///
2584/// Implemented for both block-level (`CTSdtBlock`) and inline (`CTSdtRun`)
2585/// SDT variants. Returns `Some` whenever `sdt_pr` is present; returns `None`
2586/// if there are no properties at all (which is unusual but spec-legal).
2587///
2588/// ECMA-376 §17.5.2.
2589#[cfg(feature = "wml-settings")]
2590pub trait FormFieldExt {
2591    /// Extract a [`FormField`] if this SDT has `<w:sdtPr>` properties.
2592    fn form_field(&self) -> Option<FormField>;
2593}
2594
2595#[cfg(feature = "wml-settings")]
2596impl FormFieldExt for types::CTSdtBlock {
2597    fn form_field(&self) -> Option<FormField> {
2598        let sdt_pr = self.sdt_pr.as_deref()?;
2599        let value = extract_text_from_block_sdt_content(self.sdt_content.as_deref());
2600        Some(sdt_pr_to_form_field(sdt_pr, value))
2601    }
2602}
2603
2604#[cfg(feature = "wml-settings")]
2605impl FormFieldExt for types::CTSdtRun {
2606    fn form_field(&self) -> Option<FormField> {
2607        let sdt_pr = self.sdt_pr.as_deref()?;
2608        let value = extract_text_from_run_sdt_content(self.sdt_content.as_deref());
2609        Some(sdt_pr_to_form_field(sdt_pr, value))
2610    }
2611}
2612
2613/// Extract plain text from `CTSdtContentBlock` by walking its block content
2614/// choices (paragraphs and nested tables).
2615#[cfg(feature = "wml-settings")]
2616fn extract_text_from_block_sdt_content(content: Option<&types::CTSdtContentBlock>) -> String {
2617    let content = match content {
2618        Some(c) => c,
2619        None => return String::new(),
2620    };
2621    let parts: Vec<String> = content
2622        .block_content
2623        .iter()
2624        .filter_map(|bc| match bc {
2625            types::BlockContentChoice::P(p) => Some(p.text()),
2626            types::BlockContentChoice::Tbl(t) => Some(t.text()),
2627            _ => None,
2628        })
2629        .collect();
2630    parts.join("\n")
2631}
2632
2633/// Extract plain text from `CTSdtContentRun` by walking its paragraph content.
2634#[cfg(feature = "wml-settings")]
2635fn extract_text_from_run_sdt_content(content: Option<&types::CTSdtContentRun>) -> String {
2636    let content = match content {
2637        Some(c) => c,
2638        None => return String::new(),
2639    };
2640    let mut out = String::new();
2641    for item in &content.paragraph_content {
2642        collect_text_from_paragraph_content(item, &mut out);
2643    }
2644    out
2645}
2646
2647/// Collect all form fields from a slice of [`types::BlockContent`] items,
2648/// recursing into tables and SDT block content.
2649#[cfg(feature = "wml-settings")]
2650fn collect_form_fields_from_block_content(blocks: &[types::BlockContent]) -> Vec<FormField> {
2651    let mut result = Vec::new();
2652    for block in blocks {
2653        match block {
2654            types::BlockContent::Sdt(sdt) => {
2655                if let Some(field) = sdt.form_field() {
2656                    result.push(field);
2657                }
2658                // Also look inside the SDT's block content for nested SDTs.
2659                if let Some(content) = &sdt.sdt_content {
2660                    for inner in &content.block_content {
2661                        collect_form_fields_from_block_content_choice(inner, &mut result);
2662                    }
2663                }
2664            }
2665            types::BlockContent::Tbl(t) => {
2666                for row in &t.rows {
2667                    if let types::RowContent::Tr(tr) = row {
2668                        for cell_content in &tr.cells {
2669                            if let types::CellContent::Tc(tc) = cell_content {
2670                                result.extend(collect_form_fields_from_block_content(
2671                                    &tc.block_content,
2672                                ));
2673                            }
2674                        }
2675                    }
2676                }
2677            }
2678            types::BlockContent::P(para) => {
2679                // Inline (run-level) SDTs inside paragraphs.
2680                for item in &para.paragraph_content {
2681                    if let types::ParagraphContent::Sdt(sdt_run) = item
2682                        && let Some(field) = sdt_run.form_field()
2683                    {
2684                        result.push(field);
2685                    }
2686                }
2687            }
2688            _ => {}
2689        }
2690    }
2691    result
2692}
2693
2694/// Helper: collect form fields from a single [`types::BlockContentChoice`].
2695#[cfg(feature = "wml-settings")]
2696fn collect_form_fields_from_block_content_choice(
2697    item: &types::BlockContentChoice,
2698    result: &mut Vec<FormField>,
2699) {
2700    match item {
2701        types::BlockContentChoice::Sdt(sdt) => {
2702            if let Some(field) = sdt.form_field() {
2703                result.push(field);
2704            }
2705        }
2706        types::BlockContentChoice::P(para) => {
2707            for pc in &para.paragraph_content {
2708                if let types::ParagraphContent::Sdt(sdt_run) = pc
2709                    && let Some(field) = sdt_run.form_field()
2710                {
2711                    result.push(field);
2712                }
2713            }
2714        }
2715        _ => {}
2716    }
2717}
2718
2719// =============================================================================
2720// Tests
2721// =============================================================================
2722
2723#[cfg(test)]
2724mod tests {
2725    use super::*;
2726
2727    // -------------------------------------------------------------------------
2728    // Helper tests
2729    // -------------------------------------------------------------------------
2730
2731    #[test]
2732    fn test_is_on_none() {
2733        assert!(!is_on(&None));
2734    }
2735
2736    #[test]
2737    fn test_is_on_present_no_val() {
2738        // Element present with no val attribute → on
2739        let field = Some(Box::new(types::OnOffElement {
2740            value: None,
2741            #[cfg(feature = "extra-attrs")]
2742            extra_attrs: Default::default(),
2743        }));
2744        assert!(is_on(&field));
2745    }
2746
2747    #[test]
2748    fn test_is_on_explicit_true() {
2749        for val in &["1", "true", "on"] {
2750            let field = Some(Box::new(types::OnOffElement {
2751                value: Some(val.to_string()),
2752                #[cfg(feature = "extra-attrs")]
2753                extra_attrs: Default::default(),
2754            }));
2755            assert!(is_on(&field), "expected is_on for val={val}");
2756        }
2757    }
2758
2759    #[test]
2760    fn test_is_on_explicit_false() {
2761        for val in &["0", "false", "off"] {
2762            let field = Some(Box::new(types::OnOffElement {
2763                value: Some(val.to_string()),
2764                #[cfg(feature = "extra-attrs")]
2765                extra_attrs: Default::default(),
2766            }));
2767            assert!(!is_on(&field), "expected !is_on for val={val}");
2768        }
2769    }
2770
2771    #[test]
2772    fn test_check_toggle_none() {
2773        assert_eq!(check_toggle(&None), None);
2774    }
2775
2776    #[test]
2777    fn test_check_toggle_present() {
2778        let field = Some(Box::new(types::OnOffElement {
2779            value: None,
2780            #[cfg(feature = "extra-attrs")]
2781            extra_attrs: Default::default(),
2782        }));
2783        assert_eq!(check_toggle(&field), Some(true));
2784    }
2785
2786    #[test]
2787    fn test_parse_half_points() {
2788        assert_eq!(parse_half_points("24"), Some(24));
2789        assert_eq!(parse_half_points("0"), Some(0));
2790        assert_eq!(parse_half_points("abc"), None);
2791        assert_eq!(parse_half_points(""), None);
2792    }
2793
2794    // -------------------------------------------------------------------------
2795    // RunPropertiesExt tests
2796    // -------------------------------------------------------------------------
2797
2798    #[cfg(feature = "wml-styling")]
2799    fn make_run_properties() -> types::RunProperties {
2800        types::RunProperties {
2801            run_style: None,
2802            fonts: None,
2803            bold: None,
2804            b_cs: None,
2805            italic: None,
2806            i_cs: None,
2807            caps: None,
2808            small_caps: None,
2809            strikethrough: None,
2810            dstrike: None,
2811            outline: None,
2812            shadow: None,
2813            emboss: None,
2814            imprint: None,
2815            no_proof: None,
2816            snap_to_grid: None,
2817            vanish: None,
2818            web_hidden: None,
2819            color: None,
2820            spacing: None,
2821            width: None,
2822            kern: None,
2823            position: None,
2824            size: None,
2825            size_complex_script: None,
2826            highlight: None,
2827            underline: None,
2828            effect: None,
2829            bdr: None,
2830            shading: None,
2831            fit_text: None,
2832            vert_align: None,
2833            rtl: None,
2834            cs: None,
2835            em: None,
2836            lang: None,
2837            east_asian_layout: None,
2838            spec_vanish: None,
2839            o_math: None,
2840            r_pr_change: None,
2841            #[cfg(feature = "extra-children")]
2842            extra_children: Default::default(),
2843        }
2844    }
2845
2846    #[cfg(feature = "wml-styling")]
2847    fn on_off(val: Option<&str>) -> Option<Box<types::OnOffElement>> {
2848        Some(Box::new(types::OnOffElement {
2849            value: val.map(|v| v.to_string()),
2850            #[cfg(feature = "extra-attrs")]
2851            extra_attrs: Default::default(),
2852        }))
2853    }
2854
2855    #[test]
2856    #[cfg(feature = "wml-styling")]
2857    fn test_rpr_bold_italic() {
2858        let mut rpr = make_run_properties();
2859        assert!(!rpr.is_bold());
2860        assert!(!rpr.is_italic());
2861
2862        rpr.bold = on_off(None); // present, no val → on
2863        rpr.italic = on_off(Some("true"));
2864        assert!(rpr.is_bold());
2865        assert!(rpr.is_italic());
2866    }
2867
2868    #[test]
2869    #[cfg(feature = "wml-styling")]
2870    fn test_rpr_underline() {
2871        let mut rpr = make_run_properties();
2872        assert!(!rpr.is_underline());
2873        assert!(rpr.underline_style().is_none());
2874
2875        rpr.underline = Some(Box::new(types::CTUnderline {
2876            value: Some(types::STUnderline::Single),
2877            color: None,
2878            theme_color: None,
2879            theme_tint: None,
2880            theme_shade: None,
2881            #[cfg(feature = "extra-attrs")]
2882            extra_attrs: Default::default(),
2883        }));
2884        assert!(rpr.is_underline());
2885        assert_eq!(rpr.underline_style(), Some(&types::STUnderline::Single));
2886
2887        // "none" underline should not count as underlined
2888        rpr.underline = Some(Box::new(types::CTUnderline {
2889            value: Some(types::STUnderline::None),
2890            color: None,
2891            theme_color: None,
2892            theme_tint: None,
2893            theme_shade: None,
2894            #[cfg(feature = "extra-attrs")]
2895            extra_attrs: Default::default(),
2896        }));
2897        assert!(!rpr.is_underline());
2898    }
2899
2900    #[test]
2901    #[cfg(feature = "wml-styling")]
2902    fn test_rpr_strikethrough() {
2903        let mut rpr = make_run_properties();
2904        rpr.strikethrough = on_off(None);
2905        assert!(rpr.is_strikethrough());
2906        assert!(!rpr.is_double_strikethrough());
2907
2908        rpr.strikethrough = None;
2909        rpr.dstrike = on_off(Some("1"));
2910        assert!(!rpr.is_strikethrough());
2911        assert!(rpr.is_double_strikethrough());
2912    }
2913
2914    #[test]
2915    #[cfg(feature = "wml-styling")]
2916    fn test_rpr_caps_hidden() {
2917        let mut rpr = make_run_properties();
2918        rpr.caps = on_off(None);
2919        rpr.vanish = on_off(Some("1"));
2920        assert!(rpr.is_all_caps());
2921        assert!(!rpr.is_small_caps());
2922        assert!(rpr.is_hidden());
2923    }
2924
2925    #[test]
2926    #[cfg(feature = "wml-styling")]
2927    fn test_rpr_font_size() {
2928        let mut rpr = make_run_properties();
2929        assert!(rpr.font_size_half_points().is_none());
2930
2931        rpr.size = Some(Box::new(types::HpsMeasureElement {
2932            value: "24".to_string(),
2933            #[cfg(feature = "extra-attrs")]
2934            extra_attrs: Default::default(),
2935        }));
2936        assert_eq!(rpr.font_size_half_points(), Some(24));
2937        assert_eq!(rpr.font_size_points(), Some(12.0));
2938    }
2939
2940    #[test]
2941    #[cfg(feature = "wml-styling")]
2942    fn test_rpr_color() {
2943        let mut rpr = make_run_properties();
2944        assert!(rpr.color_hex().is_none());
2945
2946        rpr.color = Some(Box::new(types::CTColor {
2947            value: "FF0000".to_string(),
2948            theme_color: None,
2949            theme_tint: None,
2950            theme_shade: None,
2951            #[cfg(feature = "extra-attrs")]
2952            extra_attrs: Default::default(),
2953        }));
2954        assert_eq!(rpr.color_hex(), Some("FF0000"));
2955    }
2956
2957    #[test]
2958    #[cfg(feature = "wml-styling")]
2959    fn test_rpr_vertical_alignment() {
2960        let mut rpr = make_run_properties();
2961        assert!(!rpr.is_superscript());
2962        assert!(!rpr.is_subscript());
2963
2964        rpr.vert_align = Some(Box::new(types::CTVerticalAlignRun {
2965            value: types::STVerticalAlignRun::Superscript,
2966            #[cfg(feature = "extra-attrs")]
2967            extra_attrs: Default::default(),
2968        }));
2969        assert!(rpr.is_superscript());
2970        assert!(!rpr.is_subscript());
2971    }
2972
2973    #[test]
2974    #[cfg(feature = "wml-styling")]
2975    fn test_rpr_font_ascii() {
2976        let mut rpr = make_run_properties();
2977        assert!(rpr.font_ascii().is_none());
2978
2979        rpr.fonts = Some(Box::new(types::Fonts {
2980            hint: None,
2981            ascii: Some("Arial".to_string()),
2982            h_ansi: None,
2983            east_asia: None,
2984            cs: None,
2985            ascii_theme: None,
2986            h_ansi_theme: None,
2987            east_asia_theme: None,
2988            cstheme: None,
2989            #[cfg(feature = "extra-attrs")]
2990            extra_attrs: Default::default(),
2991        }));
2992        assert_eq!(rpr.font_ascii(), Some("Arial"));
2993    }
2994
2995    // -------------------------------------------------------------------------
2996    // RunExt tests
2997    // -------------------------------------------------------------------------
2998
2999    fn make_text(s: &str) -> types::RunContent {
3000        types::RunContent::T(Box::new(types::Text {
3001            text: Some(s.to_string()),
3002            #[cfg(feature = "extra-children")]
3003            extra_children: Default::default(),
3004        }))
3005    }
3006
3007    fn make_tab() -> types::RunContent {
3008        types::RunContent::Tab(Box::new(types::CTEmpty))
3009    }
3010
3011    fn make_br(br_type: Option<types::STBrType>) -> types::RunContent {
3012        types::RunContent::Br(Box::new(types::CTBr {
3013            r#type: br_type,
3014            clear: None,
3015            #[cfg(feature = "extra-attrs")]
3016            extra_attrs: Default::default(),
3017        }))
3018    }
3019
3020    fn make_cr() -> types::RunContent {
3021        types::RunContent::Cr(Box::new(types::CTEmpty))
3022    }
3023
3024    fn make_run(content: Vec<types::RunContent>) -> types::Run {
3025        types::Run {
3026            rsid_r_pr: None,
3027            rsid_del: None,
3028            rsid_r: None,
3029            #[cfg(feature = "wml-styling")]
3030            r_pr: None,
3031            run_content: content,
3032            #[cfg(feature = "extra-attrs")]
3033            extra_attrs: Default::default(),
3034            #[cfg(feature = "extra-children")]
3035            extra_children: Default::default(),
3036        }
3037    }
3038
3039    #[test]
3040    fn test_run_text_simple() {
3041        let run = make_run(vec![make_text("Hello"), make_text(" World")]);
3042        assert_eq!(run.text(), "Hello World");
3043    }
3044
3045    #[test]
3046    fn test_run_text_with_tab_and_break() {
3047        let run = make_run(vec![
3048            make_text("A"),
3049            make_tab(),
3050            make_text("B"),
3051            make_br(None), // text wrapping break → newline
3052            make_text("C"),
3053        ]);
3054        assert_eq!(run.text(), "A\tB\nC");
3055    }
3056
3057    #[test]
3058    fn test_run_text_page_break_not_text() {
3059        let run = make_run(vec![
3060            make_text("Before"),
3061            make_br(Some(types::STBrType::Page)),
3062            make_text("After"),
3063        ]);
3064        // Page breaks should not produce text
3065        assert_eq!(run.text(), "BeforeAfter");
3066        assert!(run.has_page_break());
3067    }
3068
3069    #[test]
3070    fn test_run_text_cr() {
3071        let run = make_run(vec![make_text("A"), make_cr(), make_text("B")]);
3072        assert_eq!(run.text(), "A\nB");
3073    }
3074
3075    #[test]
3076    fn test_run_no_page_break() {
3077        let run = make_run(vec![make_text("Hello")]);
3078        assert!(!run.has_page_break());
3079    }
3080
3081    // -------------------------------------------------------------------------
3082    // ParagraphExt tests
3083    // -------------------------------------------------------------------------
3084
3085    fn make_p_run(text: &str) -> types::ParagraphContent {
3086        types::ParagraphContent::R(Box::new(make_run(vec![make_text(text)])))
3087    }
3088
3089    fn make_paragraph(content: Vec<types::ParagraphContent>) -> types::Paragraph {
3090        types::Paragraph {
3091            rsid_r_pr: None,
3092            rsid_r: None,
3093            rsid_del: None,
3094            rsid_p: None,
3095            rsid_r_default: None,
3096            #[cfg(feature = "wml-styling")]
3097            p_pr: None,
3098            paragraph_content: content,
3099            #[cfg(feature = "extra-attrs")]
3100            extra_attrs: Default::default(),
3101            #[cfg(feature = "extra-children")]
3102            extra_children: Default::default(),
3103        }
3104    }
3105
3106    #[test]
3107    fn test_paragraph_runs_and_text() {
3108        let para = make_paragraph(vec![make_p_run("Hello "), make_p_run("World")]);
3109        assert_eq!(para.runs().len(), 2);
3110        assert_eq!(para.text(), "Hello World");
3111    }
3112
3113    #[test]
3114    fn test_paragraph_with_hyperlink() {
3115        let hyperlink = types::ParagraphContent::Hyperlink(Box::new(types::Hyperlink {
3116            id: None,
3117            tgt_frame: None,
3118            tooltip: None,
3119            doc_location: None,
3120            history: None,
3121            anchor: Some("bookmark1".to_string()),
3122            paragraph_content: vec![make_p_run("link text")],
3123            #[cfg(feature = "extra-attrs")]
3124            extra_attrs: Default::default(),
3125            #[cfg(feature = "extra-children")]
3126            extra_children: Default::default(),
3127        }));
3128        let para = make_paragraph(vec![make_p_run("Click "), hyperlink]);
3129        assert_eq!(para.runs().len(), 2);
3130        assert_eq!(para.text(), "Click link text");
3131        assert_eq!(para.hyperlinks().len(), 1);
3132        assert_eq!(para.hyperlinks()[0].anchor_str(), Some("bookmark1"));
3133    }
3134
3135    #[test]
3136    fn test_paragraph_with_fld_simple() {
3137        let fld = types::ParagraphContent::FldSimple(Box::new(types::CTSimpleField {
3138            instr: "PAGE".to_string(),
3139            fld_lock: None,
3140            dirty: None,
3141            fld_data: None,
3142            paragraph_content: vec![make_p_run("1")],
3143            #[cfg(feature = "extra-attrs")]
3144            extra_attrs: Default::default(),
3145            #[cfg(feature = "extra-children")]
3146            extra_children: Default::default(),
3147        }));
3148        let para = make_paragraph(vec![make_p_run("Page "), fld]);
3149        assert_eq!(para.runs().len(), 2);
3150        assert_eq!(para.text(), "Page 1");
3151    }
3152
3153    // -------------------------------------------------------------------------
3154    // BodyExt tests
3155    // -------------------------------------------------------------------------
3156
3157    fn make_body(content: Vec<types::BlockContent>) -> types::Body {
3158        types::Body {
3159            block_content: content,
3160            #[cfg(feature = "wml-layout")]
3161            sect_pr: None,
3162            #[cfg(feature = "extra-children")]
3163            extra_children: Default::default(),
3164        }
3165    }
3166
3167    #[test]
3168    fn test_body_paragraphs() {
3169        let p1 = types::BlockContent::P(Box::new(make_paragraph(vec![make_p_run("First")])));
3170        let p2 = types::BlockContent::P(Box::new(make_paragraph(vec![make_p_run("Second")])));
3171        let body = make_body(vec![p1, p2]);
3172        assert_eq!(body.paragraphs().len(), 2);
3173        assert_eq!(body.text(), "First\nSecond");
3174    }
3175
3176    #[test]
3177    fn test_body_tables() {
3178        let tbl = types::BlockContent::Tbl(Box::new(types::Table {
3179            range_markup: vec![],
3180            table_properties: Box::default(),
3181            tbl_grid: Box::default(),
3182            rows: vec![],
3183            #[cfg(feature = "extra-children")]
3184            extra_children: Default::default(),
3185        }));
3186        let body = make_body(vec![tbl]);
3187        assert_eq!(body.tables().len(), 1);
3188        assert_eq!(body.paragraphs().len(), 0);
3189    }
3190
3191    // -------------------------------------------------------------------------
3192    // DocumentExt tests
3193    // -------------------------------------------------------------------------
3194
3195    #[test]
3196    fn test_document_ext_body() {
3197        let doc = types::Document {
3198            background: None,
3199            body: Some(Box::new(make_body(vec![]))),
3200            conformance: None,
3201            #[cfg(feature = "extra-attrs")]
3202            extra_attrs: Default::default(),
3203            #[cfg(feature = "extra-children")]
3204            extra_children: Default::default(),
3205        };
3206        assert!(doc.body().is_some());
3207
3208        let doc_no_body = types::Document {
3209            background: None,
3210            body: None,
3211            conformance: None,
3212            #[cfg(feature = "extra-attrs")]
3213            extra_attrs: Default::default(),
3214            #[cfg(feature = "extra-children")]
3215            extra_children: Default::default(),
3216        };
3217        assert!(doc_no_body.body().is_none());
3218    }
3219
3220    // -------------------------------------------------------------------------
3221    // HyperlinkExt tests
3222    // -------------------------------------------------------------------------
3223
3224    #[test]
3225    fn test_hyperlink_ext() {
3226        let h = types::Hyperlink {
3227            id: None,
3228            tgt_frame: None,
3229            tooltip: None,
3230            doc_location: None,
3231            history: None,
3232            anchor: Some("top".to_string()),
3233            paragraph_content: vec![make_p_run("click"), make_p_run(" here")],
3234            #[cfg(feature = "extra-attrs")]
3235            extra_attrs: Default::default(),
3236            #[cfg(feature = "extra-children")]
3237            extra_children: Default::default(),
3238        };
3239        assert_eq!(h.runs().len(), 2);
3240        assert_eq!(h.text(), "click here");
3241        assert_eq!(h.anchor_str(), Some("top"));
3242    }
3243
3244    // -------------------------------------------------------------------------
3245    // Table/Row/Cell tests
3246    // -------------------------------------------------------------------------
3247
3248    fn make_table_cell(text: &str) -> types::CellContent {
3249        types::CellContent::Tc(Box::new(types::TableCell {
3250            id: None,
3251            cell_properties: None,
3252            block_content: vec![types::BlockContent::P(Box::new(make_paragraph(vec![
3253                make_p_run(text),
3254            ])))],
3255            #[cfg(feature = "extra-attrs")]
3256            extra_attrs: Default::default(),
3257            #[cfg(feature = "extra-children")]
3258            extra_children: Default::default(),
3259        }))
3260    }
3261
3262    fn make_table_row(cells: Vec<types::CellContent>) -> types::RowContent {
3263        types::RowContent::Tr(Box::new(types::CTRow {
3264            rsid_r_pr: None,
3265            rsid_r: None,
3266            rsid_del: None,
3267            rsid_tr: None,
3268            tbl_pr_ex: None,
3269            row_properties: None,
3270            cells,
3271            #[cfg(feature = "extra-attrs")]
3272            extra_attrs: Default::default(),
3273            #[cfg(feature = "extra-children")]
3274            extra_children: Default::default(),
3275        }))
3276    }
3277
3278    fn make_table(rows: Vec<types::RowContent>) -> types::Table {
3279        types::Table {
3280            range_markup: vec![],
3281            table_properties: Box::default(),
3282            tbl_grid: Box::default(),
3283            rows,
3284            #[cfg(feature = "extra-children")]
3285            extra_children: Default::default(),
3286        }
3287    }
3288
3289    #[test]
3290    fn test_table_rows_and_text() {
3291        let tbl = make_table(vec![
3292            make_table_row(vec![make_table_cell("A1"), make_table_cell("B1")]),
3293            make_table_row(vec![make_table_cell("A2"), make_table_cell("B2")]),
3294        ]);
3295        assert_eq!(tbl.row_count(), 2);
3296        assert_eq!(tbl.rows().len(), 2);
3297        assert_eq!(tbl.text(), "A1\tB1\nA2\tB2");
3298    }
3299
3300    #[test]
3301    fn test_row_cells_and_text() {
3302        let row = types::CTRow {
3303            rsid_r_pr: None,
3304            rsid_r: None,
3305            rsid_del: None,
3306            rsid_tr: None,
3307            tbl_pr_ex: None,
3308            row_properties: None,
3309            cells: vec![make_table_cell("X"), make_table_cell("Y")],
3310            #[cfg(feature = "extra-attrs")]
3311            extra_attrs: Default::default(),
3312            #[cfg(feature = "extra-children")]
3313            extra_children: Default::default(),
3314        };
3315        assert_eq!(row.cells().len(), 2);
3316        assert_eq!(row.text(), "X\tY");
3317    }
3318
3319    #[test]
3320    fn test_cell_paragraphs_and_text() {
3321        let cell = types::TableCell {
3322            id: None,
3323            cell_properties: None,
3324            block_content: vec![
3325                types::BlockContent::P(Box::new(make_paragraph(vec![make_p_run("Line 1")]))),
3326                types::BlockContent::P(Box::new(make_paragraph(vec![make_p_run("Line 2")]))),
3327            ],
3328            #[cfg(feature = "extra-attrs")]
3329            extra_attrs: Default::default(),
3330            #[cfg(feature = "extra-children")]
3331            extra_children: Default::default(),
3332        };
3333        assert_eq!(cell.paragraphs().len(), 2);
3334        assert_eq!(cell.text(), "Line 1\nLine 2");
3335    }
3336
3337    // -------------------------------------------------------------------------
3338    // SectionPropertiesExt tests
3339    // -------------------------------------------------------------------------
3340
3341    #[test]
3342    #[cfg(feature = "wml-layout")]
3343    fn test_section_properties_ext() {
3344        let sect_pr = types::SectionProperties {
3345            rsid_r_pr: None,
3346            rsid_del: None,
3347            rsid_r: None,
3348            rsid_sect: None,
3349            header_footer_refs: vec![],
3350            footnote_pr: None,
3351            endnote_pr: None,
3352            r#type: None,
3353            pg_sz: Some(Box::new(types::PageSize {
3354                width: Some("12240".to_string()),
3355                height: Some("15840".to_string()),
3356                orient: Some(types::STPageOrientation::Portrait),
3357                code: None,
3358                #[cfg(feature = "extra-attrs")]
3359                extra_attrs: Default::default(),
3360            })),
3361            pg_mar: Some(Box::new(types::PageMargins {
3362                top: "1440".to_string(),
3363                right: "1440".to_string(),
3364                bottom: "1440".to_string(),
3365                left: "1440".to_string(),
3366                header: "720".to_string(),
3367                footer: "720".to_string(),
3368                gutter: "0".to_string(),
3369                #[cfg(feature = "extra-attrs")]
3370                extra_attrs: Default::default(),
3371            })),
3372            paper_src: None,
3373            pg_borders: None,
3374            ln_num_type: None,
3375            pg_num_type: None,
3376            cols: None,
3377            form_prot: None,
3378            v_align: None,
3379            no_endnote: None,
3380            title_pg: on_off(None),
3381            text_direction: None,
3382            bidi: None,
3383            rtl_gutter: None,
3384            doc_grid: None,
3385            printer_settings: None,
3386            sect_pr_change: None,
3387            #[cfg(feature = "extra-attrs")]
3388            extra_attrs: Default::default(),
3389            #[cfg(feature = "extra-children")]
3390            extra_children: Default::default(),
3391        };
3392
3393        assert_eq!(sect_pr.page_width_twips(), Some(12240));
3394        assert_eq!(sect_pr.page_height_twips(), Some(15840));
3395        assert_eq!(
3396            sect_pr.page_orientation(),
3397            Some(&types::STPageOrientation::Portrait)
3398        );
3399        assert!(sect_pr.has_title_page());
3400        assert!(sect_pr.page_size().is_some());
3401        assert!(sect_pr.page_margins().is_some());
3402    }
3403
3404    // -------------------------------------------------------------------------
3405    // Parsing tests
3406    // -------------------------------------------------------------------------
3407
3408    #[test]
3409    fn test_parse_document_simple() {
3410        // Generated parsers match on unprefixed element names, so use default namespace
3411        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3412        <document xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3413            <body>
3414                <p>
3415                    <r>
3416                        <t>Hello World</t>
3417                    </r>
3418                </p>
3419            </body>
3420        </document>"#;
3421
3422        let doc = parse_document(xml).expect("parse_document failed");
3423        let body = doc.body().expect("body should exist");
3424        let paragraphs = body.paragraphs();
3425        assert_eq!(paragraphs.len(), 1);
3426        assert_eq!(paragraphs[0].text(), "Hello World");
3427    }
3428
3429    #[test]
3430    fn test_parse_document_multiple_paragraphs() {
3431        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3432        <document xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3433            <body>
3434                <p>
3435                    <r><t>First</t></r>
3436                </p>
3437                <p>
3438                    <r><t>Second</t></r>
3439                </p>
3440            </body>
3441        </document>"#;
3442
3443        let doc = parse_document(xml).expect("parse failed");
3444        let body = doc.body().expect("body");
3445        assert_eq!(body.paragraphs().len(), 2);
3446        assert_eq!(body.text(), "First\nSecond");
3447    }
3448
3449    #[test]
3450    fn test_parse_styles_basic() {
3451        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3452        <styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3453            <style type="character" styleId="BoldStyle">
3454                <name val="Bold Style"/>
3455                <rPr>
3456                    <b/>
3457                </rPr>
3458            </style>
3459        </styles>"#;
3460
3461        let styles = parse_styles(xml).expect("parse_styles failed");
3462        assert_eq!(styles.style.len(), 1);
3463        assert_eq!(styles.style[0].style_id.as_deref(), Some("BoldStyle"));
3464    }
3465
3466    #[test]
3467    fn test_parse_document_no_element() {
3468        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>"#;
3469        assert!(parse_document(xml).is_err());
3470    }
3471
3472    // -------------------------------------------------------------------------
3473    // StyleContext + RunResolveExt tests
3474    // -------------------------------------------------------------------------
3475
3476    #[test]
3477    #[cfg(feature = "wml-styling")]
3478    fn test_style_context_from_styles() {
3479        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3480        <styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3481            <docDefaults>
3482                <rPrDefault>
3483                    <rPr>
3484                        <sz val="24"/>
3485                    </rPr>
3486                </rPrDefault>
3487            </docDefaults>
3488            <style type="character" styleId="Strong">
3489                <name val="Strong"/>
3490                <rPr>
3491                    <b/>
3492                </rPr>
3493            </style>
3494        </styles>"#;
3495
3496        let styles = parse_styles(xml).expect("parse");
3497        let ctx = StyleContext::from_styles(&styles);
3498
3499        assert!(ctx.style("Strong").is_some());
3500        assert!(ctx.style("Nonexistent").is_none());
3501        assert!(ctx.default_run_properties.is_some());
3502        assert_eq!(
3503            ctx.default_run_properties
3504                .as_ref()
3505                .unwrap()
3506                .font_size_half_points(),
3507            Some(24)
3508        );
3509    }
3510
3511    #[test]
3512    #[cfg(feature = "wml-styling")]
3513    fn test_resolve_bold_from_direct() {
3514        let run = types::Run {
3515            rsid_r_pr: None,
3516            rsid_del: None,
3517            rsid_r: None,
3518            r_pr: Some(Box::new({
3519                let mut rpr = make_run_properties();
3520                rpr.bold = on_off(None);
3521                rpr
3522            })),
3523            run_content: vec![make_text("bold")],
3524            #[cfg(feature = "extra-attrs")]
3525            extra_attrs: Default::default(),
3526            #[cfg(feature = "extra-children")]
3527            extra_children: Default::default(),
3528        };
3529
3530        let ctx = StyleContext::default();
3531        assert!(run.resolved_is_bold(&ctx));
3532        assert!(!run.resolved_is_italic(&ctx));
3533    }
3534
3535    #[test]
3536    #[cfg(feature = "wml-styling")]
3537    fn test_resolve_bold_from_style_chain() {
3538        // Set up: run references style "Emphasis" which is basedOn "Strong" which has bold
3539        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3540        <styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3541            <style type="character" styleId="Strong">
3542                <name val="Strong"/>
3543                <rPr>
3544                    <b/>
3545                    <sz val="28"/>
3546                </rPr>
3547            </style>
3548            <style type="character" styleId="Emphasis">
3549                <name val="Emphasis"/>
3550                <basedOn val="Strong"/>
3551                <rPr>
3552                    <i/>
3553                </rPr>
3554            </style>
3555        </styles>"#;
3556
3557        let styles = parse_styles(xml).expect("parse");
3558        let ctx = StyleContext::from_styles(&styles);
3559
3560        // Run references "Emphasis" style (which has italic, inherits bold from Strong)
3561        let run = types::Run {
3562            rsid_r_pr: None,
3563            rsid_del: None,
3564            rsid_r: None,
3565            r_pr: Some(Box::new({
3566                let mut rpr = make_run_properties();
3567                rpr.run_style = Some(Box::new(types::CTString {
3568                    value: "Emphasis".to_string(),
3569                    #[cfg(feature = "extra-attrs")]
3570                    extra_attrs: Default::default(),
3571                }));
3572                rpr
3573            })),
3574            run_content: vec![make_text("styled")],
3575            #[cfg(feature = "extra-attrs")]
3576            extra_attrs: Default::default(),
3577            #[cfg(feature = "extra-children")]
3578            extra_children: Default::default(),
3579        };
3580
3581        assert!(run.resolved_is_bold(&ctx));
3582        assert!(run.resolved_is_italic(&ctx));
3583        assert_eq!(run.resolved_font_size_half_points(&ctx), Some(28));
3584    }
3585
3586    #[test]
3587    #[cfg(feature = "wml-styling")]
3588    fn test_resolve_from_doc_defaults() {
3589        let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3590        <styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3591            <docDefaults>
3592                <rPrDefault>
3593                    <rPr>
3594                        <sz val="22"/>
3595                        <rFonts ascii="Calibri"/>
3596                    </rPr>
3597                </rPrDefault>
3598            </docDefaults>
3599        </styles>"#;
3600
3601        let styles = parse_styles(xml).expect("parse");
3602        let ctx = StyleContext::from_styles(&styles);
3603
3604        // Run with no direct properties or style reference
3605        let run = types::Run {
3606            rsid_r_pr: None,
3607            rsid_del: None,
3608            rsid_r: None,
3609            r_pr: None,
3610            run_content: vec![make_text("default")],
3611            #[cfg(feature = "extra-attrs")]
3612            extra_attrs: Default::default(),
3613            #[cfg(feature = "extra-children")]
3614            extra_children: Default::default(),
3615        };
3616
3617        assert!(!run.resolved_is_bold(&ctx));
3618        assert_eq!(run.resolved_font_size_half_points(&ctx), Some(22));
3619        assert_eq!(run.resolved_font_ascii(&ctx), Some("Calibri".to_string()));
3620    }
3621
3622    #[test]
3623    #[cfg(feature = "wml-styling")]
3624    fn test_resolved_document() {
3625        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3626        <document xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3627            <body>
3628                <p>
3629                    <r>
3630                        <rPr><b/></rPr>
3631                        <t>Bold text</t>
3632                    </r>
3633                </p>
3634            </body>
3635        </document>"#;
3636
3637        let styles_xml = br#"<?xml version="1.0" encoding="UTF-8"?>
3638        <styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3639        </styles>"#;
3640
3641        let doc = parse_document(doc_xml).expect("parse doc");
3642        let styles = parse_styles(styles_xml).expect("parse styles");
3643        let resolved = ResolvedDocument::new(doc, styles);
3644
3645        assert_eq!(resolved.text(), "Bold text");
3646
3647        let body = resolved.body().expect("body");
3648        let paras = body.paragraphs();
3649        let runs = paras[0].runs();
3650        assert!(resolved.is_bold(runs[0]));
3651        assert!(!resolved.is_italic(runs[0]));
3652    }
3653
3654    // -------------------------------------------------------------------------
3655    // DrawingChartExt tests
3656    // -------------------------------------------------------------------------
3657
3658    #[test]
3659    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3660    fn test_drawing_chart_rel_ids() {
3661        use super::DrawingChartExt;
3662        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
3663
3664        // Build: wp:anchor → a:graphic → a:graphicData → c:chart r:id="rId5"
3665        let chart = RawXmlElement {
3666            name: "c:chart".to_string(),
3667            attributes: vec![("r:id".to_string(), "rId5".to_string())],
3668            children: vec![],
3669            self_closing: true,
3670        };
3671        let graphic_data = RawXmlElement {
3672            name: "a:graphicData".to_string(),
3673            attributes: vec![(
3674                "uri".to_string(),
3675                "http://schemas.openxmlformats.org/drawingml/2006/chart".to_string(),
3676            )],
3677            children: vec![RawXmlNode::Element(chart)],
3678            self_closing: false,
3679        };
3680        let graphic = RawXmlElement {
3681            name: "a:graphic".to_string(),
3682            attributes: vec![],
3683            children: vec![RawXmlNode::Element(graphic_data)],
3684            self_closing: false,
3685        };
3686        let anchor = RawXmlElement {
3687            name: "wp:anchor".to_string(),
3688            attributes: vec![],
3689            children: vec![RawXmlNode::Element(graphic)],
3690            self_closing: false,
3691        };
3692
3693        let drawing = types::CTDrawing {
3694            extra_children: vec![PositionedNode::new(0, RawXmlNode::Element(anchor))],
3695        };
3696
3697        let ids = drawing.all_chart_rel_ids();
3698        assert_eq!(ids, vec!["rId5"]);
3699
3700        // anchored_chart_rel_ids should also return it
3701        assert_eq!(drawing.anchored_chart_rel_ids(), vec!["rId5"]);
3702        // inline should be empty
3703        assert!(drawing.inline_chart_rel_ids().is_empty());
3704    }
3705
3706    #[test]
3707    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3708    fn test_drawing_no_charts() {
3709        use super::DrawingChartExt;
3710        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
3711
3712        // Build an anchor with a blip (image), but no chart
3713        let blip = RawXmlElement {
3714            name: "a:blip".to_string(),
3715            attributes: vec![("r:embed".to_string(), "rId1".to_string())],
3716            children: vec![],
3717            self_closing: true,
3718        };
3719        let anchor = RawXmlElement {
3720            name: "wp:anchor".to_string(),
3721            attributes: vec![],
3722            children: vec![RawXmlNode::Element(blip)],
3723            self_closing: false,
3724        };
3725
3726        let drawing = types::CTDrawing {
3727            extra_children: vec![PositionedNode::new(0, RawXmlNode::Element(anchor))],
3728        };
3729
3730        assert!(drawing.all_chart_rel_ids().is_empty());
3731    }
3732
3733    // -------------------------------------------------------------------------
3734    // DrawingTextBoxExt tests
3735    // -------------------------------------------------------------------------
3736
3737    /// Build a minimal `CTDrawing` whose `extra_children` contains a `<wp:anchor>`
3738    /// that holds a `<w:txbxContent>` with the given paragraph text.
3739    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3740    fn make_drawing_with_textbox(text: &str) -> types::CTDrawing {
3741        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
3742
3743        // Build the element tree bottom-up:
3744        // <wp:anchor>
3745        //   <wps:wsp>
3746        //     <wps:txbx>
3747        //       <w:txbxContent>
3748        //         <w:p>
3749        //           <w:r>
3750        //             <w:t>text</w:t>
3751        //           </w:r>
3752        //         </w:p>
3753        //       </w:txbxContent>
3754        //     </wps:txbx>
3755        //   </wps:wsp>
3756        // </wp:anchor>
3757
3758        let t = RawXmlElement {
3759            name: "w:t".to_string(),
3760            attributes: vec![],
3761            children: vec![RawXmlNode::Text(text.to_string())],
3762            self_closing: false,
3763        };
3764        let r = RawXmlElement {
3765            name: "w:r".to_string(),
3766            attributes: vec![],
3767            children: vec![RawXmlNode::Element(t)],
3768            self_closing: false,
3769        };
3770        let p = RawXmlElement {
3771            name: "w:p".to_string(),
3772            attributes: vec![],
3773            children: vec![RawXmlNode::Element(r)],
3774            self_closing: false,
3775        };
3776        let txbx_content = RawXmlElement {
3777            name: "w:txbxContent".to_string(),
3778            attributes: vec![],
3779            children: vec![RawXmlNode::Element(p)],
3780            self_closing: false,
3781        };
3782        let txbx = RawXmlElement {
3783            name: "wps:txbx".to_string(),
3784            attributes: vec![],
3785            children: vec![RawXmlNode::Element(txbx_content)],
3786            self_closing: false,
3787        };
3788        let wsp = RawXmlElement {
3789            name: "wps:wsp".to_string(),
3790            attributes: vec![],
3791            children: vec![RawXmlNode::Element(txbx)],
3792            self_closing: false,
3793        };
3794        let anchor = RawXmlElement {
3795            name: "wp:anchor".to_string(),
3796            attributes: vec![],
3797            children: vec![RawXmlNode::Element(wsp)],
3798            self_closing: false,
3799        };
3800
3801        types::CTDrawing {
3802            extra_children: vec![PositionedNode::new(0, RawXmlNode::Element(anchor))],
3803        }
3804    }
3805
3806    #[test]
3807    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3808    fn test_drawing_text_box_texts_single() {
3809        use super::DrawingTextBoxExt;
3810        let drawing = make_drawing_with_textbox("Hello from text box");
3811        let texts = drawing.text_box_texts();
3812        assert_eq!(texts.len(), 1);
3813        assert_eq!(texts[0], "Hello from text box");
3814    }
3815
3816    #[test]
3817    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3818    fn test_drawing_text_box_texts_empty() {
3819        use super::DrawingTextBoxExt;
3820        let drawing = types::CTDrawing {
3821            extra_children: vec![],
3822        };
3823        let texts = drawing.text_box_texts();
3824        assert!(texts.is_empty());
3825    }
3826
3827    #[test]
3828    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3829    fn test_drawing_text_box_texts_multiple() {
3830        use super::DrawingTextBoxExt;
3831        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
3832
3833        // Build two anchors each with a text box.
3834        fn make_anchor(text: &str) -> RawXmlElement {
3835            let t = RawXmlElement {
3836                name: "w:t".to_string(),
3837                attributes: vec![],
3838                children: vec![RawXmlNode::Text(text.to_string())],
3839                self_closing: false,
3840            };
3841            let r = RawXmlElement {
3842                name: "w:r".to_string(),
3843                attributes: vec![],
3844                children: vec![RawXmlNode::Element(t)],
3845                self_closing: false,
3846            };
3847            let p = RawXmlElement {
3848                name: "w:p".to_string(),
3849                attributes: vec![],
3850                children: vec![RawXmlNode::Element(r)],
3851                self_closing: false,
3852            };
3853            let txbx_content = RawXmlElement {
3854                name: "w:txbxContent".to_string(),
3855                attributes: vec![],
3856                children: vec![RawXmlNode::Element(p)],
3857                self_closing: false,
3858            };
3859            RawXmlElement {
3860                name: "wp:anchor".to_string(),
3861                attributes: vec![],
3862                children: vec![RawXmlNode::Element(txbx_content)],
3863                self_closing: false,
3864            }
3865        }
3866
3867        let drawing = types::CTDrawing {
3868            extra_children: vec![
3869                PositionedNode::new(0, RawXmlNode::Element(make_anchor("First box"))),
3870                PositionedNode::new(1, RawXmlNode::Element(make_anchor("Second box"))),
3871            ],
3872        };
3873
3874        let texts = drawing.text_box_texts();
3875        assert_eq!(texts.len(), 2);
3876        assert_eq!(texts[0], "First box");
3877        assert_eq!(texts[1], "Second box");
3878    }
3879
3880    // -------------------------------------------------------------------------
3881    // PictExt tests (VML text boxes)
3882    // -------------------------------------------------------------------------
3883
3884    /// Build a minimal `CTPicture` whose `extra_children` contains a `<v:shape>`
3885    /// that holds a `<v:textbox>` which holds a `<w:txbxContent>`.
3886    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3887    fn make_pict_with_textbox(text: &str) -> types::CTPicture {
3888        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
3889
3890        let t = RawXmlElement {
3891            name: "w:t".to_string(),
3892            attributes: vec![],
3893            children: vec![RawXmlNode::Text(text.to_string())],
3894            self_closing: false,
3895        };
3896        let r = RawXmlElement {
3897            name: "w:r".to_string(),
3898            attributes: vec![],
3899            children: vec![RawXmlNode::Element(t)],
3900            self_closing: false,
3901        };
3902        let p = RawXmlElement {
3903            name: "w:p".to_string(),
3904            attributes: vec![],
3905            children: vec![RawXmlNode::Element(r)],
3906            self_closing: false,
3907        };
3908        let txbx_content = RawXmlElement {
3909            name: "w:txbxContent".to_string(),
3910            attributes: vec![],
3911            children: vec![RawXmlNode::Element(p)],
3912            self_closing: false,
3913        };
3914        let textbox = RawXmlElement {
3915            name: "v:textbox".to_string(),
3916            attributes: vec![],
3917            children: vec![RawXmlNode::Element(txbx_content)],
3918            self_closing: false,
3919        };
3920        let shape = RawXmlElement {
3921            name: "v:shape".to_string(),
3922            attributes: vec![("id".to_string(), "TextBox1".to_string())],
3923            children: vec![RawXmlNode::Element(textbox)],
3924            self_closing: false,
3925        };
3926
3927        types::CTPicture {
3928            #[cfg(feature = "wml-drawings")]
3929            movie: None,
3930            #[cfg(feature = "wml-drawings")]
3931            control: None,
3932            extra_children: vec![PositionedNode::new(0, RawXmlNode::Element(shape))],
3933        }
3934    }
3935
3936    #[test]
3937    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3938    fn test_pict_text_box_text() {
3939        use super::PictExt;
3940        let pict = make_pict_with_textbox("VML text box content");
3941        assert_eq!(
3942            pict.text_box_text(),
3943            Some("VML text box content".to_string())
3944        );
3945    }
3946
3947    #[test]
3948    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3949    fn test_pict_text_box_text_none_when_empty() {
3950        use super::PictExt;
3951        let pict = types::CTPicture {
3952            #[cfg(feature = "wml-drawings")]
3953            movie: None,
3954            #[cfg(feature = "wml-drawings")]
3955            control: None,
3956            extra_children: vec![],
3957        };
3958        assert_eq!(pict.text_box_text(), None);
3959    }
3960
3961    #[test]
3962    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3963    fn test_pict_text_box_texts() {
3964        use super::PictExt;
3965        let pict = make_pict_with_textbox("Hello");
3966        let texts = pict.text_box_texts();
3967        assert_eq!(texts.len(), 1);
3968        assert_eq!(texts[0], "Hello");
3969    }
3970
3971    #[test]
3972    #[cfg(all(feature = "wml-drawings", feature = "extra-children"))]
3973    fn test_drawing_text_box_via_xml_parse() {
3974        // Integration test: build the drawing from raw XML, then extract text.
3975        use super::DrawingTextBoxExt;
3976
3977        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
3978<document xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3979          xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
3980          xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
3981  <body>
3982    <p>
3983      <r>
3984        <drawing>
3985          <wp:anchor>
3986            <wps:wsp>
3987              <wps:txbx>
3988                <txbxContent>
3989                  <p><r><t>Anchored box text</t></r></p>
3990                </txbxContent>
3991              </wps:txbx>
3992            </wps:wsp>
3993          </wp:anchor>
3994        </drawing>
3995      </r>
3996    </p>
3997  </body>
3998</document>"#;
3999
4000        let doc = parse_document(xml.as_bytes()).expect("parse");
4001        let body = doc.body().expect("body");
4002        let paras = body.paragraphs();
4003        assert!(!paras.is_empty());
4004
4005        let run = &paras[0].runs()[0];
4006        let drawings = run.drawings();
4007        assert_eq!(drawings.len(), 1);
4008
4009        let texts = drawings[0].text_box_texts();
4010        assert_eq!(texts.len(), 1);
4011        assert_eq!(texts[0], "Anchored box text");
4012    }
4013
4014    // =========================================================================
4015    // TOC tests
4016    // =========================================================================
4017
4018    /// Build a minimal paragraph XML with the given style name and run text.
4019    #[cfg(feature = "wml-styling")]
4020    fn toc_para_xml(style: &str, text: &str) -> String {
4021        format!(
4022            r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4023  <w:pPr><w:pStyle w:val="{style}"/></w:pPr>
4024  <w:r><w:t>{text}</w:t></w:r>
4025</w:p>"#,
4026        )
4027    }
4028
4029    /// Build a document XML with the given body XML (pre-formatted).
4030    #[cfg(feature = "wml-styling")]
4031    fn doc_with_body(body_inner: &str) -> String {
4032        format!(
4033            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4034  <w:body>
4035    {body_inner}
4036  </w:body>
4037</w:document>"#,
4038        )
4039    }
4040
4041    #[test]
4042    #[cfg(feature = "wml-styling")]
4043    fn test_toc_no_entries() {
4044        // A body with no TOC-style paragraphs returns an empty vec.
4045        let xml = doc_with_body(
4046            r#"<w:p><w:pPr><w:pStyle w:val="Normal"/></w:pPr><w:r><w:t>Hello</w:t></w:r></w:p>"#,
4047        );
4048        let doc = parse_document(xml.as_bytes()).expect("parse");
4049        let body = doc.body().expect("body");
4050        let tocs = body.table_of_contents();
4051        assert!(tocs.is_empty(), "expected no TOCs, got: {tocs:?}");
4052    }
4053
4054    #[test]
4055    #[cfg(feature = "wml-styling")]
4056    fn test_toc_levels() {
4057        // Three consecutive TOC-style paragraphs form a single TOC with correct levels.
4058        let p1 = toc_para_xml("TOC 1", "Chapter One");
4059        let p2 = toc_para_xml("TOC 2", "Section 1.1");
4060        let p3 = toc_para_xml("TOC 3", "Subsection 1.1.1");
4061        let xml = doc_with_body(&format!("{p1}{p2}{p3}"));
4062        let doc = parse_document(xml.as_bytes()).expect("parse");
4063        let body = doc.body().expect("body");
4064        let tocs = body.table_of_contents();
4065        assert_eq!(tocs.len(), 1);
4066        let toc = &tocs[0];
4067        assert_eq!(toc.entries.len(), 3);
4068        assert_eq!(toc.entries[0].level, 1);
4069        assert_eq!(toc.entries[0].text, "Chapter One");
4070        assert_eq!(toc.entries[1].level, 2);
4071        assert_eq!(toc.entries[1].text, "Section 1.1");
4072        assert_eq!(toc.entries[2].level, 3);
4073        assert_eq!(toc.entries[2].text, "Subsection 1.1.1");
4074    }
4075
4076    #[test]
4077    #[cfg(feature = "wml-styling")]
4078    fn test_toc_style_id_form() {
4079        // Style IDs "toc1"/"toc2" (no space) are also recognised.
4080        let p1 = toc_para_xml("toc1", "First");
4081        let p2 = toc_para_xml("toc2", "Second");
4082        let xml = doc_with_body(&format!("{p1}{p2}"));
4083        let doc = parse_document(xml.as_bytes()).expect("parse");
4084        let body = doc.body().expect("body");
4085        let tocs = body.table_of_contents();
4086        assert_eq!(tocs.len(), 1);
4087        assert_eq!(tocs[0].entries[0].level, 1);
4088        assert_eq!(tocs[0].entries[1].level, 2);
4089    }
4090
4091    #[test]
4092    #[cfg(feature = "wml-styling")]
4093    fn test_toc_entry_from_sdt() {
4094        // TOC entries inside an SDT block are extracted as a separate TableOfContents.
4095        let xml = doc_with_body(
4096            r#"<w:sdt xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4097  <w:sdtContent>
4098    <w:p><w:pPr><w:pStyle w:val="TOC 1"/></w:pPr><w:r><w:t>Alpha</w:t></w:r></w:p>
4099    <w:p><w:pPr><w:pStyle w:val="TOC 2"/></w:pPr><w:r><w:t>Beta</w:t></w:r></w:p>
4100  </w:sdtContent>
4101</w:sdt>"#,
4102        );
4103        let doc = parse_document(xml.as_bytes()).expect("parse");
4104        let body = doc.body().expect("body");
4105        let tocs = body.table_of_contents();
4106        assert_eq!(tocs.len(), 1, "expected 1 TOC from SDT, got: {tocs:?}");
4107        assert_eq!(tocs[0].entries.len(), 2);
4108        assert_eq!(tocs[0].entries[0].level, 1);
4109        assert_eq!(tocs[0].entries[0].text, "Alpha");
4110        assert_eq!(tocs[0].entries[1].level, 2);
4111        assert_eq!(tocs[0].entries[1].text, "Beta");
4112    }
4113
4114    #[test]
4115    #[cfg(feature = "wml-styling")]
4116    fn test_toc_page_number_extraction() {
4117        // A page number after a tab stop is extracted as `page`.
4118        let xml = doc_with_body(
4119            r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4120  <w:pPr><w:pStyle w:val="TOC 1"/></w:pPr>
4121  <w:r><w:t>My Chapter</w:t></w:r>
4122  <w:r><w:tab/></w:r>
4123  <w:r><w:t>42</w:t></w:r>
4124</w:p>"#,
4125        );
4126        let doc = parse_document(xml.as_bytes()).expect("parse");
4127        let body = doc.body().expect("body");
4128        let tocs = body.table_of_contents();
4129        assert_eq!(tocs.len(), 1);
4130        let entry = &tocs[0].entries[0];
4131        assert_eq!(entry.text, "My Chapter");
4132        assert_eq!(entry.page, Some(42));
4133    }
4134
4135    #[test]
4136    #[cfg(feature = "wml-styling")]
4137    fn test_toc_non_toc_para_splits_groups() {
4138        // A non-TOC paragraph between two TOC runs produces two separate TOCs.
4139        let p1 = toc_para_xml("TOC 1", "First TOC entry");
4140        let normal = r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4141  <w:pPr><w:pStyle w:val="Normal"/></w:pPr>
4142  <w:r><w:t>Regular text</w:t></w:r>
4143</w:p>"#;
4144        let p2 = toc_para_xml("TOC 1", "Second TOC entry");
4145        let xml = doc_with_body(&format!("{p1}{normal}{p2}"));
4146        let doc = parse_document(xml.as_bytes()).expect("parse");
4147        let body = doc.body().expect("body");
4148        let tocs = body.table_of_contents();
4149        assert_eq!(tocs.len(), 2, "expected 2 separate TOCs");
4150        assert_eq!(tocs[0].entries[0].text, "First TOC entry");
4151        assert_eq!(tocs[1].entries[0].text, "Second TOC entry");
4152    }
4153
4154    // -------------------------------------------------------------------------
4155    // RevisionExt / BodyRevisionExt tests
4156    // -------------------------------------------------------------------------
4157
4158    /// Build a paragraph with an `<w:ins>` wrapping a run with `text`, plus an
4159    /// additional normal run with `suffix`.
4160    #[cfg(feature = "wml-track-changes")]
4161    fn make_para_with_ins(ins_text: &str, suffix: &str) -> types::Paragraph {
4162        use crate::convenience::ins_run;
4163        let mut para = types::Paragraph::default();
4164        para.paragraph_content
4165            .push(ins_run(1, "Alice", Some("2026-01-01T00:00:00Z"), ins_text));
4166        // Normal run
4167        let t = types::Text {
4168            text: Some(suffix.to_string()),
4169            #[cfg(feature = "extra-children")]
4170            extra_children: Vec::new(),
4171        };
4172        let run = types::Run {
4173            #[cfg(feature = "wml-track-changes")]
4174            rsid_r_pr: None,
4175            #[cfg(feature = "wml-track-changes")]
4176            rsid_del: None,
4177            #[cfg(feature = "wml-track-changes")]
4178            rsid_r: None,
4179            #[cfg(feature = "wml-styling")]
4180            r_pr: None,
4181            run_content: vec![types::RunContent::T(Box::new(t))],
4182            #[cfg(feature = "extra-attrs")]
4183            extra_attrs: Default::default(),
4184            #[cfg(feature = "extra-children")]
4185            extra_children: Vec::new(),
4186        };
4187        para.paragraph_content
4188            .push(types::ParagraphContent::R(Box::new(run)));
4189        para
4190    }
4191
4192    /// Build a paragraph with a `<w:del>` wrapping a run with `del_text`, plus a
4193    /// normal run with `suffix`.
4194    #[cfg(feature = "wml-track-changes")]
4195    fn make_para_with_del(del_text: &str, suffix: &str) -> types::Paragraph {
4196        use crate::convenience::del_run;
4197        let mut para = types::Paragraph::default();
4198        para.paragraph_content
4199            .push(del_run(2, "Bob", None, del_text));
4200        let t = types::Text {
4201            text: Some(suffix.to_string()),
4202            #[cfg(feature = "extra-children")]
4203            extra_children: Vec::new(),
4204        };
4205        let run = types::Run {
4206            #[cfg(feature = "wml-track-changes")]
4207            rsid_r_pr: None,
4208            #[cfg(feature = "wml-track-changes")]
4209            rsid_del: None,
4210            #[cfg(feature = "wml-track-changes")]
4211            rsid_r: None,
4212            #[cfg(feature = "wml-styling")]
4213            r_pr: None,
4214            run_content: vec![types::RunContent::T(Box::new(t))],
4215            #[cfg(feature = "extra-attrs")]
4216            extra_attrs: Default::default(),
4217            #[cfg(feature = "extra-children")]
4218            extra_children: Vec::new(),
4219        };
4220        para.paragraph_content
4221            .push(types::ParagraphContent::R(Box::new(run)));
4222        para
4223    }
4224
4225    #[test]
4226    #[cfg(feature = "wml-track-changes")]
4227    fn test_track_changes_accepted_text() {
4228        use super::RevisionExt;
4229        // Ins("hello") + Run(" world") → accepted = "hello world"
4230        let para = make_para_with_ins("hello", " world");
4231        assert_eq!(para.accepted_text(), "hello world");
4232    }
4233
4234    #[test]
4235    #[cfg(feature = "wml-track-changes")]
4236    fn test_track_changes_rejected_text() {
4237        use super::RevisionExt;
4238        // Del("old") + Run(" word") → rejected = "old word"
4239        let para = make_para_with_del("old", " word");
4240        assert_eq!(para.rejected_text(), "old word");
4241    }
4242
4243    #[test]
4244    #[cfg(feature = "wml-track-changes")]
4245    fn test_track_changes_accepted_text_excludes_deletions() {
4246        use super::RevisionExt;
4247        // Del("old") + Run(" word") → accepted = " word" (deletion excluded)
4248        let para = make_para_with_del("old", " word");
4249        assert_eq!(para.accepted_text(), " word");
4250    }
4251
4252    #[test]
4253    #[cfg(feature = "wml-track-changes")]
4254    fn test_track_changes_rejected_text_excludes_insertions() {
4255        use super::RevisionExt;
4256        // Ins("hello") + Run(" world") → rejected = " world" (insertion excluded)
4257        let para = make_para_with_ins("hello", " world");
4258        assert_eq!(para.rejected_text(), " world");
4259    }
4260
4261    #[test]
4262    #[cfg(feature = "wml-track-changes")]
4263    fn test_has_track_changes() {
4264        use super::RevisionExt;
4265        let para_with = make_para_with_ins("text", "");
4266        assert!(para_with.has_track_changes());
4267
4268        // A plain paragraph with no tracked changes
4269        let plain = types::Paragraph::default();
4270        assert!(!plain.has_track_changes());
4271    }
4272
4273    #[test]
4274    #[cfg(feature = "wml-track-changes")]
4275    fn test_track_changes_list() {
4276        use super::{RevisionExt, TrackChangeType};
4277        let para = make_para_with_ins("hello", " world");
4278        let changes = para.track_changes();
4279        assert_eq!(changes.len(), 1);
4280        let tc = &changes[0];
4281        assert_eq!(tc.id, 1);
4282        assert_eq!(tc.author, "Alice");
4283        assert_eq!(tc.date.as_deref(), Some("2026-01-01T00:00:00Z"));
4284        assert_eq!(tc.change_type, TrackChangeType::Insertion);
4285        assert_eq!(tc.text, "hello");
4286    }
4287
4288    #[test]
4289    #[cfg(feature = "wml-track-changes")]
4290    fn test_track_changes_deletion_list() {
4291        use super::{RevisionExt, TrackChangeType};
4292        let para = make_para_with_del("old", " text");
4293        let changes = para.track_changes();
4294        assert_eq!(changes.len(), 1);
4295        let tc = &changes[0];
4296        assert_eq!(tc.id, 2);
4297        assert_eq!(tc.author, "Bob");
4298        assert_eq!(tc.date, None);
4299        assert_eq!(tc.change_type, TrackChangeType::Deletion);
4300        assert_eq!(tc.text, "old");
4301    }
4302
4303    #[test]
4304    #[cfg(feature = "wml-track-changes")]
4305    fn test_body_revision_ext_all_track_changes() {
4306        use super::{BodyRevisionExt, TrackChangeType};
4307        let para1 = make_para_with_ins("inserted", "");
4308        let para2 = make_para_with_del("deleted", "");
4309
4310        let body = types::Body {
4311            block_content: vec![
4312                types::BlockContent::P(Box::new(para1)),
4313                types::BlockContent::P(Box::new(para2)),
4314            ],
4315            #[cfg(feature = "wml-layout")]
4316            sect_pr: None,
4317            #[cfg(feature = "extra-children")]
4318            extra_children: Vec::new(),
4319        };
4320        let all = body.all_track_changes();
4321        assert_eq!(all.len(), 2);
4322        assert_eq!(all[0].change_type, TrackChangeType::Insertion);
4323        assert_eq!(all[0].text, "inserted");
4324        assert_eq!(all[1].change_type, TrackChangeType::Deletion);
4325        assert_eq!(all[1].text, "deleted");
4326    }
4327
4328    #[test]
4329    #[cfg(feature = "wml-track-changes")]
4330    fn test_body_revision_ext_accepted_text() {
4331        use super::BodyRevisionExt;
4332        // Para 1: Ins("hello") + Run(" world")  → accepted = "hello world"
4333        // Para 2: Del("old") + Run(" text")     → accepted = " text"
4334        // joined with "\n"
4335        let para1 = make_para_with_ins("hello", " world");
4336        let para2 = make_para_with_del("old", " text");
4337
4338        let body = types::Body {
4339            block_content: vec![
4340                types::BlockContent::P(Box::new(para1)),
4341                types::BlockContent::P(Box::new(para2)),
4342            ],
4343            #[cfg(feature = "wml-layout")]
4344            sect_pr: None,
4345            #[cfg(feature = "extra-children")]
4346            extra_children: Vec::new(),
4347        };
4348        assert_eq!(body.accepted_text(), "hello world\n text");
4349    }
4350
4351    // -------------------------------------------------------------------------
4352    // FormFieldExt / BodyExt::form_fields tests
4353    // -------------------------------------------------------------------------
4354
4355    /// Build a minimal `CTSdtPr` with only alias/tag set.
4356    #[cfg(feature = "wml-settings")]
4357    fn make_sdt_pr_base(alias: Option<&str>, tag: Option<&str>) -> types::CTSdtPr {
4358        types::CTSdtPr {
4359            r_pr: None,
4360            alias: alias.map(|s| {
4361                Box::new(types::CTString {
4362                    value: s.to_string(),
4363                    #[cfg(feature = "extra-attrs")]
4364                    extra_attrs: Default::default(),
4365                })
4366            }),
4367            tag: tag.map(|s| {
4368                Box::new(types::CTString {
4369                    value: s.to_string(),
4370                    #[cfg(feature = "extra-attrs")]
4371                    extra_attrs: Default::default(),
4372                })
4373            }),
4374            id: None,
4375            lock: None,
4376            placeholder: None,
4377            temporary: None,
4378            showing_plc_hdr: None,
4379            data_binding: None,
4380            label: None,
4381            tab_index: None,
4382            equation: None,
4383            combo_box: None,
4384            date: None,
4385            doc_part_obj: None,
4386            doc_part_list: None,
4387            drop_down_list: None,
4388            picture: None,
4389            rich_text: None,
4390            text: None,
4391            citation: None,
4392            group: None,
4393            bibliography: None,
4394            #[cfg(feature = "extra-children")]
4395            extra_children: Default::default(),
4396        }
4397    }
4398
4399    /// Build a `CTSdtRun` containing a run with the given text.
4400    #[cfg(feature = "wml-settings")]
4401    fn make_sdt_run(sdt_pr: types::CTSdtPr, value_text: &str) -> types::CTSdtRun {
4402        let content = types::CTSdtContentRun {
4403            paragraph_content: vec![types::ParagraphContent::R(Box::new(make_run(vec![
4404                make_text(value_text),
4405            ])))],
4406            #[cfg(feature = "extra-children")]
4407            extra_children: Default::default(),
4408        };
4409        types::CTSdtRun {
4410            sdt_pr: Some(Box::new(sdt_pr)),
4411            sdt_end_pr: None,
4412            sdt_content: Some(Box::new(content)),
4413            #[cfg(feature = "extra-children")]
4414            extra_children: Default::default(),
4415        }
4416    }
4417
4418    /// Build a `CTSdtBlock` containing a paragraph with the given text.
4419    #[cfg(feature = "wml-settings")]
4420    fn make_sdt_block(sdt_pr: types::CTSdtPr, value_text: &str) -> types::CTSdtBlock {
4421        let para = make_paragraph(vec![make_p_run(value_text)]);
4422        let content = types::CTSdtContentBlock {
4423            block_content: vec![types::BlockContentChoice::P(Box::new(para))],
4424            #[cfg(feature = "extra-children")]
4425            extra_children: Default::default(),
4426        };
4427        types::CTSdtBlock {
4428            sdt_pr: Some(Box::new(sdt_pr)),
4429            sdt_end_pr: None,
4430            sdt_content: Some(Box::new(content)),
4431            #[cfg(feature = "extra-children")]
4432            extra_children: Default::default(),
4433        }
4434    }
4435
4436    #[test]
4437    #[cfg(feature = "wml-settings")]
4438    fn test_form_field_plain_text() {
4439        use super::{FormFieldExt, FormFieldType};
4440        let mut sdt_pr = make_sdt_pr_base(None, None);
4441        sdt_pr.text = Some(Box::new(types::CTSdtText {
4442            multi_line: None,
4443            #[cfg(feature = "extra-attrs")]
4444            extra_attrs: Default::default(),
4445        }));
4446        let sdt_run = make_sdt_run(sdt_pr, "my value");
4447        let field = sdt_run.form_field().expect("should have form field");
4448        assert_eq!(
4449            field.field_type,
4450            FormFieldType::PlainText { multi_line: false }
4451        );
4452        assert_eq!(field.current_value, "my value");
4453    }
4454
4455    #[test]
4456    #[cfg(feature = "wml-settings")]
4457    fn test_form_field_plain_text_multiline() {
4458        use super::{FormFieldExt, FormFieldType};
4459        let mut sdt_pr = make_sdt_pr_base(None, None);
4460        sdt_pr.text = Some(Box::new(types::CTSdtText {
4461            multi_line: Some("1".to_string()),
4462            #[cfg(feature = "extra-attrs")]
4463            extra_attrs: Default::default(),
4464        }));
4465        let sdt_run = make_sdt_run(sdt_pr, "line1");
4466        let field = sdt_run.form_field().expect("should have form field");
4467        assert_eq!(
4468            field.field_type,
4469            FormFieldType::PlainText { multi_line: true }
4470        );
4471    }
4472
4473    #[test]
4474    #[cfg(feature = "wml-settings")]
4475    fn test_form_field_combo_box() {
4476        use super::{FormFieldExt, FormFieldType};
4477        let mut sdt_pr = make_sdt_pr_base(None, None);
4478        sdt_pr.combo_box = Some(Box::new(types::CTSdtComboBox {
4479            last_value: Some("Option A".to_string()),
4480            list_item: vec![
4481                types::CTSdtListItem {
4482                    display_text: Some("Option A".to_string()),
4483                    value: Some("a".to_string()),
4484                    #[cfg(feature = "extra-attrs")]
4485                    extra_attrs: Default::default(),
4486                },
4487                types::CTSdtListItem {
4488                    display_text: Some("Option B".to_string()),
4489                    value: Some("b".to_string()),
4490                    #[cfg(feature = "extra-attrs")]
4491                    extra_attrs: Default::default(),
4492                },
4493            ],
4494            #[cfg(feature = "extra-attrs")]
4495            extra_attrs: Default::default(),
4496            #[cfg(feature = "extra-children")]
4497            extra_children: Default::default(),
4498        }));
4499        let sdt_block = make_sdt_block(sdt_pr, "Option A");
4500        let field = sdt_block.form_field().expect("should have form field");
4501        match &field.field_type {
4502            FormFieldType::ComboBox { choices } => {
4503                assert_eq!(
4504                    choices,
4505                    &vec!["Option A".to_string(), "Option B".to_string()]
4506                );
4507            }
4508            other => panic!("expected ComboBox, got {other:?}"),
4509        }
4510        assert_eq!(field.current_value, "Option A");
4511    }
4512
4513    #[test]
4514    #[cfg(feature = "wml-settings")]
4515    fn test_form_field_dropdown() {
4516        use super::{FormFieldExt, FormFieldType};
4517        let mut sdt_pr = make_sdt_pr_base(None, None);
4518        sdt_pr.drop_down_list = Some(Box::new(types::CTSdtDropDownList {
4519            last_value: None,
4520            list_item: vec![
4521                types::CTSdtListItem {
4522                    display_text: Some("Red".to_string()),
4523                    value: Some("red".to_string()),
4524                    #[cfg(feature = "extra-attrs")]
4525                    extra_attrs: Default::default(),
4526                },
4527                types::CTSdtListItem {
4528                    display_text: Some("Blue".to_string()),
4529                    value: Some("blue".to_string()),
4530                    #[cfg(feature = "extra-attrs")]
4531                    extra_attrs: Default::default(),
4532                },
4533            ],
4534            #[cfg(feature = "extra-attrs")]
4535            extra_attrs: Default::default(),
4536            #[cfg(feature = "extra-children")]
4537            extra_children: Default::default(),
4538        }));
4539        let sdt_block = make_sdt_block(sdt_pr, "Red");
4540        let field = sdt_block.form_field().expect("should have form field");
4541        match &field.field_type {
4542            FormFieldType::DropDownList { choices } => {
4543                assert_eq!(choices, &vec!["Red".to_string(), "Blue".to_string()]);
4544            }
4545            other => panic!("expected DropDownList, got {other:?}"),
4546        }
4547        assert_eq!(field.current_value, "Red");
4548    }
4549
4550    #[test]
4551    #[cfg(feature = "wml-settings")]
4552    fn test_form_field_alias_and_tag() {
4553        use super::{FormFieldExt, FormFieldType};
4554        let mut sdt_pr = make_sdt_pr_base(Some("Full Name"), Some("fullName"));
4555        sdt_pr.text = Some(Box::new(types::CTSdtText {
4556            multi_line: None,
4557            #[cfg(feature = "extra-attrs")]
4558            extra_attrs: Default::default(),
4559        }));
4560        let sdt_run = make_sdt_run(sdt_pr, "Jane Doe");
4561        let field = sdt_run.form_field().expect("should have form field");
4562        assert_eq!(field.alias.as_deref(), Some("Full Name"));
4563        assert_eq!(field.tag.as_deref(), Some("fullName"));
4564        assert_eq!(
4565            field.field_type,
4566            FormFieldType::PlainText { multi_line: false }
4567        );
4568        assert_eq!(field.current_value, "Jane Doe");
4569    }
4570
4571    #[test]
4572    #[cfg(feature = "wml-settings")]
4573    fn test_form_field_rich_text() {
4574        use super::{FormFieldExt, FormFieldType};
4575        let mut sdt_pr = make_sdt_pr_base(None, None);
4576        sdt_pr.rich_text = Some(Box::new(types::CTEmpty));
4577        let sdt_block = make_sdt_block(sdt_pr, "rich content here");
4578        let field = sdt_block.form_field().expect("should have form field");
4579        assert_eq!(field.field_type, FormFieldType::RichText);
4580        assert_eq!(field.current_value, "rich content here");
4581    }
4582
4583    #[test]
4584    #[cfg(feature = "wml-settings")]
4585    fn test_form_field_date_picker() {
4586        use super::{FormFieldExt, FormFieldType};
4587        let mut sdt_pr = make_sdt_pr_base(None, None);
4588        sdt_pr.date = Some(Box::new(types::CTSdtDate {
4589            full_date: Some("2026-02-24T00:00:00Z".to_string()),
4590            date_format: Some(Box::new(types::CTString {
4591                value: "yyyy-MM-dd".to_string(),
4592                #[cfg(feature = "extra-attrs")]
4593                extra_attrs: Default::default(),
4594            })),
4595            lid: None,
4596            store_mapped_data_as: None,
4597            calendar: None,
4598            #[cfg(feature = "extra-attrs")]
4599            extra_attrs: Default::default(),
4600            #[cfg(feature = "extra-children")]
4601            extra_children: Default::default(),
4602        }));
4603        let sdt_block = make_sdt_block(sdt_pr, "2026-02-24");
4604        let field = sdt_block.form_field().expect("should have form field");
4605        match &field.field_type {
4606            FormFieldType::DatePicker { format } => {
4607                assert_eq!(format.as_deref(), Some("yyyy-MM-dd"));
4608            }
4609            other => panic!("expected DatePicker, got {other:?}"),
4610        }
4611        assert_eq!(field.current_value, "2026-02-24");
4612    }
4613
4614    #[test]
4615    #[cfg(feature = "wml-settings")]
4616    fn test_form_fields_from_body() {
4617        use super::{BodyExt, FormFieldType};
4618
4619        // SDT 1: block-level plain text
4620        let sdt_pr1 = {
4621            let mut pr = make_sdt_pr_base(Some("First Name"), Some("firstName"));
4622            pr.text = Some(Box::new(types::CTSdtText {
4623                multi_line: None,
4624                #[cfg(feature = "extra-attrs")]
4625                extra_attrs: Default::default(),
4626            }));
4627            pr
4628        };
4629        let block_sdt = make_sdt_block(sdt_pr1, "John");
4630
4631        // SDT 2: inline (run-level) combo box inside a paragraph
4632        let sdt_pr2 = {
4633            let mut pr = make_sdt_pr_base(Some("Color"), None);
4634            pr.combo_box = Some(Box::new(types::CTSdtComboBox {
4635                last_value: Some("Red".to_string()),
4636                list_item: vec![types::CTSdtListItem {
4637                    display_text: Some("Red".to_string()),
4638                    value: Some("red".to_string()),
4639                    #[cfg(feature = "extra-attrs")]
4640                    extra_attrs: Default::default(),
4641                }],
4642                #[cfg(feature = "extra-attrs")]
4643                extra_attrs: Default::default(),
4644                #[cfg(feature = "extra-children")]
4645                extra_children: Default::default(),
4646            }));
4647            pr
4648        };
4649        let inline_sdt = make_sdt_run(sdt_pr2, "Red");
4650        let para_with_sdt =
4651            make_paragraph(vec![types::ParagraphContent::Sdt(Box::new(inline_sdt))]);
4652
4653        let body = make_body(vec![
4654            types::BlockContent::Sdt(Box::new(block_sdt)),
4655            types::BlockContent::P(Box::new(para_with_sdt)),
4656        ]);
4657
4658        let fields = body.form_fields();
4659        assert_eq!(fields.len(), 2);
4660
4661        assert_eq!(fields[0].alias.as_deref(), Some("First Name"));
4662        assert_eq!(fields[0].tag.as_deref(), Some("firstName"));
4663        assert_eq!(
4664            fields[0].field_type,
4665            FormFieldType::PlainText { multi_line: false }
4666        );
4667        assert_eq!(fields[0].current_value, "John");
4668
4669        assert_eq!(fields[1].alias.as_deref(), Some("Color"));
4670        assert!(
4671            matches!(&fields[1].field_type, FormFieldType::ComboBox { choices } if choices == &["Red"])
4672        );
4673        assert_eq!(fields[1].current_value, "Red");
4674    }
4675
4676    #[test]
4677    #[cfg(feature = "wml-settings")]
4678    fn test_form_field_no_sdt_pr_returns_none() {
4679        use super::FormFieldExt;
4680        let sdt_run = types::CTSdtRun {
4681            sdt_pr: None,
4682            sdt_end_pr: None,
4683            sdt_content: None,
4684            #[cfg(feature = "extra-children")]
4685            extra_children: Default::default(),
4686        };
4687        assert!(sdt_run.form_field().is_none());
4688    }
4689
4690    // -------------------------------------------------------------------------
4691    // MathExt tests
4692    // -------------------------------------------------------------------------
4693
4694    /// Build a `Paragraph` whose `extra_children` contains a minimal
4695    /// `<m:oMath>` element with the supplied math text.
4696    ///
4697    /// Structure:
4698    /// ```xml
4699    /// <m:oMath>
4700    ///   <m:r>
4701    ///     <m:t>text</m:t>
4702    ///   </m:r>
4703    /// </m:oMath>
4704    /// ```
4705    #[cfg(feature = "extra-children")]
4706    fn make_paragraph_with_inline_math(math_text: &str) -> types::Paragraph {
4707        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
4708
4709        let t = RawXmlElement {
4710            name: "m:t".to_string(),
4711            attributes: vec![],
4712            children: vec![RawXmlNode::Text(math_text.to_string())],
4713            self_closing: false,
4714        };
4715        let r = RawXmlElement {
4716            name: "m:r".to_string(),
4717            attributes: vec![],
4718            children: vec![RawXmlNode::Element(t)],
4719            self_closing: false,
4720        };
4721        let o_math = RawXmlElement {
4722            name: "m:oMath".to_string(),
4723            attributes: vec![],
4724            children: vec![RawXmlNode::Element(r)],
4725            self_closing: false,
4726        };
4727
4728        types::Paragraph {
4729            #[cfg(feature = "wml-track-changes")]
4730            rsid_r_pr: None,
4731            #[cfg(feature = "wml-track-changes")]
4732            rsid_r: None,
4733            #[cfg(feature = "wml-track-changes")]
4734            rsid_del: None,
4735            #[cfg(feature = "wml-track-changes")]
4736            rsid_p: None,
4737            #[cfg(feature = "wml-track-changes")]
4738            rsid_r_default: None,
4739            #[cfg(feature = "wml-styling")]
4740            p_pr: None,
4741            paragraph_content: vec![],
4742            #[cfg(feature = "extra-attrs")]
4743            extra_attrs: Default::default(),
4744            extra_children: vec![PositionedNode::new(0, RawXmlNode::Element(o_math))],
4745        }
4746    }
4747
4748    /// Build a `Paragraph` whose `extra_children` contains a display
4749    /// `<m:oMathPara>` wrapping a `<m:oMath>`.
4750    ///
4751    /// Structure:
4752    /// ```xml
4753    /// <m:oMathPara>
4754    ///   <m:oMath>
4755    ///     <m:r><m:t>text</m:t></m:r>
4756    ///   </m:oMath>
4757    /// </m:oMathPara>
4758    /// ```
4759    #[cfg(feature = "extra-children")]
4760    fn make_paragraph_with_display_math(math_text: &str) -> types::Paragraph {
4761        use ooxml_xml::{PositionedNode, RawXmlElement, RawXmlNode};
4762
4763        let t = RawXmlElement {
4764            name: "m:t".to_string(),
4765            attributes: vec![],
4766            children: vec![RawXmlNode::Text(math_text.to_string())],
4767            self_closing: false,
4768        };
4769        let r = RawXmlElement {
4770            name: "m:r".to_string(),
4771            attributes: vec![],
4772            children: vec![RawXmlNode::Element(t)],
4773            self_closing: false,
4774        };
4775        let o_math = RawXmlElement {
4776            name: "m:oMath".to_string(),
4777            attributes: vec![],
4778            children: vec![RawXmlNode::Element(r)],
4779            self_closing: false,
4780        };
4781        let o_math_para = RawXmlElement {
4782            name: "m:oMathPara".to_string(),
4783            attributes: vec![],
4784            children: vec![RawXmlNode::Element(o_math)],
4785            self_closing: false,
4786        };
4787
4788        types::Paragraph {
4789            #[cfg(feature = "wml-track-changes")]
4790            rsid_r_pr: None,
4791            #[cfg(feature = "wml-track-changes")]
4792            rsid_r: None,
4793            #[cfg(feature = "wml-track-changes")]
4794            rsid_del: None,
4795            #[cfg(feature = "wml-track-changes")]
4796            rsid_p: None,
4797            #[cfg(feature = "wml-track-changes")]
4798            rsid_r_default: None,
4799            #[cfg(feature = "wml-styling")]
4800            p_pr: None,
4801            paragraph_content: vec![],
4802            #[cfg(feature = "extra-attrs")]
4803            extra_attrs: Default::default(),
4804            extra_children: vec![PositionedNode::new(0, RawXmlNode::Element(o_math_para))],
4805        }
4806    }
4807
4808    #[test]
4809    #[cfg(feature = "extra-children")]
4810    fn test_math_expression_inline() {
4811        use super::MathExt;
4812        let para = make_paragraph_with_inline_math("x+y");
4813        let exprs = para.math_expressions();
4814        assert_eq!(exprs.len(), 1);
4815        assert!(!exprs[0].is_display);
4816        #[cfg(feature = "wml-math")]
4817        assert_eq!(exprs[0].text(), "x+y");
4818    }
4819
4820    #[test]
4821    #[cfg(feature = "extra-children")]
4822    fn test_math_expression_display() {
4823        use super::MathExt;
4824        let para = make_paragraph_with_display_math("E=mc²");
4825        let exprs = para.math_expressions();
4826        assert_eq!(exprs.len(), 1);
4827        assert!(exprs[0].is_display);
4828        #[cfg(feature = "wml-math")]
4829        assert_eq!(exprs[0].text(), "E=mc²");
4830    }
4831
4832    #[test]
4833    #[cfg(feature = "extra-children")]
4834    fn test_has_math_true() {
4835        use super::MathExt;
4836        let para = make_paragraph_with_inline_math("a²+b²=c²");
4837        assert!(para.has_math());
4838    }
4839
4840    #[test]
4841    #[cfg(feature = "extra-children")]
4842    fn test_has_math_false() {
4843        use super::MathExt;
4844        // A paragraph with no math in extra_children.
4845        let para = types::Paragraph {
4846            #[cfg(feature = "wml-track-changes")]
4847            rsid_r_pr: None,
4848            #[cfg(feature = "wml-track-changes")]
4849            rsid_r: None,
4850            #[cfg(feature = "wml-track-changes")]
4851            rsid_del: None,
4852            #[cfg(feature = "wml-track-changes")]
4853            rsid_p: None,
4854            #[cfg(feature = "wml-track-changes")]
4855            rsid_r_default: None,
4856            #[cfg(feature = "wml-styling")]
4857            p_pr: None,
4858            paragraph_content: vec![],
4859            #[cfg(feature = "extra-attrs")]
4860            extra_attrs: Default::default(),
4861            extra_children: vec![],
4862        };
4863        assert!(!para.has_math());
4864    }
4865
4866    #[test]
4867    #[cfg(feature = "extra-children")]
4868    fn test_body_math_expressions() {
4869        use super::MathExt;
4870
4871        let para1 = make_paragraph_with_inline_math("x+y");
4872        let para2 = make_paragraph_with_display_math("∫f(x)dx");
4873        // A paragraph without math.
4874        let para3 = types::Paragraph {
4875            #[cfg(feature = "wml-track-changes")]
4876            rsid_r_pr: None,
4877            #[cfg(feature = "wml-track-changes")]
4878            rsid_r: None,
4879            #[cfg(feature = "wml-track-changes")]
4880            rsid_del: None,
4881            #[cfg(feature = "wml-track-changes")]
4882            rsid_p: None,
4883            #[cfg(feature = "wml-track-changes")]
4884            rsid_r_default: None,
4885            #[cfg(feature = "wml-styling")]
4886            p_pr: None,
4887            paragraph_content: vec![],
4888            #[cfg(feature = "extra-attrs")]
4889            extra_attrs: Default::default(),
4890            extra_children: vec![],
4891        };
4892
4893        let body = types::Body {
4894            block_content: vec![
4895                types::BlockContent::P(Box::new(para1)),
4896                types::BlockContent::P(Box::new(para3)),
4897                types::BlockContent::P(Box::new(para2)),
4898            ],
4899            #[cfg(feature = "wml-layout")]
4900            sect_pr: None,
4901            extra_children: vec![],
4902        };
4903
4904        let exprs = body.math_expressions();
4905        assert_eq!(exprs.len(), 2);
4906        assert!(!exprs[0].is_display);
4907        assert!(exprs[1].is_display);
4908    }
4909}