Skip to main content

easydoc_reader/extractor/
sax.rs

1//! 基于 SAX 的流式 DOCX 读取器。
2//!
3//! 使用 `quick-xml` 解析 `.docx` ZIP 归档内的 `word/document.xml`,
4//! 内存开销为 O(1)(与文档大小无关)。每个块级元素转换为 [`DocumentEvent`]
5//! 并推送给 [`EventSink`]。
6//!
7//! 类比 easyexcel-rust 的 `XlsxSaxAnalyser`。
8
9use std::borrow::Cow;
10use std::fs::File;
11use std::io::{BufReader, Read};
12use std::path::Path;
13
14use super::image::{Relationships, extension_from_filename, read_zip_part};
15use crate::security::{SecurityPolicy, SsrfGuard};
16use easydoc_core::{
17    DocError, DocumentBlock, DocumentEvent, DocumentImage, DocumentList, DocumentListItem,
18    DocumentTableCell, DocumentTableRow, DocumentTextRun, EventSink, Result,
19};
20use quick_xml::Reader as XmlReader;
21use quick_xml::events::Event;
22
23// ---------------------------------------------------------------------------
24// OOXML element / attribute name constants (with w: namespace prefix)
25// ---------------------------------------------------------------------------
26
27const W_P: &[u8] = b"w:p";
28const W_R: &[u8] = b"w:r";
29const W_T: &[u8] = b"w:t";
30const W_PPR: &[u8] = b"w:pPr";
31const W_PSTYLE: &[u8] = b"w:pStyle";
32const W_RPR: &[u8] = b"w:rPr";
33const W_B: &[u8] = b"w:b";
34const W_I: &[u8] = b"w:i";
35const W_STRIKE: &[u8] = b"w:strike";
36const W_TBL: &[u8] = b"w:tbl";
37const W_TR: &[u8] = b"w:tr";
38const W_TC: &[u8] = b"w:tc";
39const W_BR: &[u8] = b"w:br";
40const W_DRAWING: &[u8] = b"w:drawing";
41const W_TCPR: &[u8] = b"w:tcPr";
42const W_GRIDSPAN: &[u8] = b"w:gridSpan";
43const W_VMERGE: &[u8] = b"w:vMerge";
44
45const A_BLIP: &[u8] = b"a:blip";
46const WP_DOC_PR: &[u8] = b"wp:docPr";
47const R_EMBED: &[u8] = b"r:embed";
48
49const W_VAL: &[u8] = b"w:val";
50const W_TYPE: &[u8] = b"w:type";
51
52// List numbering constants
53const W_NUMPR: &[u8] = b"w:numPr";
54const W_NUMID: &[u8] = b"w:numId";
55const W_ILVL: &[u8] = b"w:ilvl";
56
57// Hyperlink constants
58const W_HYPERLINK: &[u8] = b"w:hyperlink";
59const R_ID: &[u8] = b"r:id";
60
61// OMML math namespace constants (m: prefix)
62const M_OMATH: &[u8] = b"m:oMath";
63const M_OMATHPARA: &[u8] = b"m:oMathPara";
64
65// ---------------------------------------------------------------------------
66// State machine
67// ---------------------------------------------------------------------------
68
69/// Internal parser state, maintained as a stack.
70#[derive(Debug)]
71enum ParseState {
72    /// Top-level `<w:document>`.
73    Document,
74    /// Inside `<w:p>` -- accumulating runs.
75    Paragraph {
76        /// Runs collected so far.
77        runs: Vec<DocumentTextRun>,
78        /// Heading level parsed from `<w:pStyle>`, if any.
79        heading_level: Option<u8>,
80        /// Whether we are currently inside `<w:pPr>`.
81        in_ppr: bool,
82        /// Whether we are currently inside a `<w:r>`.
83        in_run: bool,
84        /// Whether we are currently inside `<w:rPr>` of the current run.
85        in_rpr: bool,
86        /// Bold state for the current run (propagated from `<w:rPr>`).
87        run_bold: bool,
88        /// Italic state for the current run.
89        run_italic: bool,
90        /// Strikethrough state for the current run.
91        run_strike: bool,
92        /// Text buffer for the current `<w:t>` element.
93        text_buf: String,
94        /// Whether we are inside `<w:t>`.
95        in_text: bool,
96        /// Whether `xml:space="preserve"` is set on the current `<w:t>`.
97        preserve_space: bool,
98        /// Whether this paragraph contains `<w:numPr>` (is a list item).
99        has_num_pr: bool,
100        /// `numId` from `<w:numId w:val="..."/>` inside `<w:numPr>`.
101        num_id: Option<u32>,
102        /// `ilvl` from `<w:ilvl w:val="..."/>` inside `<w:numPr>`.
103        ilvl: Option<u8>,
104        /// Whether we are currently inside `<w:hyperlink>`.
105        in_hyperlink: bool,
106        /// The `r:id` of the current `<w:hyperlink>`, if any.
107        hyperlink_rid: Option<String>,
108    },
109    /// Inside `<w:tbl>` -- accumulating rows.
110    Table {
111        /// Rows collected so far.
112        rows: Vec<DocumentTableRow>,
113        /// Current row being built, if inside `<w:tr>`.
114        current_row: Option<TableRowBuilder>,
115    },
116    /// Inside `<w:drawing>` -- accumulating image metadata.
117    Drawing {
118        /// Relationship ID from `<a:blip r:embed="..."/>`.
119        pending_rid: Option<String>,
120        /// Alt text from `<wp:docPr descr="..." name="..."/>`.
121        pending_alt: Option<String>,
122    },
123}
124
125/// Builder for a table row while parsing `<w:tr>`.
126#[derive(Debug)]
127struct TableRowBuilder {
128    cells: Vec<TableCellBuilder>,
129}
130
131/// Vertical merge kind for OOXML `<w:vMerge>`.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum VMerge {
134    /// `<w:vMerge w:val="restart"/>` -- first cell in a vertical merge.
135    Restart,
136    /// `<w:vMerge/>` or `<w:vMerge w:val="continue"/>` -- continuation cell.
137    Continue,
138}
139
140/// Builder for a table cell while parsing `<w:tc>`.
141#[derive(Debug)]
142struct TableCellBuilder {
143    text: String,
144    /// Column span from `<w:gridSpan w:val="N"/>` (default 1).
145    column_span: u32,
146    /// Row span; set to 0 for vMerge continue cells (merged into cell above).
147    row_span: u32,
148    /// Parsed vMerge state, if any.
149    v_merge: Option<VMerge>,
150    /// Whether we are currently inside `<w:tcPr>`.
151    in_tcpr: bool,
152    /// Blocks accumulated from nested tables or other block-level content.
153    blocks: Vec<DocumentBlock>,
154}
155
156// ---------------------------------------------------------------------------
157// Internal ParseSink abstraction
158// ---------------------------------------------------------------------------
159
160/// Internal trait unifying event-based and direct block output during parsing.
161///
162/// This allows the SAX loop to emit [`DocumentBlock::Math`] inline alongside
163/// normal [`DocumentEvent`]s, preserving document ordering.
164trait ParseSink {
165    /// Push a document event (converted to block(s) by the implementation).
166    fn on_event(&mut self, event: &DocumentEvent) -> Result<()>;
167    /// Push a block directly (used for `DocumentBlock::Math`).
168    fn push_block(&mut self, block: DocumentBlock);
169    /// Called when parsing is complete.
170    fn on_complete(&mut self) {}
171}
172
173/// Wraps an [`EventSink`] as a [`ParseSink`].
174///
175/// Math blocks are silently dropped because [`DocumentEvent`] has no `Math`
176/// variant. Callers requiring math should use [`DocxSaxReader::read_blocks`].
177struct EventSinkAdapter<'a>(&'a mut dyn EventSink);
178
179impl ParseSink for EventSinkAdapter<'_> {
180    fn on_event(&mut self, event: &DocumentEvent) -> Result<()> {
181        self.0.on_event(event)
182    }
183
184    fn push_block(&mut self, _block: DocumentBlock) {
185        // DocumentEvent has no Math variant; blocks are dropped here.
186        // Use read_blocks() for full math support.
187    }
188
189    fn on_complete(&mut self) {
190        self.0.on_complete();
191    }
192}
193
194/// Collects parsed content as `Vec<DocumentBlock>`, preserving ordering.
195///
196/// Both event-derived and directly-pushed blocks go into a single sequence.
197struct BlockCollector(Vec<DocumentBlock>);
198
199impl ParseSink for BlockCollector {
200    fn on_event(&mut self, event: &DocumentEvent) -> Result<()> {
201        match event {
202            DocumentEvent::Heading { level, runs } => {
203                self.0.push(DocumentBlock::Heading {
204                    level: *level,
205                    runs: runs.clone(),
206                });
207            }
208            DocumentEvent::Paragraph(runs) => {
209                self.0.push(DocumentBlock::Paragraph(runs.clone()));
210            }
211            DocumentEvent::Table(table) => {
212                self.0.push(DocumentBlock::Table(table.clone()));
213            }
214            DocumentEvent::List(list) => {
215                self.0.push(DocumentBlock::List(list.clone()));
216            }
217            DocumentEvent::Image(image) => {
218                self.0.push(DocumentBlock::Image(image.clone()));
219            }
220            DocumentEvent::PageBreak => {
221                self.0.push(DocumentBlock::PageBreak);
222            }
223            DocumentEvent::ColumnBreak => {
224                self.0.push(DocumentBlock::ColumnBreak);
225            }
226            DocumentEvent::CodeBlock { language, code } => {
227                self.0.push(DocumentBlock::CodeBlock {
228                    language: language.clone(),
229                    code: code.clone(),
230                });
231            }
232            DocumentEvent::Section { section_type } => {
233                self.0.push(DocumentBlock::Section {
234                    blocks: Vec::new(),
235                    section_type: section_type.clone(),
236                });
237            }
238            DocumentEvent::DocumentStart | DocumentEvent::DocumentEnd => {}
239        }
240        Ok(())
241    }
242
243    fn push_block(&mut self, block: DocumentBlock) {
244        self.0.push(block);
245    }
246}
247
248/// Emits the accumulated runs of the current paragraph (if non-empty) without
249/// popping the `Paragraph` state from the stack. Used to flush partial content
250/// before entering a math region.
251fn flush_paragraph_runs(sink: &mut dyn ParseSink, stack: &mut [ParseState]) -> Result<()> {
252    if let Some(ParseState::Paragraph { runs, .. }) = stack.last_mut()
253        && !runs.is_empty()
254    {
255        let taken = std::mem::take(runs);
256        sink.on_event(&DocumentEvent::Paragraph(taken))?;
257    }
258    Ok(())
259}
260
261/// Flushes accumulated list items as a single [`DocumentBlock::List`].
262///
263/// Called when a non-list paragraph, heading, table, or document end is
264/// encountered after one or more consecutive list-item paragraphs.
265///
266/// Uses `first_num_id` and `first_ilvl` (from the first list item) to look up
267/// the numbering definition and determine whether the list is ordered and what
268/// its start number is. Falls back to `ordered=false` if the numbering is
269/// unavailable.
270///
271/// Builds nested list structure from `(item, ilvl)` pairs using
272/// [`build_nested_items`] before emitting.
273///
274/// Resolves hyperlink rIds to URLs using the provided [`Relationships`].
275fn flush_list(
276    sink: &mut dyn ParseSink,
277    list_items: &mut Vec<(DocumentListItem, u8)>,
278    first_num_id: &mut Option<u32>,
279    first_ilvl: &mut u8,
280    numbering: Option<&super::numbering::Numbering>,
281    relationships: Option<&Relationships>,
282    ssrf: &SsrfGuard,
283) -> Result<()> {
284    if !list_items.is_empty() {
285        // Resolve hyperlinks in all list items (including nested).
286        resolve_hyperlinks_in_flat_items(list_items, relationships, ssrf);
287
288        // Resolve list type from numbering definitions.
289        let (ordered, start_number) = if let (Some(num_id), Some(num)) = (*first_num_id, numbering)
290        {
291            match num.lookup(num_id, *first_ilvl) {
292                Some(level) => {
293                    let start = if level.ordered { level.start } else { None };
294                    (level.ordered, start)
295                }
296                None => (false, None),
297            }
298        } else {
299            (false, None)
300        };
301
302        let flat = std::mem::take(list_items);
303        let items = build_nested_items(flat);
304        sink.push_block(DocumentBlock::List(DocumentList {
305            ordered,
306            start_number,
307            items,
308        }));
309        *first_num_id = None;
310        *first_ilvl = 0;
311    }
312    Ok(())
313}
314
315/// Builds a nested list tree from a flat sequence of `(item, ilvl)` pairs.
316///
317/// Each list item's `ilvl` (indentation level) determines where it appears in
318/// the tree. Items at `ilvl == 0` become top-level items. Items at `ilvl > 0`
319/// are nested inside the most recent ancestor with `ilvl == new_ilvl - 1`.
320///
321/// If an item jumps levels (e.g. `ilvl` goes from 0 to 2, skipping 1),
322/// intermediate empty nested lists are created to maintain the hierarchy.
323fn build_nested_items(flat: Vec<(DocumentListItem, u8)>) -> Vec<DocumentListItem> {
324    let mut items: Vec<DocumentListItem> = Vec::new();
325
326    for (new_item, ilvl) in flat {
327        if ilvl == 0 {
328            items.push(new_item);
329        } else {
330            // Attach to the last top-level item's nested subtree.
331            if let Some(parent) = items.last_mut() {
332                attach_to_nested(parent, new_item, ilvl);
333            } else {
334                // No parent exists -- promote to top level (defensive).
335                items.push(new_item);
336            }
337        }
338    }
339
340    items
341}
342
343/// Recursively attaches `new_item` at the given `ilvl` depth inside `parent`.
344///
345/// If `ilvl == 1`, the item is appended to `parent.nested.items`. If `ilvl > 1`,
346/// the function recurses into the last item of `parent.nested.items` with
347/// `ilvl - 1`. Missing intermediate nested lists are created automatically.
348fn attach_to_nested(parent: &mut DocumentListItem, new_item: DocumentListItem, ilvl: u8) {
349    if ilvl == 1 {
350        // Direct child of parent.
351        let nested = parent
352            .nested
353            .get_or_insert_with(|| Box::new(DocumentList::default()));
354        nested.items.push(new_item);
355    } else {
356        // ilvl > 1: ensure parent has a nested list, then recurse into its last item.
357        let nested = parent
358            .nested
359            .get_or_insert_with(|| Box::new(DocumentList::default()));
360        if let Some(last) = nested.items.last_mut() {
361            attach_to_nested(last, new_item, ilvl - 1);
362        } else {
363            // No items in the intermediate level -- push directly.
364            // This handles ilvl-jump edge cases (e.g. 0 -> 2 with no level-1 items).
365            nested.items.push(new_item);
366        }
367    }
368}
369
370/// Resolves hyperlink rIds to URLs in all runs of all list items.
371fn resolve_hyperlinks_in_items(
372    items: &mut [DocumentListItem],
373    relationships: Option<&Relationships>,
374    ssrf: &SsrfGuard,
375) {
376    let Some(rels) = relationships else { return };
377    for item in items.iter_mut() {
378        resolve_hyperlinks_in_blocks(&mut item.blocks, rels, ssrf);
379        // Recurse into nested lists.
380        if let Some(nested) = item.nested.as_mut() {
381            resolve_hyperlinks_in_items(&mut nested.items, Some(rels), ssrf);
382        }
383    }
384}
385
386/// Resolves hyperlink rIds to URLs in all runs of flat `(item, ilvl)` list items.
387fn resolve_hyperlinks_in_flat_items(
388    items: &mut [(DocumentListItem, u8)],
389    relationships: Option<&Relationships>,
390    ssrf: &SsrfGuard,
391) {
392    let Some(rels) = relationships else { return };
393    for (item, _ilvl) in items.iter_mut() {
394        resolve_hyperlinks_in_blocks(&mut item.blocks, rels, ssrf);
395    }
396}
397
398/// Recursively resolves hyperlink rIds in a list of blocks.
399fn resolve_hyperlinks_in_blocks(
400    blocks: &mut [DocumentBlock],
401    rels: &Relationships,
402    ssrf: &SsrfGuard,
403) {
404    for block in blocks {
405        match block {
406            DocumentBlock::Paragraph(runs) | DocumentBlock::Heading { runs, .. } => {
407                resolve_hyperlinks_in_runs(runs, rels, ssrf);
408            }
409            DocumentBlock::List(list) => {
410                resolve_hyperlinks_in_items(&mut list.items, Some(rels), ssrf);
411            }
412            DocumentBlock::Table(table) => {
413                for row in &mut table.rows {
414                    for cell in &mut row.cells {
415                        resolve_hyperlinks_in_blocks(&mut cell.blocks, rels, ssrf);
416                    }
417                }
418            }
419            _ => {}
420        }
421    }
422}
423
424/// Resolves hyperlink rIds in a list of text runs.
425///
426/// When an SSRF guard is provided, resolved URLs are validated and
427/// blocked URLs are stripped (set to `None`) to prevent SSRF.
428/// Raw rIds that could not be resolved from relationships are kept
429/// as-is (they are not network-reachable URLs).
430fn resolve_hyperlinks_in_runs(
431    runs: &mut [DocumentTextRun],
432    rels: &Relationships,
433    ssrf: &SsrfGuard,
434) {
435    for run in runs.iter_mut() {
436        if let Some(rid) = run.hyperlink.take() {
437            match rels.resolve_hyperlink(&rid) {
438                Some(resolved_url) => {
439                    // Only SSRF-check URLs that were actually resolved
440                    // from relationships (network-reachable targets).
441                    if ssrf.check_url(resolved_url).is_ok() {
442                        run.hyperlink = Some(resolved_url.to_owned());
443                    }
444                    // Blocked: drop the hyperlink entirely.
445                }
446                None => {
447                    // Not resolved -- keep the raw rId (not a URL).
448                    run.hyperlink = Some(rid);
449                }
450            }
451        }
452    }
453}
454
455/// Extracts the `r:id` attribute value from a start tag (used for hyperlinks).
456fn extract_rid(tag: &quick_xml::events::BytesStart) -> Option<String> {
457    for attr in tag.attributes().flatten() {
458        if attr.key.as_ref() == R_ID {
459            return attr
460                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
461                .ok()
462                .map(std::borrow::Cow::into_owned);
463        }
464    }
465    None
466}
467
468// ---------------------------------------------------------------------------
469// DocxSaxReader
470// ---------------------------------------------------------------------------
471
472/// 基于 SAX 风格 XML 解析的流式 DOCX 读取器。
473///
474/// 打开 `.docx` 文件(ZIP 归档),提取 `word/document.xml`,并使用 `quick-xml`
475/// 逐事件解析。每个块级元素转换为 [`DocumentEvent`] 并转发给提供的 [`EventSink`]。
476///
477/// 内存使用量相对于文档大小为 O(1) -- 仅当前正在解析的元素驻留在内存中。
478/// 类比 easyexcel-rust 的 `XlsxSaxAnalyser`。
479///
480/// # 示例
481///
482/// ```no_run
483/// use std::path::Path;
484/// use easydoc_reader::extractor::sax::DocxSaxReader;
485/// use easydoc_core::ContentCollector;
486///
487/// let mut reader = DocxSaxReader::from_path(Path::new("test.docx")).unwrap();
488/// let mut collector = ContentCollector::new();
489/// reader.read_events(&mut collector).unwrap();
490/// let content = collector.into_content();
491/// ```
492pub struct DocxSaxReader<R: Read> {
493    reader: XmlReader<BufReader<R>>,
494    /// ZIP archive handle, only present when created via [`Self::from_path`].
495    archive: Option<zip::ZipArchive<File>>,
496    /// Parsed relationships from `word/_rels/document.xml.rels`.
497    relationships: Option<Relationships>,
498    /// Parsed numbering definitions from `word/numbering.xml`.
499    numbering: Option<super::numbering::Numbering>,
500    /// Security policy for SSRF and ZIP bomb protection.
501    security: SecurityPolicy,
502}
503
504impl DocxSaxReader<std::io::Cursor<Vec<u8>>> {
505    /// 从文件路径创建读取器(使用默认安全策略)。
506    ///
507    /// 打开 `.docx` ZIP 归档,按默认 [`SecurityPolicy`](ZIP 炸弹 / 元素爆炸防护)
508    /// 验证,定位 `word/document.xml` 部分,并读入内存进行流式 XML 解析。
509    ///
510    /// 类比 easyexcel-rust 的 `XlsxSaxAnalyser::new()`。
511    ///
512    /// # Errors
513    ///
514    /// 文件不存在、不是有效 ZIP、不包含 `word/document.xml` 或安全验证失败时返回错误。
515    pub fn from_path(path: &Path) -> Result<Self> {
516        Self::from_path_with_security(path, SecurityPolicy::new())
517    }
518
519    /// 从文件路径创建读取器(使用自定义安全策略)。
520    ///
521    /// 与 [`from_path`](Self::from_path) 类似,但使用提供的策略进行
522    /// ZIP 归档验证和超链接 SSRF 检查。
523    ///
524    /// # Errors
525    ///
526    /// 文件不存在、不是有效 ZIP、不包含 `word/document.xml` 或安全验证失败时返回错误。
527    pub fn from_path_with_security(path: &Path, security: SecurityPolicy) -> Result<Self> {
528        let file = File::open(path)?;
529        let mut archive = zip::ZipArchive::new(file)?;
530
531        // Validate ZIP archive against security limits (bomb / element explosion).
532        security
533            .limits
534            .validate_archive(&mut archive)
535            .map_err(|msg| DocError::Format(format!("security: {msg}")))?;
536
537        // Resolve the entry name first to avoid overlapping borrows.
538        let entry_name = if archive.index_for_name("word/document.xml").is_some() {
539            "word/document.xml".to_owned()
540        } else {
541            find_word_document_xml(&mut archive)?
542        };
543
544        // Parse relationships for image extraction.
545        let relationships = if archive
546            .index_for_name("word/_rels/document.xml.rels")
547            .is_some()
548        {
549            let rels_bytes = {
550                let mut entry = archive.by_name("word/_rels/document.xml.rels")?;
551                let mut buf = Vec::new();
552                std::io::Read::read_to_end(&mut entry, &mut buf)?;
553                buf
554            };
555            let rels_xml = String::from_utf8(rels_bytes)
556                .map_err(|e| DocError::Format(format!("rels XML not valid UTF-8: {e}")))?;
557            Some(Relationships::parse(&rels_xml)?)
558        } else {
559            None
560        };
561
562        // Parse numbering definitions (for ordered/unordered list detection).
563        let numbering = if archive.index_for_name("word/numbering.xml").is_some() {
564            let num_bytes = {
565                let mut entry = archive.by_name("word/numbering.xml")?;
566                let mut buf = Vec::new();
567                std::io::Read::read_to_end(&mut entry, &mut buf)?;
568                buf
569            };
570            match String::from_utf8(num_bytes) {
571                Ok(xml) => super::numbering::Numbering::parse(&xml).ok(),
572                Err(_) => None,
573            }
574        } else {
575            None
576        };
577
578        // Extract the XML bytes from the ZIP entry.
579        let xml_bytes = {
580            let mut entry = archive.by_name(&entry_name)?;
581            let mut buf = Vec::new();
582            std::io::Read::read_to_end(&mut entry, &mut buf)?;
583            buf
584        };
585
586        let buf_reader = BufReader::new(std::io::Cursor::new(xml_bytes));
587        let mut reader = XmlReader::from_reader(buf_reader);
588        reader.config_mut().trim_text(false);
589
590        Ok(Self {
591            reader,
592            archive: Some(archive),
593            relationships,
594            numbering,
595            security,
596        })
597    }
598}
599
600impl<R: Read> DocxSaxReader<R> {
601    /// 创建包装现有 `Read` 源(包含原始 XML)的读取器。
602    ///
603    /// 适用于测试场景或 XML 已被提取的情况。
604    pub fn from_reader(source: R) -> Self {
605        let buf_reader = BufReader::new(source);
606        let mut reader = XmlReader::from_reader(buf_reader);
607        reader.config_mut().trim_text(false);
608        Self {
609            reader,
610            archive: None,
611            relationships: None,
612            numbering: None,
613            security: SecurityPolicy::new(),
614        }
615    }
616
617    /// 流式遍历文档,将 [`DocumentEvent`] 推送给 `sink`。
618    ///
619    /// 开头发出 [`DocumentEvent::DocumentStart`],结尾发出 [`DocumentEvent::DocumentEnd`]。
620    ///
621    /// **注意:** [`DocumentBlock::Math`] 无法表示为 [`DocumentEvent`],会被静默丢弃。
622    /// 如需提取数学公式,请使用 [`read_blocks`](Self::read_blocks)。
623    ///
624    /// # Errors
625    ///
626    /// XML 解析失败或 sink 返回错误时返回错误。
627    pub fn read_events(&mut self, sink: &mut dyn EventSink) -> Result<()> {
628        let mut adapter = EventSinkAdapter(sink);
629        self.parse_with_sink(&mut adapter)
630    }
631
632    /// 读取文档并按文档顺序返回所有块,包括 OMML 公式的 [`DocumentBlock::Math`]。
633    ///
634    /// 需要提取数学公式时推荐使用此入口。返回的块保留原始文档顺序:
635    /// 段落、表格、图片和数学公式按与源 DOCX 相同的顺序出现。
636    ///
637    /// Math 块的 `omml` 设置为原始 `<m:oMath>` 或 `<m:oMathPara>` XML,
638    /// `latex` 为 `None`,`display` 指示公式是块级(`<m:oMathPara>`)还是行内(`<m:oMath>`)。
639    ///
640    /// # Errors
641    ///
642    /// XML 解析失败时返回错误。
643    pub fn read_blocks(&mut self) -> Result<Vec<DocumentBlock>> {
644        let mut collector = BlockCollector(Vec::new());
645        self.parse_with_sink(&mut collector)?;
646        Ok(collector.0)
647    }
648
649    /// Core parsing loop shared by [`read_events`](Self::read_events) and
650    /// [`read_blocks`](Self::read_blocks).
651    fn parse_with_sink(&mut self, sink: &mut dyn ParseSink) -> Result<()> {
652        sink.on_event(&DocumentEvent::DocumentStart)?;
653
654        let mut state_stack: Vec<ParseState> = vec![ParseState::Document];
655        let mut buf = Vec::new();
656
657        // List accumulation state: consecutive `<w:numPr>` paragraphs are
658        // collected here and flushed as a single `DocumentBlock::List` when a
659        // non-list paragraph, heading, or table boundary is encountered.
660        // Each entry is `(item, ilvl)` where `ilvl` drives nesting.
661        let mut list_items: Vec<(DocumentListItem, u8)> = Vec::new();
662        // The numId/ilvl from the first list item in the current run, used to
663        // look up ordered/start_number from the numbering definitions.
664        let mut first_list_num_id: Option<u32> = None;
665        let mut first_list_ilvl: u8 = 0;
666
667        // Math accumulation state.
668        let mut in_math = false;
669        let mut math_is_para = false;
670        let mut math_depth: u32 = 0;
671        let mut math_xml_buf = String::new();
672
673        loop {
674            let event = self
675                .reader
676                .read_event_into(&mut buf)
677                .map_err(|e| DocError::Format(format!("XML parse error: {e}")))?;
678
679            // ---- Math accumulation mode ----
680            // When inside an <m:oMath> or <m:oMathPara>, accumulate raw XML
681            // bytes for every event (start tags, text, end tags, etc.).
682            if in_math {
683                match &event {
684                    Event::Start(start) => {
685                        let name = start.name();
686                        let name_bytes = name.as_ref();
687                        if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
688                            math_depth += 1;
689                        }
690                        math_xml_buf.push('<');
691                        math_xml_buf.push_str(std::str::from_utf8(start.as_ref()).unwrap_or(""));
692                        math_xml_buf.push('>');
693                    }
694                    Event::End(end) => {
695                        let name = end.name();
696                        let name_bytes = name.as_ref();
697
698                        // Append closing tag to buffer first.
699                        math_xml_buf.push_str("</");
700                        math_xml_buf.push_str(std::str::from_utf8(name_bytes).unwrap_or(""));
701                        math_xml_buf.push('>');
702
703                        // Check whether this end tag closes the root math
704                        // element that started the accumulation.
705                        let is_closing_root = if math_is_para {
706                            name_bytes == M_OMATHPARA
707                        } else {
708                            name_bytes == M_OMATH
709                        };
710
711                        if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
712                            math_depth = math_depth.saturating_sub(1);
713                        }
714
715                        if is_closing_root && math_depth == 0 {
716                            sink.push_block(DocumentBlock::Math {
717                                omml: Some(std::mem::take(&mut math_xml_buf)),
718                                latex: None,
719                                display: math_is_para,
720                            });
721                            in_math = false;
722                        }
723                    }
724                    Event::Empty(empty) => {
725                        math_xml_buf.push('<');
726                        math_xml_buf.push_str(std::str::from_utf8(empty.as_ref()).unwrap_or(""));
727                        math_xml_buf.push_str("/>");
728                    }
729                    Event::Text(text) => {
730                        math_xml_buf.push_str(std::str::from_utf8(text.as_ref()).unwrap_or(""));
731                    }
732                    _ => {}
733                }
734                buf.clear();
735                continue;
736            }
737
738            // ---- Normal processing ----
739            match event {
740                Event::Eof => break,
741                Event::Start(ref start) => {
742                    let name = start.name();
743                    let name_bytes = name.as_ref();
744                    if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
745                        // Flush any accumulated paragraph runs before entering
746                        // math so they appear as a separate Paragraph block.
747                        flush_paragraph_runs(sink, &mut state_stack)?;
748                        in_math = true;
749                        math_is_para = name_bytes == M_OMATHPARA;
750                        math_depth = 1;
751                        math_xml_buf.clear();
752                        math_xml_buf.push('<');
753                        math_xml_buf.push_str(std::str::from_utf8(start.as_ref()).unwrap_or(""));
754                        math_xml_buf.push('>');
755                    } else {
756                        handle_start(start, &mut state_stack)?;
757                    }
758                }
759                Event::Empty(ref empty) => {
760                    let name = empty.name();
761                    let name_bytes = name.as_ref();
762                    if name_bytes == M_OMATH || name_bytes == M_OMATHPARA {
763                        // Self-closing math element (rare but possible).
764                        flush_paragraph_runs(sink, &mut state_stack)?;
765                        let display = name_bytes == M_OMATHPARA;
766                        let mut xml = String::from("<");
767                        xml.push_str(std::str::from_utf8(empty.as_ref()).unwrap_or(""));
768                        xml.push_str("/>");
769                        sink.push_block(DocumentBlock::Math {
770                            omml: Some(xml),
771                            latex: None,
772                            display,
773                        });
774                    } else {
775                        handle_empty(empty, sink, &mut state_stack)?;
776                    }
777                }
778                Event::Text(ref text) => {
779                    handle_text(text, &mut state_stack)?;
780                }
781                Event::End(ref end) => {
782                    handle_end(
783                        end,
784                        sink,
785                        &mut state_stack,
786                        &mut ParseContext {
787                            archive: self.archive.as_mut(),
788                            relationships: self.relationships.as_ref(),
789                            numbering: self.numbering.as_ref(),
790                            list_items: &mut list_items,
791                            first_list_num_id: &mut first_list_num_id,
792                            first_list_ilvl: &mut first_list_ilvl,
793                            ssrf: &self.security.ssrf,
794                        },
795                    )?;
796                }
797                _ => {}
798            }
799
800            buf.clear();
801        }
802
803        // Flush any remaining list items at document end.
804        flush_list(
805            sink,
806            &mut list_items,
807            &mut first_list_num_id,
808            &mut first_list_ilvl,
809            self.numbering.as_ref(),
810            self.relationships.as_ref(),
811            &self.security.ssrf,
812        )?;
813
814        sink.on_event(&DocumentEvent::DocumentEnd)?;
815        sink.on_complete();
816        Ok(())
817    }
818}
819
820// ---------------------------------------------------------------------------
821// Element handlers (free functions -- no &mut self needed)
822// ---------------------------------------------------------------------------
823
824fn handle_start(start: &quick_xml::events::BytesStart, stack: &mut Vec<ParseState>) -> Result<()> {
825    let name = start.name();
826    let local = name.as_ref();
827
828    match local {
829        W_P => {
830            // Inside a table cell, paragraphs are structural wrappers for
831            // text content that should flow into the cell buffer, not be
832            // emitted as separate events.
833            if !inside_table(stack) {
834                stack.push(ParseState::Paragraph {
835                    runs: Vec::new(),
836                    heading_level: None,
837                    in_ppr: false,
838                    in_run: false,
839                    in_rpr: false,
840                    run_bold: false,
841                    run_italic: false,
842                    run_strike: false,
843                    text_buf: String::new(),
844                    in_text: false,
845                    preserve_space: false,
846                    has_num_pr: false,
847                    num_id: None,
848                    ilvl: None,
849                    in_hyperlink: false,
850                    hyperlink_rid: None,
851                });
852            }
853        }
854        W_TBL => {
855            stack.push(ParseState::Table {
856                rows: Vec::new(),
857                current_row: None,
858            });
859        }
860        W_DRAWING => {
861            stack.push(ParseState::Drawing {
862                pending_rid: None,
863                pending_alt: None,
864            });
865        }
866        W_PPR => {
867            if let Some(ParseState::Paragraph { in_ppr, .. }) = stack.last_mut() {
868                *in_ppr = true;
869            }
870        }
871        W_NUMPR => {
872            // `<w:numPr>` inside `<w:pPr>` marks this paragraph as a list item.
873            if let Some(ParseState::Paragraph { has_num_pr, .. }) = stack.last_mut() {
874                *has_num_pr = true;
875            }
876        }
877        W_NUMID => {
878            if let Some(ParseState::Paragraph { num_id, .. }) = stack.last_mut() {
879                *num_id = extract_val(start).and_then(|v| v.parse::<u32>().ok());
880            }
881        }
882        W_ILVL => {
883            if let Some(ParseState::Paragraph { ilvl, .. }) = stack.last_mut() {
884                *ilvl = extract_val(start).and_then(|v| v.parse::<u8>().ok());
885            }
886        }
887        W_HYPERLINK => {
888            // `<w:hyperlink r:id="...">` inside a paragraph marks the following
889            // runs as hyperlink content.  The `r:id` is stored for resolution.
890            if let Some(ParseState::Paragraph {
891                in_hyperlink,
892                hyperlink_rid,
893                ..
894            }) = stack.last_mut()
895            {
896                *in_hyperlink = true;
897                *hyperlink_rid = extract_rid(start);
898            }
899        }
900        W_PSTYLE => {
901            if let Some(ParseState::Paragraph { heading_level, .. }) = stack.last_mut() {
902                *heading_level = extract_val(start).and_then(|v| parse_heading_level(&v));
903            }
904        }
905        W_R => {
906            if let Some(ParseState::Paragraph {
907                in_run,
908                run_bold,
909                run_italic,
910                run_strike,
911                ..
912            }) = stack.last_mut()
913            {
914                *in_run = true;
915                *run_bold = false;
916                *run_italic = false;
917                *run_strike = false;
918            }
919        }
920        W_RPR => {
921            if let Some(ParseState::Paragraph { in_rpr, .. }) = stack.last_mut() {
922                *in_rpr = true;
923            }
924        }
925        W_B => {
926            if let Some(ParseState::Paragraph {
927                in_rpr, run_bold, ..
928            }) = stack.last_mut()
929                && *in_rpr
930            {
931                *run_bold = extract_bool_attr(start).unwrap_or(true);
932            }
933        }
934        W_I => {
935            if let Some(ParseState::Paragraph {
936                in_rpr, run_italic, ..
937            }) = stack.last_mut()
938                && *in_rpr
939            {
940                *run_italic = extract_bool_attr(start).unwrap_or(true);
941            }
942        }
943        W_STRIKE => {
944            if let Some(ParseState::Paragraph {
945                in_rpr, run_strike, ..
946            }) = stack.last_mut()
947                && *in_rpr
948            {
949                *run_strike = extract_bool_attr(start).unwrap_or(true);
950            }
951        }
952        W_T => {
953            if let Some(ParseState::Paragraph {
954                in_text,
955                preserve_space,
956                ..
957            }) = stack.last_mut()
958            {
959                *in_text = true;
960                *preserve_space = has_preserve_space(start);
961            }
962        }
963        W_TR => {
964            if let Some(ParseState::Table { current_row, .. }) = stack.last_mut() {
965                *current_row = Some(TableRowBuilder { cells: Vec::new() });
966            }
967        }
968        W_TC => {
969            if let Some(ParseState::Table {
970                current_row: Some(row),
971                ..
972            }) = stack.last_mut()
973            {
974                row.cells.push(TableCellBuilder {
975                    text: String::new(),
976                    column_span: 1,
977                    row_span: 1,
978                    v_merge: None,
979                    in_tcpr: false,
980                    blocks: Vec::new(),
981                });
982            }
983        }
984        W_TCPR => {
985            if let Some(ParseState::Table {
986                current_row: Some(row),
987                ..
988            }) = stack.last_mut()
989                && let Some(cell) = row.cells.last_mut()
990            {
991                cell.in_tcpr = true;
992            }
993        }
994        _ => {}
995    }
996
997    Ok(())
998}
999
1000fn handle_empty(
1001    empty: &quick_xml::events::BytesStart,
1002    sink: &mut dyn ParseSink,
1003    stack: &mut [ParseState],
1004) -> Result<()> {
1005    let name = empty.name();
1006    let local = name.as_ref();
1007
1008    match local {
1009        W_BR => {
1010            if br_is_page_break(empty) {
1011                sink.on_event(&DocumentEvent::PageBreak)?;
1012            }
1013        }
1014        W_NUMID => {
1015            if let Some(ParseState::Paragraph { num_id, .. }) = stack.last_mut() {
1016                *num_id = extract_val(empty).and_then(|v| v.parse::<u32>().ok());
1017            }
1018        }
1019        W_ILVL => {
1020            if let Some(ParseState::Paragraph { ilvl, .. }) = stack.last_mut() {
1021                *ilvl = extract_val(empty).and_then(|v| v.parse::<u8>().ok());
1022            }
1023        }
1024        W_PSTYLE => {
1025            if let Some(ParseState::Paragraph { heading_level, .. }) = stack.last_mut() {
1026                *heading_level = extract_val(empty).and_then(|v| parse_heading_level(&v));
1027            }
1028        }
1029        W_B => {
1030            if let Some(ParseState::Paragraph {
1031                in_rpr, run_bold, ..
1032            }) = stack.last_mut()
1033                && *in_rpr
1034            {
1035                *run_bold = extract_bool_attr(empty).unwrap_or(true);
1036            }
1037        }
1038        W_I => {
1039            if let Some(ParseState::Paragraph {
1040                in_rpr, run_italic, ..
1041            }) = stack.last_mut()
1042                && *in_rpr
1043            {
1044                *run_italic = extract_bool_attr(empty).unwrap_or(true);
1045            }
1046        }
1047        W_STRIKE => {
1048            if let Some(ParseState::Paragraph {
1049                in_rpr, run_strike, ..
1050            }) = stack.last_mut()
1051                && *in_rpr
1052            {
1053                *run_strike = extract_bool_attr(empty).unwrap_or(true);
1054            }
1055        }
1056        A_BLIP => {
1057            if let Some(ParseState::Drawing { pending_rid, .. }) = stack.last_mut() {
1058                for attr in empty.attributes().flatten() {
1059                    if attr.key.as_ref() == R_EMBED {
1060                        *pending_rid = attr
1061                            .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1062                            .ok()
1063                            .map(Cow::into_owned);
1064                        break;
1065                    }
1066                }
1067            }
1068        }
1069        W_GRIDSPAN => {
1070            // <w:gridSpan w:val="N"/> -- horizontal merge across N columns.
1071            if let Some(ParseState::Table {
1072                current_row: Some(row),
1073                ..
1074            }) = stack.last_mut()
1075                && let Some(cell) = row.cells.last_mut()
1076                && cell.in_tcpr
1077                && let Some(val) = extract_val(empty)
1078                && let Ok(n) = val.parse::<u32>()
1079                && n > 1
1080            {
1081                cell.column_span = n;
1082            }
1083        }
1084        W_VMERGE => {
1085            // <w:vMerge w:val="restart"/> or <w:vMerge/> or <w:vMerge w:val="continue"/>
1086            // restart = first cell in vertical merge (row_span=1)
1087            // no val / continue = continuation cell merged into cell above (row_span=0)
1088            if let Some(ParseState::Table {
1089                current_row: Some(row),
1090                ..
1091            }) = stack.last_mut()
1092                && let Some(cell) = row.cells.last_mut()
1093                && cell.in_tcpr
1094            {
1095                if let Some("restart") = extract_val(empty).as_deref() {
1096                    cell.v_merge = Some(VMerge::Restart);
1097                    cell.row_span = 1;
1098                } else {
1099                    // No val or val="continue" => continuation cell.
1100                    cell.v_merge = Some(VMerge::Continue);
1101                    cell.row_span = 0;
1102                }
1103            }
1104        }
1105        WP_DOC_PR => {
1106            if let Some(ParseState::Drawing { pending_alt, .. }) = stack.last_mut() {
1107                for attr in empty.attributes().flatten() {
1108                    if attr.key.as_ref() == b"descr" {
1109                        let val = attr
1110                            .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1111                            .ok()
1112                            .map(Cow::into_owned);
1113                        if val.as_deref().is_some_and(|s| !s.is_empty()) {
1114                            *pending_alt = val;
1115                        }
1116                        break;
1117                    }
1118                }
1119            }
1120        }
1121        _ => {}
1122    }
1123
1124    Ok(())
1125}
1126
1127fn handle_text(text: &quick_xml::events::BytesText, stack: &mut [ParseState]) -> Result<()> {
1128    // Accumulate text into the appropriate buffer depending on current state.
1129    // OOXML is always UTF-8, so we can decode the raw bytes directly.
1130    let decoded = std::str::from_utf8(text.as_ref()).unwrap_or("").to_owned();
1131    if let Some(state) = stack.last_mut() {
1132        match state {
1133            ParseState::Paragraph {
1134                in_text: true,
1135                text_buf,
1136                ..
1137            } => {
1138                text_buf.push_str(&decoded);
1139            }
1140            ParseState::Table {
1141                current_row: Some(row),
1142                ..
1143            } => {
1144                if let Some(cell) = row.cells.last_mut() {
1145                    cell.text.push_str(&decoded);
1146                }
1147            }
1148            _ => {}
1149        }
1150    }
1151    Ok(())
1152}
1153
1154/// Context for `handle_end`, grouping references to shared parse state.
1155struct ParseContext<'a> {
1156    archive: Option<&'a mut zip::ZipArchive<File>>,
1157    relationships: Option<&'a Relationships>,
1158    numbering: Option<&'a super::numbering::Numbering>,
1159    /// Flat list of accumulated items with their indentation levels.
1160    /// Each entry is `(item, ilvl)` where `ilvl` comes from `<w:ilvl>`.
1161    list_items: &'a mut Vec<(DocumentListItem, u8)>,
1162    first_list_num_id: &'a mut Option<u32>,
1163    first_list_ilvl: &'a mut u8,
1164    /// SSRF guard for hyperlink URL validation.
1165    ssrf: &'a SsrfGuard,
1166}
1167
1168fn handle_end(
1169    end: &quick_xml::events::BytesEnd,
1170    sink: &mut dyn ParseSink,
1171    stack: &mut Vec<ParseState>,
1172    ctx: &mut ParseContext<'_>,
1173) -> Result<()> {
1174    let name = end.name();
1175    let local = name.as_ref();
1176
1177    match local {
1178        W_P => {
1179            // Only pop and emit if a Paragraph state is on top. When inside
1180            // a table cell, no Paragraph state was pushed so we skip.
1181            if matches!(stack.last(), Some(ParseState::Paragraph { .. }))
1182                && let Some(ParseState::Paragraph {
1183                    runs,
1184                    heading_level,
1185                    has_num_pr,
1186                    num_id,
1187                    ilvl,
1188                    ..
1189                }) = stack.pop()
1190            {
1191                if let Some(level) = heading_level {
1192                    // Headings always flush the list first, then emit as heading.
1193                    flush_list(
1194                        sink,
1195                        ctx.list_items,
1196                        ctx.first_list_num_id,
1197                        ctx.first_list_ilvl,
1198                        ctx.numbering,
1199                        ctx.relationships,
1200                        ctx.ssrf,
1201                    )?;
1202                    sink.on_event(&DocumentEvent::Heading { level, runs })?;
1203                } else if has_num_pr {
1204                    // List item -- accumulate into the current list.
1205                    // Record the first item's numId/ilvl for list-level lookup.
1206                    let item_ilvl = ilvl.unwrap_or(0);
1207                    if ctx.list_items.is_empty() {
1208                        *ctx.first_list_num_id = num_id;
1209                        *ctx.first_list_ilvl = item_ilvl;
1210                    }
1211                    if !runs.is_empty() {
1212                        ctx.list_items.push((
1213                            DocumentListItem {
1214                                blocks: vec![DocumentBlock::Paragraph(runs)],
1215                                nested: None,
1216                            },
1217                            item_ilvl,
1218                        ));
1219                    }
1220                } else {
1221                    // Non-list paragraph -- flush any accumulated list first.
1222                    flush_list(
1223                        sink,
1224                        ctx.list_items,
1225                        ctx.first_list_num_id,
1226                        ctx.first_list_ilvl,
1227                        ctx.numbering,
1228                        ctx.relationships,
1229                        ctx.ssrf,
1230                    )?;
1231                    if !runs.is_empty() {
1232                        // Skip empty paragraphs (e.g. those left after math flush).
1233                        sink.on_event(&DocumentEvent::Paragraph(runs))?;
1234                    }
1235                }
1236            }
1237        }
1238        W_R => {
1239            if let Some(ParseState::Paragraph {
1240                in_run,
1241                text_buf,
1242                run_bold,
1243                run_italic,
1244                run_strike,
1245                runs,
1246                in_hyperlink,
1247                hyperlink_rid,
1248                ..
1249            }) = stack.last_mut()
1250            {
1251                if *in_run && !text_buf.is_empty() {
1252                    let hyperlink = if *in_hyperlink {
1253                        hyperlink_rid.as_ref().map(|rid| {
1254                            // Resolve rId to actual URL via relationships.
1255                            // Fallback: use raw rId if not resolved.
1256                            match ctx
1257                                .relationships
1258                                .and_then(|rels| rels.resolve_hyperlink(rid))
1259                            {
1260                                Some(resolved_url) => {
1261                                    // SSRF guard: only check resolved URLs.
1262                                    if ctx.ssrf.check_url(resolved_url).is_ok() {
1263                                        resolved_url.to_owned()
1264                                    } else {
1265                                        // Blocked URL: keep raw rId as fallback.
1266                                        rid.clone()
1267                                    }
1268                                }
1269                                None => rid.clone(),
1270                            }
1271                        })
1272                    } else {
1273                        None
1274                    };
1275                    runs.push(DocumentTextRun {
1276                        text: std::mem::take(text_buf),
1277                        bold: *run_bold,
1278                        italic: *run_italic,
1279                        strikethrough: *run_strike,
1280                        hyperlink,
1281                    });
1282                }
1283                *in_run = false;
1284                *run_bold = false;
1285                *run_italic = false;
1286                *run_strike = false;
1287            }
1288        }
1289        W_HYPERLINK => {
1290            // Closing `</w:hyperlink>` clears the hyperlink state so subsequent
1291            // runs in the same paragraph are not tagged.
1292            if let Some(ParseState::Paragraph {
1293                in_hyperlink,
1294                hyperlink_rid,
1295                ..
1296            }) = stack.last_mut()
1297            {
1298                *in_hyperlink = false;
1299                *hyperlink_rid = None;
1300            }
1301        }
1302        W_T => {
1303            if let Some(ParseState::Paragraph { in_text, .. }) = stack.last_mut() {
1304                *in_text = false;
1305            }
1306        }
1307        W_PPR => {
1308            if let Some(ParseState::Paragraph { in_ppr, .. }) = stack.last_mut() {
1309                *in_ppr = false;
1310            }
1311        }
1312        W_RPR => {
1313            if let Some(ParseState::Paragraph { in_rpr, .. }) = stack.last_mut() {
1314                *in_rpr = false;
1315            }
1316        }
1317        W_TCPR => {
1318            if let Some(ParseState::Table {
1319                current_row: Some(row),
1320                ..
1321            }) = stack.last_mut()
1322                && let Some(cell) = row.cells.last_mut()
1323            {
1324                cell.in_tcpr = false;
1325            }
1326        }
1327        W_TBL => {
1328            if let Some(ParseState::Table { rows, .. }) = stack.pop() {
1329                let table = easydoc_core::DocumentTable { rows };
1330                if inside_table(stack) {
1331                    // Nested table -- store as a block in the parent cell.
1332                    if let Some(ParseState::Table {
1333                        current_row: Some(row),
1334                        ..
1335                    }) = stack.last_mut()
1336                        && let Some(cell) = row.cells.last_mut()
1337                    {
1338                        cell.blocks.push(DocumentBlock::Table(table));
1339                    }
1340                } else {
1341                    // Top-level table -- flush any pending list, then emit.
1342                    flush_list(
1343                        sink,
1344                        ctx.list_items,
1345                        ctx.first_list_num_id,
1346                        ctx.first_list_ilvl,
1347                        ctx.numbering,
1348                        ctx.relationships,
1349                        ctx.ssrf,
1350                    )?;
1351                    sink.on_event(&DocumentEvent::Table(table))?;
1352                }
1353            }
1354        }
1355        W_TR => {
1356            if let Some(ParseState::Table {
1357                current_row, rows, ..
1358            }) = stack.last_mut()
1359                && let Some(row_builder) = current_row.take()
1360            {
1361                let cells = row_builder
1362                    .cells
1363                    .into_iter()
1364                    .map(|c| {
1365                        let mut blocks = Vec::new();
1366                        let trimmed = c.text.trim().to_owned();
1367                        if !trimmed.is_empty() {
1368                            blocks.push(easydoc_core::DocumentBlock::Paragraph(vec![
1369                                DocumentTextRun {
1370                                    text: trimmed,
1371                                    ..DocumentTextRun::default()
1372                                },
1373                            ]));
1374                        }
1375                        blocks.extend(c.blocks);
1376                        DocumentTableCell {
1377                            blocks,
1378                            column_span: c.column_span,
1379                            row_span: c.row_span,
1380                        }
1381                    })
1382                    .collect();
1383                rows.push(DocumentTableRow {
1384                    cells,
1385                    is_header: false,
1386                });
1387            }
1388        }
1389        W_DRAWING => {
1390            // Pop the Drawing state and attempt to extract real image data.
1391            if let Some(ParseState::Drawing {
1392                pending_rid,
1393                pending_alt,
1394            }) = stack.pop()
1395            {
1396                let (data, extension) = if let (Some(rid), Some(arch), Some(rels)) = (
1397                    pending_rid.as_ref(),
1398                    ctx.archive.as_deref_mut(),
1399                    ctx.relationships,
1400                ) {
1401                    if let Some(part_path) = rels.resolve(rid) {
1402                        match read_zip_part(arch, part_path) {
1403                            Ok(bytes) => (Some(bytes), extension_from_filename(part_path)),
1404                            Err(_) => (None, None),
1405                        }
1406                    } else {
1407                        (None, None)
1408                    }
1409                } else {
1410                    (None, None)
1411                };
1412
1413                let alt_text = pending_alt.or_else(|| Some("[image]".to_owned()));
1414
1415                sink.on_event(&DocumentEvent::Image(DocumentImage {
1416                    alt_text,
1417                    data,
1418                    extension,
1419                }))?;
1420            }
1421        }
1422        _ => {}
1423    }
1424
1425    Ok(())
1426}
1427
1428// ---------------------------------------------------------------------------
1429// Helper functions
1430// ---------------------------------------------------------------------------
1431
1432/// Returns `true` if the stack contains a `Table` state (i.e. we are inside a
1433/// `<w:tbl>` element).
1434fn inside_table(stack: &[ParseState]) -> bool {
1435    stack.iter().any(|s| matches!(s, ParseState::Table { .. }))
1436}
1437
1438/// Extracts the `w:val` attribute value from a start tag.
1439fn extract_val(tag: &quick_xml::events::BytesStart) -> Option<String> {
1440    for attr in tag.attributes().flatten() {
1441        if attr.key.as_ref() == W_VAL {
1442            return attr
1443                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1444                .ok()
1445                .map(std::borrow::Cow::into_owned);
1446        }
1447    }
1448    None
1449}
1450
1451/// Extracts a boolean attribute: `w:val="true"` / `w:val="false"`.
1452/// If no `w:val` is present, returns `Some(true)` (OOXML convention).
1453fn extract_bool_attr(tag: &quick_xml::events::BytesStart) -> Option<bool> {
1454    match extract_val(tag) {
1455        Some(v) => {
1456            let lower = v.to_lowercase();
1457            if lower == "false" || lower == "0" {
1458                Some(false)
1459            } else {
1460                Some(true)
1461            }
1462        }
1463        None => Some(true),
1464    }
1465}
1466
1467/// Checks `xml:space="preserve"` on a `<w:t>` tag.
1468fn has_preserve_space(tag: &quick_xml::events::BytesStart) -> bool {
1469    for attr in tag.attributes().flatten() {
1470        if attr.key.as_ref() == b"xml:space" {
1471            return attr
1472                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1473                .ok()
1474                .is_some_and(|v| v.as_ref() == "preserve");
1475        }
1476    }
1477    false
1478}
1479
1480/// Parses a heading level from a style name like `"Heading1"` -> `1`.
1481fn parse_heading_level(style: &str) -> Option<u8> {
1482    let trimmed = style.trim();
1483    // Handle both "Heading1" and "heading 1" variants.
1484    let lower = trimmed.to_lowercase();
1485    let digits = lower
1486        .strip_prefix("heading")
1487        .or_else(|| lower.strip_prefix("heading "));
1488    digits
1489        .and_then(|d| d.trim().parse::<u8>().ok())
1490        .filter(|&l| (1..=6).contains(&l))
1491}
1492
1493/// Checks if a `<w:br>` element is a page break.
1494fn br_is_page_break(tag: &quick_xml::events::BytesStart) -> bool {
1495    for attr in tag.attributes().flatten() {
1496        if attr.key.as_ref() == W_TYPE {
1497            return attr
1498                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
1499                .ok()
1500                .is_some_and(|v| v.as_ref() == "page");
1501        }
1502    }
1503    false
1504}
1505
1506/// Searches for `word/document.xml` case-insensitively in a ZIP archive.
1507fn find_word_document_xml(archive: &mut zip::ZipArchive<File>) -> Result<String> {
1508    for i in 0..archive.len() {
1509        let entry = archive
1510            .by_index(i)
1511            .map_err(|e| DocError::Zip(e.to_string()))?;
1512        if entry.name().to_lowercase() == "word/document.xml" {
1513            return Ok(entry.name().to_owned());
1514        }
1515    }
1516    Err(DocError::Format(
1517        "word/document.xml not found in DOCX archive".to_owned(),
1518    ))
1519}
1520
1521// ===========================================================================
1522// Tests
1523// ===========================================================================
1524
1525#[cfg(test)]
1526mod tests {
1527    use super::*;
1528    use easydoc_core::ContentCollector;
1529
1530    /// Helper: wraps raw OOXML XML into a minimal valid DOCX ZIP archive in
1531    /// memory and returns the bytes.
1532    fn make_docx_xml(xml: &[u8]) -> Vec<u8> {
1533        use std::io::Write;
1534        let mut buf = Vec::new();
1535        {
1536            let w = std::io::Cursor::new(&mut buf);
1537            let mut zip = zip::ZipWriter::new(w);
1538            let options = zip::write::SimpleFileOptions::default()
1539                .compression_method(zip::CompressionMethod::Stored);
1540            zip.start_file("word/document.xml", options).unwrap();
1541            zip.write_all(xml).unwrap();
1542            zip.finish().unwrap();
1543        }
1544        buf
1545    }
1546
1547    /// Writes a docx zip to a temp file and returns the path.
1548    fn write_temp_docx(xml: &[u8]) -> tempfile::NamedTempFile {
1549        let data = make_docx_xml(xml);
1550        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1551        std::io::Write::write_all(&mut tmp, &data).unwrap();
1552        tmp
1553    }
1554
1555    #[test]
1556    fn empty_document_emits_start_end() {
1557        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1558<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1559  <w:body/>
1560</w:document>"#;
1561        let tmp = write_temp_docx(xml);
1562        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1563        let mut collector = ContentCollector::new();
1564        reader.read_events(&mut collector).unwrap();
1565        let content = collector.into_content();
1566        assert!(content.blocks.is_empty());
1567    }
1568
1569    #[test]
1570    fn simple_paragraph() {
1571        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1572<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1573  <w:body>
1574    <w:p>
1575      <w:r><w:t>Hello World</w:t></w:r>
1576    </w:p>
1577  </w:body>
1578</w:document>"#;
1579        let tmp = write_temp_docx(xml);
1580        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1581        let mut collector = ContentCollector::new();
1582        reader.read_events(&mut collector).unwrap();
1583        let content = collector.into_content();
1584        assert_eq!(content.blocks.len(), 1);
1585        match &content.blocks[0] {
1586            easydoc_core::DocumentBlock::Paragraph(runs) => {
1587                assert_eq!(runs.len(), 1);
1588                assert_eq!(runs[0].text, "Hello World");
1589            }
1590            _ => panic!("expected Paragraph"),
1591        }
1592    }
1593
1594    #[test]
1595    fn heading_detection() {
1596        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1597<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1598  <w:body>
1599    <w:p>
1600      <w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
1601      <w:r><w:t>Title</w:t></w:r>
1602    </w:p>
1603  </w:body>
1604</w:document>"#;
1605        let tmp = write_temp_docx(xml);
1606        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1607        let mut collector = ContentCollector::new();
1608        reader.read_events(&mut collector).unwrap();
1609        let content = collector.into_content();
1610        assert_eq!(content.blocks.len(), 1);
1611        match &content.blocks[0] {
1612            easydoc_core::DocumentBlock::Heading { level, runs } => {
1613                assert_eq!(*level, 1);
1614                assert_eq!(runs[0].text, "Title");
1615            }
1616            _ => panic!("expected Heading"),
1617        }
1618    }
1619
1620    #[test]
1621    fn bold_run() {
1622        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1623<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1624  <w:body>
1625    <w:p>
1626      <w:r><w:rPr><w:b/></w:rPr><w:t>Bold</w:t></w:r>
1627    </w:p>
1628  </w:body>
1629</w:document>"#;
1630        let tmp = write_temp_docx(xml);
1631        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1632        let mut collector = ContentCollector::new();
1633        reader.read_events(&mut collector).unwrap();
1634        let content = collector.into_content();
1635        match &content.blocks[0] {
1636            easydoc_core::DocumentBlock::Paragraph(runs) => {
1637                assert!(runs[0].bold);
1638                assert!(!runs[0].italic);
1639            }
1640            _ => panic!("expected Paragraph"),
1641        }
1642    }
1643
1644    #[test]
1645    fn page_break() {
1646        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1647<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1648  <w:body>
1649    <w:p>
1650      <w:r><w:br w:type="page"/></w:r>
1651      <w:r><w:t>After break</w:t></w:r>
1652    </w:p>
1653  </w:body>
1654</w:document>"#;
1655        let tmp = write_temp_docx(xml);
1656        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1657        let mut collector = ContentCollector::new();
1658        reader.read_events(&mut collector).unwrap();
1659        let content = collector.into_content();
1660        // PageBreak + Paragraph
1661        assert_eq!(content.blocks.len(), 2);
1662        assert!(matches!(
1663            content.blocks[0],
1664            easydoc_core::DocumentBlock::PageBreak
1665        ));
1666    }
1667
1668    #[test]
1669    fn simple_table() {
1670        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1671<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1672  <w:body>
1673    <w:tbl>
1674      <w:tr>
1675        <w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
1676        <w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
1677      </w:tr>
1678      <w:tr>
1679        <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
1680        <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
1681      </w:tr>
1682    </w:tbl>
1683  </w:body>
1684</w:document>"#;
1685        let tmp = write_temp_docx(xml);
1686        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1687        let mut collector = ContentCollector::new();
1688        reader.read_events(&mut collector).unwrap();
1689        let content = collector.into_content();
1690        assert_eq!(content.blocks.len(), 1);
1691        match &content.blocks[0] {
1692            easydoc_core::DocumentBlock::Table(table) => {
1693                assert_eq!(table.rows.len(), 2);
1694                assert_eq!(table.rows[0].cells.len(), 2);
1695                // Check cell text
1696                let cell_text: String = table.rows[0].cells[0]
1697                    .blocks
1698                    .iter()
1699                    .filter_map(|b| match b {
1700                        easydoc_core::DocumentBlock::Paragraph(runs) => {
1701                            Some(runs.iter().map(|r| r.text.as_str()).collect::<String>())
1702                        }
1703                        _ => None,
1704                    })
1705                    .collect();
1706                assert_eq!(cell_text, "A1");
1707            }
1708            _ => panic!("expected Table"),
1709        }
1710    }
1711
1712    #[test]
1713    fn mixed_content_paragraph_and_table() {
1714        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1715<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1716  <w:body>
1717    <w:p><w:r><w:t>Before</w:t></w:r></w:p>
1718    <w:tbl>
1719      <w:tr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr>
1720    </w:tbl>
1721    <w:p><w:r><w:t>After</w:t></w:r></w:p>
1722  </w:body>
1723</w:document>"#;
1724        let tmp = write_temp_docx(xml);
1725        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1726        let mut collector = ContentCollector::new();
1727        reader.read_events(&mut collector).unwrap();
1728        let content = collector.into_content();
1729        assert_eq!(content.blocks.len(), 3);
1730        assert!(matches!(
1731            content.blocks[0],
1732            easydoc_core::DocumentBlock::Paragraph(_)
1733        ));
1734        assert!(matches!(
1735            content.blocks[1],
1736            easydoc_core::DocumentBlock::Table(_)
1737        ));
1738        assert!(matches!(
1739            content.blocks[2],
1740            easydoc_core::DocumentBlock::Paragraph(_)
1741        ));
1742    }
1743
1744    #[test]
1745    fn from_reader_basic() {
1746        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1747<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1748  <w:body>
1749    <w:p><w:r><w:t>Direct</w:t></w:r></w:p>
1750  </w:body>
1751</w:document>"#;
1752        let mut reader = DocxSaxReader::from_reader(&xml[..]);
1753        let mut collector = ContentCollector::new();
1754        reader.read_events(&mut collector).unwrap();
1755        let content = collector.into_content();
1756        assert_eq!(content.blocks.len(), 1);
1757    }
1758
1759    #[test]
1760    fn parse_heading_level_variants() {
1761        assert_eq!(parse_heading_level("Heading1"), Some(1));
1762        assert_eq!(parse_heading_level("Heading2"), Some(2));
1763        assert_eq!(parse_heading_level("heading3"), Some(3));
1764        assert_eq!(parse_heading_level("heading 4"), Some(4));
1765        assert_eq!(parse_heading_level("Heading7"), None);
1766        assert_eq!(parse_heading_level("Normal"), None);
1767        assert_eq!(parse_heading_level("Title"), None);
1768    }
1769
1770    #[test]
1771    fn drawing_emits_placeholder_image() {
1772        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1773<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1774  <w:body>
1775    <w:p>
1776      <w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"/></w:drawing></w:r>
1777    </w:p>
1778  </w:body>
1779</w:document>"#;
1780        let tmp = write_temp_docx(xml);
1781        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1782        let mut collector = ContentCollector::new();
1783        reader.read_events(&mut collector).unwrap();
1784        let content = collector.into_content();
1785        let has_image = content
1786            .blocks
1787            .iter()
1788            .any(|b| matches!(b, easydoc_core::DocumentBlock::Image(_)));
1789        assert!(has_image, "expected an Image block from drawing");
1790    }
1791
1792    #[test]
1793    fn italic_and_strikethrough() {
1794        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1795<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1796  <w:body>
1797    <w:p>
1798      <w:r><w:rPr><w:i/><w:strike/></w:rPr><w:t>Fancy</w:t></w:r>
1799    </w:p>
1800  </w:body>
1801</w:document>"#;
1802        let tmp = write_temp_docx(xml);
1803        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1804        let mut collector = ContentCollector::new();
1805        reader.read_events(&mut collector).unwrap();
1806        let content = collector.into_content();
1807        match &content.blocks[0] {
1808            easydoc_core::DocumentBlock::Paragraph(runs) => {
1809                assert!(runs[0].italic);
1810                assert!(runs[0].strikethrough);
1811                assert!(!runs[0].bold);
1812            }
1813            _ => panic!("expected Paragraph"),
1814        }
1815    }
1816
1817    #[test]
1818    fn multiple_runs_in_paragraph() {
1819        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1820<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1821  <w:body>
1822    <w:p>
1823      <w:r><w:t>Hello </w:t></w:r>
1824      <w:r><w:rPr><w:b/></w:rPr><w:t>World</w:t></w:r>
1825    </w:p>
1826  </w:body>
1827</w:document>"#;
1828        let tmp = write_temp_docx(xml);
1829        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1830        let mut collector = ContentCollector::new();
1831        reader.read_events(&mut collector).unwrap();
1832        let content = collector.into_content();
1833        match &content.blocks[0] {
1834            easydoc_core::DocumentBlock::Paragraph(runs) => {
1835                assert_eq!(runs.len(), 2);
1836                assert_eq!(runs[0].text, "Hello ");
1837                assert!(!runs[0].bold);
1838                assert_eq!(runs[1].text, "World");
1839                assert!(runs[1].bold);
1840            }
1841            _ => panic!("expected Paragraph"),
1842        }
1843    }
1844
1845    /// A minimal 1x1 white PNG (67 bytes).
1846    const MINIMAL_PNG: &[u8] = &[
1847        0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
1848        0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
1849        0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
1850        0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
1851        0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
1852    ];
1853
1854    /// Builds a DOCX ZIP containing `word/document.xml`, `word/_rels/document.xml.rels`,
1855    /// and `word/media/image1.png` with the given image bytes.
1856    fn make_docx_with_image(xml: &[u8], rels_xml: &[u8], image_bytes: &[u8]) -> Vec<u8> {
1857        use std::io::Write;
1858        let mut buf = Vec::new();
1859        {
1860            let w = std::io::Cursor::new(&mut buf);
1861            let mut zip = zip::ZipWriter::new(w);
1862            let options = zip::write::SimpleFileOptions::default()
1863                .compression_method(zip::CompressionMethod::Stored);
1864
1865            zip.start_file("word/document.xml", options).unwrap();
1866            zip.write_all(xml).unwrap();
1867
1868            zip.start_file("word/_rels/document.xml.rels", options)
1869                .unwrap();
1870            zip.write_all(rels_xml).unwrap();
1871
1872            zip.start_file("word/media/image1.png", options).unwrap();
1873            zip.write_all(image_bytes).unwrap();
1874
1875            zip.finish().unwrap();
1876        }
1877        buf
1878    }
1879
1880    /// Writes a full docx ZIP (with image) to a temp file and returns the path.
1881    fn write_temp_docx_with_image(
1882        xml: &[u8],
1883        rels_xml: &[u8],
1884        image_bytes: &[u8],
1885    ) -> tempfile::NamedTempFile {
1886        let data = make_docx_with_image(xml, rels_xml, image_bytes);
1887        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1888        std::io::Write::write_all(&mut tmp, &data).unwrap();
1889        tmp
1890    }
1891
1892    #[test]
1893    fn from_reader_drawing_emits_placeholder_no_data() {
1894        // from_reader path has no ZIP, so image data must be None.
1895        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1896<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1897            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1898            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
1899            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
1900  <w:body>
1901    <w:p>
1902      <w:r>
1903        <w:drawing>
1904          <wp:inline>
1905            <wp:docPr id="1" name="Picture 1" descr="My photo"/>
1906            <a:graphic>
1907              <a:graphicData>
1908                <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
1909                  <pic:blipFill>
1910                    <a:blip r:embed="rId5"/>
1911                  </pic:blipFill>
1912                </pic:pic>
1913              </a:graphicData>
1914            </a:graphic>
1915          </wp:inline>
1916        </w:drawing>
1917      </w:r>
1918    </w:p>
1919  </w:body>
1920</w:document>"#;
1921        let mut reader = DocxSaxReader::from_reader(&xml[..]);
1922        let mut collector = ContentCollector::new();
1923        reader.read_events(&mut collector).unwrap();
1924        let content = collector.into_content();
1925
1926        let img = content
1927            .blocks
1928            .iter()
1929            .find_map(|b| match b {
1930                easydoc_core::DocumentBlock::Image(img) => Some(img),
1931                _ => None,
1932            })
1933            .expect("expected an Image block");
1934
1935        // No ZIP archive => data is None.
1936        assert!(img.data.is_none());
1937        // Alt text should come from wp:docPr descr.
1938        assert_eq!(img.alt_text.as_deref(), Some("My photo"));
1939    }
1940
1941    #[test]
1942    fn from_path_drawing_extracts_real_image_data() {
1943        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1944<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
1945            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
1946            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
1947            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
1948  <w:body>
1949    <w:p>
1950      <w:r>
1951        <w:drawing>
1952          <wp:inline>
1953            <wp:docPr id="1" name="Picture 1" descr="A tiny image"/>
1954            <a:graphic>
1955              <a:graphicData>
1956                <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
1957                  <pic:blipFill>
1958                    <a:blip r:embed="rId5"/>
1959                  </pic:blipFill>
1960                </pic:pic>
1961              </a:graphicData>
1962            </a:graphic>
1963          </wp:inline>
1964        </w:drawing>
1965      </w:r>
1966    </w:p>
1967  </w:body>
1968</w:document>"#;
1969
1970        let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1971<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1972  <Relationship Id="rId1" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
1973  <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
1974</Relationships>"#;
1975
1976        let tmp = write_temp_docx_with_image(xml, rels_xml, MINIMAL_PNG);
1977        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
1978        let mut collector = ContentCollector::new();
1979        reader.read_events(&mut collector).unwrap();
1980        let content = collector.into_content();
1981
1982        let img = content
1983            .blocks
1984            .iter()
1985            .find_map(|b| match b {
1986                easydoc_core::DocumentBlock::Image(img) => Some(img),
1987                _ => None,
1988            })
1989            .expect("expected an Image block");
1990
1991        // Image data should be the real PNG bytes.
1992        assert_eq!(img.data.as_deref(), Some(MINIMAL_PNG));
1993        // Extension should be inferred from the media path.
1994        assert_eq!(img.extension.as_deref(), Some("png"));
1995        // Alt text from wp:docPr descr.
1996        assert_eq!(img.alt_text.as_deref(), Some("A tiny image"));
1997    }
1998
1999    #[test]
2000    fn from_path_drawing_without_rels_emits_placeholder() {
2001        // ZIP has document.xml but no rels file => image data is None.
2002        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2003<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2004            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2005            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2006            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2007  <w:body>
2008    <w:p>
2009      <w:r>
2010        <w:drawing>
2011          <wp:inline>
2012            <wp:docPr id="1" name="Pic"/>
2013            <a:graphic>
2014              <a:graphicData>
2015                <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2016                  <pic:blipFill>
2017                    <a:blip r:embed="rId5"/>
2018                  </pic:blipFill>
2019                </pic:pic>
2020              </a:graphicData>
2021            </a:graphic>
2022          </wp:inline>
2023        </w:drawing>
2024      </w:r>
2025    </w:p>
2026  </w:body>
2027</w:document>"#;
2028        let tmp = write_temp_docx(xml);
2029        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2030        let mut collector = ContentCollector::new();
2031        reader.read_events(&mut collector).unwrap();
2032        let content = collector.into_content();
2033
2034        let img = content
2035            .blocks
2036            .iter()
2037            .find_map(|b| match b {
2038                easydoc_core::DocumentBlock::Image(img) => Some(img),
2039                _ => None,
2040            })
2041            .expect("expected an Image block");
2042
2043        // No rels file => data is None.
2044        assert!(img.data.is_none());
2045        // wp:docPr name is an object label, not alt text; descr is absent,
2046        // so the fallback "[image]" is used.
2047        assert_eq!(img.alt_text.as_deref(), Some("[image]"));
2048    }
2049
2050    #[test]
2051    fn from_path_drawing_alt_from_name_when_no_descr() {
2052        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2053<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2054            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2055            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2056            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2057  <w:body>
2058    <w:p>
2059      <w:r>
2060        <w:drawing>
2061          <wp:inline>
2062            <wp:docPr id="1" name="Diagram"/>
2063            <a:graphic>
2064              <a:graphicData>
2065                <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2066                  <pic:blipFill>
2067                    <a:blip r:embed="rId5"/>
2068                  </pic:blipFill>
2069                </pic:pic>
2070              </a:graphicData>
2071            </a:graphic>
2072          </wp:inline>
2073        </w:drawing>
2074      </w:r>
2075    </w:p>
2076  </w:body>
2077</w:document>"#;
2078        let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2079<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2080  <Relationship Id="rId5" Target="media/image1.png" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2081</Relationships>"#;
2082
2083        let tmp = write_temp_docx_with_image(xml, rels_xml, MINIMAL_PNG);
2084        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2085        let mut collector = ContentCollector::new();
2086        reader.read_events(&mut collector).unwrap();
2087        let content = collector.into_content();
2088
2089        let img = content
2090            .blocks
2091            .iter()
2092            .find_map(|b| match b {
2093                easydoc_core::DocumentBlock::Image(img) => Some(img),
2094                _ => None,
2095            })
2096            .expect("expected an Image block");
2097
2098        // No descr attribute, but name="Diagram" is present.  Since we prefer
2099        // descr and fall back to "[image]", the alt should be "[image]".
2100        // (wp:docPr name is the object label, not the alt text.)
2101        assert_eq!(img.alt_text.as_deref(), Some("[image]"));
2102    }
2103
2104    #[test]
2105    fn from_path_drawing_jpeg_extension() {
2106        use std::io::Write;
2107        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2108<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2109            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
2110            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
2111            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2112  <w:body>
2113    <w:p>
2114      <w:r>
2115        <w:drawing>
2116          <wp:inline>
2117            <wp:docPr id="1" name="Pic"/>
2118            <a:graphic>
2119              <a:graphicData>
2120                <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
2121                  <pic:blipFill>
2122                    <a:blip r:embed="rId5"/>
2123                  </pic:blipFill>
2124                </pic:pic>
2125              </a:graphicData>
2126            </a:graphic>
2127          </wp:inline>
2128        </w:drawing>
2129      </w:r>
2130    </w:p>
2131  </w:body>
2132</w:document>"#;
2133
2134        let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2135<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
2136  <Relationship Id="rId5" Target="media/photo.jpeg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"/>
2137</Relationships>"#;
2138
2139        // Build a ZIP with the media entry matching the rels target.
2140        let zip_bytes = {
2141            let mut buf = Vec::new();
2142            {
2143                let w = std::io::Cursor::new(&mut buf);
2144                let mut zip = zip::ZipWriter::new(w);
2145                let options = zip::write::SimpleFileOptions::default()
2146                    .compression_method(zip::CompressionMethod::Stored);
2147
2148                zip.start_file("word/document.xml", options).unwrap();
2149                zip.write_all(xml).unwrap();
2150
2151                zip.start_file("word/_rels/document.xml.rels", options)
2152                    .unwrap();
2153                zip.write_all(rels_xml).unwrap();
2154
2155                // Match the rels target: word/media/photo.jpeg
2156                zip.start_file("word/media/photo.jpeg", options).unwrap();
2157                zip.write_all(MINIMAL_PNG).unwrap();
2158
2159                zip.finish().unwrap();
2160            }
2161            buf
2162        };
2163
2164        let mut tmp = tempfile::NamedTempFile::new().unwrap();
2165        std::io::Write::write_all(&mut tmp, &zip_bytes).unwrap();
2166        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
2167        let mut collector = ContentCollector::new();
2168        reader.read_events(&mut collector).unwrap();
2169        let content = collector.into_content();
2170
2171        let img = content
2172            .blocks
2173            .iter()
2174            .find_map(|b| match b {
2175                easydoc_core::DocumentBlock::Image(img) => Some(img),
2176                _ => None,
2177            })
2178            .expect("expected an Image block");
2179
2180        assert_eq!(img.data.as_deref(), Some(MINIMAL_PNG));
2181        assert_eq!(img.extension.as_deref(), Some("jpeg"));
2182    }
2183
2184    // -----------------------------------------------------------------------
2185    // Merge cell tests
2186    // -----------------------------------------------------------------------
2187
2188    #[test]
2189    fn cell_without_merge_has_default_span() {
2190        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2191<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2192  <w:body>
2193    <w:tbl>
2194      <w:tr>
2195        <w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc>
2196        <w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc>
2197      </w:tr>
2198    </w:tbl>
2199  </w:body>
2200</w:document>"#;
2201        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2202        let mut collector = ContentCollector::new();
2203        reader.read_events(&mut collector).unwrap();
2204        let content = collector.into_content();
2205
2206        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2207            panic!("expected Table")
2208        };
2209        assert_eq!(table.rows[0].cells.len(), 2);
2210        assert_eq!(table.rows[0].cells[0].column_span, 1);
2211        assert_eq!(table.rows[0].cells[0].row_span, 1);
2212        assert_eq!(table.rows[0].cells[1].column_span, 1);
2213        assert_eq!(table.rows[0].cells[1].row_span, 1);
2214    }
2215
2216    #[test]
2217    fn gridspan_horizontal_merge_two_columns() {
2218        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2219<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2220  <w:body>
2221    <w:tbl>
2222      <w:tr>
2223        <w:tc>
2224          <w:tcPr><w:gridSpan w:val="2"/></w:tcPr>
2225          <w:p><w:r><w:t>Merged</w:t></w:r></w:p>
2226        </w:tc>
2227      </w:tr>
2228      <w:tr>
2229        <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
2230        <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
2231      </w:tr>
2232    </w:tbl>
2233  </w:body>
2234</w:document>"#;
2235        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2236        let mut collector = ContentCollector::new();
2237        reader.read_events(&mut collector).unwrap();
2238        let content = collector.into_content();
2239
2240        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2241            panic!("expected Table")
2242        };
2243        // Row 0: one cell spanning 2 columns.
2244        assert_eq!(table.rows[0].cells.len(), 1);
2245        assert_eq!(table.rows[0].cells[0].column_span, 2);
2246        assert_eq!(table.rows[0].cells[0].row_span, 1);
2247        // Row 1: two normal cells.
2248        assert_eq!(table.rows[1].cells.len(), 2);
2249        assert_eq!(table.rows[1].cells[0].column_span, 1);
2250        assert_eq!(table.rows[1].cells[1].column_span, 1);
2251    }
2252
2253    #[test]
2254    fn gridspan_horizontal_merge_three_columns() {
2255        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2256<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2257  <w:body>
2258    <w:tbl>
2259      <w:tr>
2260        <w:tc>
2261          <w:tcPr><w:gridSpan w:val="3"/></w:tcPr>
2262          <w:p><w:r><w:t>Wide</w:t></w:r></w:p>
2263        </w:tc>
2264      </w:tr>
2265    </w:tbl>
2266  </w:body>
2267</w:document>"#;
2268        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2269        let mut collector = ContentCollector::new();
2270        reader.read_events(&mut collector).unwrap();
2271        let content = collector.into_content();
2272
2273        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2274            panic!("expected Table")
2275        };
2276        assert_eq!(table.rows[0].cells.len(), 1);
2277        assert_eq!(table.rows[0].cells[0].column_span, 3);
2278    }
2279
2280    #[test]
2281    fn vmerge_restart_sets_row_span_one() {
2282        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2283<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2284  <w:body>
2285    <w:tbl>
2286      <w:tr>
2287        <w:tc>
2288          <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2289          <w:p><w:r><w:t>Start</w:t></w:r></w:p>
2290        </w:tc>
2291        <w:tc><w:p><w:r><w:t>Right</w:t></w:r></w:p></w:tc>
2292      </w:tr>
2293    </w:tbl>
2294  </w:body>
2295</w:document>"#;
2296        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2297        let mut collector = ContentCollector::new();
2298        reader.read_events(&mut collector).unwrap();
2299        let content = collector.into_content();
2300
2301        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2302            panic!("expected Table")
2303        };
2304        // restart cell: row_span = 1 (self), column_span = 1 (default).
2305        assert_eq!(table.rows[0].cells[0].row_span, 1);
2306        assert_eq!(table.rows[0].cells[0].column_span, 1);
2307    }
2308
2309    #[test]
2310    fn vmerge_continue_sets_row_span_zero() {
2311        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2312<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2313  <w:body>
2314    <w:tbl>
2315      <w:tr>
2316        <w:tc>
2317          <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2318          <w:p><w:r><w:t>Start</w:t></w:r></w:p>
2319        </w:tc>
2320        <w:tc><w:p><w:r><w:t>R1</w:t></w:r></w:p></w:tc>
2321      </w:tr>
2322      <w:tr>
2323        <w:tc>
2324          <w:tcPr><w:vMerge w:val="continue"/></w:tcPr>
2325          <w:p/>
2326        </w:tc>
2327        <w:tc><w:p><w:r><w:t>R2</w:t></w:r></w:p></w:tc>
2328      </w:tr>
2329    </w:tbl>
2330  </w:body>
2331</w:document>"#;
2332        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2333        let mut collector = ContentCollector::new();
2334        reader.read_events(&mut collector).unwrap();
2335        let content = collector.into_content();
2336
2337        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2338            panic!("expected Table")
2339        };
2340        // Row 0, cell 0: restart => row_span = 1.
2341        assert_eq!(table.rows[0].cells[0].row_span, 1);
2342        // Row 1, cell 0: continue => row_span = 0 (merged into cell above).
2343        assert_eq!(table.rows[1].cells[0].row_span, 0);
2344    }
2345
2346    #[test]
2347    fn vmerge_no_val_treated_as_continue() {
2348        // OOXML spec: <w:vMerge/> without val is treated as "continue".
2349        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2350<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2351  <w:body>
2352    <w:tbl>
2353      <w:tr>
2354        <w:tc>
2355          <w:tcPr><w:vMerge w:val="restart"/></w:tcPr>
2356          <w:p><w:r><w:t>Top</w:t></w:r></w:p>
2357        </w:tc>
2358      </w:tr>
2359      <w:tr>
2360        <w:tc>
2361          <w:tcPr><w:vMerge/></w:tcPr>
2362          <w:p/>
2363        </w:tc>
2364      </w:tr>
2365      <w:tr>
2366        <w:tc>
2367          <w:tcPr><w:vMerge/></w:tcPr>
2368          <w:p/>
2369        </w:tc>
2370      </w:tr>
2371    </w:tbl>
2372  </w:body>
2373</w:document>"#;
2374        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2375        let mut collector = ContentCollector::new();
2376        reader.read_events(&mut collector).unwrap();
2377        let content = collector.into_content();
2378
2379        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2380            panic!("expected Table")
2381        };
2382        // Row 0: restart => row_span = 1.
2383        assert_eq!(table.rows[0].cells[0].row_span, 1);
2384        // Row 1: no val => continue => row_span = 0.
2385        assert_eq!(table.rows[1].cells[0].row_span, 0);
2386        // Row 2: no val => continue => row_span = 0.
2387        assert_eq!(table.rows[2].cells[0].row_span, 0);
2388    }
2389
2390    #[test]
2391    fn mixed_gridspan_and_vmerge() {
2392        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2393<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2394  <w:body>
2395    <w:tbl>
2396      <w:tr>
2397        <w:tc>
2398          <w:tcPr>
2399            <w:gridSpan w:val="2"/>
2400            <w:vMerge w:val="restart"/>
2401          </w:tcPr>
2402          <w:p><w:r><w:t>Big</w:t></w:r></w:p>
2403        </w:tc>
2404        <w:tc><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc>
2405      </w:tr>
2406      <w:tr>
2407        <w:tc>
2408          <w:tcPr>
2409            <w:gridSpan w:val="2"/>
2410            <w:vMerge w:val="continue"/>
2411          </w:tcPr>
2412          <w:p/>
2413        </w:tc>
2414        <w:tc><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc>
2415      </w:tr>
2416    </w:tbl>
2417  </w:body>
2418</w:document>"#;
2419        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2420        let mut collector = ContentCollector::new();
2421        reader.read_events(&mut collector).unwrap();
2422        let content = collector.into_content();
2423
2424        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2425            panic!("expected Table")
2426        };
2427        // Row 0, cell 0: gridSpan=2 + vMerge=restart => column_span=2, row_span=1.
2428        assert_eq!(table.rows[0].cells[0].column_span, 2);
2429        assert_eq!(table.rows[0].cells[0].row_span, 1);
2430        // Row 1, cell 0: gridSpan=2 + vMerge=continue => column_span=2, row_span=0.
2431        assert_eq!(table.rows[1].cells[0].column_span, 2);
2432        assert_eq!(table.rows[1].cells[0].row_span, 0);
2433    }
2434
2435    #[test]
2436    fn gridspan_val_one_is_noop() {
2437        // gridSpan=1 means no horizontal merge; column_span should remain 1.
2438        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2439<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2440  <w:body>
2441    <w:tbl>
2442      <w:tr>
2443        <w:tc>
2444          <w:tcPr><w:gridSpan w:val="1"/></w:tcPr>
2445          <w:p><w:r><w:t>X</w:t></w:r></w:p>
2446        </w:tc>
2447        <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
2448      </w:tr>
2449    </w:tbl>
2450  </w:body>
2451</w:document>"#;
2452        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2453        let mut collector = ContentCollector::new();
2454        reader.read_events(&mut collector).unwrap();
2455        let content = collector.into_content();
2456
2457        let easydoc_core::DocumentBlock::Table(table) = &content.blocks[0] else {
2458            panic!("expected Table")
2459        };
2460        assert_eq!(table.rows[0].cells[0].column_span, 1);
2461        assert_eq!(table.rows[0].cells[1].column_span, 1);
2462    }
2463
2464    // -----------------------------------------------------------------------
2465    // OMML math tests
2466    // -----------------------------------------------------------------------
2467
2468    #[test]
2469    fn inline_math_in_paragraph() {
2470        // Single <m:oMath> inside a <w:p> with surrounding text.
2471        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2472<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2473            xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2474  <w:body>
2475    <w:p>
2476      <w:r><w:t>Formula: </w:t></w:r>
2477      <m:oMath><m:r><m:t>x</m:t></m:r></m:oMath>
2478    </w:p>
2479  </w:body>
2480</w:document>"#;
2481        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2482        let blocks = reader.read_blocks().unwrap();
2483        // Expect: Paragraph(["Formula: "]), Math (inline)
2484        assert_eq!(blocks.len(), 2);
2485        match &blocks[0] {
2486            DocumentBlock::Paragraph(runs) => {
2487                assert_eq!(runs.len(), 1);
2488                assert_eq!(runs[0].text, "Formula: ");
2489            }
2490            other => panic!("expected Paragraph, got {other:?}"),
2491        }
2492        match &blocks[1] {
2493            DocumentBlock::Math {
2494                omml,
2495                latex,
2496                display,
2497            } => {
2498                let xml_str = omml.as_ref().expect("omml should be Some");
2499                assert!(xml_str.contains("<m:oMath>"), "omml = {xml_str}");
2500                assert!(xml_str.contains("</m:oMath>"), "omml = {xml_str}");
2501                assert!(
2502                    xml_str.contains("<m:r><m:t>x</m:t></m:r>"),
2503                    "omml = {xml_str}"
2504                );
2505                assert!(latex.is_none());
2506                assert!(!display, "inline math should have display=false");
2507            }
2508            other => panic!("expected Math, got {other:?}"),
2509        }
2510    }
2511
2512    #[test]
2513    fn display_math_with_omathpara() {
2514        // <m:oMathPara> wrapping an <m:oMath> -- block-level display math.
2515        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2516<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2517            xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2518  <w:body>
2519    <w:p><w:r><w:t>Before</w:t></w:r></w:p>
2520    <m:oMathPara><m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath></m:oMathPara>
2521    <w:p><w:r><w:t>After</w:t></w:r></w:p>
2522  </w:body>
2523</w:document>"#;
2524        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2525        let blocks = reader.read_blocks().unwrap();
2526        // Expect: Paragraph, Math (display), Paragraph
2527        assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2528        match &blocks[0] {
2529            DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Before"),
2530            other => panic!("expected Paragraph, got {other:?}"),
2531        }
2532        match &blocks[1] {
2533            DocumentBlock::Math { omml, display, .. } => {
2534                let xml_str = omml.as_ref().expect("omml should be Some");
2535                assert!(xml_str.contains("<m:oMathPara>"), "omml = {xml_str}");
2536                assert!(xml_str.contains("</m:oMathPara>"), "omml = {xml_str}");
2537                assert!(*display, "oMathPara should have display=true");
2538            }
2539            other => panic!("expected Math, got {other:?}"),
2540        }
2541        match &blocks[2] {
2542            DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "After"),
2543            other => panic!("expected Paragraph, got {other:?}"),
2544        }
2545    }
2546
2547    #[test]
2548    fn mixed_text_math_text_in_paragraph() {
2549        // Text before, math in the middle, text after -- all in one <w:p>.
2550        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2551<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2552            xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2553  <w:body>
2554    <w:p>
2555      <w:r><w:t>Let </w:t></w:r>
2556      <m:oMath><m:r><m:t>y</m:t></m:r></m:oMath>
2557      <w:r><w:t> be the result.</w:t></w:r>
2558    </w:p>
2559  </w:body>
2560</w:document>"#;
2561        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2562        let blocks = reader.read_blocks().unwrap();
2563        // Expect: Paragraph(["Let "]), Math, Paragraph([" be the result."])
2564        assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2565        match &blocks[0] {
2566            DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Let "),
2567            other => panic!("expected Paragraph, got {other:?}"),
2568        }
2569        assert!(matches!(
2570            &blocks[1],
2571            DocumentBlock::Math { display: false, .. }
2572        ));
2573        match &blocks[2] {
2574            DocumentBlock::Paragraph(runs) => {
2575                assert_eq!(runs[0].text, " be the result.");
2576            }
2577            other => panic!("expected Paragraph, got {other:?}"),
2578        }
2579    }
2580
2581    #[test]
2582    fn nested_math_structure() {
2583        // <m:oMath> with nested <m:f> (fraction) structure.
2584        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2585<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2586            xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2587  <w:body>
2588    <m:oMath>
2589      <m:f>
2590        <m:num><m:r><m:t>a</m:t></m:r></m:num>
2591        <m:den><m:r><m:t>b</m:t></m:r></m:den>
2592      </m:f>
2593    </m:oMath>
2594  </w:body>
2595</w:document>"#;
2596        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2597        let blocks = reader.read_blocks().unwrap();
2598        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2599        match &blocks[0] {
2600            DocumentBlock::Math { omml, display, .. } => {
2601                let xml_str = omml.as_ref().expect("omml should be Some");
2602                // Verify the nested structure is preserved in the XML.
2603                assert!(xml_str.contains("<m:f>"), "omml = {xml_str}");
2604                assert!(xml_str.contains("</m:f>"), "omml = {xml_str}");
2605                assert!(xml_str.contains("<m:num>"), "omml = {xml_str}");
2606                assert!(xml_str.contains("<m:den>"), "omml = {xml_str}");
2607                assert!(xml_str.contains("<m:t>a</m:t>"), "omml = {xml_str}");
2608                assert!(xml_str.contains("<m:t>b</m:t>"), "omml = {xml_str}");
2609                assert!(!display, "standalone oMath should have display=false");
2610            }
2611            other => panic!("expected Math, got {other:?}"),
2612        }
2613    }
2614
2615    #[test]
2616    fn block_level_math_without_omathpara() {
2617        // <m:oMath> directly in <w:body> (no wrapping <m:oMathPara>).
2618        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2619<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2620            xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2621  <w:body>
2622    <w:p><w:r><w:t>See equation:</w:t></w:r></w:p>
2623    <m:oMath><m:r><m:t>x+1=0</m:t></m:r></m:oMath>
2624  </w:body>
2625</w:document>"#;
2626        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2627        let blocks = reader.read_blocks().unwrap();
2628        assert_eq!(blocks.len(), 2, "blocks = {blocks:?}");
2629        assert!(matches!(&blocks[0], DocumentBlock::Paragraph(_)));
2630        match &blocks[1] {
2631            DocumentBlock::Math { omml, display, .. } => {
2632                let xml_str = omml.as_ref().expect("omml should be Some");
2633                assert!(xml_str.contains("<m:oMath>"));
2634                assert!(
2635                    !display,
2636                    "bare oMathPara-less math should have display=false"
2637                );
2638            }
2639            other => panic!("expected Math, got {other:?}"),
2640        }
2641    }
2642
2643    #[test]
2644    fn multiple_math_in_one_paragraph() {
2645        // Two <m:oMath> formulas in a single paragraph.
2646        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2647<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2648            xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
2649  <w:body>
2650    <w:p>
2651      <m:oMath><m:r><m:t>a</m:t></m:r></m:oMath>
2652      <w:r><w:t> + </w:t></w:r>
2653      <m:oMath><m:r><m:t>b</m:t></m:r></m:oMath>
2654    </w:p>
2655  </w:body>
2656</w:document>"#;
2657        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2658        let blocks = reader.read_blocks().unwrap();
2659        // Expect: Math("a"), Paragraph([" + "]), Math("b")
2660        assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2661        assert!(matches!(
2662            &blocks[0],
2663            DocumentBlock::Math { display: false, .. }
2664        ));
2665        match &blocks[1] {
2666            DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, " + "),
2667            other => panic!("expected Paragraph, got {other:?}"),
2668        }
2669        assert!(matches!(
2670            &blocks[2],
2671            DocumentBlock::Math { display: false, .. }
2672        ));
2673    }
2674
2675    // -----------------------------------------------------------------------
2676    // List detection tests (<w:numPr>)
2677    // -----------------------------------------------------------------------
2678
2679    #[test]
2680    fn single_list_item() {
2681        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2682<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2683  <w:body>
2684    <w:p>
2685      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2686      <w:r><w:t>Item one</w:t></w:r>
2687    </w:p>
2688  </w:body>
2689</w:document>"#;
2690        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2691        let blocks = reader.read_blocks().unwrap();
2692        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2693        match &blocks[0] {
2694            DocumentBlock::List(list) => {
2695                assert_eq!(list.items.len(), 1);
2696                match &list.items[0].blocks[0] {
2697                    DocumentBlock::Paragraph(runs) => {
2698                        assert_eq!(runs[0].text, "Item one");
2699                    }
2700                    other => panic!("expected Paragraph inside list item, got {other:?}"),
2701                }
2702            }
2703            other => panic!("expected List, got {other:?}"),
2704        }
2705    }
2706
2707    #[test]
2708    fn consecutive_list_items_merged() {
2709        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2710<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2711  <w:body>
2712    <w:p>
2713      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2714      <w:r><w:t>First</w:t></w:r>
2715    </w:p>
2716    <w:p>
2717      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2718      <w:r><w:t>Second</w:t></w:r>
2719    </w:p>
2720    <w:p>
2721      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2722      <w:r><w:t>Third</w:t></w:r>
2723    </w:p>
2724  </w:body>
2725</w:document>"#;
2726        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2727        let blocks = reader.read_blocks().unwrap();
2728        // All three list items should be merged into a single List block.
2729        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2730        match &blocks[0] {
2731            DocumentBlock::List(list) => {
2732                assert_eq!(list.items.len(), 3);
2733            }
2734            other => panic!("expected List, got {other:?}"),
2735        }
2736    }
2737
2738    #[test]
2739    fn list_followed_by_paragraph_flushes() {
2740        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2741<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2742  <w:body>
2743    <w:p>
2744      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2745      <w:r><w:t>List item</w:t></w:r>
2746    </w:p>
2747    <w:p>
2748      <w:r><w:t>Normal paragraph</w:t></w:r>
2749    </w:p>
2750  </w:body>
2751</w:document>"#;
2752        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2753        let blocks = reader.read_blocks().unwrap();
2754        assert_eq!(blocks.len(), 2, "blocks = {blocks:?}");
2755        assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2756        match &blocks[1] {
2757            DocumentBlock::Paragraph(runs) => {
2758                assert_eq!(runs[0].text, "Normal paragraph");
2759            }
2760            other => panic!("expected Paragraph, got {other:?}"),
2761        }
2762    }
2763
2764    #[test]
2765    fn two_separate_lists() {
2766        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2767<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2768  <w:body>
2769    <w:p>
2770      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2771      <w:r><w:t>A</w:t></w:r>
2772    </w:p>
2773    <w:p>
2774      <w:r><w:t>Separator</w:t></w:r>
2775    </w:p>
2776    <w:p>
2777      <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2778      <w:r><w:t>B</w:t></w:r>
2779    </w:p>
2780  </w:body>
2781</w:document>"#;
2782        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2783        let blocks = reader.read_blocks().unwrap();
2784        assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
2785        assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2786        assert!(matches!(&blocks[1], DocumentBlock::Paragraph(_)));
2787        assert!(matches!(&blocks[2], DocumentBlock::List(_)));
2788    }
2789
2790    #[test]
2791    fn list_at_document_end_flushes() {
2792        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2793<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2794  <w:body>
2795    <w:p>
2796      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
2797      <w:r><w:t>Last item</w:t></w:r>
2798    </w:p>
2799  </w:body>
2800</w:document>"#;
2801        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2802        let blocks = reader.read_blocks().unwrap();
2803        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2804        assert!(matches!(&blocks[0], DocumentBlock::List(_)));
2805    }
2806
2807    // -----------------------------------------------------------------------
2808    // Hyperlink parsing tests (<w:hyperlink>)
2809    // -----------------------------------------------------------------------
2810
2811    #[test]
2812    fn hyperlink_sets_run_field() {
2813        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2814<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2815            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2816  <w:body>
2817    <w:p>
2818      <w:hyperlink r:id="rId5">
2819        <w:r><w:t>Click here</w:t></w:r>
2820      </w:hyperlink>
2821    </w:p>
2822  </w:body>
2823</w:document>"#;
2824        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2825        let blocks = reader.read_blocks().unwrap();
2826        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2827        match &blocks[0] {
2828            DocumentBlock::Paragraph(runs) => {
2829                assert_eq!(runs.len(), 1);
2830                assert_eq!(runs[0].text, "Click here");
2831                assert_eq!(runs[0].hyperlink.as_deref(), Some("rId5"));
2832            }
2833            other => panic!("expected Paragraph, got {other:?}"),
2834        }
2835    }
2836
2837    #[test]
2838    fn hyperlink_mixed_with_normal_runs() {
2839        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2840<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2841            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2842  <w:body>
2843    <w:p>
2844      <w:r><w:t>Normal </w:t></w:r>
2845      <w:hyperlink r:id="rId3">
2846        <w:r><w:t>link text</w:t></w:r>
2847      </w:hyperlink>
2848      <w:r><w:t> after</w:t></w:r>
2849    </w:p>
2850  </w:body>
2851</w:document>"#;
2852        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2853        let blocks = reader.read_blocks().unwrap();
2854        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2855        match &blocks[0] {
2856            DocumentBlock::Paragraph(runs) => {
2857                assert_eq!(runs.len(), 3);
2858                assert_eq!(runs[0].text, "Normal ");
2859                assert!(runs[0].hyperlink.is_none());
2860                assert_eq!(runs[1].text, "link text");
2861                assert_eq!(runs[1].hyperlink.as_deref(), Some("rId3"));
2862                assert_eq!(runs[2].text, " after");
2863                assert!(runs[2].hyperlink.is_none());
2864            }
2865            other => panic!("expected Paragraph, got {other:?}"),
2866        }
2867    }
2868
2869    #[test]
2870    fn hyperlink_with_bold_run() {
2871        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2872<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
2873            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
2874  <w:body>
2875    <w:p>
2876      <w:hyperlink r:id="rId10">
2877        <w:r>
2878          <w:rPr><w:b/></w:rPr>
2879          <w:t>Bold link</w:t>
2880        </w:r>
2881      </w:hyperlink>
2882    </w:p>
2883  </w:body>
2884</w:document>"#;
2885        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2886        let blocks = reader.read_blocks().unwrap();
2887        match &blocks[0] {
2888            DocumentBlock::Paragraph(runs) => {
2889                assert_eq!(runs[0].text, "Bold link");
2890                assert!(runs[0].bold);
2891                assert_eq!(runs[0].hyperlink.as_deref(), Some("rId10"));
2892            }
2893            other => panic!("expected Paragraph, got {other:?}"),
2894        }
2895    }
2896
2897    #[test]
2898    fn no_hyperlink_field_when_not_in_hyperlink() {
2899        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2900<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2901  <w:body>
2902    <w:p>
2903      <w:r><w:t>No link</w:t></w:r>
2904    </w:p>
2905  </w:body>
2906</w:document>"#;
2907        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2908        let blocks = reader.read_blocks().unwrap();
2909        match &blocks[0] {
2910            DocumentBlock::Paragraph(runs) => {
2911                assert!(runs[0].hyperlink.is_none());
2912            }
2913            other => panic!("expected Paragraph, got {other:?}"),
2914        }
2915    }
2916
2917    // -----------------------------------------------------------------------
2918    // Nested table tests (<w:tbl> inside <w:tc>)
2919    // -----------------------------------------------------------------------
2920
2921    #[test]
2922    fn nested_table_in_cell() {
2923        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2924<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2925  <w:body>
2926    <w:tbl>
2927      <w:tr>
2928        <w:tc>
2929          <w:p><w:r><w:t>Outer</w:t></w:r></w:p>
2930          <w:tbl>
2931            <w:tr>
2932              <w:tc><w:p><w:r><w:t>Inner A</w:t></w:r></w:p></w:tc>
2933              <w:tc><w:p><w:r><w:t>Inner B</w:t></w:r></w:p></w:tc>
2934            </w:tr>
2935          </w:tbl>
2936        </w:tc>
2937        <w:tc><w:p><w:r><w:t>Right</w:t></w:r></w:p></w:tc>
2938      </w:tr>
2939    </w:tbl>
2940  </w:body>
2941</w:document>"#;
2942        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2943        let blocks = reader.read_blocks().unwrap();
2944        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2945        match &blocks[0] {
2946            DocumentBlock::Table(outer) => {
2947                assert_eq!(outer.rows.len(), 1);
2948                assert_eq!(outer.rows[0].cells.len(), 2);
2949                // First cell should have: Paragraph("Outer") + Table(inner)
2950                let cell0 = &outer.rows[0].cells[0];
2951                assert!(cell0.blocks.len() >= 2, "cell0.blocks = {:?}", cell0.blocks);
2952                assert!(matches!(&cell0.blocks[0], DocumentBlock::Paragraph(_)));
2953                assert!(matches!(&cell0.blocks[1], DocumentBlock::Table(_)));
2954                if let DocumentBlock::Table(inner) = &cell0.blocks[1] {
2955                    assert_eq!(inner.rows.len(), 1);
2956                    assert_eq!(inner.rows[0].cells.len(), 2);
2957                }
2958                // Second cell is normal.
2959                let cell1 = &outer.rows[0].cells[1];
2960                assert_eq!(cell1.blocks.len(), 1);
2961            }
2962            other => panic!("expected Table, got {other:?}"),
2963        }
2964    }
2965
2966    #[test]
2967    fn nested_table_only_in_cell() {
2968        // Cell has only a nested table, no text before it.
2969        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2970<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
2971  <w:body>
2972    <w:tbl>
2973      <w:tr>
2974        <w:tc>
2975          <w:tbl>
2976            <w:tr>
2977              <w:tc><w:p><w:r><w:t>Deep</w:t></w:r></w:p></w:tc>
2978            </w:tr>
2979          </w:tbl>
2980        </w:tc>
2981      </w:tr>
2982    </w:tbl>
2983  </w:body>
2984</w:document>"#;
2985        let mut reader = DocxSaxReader::from_reader(&xml[..]);
2986        let blocks = reader.read_blocks().unwrap();
2987        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
2988        match &blocks[0] {
2989            DocumentBlock::Table(outer) => {
2990                let cell0 = &outer.rows[0].cells[0];
2991                assert_eq!(cell0.blocks.len(), 1);
2992                assert!(matches!(&cell0.blocks[0], DocumentBlock::Table(_)));
2993            }
2994            other => panic!("expected Table, got {other:?}"),
2995        }
2996    }
2997
2998    #[test]
2999    fn flat_table_still_works() {
3000        // Regression: make sure normal tables without nesting still work.
3001        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3002<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3003  <w:body>
3004    <w:tbl>
3005      <w:tr>
3006        <w:tc><w:p><w:r><w:t>X</w:t></w:r></w:p></w:tc>
3007        <w:tc><w:p><w:r><w:t>Y</w:t></w:r></w:p></w:tc>
3008      </w:tr>
3009    </w:tbl>
3010  </w:body>
3011</w:document>"#;
3012        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3013        let blocks = reader.read_blocks().unwrap();
3014        assert_eq!(blocks.len(), 1);
3015        match &blocks[0] {
3016            DocumentBlock::Table(table) => {
3017                assert_eq!(table.rows.len(), 1);
3018                assert_eq!(table.rows[0].cells.len(), 2);
3019                // Cells should have text as Paragraph blocks.
3020                let cell_text = |cell: &easydoc_core::DocumentTableCell| -> String {
3021                    cell.blocks
3022                        .iter()
3023                        .filter_map(|b| match b {
3024                            DocumentBlock::Paragraph(runs) => {
3025                                Some(runs.iter().map(|r| r.text.as_str()).collect::<String>())
3026                            }
3027                            _ => None,
3028                        })
3029                        .collect()
3030                };
3031                assert_eq!(cell_text(&table.rows[0].cells[0]), "X");
3032                assert_eq!(cell_text(&table.rows[0].cells[1]), "Y");
3033            }
3034            other => panic!("expected Table, got {other:?}"),
3035        }
3036    }
3037
3038    // -----------------------------------------------------------------------
3039    // Combined feature tests
3040    // -----------------------------------------------------------------------
3041
3042    #[test]
3043    fn list_then_hyperlink_then_table() {
3044        // All three features in one document.
3045        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3046<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3047            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3048  <w:body>
3049    <w:p>
3050      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3051      <w:r><w:t>Item</w:t></w:r>
3052    </w:p>
3053    <w:p>
3054      <w:hyperlink r:id="rId7">
3055        <w:r><w:t>Link</w:t></w:r>
3056      </w:hyperlink>
3057    </w:p>
3058    <w:tbl>
3059      <w:tr>
3060        <w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc>
3061      </w:tr>
3062    </w:tbl>
3063  </w:body>
3064</w:document>"#;
3065        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3066        let blocks = reader.read_blocks().unwrap();
3067        assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
3068        assert!(matches!(&blocks[0], DocumentBlock::List(_)));
3069        match &blocks[1] {
3070            DocumentBlock::Paragraph(runs) => {
3071                assert_eq!(runs[0].hyperlink.as_deref(), Some("rId7"));
3072            }
3073            other => panic!("expected Paragraph, got {other:?}"),
3074        }
3075        assert!(matches!(&blocks[2], DocumentBlock::Table(_)));
3076    }
3077
3078    // -----------------------------------------------------------------------
3079    // End-to-end: numbering integration
3080    // -----------------------------------------------------------------------
3081
3082    /// Builds a DOCX ZIP containing `word/document.xml` and `word/numbering.xml`.
3083    fn make_docx_with_numbering(doc_xml: &[u8], numbering_xml: &[u8]) -> Vec<u8> {
3084        use std::io::Write;
3085        let mut buf = Vec::new();
3086        {
3087            let w = std::io::Cursor::new(&mut buf);
3088            let mut zip = zip::ZipWriter::new(w);
3089            let options = zip::write::SimpleFileOptions::default()
3090                .compression_method(zip::CompressionMethod::Stored);
3091
3092            zip.start_file("word/document.xml", options).unwrap();
3093            zip.write_all(doc_xml).unwrap();
3094
3095            zip.start_file("word/numbering.xml", options).unwrap();
3096            zip.write_all(numbering_xml).unwrap();
3097
3098            zip.finish().unwrap();
3099        }
3100        buf
3101    }
3102
3103    /// Builds a DOCX ZIP with document.xml, rels, and numbering.xml.
3104    fn make_docx_with_rels_and_numbering(
3105        doc_xml: &[u8],
3106        rels_xml: &[u8],
3107        numbering_xml: &[u8],
3108    ) -> Vec<u8> {
3109        use std::io::Write;
3110        let mut buf = Vec::new();
3111        {
3112            let w = std::io::Cursor::new(&mut buf);
3113            let mut zip = zip::ZipWriter::new(w);
3114            let options = zip::write::SimpleFileOptions::default()
3115                .compression_method(zip::CompressionMethod::Stored);
3116
3117            zip.start_file("word/document.xml", options).unwrap();
3118            zip.write_all(doc_xml).unwrap();
3119
3120            zip.start_file("word/_rels/document.xml.rels", options)
3121                .unwrap();
3122            zip.write_all(rels_xml).unwrap();
3123
3124            zip.start_file("word/numbering.xml", options).unwrap();
3125            zip.write_all(numbering_xml).unwrap();
3126
3127            zip.finish().unwrap();
3128        }
3129        buf
3130    }
3131
3132    #[test]
3133    fn e2e_ordered_list_from_numbering_xml() {
3134        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3135<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3136  <w:body>
3137    <w:p>
3138      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3139      <w:r><w:t>First</w:t></w:r>
3140    </w:p>
3141    <w:p>
3142      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3143      <w:r><w:t>Second</w:t></w:r>
3144    </w:p>
3145    <w:p>
3146      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3147      <w:r><w:t>Third</w:t></w:r>
3148    </w:p>
3149  </w:body>
3150</w:document>"#;
3151
3152        let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3153<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3154  <w:abstractNum w:abstractNumId="0">
3155    <w:lvl w:ilvl="0">
3156      <w:start w:val="1"/>
3157      <w:numFmt w:val="decimal"/>
3158      <w:lvlText w:val="%1."/>
3159    </w:lvl>
3160  </w:abstractNum>
3161  <w:num w:numId="1">
3162    <w:abstractNumId w:val="0"/>
3163  </w:num>
3164</w:numbering>"#;
3165
3166        let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3167        let tmp = tempfile::NamedTempFile::new().unwrap();
3168        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3169        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3170        let blocks = reader.read_blocks().unwrap();
3171
3172        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3173        match &blocks[0] {
3174            DocumentBlock::List(list) => {
3175                assert!(list.ordered, "list should be ordered (decimal fmt)");
3176                assert_eq!(list.start_number, Some(1));
3177                assert_eq!(list.items.len(), 3);
3178            }
3179            other => panic!("expected List, got {other:?}"),
3180        }
3181    }
3182
3183    #[test]
3184    fn e2e_bullet_list_remains_unordered() {
3185        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3186<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3187  <w:body>
3188    <w:p>
3189      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3190      <w:r><w:t>Bullet A</w:t></w:r>
3191    </w:p>
3192    <w:p>
3193      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3194      <w:r><w:t>Bullet B</w:t></w:r>
3195    </w:p>
3196  </w:body>
3197</w:document>"#;
3198
3199        let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3200<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3201  <w:abstractNum w:abstractNumId="0">
3202    <w:lvl w:ilvl="0">
3203      <w:numFmt w:val="bullet"/>
3204      <w:lvlText w:val="&#x2022;"/>
3205    </w:lvl>
3206  </w:abstractNum>
3207  <w:num w:numId="1">
3208    <w:abstractNumId w:val="0"/>
3209  </w:num>
3210</w:numbering>"#;
3211
3212        let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3213        let tmp = tempfile::NamedTempFile::new().unwrap();
3214        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3215        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3216        let blocks = reader.read_blocks().unwrap();
3217
3218        match &blocks[0] {
3219            DocumentBlock::List(list) => {
3220                assert!(!list.ordered, "bullet list should be unordered");
3221                assert_eq!(list.start_number, None);
3222                assert_eq!(list.items.len(), 2);
3223            }
3224            other => panic!("expected List, got {other:?}"),
3225        }
3226    }
3227
3228    #[test]
3229    fn e2e_numbering_missing_numid_falls_back_to_unordered() {
3230        // numId="99" does not exist in numbering.xml => fallback to unordered.
3231        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3232<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3233  <w:body>
3234    <w:p>
3235      <w:pPr><w:numPr><w:numId w:val="99"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3236      <w:r><w:t>Unknown numId</w:t></w:r>
3237    </w:p>
3238  </w:body>
3239</w:document>"#;
3240
3241        let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3242<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3243  <w:abstractNum w:abstractNumId="0">
3244    <w:lvl w:ilvl="0">
3245      <w:start w:val="1"/>
3246      <w:numFmt w:val="decimal"/>
3247    </w:lvl>
3248  </w:abstractNum>
3249  <w:num w:numId="1">
3250    <w:abstractNumId w:val="0"/>
3251  </w:num>
3252</w:numbering>"#;
3253
3254        let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3255        let tmp = tempfile::NamedTempFile::new().unwrap();
3256        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3257        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3258        let blocks = reader.read_blocks().unwrap();
3259
3260        match &blocks[0] {
3261            DocumentBlock::List(list) => {
3262                assert!(!list.ordered, "unknown numId should fallback to unordered");
3263                assert_eq!(list.start_number, None);
3264            }
3265            other => panic!("expected List, got {other:?}"),
3266        }
3267    }
3268
3269    #[test]
3270    fn e2e_no_numbering_xml_falls_back_to_unordered() {
3271        // No numbering.xml in the ZIP at all => fallback to unordered.
3272        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3273<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3274  <w:body>
3275    <w:p>
3276      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3277      <w:r><w:t>Item</w:t></w:r>
3278    </w:p>
3279  </w:body>
3280</w:document>"#;
3281
3282        // Use the basic make_docx_xml helper (no numbering.xml).
3283        let zip_data = make_docx_xml(doc_xml);
3284        let tmp = tempfile::NamedTempFile::new().unwrap();
3285        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3286        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3287        let blocks = reader.read_blocks().unwrap();
3288
3289        match &blocks[0] {
3290            DocumentBlock::List(list) => {
3291                assert!(!list.ordered, "no numbering.xml => unordered fallback");
3292                assert_eq!(list.start_number, None);
3293            }
3294            other => panic!("expected List, got {other:?}"),
3295        }
3296    }
3297
3298    #[test]
3299    fn e2e_ordered_list_with_start_value() {
3300        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3301<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3302  <w:body>
3303    <w:p>
3304      <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3305      <w:r><w:t>Item five</w:t></w:r>
3306    </w:p>
3307    <w:p>
3308      <w:pPr><w:numPr><w:numId w:val="2"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3309      <w:r><w:t>Item six</w:t></w:r>
3310    </w:p>
3311  </w:body>
3312</w:document>"#;
3313
3314        let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3315<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3316  <w:abstractNum w:abstractNumId="0">
3317    <w:lvl w:ilvl="0">
3318      <w:start w:val="5"/>
3319      <w:numFmt w:val="decimal"/>
3320      <w:lvlText w:val="%1."/>
3321    </w:lvl>
3322  </w:abstractNum>
3323  <w:num w:numId="2">
3324    <w:abstractNumId w:val="0"/>
3325  </w:num>
3326</w:numbering>"#;
3327
3328        let zip_data = make_docx_with_numbering(doc_xml, numbering_xml);
3329        let tmp = tempfile::NamedTempFile::new().unwrap();
3330        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3331        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3332        let blocks = reader.read_blocks().unwrap();
3333
3334        match &blocks[0] {
3335            DocumentBlock::List(list) => {
3336                assert!(list.ordered);
3337                assert_eq!(list.start_number, Some(5));
3338            }
3339            other => panic!("expected List, got {other:?}"),
3340        }
3341    }
3342
3343    // -----------------------------------------------------------------------
3344    // End-to-end: hyperlink resolution via relationships
3345    // -----------------------------------------------------------------------
3346
3347    #[test]
3348    fn e2e_hyperlink_resolves_to_url() {
3349        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3350<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3351            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3352  <w:body>
3353    <w:p>
3354      <w:hyperlink r:id="rId10">
3355        <w:r><w:t>Visit example</w:t></w:r>
3356      </w:hyperlink>
3357    </w:p>
3358  </w:body>
3359</w:document>"#;
3360
3361        let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3362<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3363  <Relationship Id="rId10" Target="https://example.com"
3364                Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3365                TargetMode="External"/>
3366</Relationships>"#;
3367
3368        let zip_data = make_docx_with_image(doc_xml, rels_xml, &[]);
3369        let tmp = tempfile::NamedTempFile::new().unwrap();
3370        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3371        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3372        let blocks = reader.read_blocks().unwrap();
3373
3374        match &blocks[0] {
3375            DocumentBlock::Paragraph(runs) => {
3376                assert_eq!(runs.len(), 1);
3377                assert_eq!(runs[0].text, "Visit example");
3378                assert_eq!(
3379                    runs[0].hyperlink.as_deref(),
3380                    Some("https://example.com"),
3381                    "hyperlink should be resolved to URL, not raw rId"
3382                );
3383            }
3384            other => panic!("expected Paragraph, got {other:?}"),
3385        }
3386    }
3387
3388    #[test]
3389    fn e2e_hyperlink_fallback_to_rid_when_no_rels() {
3390        // No rels file => hyperlink stays as raw rId.
3391        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3392<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3393            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3394  <w:body>
3395    <w:p>
3396      <w:hyperlink r:id="rId5">
3397        <w:r><w:t>No rels</w:t></w:r>
3398      </w:hyperlink>
3399    </w:p>
3400  </w:body>
3401</w:document>"#;
3402
3403        let zip_data = make_docx_xml(doc_xml);
3404        let tmp = tempfile::NamedTempFile::new().unwrap();
3405        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3406        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3407        let blocks = reader.read_blocks().unwrap();
3408
3409        match &blocks[0] {
3410            DocumentBlock::Paragraph(runs) => {
3411                assert_eq!(runs[0].hyperlink.as_deref(), Some("rId5"));
3412            }
3413            other => panic!("expected Paragraph, got {other:?}"),
3414        }
3415    }
3416
3417    #[test]
3418    fn e2e_hyperlink_in_list_items_resolves() {
3419        // List items containing hyperlinks should resolve both numbering and
3420        // hyperlink relationships.
3421        let doc_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3422<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
3423            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3424  <w:body>
3425    <w:p>
3426      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3427      <w:r><w:t>See </w:t></w:r>
3428      <w:hyperlink r:id="rId20">
3429        <w:r><w:t>Rust lang</w:t></w:r>
3430      </w:hyperlink>
3431    </w:p>
3432    <w:p>
3433      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3434      <w:r><w:t>Plain item</w:t></w:r>
3435    </w:p>
3436  </w:body>
3437</w:document>"#;
3438
3439        let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3440<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3441  <Relationship Id="rId20" Target="https://rust-lang.org"
3442                Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
3443                TargetMode="External"/>
3444</Relationships>"#;
3445
3446        let numbering_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3447<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3448  <w:abstractNum w:abstractNumId="0">
3449    <w:lvl w:ilvl="0">
3450      <w:start w:val="1"/>
3451      <w:numFmt w:val="decimal"/>
3452    </w:lvl>
3453  </w:abstractNum>
3454  <w:num w:numId="1">
3455    <w:abstractNumId w:val="0"/>
3456  </w:num>
3457</w:numbering>"#;
3458
3459        let zip_data = make_docx_with_rels_and_numbering(doc_xml, rels_xml, numbering_xml);
3460        let tmp = tempfile::NamedTempFile::new().unwrap();
3461        std::io::Write::write_all(&mut &tmp, &zip_data).unwrap();
3462        let mut reader = DocxSaxReader::from_path(tmp.path()).unwrap();
3463        let blocks = reader.read_blocks().unwrap();
3464
3465        // Should be a single List block.
3466        match &blocks[0] {
3467            DocumentBlock::List(list) => {
3468                assert!(list.ordered, "should be ordered (decimal)");
3469                assert_eq!(list.items.len(), 2);
3470
3471                // First item: Paragraph with two runs, second run is hyperlink.
3472                match &list.items[0].blocks[0] {
3473                    DocumentBlock::Paragraph(runs) => {
3474                        assert_eq!(runs.len(), 2);
3475                        assert_eq!(runs[0].text, "See ");
3476                        assert!(runs[0].hyperlink.is_none());
3477                        assert_eq!(runs[1].text, "Rust lang");
3478                        assert_eq!(runs[1].hyperlink.as_deref(), Some("https://rust-lang.org"),);
3479                    }
3480                    other => panic!("expected Paragraph, got {other:?}"),
3481                }
3482
3483                // Second item: plain text, no hyperlink.
3484                match &list.items[1].blocks[0] {
3485                    DocumentBlock::Paragraph(runs) => {
3486                        assert_eq!(runs[0].text, "Plain item");
3487                        assert!(runs[0].hyperlink.is_none());
3488                    }
3489                    other => panic!("expected Paragraph, got {other:?}"),
3490                }
3491            }
3492            other => panic!("expected List, got {other:?}"),
3493        }
3494    }
3495
3496    // -----------------------------------------------------------------------
3497    // Nested list tests (ilvl-based nesting)
3498    // -----------------------------------------------------------------------
3499
3500    #[test]
3501    fn two_level_list_nests_ilvl_1_in_ilvl_0() {
3502        // ilvl 0, 0, 1 => top-level items=2, first item has nested with 1 item.
3503        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3504<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3505  <w:body>
3506    <w:p>
3507      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3508      <w:r><w:t>Top A</w:t></w:r>
3509    </w:p>
3510    <w:p>
3511      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3512      <w:r><w:t>Top B</w:t></w:r>
3513    </w:p>
3514    <w:p>
3515      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3516      <w:r><w:t>Nested under B</w:t></w:r>
3517    </w:p>
3518  </w:body>
3519</w:document>"#;
3520        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3521        let blocks = reader.read_blocks().unwrap();
3522        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3523        match &blocks[0] {
3524            DocumentBlock::List(list) => {
3525                // Two top-level items: "Top A" and "Top B".
3526                assert_eq!(list.items.len(), 2, "items = {:?}", list.items);
3527
3528                // First item: "Top A", no nested.
3529                assert!(list.items[0].nested.is_none());
3530
3531                // Second item: "Top B", has nested with 1 item.
3532                let nested = list.items[1]
3533                    .nested
3534                    .as_ref()
3535                    .expect("Top B should have nested list");
3536                assert_eq!(nested.items.len(), 1);
3537                match &nested.items[0].blocks[0] {
3538                    DocumentBlock::Paragraph(runs) => {
3539                        assert_eq!(runs[0].text, "Nested under B");
3540                    }
3541                    other => panic!("expected Paragraph, got {other:?}"),
3542                }
3543            }
3544            other => panic!("expected List, got {other:?}"),
3545        }
3546    }
3547
3548    #[test]
3549    fn three_level_list_nests_correctly() {
3550        // ilvl 0, 1, 2, 0 => top-level items=2, first has nested chain 0->1->2.
3551        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3552<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3553  <w:body>
3554    <w:p>
3555      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3556      <w:r><w:t>Level 0</w:t></w:r>
3557    </w:p>
3558    <w:p>
3559      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3560      <w:r><w:t>Level 1</w:t></w:r>
3561    </w:p>
3562    <w:p>
3563      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="2"/></w:numPr></w:pPr>
3564      <w:r><w:t>Level 2</w:t></w:r>
3565    </w:p>
3566    <w:p>
3567      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3568      <w:r><w:t>Second top</w:t></w:r>
3569    </w:p>
3570  </w:body>
3571</w:document>"#;
3572        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3573        let blocks = reader.read_blocks().unwrap();
3574        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3575        match &blocks[0] {
3576            DocumentBlock::List(list) => {
3577                // Two top-level items.
3578                assert_eq!(list.items.len(), 2);
3579
3580                // First top-level item: "Level 0".
3581                let item0 = &list.items[0];
3582                let nested1 = item0.nested.as_ref().expect("Level 0 should have nested");
3583                assert_eq!(nested1.items.len(), 1);
3584
3585                // Nested level 1: "Level 1".
3586                let nested2 = nested1.items[0]
3587                    .nested
3588                    .as_ref()
3589                    .expect("Level 1 should have nested");
3590                assert_eq!(nested2.items.len(), 1);
3591
3592                // Nested level 2: "Level 2".
3593                match &nested2.items[0].blocks[0] {
3594                    DocumentBlock::Paragraph(runs) => {
3595                        assert_eq!(runs[0].text, "Level 2");
3596                    }
3597                    other => panic!("expected Paragraph, got {other:?}"),
3598                }
3599
3600                // Second top-level item: "Second top", no nesting.
3601                assert!(list.items[1].nested.is_none());
3602            }
3603            other => panic!("expected List, got {other:?}"),
3604        }
3605    }
3606
3607    #[test]
3608    fn flat_list_with_multiple_ilvl_0() {
3609        // 5 items all at ilvl=0 => top-level items=5, none nested.
3610        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3611<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3612  <w:body>
3613    <w:p>
3614      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3615      <w:r><w:t>A</w:t></w:r>
3616    </w:p>
3617    <w:p>
3618      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3619      <w:r><w:t>B</w:t></w:r>
3620    </w:p>
3621    <w:p>
3622      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3623      <w:r><w:t>C</w:t></w:r>
3624    </w:p>
3625    <w:p>
3626      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3627      <w:r><w:t>D</w:t></w:r>
3628    </w:p>
3629    <w:p>
3630      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3631      <w:r><w:t>E</w:t></w:r>
3632    </w:p>
3633  </w:body>
3634</w:document>"#;
3635        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3636        let blocks = reader.read_blocks().unwrap();
3637        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3638        match &blocks[0] {
3639            DocumentBlock::List(list) => {
3640                assert_eq!(list.items.len(), 5);
3641                for item in &list.items {
3642                    assert!(item.nested.is_none(), "flat items should not have nested");
3643                }
3644                // Verify text ordering.
3645                let texts: Vec<&str> = list
3646                    .items
3647                    .iter()
3648                    .map(|item| match &item.blocks[0] {
3649                        DocumentBlock::Paragraph(runs) => runs[0].text.as_str(),
3650                        _ => panic!("expected Paragraph"),
3651                    })
3652                    .collect();
3653                assert_eq!(texts, vec!["A", "B", "C", "D", "E"]);
3654            }
3655            other => panic!("expected List, got {other:?}"),
3656        }
3657    }
3658
3659    #[test]
3660    fn list_breaks_at_non_list_paragraph() {
3661        // ilvl 0 + ilvl 1 + normal paragraph + ilvl 0 => two separate lists.
3662        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3663<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3664  <w:body>
3665    <w:p>
3666      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3667      <w:r><w:t>List 1 top</w:t></w:r>
3668    </w:p>
3669    <w:p>
3670      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3671      <w:r><w:t>List 1 nested</w:t></w:r>
3672    </w:p>
3673    <w:p>
3674      <w:r><w:t>Separator paragraph</w:t></w:r>
3675    </w:p>
3676    <w:p>
3677      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3678      <w:r><w:t>List 2 top</w:t></w:r>
3679    </w:p>
3680  </w:body>
3681</w:document>"#;
3682        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3683        let blocks = reader.read_blocks().unwrap();
3684        // Expect: List, Paragraph, List.
3685        assert_eq!(blocks.len(), 3, "blocks = {blocks:?}");
3686
3687        // First list: 1 top-level item with nested.
3688        match &blocks[0] {
3689            DocumentBlock::List(list) => {
3690                assert_eq!(list.items.len(), 1);
3691                let nested = list.items[0]
3692                    .nested
3693                    .as_ref()
3694                    .expect("first list item should have nested");
3695                assert_eq!(nested.items.len(), 1);
3696            }
3697            other => panic!("expected List, got {other:?}"),
3698        }
3699
3700        // Separator paragraph.
3701        match &blocks[1] {
3702            DocumentBlock::Paragraph(runs) => {
3703                assert_eq!(runs[0].text, "Separator paragraph");
3704            }
3705            other => panic!("expected Paragraph, got {other:?}"),
3706        }
3707
3708        // Second list: 1 top-level item, no nested.
3709        match &blocks[2] {
3710            DocumentBlock::List(list) => {
3711                assert_eq!(list.items.len(), 1);
3712                assert!(list.items[0].nested.is_none());
3713            }
3714            other => panic!("expected List, got {other:?}"),
3715        }
3716    }
3717
3718    #[test]
3719    fn ilvl_decrease_creates_separate_branch() {
3720        // ilvl 0, 1, 0 => top-level items=2, first has nested with 1 item.
3721        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3722<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3723  <w:body>
3724    <w:p>
3725      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3726      <w:r><w:t>Branch A</w:t></w:r>
3727    </w:p>
3728    <w:p>
3729      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3730      <w:r><w:t>Branch A child</w:t></w:r>
3731    </w:p>
3732    <w:p>
3733      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3734      <w:r><w:t>Branch B</w:t></w:r>
3735    </w:p>
3736  </w:body>
3737</w:document>"#;
3738        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3739        let blocks = reader.read_blocks().unwrap();
3740        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3741        match &blocks[0] {
3742            DocumentBlock::List(list) => {
3743                assert_eq!(list.items.len(), 2);
3744
3745                // First branch: "Branch A" with nested "Branch A child".
3746                let nested = list.items[0]
3747                    .nested
3748                    .as_ref()
3749                    .expect("Branch A should have nested");
3750                assert_eq!(nested.items.len(), 1);
3751                match &nested.items[0].blocks[0] {
3752                    DocumentBlock::Paragraph(runs) => {
3753                        assert_eq!(runs[0].text, "Branch A child");
3754                    }
3755                    other => panic!("expected Paragraph, got {other:?}"),
3756                }
3757
3758                // Second branch: "Branch B", no nested.
3759                assert!(list.items[1].nested.is_none());
3760                match &list.items[1].blocks[0] {
3761                    DocumentBlock::Paragraph(runs) => {
3762                        assert_eq!(runs[0].text, "Branch B");
3763                    }
3764                    other => panic!("expected Paragraph, got {other:?}"),
3765                }
3766            }
3767            other => panic!("expected List, got {other:?}"),
3768        }
3769    }
3770
3771    #[test]
3772    fn multiple_siblings_at_nested_level() {
3773        // ilvl 0, 1, 1, 0 => first top-level has 2 nested children.
3774        let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3775<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
3776  <w:body>
3777    <w:p>
3778      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3779      <w:r><w:t>Parent</w:t></w:r>
3780    </w:p>
3781    <w:p>
3782      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3783      <w:r><w:t>Child 1</w:t></w:r>
3784    </w:p>
3785    <w:p>
3786      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="1"/></w:numPr></w:pPr>
3787      <w:r><w:t>Child 2</w:t></w:r>
3788    </w:p>
3789    <w:p>
3790      <w:pPr><w:numPr><w:numId w:val="1"/><w:ilvl w:val="0"/></w:numPr></w:pPr>
3791      <w:r><w:t>Sibling</w:t></w:r>
3792    </w:p>
3793  </w:body>
3794</w:document>"#;
3795        let mut reader = DocxSaxReader::from_reader(&xml[..]);
3796        let blocks = reader.read_blocks().unwrap();
3797        assert_eq!(blocks.len(), 1, "blocks = {blocks:?}");
3798        match &blocks[0] {
3799            DocumentBlock::List(list) => {
3800                assert_eq!(list.items.len(), 2);
3801
3802                // "Parent" has 2 nested children.
3803                let nested = list.items[0]
3804                    .nested
3805                    .as_ref()
3806                    .expect("Parent should have nested");
3807                assert_eq!(nested.items.len(), 2);
3808
3809                match &nested.items[0].blocks[0] {
3810                    DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Child 1"),
3811                    other => panic!("expected Paragraph, got {other:?}"),
3812                }
3813                match &nested.items[1].blocks[0] {
3814                    DocumentBlock::Paragraph(runs) => assert_eq!(runs[0].text, "Child 2"),
3815                    other => panic!("expected Paragraph, got {other:?}"),
3816                }
3817
3818                // "Sibling" has no nested.
3819                assert!(list.items[1].nested.is_none());
3820            }
3821            other => panic!("expected List, got {other:?}"),
3822        }
3823    }
3824}