Skip to main content

word_ooxml/
reader.rs

1//! Reading a `.docx` package into our minimal [`Document`] model.
2
3use std::collections::HashMap;
4use std::io::{Read, Seek};
5
6use drawing::{Geometry, PresetShape, ShapeProperties};
7use opc::{Package, Relationships, TargetMode};
8use xml_core::{BytesStart, Event, Reader};
9
10use crate::error::{Error, Result};
11use crate::model::{
12    Alignment, Block, Bookmark, BreakKind, CellVerticalAlign, ColorScheme, Comment,
13    CustomPropertyValue, Document, DocumentProperties, EmbeddedChart, ExtendedProperties, Field,
14    FontScheme, HeaderFooter, Highlight, Hyperlink, Image, ImageFormat, ListLevel, Note, NoteKind,
15    NoteReference, NumberFormat, NumberingDefinition, Orientation, PageSetup, Paragraph,
16    ProtectionKind, Run, RunContent, StructuredDocumentTag, Style, StyleKind, TabLeader, TabStop,
17    TabStopAlignment, Table, TableCell, TableRow, TextDirection, Theme, UnderlineStyle,
18    VerticalAlign, VerticalMerge,
19};
20
21/// The relationship type identifying the main document part of a WordprocessingML package.
22const MAIN_DOCUMENT_RELATIONSHIP_TYPE: &str =
23    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
24
25/// The relationship type identifying `word/styles.xml`. Unlike the header/ footer relationships,
26/// this one isn't referenced by an explicit `r:id` anywhere in `document.xml`'s content — Word (and
27/// this reader) locates it purely by relationship type.
28const STYLES_RELATIONSHIP_TYPE: &str =
29    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
30
31/// The relationship type identifying `word/numbering.xml`. Like `STYLES_RELATIONSHIP_TYPE`, located
32/// purely by relationship type — a paragraph's `w:numPr/w:numId` is a plain integer, not a
33/// relationship id.
34const NUMBERING_RELATIONSHIP_TYPE: &str =
35    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
36
37/// The relationship type identifying `word/footnotes.xml`. Like `STYLES_RELATIONSHIP_TYPE`, located
38/// purely by relationship type — a `<w:footnoteReference>`'s `w:id` is a plain integer, not a
39/// relationship id.
40const FOOTNOTES_RELATIONSHIP_TYPE: &str =
41    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
42
43/// The relationship type identifying `word/endnotes.xml`, mirroring `FOOTNOTES_RELATIONSHIP_TYPE`.
44const ENDNOTES_RELATIONSHIP_TYPE: &str =
45    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
46
47/// The relationship type identifying `word/comments.xml`. Like `STYLES_RELATIONSHIP_TYPE`, located
48/// purely by relationship type — a `<w:commentReference>`'s `w:id` is a plain integer, not a
49/// relationship id.
50const COMMENTS_RELATIONSHIP_TYPE: &str =
51    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
52
53/// The relationship type identifying `word/theme/theme1.xml`. Like `STYLES_RELATIONSHIP_TYPE`,
54/// located purely by relationship type.
55const THEME_RELATIONSHIP_TYPE: &str =
56    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme";
57
58/// The relationship type identifying `word/settings.xml`. Like `STYLES_RELATIONSHIP_TYPE`, located
59/// purely by relationship type. Unlike the other relationship-type-only parts above,
60/// `word/settings.xml` is always written by this crate's own `write_to` (see `writer.rs`) even when
61/// near-empty, so in practice this lookup should always succeed for a document this crate produced
62/// — but a `.docx` from another producer that genuinely omits it is still handled gracefully
63/// (`Document.track_changes` simply stays at its `false` default).
64const SETTINGS_RELATIONSHIP_TYPE: &str =
65    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
66
67/// The relationship type identifying `docProps/core.xml`. Unlike `STYLES_RELATIONSHIP_TYPE` and the
68/// other part-relative lookups above, this one is a package-level relationship (declared in the
69/// package's own root `_rels/.rels`, alongside `MAIN_DOCUMENT_RELATIONSHIP_TYPE`), not a
70/// relationship scoped to `word/document.xml.rels` — matching how `write_to` declares it (see
71/// `writer.rs`'s `package.add_relationship` call for `CORE_PROPERTIES_RELATIONSHIP_TYPE`).
72const CORE_PROPERTIES_RELATIONSHIP_TYPE: &str =
73    "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
74
75/// The relationship type identifying `docProps/app.xml`. Package-level, same posture as
76/// `CORE_PROPERTIES_RELATIONSHIP_TYPE`.
77const EXTENDED_PROPERTIES_RELATIONSHIP_TYPE: &str =
78    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
79
80/// The relationship type identifying `docProps/custom.xml`. Package-level, same posture as
81/// `CORE_PROPERTIES_RELATIONSHIP_TYPE` — but unlike core/extended properties, this part (and its
82/// relationship) is genuinely optional, only present when `Document.custom_properties` was
83/// non-empty at write time (see `writer.rs`).
84const CUSTOM_PROPERTIES_RELATIONSHIP_TYPE: &str =
85    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
86
87impl Document {
88    /// Reads a `.docx` package from a seekable byte source.
89    pub fn read_from<R: Read + Seek>(reader: R) -> Result<Self> {
90        let package = Package::read_from(reader)?;
91        let main_document_relationship = package
92            .relationships()
93            .iter()
94            .find(|relationship| relationship.rel_type == MAIN_DOCUMENT_RELATIONSHIP_TYPE)
95            .ok_or(Error::MissingMainDocument)?;
96
97        // The root relationship's target is a path relative to the package root (e.g.
98        // "word/document.xml"); part names are always root-absolute (e.g. "/word/document.xml").
99        let part_name = match main_document_relationship.target_mode {
100            TargetMode::Internal => format!("/{}", main_document_relationship.target),
101            TargetMode::External => return Err(Error::MissingMainDocument),
102        };
103
104        let part = package.part(&part_name).ok_or(Error::MissingMainDocument)?;
105        let xml = std::str::from_utf8(&part.data)?;
106
107        // Images are referenced from `document.xml` by relationship id (`r:embed`); resolving one
108        // to actual bytes means looking it up in `word/_rels/document.xml.rels`
109        // (`part.relationships`, already parsed by `opc::Package::read_from`) and then fetching the
110        // target part from the package.
111        let resolver = ImageResolver {
112            package: &package,
113            part_relationships: &part.relationships,
114        };
115
116        let parsed = parse_document_xml(xml, &resolver)?;
117        let mut document = parsed.document;
118
119        // `sectPr`'s `headerReference`/`footerReference` (only the `type="default"` one is captured
120        // by `parse_document_xml`) point at a relationship declared in `document.xml`'s own
121        // relationships — the same `part.relationships` used to resolve images above.
122        if let Some(relationship_id) = parsed.header_relationship_id {
123            document.header = Some(resolve_header_footer(
124                &package,
125                &part.relationships,
126                &relationship_id,
127            )?);
128        }
129        if let Some(relationship_id) = parsed.footer_relationship_id {
130            document.footer = Some(resolve_header_footer(
131                &package,
132                &part.relationships,
133                &relationship_id,
134            )?);
135        }
136        if let Some(relationship_id) = parsed.header_first_relationship_id {
137            document.header_first = Some(resolve_header_footer(
138                &package,
139                &part.relationships,
140                &relationship_id,
141            )?);
142        }
143        if let Some(relationship_id) = parsed.footer_first_relationship_id {
144            document.footer_first = Some(resolve_header_footer(
145                &package,
146                &part.relationships,
147                &relationship_id,
148            )?);
149        }
150        if let Some(relationship_id) = parsed.header_even_relationship_id {
151            document.header_even = Some(resolve_header_footer(
152                &package,
153                &part.relationships,
154                &relationship_id,
155            )?);
156        }
157        if let Some(relationship_id) = parsed.footer_even_relationship_id {
158            document.footer_even = Some(resolve_header_footer(
159                &package,
160                &part.relationships,
161                &relationship_id,
162            )?);
163        }
164
165        // Unlike header/footer, `word/styles.xml` isn't referenced by an explicit `r:id` anywhere
166        // in `document.xml`'s content — find it by relationship type instead, exactly how
167        // `write_to` declares it (see `writer.rs`).
168        let styles_relationship = part
169            .relationships
170            .iter()
171            .find(|relationship| relationship.rel_type == STYLES_RELATIONSHIP_TYPE);
172        if let Some(relationship) = styles_relationship {
173            let styles_part_name = format!("/word/{}", relationship.target);
174            let styles_part = package
175                .part(&styles_part_name)
176                .ok_or_else(|| Error::MissingStylesPart(styles_part_name.clone()))?;
177            let styles_xml = std::str::from_utf8(&styles_part.data)?;
178            document.styles = parse_styles_xml(styles_xml)?;
179        }
180
181        // Same relationship-type-only lookup as `word/styles.xml` above.
182        let numbering_relationship = part
183            .relationships
184            .iter()
185            .find(|relationship| relationship.rel_type == NUMBERING_RELATIONSHIP_TYPE);
186        if let Some(relationship) = numbering_relationship {
187            let numbering_part_name = format!("/word/{}", relationship.target);
188            let numbering_part = package
189                .part(&numbering_part_name)
190                .ok_or_else(|| Error::MissingNumberingPart(numbering_part_name.clone()))?;
191            let numbering_xml = std::str::from_utf8(&numbering_part.data)?;
192            document.numbering_definitions = parse_numbering_xml(numbering_xml)?;
193        }
194
195        // Same relationship-type-only lookup as `word/styles.xml`/ `word/numbering.xml` above. Each
196        // part has its own relationships (a footnote could embed its own image), so a fresh
197        // [`ImageResolver`] scoped to it is built — mirrors `resolve_header_footer`.
198        let footnotes_relationship = part
199            .relationships
200            .iter()
201            .find(|relationship| relationship.rel_type == FOOTNOTES_RELATIONSHIP_TYPE);
202        if let Some(relationship) = footnotes_relationship {
203            let footnotes_part_name = format!("/word/{}", relationship.target);
204            let footnotes_part = package
205                .part(&footnotes_part_name)
206                .ok_or_else(|| Error::MissingFootnotesPart(footnotes_part_name.clone()))?;
207            let footnotes_xml = std::str::from_utf8(&footnotes_part.data)?;
208            let footnotes_resolver = ImageResolver {
209                package: &package,
210                part_relationships: &footnotes_part.relationships,
211            };
212            document.footnotes = parse_notes_xml(footnotes_xml, &footnotes_resolver, b"footnote")?;
213        }
214
215        let endnotes_relationship = part
216            .relationships
217            .iter()
218            .find(|relationship| relationship.rel_type == ENDNOTES_RELATIONSHIP_TYPE);
219        if let Some(relationship) = endnotes_relationship {
220            let endnotes_part_name = format!("/word/{}", relationship.target);
221            let endnotes_part = package
222                .part(&endnotes_part_name)
223                .ok_or_else(|| Error::MissingEndnotesPart(endnotes_part_name.clone()))?;
224            let endnotes_xml = std::str::from_utf8(&endnotes_part.data)?;
225            let endnotes_resolver = ImageResolver {
226                package: &package,
227                part_relationships: &endnotes_part.relationships,
228            };
229            document.endnotes = parse_notes_xml(endnotes_xml, &endnotes_resolver, b"endnote")?;
230        }
231
232        let comments_relationship = part
233            .relationships
234            .iter()
235            .find(|relationship| relationship.rel_type == COMMENTS_RELATIONSHIP_TYPE);
236        if let Some(relationship) = comments_relationship {
237            let comments_part_name = format!("/word/{}", relationship.target);
238            let comments_part = package
239                .part(&comments_part_name)
240                .ok_or_else(|| Error::MissingCommentsPart(comments_part_name.clone()))?;
241            let comments_xml = std::str::from_utf8(&comments_part.data)?;
242            let comments_resolver = ImageResolver {
243                package: &package,
244                part_relationships: &comments_part.relationships,
245            };
246            document.comments = parse_comments_xml(comments_xml, &comments_resolver)?;
247        }
248
249        let theme_relationship = part
250            .relationships
251            .iter()
252            .find(|relationship| relationship.rel_type == THEME_RELATIONSHIP_TYPE);
253        if let Some(relationship) = theme_relationship {
254            let theme_part_name = format!("/word/{}", relationship.target);
255            let theme_part = package
256                .part(&theme_part_name)
257                .ok_or_else(|| Error::MissingThemePart(theme_part_name.clone()))?;
258            let theme_xml = std::str::from_utf8(&theme_part.data)?;
259            document.theme = Some(parse_theme_xml(theme_xml)?);
260        }
261
262        // Same relationship-type-only lookup as `word/styles.xml` etc. above, but tolerant of the
263        // part being missing entirely (see `SETTINGS_RELATIONSHIP_TYPE`'s doc comment) — unlike the
264        // required parts above, a document with no settings part just keeps `track_changes` at its
265        // default `false`.
266        let settings_relationship = part
267            .relationships
268            .iter()
269            .find(|relationship| relationship.rel_type == SETTINGS_RELATIONSHIP_TYPE);
270        if let Some(relationship) = settings_relationship {
271            let settings_part_name = format!("/word/{}", relationship.target);
272            if let Some(settings_part) = package.part(&settings_part_name) {
273                let settings_xml = std::str::from_utf8(&settings_part.data)?;
274                let (track_changes, protection) = parse_settings_xml(settings_xml)?;
275                document.track_changes = track_changes;
276                document.protection = protection;
277            }
278        }
279
280        // Package-level relationship (declared in the package's own root `_rels/.rels`, not
281        // `word/document.xml.rels`) — see `CORE_PROPERTIES_RELATIONSHIP_TYPE`'s doc comment.
282        // Tolerant of the part being missing entirely, same posture as settings above.
283        let core_properties_relationship = package
284            .relationships()
285            .iter()
286            .find(|relationship| relationship.rel_type == CORE_PROPERTIES_RELATIONSHIP_TYPE);
287        if let Some(relationship) = core_properties_relationship {
288            let core_properties_part_name = format!("/{}", relationship.target);
289            if let Some(core_properties_part) = package.part(&core_properties_part_name) {
290                let core_properties_xml = std::str::from_utf8(&core_properties_part.data)?;
291                document.properties = parse_core_properties_xml(core_properties_xml)?;
292            }
293        }
294
295        // Same package-level lookup as core properties above, tolerant of the part being missing
296        // entirely.
297        let extended_properties_relationship = package
298            .relationships()
299            .iter()
300            .find(|relationship| relationship.rel_type == EXTENDED_PROPERTIES_RELATIONSHIP_TYPE);
301        if let Some(relationship) = extended_properties_relationship {
302            let extended_properties_part_name = format!("/{}", relationship.target);
303            if let Some(extended_properties_part) = package.part(&extended_properties_part_name) {
304                let extended_properties_xml = std::str::from_utf8(&extended_properties_part.data)?;
305                document.extended_properties =
306                    parse_extended_properties_xml(extended_properties_xml)?;
307            }
308        }
309
310        // Same package-level lookup as core/extended properties above — but here, tolerance of the
311        // part being missing simply means `custom_properties` stays empty, its normal default (this
312        // part is genuinely optional, unlike core/extended properties which this crate's own
313        // `write_to` always produces).
314        let custom_properties_relationship = package
315            .relationships()
316            .iter()
317            .find(|relationship| relationship.rel_type == CUSTOM_PROPERTIES_RELATIONSHIP_TYPE);
318        if let Some(relationship) = custom_properties_relationship {
319            let custom_properties_part_name = format!("/{}", relationship.target);
320            if let Some(custom_properties_part) = package.part(&custom_properties_part_name) {
321                let custom_properties_xml = std::str::from_utf8(&custom_properties_part.data)?;
322                document.custom_properties = parse_custom_properties_xml(custom_properties_xml)?;
323            }
324        }
325
326        Ok(document)
327    }
328}
329
330/// Resolves a `headerReference`/`footerReference`'s relationship id (`r:id`) to its target part
331/// (`word/header1.xml`/`word/footer1.xml`) and parses its content: `CT_HdrFtr` reuses the exact
332/// same block-level content model as the document body (`EG_BlockLevelElts`), just without a
333/// `w:body` wrapper.
334fn resolve_header_footer(
335    package: &Package,
336    document_relationships: &Relationships,
337    relationship_id: &str,
338) -> Result<HeaderFooter> {
339    let relationship = document_relationships
340        .by_id(relationship_id)
341        .ok_or_else(|| Error::MissingHeaderFooterRelationship(relationship_id.to_string()))?;
342
343    // The relationship's target is relative to `word/` (e.g. "header1.xml"); part names are
344    // root-absolute.
345    let part_name = format!("/word/{}", relationship.target);
346    let part = package
347        .part(&part_name)
348        .ok_or_else(|| Error::MissingHeaderFooterPart(part_name.clone()))?;
349    let xml = std::str::from_utf8(&part.data)?;
350
351    // A header/footer can embed its own image (e.g. a logo); its relationships are scoped to its
352    // own part (`headerN.xml.rels`), not the document's.
353    let resolver = ImageResolver {
354        package,
355        part_relationships: &part.relationships,
356    };
357
358    Ok(HeaderFooter {
359        blocks: parse_blocks(xml, &resolver)?,
360    })
361}
362
363/// Resolves an image's relationship id (`r:embed`) to its actual bytes and format, by looking it up
364/// in the owning part's own relationships (either `word/document.xml`'s, or a header/footer part's)
365/// and then fetching the target part from the package. Also used, via its `part_relationships`
366/// field directly (see [`hyperlink_from_start`]), to resolve a `<w:hyperlink>`'s `r:id` to its
367/// target URL — an external relationship, so unlike an image it is never resolved to a package
368/// part.
369struct ImageResolver<'a> {
370    package: &'a Package,
371    part_relationships: &'a Relationships,
372}
373
374impl ImageResolver<'_> {
375    fn resolve(
376        &self,
377        relationship_id: &str,
378        width_emu: i64,
379        height_emu: i64,
380        description: String,
381    ) -> Result<Image> {
382        let relationship = self
383            .part_relationships
384            .by_id(relationship_id)
385            .ok_or_else(|| Error::MissingImageRelationship(relationship_id.to_string()))?;
386
387        // The relationship's target is relative to `word/` (e.g. "media/image1.png"); part names
388        // are root-absolute.
389        let part_name = format!("/word/{}", relationship.target);
390        let part = self
391            .package
392            .part(&part_name)
393            .ok_or_else(|| Error::MissingImagePart(part_name.clone()))?;
394        let format = ImageFormat::from_extension(&part_name)
395            .ok_or_else(|| Error::UnsupportedImageFormat(part_name.clone()))?;
396
397        Ok(Image {
398            data: part.data.clone(),
399            format,
400            width_emu,
401            height_emu,
402            description,
403            // Populated by `parse_drawing` afterward, if `pic:spPr` carried anything beyond the
404            // fixed default rectangle — see.
405            shape_properties: None,
406        })
407    }
408
409    /// Resolves a `<c:chart r:id="..">` relationship id to its `word/charts/chartN.xml` part and
410    /// parses it whole into a [`chart::ChartSpace`]. Same resolution posture as
411    /// [`ImageResolver::resolve`] (relationship id → part name → package lookup), just for a chart
412    /// part instead of a media part.
413    fn resolve_chart(&self, relationship_id: &str) -> Result<chart::ChartSpace> {
414        let relationship = self
415            .part_relationships
416            .by_id(relationship_id)
417            .ok_or_else(|| Error::MissingChartRelationship(relationship_id.to_string()))?;
418
419        let part_name = format!("/word/{}", relationship.target);
420        let part = self
421            .package
422            .part(&part_name)
423            .ok_or_else(|| Error::MissingChartPart(part_name.clone()))?;
424        let xml = std::str::from_utf8(&part.data)?;
425
426        Ok(chart::read_chart_space(xml)?)
427    }
428}
429
430/// The result of parsing `document.xml`: the document itself (blocks and `page_setup` —
431/// `header`/`footer`/`header_first`/`footer_first`/ `header_even`/`footer_even` are filled in by
432/// the caller, [`Document::read_from`], once these relationship ids are resolved to actual parts),
433/// plus the relationship ids of whichever header/footer variants `sectPr` referenced
434/// (`w:headerReference`/`w:footerReference`, one pair per `default`/`first`/`even` — `ST_HdrFtr`'s
435/// three enumeration values).
436struct ParsedDocumentXml {
437    document: Document,
438    header_relationship_id: Option<String>,
439    footer_relationship_id: Option<String>,
440    header_first_relationship_id: Option<String>,
441    footer_first_relationship_id: Option<String>,
442    header_even_relationship_id: Option<String>,
443    footer_even_relationship_id: Option<String>,
444}
445
446/// Parses the content of `document.xml` into our minimal [`Document`] model: paragraphs (with
447/// alignment and bold/italic/underline run properties) and tables (rows of cells, each a sequence
448/// of paragraphs), interleaved in reading order, plus the default header/footer's relationship id
449/// if `sectPr` references one. Everything else (page size/margins, other formatting,
450/// revision-tracking attributes, etc.) is ignored for now.
451///
452/// This is a small recursive-descent parser: `parse_document_xml` only dispatches on top-level
453/// block starts (`w:p`, `w:tbl`) and on `sectPr`'s header/footer references; `parse_paragraph` and
454/// `parse_table` each consume events from the shared `reader` up to their own matching end tag,
455/// calling back into each other where the grammar nests (a table cell's paragraphs, in particular).
456fn parse_document_xml(xml: &str, resolver: &ImageResolver) -> Result<ParsedDocumentXml> {
457    let mut document = Document::new();
458    let mut header_relationship_id: Option<String> = None;
459    let mut footer_relationship_id: Option<String> = None;
460    let mut header_first_relationship_id: Option<String> = None;
461    let mut footer_first_relationship_id: Option<String> = None;
462    let mut header_even_relationship_id: Option<String> = None;
463    let mut footer_even_relationship_id: Option<String> = None;
464    let mut reader = Reader::from_xml_str(xml);
465
466    loop {
467        match reader.read_event()? {
468            Event::Eof => break,
469
470            Event::Start(start) => match start.local_name().as_ref() {
471                b"p" => document
472                    .blocks
473                    .push(Block::Paragraph(parse_paragraph(&mut reader, resolver)?)),
474                b"tbl" => document
475                    .blocks
476                    .push(Block::Table(parse_table(&mut reader, resolver)?)),
477                b"sdt" => document.blocks.push(Block::StructuredDocumentTag(
478                    parse_structured_document_tag(&mut reader, resolver)?,
479                )),
480                b"headerReference" => capture_header_footer_reference(
481                    &start,
482                    &mut header_relationship_id,
483                    &mut header_first_relationship_id,
484                    &mut header_even_relationship_id,
485                ),
486                b"footerReference" => capture_header_footer_reference(
487                    &start,
488                    &mut footer_relationship_id,
489                    &mut footer_first_relationship_id,
490                    &mut footer_even_relationship_id,
491                ),
492                b"pgSz" => apply_page_size(&start, &mut document.page_setup),
493                b"pgMar" => apply_page_margins(&start, &mut document.page_setup),
494                b"cols" => {
495                    document.page_setup.columns =
496                        raw_attribute(&start, b"num").and_then(|value| value.parse().ok())
497                }
498                _ => {}
499            },
500
501            Event::Empty(start) => match start.local_name().as_ref() {
502                b"p" => document.blocks.push(Block::Paragraph(Paragraph::new())),
503                b"sdt" => document
504                    .blocks
505                    .push(Block::StructuredDocumentTag(StructuredDocumentTag::new())),
506                b"headerReference" => capture_header_footer_reference(
507                    &start,
508                    &mut header_relationship_id,
509                    &mut header_first_relationship_id,
510                    &mut header_even_relationship_id,
511                ),
512                b"footerReference" => capture_header_footer_reference(
513                    &start,
514                    &mut footer_relationship_id,
515                    &mut footer_first_relationship_id,
516                    &mut footer_even_relationship_id,
517                ),
518                b"pgSz" => apply_page_size(&start, &mut document.page_setup),
519                b"pgMar" => apply_page_margins(&start, &mut document.page_setup),
520                b"cols" => {
521                    document.page_setup.columns =
522                        raw_attribute(&start, b"num").and_then(|value| value.parse().ok())
523                }
524                _ => {}
525            },
526
527            _ => {}
528        }
529    }
530
531    Ok(ParsedDocumentXml {
532        document,
533        header_relationship_id,
534        footer_relationship_id,
535        header_first_relationship_id,
536        footer_first_relationship_id,
537        header_even_relationship_id,
538        footer_even_relationship_id,
539    })
540}
541
542/// Applies a `<w:pgSz>`'s `w:w`/`w:h`/`w:orient` attributes onto `page_setup`, leaving whatever was
543/// already there (the `PageSetup::default()` values) unchanged for any attribute that's missing or
544/// unparsable — same forgiving policy as the rest of this reader.
545fn apply_page_size(start: &BytesStart, page_setup: &mut PageSetup) {
546    if let Some(width) = raw_attribute(start, b"w").and_then(|value| value.parse().ok()) {
547        page_setup.width_twips = width;
548    }
549    if let Some(height) = raw_attribute(start, b"h").and_then(|value| value.parse().ok()) {
550        page_setup.height_twips = height;
551    }
552    if let Some(orientation) =
553        raw_attribute(start, b"orient").and_then(|value| Orientation::from_attribute_value(&value))
554    {
555        page_setup.orientation = orientation;
556    }
557}
558
559/// Applies a `<w:pgMar>`'s margin attributes onto `page_setup`, same leave-as-is-if-missing policy
560/// as [`apply_page_size`].
561fn apply_page_margins(start: &BytesStart, page_setup: &mut PageSetup) {
562    if let Some(value) = raw_attribute(start, b"top").and_then(|value| value.parse().ok()) {
563        page_setup.margin_top_twips = value;
564    }
565    if let Some(value) = raw_attribute(start, b"bottom").and_then(|value| value.parse().ok()) {
566        page_setup.margin_bottom_twips = value;
567    }
568    if let Some(value) = raw_attribute(start, b"left").and_then(|value| value.parse().ok()) {
569        page_setup.margin_left_twips = value;
570    }
571    if let Some(value) = raw_attribute(start, b"right").and_then(|value| value.parse().ok()) {
572        page_setup.margin_right_twips = value;
573    }
574    if let Some(value) = raw_attribute(start, b"header").and_then(|value| value.parse().ok()) {
575        page_setup.margin_header_twips = value;
576    }
577    if let Some(value) = raw_attribute(start, b"footer").and_then(|value| value.parse().ok()) {
578        page_setup.margin_footer_twips = value;
579    }
580    if let Some(value) = raw_attribute(start, b"gutter").and_then(|value| value.parse().ok()) {
581        page_setup.margin_gutter_twips = value;
582    }
583}
584
585/// Parses a sequence of block-level elements (paragraphs and tables, in reading order) from a whole
586/// XML part. Shared by header/footer parts (`CT_HdrFtr`, which has no `sectPr` of its own) —
587/// `parse_document_xml` keeps its own, similar loop instead of calling this, since it additionally
588/// needs to capture `sectPr`'s header/footer references.
589fn parse_blocks(xml: &str, resolver: &ImageResolver) -> Result<Vec<Block>> {
590    let mut blocks = Vec::new();
591    let mut reader = Reader::from_xml_str(xml);
592
593    loop {
594        match reader.read_event()? {
595            Event::Eof => break,
596
597            Event::Start(start) => match start.local_name().as_ref() {
598                b"p" => blocks.push(Block::Paragraph(parse_paragraph(&mut reader, resolver)?)),
599                b"tbl" => blocks.push(Block::Table(parse_table(&mut reader, resolver)?)),
600                b"sdt" => blocks.push(Block::StructuredDocumentTag(parse_structured_document_tag(
601                    &mut reader,
602                    resolver,
603                )?)),
604                _ => {}
605            },
606
607            Event::Empty(start) => match start.local_name().as_ref() {
608                b"p" => blocks.push(Block::Paragraph(Paragraph::new())),
609                b"sdt" => blocks.push(Block::StructuredDocumentTag(StructuredDocumentTag::new())),
610                _ => {}
611            },
612
613            _ => {}
614        }
615    }
616
617    Ok(blocks)
618}
619
620/// Captures a `headerReference`/`footerReference`'s `r:id` into whichever of
621/// `default_target`/`first_target`/`even_target` matches its `w:type` (`ST_HdrFtr`'s three
622/// enumeration values — `default`/`first`/`even`), only if nothing has been captured yet for that
623/// variant (the first occurrence of a given type wins).
624fn capture_header_footer_reference(
625    start: &BytesStart,
626    default_target: &mut Option<String>,
627    first_target: &mut Option<String>,
628    even_target: &mut Option<String>,
629) {
630    let type_value = raw_attribute(start, b"type");
631    match type_value.as_deref() {
632        Some("default") if default_target.is_none() => {
633            *default_target = raw_attribute(start, b"id")
634        }
635        Some("first") if first_target.is_none() => *first_target = raw_attribute(start, b"id"),
636        Some("even") if even_target.is_none() => *even_target = raw_attribute(start, b"id"),
637        _ => {}
638    }
639}
640
641/// Parses a single `<w:p>` paragraph, assuming its `Start` event was just consumed by the caller.
642/// Reads runs (text or an inline image, plus bold/italic/underline) and paragraph alignment
643/// (`w:pPr`/`w:jc`), stopping at the matching `</w:p>`.
644fn parse_paragraph(reader: &mut Reader<'_>, resolver: &ImageResolver) -> Result<Paragraph> {
645    let mut paragraph = Paragraph::new();
646    let mut current_run_text: Option<String> = None;
647    // Holds `RunContent::Image` or `RunContent::Chart(EmbeddedChart)` — `<w:drawing>`
648    // (`parse_drawing`) can produce either.
649    let mut current_run_drawing: Option<RunContent> = None;
650    // `<w:footnoteReference>`/`<w:endnoteReference>`/`<w:footnoteRef>`/ `<w:endnoteRef>` are,
651    // unlike `w:drawing`, always self-closing (`CT_FtnEdnRef`/`CT_Empty` have no content model at
652    // all), so this is only ever set from the `Empty` match arm below — see [`RunContent`]'s doc
653    // comment for why these are genuine run content, not a sibling wrapper like
654    // `w:fldSimple`/`w:hyperlink`.
655    let mut current_run_note: Option<RunContent> = None;
656    // `<w:br>`/`<w:cr>` (`CT_Br`/`CT_Empty`) are, like the note reference/marker elements above,
657    // always self-closing genuine `EG_RunInnerContent` members — only ever set from the `Empty`
658    // match arm below.
659    let mut current_run_break: Option<RunContent> = None;
660    let mut current_run_bold = false;
661    let mut current_run_italic = false;
662    let mut current_run_underline: Option<UnderlineStyle> = None;
663    let mut current_run_underline_color: Option<String> = None;
664    let mut current_run_color: Option<String> = None;
665    let mut current_run_font_size: Option<u16> = None;
666    let mut current_run_font_family: Option<String> = None;
667    let mut current_run_highlight: Option<Highlight> = None;
668    let mut current_run_strike = false;
669    let mut current_run_vertical_align: Option<VerticalAlign> = None;
670    let mut current_run_small_caps = false;
671    let mut current_run_all_caps = false;
672    let mut current_run_shading_color: Option<String> = None;
673    let mut current_run_text_border = false;
674    let mut current_run_character_spacing: Option<i16> = None;
675    let mut current_run_vertical_position: Option<i16> = None;
676    let mut current_run_hidden = false;
677    let mut current_run_style_id: Option<String> = None;
678    // Whether the loop is currently between a `<w:r>` and its matching `</w:r>` (including a
679    // field's nested run, between `<w:fldSimple>`'s own `<w:r>` and `</w:r>` — `w:fldSimple`
680    // doesn't get its own flag, this one already covers it). Needed because a handful of element
681    // names are shared, with different meanings, between `w:pPr` (always consumed before any
682    // `<w:r>` in a well-formed document, per `CT_PPrBase`'s sequence — see `write_paragraph`'s doc
683    // comment) and `w:rPr` — `w:shd`/`w:spacing` in particular, see their match arms below
684    // (`w:pBdr`/`w:bdr` don't collide, different names, so don't need this).
685    let mut in_run = false;
686    let mut in_text = false;
687    // `w:fldSimple` (`CT_SimpleField`) is a sibling of `w:r` in `EG_PContent`, wrapping its own
688    // inner run — so while `in_field` is set, the nested `w:r`'s formatting is captured as usual
689    // (into `current_run_*` above) but is NOT pushed as its own run at `</w:r>`; instead, one `Run`
690    // with `RunContent::Field` is pushed at `</w:fldSimple>`, using whatever formatting the (only)
691    // nested run set. `current_field` is `None` for a recognized-but-unsupported instruction (e.g.
692    // `DATE`) or an unrecognized one — such fields are silently dropped rather than erroring out
693    // the whole document (same policy as an unresolvable image, see `parse_drawing`).
694    let mut in_field = false;
695    let mut current_field: Option<Field> = None;
696    // `w:hyperlink` (`CT_Hyperlink`) is, like `w:fldSimple`, a sibling of `w:r` in `EG_PContent`
697    // that wraps its own nested run(s) — but unlike a field, it doesn't suppress the ordinary
698    // per-run push at `</w:r>` (a hyperlinked run is still just a run, with `hyperlink` set), so
699    // there is no `in_hyperlink` flag to gate on, just `current_hyperlink` cloned onto every run
700    // pushed between `<w:hyperlink>` and `</w:hyperlink>`. `None` if the `<w:hyperlink>` has
701    // neither `r:id` (and it doesn't resolve to a declared relationship) nor `w:anchor` — such a
702    // hyperlink is silently dropped (its nested runs are still kept as plain runs), same policy as
703    // an unsupported field.
704    let mut current_hyperlink: Option<Hyperlink> = None;
705    // Which comment ids are currently "open" (a `w:commentRangeStart` seen, no matching
706    // `w:commentRangeEnd` yet) as the loop walks the paragraph's content in order.
707    // `w:commentRangeStart`/`w:commentRangeEnd` (`CT_MarkupRange`) are, like `w:footnoteReference`,
708    // always self-closing siblings of `w:r` — never nested inside one (see `Run.comment_ids`'s doc
709    // comment) — so this list never changes *while* a `<w:r>` is open, meaning every
710    // run-construction site below can just clone it directly, with no separate "run started with
711    // these ids" snapshot needed.
712    let mut open_comment_ids: Vec<u32> = Vec::new();
713    // Which bookmarks are currently "open", mirroring `open_comment_ids` exactly
714    // (`w:bookmarkStart`/`w:bookmarkEnd` are, like `w:commentRangeStart`/`End`, always self-closing
715    // siblings of `w:r` — see `Run.bookmarks`'s doc comment). Unlike comment ids, a bookmark
716    // carries its name right on `w:bookmarkStart`, so the full [`Bookmark`] (not just its id) is
717    // tracked here.
718    let mut open_bookmarks: Vec<Bookmark> = Vec::new();
719
720    loop {
721        match reader.read_event()? {
722            Event::Eof => break,
723
724            Event::Start(start) => match start.local_name().as_ref() {
725                b"r" => {
726                    in_run = true;
727                    current_run_text = Some(String::new());
728                    current_run_drawing = None;
729                    current_run_note = None;
730                    current_run_break = None;
731                    current_run_bold = false;
732                    current_run_italic = false;
733                    current_run_underline = None;
734                    current_run_underline_color = None;
735                    current_run_color = None;
736                    current_run_font_size = None;
737                    current_run_font_family = None;
738                    current_run_highlight = None;
739                    current_run_strike = false;
740                    current_run_vertical_align = None;
741                    current_run_small_caps = false;
742                    current_run_all_caps = false;
743                    current_run_shading_color = None;
744                    current_run_text_border = false;
745                    current_run_character_spacing = None;
746                    current_run_vertical_position = None;
747                    current_run_hidden = false;
748                    current_run_style_id = None;
749                }
750                b"t" => in_text = true,
751                b"drawing" => current_run_drawing = parse_drawing(reader, resolver)?,
752                b"pStyle" => paragraph.style_id = raw_attribute(&start, b"val"),
753                b"ilvl" => {
754                    paragraph.numbering_level = raw_attribute(&start, b"val")
755                        .and_then(|value| value.parse().ok())
756                        .unwrap_or(0)
757                }
758                b"numId" => {
759                    paragraph.numbering_id =
760                        raw_attribute(&start, b"val").and_then(|value| value.parse().ok())
761                }
762                b"jc" => paragraph.alignment = alignment_from_attribute(&start),
763                b"b" => current_run_bold = on_off_attribute(&start),
764                b"i" => current_run_italic = on_off_attribute(&start),
765                b"u" => {
766                    current_run_underline = underline_attribute(&start);
767                    current_run_underline_color = raw_attribute(&start, b"color");
768                }
769                b"color" => current_run_color = raw_attribute(&start, b"val"),
770                b"sz" => {
771                    current_run_font_size = raw_attribute(&start, b"val")
772                        .and_then(|value| value.parse::<u16>().ok())
773                        .map(|half_points| half_points / 2)
774                }
775                b"rFonts" => {
776                    current_run_font_family =
777                        raw_attribute(&start, b"ascii").or_else(|| raw_attribute(&start, b"hAnsi"))
778                }
779                b"highlight" => {
780                    current_run_highlight = raw_attribute(&start, b"val")
781                        .and_then(|value| Highlight::from_attribute_value(&value))
782                }
783                b"strike" => current_run_strike = on_off_attribute(&start),
784                b"vertAlign" => {
785                    current_run_vertical_align = raw_attribute(&start, b"val")
786                        .and_then(|value| VerticalAlign::from_attribute_value(&value))
787                }
788                b"smallCaps" => current_run_small_caps = on_off_attribute(&start),
789                b"caps" => current_run_all_caps = on_off_attribute(&start),
790                // `w:shd`/`w:spacing` are shared element names between `w:rPr` (run
791                // shading/character spacing) and `w:pPr` (paragraph shading/line-and-before-after
792                // spacing) — see `in_run`'s doc comment above for why branching on it is safe here.
793                // `w:bdr`/`w:pBdr` don't collide (different names), so `b"bdr"` above is
794                // unambiguously run-level.
795                b"shd" => {
796                    if in_run {
797                        current_run_shading_color = raw_attribute(&start, b"fill");
798                    } else {
799                        paragraph.shading_color = raw_attribute(&start, b"fill");
800                    }
801                }
802                b"bdr" => current_run_text_border = true,
803                b"spacing" => {
804                    if in_run {
805                        current_run_character_spacing = raw_attribute(&start, b"val")
806                            .and_then(|value| value.parse::<i16>().ok());
807                    } else {
808                        paragraph.space_before = raw_attribute(&start, b"before")
809                            .and_then(|value| value.parse::<u32>().ok());
810                        paragraph.space_after = raw_attribute(&start, b"after")
811                            .and_then(|value| value.parse::<u32>().ok());
812                        paragraph.line_spacing = raw_attribute(&start, b"line")
813                            .and_then(|value| value.parse::<u32>().ok());
814                    }
815                }
816                b"position" => {
817                    current_run_vertical_position =
818                        raw_attribute(&start, b"val").and_then(|value| value.parse::<i16>().ok())
819                }
820                b"vanish" => current_run_hidden = true,
821                b"rStyle" => current_run_style_id = raw_attribute(&start, b"val"),
822                b"pBdr" => paragraph.border = true,
823                b"keepNext" => paragraph.keep_with_next = true,
824                b"keepLines" => paragraph.keep_lines_together = true,
825                b"pageBreakBefore" => paragraph.page_break_before = true,
826                b"contextualSpacing" => paragraph.contextual_spacing = true,
827                b"fldSimple" => {
828                    in_field = true;
829                    current_field =
830                        field_from_instruction(raw_attribute(&start, b"instr").as_deref());
831                }
832                b"hyperlink" => current_hyperlink = hyperlink_from_start(&start, resolver),
833                _ => {}
834            },
835
836            Event::Empty(start) => match start.local_name().as_ref() {
837                b"r" if !in_field => {
838                    paragraph.runs.push(Run {
839                        hyperlink: current_hyperlink.clone(),
840                        comment_ids: open_comment_ids.clone(),
841                        bookmarks: open_bookmarks.clone(),
842                        ..Run::new("")
843                    });
844                }
845                b"pStyle" => paragraph.style_id = raw_attribute(&start, b"val"),
846                b"ilvl" => {
847                    paragraph.numbering_level = raw_attribute(&start, b"val")
848                        .and_then(|value| value.parse().ok())
849                        .unwrap_or(0)
850                }
851                b"numId" => {
852                    paragraph.numbering_id =
853                        raw_attribute(&start, b"val").and_then(|value| value.parse().ok())
854                }
855                b"jc" => paragraph.alignment = alignment_from_attribute(&start),
856                b"b" => current_run_bold = on_off_attribute(&start),
857                b"i" => current_run_italic = on_off_attribute(&start),
858                b"u" => {
859                    current_run_underline = underline_attribute(&start);
860                    current_run_underline_color = raw_attribute(&start, b"color");
861                }
862                b"color" => current_run_color = raw_attribute(&start, b"val"),
863                b"sz" => {
864                    current_run_font_size = raw_attribute(&start, b"val")
865                        .and_then(|value| value.parse::<u16>().ok())
866                        .map(|half_points| half_points / 2)
867                }
868                b"rFonts" => {
869                    current_run_font_family =
870                        raw_attribute(&start, b"ascii").or_else(|| raw_attribute(&start, b"hAnsi"))
871                }
872                b"highlight" => {
873                    current_run_highlight = raw_attribute(&start, b"val")
874                        .and_then(|value| Highlight::from_attribute_value(&value))
875                }
876                b"strike" => current_run_strike = on_off_attribute(&start),
877                b"vertAlign" => {
878                    current_run_vertical_align = raw_attribute(&start, b"val")
879                        .and_then(|value| VerticalAlign::from_attribute_value(&value))
880                }
881                b"smallCaps" => current_run_small_caps = on_off_attribute(&start),
882                b"caps" => current_run_all_caps = on_off_attribute(&start),
883                b"shd" => {
884                    if in_run {
885                        current_run_shading_color = raw_attribute(&start, b"fill");
886                    } else {
887                        paragraph.shading_color = raw_attribute(&start, b"fill");
888                    }
889                }
890                b"bdr" => current_run_text_border = true,
891                b"spacing" => {
892                    if in_run {
893                        current_run_character_spacing = raw_attribute(&start, b"val")
894                            .and_then(|value| value.parse::<i16>().ok());
895                    } else {
896                        paragraph.space_before = raw_attribute(&start, b"before")
897                            .and_then(|value| value.parse::<u32>().ok());
898                        paragraph.space_after = raw_attribute(&start, b"after")
899                            .and_then(|value| value.parse::<u32>().ok());
900                        paragraph.line_spacing = raw_attribute(&start, b"line")
901                            .and_then(|value| value.parse::<u32>().ok());
902                    }
903                }
904                b"position" => {
905                    current_run_vertical_position =
906                        raw_attribute(&start, b"val").and_then(|value| value.parse::<i16>().ok())
907                }
908                b"vanish" => current_run_hidden = true,
909                b"rStyle" => current_run_style_id = raw_attribute(&start, b"val"),
910                b"pBdr" => paragraph.border = true,
911                b"keepNext" => paragraph.keep_with_next = true,
912                b"keepLines" => paragraph.keep_lines_together = true,
913                b"pageBreakBefore" => paragraph.page_break_before = true,
914                b"contextualSpacing" => paragraph.contextual_spacing = true,
915                // `w:tab` (`CT_TabStop`) has no content model at all — always self-closing. A tab
916                // with no (or an unparsable) `w:pos` is silently dropped, same forgiving policy as
917                // elsewhere in this reader.
918                b"tab" => {
919                    if let Some(position) =
920                        raw_attribute(&start, b"pos").and_then(|value| value.parse::<i32>().ok())
921                    {
922                        let mut tab = TabStop::new(position);
923                        if let Some(alignment) = raw_attribute(&start, b"val")
924                            .and_then(|value| TabStopAlignment::from_attribute_value(&value))
925                        {
926                            tab = tab.with_alignment(alignment);
927                        }
928                        if let Some(leader) = raw_attribute(&start, b"leader")
929                            .and_then(|value| TabLeader::from_attribute_value(&value))
930                        {
931                            tab = tab.with_leader(leader);
932                        }
933                        paragraph.tabs.push(tab);
934                    }
935                }
936                // `w:br`/`w:cr` (`CT_Br`/`CT_Empty`) — see `current_run_break`'s doc comment above.
937                b"br" => {
938                    current_run_break = Some(RunContent::Break(BreakKind::from_attribute_value(
939                        raw_attribute(&start, b"type").as_deref(),
940                    )));
941                }
942                b"cr" => current_run_break = Some(RunContent::CarriageReturn),
943                // A field with no inner run at all (e.g. `<w:fldSimple w:instr="PAGE"/>`) — push it
944                // immediately, there is no `</w:fldSimple>` to wait for.
945                b"fldSimple" => {
946                    if let Some(field) =
947                        field_from_instruction(raw_attribute(&start, b"instr").as_deref())
948                    {
949                        paragraph.runs.push(Run {
950                            hyperlink: current_hyperlink.clone(),
951                            comment_ids: open_comment_ids.clone(),
952                            bookmarks: open_bookmarks.clone(),
953                            ..Run::with_field(field)
954                        });
955                    }
956                }
957                // `w:commentRangeStart`/`w:commentRangeEnd` (`CT_MarkupRange`) are always
958                // self-closing (no content model at all) — see `open_comment_ids`'s doc comment
959                // above for how these drive `Run.comment_ids`. A start/end with no (or an
960                // unparsable) `id` is silently ignored, same forgiving policy as elsewhere in this
961                // reader.
962                b"commentRangeStart" => {
963                    if let Some(id) =
964                        raw_attribute(&start, b"id").and_then(|value| value.parse().ok())
965                    {
966                        open_comment_ids.push(id);
967                    }
968                }
969                b"commentRangeEnd" => {
970                    if let Some(id) =
971                        raw_attribute(&start, b"id").and_then(|value| value.parse::<u32>().ok())
972                    {
973                        open_comment_ids.retain(|open_id| *open_id != id);
974                    }
975                }
976                // `w:bookmarkStart`/`w:bookmarkEnd` (`CT_Bookmark`/ `CT_MarkupRange`) — see
977                // `open_bookmarks`'s doc comment above. A start with no (or an unparsable)
978                // `id`/`name` is silently ignored, same forgiving policy as `commentRangeStart`
979                // above.
980                b"bookmarkStart" => {
981                    if let (Some(id), Some(name)) = (
982                        raw_attribute(&start, b"id").and_then(|value| value.parse().ok()),
983                        raw_attribute(&start, b"name"),
984                    ) {
985                        open_bookmarks.push(Bookmark::new(id, name));
986                    }
987                }
988                b"bookmarkEnd" => {
989                    if let Some(id) =
990                        raw_attribute(&start, b"id").and_then(|value| value.parse::<u32>().ok())
991                    {
992                        open_bookmarks.retain(|bookmark| bookmark.id != id);
993                    }
994                }
995                // `w:commentReference` is a writer-derived artifact (see
996                // `write_comment_range_end_and_reference` in `writer.rs`) with no bearing on
997                // `Run.comment_ids` — the range itself (`commentRangeStart`/`End`) is what's
998                // authoritative, so this needs no handling beyond not falling through to an
999                // unrelated arm.
1000                b"commentReference" => {}
1001                b"footnoteReference" => {
1002                    current_run_note = raw_attribute(&start, b"id")
1003                        .and_then(|value| value.parse().ok())
1004                        .map(|id| RunContent::NoteReference(NoteReference::Footnote(id)));
1005                }
1006                b"endnoteReference" => {
1007                    current_run_note = raw_attribute(&start, b"id")
1008                        .and_then(|value| value.parse().ok())
1009                        .map(|id| RunContent::NoteReference(NoteReference::Endnote(id)));
1010                }
1011                b"footnoteRef" => {
1012                    current_run_note = Some(RunContent::NoteMarker(NoteKind::Footnote))
1013                }
1014                b"endnoteRef" => current_run_note = Some(RunContent::NoteMarker(NoteKind::Endnote)),
1015                _ => {}
1016            },
1017
1018            Event::Text(text) if in_text => {
1019                if let Some(buffer) = current_run_text.as_mut() {
1020                    let text = xml_core::decode_text(&text)?;
1021                    buffer.push_str(&text);
1022                }
1023            }
1024
1025            // `quick_xml` splits a text node's `&entity;`/`&#NNN;` references out into their own
1026            // dedicated event, distinct from `Text` — a single `<w:t>A &amp; B</w:t>` arrives as
1027            // `Text("A ")`, `GeneralRef("amp")`, `Text(" B")`, not one `Text` event containing the
1028            // whole escaped string. Missing this case entirely (there used to be no arm for it
1029            // here) is what caused entities to vanish without a trace on read — not left escaped,
1030            // not mis-decoded, just silently dropped by the catch-all `_ => {}` below. See
1031            // `xml_core::decode_text`'s doc comment for the full story and the CHANGELOG entry.
1032            Event::GeneralRef(reference) if in_text => {
1033                if let Some(buffer) = current_run_text.as_mut() {
1034                    if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
1035                        buffer.push_str(&resolved);
1036                    }
1037                }
1038            }
1039
1040            Event::End(end) => match end.local_name().as_ref() {
1041                b"t" => in_text = false,
1042                b"r" if in_field => {
1043                    // The field's own run is pushed once, at `</w:fldSimple>` below, using this
1044                    // run's formatting (left as-is in `current_run_bold/italic/underline`).
1045                    in_run = false;
1046                }
1047                b"r" => {
1048                    in_run = false;
1049                    let bold = current_run_bold;
1050                    let italic = current_run_italic;
1051                    let underline = current_run_underline;
1052                    let underline_color = current_run_underline_color.take();
1053                    let color = current_run_color.take();
1054                    let font_size = current_run_font_size;
1055                    let font_family = current_run_font_family.take();
1056                    let highlight = current_run_highlight.take();
1057                    let strike = current_run_strike;
1058                    let vertical_align = current_run_vertical_align.take();
1059                    let small_caps = current_run_small_caps;
1060                    let all_caps = current_run_all_caps;
1061                    let shading_color = current_run_shading_color.take();
1062                    let text_border = current_run_text_border;
1063                    let character_spacing = current_run_character_spacing;
1064                    let vertical_position = current_run_vertical_position;
1065                    let hidden = current_run_hidden;
1066                    let style_id = current_run_style_id.take();
1067                    let hyperlink = current_hyperlink.clone();
1068                    let comment_ids = open_comment_ids.clone();
1069                    let bookmarks = open_bookmarks.clone();
1070                    let run = if let Some(drawing_content) = current_run_drawing.take() {
1071                        Run {
1072                            content: drawing_content,
1073                            bold,
1074                            italic,
1075                            underline,
1076                            underline_color,
1077                            color,
1078                            font_size,
1079                            font_family,
1080                            highlight,
1081                            strike,
1082                            vertical_align,
1083                            small_caps,
1084                            all_caps,
1085                            shading_color,
1086                            text_border,
1087                            character_spacing,
1088                            vertical_position,
1089                            hidden,
1090                            style_id,
1091                            hyperlink,
1092                            comment_ids,
1093                            bookmarks,
1094                        }
1095                    } else if let Some(note_content) = current_run_note.take() {
1096                        Run {
1097                            content: note_content,
1098                            bold,
1099                            italic,
1100                            underline,
1101                            underline_color,
1102                            color,
1103                            font_size,
1104                            font_family,
1105                            highlight,
1106                            strike,
1107                            vertical_align,
1108                            small_caps,
1109                            all_caps,
1110                            shading_color,
1111                            text_border,
1112                            character_spacing,
1113                            vertical_position,
1114                            hidden,
1115                            style_id,
1116                            hyperlink,
1117                            comment_ids,
1118                            bookmarks,
1119                        }
1120                    } else if let Some(break_content) = current_run_break.take() {
1121                        Run {
1122                            content: break_content,
1123                            bold,
1124                            italic,
1125                            underline,
1126                            underline_color,
1127                            color,
1128                            font_size,
1129                            font_family,
1130                            highlight,
1131                            strike,
1132                            vertical_align,
1133                            small_caps,
1134                            all_caps,
1135                            shading_color,
1136                            text_border,
1137                            character_spacing,
1138                            vertical_position,
1139                            hidden,
1140                            style_id,
1141                            hyperlink,
1142                            comment_ids,
1143                            bookmarks,
1144                        }
1145                    } else {
1146                        let text = current_run_text.take().unwrap_or_default();
1147                        Run {
1148                            content: RunContent::Text(text),
1149                            bold,
1150                            italic,
1151                            underline,
1152                            underline_color,
1153                            color,
1154                            font_size,
1155                            font_family,
1156                            highlight,
1157                            strike,
1158                            vertical_align,
1159                            small_caps,
1160                            all_caps,
1161                            shading_color,
1162                            text_border,
1163                            character_spacing,
1164                            vertical_position,
1165                            hidden,
1166                            style_id,
1167                            hyperlink,
1168                            comment_ids,
1169                            bookmarks,
1170                        }
1171                    };
1172                    paragraph.runs.push(run);
1173                }
1174                b"fldSimple" => {
1175                    if let Some(field) = current_field.take() {
1176                        paragraph.runs.push(Run {
1177                            content: RunContent::Field(field),
1178                            bold: current_run_bold,
1179                            italic: current_run_italic,
1180                            underline: current_run_underline,
1181                            underline_color: current_run_underline_color.take(),
1182                            color: current_run_color.take(),
1183                            font_size: current_run_font_size,
1184                            font_family: current_run_font_family.take(),
1185                            highlight: current_run_highlight.take(),
1186                            strike: current_run_strike,
1187                            vertical_align: current_run_vertical_align.take(),
1188                            small_caps: current_run_small_caps,
1189                            all_caps: current_run_all_caps,
1190                            shading_color: current_run_shading_color.take(),
1191                            text_border: current_run_text_border,
1192                            character_spacing: current_run_character_spacing,
1193                            vertical_position: current_run_vertical_position,
1194                            hidden: current_run_hidden,
1195                            style_id: current_run_style_id.take(),
1196                            hyperlink: current_hyperlink.clone(),
1197                            comment_ids: open_comment_ids.clone(),
1198                            bookmarks: open_bookmarks.clone(),
1199                        });
1200                    }
1201                    in_field = false;
1202                }
1203                b"hyperlink" => current_hyperlink = None,
1204                b"p" => break,
1205                _ => {}
1206            },
1207
1208            _ => {}
1209        }
1210    }
1211
1212    Ok(paragraph)
1213}
1214
1215/// Parses a single `<w:tbl>` table, assuming its `Start` event was just consumed by the caller.
1216/// Reads rows and cells, delegating each cell's paragraphs to [`parse_paragraph`] and each cell's
1217/// own nested `<w:tbl>` (if any — `CT_Tc` allows one, see [`TableCell`]'s doc comment) to a
1218/// recursive call of this same function, so arbitrarily nested tables round-trip; everything else
1219/// (`tblPr`, `tblGrid`, cell/row properties) is ignored for now. Stops at the matching `</w:tbl>`.
1220fn parse_table(reader: &mut Reader<'_>, resolver: &ImageResolver) -> Result<Table> {
1221    let mut table = Table::new();
1222    let mut current_row: Option<TableRow> = None;
1223    let mut current_cell: Option<TableCell> = None;
1224
1225    // Disambiguation flags, the same pattern as `parse_paragraph`'s `in_run`/`parse_style`'s
1226    // `in_paragraph_properties`: several element names here are reused across more than one parent
1227    // in WordprocessingML (`top`/`left`/`bottom`/`right` mean a table border side in
1228    // `w:tblBorders`, a cell border side in `w:tcBorders`, OR a cell margin side in `w:tcMar`; `jc`
1229    // means table alignment in `w:tblPr` but is also (unmodeled) valid in `w:trPr`) — a paragraph's
1230    // own `w:pPr/w:jc`/`w:pBdr` never reach this loop at all, since `p` is fully delegated to
1231    // `parse_paragraph` before returning here.
1232    let mut in_table_properties = false;
1233    let mut in_tbl_borders = false;
1234    let mut in_tc_borders = false;
1235    let mut in_tc_mar = false;
1236
1237    loop {
1238        match reader.read_event()? {
1239            Event::Eof => break,
1240
1241            Event::Start(start) => match start.local_name().as_ref() {
1242                b"tblPr" => in_table_properties = true,
1243                b"tblBorders" => in_tbl_borders = true,
1244                b"tcBorders" => in_tc_borders = true,
1245                b"tcMar" => in_tc_mar = true,
1246                b"tr" => current_row = Some(TableRow::new()),
1247                b"tc" => current_cell = Some(TableCell::new()),
1248                b"p" => {
1249                    let paragraph = parse_paragraph(reader, resolver)?;
1250                    if let Some(cell) = current_cell.as_mut() {
1251                        cell.blocks.push(Block::Paragraph(paragraph));
1252                    }
1253                }
1254                // A nested `<w:tbl>` inside a cell (`CT_Tc` allows one, see [`TableCell`]'s doc
1255                // comment) — recurse, since this function's own precondition ("Start` event already
1256                // consumed") is exactly what just happened here.
1257                b"tbl" => {
1258                    let nested_table = parse_table(reader, resolver)?;
1259                    if let Some(cell) = current_cell.as_mut() {
1260                        cell.blocks.push(Block::Table(nested_table));
1261                    }
1262                }
1263                b"tblStyle" => {
1264                    table.style_id = raw_attribute(&start, b"val");
1265                }
1266                b"jc" if in_table_properties => {
1267                    table.alignment = alignment_from_attribute(&start);
1268                }
1269                b"tblInd" => {
1270                    table.indent_twips =
1271                        raw_attribute(&start, b"w").and_then(|value| value.parse::<u32>().ok());
1272                }
1273                b"top" | b"left" | b"bottom" | b"right" => {
1274                    apply_table_or_cell_side(
1275                        &start,
1276                        start.local_name().as_ref(),
1277                        in_tbl_borders,
1278                        in_table_properties,
1279                        in_tc_borders,
1280                        in_tc_mar,
1281                        &mut table,
1282                        current_cell.as_mut(),
1283                    );
1284                }
1285                b"shd" => {
1286                    if let Some(cell) = current_cell.as_mut() {
1287                        cell.shading_color = raw_attribute(&start, b"fill");
1288                    }
1289                }
1290                b"textDirection" => {
1291                    if let Some(cell) = current_cell.as_mut() {
1292                        cell.text_direction = raw_attribute(&start, b"val")
1293                            .and_then(|value| TextDirection::from_attribute_value(&value));
1294                    }
1295                }
1296                b"vAlign" => {
1297                    if let Some(cell) = current_cell.as_mut() {
1298                        cell.vertical_alignment = raw_attribute(&start, b"val")
1299                            .and_then(|value| CellVerticalAlign::from_attribute_value(&value));
1300                    }
1301                }
1302                b"cantSplit" => {
1303                    if let Some(row) = current_row.as_mut() {
1304                        row.cant_split = true;
1305                    }
1306                }
1307                b"trHeight" => {
1308                    if let Some(row) = current_row.as_mut() {
1309                        row.height_twips = raw_attribute(&start, b"val")
1310                            .and_then(|value| value.parse::<u32>().ok());
1311                    }
1312                }
1313                b"tblHeader" => {
1314                    if let Some(row) = current_row.as_mut() {
1315                        row.repeat_as_header_row = true;
1316                    }
1317                }
1318                b"gridSpan" => {
1319                    if let Some(cell) = current_cell.as_mut() {
1320                        cell.horizontal_span = raw_attribute(&start, b"val")
1321                            .and_then(|value| value.parse::<u32>().ok());
1322                    }
1323                }
1324                b"vMerge" => {
1325                    if let Some(cell) = current_cell.as_mut() {
1326                        cell.vertical_merge = Some(vertical_merge_attribute(&start));
1327                    }
1328                }
1329                // `w:gridCol` is `CT_TblGridCol`, attributes-only, so this always arrives as
1330                // `Event::Empty` in practice — handled here too anyway, same defensive-coverage
1331                // policy as the other arms in this match. See `Table.column_widths`'s doc comment
1332                // for the round-trip nuance this creates: a table WITHOUT explicit widths still
1333                // gets `column_widths` populated on read, since `w:tblGrid` always carries concrete
1334                // `w` values in this crate's own output — there is no way to tell "explicitly set
1335                // to the default" apart from "left unset" once written.
1336                b"gridCol" => {
1337                    if let Some(width) =
1338                        raw_attribute(&start, b"w").and_then(|value| value.parse::<u32>().ok())
1339                    {
1340                        table.column_widths.push(width);
1341                    }
1342                }
1343                _ => {}
1344            },
1345
1346            Event::Empty(start) => match start.local_name().as_ref() {
1347                b"p" => {
1348                    if let Some(cell) = current_cell.as_mut() {
1349                        cell.blocks.push(Block::Paragraph(Paragraph::new()));
1350                    }
1351                }
1352                b"tblStyle" => {
1353                    table.style_id = raw_attribute(&start, b"val");
1354                }
1355                b"jc" if in_table_properties => {
1356                    table.alignment = alignment_from_attribute(&start);
1357                }
1358                b"tblInd" => {
1359                    table.indent_twips =
1360                        raw_attribute(&start, b"w").and_then(|value| value.parse::<u32>().ok());
1361                }
1362                b"top" | b"left" | b"bottom" | b"right" => {
1363                    apply_table_or_cell_side(
1364                        &start,
1365                        start.local_name().as_ref(),
1366                        in_tbl_borders,
1367                        in_table_properties,
1368                        in_tc_borders,
1369                        in_tc_mar,
1370                        &mut table,
1371                        current_cell.as_mut(),
1372                    );
1373                }
1374                b"shd" => {
1375                    if let Some(cell) = current_cell.as_mut() {
1376                        cell.shading_color = raw_attribute(&start, b"fill");
1377                    }
1378                }
1379                b"textDirection" => {
1380                    if let Some(cell) = current_cell.as_mut() {
1381                        cell.text_direction = raw_attribute(&start, b"val")
1382                            .and_then(|value| TextDirection::from_attribute_value(&value));
1383                    }
1384                }
1385                b"vAlign" => {
1386                    if let Some(cell) = current_cell.as_mut() {
1387                        cell.vertical_alignment = raw_attribute(&start, b"val")
1388                            .and_then(|value| CellVerticalAlign::from_attribute_value(&value));
1389                    }
1390                }
1391                b"cantSplit" => {
1392                    if let Some(row) = current_row.as_mut() {
1393                        row.cant_split = true;
1394                    }
1395                }
1396                b"trHeight" => {
1397                    if let Some(row) = current_row.as_mut() {
1398                        row.height_twips = raw_attribute(&start, b"val")
1399                            .and_then(|value| value.parse::<u32>().ok());
1400                    }
1401                }
1402                b"tblHeader" => {
1403                    if let Some(row) = current_row.as_mut() {
1404                        row.repeat_as_header_row = true;
1405                    }
1406                }
1407                b"gridSpan" => {
1408                    if let Some(cell) = current_cell.as_mut() {
1409                        cell.horizontal_span = raw_attribute(&start, b"val")
1410                            .and_then(|value| value.parse::<u32>().ok());
1411                    }
1412                }
1413                b"vMerge" => {
1414                    if let Some(cell) = current_cell.as_mut() {
1415                        cell.vertical_merge = Some(vertical_merge_attribute(&start));
1416                    }
1417                }
1418                b"gridCol" => {
1419                    if let Some(width) =
1420                        raw_attribute(&start, b"w").and_then(|value| value.parse::<u32>().ok())
1421                    {
1422                        table.column_widths.push(width);
1423                    }
1424                }
1425                _ => {}
1426            },
1427
1428            Event::End(end) => match end.local_name().as_ref() {
1429                b"tblPr" => in_table_properties = false,
1430                b"tblBorders" => in_tbl_borders = false,
1431                b"tcBorders" => in_tc_borders = false,
1432                b"tcMar" => in_tc_mar = false,
1433                b"tc" => {
1434                    if let (Some(cell), Some(row)) = (current_cell.take(), current_row.as_mut()) {
1435                        row.cells.push(cell);
1436                    }
1437                }
1438                b"tr" => {
1439                    if let Some(row) = current_row.take() {
1440                        table.rows.push(row);
1441                    }
1442                }
1443                b"tbl" => break,
1444                _ => {}
1445            },
1446
1447            _ => {}
1448        }
1449    }
1450
1451    Ok(table)
1452}
1453
1454/// Applies a `top`/`left`/`bottom`/`right` element to whichever of
1455/// `Table.border_color`/`TableCell.border`/`TableCell`'s margin fields it actually belongs to,
1456/// based on which container it was found in (`in_tbl_borders`/`in_tc_borders`/`in_tc_mar`, mutually
1457/// exclusive since these elements never nest inside one another) — see `parse_table`'s own doc
1458/// comment on its disambiguation flags for why this dispatch is needed at all. `tag_name` is the
1459/// raw local name (`b"top"`/`b"left"`/`b"bottom"`/ `b"right"`), used to pick the right margin field
1460/// when `in_tc_mar`.
1461#[allow(clippy::too_many_arguments)]
1462fn apply_table_or_cell_side(
1463    start: &BytesStart,
1464    tag_name: &[u8],
1465    in_tbl_borders: bool,
1466    in_table_properties: bool,
1467    in_tc_borders: bool,
1468    in_tc_mar: bool,
1469    table: &mut Table,
1470    current_cell: Option<&mut TableCell>,
1471) {
1472    if in_tc_mar {
1473        if let Some(cell) = current_cell {
1474            let value = raw_attribute(start, b"w").and_then(|value| value.parse::<u32>().ok());
1475            match tag_name {
1476                b"top" => cell.margin_top_twips = value,
1477                b"left" => cell.margin_left_twips = value,
1478                b"bottom" => cell.margin_bottom_twips = value,
1479                b"right" => cell.margin_right_twips = value,
1480                _ => {}
1481            }
1482        }
1483    } else if in_tc_borders {
1484        if let Some(cell) = current_cell {
1485            cell.border = true;
1486        }
1487    } else if in_tbl_borders && in_table_properties {
1488        if let Some(color) = raw_attribute(start, b"color") {
1489            if color != "auto" {
1490                table.border_color = Some(color);
1491            }
1492        }
1493    }
1494}
1495
1496/// Parses a single `<w:drawing>` element, assuming its `Start` event was just consumed by the
1497/// caller. Reads `wp:extent` (size), `wp:docPr` (description/name) and either `a:blip`'s `r:embed`
1498/// (resolved to actual image bytes via [`ImageResolver::resolve`]) or `c:chart`'s `r:id` (resolved
1499/// to a whole `chart::ChartSpace` via [`ImageResolver::resolve_chart`],), ignoring everything else
1500/// (position/shape details, which we always write as a fixed rectangle anyway, for a picture).
1501/// Returns `None` if the drawing has neither a resolvable `a:blip` nor a resolvable `c:chart` (e.g.
1502/// an empty/broken reference), rather than failing the whole document. Stops at the matching
1503/// `</w:drawing>`.
1504fn parse_drawing(reader: &mut Reader<'_>, resolver: &ImageResolver) -> Result<Option<RunContent>> {
1505    let mut width_emu: i64 = 0;
1506    let mut height_emu: i64 = 0;
1507    let mut description = String::new();
1508    let mut image_relationship_id: Option<String> = None;
1509    let mut chart_relationship_id: Option<String> = None;
1510    // `pic:spPr`'s own geometry/fill/line, if any (its `a:xfrm` is never read back —
1511    // `width_emu`/`height_emu` from `wp:extent` above stay authoritative, matching `writer.rs`'s
1512    // own posture of never consulting `shape_properties.transform`).
1513    let mut shape_properties: Option<ShapeProperties> = None;
1514
1515    loop {
1516        match reader.read_event()? {
1517            Event::Eof => break,
1518
1519            // Handled before the flatter `Start | Empty` match below since it needs the `Reader`
1520            // itself (to recurse via `drawing::read_shape_properties`), not just the start tag's
1521            // attributes.
1522            Event::Start(start) if start.local_name().as_ref() == b"spPr" => {
1523                shape_properties = Some(drawing::read_shape_properties(reader)?);
1524            }
1525            Event::Empty(start) if start.local_name().as_ref() == b"spPr" => {
1526                shape_properties = Some(ShapeProperties::new());
1527            }
1528
1529            Event::Start(start) | Event::Empty(start) => match start.local_name().as_ref() {
1530                b"extent" => {
1531                    width_emu = raw_attribute(&start, b"cx")
1532                        .and_then(|value| value.parse().ok())
1533                        .unwrap_or(0);
1534                    height_emu = raw_attribute(&start, b"cy")
1535                        .and_then(|value| value.parse().ok())
1536                        .unwrap_or(0);
1537                }
1538                b"docPr" => description = raw_attribute(&start, b"descr").unwrap_or_default(),
1539                b"blip" => image_relationship_id = raw_attribute(&start, b"embed"),
1540                // `<c:chart r:id="..">`, a sibling possibility to `a:blip` inside the same
1541                // `a:graphicData` (never both at once, since `write_embedded_chart` never writes a
1542                // `pic:pic`/`a:blip` alongside it).
1543                b"chart" => chart_relationship_id = raw_attribute(&start, b"id"),
1544                _ => {}
1545            },
1546
1547            Event::End(end) if end.local_name().as_ref() == b"drawing" => break,
1548
1549            _ => {}
1550        }
1551    }
1552
1553    if let Some(id) = image_relationship_id {
1554        let mut image = resolver.resolve(&id, width_emu, height_emu, description)?;
1555        if let Some(mut shape_properties) = shape_properties {
1556            // `writer.rs::write_drawing` always writes its own `<a:xfrm>` unconditionally (derived
1557            // from `width_emu`/`height_emu`, already captured above), regardless of what the
1558            // caller's own `shape_properties.transform` held (which is never consulted on write —
1559            // see that function's doc comment) — so whatever `drawing::read_shape_properties`
1560            // picked up for `transform` here is always that fixed derived value, never something
1561            // the caller set, and must never leak into the round-tripped value.
1562            shape_properties.transform = None;
1563
1564            // Similarly, `write_drawing` always writes *some* geometry: `shape_properties`'s own
1565            // (when `Some`), or, when `None`, a fixed default `<a:prstGeom prst="rect">`. That
1566            // fixed default parses back as exactly `Geometry::Preset(PresetShape::Rectangle)` —
1567            // indistinguishable, on the wire, from a deliberately-set plain rectangle — so it's
1568            // normalized back to `None` here too, the same "ambiguous fixed default treated as
1569            // nothing set" reasoning applied to the whole-`shape_properties` case below.
1570            if matches!(
1571                shape_properties.geometry,
1572                Some(Geometry::Preset(PresetShape::Rectangle))
1573            ) {
1574                shape_properties.geometry = None;
1575            }
1576
1577            // `effects` must be included in this check: an image whose only `shape_properties`
1578            // content is `effects` (no fill/line, geometry normalized away above) would otherwise
1579            // be wrongly treated as "nothing set at all" and silently discarded here, even though
1580            // `effects` was just correctly parsed.
1581            let is_fixed_default = shape_properties.geometry.is_none()
1582                && shape_properties.fill.is_none()
1583                && shape_properties.line.is_none()
1584                && shape_properties.effects.is_none();
1585            if !is_fixed_default {
1586                image = image.with_shape_properties(shape_properties);
1587            }
1588        }
1589        return Ok(Some(RunContent::Image(Box::new(image))));
1590    }
1591
1592    if let Some(id) = chart_relationship_id {
1593        let chart_space = resolver.resolve_chart(&id)?;
1594        let chart = EmbeddedChart::new(chart_space, width_emu, height_emu).with_name(description);
1595        return Ok(Some(RunContent::Chart(Box::new(chart))));
1596    }
1597
1598    Ok(None)
1599}
1600
1601/// Reads the `w:val` attribute of a `CT_OnOff` element (e.g. `w:b`, `w:i`): absent means "on"
1602/// (`<w:b/>` alone means bold), otherwise the value is interpreted as an XML boolean (`true`/`1` =>
1603/// on, `false`/`0` => off).
1604fn on_off_attribute(start: &BytesStart) -> bool {
1605    match raw_attribute(start, b"val") {
1606        None => true,
1607        Some(value) => matches!(value.as_str(), "true" | "1" | "on"),
1608    }
1609}
1610
1611/// Reads the `w:val` attribute of a `w:u` (`CT_Underline`) element into an [`UnderlineStyle`], or
1612/// `None` for `"none"`, an unrecognized value, or a missing `val` (schema-optional, but real-world
1613/// output always includes it when `w:u` is present at all — falls back to
1614/// [`UnderlineStyle::Single`] to match this reader's previous on/off-only behavior for such a run).
1615fn underline_attribute(start: &BytesStart) -> Option<UnderlineStyle> {
1616    match raw_attribute(start, b"val") {
1617        None => Some(UnderlineStyle::Single),
1618        Some(value) => UnderlineStyle::from_attribute_value(&value),
1619    }
1620}
1621
1622/// Reads the `w:val` attribute of a `w:vMerge` (`CT_VMerge`) element into a [`VerticalMerge`]. Per
1623/// ECMA-376's own documented default: if `val` is omitted, the value is assumed to be `continue` —
1624/// unlike `underline_attribute`'s fallback (a pragmatic choice to preserve old behavior), this
1625/// default comes directly from the spec text for `CT_VMerge`, not from a guess. An unrecognized
1626/// value also falls back to `Continue`, the least surprising choice (it keeps the cell as part of
1627/// whatever merge is already open rather than silently ending it).
1628fn vertical_merge_attribute(start: &BytesStart) -> VerticalMerge {
1629    match raw_attribute(start, b"val").as_deref() {
1630        Some("restart") => VerticalMerge::Restart,
1631        _ => VerticalMerge::Continue,
1632    }
1633}
1634
1635/// Reads the `w:val` attribute of a `w:jc` (`CT_Jc`) element and maps it to an [`Alignment`].
1636/// Unrecognized values (the more exotic `ST_Jc` members like `distribute`, or the
1637/// Strict-conformance `start`/`end` pair) are ignored rather than causing an error, since alignment
1638/// is a lossy, best-effort read for now.
1639fn alignment_from_attribute(start: &BytesStart) -> Option<Alignment> {
1640    match raw_attribute(start, b"val")?.as_str() {
1641        "left" | "start" => Some(Alignment::Left),
1642        "center" => Some(Alignment::Center),
1643        "right" | "end" => Some(Alignment::Right),
1644        "both" => Some(Alignment::Justify),
1645        _ => None,
1646    }
1647}
1648
1649/// Reads the decoded, XML-entity-unescaped value of an attribute by its local (non-prefixed) name,
1650/// or `None` if it isn't present (or isn't valid UTF-8 — see [`xml_core::decode_attribute_value`]'s
1651/// doc comment).
1652///
1653/// Used for both simple keyword-only attributes (on/off flags, alignment values,
1654/// `ST_NumberFormat`..) where unescaping never actually matters, and genuinely free-form text
1655/// (hyperlink URLs, image descriptions, style names..) where it does — a URL containing a literal
1656/// `&` (e.g. `?a=1&b=2`, extremely common) or a description containing an apostrophe would
1657/// otherwise silently corrupt on a round trip, since `BytesStart:: push_attribute` escapes on
1658/// write. This one helper covers both cases rather than needing two.
1659fn raw_attribute(start: &BytesStart, name: &[u8]) -> Option<String> {
1660    start
1661        .attributes()
1662        .flatten()
1663        .find(|attribute| attribute.key.local_name().as_ref() == name)
1664        .and_then(|attribute| {
1665            xml_core::decode_attribute_value(attribute.value.as_ref())
1666                .ok()
1667                .flatten()
1668        })
1669}
1670
1671/// Maps a `w:fldSimple`'s `w:instr` (`CT_SimpleField`'s instruction text) to a [`Field`], or `None`
1672/// if it isn't one of the two field codes this crate supports. Real Word instructions often carry
1673/// extra switches (e.g. `"PAGE \* MERGEFORMAT"`), so only the first whitespace-separated token is
1674/// matched, case-insensitively (Word treats field codes as case-insensitive). Matched as whole
1675/// tokens, not substrings — `"PAGE"` must not match inside `"NUMPAGES"`.
1676fn field_from_instruction(instr: Option<&str>) -> Option<Field> {
1677    let first_token = instr?.split_whitespace().next()?;
1678    if first_token.eq_ignore_ascii_case("PAGE") {
1679        Some(Field::PageNumber)
1680    } else if first_token.eq_ignore_ascii_case("NUMPAGES") {
1681        Some(Field::TotalPages)
1682    } else {
1683        None
1684    }
1685}
1686
1687/// Maps a `<w:hyperlink>` start tag's attributes to a [`Hyperlink`]. `r:id` (an external
1688/// relationship, resolved against `resolver`'s `part_relationships`) takes priority over `w:anchor`
1689/// if somehow both are present — `CT_Hyperlink`'s schema allows either or both, but a real document
1690/// only ever sets one. Returns `None` if `r:id` is set but doesn't resolve to a declared
1691/// relationship (a malformed document) or if neither attribute is present at all — the hyperlink is
1692/// silently dropped in either case, its nested run(s) still kept as plain runs (same policy as an
1693/// unsupported field).
1694fn hyperlink_from_start(start: &BytesStart, resolver: &ImageResolver) -> Option<Hyperlink> {
1695    if let Some(relationship_id) = raw_attribute(start, b"id") {
1696        return resolver
1697            .part_relationships
1698            .by_id(&relationship_id)
1699            .map(|relationship| Hyperlink::External(relationship.target.clone()));
1700    }
1701    raw_attribute(start, b"anchor").map(Hyperlink::Internal)
1702}
1703
1704/// Parses `word/styles.xml` (`CT_Styles`) into a flat list of [`Style`]s. Only `<w:style>` elements
1705/// whose `w:type` is `paragraph` or `character` are kept (table/numbering styles, and
1706/// `docDefaults`/`latentStyles`, are ignored — see `Style`'s doc comment for the full list of what
1707/// isn't modeled).
1708fn parse_styles_xml(xml: &str) -> Result<Vec<Style>> {
1709    let mut styles = Vec::new();
1710    let mut reader = Reader::from_xml_str(xml);
1711
1712    loop {
1713        match reader.read_event()? {
1714            Event::Eof => break,
1715            Event::Start(start) if start.local_name().as_ref() == b"style" => {
1716                if let Some(style) = parse_style(&mut reader, &start)? {
1717                    styles.push(style);
1718                }
1719            }
1720            _ => {}
1721        }
1722    }
1723
1724    Ok(styles)
1725}
1726
1727/// Parses a single `<w:style>` element, assuming its `Start` event was just consumed by the caller
1728/// (`start` is that same event, kept around to read its `w:type`/`w:styleId` attributes). Reads
1729/// `w:name`, `w:basedOn`, and — from `w:pPr`/`w:rPr` — alignment, bold/italic/underline, color,
1730/// font size, font family and highlight, stopping at the matching `</w:style>`. Returns `None` if
1731/// `w:type` isn't `paragraph` or `character` (the only two [`StyleKind`]s modeled).
1732fn parse_style(reader: &mut Reader<'_>, start: &BytesStart) -> Result<Option<Style>> {
1733    let kind = match raw_attribute(start, b"type").as_deref() {
1734        Some("paragraph") => StyleKind::Paragraph,
1735        Some("character") => StyleKind::Character,
1736        _ => {
1737            skip_to_end(reader, b"style")?;
1738            return Ok(None);
1739        }
1740    };
1741    let id = raw_attribute(start, b"styleId").unwrap_or_default();
1742
1743    let mut name = String::new();
1744    let mut based_on: Option<String> = None;
1745    let mut alignment: Option<Alignment> = None;
1746    let mut bold = false;
1747    let mut italic = false;
1748    let mut underline: Option<UnderlineStyle> = None;
1749    let mut underline_color: Option<String> = None;
1750    let mut color: Option<String> = None;
1751    let mut font_size: Option<u16> = None;
1752    let mut font_family: Option<String> = None;
1753    let mut highlight: Option<Highlight> = None;
1754    let mut strike = false;
1755    let mut vertical_align: Option<VerticalAlign> = None;
1756    let mut small_caps = false;
1757    let mut all_caps = false;
1758    let mut shading_color: Option<String> = None;
1759    let mut text_border = false;
1760    let mut character_spacing: Option<i16> = None;
1761    let mut vertical_position: Option<i16> = None;
1762    let mut hidden = false;
1763    let mut line_spacing: Option<u32> = None;
1764    let mut space_before: Option<u32> = None;
1765    let mut space_after: Option<u32> = None;
1766    let mut paragraph_shading_color: Option<String> = None;
1767    let mut paragraph_border = false;
1768    let mut keep_with_next = false;
1769    let mut keep_lines_together = false;
1770    let mut page_break_before = false;
1771    let mut tabs: Vec<TabStop> = Vec::new();
1772    let mut contextual_spacing = false;
1773    // Whether the loop is currently inside `<w:pPr>` rather than `<w:rPr>` — both are direct
1774    // children of `<w:style>`, `w:pPr` always fully consumed before `w:rPr` starts (`CT_Style`'s
1775    // own sequence), mirroring `parse_paragraph`'s `in_run` flag and needed for the same reason:
1776    // `w:shd`/`w:spacing` are shared element names with different meanings depending on which one
1777    // they're nested in. Flipped back to `false` at `w:rPr`'s own start (no need to watch for
1778    // `w:pPr`'s end specifically, since `w:rPr` never appears before it).
1779    let mut in_paragraph_properties = false;
1780
1781    loop {
1782        match reader.read_event()? {
1783            Event::Eof => break,
1784
1785            Event::Start(start) | Event::Empty(start) => match start.local_name().as_ref() {
1786                b"name" => name = raw_attribute(&start, b"val").unwrap_or_default(),
1787                b"basedOn" => based_on = raw_attribute(&start, b"val"),
1788                b"pPr" => in_paragraph_properties = true,
1789                b"rPr" => in_paragraph_properties = false,
1790                b"pBdr" => paragraph_border = true,
1791                b"keepNext" => keep_with_next = true,
1792                b"keepLines" => keep_lines_together = true,
1793                b"pageBreakBefore" => page_break_before = true,
1794                b"contextualSpacing" => contextual_spacing = true,
1795                b"tab" => {
1796                    if let Some(position) =
1797                        raw_attribute(&start, b"pos").and_then(|value| value.parse::<i32>().ok())
1798                    {
1799                        let mut tab = TabStop::new(position);
1800                        if let Some(alignment) = raw_attribute(&start, b"val")
1801                            .and_then(|value| TabStopAlignment::from_attribute_value(&value))
1802                        {
1803                            tab = tab.with_alignment(alignment);
1804                        }
1805                        if let Some(leader) = raw_attribute(&start, b"leader")
1806                            .and_then(|value| TabLeader::from_attribute_value(&value))
1807                        {
1808                            tab = tab.with_leader(leader);
1809                        }
1810                        tabs.push(tab);
1811                    }
1812                }
1813                b"jc" => alignment = alignment_from_attribute(&start),
1814                b"b" => bold = on_off_attribute(&start),
1815                b"i" => italic = on_off_attribute(&start),
1816                b"u" => {
1817                    underline = underline_attribute(&start);
1818                    underline_color = raw_attribute(&start, b"color");
1819                }
1820                b"color" => color = raw_attribute(&start, b"val"),
1821                b"sz" => {
1822                    font_size = raw_attribute(&start, b"val")
1823                        .and_then(|value| value.parse::<u16>().ok())
1824                        .map(|half_points| half_points / 2)
1825                }
1826                b"rFonts" => {
1827                    font_family =
1828                        raw_attribute(&start, b"ascii").or_else(|| raw_attribute(&start, b"hAnsi"))
1829                }
1830                b"highlight" => {
1831                    highlight = raw_attribute(&start, b"val")
1832                        .and_then(|value| Highlight::from_attribute_value(&value))
1833                }
1834                b"strike" => strike = on_off_attribute(&start),
1835                b"vertAlign" => {
1836                    vertical_align = raw_attribute(&start, b"val")
1837                        .and_then(|value| VerticalAlign::from_attribute_value(&value))
1838                }
1839                b"smallCaps" => small_caps = on_off_attribute(&start),
1840                b"caps" => all_caps = on_off_attribute(&start),
1841                b"shd" => {
1842                    if in_paragraph_properties {
1843                        paragraph_shading_color = raw_attribute(&start, b"fill");
1844                    } else {
1845                        shading_color = raw_attribute(&start, b"fill");
1846                    }
1847                }
1848                b"bdr" => text_border = true,
1849                b"spacing" => {
1850                    if in_paragraph_properties {
1851                        space_before = raw_attribute(&start, b"before")
1852                            .and_then(|value| value.parse::<u32>().ok());
1853                        space_after = raw_attribute(&start, b"after")
1854                            .and_then(|value| value.parse::<u32>().ok());
1855                        line_spacing = raw_attribute(&start, b"line")
1856                            .and_then(|value| value.parse::<u32>().ok());
1857                    } else {
1858                        character_spacing = raw_attribute(&start, b"val")
1859                            .and_then(|value| value.parse::<i16>().ok());
1860                    }
1861                }
1862                b"position" => {
1863                    vertical_position =
1864                        raw_attribute(&start, b"val").and_then(|value| value.parse::<i16>().ok())
1865                }
1866                b"vanish" => hidden = true,
1867                _ => {}
1868            },
1869
1870            Event::End(end) if end.local_name().as_ref() == b"style" => break,
1871
1872            _ => {}
1873        }
1874    }
1875
1876    let mut style = Style::new(id, name, kind)
1877        .with_bold(bold)
1878        .with_italic(italic);
1879    if let Some(underline) = underline {
1880        style = style.with_underline(underline);
1881    }
1882    if let Some(underline_color) = underline_color {
1883        style = style.with_underline_color(underline_color);
1884    }
1885    if let Some(based_on) = based_on {
1886        style = style.with_based_on(based_on);
1887    }
1888    if let Some(alignment) = alignment {
1889        style = style.with_alignment(alignment);
1890    }
1891    if let Some(color) = color {
1892        style = style.with_color(color);
1893    }
1894    if let Some(font_size) = font_size {
1895        style = style.with_font_size(font_size);
1896    }
1897    if let Some(font_family) = font_family {
1898        style = style.with_font_family(font_family);
1899    }
1900    if let Some(highlight) = highlight {
1901        style = style.with_highlight(highlight);
1902    }
1903    style = style.with_strike(strike);
1904    if let Some(vertical_align) = vertical_align {
1905        style = style.with_vertical_align(vertical_align);
1906    }
1907    style = style
1908        .with_small_caps(small_caps)
1909        .with_all_caps(all_caps)
1910        .with_text_border(text_border)
1911        .with_hidden(hidden);
1912    if let Some(shading_color) = shading_color {
1913        style = style.with_shading_color(shading_color);
1914    }
1915    if let Some(character_spacing) = character_spacing {
1916        style = style.with_character_spacing(character_spacing);
1917    }
1918    if let Some(vertical_position) = vertical_position {
1919        style = style.with_vertical_position(vertical_position);
1920    }
1921    style = style.with_paragraph_border(paragraph_border);
1922    if let Some(paragraph_shading_color) = paragraph_shading_color {
1923        style = style.with_paragraph_shading_color(paragraph_shading_color);
1924    }
1925    if let Some(line_spacing) = line_spacing {
1926        style = style.with_line_spacing(line_spacing);
1927    }
1928    if let Some(space_before) = space_before {
1929        style = style.with_space_before(space_before);
1930    }
1931    if let Some(space_after) = space_after {
1932        style = style.with_space_after(space_after);
1933    }
1934    style = style
1935        .with_keep_with_next(keep_with_next)
1936        .with_keep_lines_together(keep_lines_together)
1937        .with_page_break_before(page_break_before)
1938        .with_contextual_spacing(contextual_spacing);
1939    for tab in tabs {
1940        style = style.with_tab(tab);
1941    }
1942
1943    Ok(Some(style))
1944}
1945
1946/// Consumes events up to and including the matching end tag for an already-open element named
1947/// `local_name`, without interpreting any of its content — used by `parse_style` to skip over a
1948/// `<w:style>` whose `w:type` isn't one we model (e.g. `table`, `numbering`).
1949fn skip_to_end(reader: &mut Reader<'_>, local_name: &[u8]) -> Result<()> {
1950    loop {
1951        match reader.read_event()? {
1952            Event::Eof => break,
1953            Event::End(end) if end.local_name().as_ref() == local_name => break,
1954            _ => {}
1955        }
1956    }
1957    Ok(())
1958}
1959
1960/// Parses `word/numbering.xml` (`CT_Numbering`) into a flat list of [`NumberingDefinition`]s, one
1961/// per `<w:num>` element (see `NumberingDefinition`'s doc comment for why `w:abstractNum` isn't
1962/// modeled as its own separate type). `CT_Numbering`'s own sequence (`numPicBullet*, abstractNum*,
1963/// num*`) guarantees every `w:abstractNum` is read before any `w:num` that might reference it, so
1964/// this is a single forward pass: abstractNums are collected into `abstract_nums` (keyed by
1965/// `abstractNumId`) as they're read, then each `w:num` is resolved against that map immediately. A
1966/// `w:num` with no (or an unparsable) `numId`, or referencing an `abstractNumId` that wasn't
1967/// declared (either way, a malformed document), is skipped/given an empty `levels` list rather than
1968/// failing the whole document — same policy as elsewhere in this reader.
1969fn parse_numbering_xml(xml: &str) -> Result<Vec<NumberingDefinition>> {
1970    let mut abstract_nums: HashMap<u32, Vec<ListLevel>> = HashMap::new();
1971    let mut definitions = Vec::new();
1972    let mut reader = Reader::from_xml_str(xml);
1973
1974    loop {
1975        match reader.read_event()? {
1976            Event::Eof => break,
1977
1978            Event::Start(start) => match start.local_name().as_ref() {
1979                b"abstractNum" => match raw_attribute(&start, b"abstractNumId")
1980                    .and_then(|value| value.parse().ok())
1981                {
1982                    Some(id) => {
1983                        let levels = parse_abstract_num(&mut reader)?;
1984                        abstract_nums.insert(id, levels);
1985                    }
1986                    None => skip_to_end(&mut reader, b"abstractNum")?,
1987                },
1988                b"num" => {
1989                    match raw_attribute(&start, b"numId").and_then(|value| value.parse().ok()) {
1990                        Some(num_id) => {
1991                            let abstract_num_id = parse_num_abstract_ref(&mut reader)?;
1992                            let levels = abstract_num_id
1993                                .and_then(|id| abstract_nums.get(&id).cloned())
1994                                .unwrap_or_default();
1995                            definitions.push(NumberingDefinition::new(num_id, levels));
1996                        }
1997                        None => skip_to_end(&mut reader, b"num")?,
1998                    }
1999                }
2000                _ => {}
2001            },
2002
2003            _ => {}
2004        }
2005    }
2006
2007    Ok(definitions)
2008}
2009
2010/// Parses a single `<w:abstractNum>` element's `<w:lvl>` children into [`ListLevel`]s, assuming its
2011/// `Start` event was just consumed by the caller. Levels are pushed in the order encountered — real
2012/// documents (and this crate's own writer) always write them in ascending `ilvl` order with no
2013/// gaps, so the resulting `Vec`'s index matches `ilvl` without needing to read that attribute at
2014/// all. Stops at the matching `</w:abstractNum>`.
2015fn parse_abstract_num(reader: &mut Reader<'_>) -> Result<Vec<ListLevel>> {
2016    let mut levels = Vec::new();
2017
2018    loop {
2019        match reader.read_event()? {
2020            Event::Eof => break,
2021            Event::Start(start) if start.local_name().as_ref() == b"lvl" => {
2022                levels.push(parse_lvl(reader)?)
2023            }
2024            Event::End(end) if end.local_name().as_ref() == b"abstractNum" => break,
2025            _ => {}
2026        }
2027    }
2028
2029    Ok(levels)
2030}
2031
2032/// Parses a single `<w:lvl>` element, assuming its `Start` event was just consumed by the caller.
2033/// Reads `w:numFmt`, `w:lvlText` and — from the nested `w:pPr/w:ind` — the level's indentation,
2034/// ignoring everything else (`start`, `lvlJc`, `pStyle`, `rPr`. — see [`ListLevel`]'s doc comment
2035/// for the full list of what isn't modeled). Stops at the matching `</w:lvl>`.
2036fn parse_lvl(reader: &mut Reader<'_>) -> Result<ListLevel> {
2037    let mut format = NumberFormat::Decimal;
2038    let mut text = String::new();
2039    let mut indent_twips: i32 = 0;
2040    let mut hanging_twips: i32 = 0;
2041
2042    loop {
2043        match reader.read_event()? {
2044            Event::Eof => break,
2045
2046            Event::Start(start) | Event::Empty(start) => match start.local_name().as_ref() {
2047                b"numFmt" => {
2048                    format = number_format_from_attribute(&start).unwrap_or(NumberFormat::Decimal)
2049                }
2050                b"lvlText" => text = raw_attribute(&start, b"val").unwrap_or_default(),
2051                b"ind" => {
2052                    indent_twips = raw_attribute(&start, b"left")
2053                        .and_then(|value| value.parse().ok())
2054                        .unwrap_or(0);
2055                    hanging_twips = raw_attribute(&start, b"hanging")
2056                        .and_then(|value| value.parse().ok())
2057                        .unwrap_or(0);
2058                }
2059                _ => {}
2060            },
2061
2062            Event::End(end) if end.local_name().as_ref() == b"lvl" => break,
2063
2064            _ => {}
2065        }
2066    }
2067
2068    Ok(ListLevel::new(format, text, indent_twips, hanging_twips))
2069}
2070
2071/// Parses a single `<w:num>` element's `<w:abstractNumId>` child (the only one this reader cares
2072/// about — `lvlOverride` isn't modeled), assuming the `<w:num>` `Start` event was just consumed by
2073/// the caller. Stops at the matching `</w:num>`.
2074fn parse_num_abstract_ref(reader: &mut Reader<'_>) -> Result<Option<u32>> {
2075    let mut abstract_num_id = None;
2076
2077    loop {
2078        match reader.read_event()? {
2079            Event::Eof => break,
2080
2081            Event::Start(start) | Event::Empty(start)
2082                if start.local_name().as_ref() == b"abstractNumId" =>
2083            {
2084                abstract_num_id =
2085                    raw_attribute(&start, b"val").and_then(|value| value.parse().ok());
2086            }
2087
2088            Event::End(end) if end.local_name().as_ref() == b"num" => break,
2089
2090            _ => {}
2091        }
2092    }
2093
2094    Ok(abstract_num_id)
2095}
2096
2097/// Maps a `w:numFmt`'s `w:val` (`ST_NumberFormat`) to a [`NumberFormat`], or `None` if it isn't one
2098/// of the handful this crate models (see [`NumberFormat`]'s doc comment).
2099fn number_format_from_attribute(start: &BytesStart) -> Option<NumberFormat> {
2100    match raw_attribute(start, b"val")?.as_str() {
2101        "bullet" => Some(NumberFormat::Bullet),
2102        "decimal" => Some(NumberFormat::Decimal),
2103        "lowerLetter" => Some(NumberFormat::LowerLetter),
2104        "upperLetter" => Some(NumberFormat::UpperLetter),
2105        "lowerRoman" => Some(NumberFormat::LowerRoman),
2106        "upperRoman" => Some(NumberFormat::UpperRoman),
2107        _ => None,
2108    }
2109}
2110
2111/// Parses `word/footnotes.xml`/`word/endnotes.xml` (`CT_Footnotes`/ `CT_Endnotes`) into a flat list
2112/// of [`Note`]s, one per `<w:footnote>`/ `<w:endnote>` element (`note_element_name` is
2113/// `b"footnote"`/ `b"endnote"`).
2114///
2115/// Notes of type `"separator"`/`"continuationSeparator"`/ `"continuationNotice"` — the boilerplate
2116/// entries real Word documents also carry — are skipped rather than exposed as a [`Note`]; see
2117/// [`Note`]'s doc comment for why. A note with no (or an unparsable) `id` is skipped too (a
2118/// malformed document), same policy as an unresolvable `w:num` in `parse_numbering_xml`.
2119fn parse_notes_xml(
2120    xml: &str,
2121    resolver: &ImageResolver,
2122    note_element_name: &[u8],
2123) -> Result<Vec<Note>> {
2124    let mut notes = Vec::new();
2125    let mut reader = Reader::from_xml_str(xml);
2126
2127    loop {
2128        match reader.read_event()? {
2129            Event::Eof => break,
2130
2131            Event::Start(start) if start.local_name().as_ref() == note_element_name => {
2132                let is_special_note = matches!(
2133                    raw_attribute(&start, b"type").as_deref(),
2134                    Some("separator" | "continuationSeparator" | "continuationNotice")
2135                );
2136                let id = raw_attribute(&start, b"id").and_then(|value| value.parse().ok());
2137
2138                match (is_special_note, id) {
2139                    (false, Some(id)) => {
2140                        let blocks = parse_note_blocks(&mut reader, resolver, note_element_name)?;
2141                        notes.push(Note { id, blocks });
2142                    }
2143                    _ => skip_to_end(&mut reader, note_element_name)?,
2144                }
2145            }
2146
2147            _ => {}
2148        }
2149    }
2150
2151    Ok(notes)
2152}
2153
2154/// Parses a single note's/comment's/structured-document-tag's block-level content (`CT_FtnEdn`,
2155/// `CT_Comment` and `CT_SdtContentBlock` all reuse `EG_BlockLevelElts`/`EG_ContentBlockContent`,
2156/// the same group `w:body`/[`HeaderFooter`] use), assuming the `<w:footnote>`/
2157/// `<w:endnote>`/`<w:comment>`/`<w:sdtContent>` `Start` event was just consumed by the caller.
2158/// Stops at the matching end tag (`stop_local_name`,
2159/// `b"footnote"`/`b"endnote"`/`b"comment"`/`b"sdtContent"`) — shared verbatim by
2160/// [`parse_notes_xml`], [`parse_comments_xml`] and [`parse_structured_document_tag`], since none of
2161/// them actually care whether the surrounding element is a note, a comment or a content control.
2162/// Handles `w:sdt` itself too, so a nested structured document tag (inside a note, a comment, or
2163/// another structured document tag) round- trips correctly through the same mutual recursion with
2164/// [`parse_structured_document_tag`].
2165fn parse_note_blocks(
2166    reader: &mut Reader<'_>,
2167    resolver: &ImageResolver,
2168    stop_local_name: &[u8],
2169) -> Result<Vec<Block>> {
2170    let mut blocks = Vec::new();
2171
2172    loop {
2173        match reader.read_event()? {
2174            Event::Eof => break,
2175
2176            Event::Start(start) => match start.local_name().as_ref() {
2177                b"p" => blocks.push(Block::Paragraph(parse_paragraph(reader, resolver)?)),
2178                b"tbl" => blocks.push(Block::Table(parse_table(reader, resolver)?)),
2179                b"sdt" => blocks.push(Block::StructuredDocumentTag(parse_structured_document_tag(
2180                    reader, resolver,
2181                )?)),
2182                _ => {}
2183            },
2184
2185            Event::Empty(start) => match start.local_name().as_ref() {
2186                b"p" => blocks.push(Block::Paragraph(Paragraph::new())),
2187                b"sdt" => blocks.push(Block::StructuredDocumentTag(StructuredDocumentTag::new())),
2188                _ => {}
2189            },
2190
2191            Event::End(end) if end.local_name().as_ref() == stop_local_name => break,
2192
2193            _ => {}
2194        }
2195    }
2196
2197    Ok(blocks)
2198}
2199
2200/// Parses a single `<w:sdt>` structured document tag / content control (`CT_SdtBlock`), assuming
2201/// its `Start` event was just consumed by the caller. Reads `w:sdtPr`'s `id`/`tag`/`alias` via
2202/// [`parse_sdt_properties`] (everything else — `w:lock`, `w:placeholder`, `w:showingPlcHdr`,
2203/// `w:dataBinding`, `w:temporary`, and the dozen-plus type indicators
2204/// `w:richText`/`w:text`/`w:date`/. — is silently skipped, see [`StructuredDocumentTag`]'s doc
2205/// comment for why) and `w:sdtContent`'s block-level content (delegated to [`parse_note_blocks`],
2206/// which handles `w:sdt` itself too — so a nested content control round-trips correctly). Stops at
2207/// the matching `</w:sdt>`.
2208fn parse_structured_document_tag(
2209    reader: &mut Reader<'_>,
2210    resolver: &ImageResolver,
2211) -> Result<StructuredDocumentTag> {
2212    let mut sdt = StructuredDocumentTag::new();
2213
2214    loop {
2215        match reader.read_event()? {
2216            Event::Eof => break,
2217
2218            Event::Start(start) => match start.local_name().as_ref() {
2219                b"sdtPr" => parse_sdt_properties(reader, &mut sdt)?,
2220                b"sdtContent" => sdt.blocks = parse_note_blocks(reader, resolver, b"sdtContent")?,
2221                _ => {}
2222            },
2223
2224            Event::End(end) if end.local_name().as_ref() == b"sdt" => break,
2225
2226            _ => {}
2227        }
2228    }
2229
2230    Ok(sdt)
2231}
2232
2233/// Parses `w:sdtPr`'s `id`/`tag`/`alias` children into `sdt`, assuming its `Start` event was just
2234/// consumed by the caller. `CT_SdtPr` is an unbounded `choice` (every child optional, no fixed
2235/// order), so this just accumulates whichever of the three it recognizes — via their always-
2236/// self-closing `<w:../w:val="..">` shape, `CT_DecimalNumber`/`CT_String` having no content model
2237/// of their own — and silently ignores everything else (`w:lock`, `w:placeholder`, the
2238/// type-indicator choice..). Stops at the matching `</w:sdtPr>`.
2239fn parse_sdt_properties(reader: &mut Reader<'_>, sdt: &mut StructuredDocumentTag) -> Result<()> {
2240    loop {
2241        match reader.read_event()? {
2242            Event::Eof => break,
2243
2244            Event::Empty(start) => match start.local_name().as_ref() {
2245                b"id" => {
2246                    sdt.id = raw_attribute(&start, b"val").and_then(|value| value.parse().ok())
2247                }
2248                b"tag" => sdt.tag = raw_attribute(&start, b"val"),
2249                b"alias" => sdt.alias = raw_attribute(&start, b"val"),
2250                _ => {}
2251            },
2252
2253            Event::End(end) if end.local_name().as_ref() == b"sdtPr" => break,
2254
2255            _ => {}
2256        }
2257    }
2258
2259    Ok(())
2260}
2261
2262/// Parses `word/comments.xml` (`CT_Comments`) into a flat list of [`Comment`]s, one per
2263/// `<w:comment>` element — structurally close to [`parse_notes_xml`] (both share
2264/// [`parse_note_blocks`] for their content), but with `w:comment`'s extra
2265/// `author`/`date`/`initials` attributes and no special/boilerplate variant to filter out (unlike
2266/// footnotes/endnotes, real Word documents don't carry a "separator" comment). A comment with no
2267/// (or an unparsable) `id` is skipped, same policy as an unresolvable note.
2268fn parse_comments_xml(xml: &str, resolver: &ImageResolver) -> Result<Vec<Comment>> {
2269    let mut comments = Vec::new();
2270    let mut reader = Reader::from_xml_str(xml);
2271
2272    loop {
2273        match reader.read_event()? {
2274            Event::Eof => break,
2275
2276            Event::Start(start) if start.local_name().as_ref() == b"comment" => {
2277                match raw_attribute(&start, b"id").and_then(|value| value.parse().ok()) {
2278                    Some(id) => {
2279                        let author = raw_attribute(&start, b"author");
2280                        let initials = raw_attribute(&start, b"initials");
2281                        let date = raw_attribute(&start, b"date");
2282                        let blocks = parse_note_blocks(&mut reader, resolver, b"comment")?;
2283                        comments.push(Comment {
2284                            id,
2285                            author,
2286                            initials,
2287                            date,
2288                            blocks,
2289                        });
2290                    }
2291                    None => skip_to_end(&mut reader, b"comment")?,
2292                }
2293            }
2294
2295            _ => {}
2296        }
2297    }
2298
2299    Ok(comments)
2300}
2301
2302/// Parses `word/theme/theme1.xml` (`<a:theme>`, `CT_OfficeStyleSheet`) into a [`Theme`]: the root's
2303/// `name` attribute, `<a:clrScheme>` and `<a:fontScheme>`. `<a:fmtScheme>` and any
2304/// `objectDefaults`/ `extraClrSchemeLst` are never matched by this flat scan, so they're skipped
2305/// without being interpreted — whether reading this crate's own fixed output or a real,
2306/// externally-authored theme with a genuinely different `fmtScheme` (see [`Theme`]'s doc comment).
2307fn parse_theme_xml(xml: &str) -> Result<Theme> {
2308    let mut reader = Reader::from_xml_str(xml);
2309    let mut name = String::new();
2310    let mut colors = ColorScheme::office_default();
2311    let mut fonts = FontScheme::office_default();
2312
2313    loop {
2314        match reader.read_event()? {
2315            Event::Eof => break,
2316
2317            Event::Start(start) => match start.local_name().as_ref() {
2318                b"theme" => {
2319                    if let Some(value) = raw_attribute(&start, b"name") {
2320                        name = value;
2321                    }
2322                }
2323                b"clrScheme" => colors = parse_color_scheme(&mut reader)?,
2324                b"fontScheme" => fonts = parse_font_scheme(&mut reader)?,
2325                _ => {}
2326            },
2327
2328            _ => {}
2329        }
2330    }
2331
2332    Ok(Theme {
2333        name,
2334        colors,
2335        fonts,
2336    })
2337}
2338
2339/// Parses `word/settings.xml`'s `<w:trackRevisions>` and `<w:documentProtection>` (`CT_Settings`)
2340/// into `(track_changes, protection)`, feeding [`Document::track_changes`]/[`Document::protection`]
2341/// — the only two settings this crate reads back from that part (everything else `write_to` may
2342/// write there, currently just `w:evenAndOddHeaders`, is instead re-derived from `document.xml`
2343/// itself on read, via `header_even`/`footer_even` being `Some`; see `SETTINGS_RELATIONSHIP_TYPE`'s
2344/// doc comment for why `w:trackRevisions`/ `w:documentProtection` have no such alternate signal and
2345/// genuinely need this part read). `<w:trackRevisions w:val="false"/>` (an explicit,
2346/// present-but-off form, distinct from the element being entirely absent) is treated the same as
2347/// absence — `on_off_attribute` already gives that same forgiving,
2348/// attribute-defaults-to-true-when-omitted behavior used everywhere else in this reader.
2349/// `w:documentProtection`'s `w:edit` value is parsed via [`ProtectionKind::from_attribute_value`],
2350/// which already returns `None` for `"none"` or any unrecognized value — a password-protected
2351/// `w:documentProtection` (extra `w:cryptProviderType`/ hash/salt attributes) still parses its
2352/// `w:edit` value the same way, those extra attributes are just ignored (this crate never writes
2353/// them, see `Document.protection`'s doc comment).
2354fn parse_settings_xml(xml: &str) -> Result<(bool, Option<ProtectionKind>)> {
2355    let mut reader = Reader::from_xml_str(xml);
2356    let mut track_changes = false;
2357    let mut protection = None;
2358
2359    loop {
2360        match reader.read_event()? {
2361            Event::Eof => break,
2362
2363            Event::Start(start) | Event::Empty(start) => match start.local_name().as_ref() {
2364                b"trackRevisions" => track_changes = on_off_attribute(&start),
2365                b"documentProtection" => {
2366                    protection = raw_attribute(&start, b"edit")
2367                        .and_then(|value| ProtectionKind::from_attribute_value(&value));
2368                }
2369                _ => {}
2370            },
2371
2372            _ => {}
2373        }
2374    }
2375
2376    Ok((track_changes, protection))
2377}
2378
2379/// Parses `docProps/core.xml` (`CT_CoreProperties`) into a [`DocumentProperties`]. Every child of
2380/// `CT_CoreProperties` this crate models (`dc:title`/`dc:subject`/`dc:creator`/`cp:keywords`/
2381/// `dc:description`/`cp:lastModifiedBy`/`cp:revision`/`dcterms:created`/
2382/// `dcterms:modified`/`cp:category`/`cp:contentStatus`) is a simple leaf text element with no
2383/// children of its own, so unlike `parse_paragraph`'s `in_run`-style disambiguation, no nesting
2384/// tracking is needed here: once a tracked element's `Start` is seen, every `Text`/`GeneralRef`
2385/// event up to its own matching `End` belongs to it. Text is decoded the same way run text is
2386/// (`xml_core::decode_text`/`decode_general_ref`), so an author name or title containing
2387/// `&`/`<`/`>` round-trips correctly — the same class of bug this project has already been bitten
2388/// by once for run text (see the CHANGELOG). Any other child (`dc:identifier`, `dc:language`,
2389/// `cp:lastPrinted`, `cp:version`..) is silently ignored, matching this reader's usual forgiving
2390/// policy for unmodeled elements. `xsi:type` on `dcterms:created`/`dcterms:modified` is not
2391/// inspected — the raw text is taken as-is regardless (see `DocumentProperties`'s doc comment on
2392/// why dates are plain strings, not validated).
2393fn parse_core_properties_xml(xml: &str) -> Result<DocumentProperties> {
2394    let mut reader = Reader::from_xml_str(xml);
2395    let mut properties = DocumentProperties::new();
2396    let mut current_element: Option<Vec<u8>> = None;
2397    let mut buffer = String::new();
2398
2399    loop {
2400        match reader.read_event()? {
2401            Event::Eof => break,
2402
2403            Event::Start(start) => {
2404                let local_name = start.local_name().as_ref().to_vec();
2405                if is_tracked_core_property_element(&local_name) {
2406                    current_element = Some(local_name);
2407                    buffer.clear();
2408                }
2409            }
2410
2411            Event::Text(text) if current_element.is_some() => {
2412                buffer.push_str(&xml_core::decode_text(&text)?);
2413            }
2414
2415            Event::GeneralRef(reference) if current_element.is_some() => {
2416                if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
2417                    buffer.push_str(&resolved);
2418                }
2419            }
2420
2421            Event::End(end) => {
2422                if let Some(name) = current_element.take() {
2423                    if end.local_name().as_ref() == name.as_slice() {
2424                        apply_core_property(&mut properties, &name, std::mem::take(&mut buffer));
2425                    } else {
2426                        // Shouldn't happen for a well-formed leaf element, but keep tracking
2427                        // defensively rather than losing the buffer silently.
2428                        current_element = Some(name);
2429                    }
2430                }
2431            }
2432
2433            _ => {}
2434        }
2435    }
2436
2437    Ok(properties)
2438}
2439
2440/// Whether `local_name` is one of the `CT_CoreProperties` children this crate models — see
2441/// `parse_core_properties_xml`'s doc comment.
2442fn is_tracked_core_property_element(local_name: &[u8]) -> bool {
2443    matches!(
2444        local_name,
2445        b"title"
2446            | b"subject"
2447            | b"creator"
2448            | b"keywords"
2449            | b"description"
2450            | b"lastModifiedBy"
2451            | b"revision"
2452            | b"created"
2453            | b"modified"
2454            | b"category"
2455            | b"contentStatus"
2456    )
2457}
2458
2459/// Assigns `value` to `properties`' field matching `local_name` — the inverse of
2460/// `to_core_properties_xml`'s element-name choices in `writer.rs`.
2461fn apply_core_property(properties: &mut DocumentProperties, local_name: &[u8], value: String) {
2462    match local_name {
2463        b"title" => properties.title = Some(value),
2464        b"subject" => properties.subject = Some(value),
2465        b"creator" => properties.creator = Some(value),
2466        b"keywords" => properties.keywords = Some(value),
2467        b"description" => properties.description = Some(value),
2468        b"lastModifiedBy" => properties.last_modified_by = Some(value),
2469        b"revision" => properties.revision = Some(value),
2470        b"created" => properties.created = Some(value),
2471        b"modified" => properties.modified = Some(value),
2472        b"category" => properties.category = Some(value),
2473        b"contentStatus" => properties.content_status = Some(value),
2474        _ => {}
2475    }
2476}
2477
2478/// Parses `docProps/app.xml` (extended properties) into an [`ExtendedProperties`], keeping only
2479/// `Company`/`Manager` — the same leaf-text-tracking approach as `parse_core_properties_xml`, just
2480/// with a smaller tracked-element set (`Application` and the many statistics fields — see
2481/// [`ExtendedProperties`]'s doc comment — are silently ignored, matching this reader's usual
2482/// forgiving policy for unmodeled elements).
2483fn parse_extended_properties_xml(xml: &str) -> Result<ExtendedProperties> {
2484    let mut reader = Reader::from_xml_str(xml);
2485    let mut properties = ExtendedProperties::new();
2486    let mut current_element: Option<Vec<u8>> = None;
2487    let mut buffer = String::new();
2488
2489    loop {
2490        match reader.read_event()? {
2491            Event::Eof => break,
2492
2493            Event::Start(start) => {
2494                let local_name = start.local_name().as_ref().to_vec();
2495                if local_name == b"Company" || local_name == b"Manager" {
2496                    current_element = Some(local_name);
2497                    buffer.clear();
2498                }
2499            }
2500
2501            Event::Text(text) if current_element.is_some() => {
2502                buffer.push_str(&xml_core::decode_text(&text)?);
2503            }
2504
2505            Event::GeneralRef(reference) if current_element.is_some() => {
2506                if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
2507                    buffer.push_str(&resolved);
2508                }
2509            }
2510
2511            Event::End(end) => {
2512                if let Some(name) = current_element.take() {
2513                    if end.local_name().as_ref() == name.as_slice() {
2514                        let value = std::mem::take(&mut buffer);
2515                        match name.as_slice() {
2516                            b"Company" => properties.company = Some(value),
2517                            b"Manager" => properties.manager = Some(value),
2518                            _ => {}
2519                        }
2520                    } else {
2521                        current_element = Some(name);
2522                    }
2523                }
2524            }
2525
2526            _ => {}
2527        }
2528    }
2529
2530    Ok(properties)
2531}
2532
2533/// Parses `docProps/custom.xml` (custom properties) into a `Vec` of `(name, value)` pairs,
2534/// preserving the file's own `<property>` order (not re-sorted by `pid`, though a well-formed file
2535/// this crate produced already has them in ascending `pid` order). Each `<property name="..">`
2536/// element's single variant-type child (`vt:lpwstr`/`vt:bool`/`vt:i4`/ `vt:r8` — see
2537/// [`CustomPropertyValue`]'s doc comment for which types are modeled) is decoded by its own local
2538/// name; a property whose variant type isn't one of these four, or that has no `name` attribute, is
2539/// silently skipped, matching this reader's usual forgiving policy. `fmtid`/`pid` are not validated
2540/// against `CUSTOM_PROPERTY_FMTID`/a specific sequence — same best-effort posture as elsewhere in
2541/// this crate.
2542fn parse_custom_properties_xml(xml: &str) -> Result<Vec<(String, CustomPropertyValue)>> {
2543    let mut reader = Reader::from_xml_str(xml);
2544    let mut custom_properties = Vec::new();
2545    let mut current_name: Option<String> = None;
2546    let mut current_variant: Option<Vec<u8>> = None;
2547    let mut buffer = String::new();
2548
2549    loop {
2550        match reader.read_event()? {
2551            Event::Eof => break,
2552
2553            Event::Start(start) if start.local_name().as_ref() == b"property" => {
2554                current_name = raw_attribute(&start, b"name");
2555            }
2556
2557            Event::Start(start) | Event::Empty(start)
2558                if current_name.is_some()
2559                    && matches!(
2560                        start.local_name().as_ref(),
2561                        b"lpwstr" | b"bool" | b"i4" | b"r8"
2562                    ) =>
2563            {
2564                current_variant = Some(start.local_name().as_ref().to_vec());
2565                buffer.clear();
2566            }
2567
2568            Event::Text(text) if current_variant.is_some() => {
2569                buffer.push_str(&xml_core::decode_text(&text)?);
2570            }
2571
2572            Event::GeneralRef(reference) if current_variant.is_some() => {
2573                if let Some(resolved) = xml_core::decode_general_ref(&reference)? {
2574                    buffer.push_str(&resolved);
2575                }
2576            }
2577
2578            Event::End(end) => {
2579                if let Some(variant) = current_variant.take() {
2580                    if end.local_name().as_ref() == variant.as_slice() {
2581                        if let Some(name) = current_name.clone() {
2582                            let value = std::mem::take(&mut buffer);
2583                            let parsed = match variant.as_slice() {
2584                                b"lpwstr" => Some(CustomPropertyValue::Text(value)),
2585                                b"bool" => {
2586                                    Some(CustomPropertyValue::Bool(value == "true" || value == "1"))
2587                                }
2588                                b"i4" => value.parse::<i32>().ok().map(CustomPropertyValue::Int),
2589                                b"r8" => value.parse::<f64>().ok().map(CustomPropertyValue::Number),
2590                                _ => None,
2591                            };
2592                            if let Some(value) = parsed {
2593                                custom_properties.push((name, value));
2594                            }
2595                        }
2596                    } else {
2597                        current_variant = Some(variant);
2598                    }
2599                } else if end.local_name().as_ref() == b"property" {
2600                    current_name = None;
2601                }
2602            }
2603
2604            _ => {}
2605        }
2606    }
2607
2608    Ok(custom_properties)
2609}
2610
2611/// Parses `<a:clrScheme>`'s 12 fixed-order color slots, assuming its `Start` event was just
2612/// consumed by the caller. Stops at the matching `</a:clrScheme>`. Any slot this crate doesn't
2613/// model (there are none in the standard 12, but a malformed/future document could carry an
2614/// `extLst`) is silently ignored, same policy as elsewhere in this reader.
2615fn parse_color_scheme(reader: &mut Reader<'_>) -> Result<ColorScheme> {
2616    let mut colors = ColorScheme::office_default();
2617
2618    loop {
2619        match reader.read_event()? {
2620            Event::Eof => break,
2621            Event::End(end) if end.local_name().as_ref() == b"clrScheme" => break,
2622
2623            Event::Start(start) => {
2624                let slot = start.local_name().as_ref().to_vec();
2625                if let Some(value) = read_color_slot_value(reader, &slot)? {
2626                    assign_color_slot(&mut colors, &slot, value);
2627                }
2628            }
2629
2630            _ => {}
2631        }
2632    }
2633
2634    Ok(colors)
2635}
2636
2637/// Reads a single color slot's value (`<a:dk1>`, `<a:accent1>`..): its one child, either
2638/// `<a:srgbClr val="..">` (used directly) or `<a:sysClr val=".." lastClr="..">` (its `lastClr`
2639/// fallback RGB is used instead — see [`ColorScheme`]'s doc comment for why the live system-color
2640/// binding itself isn't preserved). Consumes up to and including the matching end tag for
2641/// `wrapper_local_name`.
2642fn read_color_slot_value(
2643    reader: &mut Reader<'_>,
2644    wrapper_local_name: &[u8],
2645) -> Result<Option<String>> {
2646    let mut value = None;
2647
2648    loop {
2649        match reader.read_event()? {
2650            Event::Eof => break,
2651            Event::End(end) if end.local_name().as_ref() == wrapper_local_name => break,
2652
2653            Event::Empty(start) | Event::Start(start) => match start.local_name().as_ref() {
2654                b"srgbClr" => value = raw_attribute(&start, b"val"),
2655                b"sysClr" => {
2656                    value =
2657                        raw_attribute(&start, b"lastClr").or_else(|| raw_attribute(&start, b"val"))
2658                }
2659                _ => {}
2660            },
2661
2662            _ => {}
2663        }
2664    }
2665
2666    Ok(value)
2667}
2668
2669/// Assigns a parsed color value to the matching field of `colors`, by its `<a:clrScheme>` child
2670/// element name (`dk1`/`lt1`/`dk2`/`lt2`/ `accent1`-`accent6`/`hlink`/`folHlink`). Any other name
2671/// is ignored.
2672fn assign_color_slot(colors: &mut ColorScheme, slot: &[u8], value: String) {
2673    match slot {
2674        b"dk1" => colors.dark1 = value,
2675        b"lt1" => colors.light1 = value,
2676        b"dk2" => colors.dark2 = value,
2677        b"lt2" => colors.light2 = value,
2678        b"accent1" => colors.accent1 = value,
2679        b"accent2" => colors.accent2 = value,
2680        b"accent3" => colors.accent3 = value,
2681        b"accent4" => colors.accent4 = value,
2682        b"accent5" => colors.accent5 = value,
2683        b"accent6" => colors.accent6 = value,
2684        b"hlink" => colors.hyperlink = value,
2685        b"folHlink" => colors.followed_hyperlink = value,
2686        _ => {}
2687    }
2688}
2689
2690/// Parses `<a:fontScheme>`'s `majorFont`/`minorFont`, assuming its `Start` event was just consumed
2691/// by the caller. Stops at the matching `</a:fontScheme>`. Only each font collection's `<a:latin>`
2692/// typeface is captured — see [`FontScheme`]'s doc comment for why `ea`/`cs`/per-script fallbacks
2693/// are ignored.
2694fn parse_font_scheme(reader: &mut Reader<'_>) -> Result<FontScheme> {
2695    let mut fonts = FontScheme::office_default();
2696
2697    loop {
2698        match reader.read_event()? {
2699            Event::Eof => break,
2700            Event::End(end) if end.local_name().as_ref() == b"fontScheme" => break,
2701
2702            Event::Start(start) if start.local_name().as_ref() == b"majorFont" => {
2703                if let Some(typeface) = parse_font_collection_latin(reader, b"majorFont")? {
2704                    fonts.major_latin = typeface;
2705                }
2706            }
2707            Event::Start(start) if start.local_name().as_ref() == b"minorFont" => {
2708                if let Some(typeface) = parse_font_collection_latin(reader, b"minorFont")? {
2709                    fonts.minor_latin = typeface;
2710                }
2711            }
2712
2713            _ => {}
2714        }
2715    }
2716
2717    Ok(fonts)
2718}
2719
2720/// Reads a `<a:majorFont>`/`<a:minorFont>` font collection's `<a:latin>` typeface, ignoring
2721/// `ea`/`cs`/per-script fallback children. Consumes up to and including the matching end tag for
2722/// `wrapper_local_name`.
2723fn parse_font_collection_latin(
2724    reader: &mut Reader<'_>,
2725    wrapper_local_name: &[u8],
2726) -> Result<Option<String>> {
2727    let mut typeface = None;
2728
2729    loop {
2730        match reader.read_event()? {
2731            Event::Eof => break,
2732            Event::End(end) if end.local_name().as_ref() == wrapper_local_name => break,
2733
2734            Event::Empty(start) | Event::Start(start)
2735                if start.local_name().as_ref() == b"latin" =>
2736            {
2737                typeface = raw_attribute(&start, b"typeface");
2738            }
2739
2740            _ => {}
2741        }
2742    }
2743
2744    Ok(typeface)
2745}
2746
2747#[cfg(test)]
2748mod tests {
2749    use super::*;
2750
2751    /// Parses `xml` against an empty package (no images to resolve) — convenient for the many tests
2752    /// that don't involve `<w:drawing>`.
2753    fn parse(xml: &str) -> Document {
2754        let package = Package::new();
2755        let relationships = Relationships::new();
2756        let resolver = ImageResolver {
2757            package: &package,
2758            part_relationships: &relationships,
2759        };
2760        parse_document_xml(xml, &resolver).unwrap().document
2761    }
2762
2763    #[test]
2764    fn unescapes_xml_special_characters_from_hand_written_xml() {
2765        // Diagnostic/regression test isolating the READ side only: the XML below is written BY HAND
2766        // with the entities already present (bypassing this crate's own `Writer` entirely), so this
2767        // proves whether `parse_paragraph`'s text accumulation correctly unescapes on its own,
2768        // independent of whether the write side produces correct output.
2769        let document = parse(
2770            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2771<w:body><w:p><w:r><w:t>A &amp; B &lt; C &gt; D &apos; E &quot; F</w:t></w:r></w:p></w:body>
2772</w:document>"#,
2773        );
2774
2775        let paragraphs: Vec<_> = document.paragraphs().collect();
2776        assert_eq!(paragraphs[0].text(), r#"A & B < C > D ' E " F"#);
2777    }
2778
2779    #[test]
2780    fn parses_paragraphs_and_runs() {
2781        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2782<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2783<w:body>
2784<w:p><w:r><w:t>Hello</w:t></w:r></w:p>
2785<w:p><w:r><w:t>How are you?</w:t></w:r></w:p>
2786<w:p/>
2787<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr>
2788</w:body>
2789</w:document>"#;
2790
2791        let document = parse(xml);
2792        let paragraphs: Vec<_> = document.paragraphs().collect();
2793
2794        assert_eq!(paragraphs.len(), 3);
2795        assert_eq!(paragraphs[0].text(), "Hello");
2796        assert_eq!(paragraphs[1].text(), "How are you?");
2797        assert_eq!(paragraphs[2].text(), "");
2798    }
2799
2800    #[test]
2801    fn parses_a_paragraph_with_multiple_runs() {
2802        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2803<w:body><w:p><w:r><w:t>Hello, </w:t></w:r><w:r><w:t>world!</w:t></w:r></w:p></w:body>
2804</w:document>"#;
2805
2806        let document = parse(xml);
2807        let paragraphs: Vec<_> = document.paragraphs().collect();
2808
2809        assert_eq!(paragraphs.len(), 1);
2810        assert_eq!(paragraphs[0].runs.len(), 2);
2811        assert_eq!(paragraphs[0].text(), "Hello, world!");
2812    }
2813
2814    #[test]
2815    fn parses_bold_italic_and_underline_run_properties() {
2816        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2817<w:body><w:p><w:r><w:rPr><w:b/><w:i/><w:u w:val="single"/></w:rPr><w:t>Styled</w:t></w:r></w:p></w:body>
2818</w:document>"#;
2819
2820        let document = parse(xml);
2821        let paragraphs: Vec<_> = document.paragraphs().collect();
2822
2823        let run = &paragraphs[0].runs[0];
2824        assert!(run.bold, "{run:?}");
2825        assert!(run.italic, "{run:?}");
2826        assert_eq!(run.underline, Some(UnderlineStyle::Single), "{run:?}");
2827    }
2828
2829    #[test]
2830    fn parses_color_size_and_font_family_run_properties() {
2831        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2832<w:body><w:p><w:r><w:rPr><w:rFonts w:ascii="Georgia" w:hAnsi="Georgia"/><w:color w:val="FF0000"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr><w:t>Colorful</w:t></w:r></w:p></w:body>
2833</w:document>"#;
2834
2835        let document = parse(xml);
2836        let paragraphs: Vec<_> = document.paragraphs().collect();
2837        let run = &paragraphs[0].runs[0];
2838
2839        assert_eq!(run.color.as_deref(), Some("FF0000"));
2840        assert_eq!(run.font_size, Some(14));
2841        assert_eq!(run.font_family.as_deref(), Some("Georgia"));
2842    }
2843
2844    #[test]
2845    fn parses_a_highlighted_run() {
2846        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2847<w:body><w:p><w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>Highlighted</w:t></w:r></w:p></w:body>
2848</w:document>"#;
2849
2850        let document = parse(xml);
2851        let paragraphs: Vec<_> = document.paragraphs().collect();
2852
2853        assert_eq!(paragraphs[0].runs[0].highlight, Some(Highlight::Yellow));
2854    }
2855
2856    #[test]
2857    fn an_unrecognized_highlight_value_is_not_set() {
2858        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2859<w:body><w:p><w:r><w:rPr><w:highlight w:val="none"/></w:rPr><w:t>No highlight</w:t></w:r></w:p></w:body>
2860</w:document>"#;
2861
2862        let document = parse(xml);
2863        let paragraphs: Vec<_> = document.paragraphs().collect();
2864
2865        assert_eq!(paragraphs[0].runs[0].highlight, None);
2866    }
2867
2868    #[test]
2869    fn parses_a_struck_through_run() {
2870        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2871<w:body><w:p><w:r><w:rPr><w:strike/></w:rPr><w:t>Struck</w:t></w:r></w:p></w:body>
2872</w:document>"#;
2873
2874        let document = parse(xml);
2875        let paragraphs: Vec<_> = document.paragraphs().collect();
2876
2877        assert!(paragraphs[0].runs[0].strike);
2878    }
2879
2880    #[test]
2881    fn parses_a_superscript_run() {
2882        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2883<w:body><w:p><w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>Superscript</w:t></w:r></w:p></w:body>
2884</w:document>"#;
2885
2886        let document = parse(xml);
2887        let paragraphs: Vec<_> = document.paragraphs().collect();
2888
2889        assert_eq!(
2890            paragraphs[0].runs[0].vertical_align,
2891            Some(VerticalAlign::Superscript)
2892        );
2893    }
2894
2895    #[test]
2896    fn a_baseline_vert_align_value_is_not_set() {
2897        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2898<w:body><w:p><w:r><w:rPr><w:vertAlign w:val="baseline"/></w:rPr><w:t>Normal</w:t></w:r></w:p></w:body>
2899</w:document>"#;
2900
2901        let document = parse(xml);
2902        let paragraphs: Vec<_> = document.paragraphs().collect();
2903
2904        assert_eq!(paragraphs[0].runs[0].vertical_align, None);
2905    }
2906
2907    #[test]
2908    fn a_u_element_with_val_none_is_not_underlined() {
2909        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2910<w:body><w:p><w:r><w:rPr><w:u w:val="none"/></w:rPr><w:t>Not underlined</w:t></w:r></w:p></w:body>
2911</w:document>"#;
2912
2913        let document = parse(xml);
2914        let paragraphs: Vec<_> = document.paragraphs().collect();
2915
2916        assert_eq!(paragraphs[0].runs[0].underline, None);
2917    }
2918
2919    #[test]
2920    fn parses_a_wave_underline_and_color() {
2921        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2922<w:body><w:p><w:r><w:rPr><w:u w:val="wave" w:color="FF0000"/></w:rPr><w:t>Red wave</w:t></w:r></w:p></w:body>
2923</w:document>"#;
2924
2925        let document = parse(xml);
2926        let paragraphs: Vec<_> = document.paragraphs().collect();
2927        let run = &paragraphs[0].runs[0];
2928
2929        assert_eq!(run.underline, Some(UnderlineStyle::Wave));
2930        assert_eq!(run.underline_color.as_deref(), Some("FF0000"));
2931    }
2932
2933    #[test]
2934    fn parses_small_caps_and_all_caps() {
2935        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2936<w:body><w:p><w:r><w:rPr><w:smallCaps/></w:rPr><w:t>Small caps</w:t></w:r><w:r><w:rPr><w:caps/></w:rPr><w:t>All caps</w:t></w:r></w:p></w:body>
2937</w:document>"#;
2938
2939        let document = parse(xml);
2940        let paragraphs: Vec<_> = document.paragraphs().collect();
2941
2942        assert!(paragraphs[0].runs[0].small_caps);
2943        assert!(!paragraphs[0].runs[0].all_caps);
2944        assert!(paragraphs[0].runs[1].all_caps);
2945        assert!(!paragraphs[0].runs[1].small_caps);
2946    }
2947
2948    #[test]
2949    fn parses_a_shading_color() {
2950        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2951<w:body><w:p><w:r><w:rPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/></w:rPr><w:t>Highlighted background</w:t></w:r></w:p></w:body>
2952</w:document>"#;
2953
2954        let document = parse(xml);
2955        let paragraphs: Vec<_> = document.paragraphs().collect();
2956
2957        assert_eq!(
2958            paragraphs[0].runs[0].shading_color.as_deref(),
2959            Some("FFFF00")
2960        );
2961    }
2962
2963    #[test]
2964    fn parses_a_text_border() {
2965        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2966<w:body><w:p><w:r><w:rPr><w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:rPr><w:t>Bordered</w:t></w:r></w:p></w:body>
2967</w:document>"#;
2968
2969        let document = parse(xml);
2970        let paragraphs: Vec<_> = document.paragraphs().collect();
2971
2972        assert!(paragraphs[0].runs[0].text_border);
2973    }
2974
2975    #[test]
2976    fn parses_character_spacing_and_vertical_position() {
2977        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2978<w:body><w:p><w:r><w:rPr><w:spacing w:val="40"/><w:position w:val="-6"/></w:rPr><w:t>Spaced and lowered</w:t></w:r></w:p></w:body>
2979</w:document>"#;
2980
2981        let document = parse(xml);
2982        let paragraphs: Vec<_> = document.paragraphs().collect();
2983        let run = &paragraphs[0].runs[0];
2984
2985        assert_eq!(run.character_spacing, Some(40));
2986        assert_eq!(run.vertical_position, Some(-6));
2987    }
2988
2989    #[test]
2990    fn parses_hidden_text() {
2991        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2992<w:body><w:p><w:r><w:rPr><w:vanish/></w:rPr><w:t>Hidden</w:t></w:r></w:p></w:body>
2993</w:document>"#;
2994
2995        let document = parse(xml);
2996        let paragraphs: Vec<_> = document.paragraphs().collect();
2997
2998        assert!(paragraphs[0].runs[0].hidden);
2999    }
3000
3001    #[test]
3002    fn parses_paragraph_alignment() {
3003        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3004<w:body><w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:t>Centered</w:t></w:r></w:p></w:body>
3005</w:document>"#;
3006
3007        let document = parse(xml);
3008        let paragraphs: Vec<_> = document.paragraphs().collect();
3009
3010        assert_eq!(paragraphs[0].alignment, Some(Alignment::Center));
3011    }
3012
3013    #[test]
3014    fn paragraphs_without_jc_have_no_alignment() {
3015        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3016<w:body><w:p><w:r><w:t>Default</w:t></w:r></w:p></w:body>
3017</w:document>"#;
3018
3019        let document = parse(xml);
3020        let paragraphs: Vec<_> = document.paragraphs().collect();
3021
3022        assert_eq!(paragraphs[0].alignment, None);
3023    }
3024
3025    #[test]
3026    fn parses_paragraph_line_spacing_and_before_after() {
3027        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3028<w:body><w:p><w:pPr><w:spacing w:before="240" w:after="120" w:line="480" w:lineRule="auto"/></w:pPr><w:r><w:t>Spaced out</w:t></w:r></w:p></w:body>
3029</w:document>"#;
3030
3031        let document = parse(xml);
3032        let paragraphs: Vec<_> = document.paragraphs().collect();
3033
3034        assert_eq!(paragraphs[0].line_spacing, Some(480));
3035        assert_eq!(paragraphs[0].space_before, Some(240));
3036        assert_eq!(paragraphs[0].space_after, Some(120));
3037    }
3038
3039    #[test]
3040    fn parses_a_paragraph_shading_color() {
3041        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3042<w:body><w:p><w:pPr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/></w:pPr><w:r><w:t>Shaded paragraph</w:t></w:r></w:p></w:body>
3043</w:document>"#;
3044
3045        let document = parse(xml);
3046        let paragraphs: Vec<_> = document.paragraphs().collect();
3047
3048        assert_eq!(paragraphs[0].shading_color.as_deref(), Some("D9D9D9"));
3049    }
3050
3051    #[test]
3052    fn parses_a_paragraph_border() {
3053        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3054<w:body><w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:pBdr></w:pPr><w:r><w:t>Boxed paragraph</w:t></w:r></w:p></w:body>
3055</w:document>"#;
3056
3057        let document = parse(xml);
3058        let paragraphs: Vec<_> = document.paragraphs().collect();
3059
3060        assert!(paragraphs[0].border);
3061    }
3062
3063    // `w:shd`/`w:spacing` are shared element names between `w:pPr` and `w:rPr` (see `in_run`'s doc
3064    // comment in `parse_paragraph`) — this regression test proves the two never leak into each
3065    // other: a paragraph with BOTH a paragraph-level shading/spacing AND a run with its own,
3066    // different, run-level shading/character-spacing must keep all four values distinct.
3067    #[test]
3068    fn paragraph_and_run_level_shading_and_spacing_do_not_collide() {
3069        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3070<w:body><w:p><w:pPr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/><w:spacing w:before="240" w:after="120" w:line="480" w:lineRule="auto"/></w:pPr><w:r><w:rPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/><w:spacing w:val="40"/></w:rPr><w:t>Run</w:t></w:r></w:p></w:body>
3071</w:document>"#;
3072
3073        let document = parse(xml);
3074        let paragraphs: Vec<_> = document.paragraphs().collect();
3075        let paragraph = &paragraphs[0];
3076        let run = &paragraph.runs[0];
3077
3078        assert_eq!(paragraph.shading_color.as_deref(), Some("D9D9D9"));
3079        assert_eq!(paragraph.line_spacing, Some(480));
3080        assert_eq!(paragraph.space_before, Some(240));
3081        assert_eq!(paragraph.space_after, Some(120));
3082        assert_eq!(run.shading_color.as_deref(), Some("FFFF00"));
3083        assert_eq!(run.character_spacing, Some(40));
3084    }
3085
3086    #[test]
3087    fn parses_a_table_of_text_cells() {
3088        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3089<w:body>
3090<w:tbl>
3091<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
3092<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
3093<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
3094<w:tr><w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc><w:tc><w:p/></w:tc></w:tr>
3095</w:tbl>
3096</w:body>
3097</w:document>"#;
3098
3099        let document = parse(xml);
3100        let tables: Vec<_> = document.tables().collect();
3101
3102        assert_eq!(tables.len(), 1);
3103        let table = tables[0];
3104        assert_eq!(table.rows.len(), 2);
3105        assert_eq!(table.rows[0].cells.len(), 2);
3106        assert_eq!(table.rows[0].cells[0].text(), "A1");
3107        assert_eq!(table.rows[0].cells[1].text(), "B1");
3108        assert_eq!(table.rows[1].cells[0].text(), "A2");
3109        assert_eq!(table.rows[1].cells[1].text(), "");
3110    }
3111
3112    #[test]
3113    fn parses_a_nested_table_inside_a_cell() {
3114        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3115<w:body>
3116<w:tbl>
3117<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
3118<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3119<w:tr><w:tc>
3120<w:tbl>
3121<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
3122<w:tblGrid><w:gridCol w:w="1000"/></w:tblGrid>
3123<w:tr><w:tc><w:p><w:r><w:t>Inner</w:t></w:r></w:p></w:tc></w:tr>
3124</w:tbl>
3125<w:p/>
3126</w:tc></w:tr>
3127</w:tbl>
3128</w:body>
3129</w:document>"#;
3130
3131        let document = parse(xml);
3132        let outer_table = document.tables().next().unwrap();
3133        let cell = &outer_table.rows[0].cells[0];
3134
3135        let nested_tables: Vec<_> = cell.tables().collect();
3136        assert_eq!(nested_tables.len(), 1);
3137        assert_eq!(nested_tables[0].rows[0].cells[0].text(), "Inner");
3138        // The outer cell's own paragraph text stays empty — only the nested table's text was found
3139        // above, confirming `TableCell::text` doesn't accidentally reach into a nested table.
3140        assert_eq!(cell.text(), "");
3141    }
3142
3143    #[test]
3144    fn parses_gridcol_widths_into_column_widths() {
3145        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3146<w:body>
3147<w:tbl>
3148<w:tblPr><w:tblW w:w="4000" w:type="dxa"/><w:tblLayout w:type="fixed"/></w:tblPr>
3149<w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="3000"/></w:tblGrid>
3150<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
3151</w:tbl>
3152</w:body>
3153</w:document>"#;
3154
3155        let document = parse(xml);
3156        let tables: Vec<_> = document.tables().collect();
3157
3158        assert_eq!(tables[0].column_widths, vec![1000, 3000]);
3159    }
3160
3161    #[test]
3162    fn parses_a_horizontal_cell_merge() {
3163        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3164<w:body>
3165<w:tbl>
3166<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
3167<w:tr><w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>Spans two columns</w:t></w:r></w:p></w:tc></w:tr>
3168</w:tbl>
3169</w:body>
3170</w:document>"#;
3171
3172        let document = parse(xml);
3173        let tables: Vec<_> = document.tables().collect();
3174
3175        assert_eq!(tables[0].rows[0].cells[0].horizontal_span, Some(2));
3176    }
3177
3178    #[test]
3179    fn parses_a_vertical_cell_merge() {
3180        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3181<w:body>
3182<w:tbl>
3183<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3184<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>Merged</w:t></w:r></w:p></w:tc></w:tr>
3185<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc></w:tr>
3186</w:tbl>
3187</w:body>
3188</w:document>"#;
3189
3190        let document = parse(xml);
3191        let tables: Vec<_> = document.tables().collect();
3192
3193        assert_eq!(
3194            tables[0].rows[0].cells[0].vertical_merge,
3195            Some(VerticalMerge::Restart)
3196        );
3197        // `w:vMerge` with no `val` at all defaults to `continue` per ECMA-376 — a real-world Word
3198        // document commonly omits it exactly like this, relying on the default rather than writing
3199        // it out.
3200        assert_eq!(
3201            tables[0].rows[1].cells[0].vertical_merge,
3202            Some(VerticalMerge::Continue)
3203        );
3204    }
3205
3206    #[test]
3207    fn a_cell_with_no_tcpr_has_no_merge_properties() {
3208        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3209<w:body>
3210<w:tbl>
3211<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3212<w:tr><w:tc><w:p><w:r><w:t>Ordinary</w:t></w:r></w:p></w:tc></w:tr>
3213</w:tbl>
3214</w:body>
3215</w:document>"#;
3216
3217        let document = parse(xml);
3218        let tables: Vec<_> = document.tables().collect();
3219
3220        assert_eq!(tables[0].rows[0].cells[0].horizontal_span, None);
3221        assert_eq!(tables[0].rows[0].cells[0].vertical_merge, None);
3222    }
3223
3224    #[test]
3225    fn parses_a_custom_table_border_color() {
3226        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3227<w:body>
3228<w:tbl>
3229<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="FF0000"/></w:tblBorders></w:tblPr>
3230<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3231<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc></w:tr>
3232</w:tbl>
3233</w:body>
3234</w:document>"#;
3235
3236        let document = parse(xml);
3237        let tables: Vec<_> = document.tables().collect();
3238
3239        assert_eq!(tables[0].border_color.as_deref(), Some("FF0000"));
3240    }
3241
3242    #[test]
3243    fn a_table_with_the_fixed_auto_border_color_leaves_border_color_unset() {
3244        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3245<w:body>
3246<w:tbl>
3247<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:tblBorders></w:tblPr>
3248<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3249<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc></w:tr>
3250</w:tbl>
3251</w:body>
3252</w:document>"#;
3253
3254        let document = parse(xml);
3255        let tables: Vec<_> = document.tables().collect();
3256
3257        // Mirrors the same "don't populate the field for our own fixed default" posture as
3258        // `Run.underline_color`/`Paragraph.border` — `"auto"` is what this crate itself always
3259        // wrote before `border_color` existed, so it round-trips back to `None` rather than
3260        // `Some("auto")`.
3261        assert_eq!(tables[0].border_color, None);
3262    }
3263
3264    #[test]
3265    fn parses_table_style_alignment_and_indent() {
3266        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3267<w:body>
3268<w:tbl>
3269<w:tblPr><w:tblStyle w:val="TableGrid"/><w:jc w:val="center"/><w:tblInd w:w="200" w:type="dxa"/></w:tblPr>
3270<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3271<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc></w:tr>
3272</w:tbl>
3273</w:body>
3274</w:document>"#;
3275
3276        let document = parse(xml);
3277        let tables: Vec<_> = document.tables().collect();
3278
3279        assert_eq!(tables[0].style_id.as_deref(), Some("TableGrid"));
3280        assert_eq!(tables[0].alignment, Some(Alignment::Center));
3281        assert_eq!(tables[0].indent_twips, Some(200));
3282    }
3283
3284    #[test]
3285    fn parses_a_cell_border_shading_valign_margins_and_text_direction() {
3286        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3287<w:body>
3288<w:tbl>
3289<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3290<w:tr><w:tc><w:tcPr>
3291<w:tcBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:tcBorders>
3292<w:shd w:val="clear" w:color="auto" w:fill="00FF00"/>
3293<w:tcMar><w:top w:w="10" w:type="dxa"/><w:left w:w="20" w:type="dxa"/><w:bottom w:w="30" w:type="dxa"/><w:right w:w="40" w:type="dxa"/></w:tcMar>
3294<w:textDirection w:val="btLr"/>
3295<w:vAlign w:val="bottom"/>
3296</w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr>
3297</w:tbl>
3298</w:body>
3299</w:document>"#;
3300
3301        let document = parse(xml);
3302        let tables: Vec<_> = document.tables().collect();
3303        let cell = &tables[0].rows[0].cells[0];
3304
3305        assert!(cell.border);
3306        assert_eq!(cell.shading_color.as_deref(), Some("00FF00"));
3307        assert_eq!(cell.margin_top_twips, Some(10));
3308        assert_eq!(cell.margin_left_twips, Some(20));
3309        assert_eq!(cell.margin_bottom_twips, Some(30));
3310        assert_eq!(cell.margin_right_twips, Some(40));
3311        assert_eq!(cell.text_direction, Some(TextDirection::BottomToTop));
3312        assert_eq!(cell.vertical_alignment, Some(CellVerticalAlign::Bottom));
3313    }
3314
3315    #[test]
3316    fn cell_borders_and_margins_do_not_leak_into_the_table_level_border_color() {
3317        // Regression test for the `top`/`left`/`bottom`/`right` disambiguation in `parse_table`: a
3318        // cell's own `w:tcBorders` (fixed "auto" color, same as the table's) must never be mistaken
3319        // for the table-level `w:tblBorders` and populate `Table.border_color`.
3320        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3321<w:body>
3322<w:tbl>
3323<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="0000FF"/></w:tblBorders></w:tblPr>
3324<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3325<w:tr><w:tc><w:tcPr><w:tcBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr>
3326</w:tbl>
3327</w:body>
3328</w:document>"#;
3329
3330        let document = parse(xml);
3331        let tables: Vec<_> = document.tables().collect();
3332
3333        assert_eq!(tables[0].border_color.as_deref(), Some("0000FF"));
3334        assert!(tables[0].rows[0].cells[0].border);
3335    }
3336
3337    #[test]
3338    fn parses_row_repeat_as_header_height_and_cant_split() {
3339        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3340<w:body>
3341<w:tbl>
3342<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3343<w:tr><w:trPr><w:cantSplit/><w:trHeight w:val="500" w:hRule="atLeast"/><w:tblHeader/></w:trPr><w:tc><w:p><w:r><w:t>Header</w:t></w:r></w:p></w:tc></w:tr>
3344</w:tbl>
3345</w:body>
3346</w:document>"#;
3347
3348        let document = parse(xml);
3349        let tables: Vec<_> = document.tables().collect();
3350        let row = &tables[0].rows[0];
3351
3352        assert!(row.repeat_as_header_row);
3353        assert_eq!(row.height_twips, Some(500));
3354        assert!(row.cant_split);
3355    }
3356
3357    #[test]
3358    fn a_row_with_no_trpr_has_default_row_properties() {
3359        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3360<w:body>
3361<w:tbl>
3362<w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
3363<w:tr><w:tc><w:p><w:r><w:t>Ordinary</w:t></w:r></w:p></w:tc></w:tr>
3364</w:tbl>
3365</w:body>
3366</w:document>"#;
3367
3368        let document = parse(xml);
3369        let tables: Vec<_> = document.tables().collect();
3370        let row = &tables[0].rows[0];
3371
3372        assert!(!row.repeat_as_header_row);
3373        assert_eq!(row.height_twips, None);
3374        assert!(!row.cant_split);
3375    }
3376
3377    #[test]
3378    fn paragraphs_and_tables_are_kept_in_reading_order() {
3379        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3380<w:body>
3381<w:p><w:r><w:t>Before</w:t></w:r></w:p>
3382<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
3383<w:p><w:r><w:t>After</w:t></w:r></w:p>
3384</w:body>
3385</w:document>"#;
3386
3387        let document = parse(xml);
3388
3389        assert_eq!(document.blocks.len(), 3);
3390        assert!(matches!(document.blocks[0], Block::Paragraph(ref p) if p.text() == "Before"));
3391        assert!(matches!(document.blocks[1], Block::Table(_)));
3392        assert!(matches!(document.blocks[2], Block::Paragraph(ref p) if p.text() == "After"));
3393    }
3394
3395    #[test]
3396    fn resolves_an_inline_image_via_its_relationship() {
3397        use opc::{Part, Relationship};
3398
3399        let mut relationships = Relationships::new();
3400        relationships.add(Relationship {
3401            id: "rId2".to_string(),
3402            rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
3403                .to_string(),
3404            target: "media/image1.png".to_string(),
3405            target_mode: TargetMode::Internal,
3406        });
3407
3408        let mut package = Package::new();
3409        package.add_part(Part::new(
3410            "/word/media/image1.png",
3411            "image/png",
3412            vec![0u8, 1, 2, 3],
3413        ));
3414
3415        let resolver = ImageResolver {
3416            package: &package,
3417            part_relationships: &relationships,
3418        };
3419
3420        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3421<w:body><w:p><w:r><w:drawing><wp:inline>
3422<wp:extent cx="914400" cy="457200"/>
3423<wp:docPr id="1" name="rId2" descr="a test image"/>
3424<a:graphic><a:graphicData uri="picture"><pic:pic><pic:blipFill><a:blip r:embed="rId2"/></pic:blipFill></pic:pic></a:graphicData></a:graphic>
3425</wp:inline></w:drawing></w:r></w:p></w:body>
3426</w:document>"#;
3427
3428        let document = parse_document_xml(xml, &resolver).unwrap().document;
3429        let paragraphs: Vec<_> = document.paragraphs().collect();
3430        let image = paragraphs[0].runs[0]
3431            .image()
3432            .expect("should be an image run");
3433
3434        assert_eq!(image.data, vec![0u8, 1, 2, 3]);
3435        assert_eq!(image.format, ImageFormat::Png);
3436        assert_eq!(image.width_emu, 914_400);
3437        assert_eq!(image.height_emu, 457_200);
3438        assert_eq!(image.description, "a test image");
3439    }
3440
3441    #[test]
3442    fn a_drawing_with_no_blip_yields_no_image_run() {
3443        let package = Package::new();
3444        let relationships = Relationships::new();
3445        let resolver = ImageResolver {
3446            package: &package,
3447            part_relationships: &relationships,
3448        };
3449
3450        // A drawing that never got a real picture inserted (e.g. a shape, which we don't support):
3451        // no `a:blip`, so no relationship to resolve. Should not error out the whole document.
3452        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3453<w:body><w:p><w:r><w:drawing><wp:inline><wp:extent cx="1" cy="1"/></wp:inline></w:drawing></w:r></w:p></w:body>
3454</w:document>"#;
3455
3456        let document = parse_document_xml(xml, &resolver).unwrap().document;
3457        let paragraphs: Vec<_> = document.paragraphs().collect();
3458
3459        assert_eq!(paragraphs[0].runs.len(), 1);
3460        assert!(paragraphs[0].runs[0].image().is_none());
3461        assert_eq!(paragraphs[0].runs[0].text(), Some(""));
3462    }
3463
3464    #[test]
3465    fn captures_the_default_header_and_footer_relationship_ids_from_sectpr() {
3466        let package = Package::new();
3467        let relationships = Relationships::new();
3468        let resolver = ImageResolver {
3469            package: &package,
3470            part_relationships: &relationships,
3471        };
3472
3473        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3474<w:body>
3475<w:p><w:r><w:t>Body</w:t></w:r></w:p>
3476<w:sectPr>
3477<w:headerReference w:type="even" r:id="rId9"/>
3478<w:headerReference w:type="default" r:id="rId2"/>
3479<w:footerReference w:type="default" r:id="rId3"/>
3480<w:pgSz w:w="11906" w:h="16838"/>
3481</w:sectPr>
3482</w:body>
3483</w:document>"#;
3484
3485        let parsed = parse_document_xml(xml, &resolver).unwrap();
3486
3487        // The "default" and "even" references are each captured into their own field (see
3488        // `capture_header_footer_reference`); no "first" reference is present here, so that one
3489        // stays `None`.
3490        assert_eq!(parsed.header_relationship_id.as_deref(), Some("rId2"));
3491        assert_eq!(parsed.header_even_relationship_id.as_deref(), Some("rId9"));
3492        assert_eq!(parsed.header_first_relationship_id, None);
3493        assert_eq!(parsed.footer_relationship_id.as_deref(), Some("rId3"));
3494    }
3495
3496    #[test]
3497    fn no_header_or_footer_reference_yields_none() {
3498        let document = parse(
3499            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3500<w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:body>
3501</w:document>"#,
3502        );
3503
3504        assert!(document.header.is_none());
3505        assert!(document.footer.is_none());
3506    }
3507
3508    #[test]
3509    fn parses_a_page_number_field() {
3510        let document = parse(
3511            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3512<w:body><w:p><w:r><w:t>Page </w:t></w:r><w:fldSimple w:instr="PAGE"><w:r><w:rPr><w:b/></w:rPr><w:t>1</w:t></w:r></w:fldSimple></w:p></w:body>
3513</w:document>"#,
3514        );
3515        let paragraphs: Vec<_> = document.paragraphs().collect();
3516
3517        assert_eq!(paragraphs[0].runs.len(), 2);
3518        assert_eq!(paragraphs[0].runs[0].text(), Some("Page "));
3519        let field_run = &paragraphs[0].runs[1];
3520        assert_eq!(field_run.field(), Some(Field::PageNumber));
3521        // The field's own run carried `<w:b/>` — that formatting is kept.
3522        assert!(field_run.bold, "{field_run:?}");
3523    }
3524
3525    #[test]
3526    fn parses_a_total_pages_field_and_does_not_confuse_it_with_page() {
3527        let document = parse(
3528            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3529<w:body><w:p><w:fldSimple w:instr="NUMPAGES"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:body>
3530</w:document>"#,
3531        );
3532        let paragraphs: Vec<_> = document.paragraphs().collect();
3533
3534        assert_eq!(paragraphs[0].runs[0].field(), Some(Field::TotalPages));
3535    }
3536
3537    #[test]
3538    fn an_unsupported_field_instruction_is_silently_dropped() {
3539        let document = parse(
3540            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3541<w:body><w:p><w:r><w:t>Before</w:t></w:r><w:fldSimple w:instr="DATE \@ &quot;MM/dd/yyyy&quot;"><w:r><w:t>1/1/2026</w:t></w:r></w:fldSimple><w:r><w:t>After</w:t></w:r></w:p></w:body>
3542</w:document>"#,
3543        );
3544        let paragraphs: Vec<_> = document.paragraphs().collect();
3545
3546        // The unsupported DATE field is dropped, but the surrounding text runs are not affected.
3547        assert_eq!(paragraphs[0].runs.len(), 2);
3548        assert_eq!(paragraphs[0].runs[0].text(), Some("Before"));
3549        assert_eq!(paragraphs[0].runs[1].text(), Some("After"));
3550    }
3551
3552    #[test]
3553    fn a_self_closed_field_with_no_inner_run_is_parsed() {
3554        let document = parse(
3555            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3556<w:body><w:p><w:fldSimple w:instr="PAGE"/></w:p></w:body>
3557</w:document>"#,
3558        );
3559        let paragraphs: Vec<_> = document.paragraphs().collect();
3560
3561        assert_eq!(paragraphs[0].runs[0].field(), Some(Field::PageNumber));
3562    }
3563
3564    #[test]
3565    fn parses_a_paragraph_style_referenced_by_pstyle() {
3566        let document = parse(
3567            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3568<w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:jc w:val="center"/></w:pPr><w:r><w:t>Title</w:t></w:r></w:p></w:body>
3569</w:document>"#,
3570        );
3571        let paragraphs: Vec<_> = document.paragraphs().collect();
3572
3573        assert_eq!(paragraphs[0].style_id.as_deref(), Some("Heading1"));
3574        assert_eq!(paragraphs[0].alignment, Some(Alignment::Center));
3575    }
3576
3577    #[test]
3578    fn parses_a_character_style_referenced_by_rstyle() {
3579        let document = parse(
3580            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3581<w:body><w:p><w:r><w:rPr><w:rStyle w:val="Strong"/><w:b/></w:rPr><w:t>Bold</w:t></w:r></w:p></w:body>
3582</w:document>"#,
3583        );
3584        let paragraphs: Vec<_> = document.paragraphs().collect();
3585
3586        assert_eq!(paragraphs[0].runs[0].style_id.as_deref(), Some("Strong"));
3587        assert!(paragraphs[0].runs[0].bold);
3588    }
3589
3590    #[test]
3591    fn a_page_number_field_can_carry_a_run_style() {
3592        let document = parse(
3593            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3594<w:body><w:p><w:fldSimple w:instr="PAGE"><w:r><w:rPr><w:rStyle w:val="PageNumber"/></w:rPr><w:t>1</w:t></w:r></w:fldSimple></w:p></w:body>
3595</w:document>"#,
3596        );
3597        let paragraphs: Vec<_> = document.paragraphs().collect();
3598
3599        assert_eq!(
3600            paragraphs[0].runs[0].style_id.as_deref(),
3601            Some("PageNumber")
3602        );
3603    }
3604
3605    #[test]
3606    fn parses_styles_xml_into_paragraph_and_character_styles() {
3607        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3608<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3609<w:style w:type="paragraph" w:styleId="Heading1">
3610<w:name w:val="Heading 1"/>
3611<w:pPr><w:jc w:val="center"/></w:pPr>
3612<w:rPr><w:b/></w:rPr>
3613</w:style>
3614<w:style w:type="character" w:styleId="Strong">
3615<w:name w:val="Strong"/>
3616<w:basedOn w:val="DefaultParagraphFont"/>
3617<w:rPr><w:b/><w:i/></w:rPr>
3618</w:style>
3619<w:style w:type="table" w:styleId="TableGrid">
3620<w:name w:val="Table Grid"/>
3621</w:style>
3622</w:styles>"#;
3623
3624        let styles = parse_styles_xml(xml).unwrap();
3625
3626        // The `table`-typed style is skipped — only paragraph/character styles are modeled.
3627        assert_eq!(styles.len(), 2);
3628
3629        assert_eq!(styles[0].id, "Heading1");
3630        assert_eq!(styles[0].name, "Heading 1");
3631        assert_eq!(styles[0].kind, StyleKind::Paragraph);
3632        assert_eq!(styles[0].alignment, Some(Alignment::Center));
3633        assert!(styles[0].bold);
3634        assert_eq!(styles[0].based_on, None);
3635
3636        assert_eq!(styles[1].id, "Strong");
3637        assert_eq!(styles[1].kind, StyleKind::Character);
3638        assert_eq!(styles[1].based_on.as_deref(), Some("DefaultParagraphFont"));
3639        assert!(styles[1].bold);
3640        assert!(styles[1].italic);
3641    }
3642
3643    #[test]
3644    fn parses_a_style_with_color_size_and_font_family() {
3645        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3646<w:style w:type="paragraph" w:styleId="Heading1">
3647<w:name w:val="Heading 1"/>
3648<w:rPr><w:rFonts w:ascii="Cambria" w:hAnsi="Cambria"/><w:color w:val="2E74B5"/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr>
3649</w:style>
3650</w:styles>"#;
3651
3652        let styles = parse_styles_xml(xml).unwrap();
3653
3654        assert_eq!(styles[0].color.as_deref(), Some("2E74B5"));
3655        assert_eq!(styles[0].font_size, Some(16));
3656        assert_eq!(styles[0].font_family.as_deref(), Some("Cambria"));
3657    }
3658
3659    #[test]
3660    fn parses_a_style_with_a_highlight() {
3661        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3662<w:style w:type="paragraph" w:styleId="Heading1">
3663<w:name w:val="Heading 1"/>
3664<w:rPr><w:highlight w:val="lightGray"/></w:rPr>
3665</w:style>
3666</w:styles>"#;
3667
3668        let styles = parse_styles_xml(xml).unwrap();
3669
3670        assert_eq!(styles[0].highlight, Some(Highlight::LightGray));
3671    }
3672
3673    #[test]
3674    fn parses_a_style_with_strike_and_subscript() {
3675        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3676<w:style w:type="character" w:styleId="FootnoteReference">
3677<w:name w:val="footnote reference"/>
3678<w:rPr><w:strike/><w:vertAlign w:val="subscript"/></w:rPr>
3679</w:style>
3680</w:styles>"#;
3681
3682        let styles = parse_styles_xml(xml).unwrap();
3683
3684        assert!(styles[0].strike);
3685        assert_eq!(styles[0].vertical_align, Some(VerticalAlign::Subscript));
3686    }
3687
3688    #[test]
3689    fn parses_a_style_with_a_double_underline_and_color() {
3690        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3691<w:style w:type="paragraph" w:styleId="Heading1">
3692<w:name w:val="Heading 1"/>
3693<w:rPr><w:u w:val="double" w:color="2E74B5"/></w:rPr>
3694</w:style>
3695</w:styles>"#;
3696
3697        let styles = parse_styles_xml(xml).unwrap();
3698
3699        assert_eq!(styles[0].underline, Some(UnderlineStyle::Double));
3700        assert_eq!(styles[0].underline_color.as_deref(), Some("2E74B5"));
3701    }
3702
3703    #[test]
3704    fn parses_a_style_with_small_caps_shading_border_spacing_and_hidden() {
3705        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3706<w:style w:type="character" w:styleId="Emphasis">
3707<w:name w:val="Emphasis"/>
3708<w:rPr><w:smallCaps/><w:caps/><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/><w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:spacing w:val="40"/><w:position w:val="-6"/><w:vanish/></w:rPr>
3709</w:style>
3710</w:styles>"#;
3711
3712        let styles = parse_styles_xml(xml).unwrap();
3713        let style = &styles[0];
3714
3715        assert!(style.small_caps);
3716        assert!(style.all_caps);
3717        assert_eq!(style.shading_color.as_deref(), Some("FFFF00"));
3718        assert!(style.text_border);
3719        assert_eq!(style.character_spacing, Some(40));
3720        assert_eq!(style.vertical_position, Some(-6));
3721        assert!(style.hidden);
3722    }
3723
3724    #[test]
3725    fn parses_a_paragraph_style_with_spacing_shading_and_border() {
3726        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3727<w:style w:type="paragraph" w:styleId="Heading1">
3728<w:name w:val="Heading 1"/>
3729<w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:left w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="auto"/><w:right w:val="single" w:sz="4" w:space="0" w:color="auto"/></w:pBdr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/><w:spacing w:before="240" w:after="120" w:line="360" w:lineRule="auto"/></w:pPr>
3730</w:style>
3731</w:styles>"#;
3732
3733        let styles = parse_styles_xml(xml).unwrap();
3734        let style = &styles[0];
3735
3736        assert!(style.paragraph_border);
3737        assert_eq!(style.paragraph_shading_color.as_deref(), Some("D9D9D9"));
3738        assert_eq!(style.line_spacing, Some(360));
3739        assert_eq!(style.space_before, Some(240));
3740        assert_eq!(style.space_after, Some(120));
3741    }
3742
3743    #[test]
3744    fn parses_a_paragraph_style_with_keep_settings_tabs_and_contextual_spacing() {
3745        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3746<w:style w:type="paragraph" w:styleId="ListParagraph">
3747<w:name w:val="List Paragraph"/>
3748<w:pPr><w:keepNext/><w:keepLines/><w:pageBreakBefore/><w:tabs><w:tab w:val="left" w:pos="720"/></w:tabs><w:contextualSpacing/></w:pPr>
3749</w:style>
3750</w:styles>"#;
3751
3752        let styles = parse_styles_xml(xml).unwrap();
3753        let style = &styles[0];
3754
3755        assert!(style.keep_with_next);
3756        assert!(style.keep_lines_together);
3757        assert!(style.page_break_before);
3758        assert!(style.contextual_spacing);
3759        assert_eq!(style.tabs.len(), 1);
3760        assert_eq!(style.tabs[0].position_twips, 720);
3761    }
3762
3763    // Mirrors `paragraph_and_run_level_shading_and_spacing_do_not_collide` above, but for a style
3764    // declaring BOTH its own run-level formatting (`w:rPr`) and paragraph-level formatting
3765    // (`w:pPr`) at once — proves `in_paragraph_properties` correctly routes `w:shd`/`w:spacing` to
3766    // the right pair of fields depending on which parent they're nested in.
3767    #[test]
3768    fn style_paragraph_and_run_level_shading_and_spacing_do_not_collide() {
3769        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3770<w:style w:type="paragraph" w:styleId="Heading1">
3771<w:name w:val="Heading 1"/>
3772<w:pPr><w:shd w:val="clear" w:color="auto" w:fill="D9D9D9"/><w:spacing w:before="240" w:after="120" w:line="480" w:lineRule="auto"/></w:pPr>
3773<w:rPr><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/><w:spacing w:val="40"/></w:rPr>
3774</w:style>
3775</w:styles>"#;
3776
3777        let styles = parse_styles_xml(xml).unwrap();
3778        let style = &styles[0];
3779
3780        assert_eq!(style.paragraph_shading_color.as_deref(), Some("D9D9D9"));
3781        assert_eq!(style.line_spacing, Some(480));
3782        assert_eq!(style.space_before, Some(240));
3783        assert_eq!(style.space_after, Some(120));
3784        assert_eq!(style.shading_color.as_deref(), Some("FFFF00"));
3785        assert_eq!(style.character_spacing, Some(40));
3786    }
3787
3788    #[test]
3789    fn an_unrecognized_style_type_is_skipped_without_disturbing_the_next_style() {
3790        let xml = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3791<w:style w:type="numbering" w:styleId="ListParagraph">
3792<w:name w:val="List Paragraph"/>
3793</w:style>
3794<w:style w:type="paragraph" w:styleId="Normal">
3795<w:name w:val="Normal"/>
3796</w:style>
3797</w:styles>"#;
3798
3799        let styles = parse_styles_xml(xml).unwrap();
3800
3801        assert_eq!(styles.len(), 1);
3802        assert_eq!(styles[0].id, "Normal");
3803    }
3804
3805    #[test]
3806    fn a_document_without_a_styles_relationship_has_no_styles() {
3807        let document = parse(
3808            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3809<w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body>
3810</w:document>"#,
3811        );
3812
3813        assert!(document.styles.is_empty());
3814    }
3815
3816    #[test]
3817    fn resolves_an_external_hyperlink_via_its_relationship() {
3818        use opc::Relationship;
3819
3820        let mut relationships = Relationships::new();
3821        relationships.add(Relationship {
3822            id: "rId4".to_string(),
3823            rel_type:
3824                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3825                    .to_string(),
3826            target: "https://www.rust-lang.org".to_string(),
3827            target_mode: TargetMode::External,
3828        });
3829
3830        let package = Package::new();
3831        let resolver = ImageResolver {
3832            package: &package,
3833            part_relationships: &relationships,
3834        };
3835
3836        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3837<w:body><w:p><w:hyperlink r:id="rId4"><w:r><w:t>Rust</w:t></w:r></w:hyperlink></w:p></w:body>
3838</w:document>"#;
3839
3840        let document = parse_document_xml(xml, &resolver).unwrap().document;
3841        let paragraphs: Vec<_> = document.paragraphs().collect();
3842
3843        assert_eq!(paragraphs[0].runs.len(), 1);
3844        assert_eq!(paragraphs[0].runs[0].text(), Some("Rust"));
3845        assert_eq!(
3846            paragraphs[0].runs[0].hyperlink,
3847            Some(Hyperlink::External("https://www.rust-lang.org".to_string()))
3848        );
3849    }
3850
3851    #[test]
3852    fn parses_an_internal_hyperlink_from_its_anchor_attribute() {
3853        let document = parse(
3854            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3855<w:body><w:p><w:hyperlink w:anchor="Section1"><w:r><w:t>See above</w:t></w:r></w:hyperlink></w:p></w:body>
3856</w:document>"#,
3857        );
3858        let paragraphs: Vec<_> = document.paragraphs().collect();
3859
3860        assert_eq!(
3861            paragraphs[0].runs[0].hyperlink,
3862            Some(Hyperlink::Internal("Section1".to_string()))
3863        );
3864    }
3865
3866    #[test]
3867    fn a_hyperlink_id_with_no_matching_relationship_is_dropped_but_its_run_is_kept() {
3868        // A malformed document (r:id declared but not backed by a real relationship) — same
3869        // silent-drop policy as an unsupported field or an unresolvable image, rather than failing
3870        // the whole document.
3871        let document = parse(
3872            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3873<w:body><w:p><w:hyperlink r:id="rId99"><w:r><w:t>Broken link</w:t></w:r></w:hyperlink></w:p></w:body>
3874</w:document>"#,
3875        );
3876        let paragraphs: Vec<_> = document.paragraphs().collect();
3877
3878        assert_eq!(paragraphs[0].runs[0].text(), Some("Broken link"));
3879        assert_eq!(paragraphs[0].runs[0].hyperlink, None);
3880    }
3881
3882    #[test]
3883    fn multiple_runs_inside_one_hyperlink_all_get_the_same_target() {
3884        let document = parse(
3885            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3886<w:body><w:p><w:hyperlink w:anchor="Top"><w:r><w:rPr><w:b/></w:rPr><w:t>Back </w:t></w:r><w:r><w:t>to top</w:t></w:r></w:hyperlink></w:p></w:body>
3887</w:document>"#,
3888        );
3889        let paragraphs: Vec<_> = document.paragraphs().collect();
3890        let runs = &paragraphs[0].runs;
3891
3892        assert_eq!(runs.len(), 2);
3893        assert_eq!(
3894            runs[0].hyperlink,
3895            Some(Hyperlink::Internal("Top".to_string()))
3896        );
3897        assert_eq!(
3898            runs[1].hyperlink,
3899            Some(Hyperlink::Internal("Top".to_string()))
3900        );
3901        assert!(runs[0].bold);
3902        assert!(!runs[1].bold);
3903    }
3904
3905    #[test]
3906    fn a_run_outside_any_hyperlink_has_no_hyperlink() {
3907        let document = parse(
3908            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3909<w:body><w:p><w:hyperlink w:anchor="Top"><w:r><w:t>link</w:t></w:r></w:hyperlink><w:r><w:t> plain</w:t></w:r></w:p></w:body>
3910</w:document>"#,
3911        );
3912        let paragraphs: Vec<_> = document.paragraphs().collect();
3913        let runs = &paragraphs[0].runs;
3914
3915        assert_eq!(runs.len(), 2);
3916        assert!(runs[0].hyperlink.is_some());
3917        assert_eq!(runs[1].hyperlink, None);
3918    }
3919
3920    #[test]
3921    fn parses_a_paragraphs_numpr_into_numbering_id_and_level() {
3922        let document = parse(
3923            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3924<w:body><w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="7"/></w:numPr></w:pPr><w:r><w:t>Item</w:t></w:r></w:p></w:body>
3925</w:document>"#,
3926        );
3927        let paragraphs: Vec<_> = document.paragraphs().collect();
3928
3929        assert_eq!(paragraphs[0].numbering_id, Some(7));
3930        assert_eq!(paragraphs[0].numbering_level, 1);
3931    }
3932
3933    #[test]
3934    fn a_paragraph_without_numpr_has_no_numbering() {
3935        let document = parse(
3936            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3937<w:body><w:p><w:r><w:t>Not a list item</w:t></w:r></w:p></w:body>
3938</w:document>"#,
3939        );
3940        let paragraphs: Vec<_> = document.paragraphs().collect();
3941
3942        assert_eq!(paragraphs[0].numbering_id, None);
3943        assert_eq!(paragraphs[0].numbering_level, 0);
3944    }
3945
3946    #[test]
3947    fn parses_numbering_xml_resolving_num_through_its_abstractnum() {
3948        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3949<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3950<w:abstractNum w:abstractNumId="0">
3951<w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
3952<w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="◦"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl>
3953</w:abstractNum>
3954<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
3955</w:numbering>"#;
3956
3957        let definitions = parse_numbering_xml(xml).unwrap();
3958
3959        assert_eq!(definitions.len(), 1);
3960        assert_eq!(definitions[0].id, 1);
3961        assert_eq!(definitions[0].levels.len(), 2);
3962        assert_eq!(definitions[0].levels[0].format, NumberFormat::Bullet);
3963        assert_eq!(definitions[0].levels[0].text, "•");
3964        assert_eq!(definitions[0].levels[0].indent_twips, 720);
3965        assert_eq!(definitions[0].levels[0].hanging_twips, 360);
3966        assert_eq!(definitions[0].levels[1].text, "◦");
3967    }
3968
3969    #[test]
3970    fn numid_and_abstractnumid_can_differ_like_in_a_real_word_document() {
3971        // Real Word-authored documents commonly have several `w:num` entries with different
3972        // `numId`s all pointing at the same `w:abstractNum` (or at least not numerically matching
3973        // it) — unlike this crate's own writer, which always keeps them equal for simplicity. The
3974        // reader must not assume they match.
3975        let xml = r#"<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3976<w:abstractNum w:abstractNumId="4">
3977<w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl>
3978</w:abstractNum>
3979<w:num w:numId="2"><w:abstractNumId w:val="4"/></w:num>
3980</w:numbering>"#;
3981
3982        let definitions = parse_numbering_xml(xml).unwrap();
3983
3984        assert_eq!(definitions.len(), 1);
3985        assert_eq!(definitions[0].id, 2);
3986        assert_eq!(definitions[0].levels[0].format, NumberFormat::Decimal);
3987    }
3988
3989    #[test]
3990    fn a_num_referencing_an_undeclared_abstractnum_gets_empty_levels() {
3991        let xml = r#"<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3992<w:num w:numId="1"><w:abstractNumId w:val="99"/></w:num>
3993</w:numbering>"#;
3994
3995        let definitions = parse_numbering_xml(xml).unwrap();
3996
3997        assert_eq!(definitions.len(), 1);
3998        assert_eq!(definitions[0].id, 1);
3999        assert!(definitions[0].levels.is_empty());
4000    }
4001
4002    #[test]
4003    fn a_document_without_a_numbering_relationship_has_no_numbering_definitions() {
4004        let document = parse(
4005            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4006<w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body>
4007</w:document>"#,
4008        );
4009
4010        assert!(document.numbering_definitions.is_empty());
4011    }
4012
4013    #[test]
4014    fn parses_a_footnote_reference_run_into_a_run_content_variant() {
4015        let document = parse(
4016            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4017<w:body><w:p><w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteReference w:id="1"/></w:r></w:p></w:body>
4018</w:document>"#,
4019        );
4020
4021        let paragraphs: Vec<_> = document.paragraphs().collect();
4022        assert_eq!(paragraphs[0].runs.len(), 1);
4023        assert_eq!(
4024            paragraphs[0].runs[0].content,
4025            RunContent::NoteReference(NoteReference::Footnote(1))
4026        );
4027        assert_eq!(
4028            paragraphs[0].runs[0].style_id.as_deref(),
4029            Some("FootnoteReference")
4030        );
4031    }
4032
4033    #[test]
4034    fn parses_an_endnote_reference_run() {
4035        let document = parse(
4036            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4037<w:body><w:p><w:r><w:endnoteReference w:id="3"/></w:r></w:p></w:body>
4038</w:document>"#,
4039        );
4040
4041        let paragraphs: Vec<_> = document.paragraphs().collect();
4042        assert_eq!(
4043            paragraphs[0].runs[0].content,
4044            RunContent::NoteReference(NoteReference::Endnote(3))
4045        );
4046    }
4047
4048    #[test]
4049    fn parses_notes_xml_into_notes_with_their_blocks() {
4050        let package = Package::new();
4051        let relationships = Relationships::new();
4052        let resolver = ImageResolver {
4053            package: &package,
4054            part_relationships: &relationships,
4055        };
4056
4057        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4058<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4059<w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> </w:t></w:r><w:r><w:t>Some detail.</w:t></w:r></w:p></w:footnote>
4060</w:footnotes>"#;
4061
4062        let notes = parse_notes_xml(xml, &resolver, b"footnote").unwrap();
4063
4064        assert_eq!(notes.len(), 1);
4065        assert_eq!(notes[0].id, 1);
4066        let paragraph = notes[0].paragraphs().next().expect("missing paragraph");
4067        assert_eq!(paragraph.runs.len(), 3);
4068        assert_eq!(
4069            paragraph.runs[0].content,
4070            RunContent::NoteMarker(NoteKind::Footnote)
4071        );
4072        assert_eq!(paragraph.runs[2].text(), Some("Some detail."));
4073    }
4074
4075    #[test]
4076    fn skips_separator_and_continuation_separator_notes() {
4077        let package = Package::new();
4078        let relationships = Relationships::new();
4079        let resolver = ImageResolver {
4080            package: &package,
4081            part_relationships: &relationships,
4082        };
4083
4084        // Real Word-authored footnotes.xml files always carry these two boilerplate entries — they
4085        // must not show up as user-visible notes (see `Note`'s doc comment).
4086        let xml = r#"<w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4087<w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote>
4088<w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:footnote>
4089<w:footnote w:id="1"><w:p><w:r><w:t>Real footnote.</w:t></w:r></w:p></w:footnote>
4090</w:footnotes>"#;
4091
4092        let notes = parse_notes_xml(xml, &resolver, b"footnote").unwrap();
4093
4094        assert_eq!(notes.len(), 1);
4095        assert_eq!(notes[0].id, 1);
4096    }
4097
4098    #[test]
4099    fn a_document_without_a_footnotes_or_endnotes_relationship_has_none() {
4100        let document = parse(
4101            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4102<w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body>
4103</w:document>"#,
4104        );
4105
4106        assert!(document.footnotes.is_empty());
4107        assert!(document.endnotes.is_empty());
4108    }
4109
4110    #[test]
4111    fn parses_a_comment_range_into_the_covered_run_s_comment_ids() {
4112        // ECMA-376's own `commentRangeStart` example fragment, verbatim (see this project's own
4113        // copy of it in `model.rs`'s `Run.comment_ids` doc comment).
4114        let document = parse(
4115            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4116<w:body><w:p>
4117<w:r><w:t xml:space="preserve">Some </w:t></w:r>
4118<w:commentRangeStart w:id="0"/>
4119<w:r><w:t>text.</w:t></w:r>
4120<w:commentRangeEnd w:id="0"/>
4121<w:r><w:commentReference w:id="0"/></w:r>
4122</w:p></w:body>
4123</w:document>"#,
4124        );
4125
4126        let paragraphs: Vec<_> = document.paragraphs().collect();
4127        let runs = &paragraphs[0].runs;
4128        // The reference run itself isn't inside the range (it comes after `commentRangeEnd`) and
4129        // carries no text — it's still a run in the model (matching what was actually in the XML),
4130        // just with no comment id of its own.
4131        assert_eq!(runs.len(), 3);
4132        assert_eq!(runs[0].text(), Some("Some "));
4133        assert!(runs[0].comment_ids.is_empty(), "{:?}", runs[0].comment_ids);
4134        assert_eq!(runs[1].text(), Some("text."));
4135        assert_eq!(runs[1].comment_ids, vec![0]);
4136        assert!(runs[2].comment_ids.is_empty(), "{:?}", runs[2].comment_ids);
4137    }
4138
4139    #[test]
4140    fn overlapping_comment_ranges_parse_back_into_each_run_s_own_id_set() {
4141        let document = parse(
4142            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4143<w:body><w:p>
4144<w:commentRangeStart w:id="0"/>
4145<w:r><w:t>a</w:t></w:r>
4146<w:commentRangeStart w:id="1"/>
4147<w:r><w:t>b</w:t></w:r>
4148<w:commentRangeEnd w:id="1"/>
4149<w:r><w:commentReference w:id="1"/></w:r>
4150<w:r><w:t>c</w:t></w:r>
4151<w:commentRangeEnd w:id="0"/>
4152<w:r><w:commentReference w:id="0"/></w:r>
4153</w:p></w:body>
4154</w:document>"#,
4155        );
4156
4157        let paragraphs: Vec<_> = document.paragraphs().collect();
4158        let text_runs: Vec<_> = paragraphs[0]
4159            .runs
4160            .iter()
4161            .filter(|run| run.text().is_some_and(|text| !text.is_empty()))
4162            .collect();
4163        assert_eq!(text_runs[0].text(), Some("a"));
4164        assert_eq!(text_runs[0].comment_ids, vec![0]);
4165        assert_eq!(text_runs[1].text(), Some("b"));
4166        assert_eq!(text_runs[1].comment_ids, vec![0, 1]);
4167        assert_eq!(text_runs[2].text(), Some("c"));
4168        assert_eq!(text_runs[2].comment_ids, vec![0]);
4169    }
4170
4171    #[test]
4172    fn parses_a_bookmark_range_into_the_covered_run_s_bookmarks() {
4173        let document = parse(
4174            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4175<w:body><w:p>
4176<w:r><w:t xml:space="preserve">Some </w:t></w:r>
4177<w:bookmarkStart w:id="0" w:name="MyBookmark"/>
4178<w:r><w:t>text.</w:t></w:r>
4179<w:bookmarkEnd w:id="0"/>
4180</w:p></w:body>
4181</w:document>"#,
4182        );
4183
4184        let paragraphs: Vec<_> = document.paragraphs().collect();
4185        let runs = &paragraphs[0].runs;
4186        assert_eq!(runs.len(), 2);
4187        assert_eq!(runs[0].text(), Some("Some "));
4188        assert!(runs[0].bookmarks.is_empty(), "{:?}", runs[0].bookmarks);
4189        assert_eq!(runs[1].text(), Some("text."));
4190        assert_eq!(runs[1].bookmarks, vec![Bookmark::new(0, "MyBookmark")]);
4191    }
4192
4193    #[test]
4194    fn overlapping_bookmarks_parse_back_into_each_run_s_own_set() {
4195        let document = parse(
4196            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4197<w:body><w:p>
4198<w:bookmarkStart w:id="0" w:name="Outer"/>
4199<w:r><w:t>a</w:t></w:r>
4200<w:bookmarkStart w:id="1" w:name="Inner"/>
4201<w:r><w:t>b</w:t></w:r>
4202<w:bookmarkEnd w:id="1"/>
4203<w:r><w:t>c</w:t></w:r>
4204<w:bookmarkEnd w:id="0"/>
4205</w:p></w:body>
4206</w:document>"#,
4207        );
4208
4209        let paragraphs: Vec<_> = document.paragraphs().collect();
4210        let runs = &paragraphs[0].runs;
4211        assert_eq!(runs[0].text(), Some("a"));
4212        assert_eq!(runs[0].bookmarks, vec![Bookmark::new(0, "Outer")]);
4213        assert_eq!(runs[1].text(), Some("b"));
4214        assert_eq!(
4215            runs[1].bookmarks,
4216            vec![Bookmark::new(0, "Outer"), Bookmark::new(1, "Inner")]
4217        );
4218        assert_eq!(runs[2].text(), Some("c"));
4219        assert_eq!(runs[2].bookmarks, vec![Bookmark::new(0, "Outer")]);
4220    }
4221
4222    #[test]
4223    fn parses_comments_xml_into_comments_with_author_date_initials_and_blocks() {
4224        let package = Package::new();
4225        let relationships = Relationships::new();
4226        let resolver = ImageResolver {
4227            package: &package,
4228            part_relationships: &relationships,
4229        };
4230
4231        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4232<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4233<w:comment w:id="0" w:author="Morgan" w:date="2026-07-16T12:00:00Z" w:initials="MR"><w:p><w:r><w:t>Please rephrase.</w:t></w:r></w:p></w:comment>
4234</w:comments>"#;
4235
4236        let comments = parse_comments_xml(xml, &resolver).unwrap();
4237
4238        assert_eq!(comments.len(), 1);
4239        assert_eq!(comments[0].id, 0);
4240        assert_eq!(comments[0].author.as_deref(), Some("Morgan"));
4241        assert_eq!(comments[0].date.as_deref(), Some("2026-07-16T12:00:00Z"));
4242        assert_eq!(comments[0].initials.as_deref(), Some("MR"));
4243        let paragraph = comments[0].paragraphs().next().expect("missing paragraph");
4244        assert_eq!(paragraph.text(), "Please rephrase.");
4245    }
4246
4247    #[test]
4248    fn a_comment_with_no_id_is_skipped() {
4249        let package = Package::new();
4250        let relationships = Relationships::new();
4251        let resolver = ImageResolver {
4252            package: &package,
4253            part_relationships: &relationships,
4254        };
4255
4256        let xml = r#"<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4257<w:comment w:author="No id"><w:p><w:r><w:t>Malformed.</w:t></w:r></w:p></w:comment>
4258<w:comment w:id="1"><w:p><w:r><w:t>Valid.</w:t></w:r></w:p></w:comment>
4259</w:comments>"#;
4260
4261        let comments = parse_comments_xml(xml, &resolver).unwrap();
4262
4263        assert_eq!(comments.len(), 1);
4264        assert_eq!(comments[0].id, 1);
4265    }
4266
4267    #[test]
4268    fn a_document_without_a_comments_relationship_has_none() {
4269        let document = parse(
4270            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4271<w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body>
4272</w:document>"#,
4273        );
4274
4275        assert!(document.comments.is_empty());
4276    }
4277
4278    #[test]
4279    fn parses_a_structured_document_tag_with_its_properties_and_content() {
4280        let document = parse(
4281            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4282<w:body><w:sdt>
4283<w:sdtPr><w:alias w:val="Customer Name"/><w:tag w:val="CustomerName"/><w:id w:val="1"/></w:sdtPr>
4284<w:sdtContent><w:p><w:r><w:t>Acme Corp</w:t></w:r></w:p></w:sdtContent>
4285</w:sdt></w:body>
4286</w:document>"#,
4287        );
4288
4289        assert_eq!(document.blocks.len(), 1);
4290        match &document.blocks[0] {
4291            Block::StructuredDocumentTag(sdt) => {
4292                assert_eq!(sdt.id, Some(1));
4293                assert_eq!(sdt.tag.as_deref(), Some("CustomerName"));
4294                assert_eq!(sdt.alias.as_deref(), Some("Customer Name"));
4295                assert_eq!(
4296                    sdt.paragraphs().next().expect("missing paragraph").text(),
4297                    "Acme Corp"
4298                );
4299            }
4300            other => panic!("expected a StructuredDocumentTag block, got {other:?}"),
4301        }
4302    }
4303
4304    #[test]
4305    fn a_structured_document_tag_with_no_sdtpr_has_no_properties() {
4306        let document = parse(
4307            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4308<w:body><w:sdt><w:sdtContent><w:p><w:r><w:t>Plain</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body>
4309</w:document>"#,
4310        );
4311
4312        match &document.blocks[0] {
4313            Block::StructuredDocumentTag(sdt) => {
4314                assert!(sdt.id.is_none());
4315                assert!(sdt.tag.is_none());
4316                assert!(sdt.alias.is_none());
4317                assert_eq!(sdt.paragraphs().next().unwrap().text(), "Plain");
4318            }
4319            other => panic!("expected a StructuredDocumentTag block, got {other:?}"),
4320        }
4321    }
4322
4323    #[test]
4324    fn a_nested_structured_document_tag_parses_correctly() {
4325        let document = parse(
4326            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4327<w:body><w:sdt>
4328<w:sdtPr><w:tag w:val="outer"/></w:sdtPr>
4329<w:sdtContent><w:sdt>
4330<w:sdtPr><w:tag w:val="inner"/></w:sdtPr>
4331<w:sdtContent><w:p><w:r><w:t>nested</w:t></w:r></w:p></w:sdtContent>
4332</w:sdt></w:sdtContent>
4333</w:sdt></w:body>
4334</w:document>"#,
4335        );
4336
4337        match &document.blocks[0] {
4338            Block::StructuredDocumentTag(outer) => {
4339                assert_eq!(outer.tag.as_deref(), Some("outer"));
4340                assert_eq!(outer.blocks.len(), 1);
4341                match &outer.blocks[0] {
4342                    Block::StructuredDocumentTag(inner) => {
4343                        assert_eq!(inner.tag.as_deref(), Some("inner"));
4344                        assert_eq!(inner.paragraphs().next().unwrap().text(), "nested");
4345                    }
4346                    other => panic!("expected a nested StructuredDocumentTag block, got {other:?}"),
4347                }
4348            }
4349            other => panic!("expected a StructuredDocumentTag block, got {other:?}"),
4350        }
4351    }
4352
4353    #[test]
4354    fn a_structured_document_tag_interleaved_with_paragraphs_keeps_reading_order() {
4355        let document = parse(
4356            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4357<w:body>
4358<w:p><w:r><w:t>Before</w:t></w:r></w:p>
4359<w:sdt><w:sdtContent><w:p><w:r><w:t>Inside</w:t></w:r></w:p></w:sdtContent></w:sdt>
4360<w:p><w:r><w:t>After</w:t></w:r></w:p>
4361</w:body>
4362</w:document>"#,
4363        );
4364
4365        assert_eq!(document.blocks.len(), 3);
4366        assert!(matches!(&document.blocks[0], Block::Paragraph(p) if p.text() == "Before"));
4367        assert!(matches!(
4368            &document.blocks[1],
4369            Block::StructuredDocumentTag(_)
4370        ));
4371        assert!(matches!(&document.blocks[2], Block::Paragraph(p) if p.text() == "After"));
4372    }
4373
4374    #[test]
4375    fn parses_a_theme_with_colors_and_fonts() {
4376        let theme = parse_theme_xml(
4377            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4378<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom">
4379<a:themeElements>
4380<a:clrScheme name="Custom">
4381<a:dk1><a:srgbClr val="111111"/></a:dk1>
4382<a:lt1><a:srgbClr val="EEEEEE"/></a:lt1>
4383<a:dk2><a:srgbClr val="222222"/></a:dk2>
4384<a:lt2><a:srgbClr val="DDDDDD"/></a:lt2>
4385<a:accent1><a:srgbClr val="AA0000"/></a:accent1>
4386<a:accent2><a:srgbClr val="BB1100"/></a:accent2>
4387<a:accent3><a:srgbClr val="CC2200"/></a:accent3>
4388<a:accent4><a:srgbClr val="DD3300"/></a:accent4>
4389<a:accent5><a:srgbClr val="EE4400"/></a:accent5>
4390<a:accent6><a:srgbClr val="FF5500"/></a:accent6>
4391<a:hlink><a:srgbClr val="0000AA"/></a:hlink>
4392<a:folHlink><a:srgbClr val="AA00AA"/></a:folHlink>
4393</a:clrScheme>
4394<a:fontScheme name="Custom">
4395<a:majorFont><a:latin typeface="Georgia"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
4396<a:minorFont><a:latin typeface="Verdana"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
4397</a:fontScheme>
4398<a:fmtScheme name="Office"><a:fillStyleLst/><a:lnStyleLst/><a:effectStyleLst/><a:bgFillStyleLst/></a:fmtScheme>
4399</a:themeElements>
4400</a:theme>"#,
4401        )
4402        .unwrap();
4403
4404        assert_eq!(theme.name, "Custom");
4405        assert_eq!(theme.colors.dark1, "111111");
4406        assert_eq!(theme.colors.light1, "EEEEEE");
4407        assert_eq!(theme.colors.accent6, "FF5500");
4408        assert_eq!(theme.colors.followed_hyperlink, "AA00AA");
4409        assert_eq!(theme.fonts.major_latin, "Georgia");
4410        assert_eq!(theme.fonts.minor_latin, "Verdana");
4411    }
4412
4413    #[test]
4414    fn parses_trackrevisions_from_settings_xml() {
4415        let xml = r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4416<w:trackRevisions/>
4417</w:settings>"#;
4418
4419        let (track_changes, _protection) = parse_settings_xml(xml).unwrap();
4420        assert!(track_changes);
4421    }
4422
4423    #[test]
4424    fn a_settings_xml_with_no_trackrevisions_parses_as_false() {
4425        let xml = r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4426<w:evenAndOddHeaders/>
4427</w:settings>"#;
4428
4429        let (track_changes, _protection) = parse_settings_xml(xml).unwrap();
4430        assert!(!track_changes);
4431    }
4432
4433    #[test]
4434    fn an_explicit_trackrevisions_val_false_parses_as_false() {
4435        let xml = r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4436<w:trackRevisions w:val="false"/>
4437</w:settings>"#;
4438
4439        let (track_changes, _protection) = parse_settings_xml(xml).unwrap();
4440        assert!(!track_changes);
4441    }
4442
4443    #[test]
4444    fn parses_documentprotection_edit_value_into_protectionkind() {
4445        let xml = r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4446<w:documentProtection w:edit="readOnly" w:enforcement="1"/>
4447</w:settings>"#;
4448
4449        let (_track_changes, protection) = parse_settings_xml(xml).unwrap();
4450        assert_eq!(protection, Some(ProtectionKind::ReadOnly));
4451    }
4452
4453    #[test]
4454    fn a_settings_xml_with_no_documentprotection_parses_protection_as_none() {
4455        let xml = r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4456<w:trackRevisions/>
4457</w:settings>"#;
4458
4459        let (_track_changes, protection) = parse_settings_xml(xml).unwrap();
4460        assert_eq!(protection, None);
4461    }
4462
4463    #[test]
4464    fn an_unrecognized_documentprotection_edit_value_parses_as_none() {
4465        let xml = r#"<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4466<w:documentProtection w:edit="none" w:enforcement="1"/>
4467</w:settings>"#;
4468
4469        let (_track_changes, protection) = parse_settings_xml(xml).unwrap();
4470        assert_eq!(protection, None);
4471    }
4472
4473    #[test]
4474    fn parses_core_properties_xml_into_documentproperties() {
4475        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4476<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
4477<dc:title>Rapport trimestriel</dc:title>
4478<dc:subject>Finances</dc:subject>
4479<dc:creator>Morgan &amp; Associ&#233;s</dc:creator>
4480<cp:keywords>rapport, finances</cp:keywords>
4481<dc:description>Notes internes</dc:description>
4482<cp:lastModifiedBy>Morgan</cp:lastModifiedBy>
4483<cp:revision>3</cp:revision>
4484<dcterms:created xsi:type="dcterms:W3CDTF">2026-07-17T10:00:00Z</dcterms:created>
4485<dcterms:modified xsi:type="dcterms:W3CDTF">2026-07-17T11:30:00Z</dcterms:modified>
4486<cp:category>Interne</cp:category>
4487<cp:contentStatus>Draft</cp:contentStatus>
4488</cp:coreProperties>"#;
4489
4490        let properties = parse_core_properties_xml(xml).unwrap();
4491
4492        assert_eq!(properties.title.as_deref(), Some("Rapport trimestriel"));
4493        assert_eq!(properties.subject.as_deref(), Some("Finances"));
4494        // Confirms both a named entity (`&amp;`) and a numeric character reference (`&#233;`, "é")
4495        // survive `Event::GeneralRef` decoding — same class of bug already fixed once for run text
4496        // (see the CHANGELOG), now exercised here for core properties too.
4497        assert_eq!(properties.creator.as_deref(), Some("Morgan & Associés"));
4498        assert_eq!(properties.keywords.as_deref(), Some("rapport, finances"));
4499        assert_eq!(properties.description.as_deref(), Some("Notes internes"));
4500        assert_eq!(properties.last_modified_by.as_deref(), Some("Morgan"));
4501        assert_eq!(properties.revision.as_deref(), Some("3"));
4502        assert_eq!(properties.created.as_deref(), Some("2026-07-17T10:00:00Z"));
4503        assert_eq!(properties.modified.as_deref(), Some("2026-07-17T11:30:00Z"));
4504        assert_eq!(properties.category.as_deref(), Some("Interne"));
4505        assert_eq!(properties.content_status.as_deref(), Some("Draft"));
4506    }
4507
4508    #[test]
4509    fn an_empty_core_properties_element_parses_as_all_none() {
4510        let xml = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>"#;
4511
4512        let properties = parse_core_properties_xml(xml).unwrap();
4513
4514        assert_eq!(properties, DocumentProperties::new());
4515    }
4516
4517    #[test]
4518    fn a_sysclr_color_slot_uses_its_lastclr_fallback() {
4519        let theme = parse_theme_xml(
4520            r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office">
4521<a:themeElements>
4522<a:clrScheme name="Office">
4523<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
4524<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
4525<a:dk2><a:srgbClr val="1F497D"/></a:dk2>
4526<a:lt2><a:srgbClr val="EEECE1"/></a:lt2>
4527<a:accent1><a:srgbClr val="4F81BD"/></a:accent1>
4528<a:accent2><a:srgbClr val="C0504D"/></a:accent2>
4529<a:accent3><a:srgbClr val="9BBB59"/></a:accent3>
4530<a:accent4><a:srgbClr val="8064A2"/></a:accent4>
4531<a:accent5><a:srgbClr val="4BACC6"/></a:accent5>
4532<a:accent6><a:srgbClr val="F79646"/></a:accent6>
4533<a:hlink><a:srgbClr val="0000FF"/></a:hlink>
4534<a:folHlink><a:srgbClr val="800080"/></a:folHlink>
4535</a:clrScheme>
4536</a:themeElements>
4537</a:theme>"#,
4538        )
4539        .unwrap();
4540
4541        // `sysClr`'s live system-color binding (`val="windowText"`) isn't preserved — only its
4542        // `lastClr` fallback RGB is, see `ColorScheme`'s doc comment.
4543        assert_eq!(theme.colors.dark1, "000000");
4544        assert_eq!(theme.colors.light1, "FFFFFF");
4545    }
4546
4547    #[test]
4548    fn a_theme_with_no_font_scheme_keeps_the_default_fonts() {
4549        let theme = parse_theme_xml(
4550            r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="ColorsOnly">
4551<a:themeElements>
4552<a:clrScheme name="ColorsOnly">
4553<a:dk1><a:srgbClr val="000000"/></a:dk1>
4554<a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>
4555<a:dk2><a:srgbClr val="1F497D"/></a:dk2>
4556<a:lt2><a:srgbClr val="EEECE1"/></a:lt2>
4557<a:accent1><a:srgbClr val="4F81BD"/></a:accent1>
4558<a:accent2><a:srgbClr val="C0504D"/></a:accent2>
4559<a:accent3><a:srgbClr val="9BBB59"/></a:accent3>
4560<a:accent4><a:srgbClr val="8064A2"/></a:accent4>
4561<a:accent5><a:srgbClr val="4BACC6"/></a:accent5>
4562<a:accent6><a:srgbClr val="F79646"/></a:accent6>
4563<a:hlink><a:srgbClr val="0000FF"/></a:hlink>
4564<a:folHlink><a:srgbClr val="800080"/></a:folHlink>
4565</a:clrScheme>
4566</a:themeElements>
4567</a:theme>"#,
4568        )
4569        .unwrap();
4570
4571        assert_eq!(theme.fonts, FontScheme::office_default());
4572    }
4573
4574    #[test]
4575    fn parses_page_size_margins_and_columns_from_sectpr() {
4576        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4577<w:body>
4578<w:p><w:r><w:t>Body</w:t></w:r></w:p>
4579<w:sectPr>
4580<w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>
4581<w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000" w:header="500" w:footer="500" w:gutter="0"/>
4582<w:cols w:num="2" w:space="720" w:equalWidth="true"/>
4583</w:sectPr>
4584</w:body>
4585</w:document>"#;
4586
4587        let document = parse(xml);
4588
4589        assert_eq!(document.page_setup.width_twips, 16838);
4590        assert_eq!(document.page_setup.height_twips, 11906);
4591        assert_eq!(document.page_setup.orientation, Orientation::Landscape);
4592        assert_eq!(document.page_setup.margin_top_twips, 1000);
4593        assert_eq!(document.page_setup.margin_right_twips, 1000);
4594        assert_eq!(document.page_setup.margin_bottom_twips, 1000);
4595        assert_eq!(document.page_setup.margin_left_twips, 1000);
4596        assert_eq!(document.page_setup.margin_header_twips, 500);
4597        assert_eq!(document.page_setup.margin_footer_twips, 500);
4598        assert_eq!(document.page_setup.columns, Some(2));
4599    }
4600
4601    #[test]
4602    fn parses_an_a3_page_size_distinct_from_the_default_a4() {
4603        // Distinct from the test above, which only swaps A4's own width/height for landscape — this
4604        // proves `pgSz`'s `w`/`h` parse into genuinely arbitrary values, not just the two numbers
4605        // this crate's own default already uses.
4606        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4607<w:body>
4608<w:p><w:r><w:t>Body</w:t></w:r></w:p>
4609<w:sectPr>
4610<w:pgSz w:w="16838" w:h="23811"/>
4611</w:sectPr>
4612</w:body>
4613</w:document>"#;
4614
4615        let document = parse(xml);
4616
4617        assert_eq!(document.page_setup.width_twips, 16838);
4618        assert_eq!(document.page_setup.height_twips, 23811);
4619        assert_eq!(document.page_setup.orientation, Orientation::Portrait);
4620    }
4621
4622    #[test]
4623    fn a_missing_pgsz_keeps_the_default_page_setup() {
4624        let document = parse(
4625            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4626<w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body>
4627</w:document>"#,
4628        );
4629
4630        assert_eq!(document.page_setup, PageSetup::default());
4631    }
4632
4633    #[test]
4634    fn captures_first_and_even_header_footer_reference_ids_separately() {
4635        let package = Package::new();
4636        let relationships = Relationships::new();
4637        let resolver = ImageResolver {
4638            package: &package,
4639            part_relationships: &relationships,
4640        };
4641
4642        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4643<w:body>
4644<w:p><w:r><w:t>Body</w:t></w:r></w:p>
4645<w:sectPr>
4646<w:headerReference w:type="default" r:id="rId2"/>
4647<w:headerReference w:type="first" r:id="rId4"/>
4648<w:footerReference w:type="first" r:id="rId5"/>
4649<w:headerReference w:type="even" r:id="rId6"/>
4650<w:footerReference w:type="even" r:id="rId7"/>
4651<w:pgSz w:w="11906" w:h="16838"/>
4652</w:sectPr>
4653</w:body>
4654</w:document>"#;
4655
4656        let parsed = parse_document_xml(xml, &resolver).unwrap();
4657
4658        assert_eq!(parsed.header_relationship_id.as_deref(), Some("rId2"));
4659        assert_eq!(parsed.header_first_relationship_id.as_deref(), Some("rId4"));
4660        assert_eq!(parsed.footer_first_relationship_id.as_deref(), Some("rId5"));
4661        assert_eq!(parsed.header_even_relationship_id.as_deref(), Some("rId6"));
4662        assert_eq!(parsed.footer_even_relationship_id.as_deref(), Some("rId7"));
4663    }
4664
4665    #[test]
4666    fn parses_custom_tab_stops_in_order() {
4667        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4668<w:body>
4669<w:p><w:pPr><w:tabs>
4670<w:tab w:val="left" w:pos="720"/>
4671<w:tab w:val="decimal" w:leader="dot" w:pos="-360"/>
4672</w:tabs></w:pPr><w:r><w:t>Tabbed</w:t></w:r></w:p>
4673</w:body>
4674</w:document>"#;
4675
4676        let document = parse(xml);
4677        let paragraphs: Vec<_> = document.paragraphs().collect();
4678
4679        assert_eq!(paragraphs[0].tabs.len(), 2);
4680        assert_eq!(paragraphs[0].tabs[0].position_twips, 720);
4681        assert_eq!(paragraphs[0].tabs[0].alignment, TabStopAlignment::Left);
4682        assert_eq!(paragraphs[0].tabs[0].leader, None);
4683        assert_eq!(paragraphs[0].tabs[1].position_twips, -360);
4684        assert_eq!(paragraphs[0].tabs[1].alignment, TabStopAlignment::Decimal);
4685        assert_eq!(paragraphs[0].tabs[1].leader, Some(TabLeader::Dot));
4686    }
4687
4688    #[test]
4689    fn parses_keep_next_keep_lines_page_break_before_and_contextual_spacing() {
4690        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4691<w:body>
4692<w:p><w:pPr>
4693<w:keepNext/><w:keepLines/><w:pageBreakBefore/><w:contextualSpacing/>
4694</w:pPr><w:r><w:t>Heading</w:t></w:r></w:p>
4695</w:body>
4696</w:document>"#;
4697
4698        let document = parse(xml);
4699        let paragraphs: Vec<_> = document.paragraphs().collect();
4700
4701        assert!(paragraphs[0].keep_with_next);
4702        assert!(paragraphs[0].keep_lines_together);
4703        assert!(paragraphs[0].page_break_before);
4704        assert!(paragraphs[0].contextual_spacing);
4705    }
4706
4707    #[test]
4708    fn parses_page_and_column_breaks_and_a_carriage_return() {
4709        let xml = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4710<w:body>
4711<w:p>
4712<w:r><w:br/></w:r>
4713<w:r><w:br w:type="page"/></w:r>
4714<w:r><w:br w:type="column"/></w:r>
4715<w:r><w:cr/></w:r>
4716</w:p>
4717</w:body>
4718</w:document>"#;
4719
4720        let document = parse(xml);
4721        let paragraphs: Vec<_> = document.paragraphs().collect();
4722
4723        assert_eq!(paragraphs[0].runs.len(), 4);
4724        assert_eq!(
4725            paragraphs[0].runs[0].content,
4726            RunContent::Break(BreakKind::TextWrapping)
4727        );
4728        assert_eq!(
4729            paragraphs[0].runs[1].content,
4730            RunContent::Break(BreakKind::Page)
4731        );
4732        assert_eq!(
4733            paragraphs[0].runs[2].content,
4734            RunContent::Break(BreakKind::Column)
4735        );
4736        assert_eq!(paragraphs[0].runs[3].content, RunContent::CarriageReturn);
4737    }
4738}