跳到主要内容

统一中间表示 middle_json、magic_model 与 Markdown 渲染

30 秒导读: MinerU 有三个能解析 PDF 的后端(pipelinevlm、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_json
    union_makemiddle_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:29pipeline_magic_model.py:17hybrid_magic_model.py:17
blocks_to_page_info把 magic_model 抽出的块组装成一页的 page_infovlm/model_output_to_middle_json.py:23
para_split阅读顺序内的段落合并、list/index 识别pipeline/para_split.py:417
union_make遍历 pdf_info,按 MakeMode 拼成 md/jsonvlm_middle_json_mkcontent.py:892
枚举常量定义所有 block/content 类型名与渲染模式utils/enum_class.py

主线走一遍(高层): 后端产出原始检测结果 → 对应的 magic_model 把它整理成分好类的 block → 组装成 page_infopara_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 的 typespans,不关心这些块是 OCR 出来的还是 VLM 生成的。


3. 统一数据模型:middle_json / pdf_info 的结构

3.1 四层嵌套:page → block → line → span

middle_json 的顶层就三个字段(vlm/model_output_to_middle_json.py:79init_middle_json):

# 示意,非源码
{
"pdf_info": [ <每页一个 page_info> ],
"_backend": "vlm", # 记住是谁生的,渲染时据此选 union_make
"_version_name": "3.4.1",
}

pdf_info逐页的列表。每个 page_infoblocks_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一个语义单元(段落/标题/图/表)typeindexbboxlines
line块内一行bboxspans
span行内最小片段(文本/公式/图/表)typecontent / 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): 块级语义类型——TEXTTITLEIMAGETABLECHARTCODELISTINTERLINE_EQUATIONDISCARDED…还有一批带 _BODY/_CAPTION/_FOOTNOTE 后缀的子块(如 IMAGE_BODYTABLE_CAPTION)。这是 magic_model 的输出词汇表。

  • ContentType(enum_class.py:51): span 级内容类型——TEXTIMAGETABLECHARTINLINE_EQUATIONINTERLINE_EQUATION。粒度比 BlockType 细:一个 text 块的 line 里可以混着 text span 和 inline_equation span。

  • 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_MDNLP_MD 的区别就在渲染时对图/表/图表的处理:NLP_MD 直接 continue 跳过(vlm_middle_json_mkcontent.py:381391401)。


4. magic_model:从原始检测结果抽出语义块

4.1 它要解决的小问题

后端给的原始结果是"一堆带类型标签和坐标的检测框",还很脏:图和它的标题各自是一个框、代码块里混着公式、目录被拆成一行行文本、caption 碎成几段。magic_model 的活是把这堆碎片整理成干净的、分好类的、该合的合在一起的 block 列表

三个后端各有一个 MagicModel 类,但形状高度一致——都遵循"逐块解析 → 抽 span → 建 line/block → 按类型分桶 → 修补分组"这条流水线。

4.2 三个 magic_model 的角色

magic_model输入特点
vlm_magic_model.py:29VLM 输出的 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:17VLM block + OCR det 行 + 行内公式的混合折中——大部分块用 VLM 文本,但保留 OCR det 行提示供段落合并

共同的抽块逻辑(以 vlm 为例): 构造函数遍历每个原始块,做三件事:

  1. 坐标还原 —— 原始 bbox 是 0~1 归一化的,乘上页宽页高变成像素(vlm_magic_model.py:40-50)。
  2. 类型归一 + 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
  3. 分桶 —— 把建好的 block 按类型塞进 self.text_blocksself.title_blocksself.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_fragmentsfallback_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-118finalize_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)分三步:

  1. 分组 —— __process_blocks(para_split.py:16)把连续的文本块聚成 group,遇到标题/公式/摘要就切一刀(para_split.py:43-50)。段落合并只在 group 内发生,不会跨越标题。

  2. 逐块判类型 —— 对 group 里每个块跑 __is_list_or_index_block(para_split.py:59),用几何特征(左侧是否顶格、右侧狗牙状、是否以句末标点结尾、是否数字开头/结尾)判断它是普通 text、还是 listindexvertical_text。这是本文件里最密的一段启发式。

  3. 相邻合并 —— 倒序遍历,按类型配对合并:两个 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:11ListLineTag)。它区分好几种 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_EQUATIONmerge_para_with_text 拼文本:366-367
LIST遍历子块,每项拼一行加 \n:368-376
TITLE# × 级别 + 文本:377-379
IMAGE/TABLE/CHARTNLP_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):

  • 图片本体![](img_buket_path/image_path),若有 VLM 生成的图片描述再包一个 <details> 折叠块(_build_visual_body_segments,:119)。
  • 表格本体 → 若开启表格且有 HTML,直接嵌入 <table> HTML(:220-224);否则退化成表格截图 ![](...)。嵌入前做两步清洗:给表内相对图片路径加前缀、把 <eq>...</eq> 标签换成行内公式(_format_embedded_html,:60)。
  • 代码块_render_code_block_markdown(:146):sub_typecode 就用 ```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_typetext_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,渲染就能复用。


7. 巧妙之处(可借鉴的技术)

  • 用统一中间表示解耦"解析"与"渲染"。 三个后端 × 四种输出 = 12 种组合,但因为中间夹了 middle_json,渲染只写一次(准确说两份:pipeline 一份,vlm/hybrid 共用一份,office 一份)。加新后端只需让它产出同构 middle_json,渲染零改动。见 client_side_output.py:16_select_union_make

  • _backend 字段自描述。 middle_json 自己记住是谁生的(init_middle_json,vlm/model_output_to_middle_json.py:80),于是可以把 middle_json 存下来,事后任意时刻重新渲染(regenerate_client_side_outputs,client_side_output.py:44)——解析和渲染彻底分家。

  • 一个块能拆出混合类型的 span。 行内公式 \(...\) 被拆成 text/equation 交替的 span 序列(vlm_magic_model.py:151),让"一行里文字和公式混排"这件事在数据结构上就表达清楚,渲染只按 span 类型逐个处理即可。

  • 换行加不加空格,分 CJK / 西文两套策略,还处理连字符断词。 merge_para_with_text:275 里 CJK 不加空格、西文加空格且删行末连字符——这是把 OCR 的物理换行还原成语义文本的关键细节,容易被忽视。

  • 视觉块的 caption 顺序由 index 决定,忠实原文。 不硬编码"图注在下",而是让子块按检测到的 index 排序(_get_blocks_in_index_order:136)。


8. 边界与局限

  • para_split 的 list/index 判定是启发式(__is_list_or_index_block:59),靠一堆几何阈值(0.80.7*line_height0.26*block_weight…)。版式怪异时会误判,代码注释里也坦承有"拍脑袋"的阈值(para_split.py:147)。

  • 段落合并可能把不该合的合了或漏合。 __merge_2_text_blocks 的条件是经验规则(para_split.py:282-296),遇到无标点结尾的短段、或首字大写的续行,判断会失准。

  • content_list_v2 的复杂表判定只看关键词。 靠字符串里有没有 colspan/rowspan/多个 <table>(:631-642),不解析真实结构,极端 HTML 可能误分类。

  • 本章不覆盖后端内部如何产生原始检测结果。 magic_model 的输入(VLM 的 token 序列、pipeline 的多模型输出)怎么来的,见 02-pipeline-backend.md03-vlm-backend.md;hybrid 与 Office 直解见 05-hybrid-and-office.md


9. 代码地图(导航索引)

主题文件路径符号名
类型常量(块/内容/模式)mineru/utils/enum_class.pyBlockType / ContentType / ContentTypeV2 / MakeMode
vlm 抽块 + 分组mineru/backend/vlm/vlm_magic_model.pyMagicModel
list 块合并mineru/backend/vlm/vlm_magic_model.pyfix_list_blocks
pipeline 抽块(标签映射)mineru/backend/pipeline/pipeline_magic_model.pyMagicModel
hybrid 抽块(VLM+OCR 混合)mineru/backend/hybrid/hybrid_magic_model.pyMagicModel
组装 page_infomineru/backend/vlm/model_output_to_middle_json.pyblocks_to_page_info / init_middle_json
段落重组 + list 识别mineru/backend/pipeline/para_split.pypara_split / __para_merge_page / __is_list_or_index_block
vlm/hybrid 渲染总入口mineru/backend/vlm/vlm_middle_json_mkcontent.pyunion_make
块 → Markdown 分发mineru/backend/vlm/vlm_middle_json_mkcontent.pymk_blocks_to_markdown
文本拼接(空格/连字符/公式)mineru/backend/vlm/vlm_middle_json_mkcontent.pymerge_para_with_text
视觉块渲染(图/表/代码)mineru/backend/vlm/vlm_middle_json_mkcontent.py_merge_visual_blocks_to_markdown / _render_visual_block_segments
content_list 两版mineru/backend/vlm/vlm_middle_json_mkcontent.pymake_blocks_to_content_list / make_blocks_to_content_list_v2
按后端选渲染器mineru/cli/client_side_output.py_select_union_make / regenerate_client_side_outputs