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