Skip to main content

easydoc_writer/
content_renderer.rs

1//! 语义模型渲染器 — 将 `DocumentContent` 转换为 docx-rs 文档。
2//!
3//! 这是打通 Read → Modify → Write 闭环的关键桥梁。
4//! Reader 输出 `DocumentContent`,本渲染器将其渲染为 DOCX。
5
6use docx_rs::{
7    AbstractNumbering, BreakType, Docx, Hyperlink, HyperlinkType, IndentLevel, Level, LevelJc,
8    LevelText, NumberFormat, Numbering, NumberingId, Pic, RunFonts, SpecialIndentType, Start,
9};
10use easydoc_core::{
11    DocumentBlock, DocumentContent, DocumentImage, DocumentList, DocumentTable, DocumentTextRun,
12    HeadingLevel, Result,
13};
14
15/// Bullet list numbering ID (references `numbering.xml` abstractNum 0).
16const BULLET_NUM_ID: usize = 10;
17/// Ordered (decimal) list numbering ID (references `numbering.xml` abstractNum 1).
18const DECIMAL_NUM_ID: usize = 11;
19
20// 渲染期间收集的数学公式:`(标记, latex, 是否块级)`。
21// 线程本地收集:`render_document_content` 渲染 Math 块时写入,调用方
22// 打包前用 `take_rendered_math` 取出并替换占位标记为原生 OMML。
23// 库为同步单线程使用模型,`RefCell` 无并发访问。
24thread_local! {
25    static RENDERED_MATH: std::cell::RefCell<Vec<(String, String, bool)>> =
26        const { std::cell::RefCell::new(Vec::new()) };
27}
28
29/// 生成下一个 Math 占位标记(`@@EASYDOC_MATH_n@@`)。
30fn render_math_marker() -> String {
31    let n = RENDERED_MATH.with(|cell| cell.borrow().len());
32    format!("@@EASYDOC_MATH_{n}@@")
33}
34
35/// 取出本次渲染收集的数学公式列表 `(标记, latex, display)`。
36///
37/// 调用方应在 `render_document_content` 之后、打包之前调用;
38/// 随后用 [`crate::math_omml::postprocess_math_xml`] 把 document.xml
39/// 中的占位标记替换为原生 `<m:oMath>` 元素。
40#[must_use]
41pub fn take_rendered_math() -> Vec<(String, String, bool)> {
42    RENDERED_MATH.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
43}
44
45/// Starting abstract numbering ID for dynamically created numbering definitions
46/// (used when `start_number` differs from 1).
47const DYNAMIC_ABSTRACT_NUM_START: usize = 100;
48/// Starting numbering ID for dynamically created numbering references.
49const DYNAMIC_NUM_ID_START: usize = 100;
50
51/// Adds predefined bullet and decimal numbering definitions to the `Docx` instance.
52///
53/// This populates `word/numbering.xml` with two abstract numbering definitions:
54/// - abstractNum 0: bullet list (multi-level with `•`, `◦`, `▪`)
55/// - abstractNum 1: decimal list (multi-level with `1.`, `a.`, `i.`)
56fn add_list_numberings(docx: Docx) -> Docx {
57    // --- Bullet list (abstractNum 0) ---
58    let mut bullet_abstract = AbstractNumbering::new(0);
59    bullet_abstract.multi_level_type = Some("hybridMultilevel".to_string());
60    let bullet_abstract = bullet_abstract
61        .add_level(
62            Level::new(
63                0,
64                Start::new(1),
65                NumberFormat::new("bullet"),
66                LevelText::new("\u{2022}"), // •
67                LevelJc::new("left"),
68            )
69            .indent(Some(720), Some(SpecialIndentType::Hanging(360)), None, None),
70        )
71        .add_level(
72            Level::new(
73                1,
74                Start::new(1),
75                NumberFormat::new("bullet"),
76                LevelText::new("\u{25E6}"), // ◦
77                LevelJc::new("left"),
78            )
79            .indent(
80                Some(1080),
81                Some(SpecialIndentType::Hanging(360)),
82                None,
83                None,
84            ),
85        )
86        .add_level(
87            Level::new(
88                2,
89                Start::new(1),
90                NumberFormat::new("bullet"),
91                LevelText::new("\u{25AA}"), // ▪
92                LevelJc::new("left"),
93            )
94            .indent(
95                Some(1440),
96                Some(SpecialIndentType::Hanging(360)),
97                None,
98                None,
99            ),
100        );
101
102    // --- Decimal list (abstractNum 1) ---
103    let mut decimal_abstract = AbstractNumbering::new(1);
104    decimal_abstract.multi_level_type = Some("hybridMultilevel".to_string());
105    let decimal_abstract = decimal_abstract
106        .add_level(
107            Level::new(
108                0,
109                Start::new(1),
110                NumberFormat::new("decimal"),
111                LevelText::new("%1."),
112                LevelJc::new("left"),
113            )
114            .indent(Some(720), Some(SpecialIndentType::Hanging(360)), None, None),
115        )
116        .add_level(
117            Level::new(
118                1,
119                Start::new(1),
120                NumberFormat::new("lowerLetter"),
121                LevelText::new("%2."),
122                LevelJc::new("left"),
123            )
124            .indent(
125                Some(1080),
126                Some(SpecialIndentType::Hanging(360)),
127                None,
128                None,
129            ),
130        )
131        .add_level(
132            Level::new(
133                2,
134                Start::new(1),
135                NumberFormat::new("lowerRoman"),
136                LevelText::new("%3."),
137                LevelJc::new("left"),
138            )
139            .indent(
140                Some(1440),
141                Some(SpecialIndentType::Hanging(360)),
142                None,
143                None,
144            ),
145        );
146
147    docx.add_abstract_numbering(bullet_abstract)
148        .add_abstract_numbering(decimal_abstract)
149        .add_numbering(Numbering::new(BULLET_NUM_ID, 0))
150        .add_numbering(Numbering::new(DECIMAL_NUM_ID, 1))
151}
152
153/// Counter for generating unique numbering IDs for custom start numbers.
154static DYNAMIC_NUM_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
155
156/// Registers a custom abstract numbering definition with the given start value.
157///
158/// Returns the `numId` to use in `<w:numPr>` for this list.
159/// Each call creates a new abstract numbering + numbering pair to avoid
160/// conflicting with existing definitions.
161fn register_custom_start_numbering(docx: &mut Docx, start: u32) -> usize {
162    use std::sync::atomic::Ordering;
163
164    let idx = DYNAMIC_NUM_COUNTER.fetch_add(1, Ordering::Relaxed);
165    let abstract_num_id = DYNAMIC_ABSTRACT_NUM_START + idx;
166    let num_id = DYNAMIC_NUM_ID_START + idx;
167
168    let mut abstract_num = AbstractNumbering::new(abstract_num_id);
169    abstract_num.multi_level_type = Some("hybridMultilevel".to_string());
170    let abstract_num = abstract_num
171        .add_level(
172            Level::new(
173                0,
174                Start::new(start as usize),
175                NumberFormat::new("decimal"),
176                LevelText::new("%1."),
177                LevelJc::new("left"),
178            )
179            .indent(Some(720), Some(SpecialIndentType::Hanging(360)), None, None),
180        )
181        .add_level(
182            Level::new(
183                1,
184                Start::new(1),
185                NumberFormat::new("lowerLetter"),
186                LevelText::new("%2."),
187                LevelJc::new("left"),
188            )
189            .indent(
190                Some(1080),
191                Some(SpecialIndentType::Hanging(360)),
192                None,
193                None,
194            ),
195        )
196        .add_level(
197            Level::new(
198                2,
199                Start::new(1),
200                NumberFormat::new("lowerRoman"),
201                LevelText::new("%3."),
202                LevelJc::new("left"),
203            )
204            .indent(
205                Some(1440),
206                Some(SpecialIndentType::Hanging(360)),
207                None,
208                None,
209            ),
210        );
211
212    // Swap out the docx, add numbering, and swap back.
213    let owned = std::mem::replace(docx, Docx::new());
214    *docx = owned
215        .add_abstract_numbering(abstract_num)
216        .add_numbering(Numbering::new(num_id, abstract_num_id));
217
218    num_id
219}
220
221/// Wraps a set of runs into a paragraph, grouping consecutive hyperlink runs
222/// into `<w:hyperlink>` elements.
223///
224/// Runs sharing the same non-empty `hyperlink` URL are grouped into a single
225/// `Hyperlink` element.  Runs without a hyperlink are added as plain `Run` children.
226fn add_runs_with_hyperlinks(
227    p: docx_rs::Paragraph,
228    runs: &[DocumentTextRun],
229    bold: bool,
230) -> docx_rs::Paragraph {
231    let mut p = p;
232    let mut i = 0;
233    while i < runs.len() {
234        if let Some(ref url) = runs[i].hyperlink {
235            // Group consecutive runs with the same hyperlink URL.
236            let mut link = Hyperlink::new(url.as_str(), HyperlinkType::External);
237            while i < runs.len() && runs[i].hyperlink.as_deref() == Some(url.as_str()) {
238                link = link.add_run(text_run_to_docx_run(&runs[i], bold));
239                i += 1;
240            }
241            p = p.add_hyperlink(link);
242        } else {
243            p = p.add_run(text_run_to_docx_run(&runs[i], bold));
244            i += 1;
245        }
246    }
247    p
248}
249
250/// 将核心语义模型渲染为 docx-rs 的 `Docx` 实例。
251///
252/// # 参数
253/// - `content`: 完整的语义文档模型。
254///
255/// # 返回
256/// 构建好的 `Docx` 实例,可进一步 `pack()` 为 DOCX 文件。
257pub fn render_document_content(content: &DocumentContent) -> Result<Docx> {
258    let mut docx = Docx::new();
259
260    // Pre-register bullet/decimal numbering definitions so that list paragraphs
261    // can reference them via `<w:numPr>`.
262    docx = add_list_numberings(docx);
263
264    for block in &content.blocks {
265        docx = render_block(docx, block)?;
266    }
267
268    Ok(docx)
269}
270
271/// 递归渲染单个块级元素。
272fn render_block(mut docx: Docx, block: &DocumentBlock) -> Result<Docx> {
273    match block {
274        DocumentBlock::Heading { level, runs } => {
275            let _heading_level = u8_to_heading_level(*level);
276            let p = docx_rs::Paragraph::new()
277                .style(heading_style_name(*level))
278                .outline_lvl(heading_outline_level(*level));
279            let p = add_runs_with_hyperlinks(p, runs, true);
280            docx = docx.add_paragraph(p);
281        }
282        DocumentBlock::Paragraph(runs) => {
283            let p = docx_rs::Paragraph::new();
284            let p = add_runs_with_hyperlinks(p, runs, false);
285            docx = docx.add_paragraph(p);
286        }
287        DocumentBlock::Table(table) => {
288            docx = render_table(docx, table)?;
289        }
290        DocumentBlock::List(list) => {
291            docx = render_list(docx, list)?;
292        }
293        DocumentBlock::Image(image) => {
294            docx = render_image(docx, image)?;
295        }
296        DocumentBlock::ThematicBreak | DocumentBlock::PageBreak => {
297            let p =
298                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_break(BreakType::Page));
299            docx = docx.add_paragraph(p);
300        }
301        DocumentBlock::ColumnBreak => {
302            let p =
303                docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_break(BreakType::Column));
304            docx = docx.add_paragraph(p);
305        }
306        DocumentBlock::CodeBlock { language: _, code } => {
307            // 代码块渲染为等宽字体段落
308            let mut p = docx_rs::Paragraph::new();
309            let r = docx_rs::Run::new()
310                .add_text(code.as_str())
311                .fonts(RunFonts::new().ascii("Courier New"))
312                .size(20); // 10pt
313            p = p.add_run(r);
314            docx = docx.add_paragraph(p);
315        }
316        DocumentBlock::TextBox(blocks) => {
317            // 文本框内容渲染为普通段落
318            for inner in blocks {
319                docx = render_block(docx, inner)?;
320            }
321        }
322        DocumentBlock::Footnote { id, blocks } => {
323            // 脚注以 `[^id]: content` 文本段落写出,与 markdown_renderer
324            // 的输出格式对称,保证 MD→DOCX→MD 往返不丢失脚注语义。
325            let body = plain_blocks(blocks);
326            let mut p = docx_rs::Paragraph::new();
327            p = p.add_run(docx_rs::Run::new().add_text(format!("[^{id}]: {body}")));
328            docx = docx.add_paragraph(p);
329        }
330        DocumentBlock::Endnote { id, blocks } => {
331            // 尾注同脚注,标记为 `[^endnote-{id}]`(与 renderer 一致)。
332            let body = plain_blocks(blocks);
333            let mut p = docx_rs::Paragraph::new();
334            p = p.add_run(docx_rs::Run::new().add_text(format!("[^endnote-{id}]: {body}")));
335            docx = docx.add_paragraph(p);
336        }
337        DocumentBlock::Section {
338            blocks,
339            section_type: _,
340        } => {
341            // 分区内容渲染为普通子块
342            for inner in blocks {
343                docx = render_block(docx, inner)?;
344            }
345        }
346        DocumentBlock::Math {
347            omml: _,
348            latex,
349            display,
350        } => {
351            // docx-rs 不支持 OMML;Math 块渲染为带唯一标记的占位段落,
352            // latex 存入线程本地收集器,由调用方在打包前调用
353            // [`take_rendered_math`] 取出并替换为原生 `<m:oMath>`。
354            let text = latex.clone().unwrap_or_default();
355            if !text.is_empty() {
356                let marker = render_math_marker();
357                let mut p = docx_rs::Paragraph::new();
358                p = p.add_run(
359                    docx_rs::Run::new()
360                        .add_text(&marker)
361                        .fonts(RunFonts::new().ascii("Courier New")),
362                );
363                RENDERED_MATH.with(|cell| {
364                    cell.borrow_mut().push((marker, text, *display));
365                });
366                docx = docx.add_paragraph(p);
367            }
368        }
369        _ => {
370            // 未来新增的块类型暂时跳过
371        }
372    }
373    Ok(docx)
374}
375
376/// 将 `DocumentTextRun` 转换为 `docx_rs::Run`。
377fn text_run_to_docx_run(run: &DocumentTextRun, bold: bool) -> docx_rs::Run {
378    let mut r = docx_rs::Run::new();
379    r = r.add_text(run.text.as_str());
380    if run.bold || bold {
381        r = r.bold();
382    }
383    if run.italic {
384        r = r.italic();
385    }
386    if run.strikethrough {
387        r = r.strike();
388    }
389    r
390}
391
392/// 渲染语义表格。
393fn render_table(mut docx: Docx, table: &DocumentTable) -> Result<Docx> {
394    let mut rows: Vec<docx_rs::TableRow> = Vec::new();
395
396    for table_row in &table.rows {
397        let mut cells: Vec<docx_rs::TableCell> = Vec::new();
398        for cell in &table_row.cells {
399            let mut cell_paragraphs: Vec<docx_rs::Paragraph> = Vec::new();
400            for block in &cell.blocks {
401                let p = render_block_to_paragraph(block)?;
402                cell_paragraphs.push(p);
403            }
404            let mut tc = docx_rs::TableCell::new();
405            for p in cell_paragraphs {
406                tc = tc.add_paragraph(p);
407            }
408            if cell.column_span > 1 {
409                tc = tc.grid_span(cell.column_span as usize);
410            }
411            // 纵向合并映射:row_span == 0 表示 vMerge continue(合并到上方
412            // 单元格),row_span > 1 表示 vMerge restart(向下跨 N-1 行)。
413            if cell.row_span == 0 {
414                tc = tc.vertical_merge(docx_rs::VMergeType::Continue);
415            } else if cell.row_span > 1 {
416                tc = tc.vertical_merge(docx_rs::VMergeType::Restart);
417            }
418            cells.push(tc);
419        }
420        rows.push(docx_rs::TableRow::new(cells));
421    }
422
423    docx = docx.add_table(docx_rs::Table::new(rows));
424    Ok(docx)
425}
426
427/// 将块列表提取为纯文本(段落取 run 文本拼接,其他块递归)。
428///
429/// 用于脚注/尾注正文的扁平化输出,与 `markdown_renderer` 的
430/// `plain_blocks` 语义一致。
431fn plain_blocks(blocks: &[DocumentBlock]) -> String {
432    let mut out = String::new();
433    for block in blocks {
434        match block {
435            DocumentBlock::Paragraph(runs) | DocumentBlock::Heading { runs, .. } => {
436                for run in runs {
437                    out.push_str(&run.text);
438                }
439            }
440            DocumentBlock::Footnote { blocks, .. } | DocumentBlock::Endnote { blocks, .. } => {
441                out.push_str(&plain_blocks(blocks));
442            }
443            _ => {}
444        }
445    }
446    out
447}
448
449/// 将单个块渲染为段落(用于表格单元格内)。
450fn render_block_to_paragraph(block: &DocumentBlock) -> Result<docx_rs::Paragraph> {
451    match block {
452        DocumentBlock::Heading { level: _, runs } => {
453            let p = docx_rs::Paragraph::new();
454            Ok(add_runs_with_hyperlinks(p, runs, true))
455        }
456        DocumentBlock::Paragraph(runs) => {
457            let p = docx_rs::Paragraph::new();
458            Ok(add_runs_with_hyperlinks(p, runs, false))
459        }
460        DocumentBlock::List(list) => {
461            // Render list inside a table cell with numbering properties.
462            let mut p = docx_rs::Paragraph::new();
463            let num_id = if list.ordered {
464                DECIMAL_NUM_ID
465            } else {
466                BULLET_NUM_ID
467            };
468            p = p.numbering(NumberingId::new(num_id), IndentLevel::new(0));
469            for item in &list.items {
470                for inner_block in &item.blocks {
471                    if let DocumentBlock::Paragraph(runs) = inner_block {
472                        p = add_runs_with_hyperlinks(p, runs, false);
473                    }
474                }
475            }
476            Ok(p)
477        }
478        DocumentBlock::CodeBlock { code, .. } => {
479            let mut p = docx_rs::Paragraph::new();
480            let r = docx_rs::Run::new()
481                .add_text(code.as_str())
482                .fonts(RunFonts::new().ascii("Courier New"))
483                .size(20);
484            p = p.add_run(r);
485            Ok(p)
486        }
487        _ => Ok(docx_rs::Paragraph::new()),
488    }
489}
490
491/// Renders a semantic list as OOXML paragraphs with `<w:numPr>` numbering properties.
492///
493/// Each list item becomes a paragraph tagged with the appropriate `numId` and `ilvl`.
494/// Nested lists recurse with an incremented indent level (max depth 3).
495fn render_list(mut docx: Docx, list: &DocumentList) -> Result<Docx> {
496    docx = render_list_at_level(docx, list, 0)?;
497    Ok(docx)
498}
499
500/// Recursively renders list items at a given indent level.
501///
502/// When the list is ordered and has a non-default `start_number`, a new abstract
503/// numbering definition with the correct start value is registered dynamically.
504fn render_list_at_level(mut docx: Docx, list: &DocumentList, level: usize) -> Result<Docx> {
505    let num_id = if list.ordered {
506        // If start_number is non-default, create a dynamic numbering definition.
507        if let Some(start) = list.start_number
508            && start != 1
509        {
510            register_custom_start_numbering(&mut docx, start)
511        } else {
512            DECIMAL_NUM_ID
513        }
514    } else {
515        BULLET_NUM_ID
516    };
517    // Clamp level to the 3 levels we defined (0, 1, 2).
518    let ilvl = level.min(2);
519
520    for item in &list.items {
521        let mut p =
522            docx_rs::Paragraph::new().numbering(NumberingId::new(num_id), IndentLevel::new(ilvl));
523
524        // Add content from each block inside the list item.
525        for block in &item.blocks {
526            match block {
527                DocumentBlock::Paragraph(runs) => {
528                    p = add_runs_with_hyperlinks(p, runs, false);
529                }
530                DocumentBlock::Heading { runs, .. } => {
531                    p = add_runs_with_hyperlinks(p, runs, true);
532                }
533                _ => {
534                    // Other block types inside list items are rendered as-is
535                    // (best-effort; they become additional runs).
536                }
537            }
538        }
539
540        docx = docx.add_paragraph(p);
541
542        // Recursively render nested list at the next indent level.
543        if let Some(nested) = &item.nested {
544            docx = render_list_at_level(docx, nested, level + 1)?;
545        }
546    }
547    Ok(docx)
548}
549
550/// 渲染图片。
551fn render_image(mut docx: Docx, image: &DocumentImage) -> Result<Docx> {
552    if let Some(data) = &image.data {
553        let pic = Pic::new(data);
554        let p = docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_image(pic));
555        docx = docx.add_paragraph(p);
556    }
557    Ok(docx)
558}
559
560fn u8_to_heading_level(level: u8) -> HeadingLevel {
561    match level {
562        1 => HeadingLevel::H1,
563        2 => HeadingLevel::H2,
564        3 => HeadingLevel::H3,
565        4 => HeadingLevel::H4,
566        5 => HeadingLevel::H5,
567        _ => HeadingLevel::H6,
568    }
569}
570
571fn heading_style_name(level: u8) -> &'static str {
572    match level {
573        1 => "Heading1",
574        2 => "Heading2",
575        3 => "Heading3",
576        4 => "Heading4",
577        5 => "Heading5",
578        _ => "Heading6",
579    }
580}
581
582fn heading_outline_level(level: u8) -> usize {
583    (level.saturating_sub(1)) as usize
584}
585
586// ---------------------------------------------------------------------------
587// DocWriteHandler 集成 — 在渲染过程中触发生命周期回调
588// ---------------------------------------------------------------------------
589
590/// 将语义模型渲染为 docx-rs 的 Docx 实例,并在渲染过程中触发 handler 回调。
591///
592/// 支持 `DocWriteHandler` trait 的 before/after 回调:
593/// - `before_document` / `after_document`
594/// - `before_paragraph` / `after_paragraph`
595/// - `before_table` / `after_table`
596///
597/// # 参数
598/// - `content`: 完整的语义文档模型。
599/// - `handlers`: 可变引用的 handler 切片,按 order 排序。
600///
601/// # 返回
602pub fn render_with_handler<H: easydoc_core::traits::DocWriteHandler>(
603    content: &DocumentContent,
604    handler: &mut H,
605) -> Result<Docx> {
606    let ctx = easydoc_core::traits::DocWriteContext {
607        path: String::new(),
608    };
609
610    handler.before_document(&ctx)?;
611
612    let mut docx = Docx::new();
613    docx = add_list_numberings(docx);
614    let mut para_index: usize = 0;
615    let mut table_index: usize = 0;
616
617    for block in &content.blocks {
618        match block {
619            DocumentBlock::Heading { .. } | DocumentBlock::Paragraph(_) => {
620                let p_ctx = easydoc_core::traits::ParagraphContext { index: para_index };
621                handler.before_paragraph(&p_ctx)?;
622                docx = render_block(docx, block)?;
623                handler.after_paragraph(&p_ctx)?;
624                para_index += 1;
625            }
626            DocumentBlock::Table(table) => {
627                let t_ctx = easydoc_core::traits::TableWriteContext {
628                    index: table_index,
629                    row_count: table.rows.len(),
630                };
631                handler.before_table(&t_ctx)?;
632                docx = render_block(docx, block)?;
633                handler.after_table(&t_ctx)?;
634                table_index += 1;
635            }
636            _ => {
637                docx = render_block(docx, block)?;
638            }
639        }
640    }
641
642    handler.after_document(&ctx)?;
643
644    Ok(docx)
645}
646
647#[cfg(test)]
648mod coverage_tests {
649    use super::*;
650    use easydoc_core::{
651        CellContext, DocWriteContext, DocWriteHandler, DocumentListItem, DocumentTableCell,
652        DocumentTableRow, ParagraphContext, TableWriteContext,
653    };
654
655    fn make_text_run(text: &str) -> DocumentTextRun {
656        DocumentTextRun {
657            text: text.into(),
658            bold: false,
659            italic: false,
660            strikethrough: false,
661            hyperlink: None,
662        }
663    }
664
665    fn make_bold_run(text: &str) -> DocumentTextRun {
666        DocumentTextRun {
667            text: text.into(),
668            bold: true,
669            italic: false,
670            strikethrough: false,
671            hyperlink: None,
672        }
673    }
674
675    #[test]
676    fn render_heading_variants() {
677        for level in 1..=7 {
678            let content = DocumentContent {
679                blocks: vec![DocumentBlock::Heading {
680                    level,
681                    runs: vec![make_text_run("Title")],
682                }],
683                ..Default::default()
684            };
685            let docx = render_document_content(&content).unwrap();
686            let _ = docx;
687        }
688    }
689
690    #[test]
691    fn render_paragraph_with_runs() {
692        let content = DocumentContent {
693            blocks: vec![DocumentBlock::Paragraph(vec![
694                make_text_run("Hello "),
695                make_bold_run("World"),
696            ])],
697            ..Default::default()
698        };
699        let docx = render_document_content(&content).unwrap();
700        let _ = docx;
701    }
702
703    #[test]
704    fn render_table_with_spans() {
705        let content = DocumentContent {
706            blocks: vec![DocumentBlock::Table(DocumentTable {
707                rows: vec![DocumentTableRow {
708                    cells: vec![
709                        DocumentTableCell {
710                            blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("A")])],
711                            column_span: 2,
712                            row_span: 1,
713                        },
714                        DocumentTableCell {
715                            blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("B")])],
716                            column_span: 1,
717                            row_span: 1,
718                        },
719                    ],
720                    is_header: true,
721                }],
722            })],
723            ..Default::default()
724        };
725        let docx = render_document_content(&content).unwrap();
726        let _ = docx;
727    }
728
729    #[test]
730    fn render_table_vertical_merge_emits_vmerge_xml() {
731        // 两行一列:第一行 restart(row_span=2),第二行 continue(row_span=0)。
732        let content = DocumentContent {
733            blocks: vec![DocumentBlock::Table(DocumentTable {
734                rows: vec![
735                    DocumentTableRow {
736                        cells: vec![DocumentTableCell {
737                            blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Merged")])],
738                            column_span: 1,
739                            row_span: 2,
740                        }],
741                        is_header: false,
742                    },
743                    DocumentTableRow {
744                        cells: vec![DocumentTableCell {
745                            blocks: vec![],
746                            column_span: 1,
747                            row_span: 0,
748                        }],
749                        is_header: false,
750                    },
751                ],
752            })],
753            ..Default::default()
754        };
755        let docx = render_document_content(&content).unwrap();
756        let xml = String::from_utf8(docx.build().document).expect("document.xml is UTF-8");
757        // restart 单元格输出 <w:vMerge w:val="restart"/>,continue 输出 <w:vMerge w:val="continue"/>
758        assert!(
759            xml.contains("vMerge") && xml.contains("restart") && xml.contains("continue"),
760            "document.xml should contain vMerge restart+continue, got: {xml}"
761        );
762    }
763
764    #[test]
765    fn render_table_plain_cells_have_no_vmerge() {
766        let content = DocumentContent {
767            blocks: vec![DocumentBlock::Table(DocumentTable {
768                rows: vec![DocumentTableRow {
769                    cells: vec![DocumentTableCell {
770                        blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Plain")])],
771                        column_span: 1,
772                        row_span: 1,
773                    }],
774                    is_header: false,
775                }],
776            })],
777            ..Default::default()
778        };
779        let docx = render_document_content(&content).unwrap();
780        let xml = String::from_utf8(docx.build().document).expect("document.xml is UTF-8");
781        assert!(
782            !xml.contains("vMerge"),
783            "plain cells should not emit vMerge, got: {xml}"
784        );
785    }
786
787    #[test]
788    fn render_list_ordered_and_unordered() {
789        let content = DocumentContent {
790            blocks: vec![
791                DocumentBlock::List(DocumentList {
792                    ordered: false,
793                    start_number: None,
794                    items: vec![DocumentListItem {
795                        blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Item 1")])],
796                        nested: None,
797                    }],
798                }),
799                DocumentBlock::List(DocumentList {
800                    ordered: true,
801                    start_number: Some(5),
802                    items: vec![DocumentListItem {
803                        blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Item 2")])],
804                        nested: Some(Box::new(DocumentList {
805                            ordered: false,
806                            start_number: None,
807                            items: vec![DocumentListItem {
808                                blocks: vec![DocumentBlock::Paragraph(vec![make_text_run(
809                                    "Nested",
810                                )])],
811                                nested: None,
812                            }],
813                        })),
814                    }],
815                }),
816            ],
817            ..Default::default()
818        };
819        let docx = render_document_content(&content).unwrap();
820        let _ = docx;
821    }
822
823    #[test]
824    fn render_image_with_data() {
825        let content = DocumentContent {
826            blocks: vec![DocumentBlock::Image(DocumentImage {
827                alt_text: Some("test".into()),
828                data: Some(vec![
829                    0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49,
830                    0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
831                    0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44,
832                    0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00,
833                    0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44,
834                    0xAE, 0x42, 0x60, 0x82,
835                ]),
836                extension: Some("png".into()),
837            })],
838            ..Default::default()
839        };
840        let docx = render_document_content(&content).unwrap();
841        let _ = docx;
842    }
843
844    #[test]
845    fn render_image_without_data() {
846        let content = DocumentContent {
847            blocks: vec![DocumentBlock::Image(DocumentImage {
848                alt_text: Some("test".into()),
849                data: None,
850                extension: None,
851            })],
852            ..Default::default()
853        };
854        let docx = render_document_content(&content).unwrap();
855        let _ = docx;
856    }
857
858    #[test]
859    fn render_codeblock() {
860        let content = DocumentContent {
861            blocks: vec![DocumentBlock::CodeBlock {
862                language: Some("rust".into()),
863                code: "fn main() {}".into(),
864            }],
865            ..Default::default()
866        };
867        let docx = render_document_content(&content).unwrap();
868        let _ = docx;
869    }
870
871    #[test]
872    fn render_textbox() {
873        let content = DocumentContent {
874            blocks: vec![DocumentBlock::TextBox(vec![DocumentBlock::Paragraph(
875                vec![make_text_run("inside")],
876            )])],
877            ..Default::default()
878        };
879        let docx = render_document_content(&content).unwrap();
880        let _ = docx;
881    }
882
883    #[test]
884    fn render_footnote_and_endnote() {
885        let content = DocumentContent {
886            blocks: vec![
887                DocumentBlock::Footnote {
888                    id: 1,
889                    blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("note")])],
890                },
891                DocumentBlock::Endnote {
892                    id: 2,
893                    blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("end")])],
894                },
895            ],
896            ..Default::default()
897        };
898        let docx = render_document_content(&content).unwrap();
899        let _ = docx;
900    }
901
902    #[test]
903    fn render_section() {
904        let content = DocumentContent {
905            blocks: vec![DocumentBlock::Section {
906                blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("in section")])],
907                section_type: Some("nextPage".into()),
908            }],
909            ..Default::default()
910        };
911        let docx = render_document_content(&content).unwrap();
912        let _ = docx;
913    }
914
915    #[test]
916    fn render_thematic_and_page_and_column_break() {
917        let content = DocumentContent {
918            blocks: vec![
919                DocumentBlock::ThematicBreak,
920                DocumentBlock::PageBreak,
921                DocumentBlock::ColumnBreak,
922            ],
923            ..Default::default()
924        };
925        let docx = render_document_content(&content).unwrap();
926        let _ = docx;
927    }
928
929    #[test]
930    fn render_with_handler_all_blocks() {
931        struct TestHandler;
932        impl DocWriteHandler for TestHandler {
933            fn order() -> i32 {
934                0
935            }
936            fn before_document(&mut self, _: &DocWriteContext) -> Result<()> {
937                Ok(())
938            }
939            fn after_document(&mut self, _: &DocWriteContext) -> Result<()> {
940                Ok(())
941            }
942            fn before_paragraph(&mut self, _: &ParagraphContext) -> Result<()> {
943                Ok(())
944            }
945            fn after_paragraph(&mut self, _: &ParagraphContext) -> Result<()> {
946                Ok(())
947            }
948            fn before_table(&mut self, _: &TableWriteContext) -> Result<()> {
949                Ok(())
950            }
951            fn after_table(&mut self, _: &TableWriteContext) -> Result<()> {
952                Ok(())
953            }
954            fn before_cell(&mut self, _: &CellContext) -> Result<()> {
955                Ok(())
956            }
957            fn after_cell(&mut self, _: &CellContext) -> Result<()> {
958                Ok(())
959            }
960        }
961        let content = DocumentContent {
962            blocks: vec![
963                DocumentBlock::Heading {
964                    level: 1,
965                    runs: vec![make_text_run("H1")],
966                },
967                DocumentBlock::Paragraph(vec![make_text_run("P")]),
968                DocumentBlock::Table(DocumentTable { rows: vec![] }),
969                DocumentBlock::CodeBlock {
970                    language: None,
971                    code: "x".into(),
972                },
973                DocumentBlock::ThematicBreak,
974                DocumentBlock::PageBreak,
975                DocumentBlock::ColumnBreak,
976                DocumentBlock::Section {
977                    blocks: vec![],
978                    section_type: None,
979                },
980            ],
981            ..Default::default()
982        };
983        let mut handler = TestHandler;
984        let docx = render_with_handler(&content, &mut handler).unwrap();
985        let _ = docx;
986    }
987
988    #[test]
989    fn heading_style_names() {
990        assert_eq!(heading_style_name(1), "Heading1");
991        assert_eq!(heading_style_name(2), "Heading2");
992        assert_eq!(heading_style_name(3), "Heading3");
993        assert_eq!(heading_style_name(4), "Heading4");
994        assert_eq!(heading_style_name(5), "Heading5");
995        assert_eq!(heading_style_name(6), "Heading6");
996        assert_eq!(heading_style_name(7), "Heading6"); // default
997    }
998
999    #[test]
1000    fn heading_outline_levels() {
1001        assert_eq!(heading_outline_level(1), 0);
1002        assert_eq!(heading_outline_level(2), 1);
1003        assert_eq!(heading_outline_level(6), 5);
1004        assert_eq!(heading_outline_level(0), 0); // saturating_sub
1005    }
1006
1007    #[test]
1008    fn u8_to_heading_level_all() {
1009        assert_eq!(u8_to_heading_level(1), HeadingLevel::H1);
1010        assert_eq!(u8_to_heading_level(2), HeadingLevel::H2);
1011        assert_eq!(u8_to_heading_level(3), HeadingLevel::H3);
1012        assert_eq!(u8_to_heading_level(4), HeadingLevel::H4);
1013        assert_eq!(u8_to_heading_level(5), HeadingLevel::H5);
1014        assert_eq!(u8_to_heading_level(6), HeadingLevel::H6);
1015        assert_eq!(u8_to_heading_level(99), HeadingLevel::H6); // default
1016    }
1017
1018    #[test]
1019    fn render_ordered_list_with_custom_start_number() {
1020        let content = DocumentContent {
1021            blocks: vec![DocumentBlock::List(DocumentList {
1022                ordered: true,
1023                start_number: Some(5),
1024                items: vec![
1025                    DocumentListItem {
1026                        blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Fifth")])],
1027                        nested: None,
1028                    },
1029                    DocumentListItem {
1030                        blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Sixth")])],
1031                        nested: None,
1032                    },
1033                ],
1034            })],
1035            ..Default::default()
1036        };
1037        let docx = render_document_content(&content).unwrap();
1038        // Should succeed -- custom numbering definitions are registered.
1039        let _ = docx;
1040    }
1041
1042    #[test]
1043    fn render_ordered_list_with_default_start_number() {
1044        let content = DocumentContent {
1045            blocks: vec![DocumentBlock::List(DocumentList {
1046                ordered: true,
1047                start_number: Some(1),
1048                items: vec![DocumentListItem {
1049                    blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("First")])],
1050                    nested: None,
1051                }],
1052            })],
1053            ..Default::default()
1054        };
1055        let docx = render_document_content(&content).unwrap();
1056        let _ = docx;
1057    }
1058
1059    #[test]
1060    fn render_ordered_list_with_none_start_number() {
1061        let content = DocumentContent {
1062            blocks: vec![DocumentBlock::List(DocumentList {
1063                ordered: true,
1064                start_number: None,
1065                items: vec![DocumentListItem {
1066                    blocks: vec![DocumentBlock::Paragraph(vec![make_text_run("Default")])],
1067                    nested: None,
1068                }],
1069            })],
1070            ..Default::default()
1071        };
1072        let docx = render_document_content(&content).unwrap();
1073        let _ = docx;
1074    }
1075
1076    #[test]
1077    fn text_run_to_docx_run_styles() {
1078        let run = DocumentTextRun {
1079            text: "test".into(),
1080            bold: true,
1081            italic: true,
1082            strikethrough: true,
1083            hyperlink: None,
1084        };
1085        let r = text_run_to_docx_run(&run, false);
1086        let _ = r;
1087        let r2 = text_run_to_docx_run(&run, true);
1088        let _ = r2;
1089    }
1090
1091    #[test]
1092    fn render_block_to_paragraph_codeblock() {
1093        let block = DocumentBlock::CodeBlock {
1094            language: Some("python".into()),
1095            code: "print()".into(),
1096        };
1097        let p = render_block_to_paragraph(&block).unwrap();
1098        let _ = p;
1099    }
1100
1101    #[test]
1102    fn render_block_to_paragraph_heading() {
1103        let block = DocumentBlock::Heading {
1104            level: 2,
1105            runs: vec![make_text_run("Sub")],
1106        };
1107        let p = render_block_to_paragraph(&block).unwrap();
1108        let _ = p;
1109    }
1110
1111    #[test]
1112    fn render_block_to_paragraph_fallback() {
1113        // Non-paragraph blocks in table cells fall back to empty paragraph
1114        let block = DocumentBlock::ThematicBreak;
1115        let p = render_block_to_paragraph(&block).unwrap();
1116        let _ = p;
1117    }
1118}