数据截至 (上游 commit 4fe4bde114a2)
统一中间表示 middle_json、magic_model 与 Markdown 渲染
30 秒导读: MinerU 有三个能解析 PDF 的后端(pipeline、vlm、hybrid),它们内部实现天差地别,但都要吐出同一种数据结构
middle_json。本章讲这个统一数据结构长什么样、magic_model怎么把杂乱的检测结果整理成它、以及一份共享的union_make渲染器怎么把它拼成最终的 Markdown 和 JSON。理解了这一层,你就明白 MinerU"可插拔后端"的关键:换后端不用改渲染。
1. 这是什么(零基础也能懂)
-
一句话定义:
middle_json是 MinerU 的统一中间表示——不管前面用哪个后端解析 PDF,结果都先落成同一套页(page)→块(block)→行(line)→段(span)的嵌套 JSON,然后再由渲染层拼成 Markdown。 -
解决什么问题: 假设你想让一个产品"既能用传统 OCR 流水线解析扫描件,又能用视觉大模型解析复杂版式",两条路给的原始结果格式完全不同。如果每个后端都自己写一套"结果 → Markdown"的转换,你就有两套渲染代码要维护,而且输出还不一致。MinerU 的办法是:强制所有后端先归一到
middle_json,渲染只写一次。 -
三层职责,一句话各表:
层 干什么 产物 magic_model从后端的原始检测结果里,按类型抽出语义块并做修补 分好类的 block 列表 组装 + para_split把 block 拼成页、排好阅读顺序、重组段落 middle_jsonunion_make把 middle_json拼成 Markdown / content_list.md、_content_list.json -
一句话直觉: 把
middle_json想成"排好版的乐高说明书"——每一块积木(block)标了类型、位置、内容和该拼的顺序。三个后端是三个不同的拆包工人,但拆完都按同一张说明书摆放;最后拼装工人(union_make)只认说明书,不管积木是谁拆的。
本节不出现底层代码。目标:知道"middle_json 是所有后端的公共语言,渲染只认它"。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上到下是数据流。三个后端在左,各有自己的 magic_model,但都汇聚到中间的 middle_json;右边的渲染层是共享的,一份代码服务所有后端。
原始检测结果(每个后端格式不同)
┌─────────────┬─────────────┬─────────────┐
│ pipeline │ vlm │ hybrid │ ← 见 02/03 章
│ 多模型输出 │ VLM 文本序列 │ 版面+OCR混合 │
└──────┬──────┴──────┬──────┴──────┬──────┘
│ │ │
pipeline_ vlm_ hybrid_
magic_model magic_model magic_model ← ①按类型抽块 + 修补
│ │ │
└─────────────┼─────────────┘
▼
blocks_to_page_info ← ②组装成页
+ para_split ← ③排序 + 段落重组
▼
┌──────────────────────────────┐
│ middle_json │ 统一中间表示
│ { pdf_info:[ page → │ _backend 记住是谁生的
│ block → line → span ]} │
└──────────────┬───────────────┘
▼
union_make ④按 MakeMode 渲染
(按 _backend 选对应实现)
┌───────────┬───────────┬───────── ─────┐
▼ ▼ ▼ ▼
.md content_list content_list_v2 (nlp_md)
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
MagicModel(三份) | 从原始检测结果按类型抽块、修补、分组 | vlm_magic_model.py:29、pipeline_magic_model.py:17、hybrid_magic_model.py:17 |
blocks_to_page_info | 把 magic_model 抽出的块组装成一页的 page_info | vlm/model_output_to_middle_json.py:23 |
para_split | 阅读顺序内的段落合并、list/index 识别 | pipeline/para_split.py:417 |
union_make | 遍历 pdf_info,按 MakeMode 拼成 md/json | vlm_middle_json_mkcontent.py:892 等 |
| 枚举常量 | 定义所有 block/content 类型名与渲染模式 | utils/enum_class.py |
主线走一遍(高层): 后端产出原始检测结果 → 对应的 magic_model 把它整理成分好类的 block → 组装成 page_info 并 para_split 重排 → 汇成 middle_json → 客户端根据 _backend 字段挑对应的 union_make → 输出 md 和 content_list。
换后端为什么不用改渲染? 因为 middle_json 里带了 _backend 字段,客户端读到它就知道该调哪个 union_make(cli/client_side_output.py:16 的 _select_union_make);而 vlm 与 hybrid 共用同一个 union_make(client_side_output.py:22-25),因为它们的 magic_model 产出的 block 结构一致。渲染层只认 block 的 type 和 spans,不关心这些块是 OCR 出来的还是 VLM 生成的。
3. 统一数据模型:middle_json / pdf_info 的结构
3.1 四层嵌套:page → block → line → span
middle_json 的顶层就三个字段(vlm/model_output_to_middle_json.py:79 的 init_middle_json):
# 示意,非源码
{
"pdf_info": [ <每页一个 page_info> ],
"_backend": "vlm", # 记住是谁生的,渲染时据此选 union_make
"_version_name": "3.4.1",
}
pdf_info 是逐页的列表。每个 page_info 由 blocks_to_page_info 组装(vlm/model_output_to_middle_json.py:70-76):
# 示意,非源码
page_info = {
"preproc_blocks": [ ...正文块,已按 index 排序... ], # 参与渲染
"discarded_blocks": [ ...页眉/页脚/页码等... ], # 只进 content_list
"page_size": [width, height],
"page_idx": page_index,
}
再往下就是四层里的三层——block、line、span。一个普通文本块长这样(由 MagicModel 构造,见 vlm_magic_model.py:222-228):
# 示意,非源码
block = {
"bbox": (x1, y1, x2, y2), # 像素坐标(已从归一化坐标还原)
"type": "text", # BlockType 之一
"angle": 0,
"index": 5, # 阅读顺序编号——排序靠它
"lines": [ # 一个块含若干行
{ "bbox": (...), "spans": [
{ "bbox": (...), "type": "text", "content": "这是一行文字" },
]},
],
}
四层各管什么:
| 层 | 承载 | 关键字段 |
|---|---|---|
| page | 一页 | preproc_blocks / discarded_blocks / page_size |
| block | 一个语义单元(段落/标题/图/表) | type、index、bbox、lines |
| line | 块内一行 | bbox、spans |
| span | 行内最小片段(文本/公式/图/表) | type、content / html / image_path |
index 字段是"阅读顺序"的载体——组装完 page_blocks.sort(key=lambda x: x["index"])(vlm/model_output_to_middle_json.py:68)就把一页的块排回正确的先后。
3.2 类型系统:BlockType / ContentType / ContentTypeV2 / MakeMode
所有"类型名"都是裸字符串常量,集中定义在 mineru/utils/enum_class.py,四组各司其职:
-
BlockType(enum_class.py:4): 块级语义类型——TEXT、TITLE、IMAGE、TABLE、CHART、CODE、LIST、INTERLINE_EQUATION、DISCARDED…还有一批带_BODY/_CAPTION/_FOOTNOTE后缀的子块(如IMAGE_BODY、TABLE_CAPTION)。这是 magic_model 的输出词汇表。 -
ContentType(enum_class.py:51): span 级内容类型——TEXT、IMAGE、TABLE、CHART、INLINE_EQUATION、INTERLINE_EQUATION。粒度比 BlockType 细:一个text块的 line 里可以混着textspan 和inline_equationspan。 -
ContentTypeV2(enum_class.py:62): content_list v2 输出专用的、更细的类型体系——把表格再分simple_table/complex_table,把标题/正文分title/paragraph,把列表分text_list/reference_list等。只在 v2 渲染里用到。 -
MakeMode(enum_class.py:89): 渲染模式开关,决定union_make产出什么:MakeMode 值 产物 MM_MDmm_markdown完整 Markdown(含图片、表格 HTML) NLP_MDnlp_markdown纯文本 Markdown(跳过图/表) CONTENT_LISTcontent_list扁平结构化 JSON(每块一条) CONTENT_LIST_V2content_list_v2更细的分层结构化 JSON MM_MD和NLP_MD的区别就在渲染时对图/表/图表的处理:NLP_MD直接continue跳过(vlm_middle_json_mkcontent.py:381、391、401)。
4. magic_model:从原始检测结果抽出语义块
4.1 它要解决的小问题
后端给的原始结果是"一堆带类型标签和坐标的检测框",还很脏:图和它的标题各自是一个框、代码块里混着公式、目录被拆成一行行文本、caption 碎成几段。magic_model 的活是把这堆碎片整理成干净的、分好类的、该合的合在一起的 block 列表。
三个后端各有一个 MagicModel 类,但形状高度一致——都遵循"逐块解析 → 抽 span → 建 line/block → 按类型分桶 → 修补分组"这条流水线。
4.2 三个 magic_model 的角色
| magic_model | 输入 | 特点 |
|---|---|---|
vlm_magic_model.py:29 | VLM 输出的 block 列表(已带归一化 bbox + type + content) | 最简单——VLM 已经给了结构化块,主要做坐标还原、span 拆分、分组 |
pipeline_magic_model.py:17 | 多个检测模型的 layout_dets(纯检测框,无文本内容) | 最重——要靠 PP_DOCLAYOUT_V2_LABELS_TO_BLOCK_TYPES 映射标签、跑 OCR 填文本、匹配 span 到块 |
hybrid_magic_model.py:17 | VLM block + OCR det 行 + 行内公式的混合 | 折中——大部分块用 VLM 文本,但保留 OCR det 行提示供段落合并 |
共同的抽块逻辑(以 vlm 为例): 构造函数遍历每个原始块,做三件事:
- 坐标还原 —— 原 始 bbox 是 0~1 归一化的,乘上页宽页高变成像素(
vlm_magic_model.py:40-50)。 - 类型归一 + span 生成 —— 一长串
if/elif把原始 type 映射到BlockType,并据此决定 span 类型(vlm_magic_model.py:69-108)。例如原始"image"→ block 变IMAGE_BODY、span 变ContentType.IMAGE;"table"→TABLE_BODY+ span 带html;"equation"→INTERLINE_EQUATION。 - 分桶 —— 把建好的 block 按类型塞进
self.text_blocks、self.title_blocks、self.list_blocks… 等列表(vlm_magic_model.py:252-274),外部通过get_text_blocks()等一组 getter 取用。
4.3 一个精巧点:文本里藏着的行内公式会被拆成多个 span
VLM 有时把行内公式写成 \( ... \) 夹在文本里。magic_model 会检测这种情况(\( 和 \) 数量相等且大于 0,vlm_magic_model.py:139-143),然后用正则 re.finditer(r"\\\((.+?)\\\)", ...) 把这一段文本拆成"文本 span + 公式 span + 文本 span…"的序列(vlm_magic_model.py:151-190):
# 示意,非源码:一个块拆出多个 span
for match in re.finditer(r"\\\((.+?)\\\)", block_content):
# 公式前的普通文本 → text span
# 匹配到的公式 → inline_equation span(去掉 \( \))
# 于是一个 line 的 spans 里就混着 text 和 inline_equation
这也是为什么下游渲染要能处理"一行里 text 和 inline_equation 交替"的情形。
4.4 修补一:视觉块重组(图/表/图表 + 它们的 caption/footnote)
图、表、图表在原始结果里是散的:图本体一个块、图注一个块、脚注一个块。regroup_visual_blocks(在 visual_magic_model_utils.py,由 vlm_magic_model.py:282 调用)把它们按空间关系归拢成一个父块,结构变成:
# 示意,非源码:一个 image 父块
{ "type": "image", "bbox": (...),
"blocks": [
{ "type": "image_body", "lines": [...] }, # 图本体
{ "type": "image_caption", "lines": [...] }, # 图注
{ "type": "image_footnote","lines": [...] }, # 脚注
]}
注意这里出现了第二种块形态:视觉父块用 blocks(子块列表)而不是 lines。渲染时要分别处理这两种。重组前还有两个 fallback 修补:fallback_inline_caption_fragments 和 fallback_leading_table_continuation_captions(vlm_magic_model.py:237-238),抢救被拆碎、或跨表续排的 caption。
4.5 修补二:list 块的合并(fix_list_blocks:340)
列表在版面里常被检测成一个大 list 框 + 若干独立的文本行块。fix_list_blocks(vlm_magic_model.py:340)负责把落在 list 框里的文本块收编成它的子项:
# 真实逻辑(vlm_magic_model.py:348-359),简化说明
for block in text_blocks + ref_text_blocks:
for list_block in list_blocks:
# 文本块 80% 面积落在 list 框内 → 收编为子项
if calculate_overlap_area_in_bbox1_area_ratio(block["bbox"], list_block["bbox"]) >= 0.8:
list_block["blocks"].append(block) # 收编
need_remove_blocks.append(block) # 稍后从原列表移除
收编后:空的 list 块被丢弃,并用子项类型的众数给 list 定一个 sub_type(vlm_magic_model.py:370-382)——这个 sub_type(如 text/ref_text)后面在 content_list v2 里决定列表是 text_list 还是 reference_list。
5. 阅读顺序与段落重组:para_split
5.1 它要解决的小问题
magic_model 抽出的是"块",但一个自然段可能被版面检测切成好几个块(跨列、跨页、行间距抖动都会导致)。para_split.py 的活是在正确的阅读顺序里,把本该是一段的相邻文本块合并,并识别出 list/index 这类特殊结构。
注意:
para_split.py属于 pipeline 后端,vlm/hybrid 走的是backend/utils/para_block_utils.py里的build_para_blocks_from_preproc+merge_para_text_blocks(见vlm/model_output_to_middle_json.py:106-118的finalize_middle_json)。两条路思路一致,这里以 pipeline 版讲透合并逻辑。
5.2 三步走
para_split(para_split.py:417)先把所有页的 preproc_blocks 摊平、给每块打上 page_num/page_size,然后调 __para_merge_page 做核心工作,最后按 page_idx 分回各页写入 para_blocks。
__para_merge_page(para_split.py:371)分三步:
-
分组 ——
__process_blocks(para_split.py:16)把连续的文本块聚成 group,遇到标题/公式/摘要就切一刀(para_split.py:43-50)。段落合并只在 group 内发生,不会跨越标题。 -
逐块判类型 —— 对 group 里每个块跑
__is_list_or_index_block(para_split.py:59),用几何特征(左侧是否顶格、右侧狗牙状、是否以句末标点结尾、是否数字开头/结尾)判断它是普通text、还是list、index、vertical_text。这是本文件里最密的一段启发式。 -
相邻合并 —— 倒序遍历,按类型配对合并:两个 text 用
__merge_2_text_blocks、两个 list 用__merge_2_list_blocks、纵排文本用__merge_2_vertical_text_blocks(para_split.py:393-411)。
5.3 合并的门槛:什么时候两个文本块算一段?
__merge_2_text_blocks(para_split.py:265)不是无脑合并,要同时满足一串条件(para_split.py:282-296),挑关 键的:
- 上一块最后一行右边界接近块右边界(说明这行是写满的,不是段落结尾的短行);
- 上一块最后一个 span 不以句末标点结尾(
.。!?…); - 下一块首字符不是数字、不是大写字母(数字/大写多半是新列表项或新句);
- 两块宽度差不超过 2 倍。
跨页合并时,会给被合并的行打上 SplitFlag.CROSS_PAGE 标记(para_split.py:298-301),供后续追溯。
5.4 list 内部还要切成 item
__is_list_or_index_block 判定为 list 后,还要在块内标出每个 item 的起止行——给行打 IS_LIST_START_LINE / IS_LIST_END_LINE 标签(para_split.py:11 的 ListLineTag)。它区分好几种 list 形态:有缩进的、没缩进靠右侧空隙判断的、靠句末标点分项的、数字有序列表的(para_split.py:209-256)。这些标签让渲染层知道该在哪里断行成 - item。
6. 渲染层:union_make 把 middle_json 拼成 Markdown
6.1 入口:union_make 与 MakeMode
union_make(vlm 版在 vlm_middle_json_mkcontent.py:892)是渲染总入口。它遍历 pdf_info 的每一页,按 make_mode 分派:
# 真实逻辑(vlm_middle_json_mkcontent.py:906-926),简化
for page_info in pdf_info_dict:
paras_of_layout = page_info.get('para_blocks') # 正文块
paras_of_discarded = page_info.get('discarded_blocks') # 页眉页脚等
if make_mode in [MM_MD, NLP_MD]:
page_markdown = mk_blocks_to_markdown(paras_of_layout, ...) # → 6.2
elif make_mode == CONTENT_LIST:
# 正文 + discarded 都进,逐块转 make_blocks_to_content_list → 6.6
elif make_mode == CONTENT_LIST_V2:
# 逐块转 make_blocks_to_content_list_v2,按页分组 → 6.6
关键差异:Markdown 只渲染正文(paras_of_layout);content_list 会把 discarded_blocks 也带上(vlm_middle_json_mkcontent.py:912),所以页眉页脚不进 md、但进结构化 JSON。最后 md 用 '\n\n'.join 拼接返回(vlm_middle_json_mkcontent.py:928-929)。
6.2 块 → Markdown:mk_blocks_to_markdown
mk_blocks_to_markdown(vlm_middle_json_mkcontent.py:361)是"按 block 类型分发渲染"的大 switch。每种类型走不同分支:
| block type | 怎么渲染 | 代码 |
|---|---|---|
TEXT/REF_TEXT/PHONETIC/INTERLINE_EQUATION | merge_para_with_text 拼文本 | :366-367 |
LIST | 遍历子块,每项拼一行加 \n | :368-376 |
TITLE | # × 级别 + 文本 | :377-379 |
IMAGE/TABLE/CHART | NLP_MD 跳过;MM_MD 走视觉块渲染 | :380-407 |
CODE | 视觉块渲染(代码/算法) | :408-413 |
空文本的块直接跳过(vlm_middle_json_mkcontent.py:415-416)。
6.3 文本拼接的精髓:merge_para_with_text
merge_para_with_text(vlm_middle_json_mkcontent.py:275)把一个块的所有 line/span 拼成一段连贯文本,难点全在换行处该不该加空格:
- CJK(中日韩)语境: 换行处不加空格,直接接上(
vlm_middle_json_mkcontent.py:323-330)——中文不用空格分词。 - 西文语境: 换行处加空格分隔单词;但若行末是连字符
-(is_hyphen_at_line_end),且下一行首字母小写,说明是被断开的单词,删掉连字符直接拼(vlm_middle_json_mkcontent.py:335-350)。
span 类型也各有渲染:
TEXT→ 做保守的 Markdown 转义(escape_conservative_markdown_text,:297-298);INLINE_EQUATION→ 包上行内公式定界符$...$(:299-300);INTERLINE_EQUATION→ 包上独立公式定界符$$...$$换行(:301-303);若关了公式(formula_enable=False)则退化为图片链接。
定界符不是写死的——从配置读 latex_delimiters_config,默认 $/$$(vlm_middle_json_mkcontent.py:18-30)。
6.4 视觉块渲染:图片路径 / 表格 HTML / 代码
图、表、图表、代码这类"父块含 blocks"的结构走 _merge_visual_blocks_to_markdown(vlm_middle_json_mkcontent.py:252),它按子块 index 排序后逐块渲染(_render_visual_block_segments,:162):
- 图片本体 →
,若有 VLM 生成的图片描述再包一个<details>折叠块(_build_visual_body_segments,:119)。 - 表格本体 → 若开启表格且有 HTML,直接嵌入
<table>HTML(:220-224);否则退化成表格截图。嵌入前做两步清洗:给表内相对图片路径加前缀、把<eq>...</eq>标签换成行内公式(_format_embedded_html,:60)。 - 代码块 →
_render_code_block_markdown(:146):sub_type是code就用```lang ... ```(语言来自guess_lang),是algorithm就渲染成算法 HTML。
img_buket_path(通常是 "images")是所有媒体路径的前缀,由调用方统一传入(client_side_output.py:75)。
6.5 caption 与 body 的顺序
一个 image 父块的 caption、body、footnote 是分开的子块,渲染时按各自的 index 排序(_get_blocks_in_index_order,:136),所以图注在图上还是图下,取决于版面检测给的 index——保留了原文的相对位置。
6.6 两种结构化 JSON:content_list 与 content_list_v2
除了 md,同一份 middle_json 还能渲染成两种结构化 JSON,给下游程序(如 RAG 切块)用:
content_list(make_blocks_to_content_list,:424): 扁平列表,每个块一条记录,字段直白:
# 示意,非源码
{ "type": "text", "text": "...", "bbox": [归一到 0~1000], "page_idx": 3 }
{ "type": "image", "img_path": "images/xxx.jpg", "image_caption": [...], "content": "..." }
{ "type": "table", "table_body": "<table>...</table>", "table_caption": [...] }
bbox 会被归一化到 0~1000 的整数区间(:522-529),便于跨页尺寸比较。
content_list_v2(make_blocks_to_content_list_v2,:536): 更细的分层结构,用 ContentTypeV2。它做的额外判断:
- 表格按是否含
colspan/rowspan/嵌套<table>分成simple_table/complex_table,并记table_nest_level(:631-642); - 标题按级别分
title(带level)或降级为paragraph(:564-580); - 列表按
sub_type分text_list/reference_list(:722-731); - 文本用
merge_para_with_text_v2(:806)拆成 span 数组而非一整串,保留 text/inline_equation/phonetic 的分段。
v2 还是按页分组输出(vlm_middle_json_mkcontent.py:918-926,每页一个 list),而 v1 是全文档一个扁平列表。
6.7 三后端的 union_make:同签名,微差异
三个后端各有一份 union_make,签名基本一致(pdf_info_dict, make_mode, img_buket_path),差异很小:
- vlm 版从环境变量读
formula_enable/table_enable开关(vlm_middle_json_mkcontent.py:897-898),pipeline 版则不带这两个参数(pipeline_middle_json_mkcontent.py:968-971); - 客户端
_select_union_make(client_side_output.py:16)让 vlm 与 hybrid 共用 vlm 版——因为两者的 magic_model 产出的 block 结构一致,渲染无需区分。
这就是"可插拔"落地的一环:后端不同,但只要吐出同构的 middle_json,渲染就能复用。