背景:手里有 HK32F030M / HK32F0301M 系列芯片的 6 本 PDF 手册(用户手册、数据手册、应用笔记,共 888 页),需要转成带表格的 Markdown,按章节拆分,方便检索和校对。本文记录从"听说有现成工具"到"自己写了一条混合管线"的全过程,包括三次失败、两个隐蔽的性能 bug,以及最终沉淀下来的可复用脚本。
转换得到的markdown 手册和所有代码都在:gitee.com/etberzin/hk32f030m_doc
1. 需求:看起来很简单
需求一句话:把 PDF 变成 Markdown,表格不能丢。
- 中文技术手册(用户手册 402 页、371 页各一本)
- 大量表格:寄存器位图条、位描述表、引脚定义、电气特性参数表
- 输出要按章节拆分(方便逐章校对)、图片要保留、页码要能对应回原 PDF
当时以为这是"装个工具跑一下"的事。事实证明,这是一条"每走一步都要自己造轮子"的路。
2. 第一轮选型:主力工具全军覆没
2.1 marker-pdf:装都装不上
marker是业界口碑最好的 PDF→Markdown 工具。pip install marker-pdf直接报错:
Pillow 10.4.0 does not support Python 3.14 and does not provide prebuilt Windows binaries查了依赖才发现是死结:marker 固定要求pillow<11,pip 只能降级到 10.4.0,而 Pillow 10.4.0 没有 Python 3.14 的 Windows wheel。除非单独装一个 Python 3.12 的 venv 来伺候它——为一个转换任务引入整个隔离环境,先搁置。
2.2 markitdown:装上了,表格基本废
微软的markitdown很轻量,但 PDF 走的是纯文本抽取,表格直接变成一行行文字。我们要的就是表格,pass。
2.3 “先导 Word 再导别的格式”?
很多人说 PDF 可以带格式导出 Word 再转。评估下来:pdf2docx底层同样是 PyMuPDF,还依赖 opencv(Python 3.14 上可能没有 wheel),多一跳转换多一次精度损失。机器上恰好有 pandoc 3.9,但那是用来做最终格式转换(md→html/docx)的,不是用来解决 PDF 解析的。放弃。
2.4 pymupdf_layout:半好半坏,但给了关键启发
PyMuPDF 1.28 把 markdown 提取拆成了独立包pymupdf_layout(自带 ONNX 布局分析模型,能识别标题/表格/图片/页眉页脚)。实测:
- ✅表格强:合并单元格、跨列表头还原得很好
- ❌正文乱序:中文手册里上下标/公式被拆得七零八落,例如原文"在第 9 个时钟期间,接收器必须向发送器发送一个应答位(ACK)“,它输出成"在 传输一个 字节 所需的 个时钟周期后是第 个时钟脉冲 在 第 个时钟 期间 接收器必须向发送 8 9 9 。 , 器发送一个应答位”
- ❌标题错位:“18.5.4 本机地址2 寄存器(I2C_OAR2)” 变成 “本机地址 寄存器( 18.5.4 2 I2C_OAR2 )”
- ❌寄存器位图条(寄存器标题下方的 31…0 位号/位名/rw 三行图):列合并错乱,“Res” 变 “R es”、“PECEN” 变 “PECE N” 跨两行
教训:没有万能工具,只有"哪个环节谁做得好"。
3. 混合管线:取各家长处
最终方案是按页分组,取长补短:
3.1 正文与标题:布局模型分组 + PDF 内部阅读顺序
布局模型predict(page, return_raw=True)把每页分成若干"组"(标题、正文、列表、表格、图片、页眉页脚),每组带 bbox。关键技巧:
- 文本组用
page.get_text(clip=组bbox)提取,而不是用模型自己的文本组装——前者按 PDF 内部阅读顺序输出,避开了模型的乱序问题 - 页眉/页脚组直接跳过(页码、版权行、运行章节名自动消失)
- 标题组加
##、图注加斜体,漏识别的小标题用正则兜底
3.2 表格:find_tables + 自写渲染器
布局模型的表格虽然好,但描述表有"幽灵空列"(合并表头导致),且合并单元格文本被官方渲染器复制到每个跨越的列(一段话出现 3 遍)。于是:
- 表格主来源换成
page.find_tables()(纯几何检测,位图条/描述表分离正确) - 自写渲染器
render_table_md:- 文本只放单元格原位(origin-only)
- 互补列合并:合并表头锚点在右格、数据在左格造成的错位,用"两列从不在同一行同时有内容 → 视为同一逻辑列"的规则合并
- 折叠全空列:消灭幽灵列
- 模型表格组只作 find_tables 未覆盖区域的兜底
3.3 寄存器位图条:词级几何重建(本文最得意的一步)
位图条是文本层+矢量框的复合体,两种表格检测都会翻车。最终方案完全绕开表格检测:
- 几何检测:一行 ≥8 个纯数字、横向跨幅 >150pt → 位图条区域;后续短词行(位名/rw/数字下半段)一起收进条带,遇"位"开头(描述表)或长词停止
- 词级重建:数字行给出各位的 x 范围;位名/rw 按 x 映射到 32 列;每个词找上方最近的数字行归属(高低半段各自映射,避免 x 范围重叠歧义)
- 上标拆行合并:位号 “31” 的个位是上标,在词层被拆成 “3”(y1 行)和 “1”(y2 行)——按 x 最近配对回 “31”;位名尾字母同理(“PECE”+“N"→"PECEN”)
- 描述表反向校正:位名文本在单元格里是居中的,落在中间位号;用描述表的
位 X:Y 位名把名字挪到起始位(跨行按距离匹配,避免多处 Res 抢位) - 单元格边框线:位图的竖向边框是填充矩形(w≈0,h≈11),可作辅助定位
效果对比(I2C_CR1 位图高半段):
❌ 布局模型: | 3 3 | 2 | 2 | | 2 | 2 | 2 | 2 | 23 | 22 | ... | R es | es | ... ✅ 重建结果: | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | ... | PECEN | ALERTEN | ... | SBC |3.4 合并单元格记号:留空会误会,那就填符号
markdown 表格表达不了合并单元格,直接留空会让人分不清"这里是合并的"还是"本来就空"。方案:被合并段覆盖的空槽填入记号,同一行不同合并段轮换符号▢ ◯ △ ◇ ☆。
- 时序表的"从模式"行:
| ▢ | ◯ | 从模式 | 5 | - | ns |—— ▢ 是上方"符号"格跨行,◯ 是"参数"格跨行 - 位图里:
Res ▢▢▢…表示 Res 的字段跨度
实现要点:跨度从单元格 bbox 覆盖的槽位(列合并/折叠之后按列映射回填)和描述表位 X:Y范围推导,记号只出现在真正属于合并段的空槽。
3.5 章节拆分与图片
- 章节:直接用 PDF 自带目录
get_toc()按一级章节切页,每章一个 md,封面/目录页归"前言",目录页整页跳过 - 图片:布局模型的 picture 组渲染成 PNG;无图片组页面兜底找大块矢量绘图(排除页眉 logo、目录点线、细线碎片)
- 每页末尾留
<!-- 源PDF第 N 页 -->注释,校对时能对应回原 PDF
4. 性能:GPU 之梦碎,和一个隐蔽的 18 倍性能 bug
4.1 GPU 加速调查:三条路全断
机器有 RTX 5070 Ti,但:
- onnxruntime-gpu:ORT 1.28 需要 CUDA 13 运行时库(
cublasLt64_13.dll),pip 上 NVIDIA 的包是个空包(0.0.1 无 wheel);旧版 ORT(CUDA 12)没有 Python 3.14 的 wheel——Python 3.14 把 ORT 版本锁死在需要 CUDA 13 的版本上 - onnxruntime-directml:不需要 CUDA 运行时,但布局模型的 GNN 算子(Identity 节点)在 DML 上直接报错
- 多进程并行:实测 8 进程只有 1.5x,spawn/模型重复加载/IPC 开销盖过收益
结论:GPU 加速在本机不可行,原因全是工具链而非硬件。
4.2 意外收获:ORT 线程池把 find_tables 拖慢了 18 倍
剖析性能时发现一个反常现象:只要 ONNX 模型一加载,同进程内find_tables()从 0.08s 慢到 1.5s/页。逐项排查:
- 分配 400MB 大内存:无影响
- 限制 ORT 线程数到 1:仍有影响
- 删除模型 + gc:部分恢复
最终定位:onnxruntime 的 CPU 线程池(默认 24 线程)在 session 创建后常驻自旋,疯狂抢占 CPU,把单线程的 MuPDF 操作拖垮。而且这个干扰的强度随 ORT 线程数增加:
| intra_op 线程数 | find_tables | predict |
|---|---|---|
| 1 | 1.35s | 0.33s |
| 4 | 0.64s | 0.27s |
| 24 | 1.45s | 1.05s |
顺带发现:模型推理本身只有 ~0.3s/页,之前测的 1.1s 大部分是这个干扰。
修复:脚本默认给所有 ORT session 设inter_op_num_threads=1, intra_op_num_threads=4,单进程提速 1.9 倍,全量转换从 ~12 分钟降到 ~9 分钟。
5. 成果
| 手册 | 页数 | 表格 | 位图条 | 合并记号 |
|---|---|---|---|---|
| HK32F030M 用户手册 V1.8 | 385 | 483 | 171 | 2895 |
| HK32F0301MxxxxC 用户手册 V1.0 | 355 | 470 | 171 | 2829 |
| HK32F030M 数据手册 V1.6 | 42 | 58 | 0 | 280 |
| HK32F0301MxxxxC 数据手册 V1.2 | 49 | 51 | 0 | 359 |
| HK32F030M Datasheet (EN) | 44 | 61 | 0 | 322 |
| 应用笔记 | 13 | 3 | 0 | 0 |
| 合计 | 888 | 1126 | 342 | 6685 |
- 全部输出:章节 md + 全书合并版 + 转换报告 + 渲染图片,核验脚本 0 个可疑问题
- 每页带
<!-- 源PDF第 N 页 -->注释,方便逐页校对 - 工具沉淀为
pdf2md/pdf2md.py:python pdf2md/pdf2md.py 手册.pdf一条命令
6. 经验总结
- 没有万能转换工具:marker 装不上、markitdown 丢表格、pymupdf_layout 半好半坏。选型时先"装得上 + 抽 3 页实测",别信口碑。
- 混合管线是正解:同一个 PDF,让"各环节最擅长的工具"各干各的活——模型管布局、MuPDF 管文本顺序、几何检测管表格、词级重建管位图。
- 中文技术手册的隐藏杀手是上标:位号、寄存器名里的上下标在文本层里是分离的,任何"按顺序拼文本"的方案都会翻车,必须按几何(x/y 坐标)重建。
- 性能剖析别信第一感觉:看似"推理慢",实际是线程池自旋;看似"该上 GPU",实际是 Python 版本锁死了 CUDA 工具链。多测几组对照。
- 给校对留后路:每页留页码注释、合并单元格用记号表达、保留 regs2md 式的干净提取作为对照——转换工具的输出永远需要人眼过一遍,让"过一遍"容易一点。
附:工具清单——PyMuPDF 1.28 + pymupdf_layout(ONNX 布局模型)+ 自写 ~800 行管线脚本,全部在 Python 3.14 / Windows 上开箱即用。