首页
/ Test Table Slide

Test Table Slide

2026-09-06 19:02:58作者:齐冠琰
Class1 Class1 Class1 Class2 Class2 Class2
A merged with B A merged with B C A B C
R1 True False False True True
R2 True False
R3 False False
R3 True True
R4 False False
R4 True True False False
R4 True False True False True False

With footnote

A rectangle shape with this text inside.

Let’s introduce a list

  • With foo
  • Bar
  • And baz things
  1. List item4
  2. List item5
  3. List item6
  • I1
  • I2
  • I3
  • I4

Some info:

  • Item A
  • Item B

Maybe a list?

  1. List1
  2. List2
  3. List3
  • l1
  • l2
  • l3

逐行观察可得到三个关键结论:每页"标题占位符"被渲染成一级标题 `#`;幻灯片正文中的文本/表格/列表被**线性平铺**;表格的合并单元格以重复文本的方式展开。下面结合源码逐点展开。

## 页与标题:占位符类型决定文本语义

源文件共 3 页(`test_pptx_page_range` 中断言 `page_count == 3`)。在 [tests/data/pptx/groundtruth/powerpoint_sample.pptx.itxt](https://gitcode.com/GitHub_Trending/do/docling/blob/3abb87a7a05ce60264399ab45047304e81bda926/tests/data/pptx/groundtruth/powerpoint_sample.pptx.itxt?utm_source=gitcode_repo_files) 中,每一页都成为文档树里的一个 `chapter` 分组(`group slide-0`/`slide-1`/`slide-2`),结构清晰:

item-2 at level 2: title: Test Table Slide item-3 at level 2: table with [9x7] item-4 at level 2: paragraph: With footnote


这个层级模型恰恰是 JSON/itxt 与 Markdown 的差异点:**Markdown 是"拍平"后的线性视图**,幻灯片的页分组信息并不直接出现在 `.md` 中,而是体现为连续的标题和正文流。

从 XML 侧核对 `ppt/slides/slide1.xml`、`slide2.xml` 可以看到,第 1、2 页都有名为 `Title 1` 的标题占位符(placeholder),所以输出中出现两处 `#` 标题;而第 3 页只含 `TextBox 3`~`TextBox 7` 五个独立文本框、没有标题占位符,因此对应片段**没有**任何 `#` 标题。这与源码中标题判定逻辑吻合:

- 在 [mspowerpoint_backend.py](https://gitcode.com/GitHub_Trending/do/docling/blob/3abb87a7a05ce60264399ab45047304e81bda926/docling/backend/mspowerpoint_backend.py?utm_source=gitcode_repo_files) 的 `_handle_text_elements`(L692 起)与 `_handle_title`(L763 起)中,只有占位符类型为 `PP_PLACEHOLDER.TITLE` / `CENTER_TITLE` 的段落才被标记为 `DocItemLabel.TITLE`(L745-L753);
- 其余普通文本统一落入 `DocItemLabel.PARAGRAPH`;
- 标题在 Markdown 导出中默认渲染为一级标题(`#`),这解释了为什么每出现一个页面标题就会新增一个 `#`。

值得注意的细节是第 1 页的 "With footnote"。它在 XML 中是名为 `Subtitle 2` 的副标题占位符,但 itxt 树里是 `paragraph: With footnote`,在 Markdown 中也只以普通段落呈现,**没有**被当作标题。对照源码:`_handle_title` 中对 `SUBTITLE` 的处理只覆盖"整段即副标题"的分支,而经过正文遍历路径时,由于 `SUBTITLE` 不在 TITLE/CENTER_TITLE 白名单内,默认落到 `PARAGRAPH`——这正是该样本想锁定的边界行为。

## 表格:合并单元格如何在 GFM 中落地

第 1 页的表格是真值文件中最有信息量的部分。从 `ppt/slides/slide1.xml` 的 `<a:tbl>` 看,该表由 `a:tblGrid` 定义 **7 列**、9 行 `<a:tr>`,且大量使用合并:

| 行 | 特征 | XML 属性 |
|---|---|---|
| 表头第 1 行 | `Class1` 横跨 3 列、`Class2` 横跨 3 列 | `gridSpan="3"` |
| 表头第 2 行 | `A merged with B` 横跨 2 列 | `gridSpan="2"` |
| `R3` 数据行 | 纵向合并两行 | `rowSpan="2"` |
| `R4` 数据行 | 纵向合并三行 | `rowSpan="3"` |

后端读取这些原始属性并翻译成结构化 `TableCell`,见 [mspowerpoint_backend.py](https://gitcode.com/GitHub_Trending/do/docling/blob/3abb87a7a05ce60264399ab45047304e81bda926/docling/backend/mspowerpoint_backend.py?utm_source=gitcode_repo_files) 的 `_handle_tables`(L818-L880):

```python
row_span = cell_xml.get("rowSpan")   # 纵向合并
col_span = cell_xml.get("gridSpan")  # 横向合并
icell = TableCell(
    text=cell.text.strip(),
    row_span=row_span,
    col_span=col_span,
    start_row_offset_idx=row_idx,
    end_row_offset_idx=row_idx + row_span,
    start_col_offset_idx=col_idx,
    end_col_offset_idx=col_idx + col_span,
    column_header=row_idx == 0,  # 首行作为列头
    row_header=False,
)

由于 GitHub Flavored Markdown 的管道表格无法表达 rowSpan/gridSpan,导出的 .md 采用"展开填充"策略:横向合并的文本在覆盖的每一列重复出现,纵向合并的行标签在每一行重复出现。对照真值即可验证:

  • | | Class1 | Class1 | Class1 | Class2 | Class2 | Class2 |Class1/Class2 各自重复 3 次,恰好占满 7 列;
  • | | A merged with B | A merged with B | ...A merged with B 重复 2 次;
  • 数据区出现连续两行 R3、连续三行 R4,正是 rowSpan 在二维表格中的"视觉展开"。

合并信息并不会丢失——row_span/col_span 以及行列偏移量被完整保留在 TableCell 中,可在 .json 真值(powerpoint_sample.pptx.json)中按结构化方式核对。itxt 真值中的 table with [9x7] 也与 XML 的 9 行 × 7 列完全一致。简言之:Markdown 保布局可读,JSON 保结构无损

文本与列表:有序 / 无序标记的判定链

第二、三页输出里混排了四种元素:普通段落、"-"无序列表、"1. 2. 3."编号列表。决定它们语义的核心在 mspowerpoint_backend.py 的列表标记解析链:

  1. _is_list_item(L419)先尽量通过段落所属 shape 解析"有效标记",拿不到再退化为检查 XML 中的 a:buChar / a:buAutoNum / 段落层级;
  2. _get_effective_list_marker(L486)按四级优先级逐层查找标记定义:段落直接属性 a:pPr → 文本框 a:lstStyle → 版式(layout)占位符 → 母版(master)文本样式 p:txStyles
  3. _parse_bullet_from_paragraph_properties(L272)把 a:buChar(字符项目符号)、a:buAutoNum(自动编号)、a:buBlip(图片符号)、a:buNone(显式无符号)区分开,段落缩进层级 lvla:pPr/@lvl(0-8)读取;
  4. 最终在 _handle_text_elements(L692)中,is_a_list=True 的连续段落被聚合进 doc.add_list_group,其中 bullet_type == "Numbered" 的项会递增生成 "1.""2." 这类枚举标记。

这就是为什么真值里同一页会出现"正文打断列表"的结构:第 3 页中 Some info:(普通段落)隔开了两个列表组,Maybe a list?(普通段落)又把后面的内容切成新的列表组——在源码层面,一旦遍历遇到非列表段落,当前列表组即被关闭,之后新的列表项会另起一个 list 分组(itxt 中 slide-2 下多个独立 list group 正是这一行为的产物)。

视觉阅读顺序:为什么输出顺序不等于创建顺序

PowerPoint 内部按创建顺序/层级存储 shape,这与人的"视觉阅读顺序"常常不一致。后端在 mspowerpoint_backend.py_iter_shapes_by_position(L621-L690)中做了排序:先按 top 从上到下分组,同一行内(顶边差不超过 _SHAPE_ROW_TOLERANCE_EMU = 45720 EMU,即 0.05 英寸)再按 left 从左到右输出;行判定采用"滑动窗口",只和紧邻前一个 shape 比较,因而连续带状的图标+文字组合能归入同一视觉行;坐标缺失的 shape 垫底保留原始相对顺序。

观察第 2 页就能看到它的效果:幻灯片上有标题、"A rectangle shape with this text inside." 的矩形、以及包含 "Let’s introduce a list" 与 3 个 bullet 的内容占位符。若按 XML 创建顺序,内容占位符在矩形之前;但 Markdown 真值输出为:

A rectangle shape with this text inside.

Let’s introduce a list

- With foo
- Bar
- And baz things

即矩形段落先于列表块出现——这正符合"按视觉位置重排"的设计目标(函数 docstring 明确指出:被拆开的文本块需要依据视觉顺序相邻输出)。第 3 页的 5 个独立文本框(编号列表、符号列表、"Some info:"、混合列表、l1-l3)同样按视觉位置从上到下依次成段,构成了真值后三分之一的内容。

本地复现:亲手导出同一份真值 Markdown

要复现本文引用的真值,只需按测试同款参数执行一次转换:

from pathlib import Path

from docling.datamodel.base_models import InputFormat
from docling.document_converter import DocumentConverter

converter = DocumentConverter(allowed_formats=[InputFormat.PPTX])
conv = converter.convert(Path("tests/data/pptx/sources/powerpoint_sample.pptx"))

markdown = conv.document.export_to_markdown(
    compact_tables=True,        # 测试使用的表格紧凑格式
)
print(markdown)
登录后查看全文
热门项目推荐
相关项目推荐