前阵子整理资料库,翻出一堆PDF格式的论文、合同和技术文档,想转成Markdown整理成自己的知识库,结果发现市面上能打的转换工具真没几个。在线网站要么限制页数,要么转完表格全变成乱码;桌面软件导出的Markdown经常把标题层级直接拍平;好不容易找到个开源库,图片又莫名其妙丢一半。折腾了一个多星期,我干脆自己写了一套转换工具,系列命名为pdf2md。这是第一篇开篇,重点讲整体设计思路、技术选型,以及标题、表格、图片这三类元素的高保真还原源码,目标就一个:把“只能在阅读器里看的PDF”变成“能在编辑器里随便改的Markdown”。
说实话,PDF转Markdown这件事,难的不是读文字,而是“还原结构”。PDF本身是一种固定版面的呈现格式,它不像docx那样在文件里存好了“这个段落是标题、那是一张表格”这样的语义信息,所有元素渲染出来就是一坨坐标加图形指令。所以要从PDF里提取有层次的Markdown,本质上是在做逆向工程。这篇博文适合三类人看:一是经常处理PDF文档、想批量转Markdown做知识库的,二是想在Python生态里自己搭一套文档解析管道的开发者,三是对文档结构化处理感兴趣、想深入理解PDF内部机制的学习者。
1. 为什么要自己写一套PDF转Markdown方案
1.1 现有工具的真实体验
我一开始没打算自己写,先老老实实把市面上已知的路径都试了一遍。第一类是纯在线转换网站,把文件传上去,点个按钮,下载结果。这方案对单文件、小文件还能凑合,但文档稍微带个大表格、复杂排版,转出来的Markdown就完全不能用。而且有些文档涉及隐私,我实在不想为了转个PDF把内部资料传到别人服务器上。第二类是桌面编辑器的另存为功能,比如用WPS或Adobe Acrobat先把PDF转成docx,再用Pandoc把docx转成Markdown,两条流水线叠一起,误差叠加得很厉害,标题的层级、表格的宽度都会失真。第三类是直接用Python现成库去读,这已经接近底层了,但pdfplumber这类库擅长抽文本抽表格,却不擅长判断“哪段文字是标题”;PyMuPDF能拿到非常精细的坐标、字号、字体信息,又没帮你做结构判断。真正的痛点在于,没有一个库能把“版面结构”这件事打包处理好。
为了直观对比,我把几类方案的主要体验列成了表格:
| 方案 | 文字抽取 | 标题层级 | 表格还原 | 图片提取 | 批量处理 | 隐私安全 |
|---|---|---|---|---|---|---|
| 在线转换网站 | 较好 | 不稳定 | 差,表格常错位 | 部分丢失 | 受限制 | 不放心 |
| 桌面编辑器转docx再转Markdown | 好 | 一般 | 一般 | 好 | 差,依赖GUI | 本地可靠 |
| pdfplumber等纯文本库 | 好 | 无 | 较好,需要配置 | 不支持 | 好 | 本地可靠 |
| PyMuPDF | 好,含样式信息 | 无,需要自建规则 | 不擅长 | 支持好 | 好 | 本地可靠 |
| 深度学习识别方案 | 非常好 | 好 | 好 | 好 | 中 | 依赖大模型环境 |
这个表格看下来,结论很清晰:没有现成方案能一步到位,但Python生态里其实已经给了足够的“积木”。我需要做的是把PyMuPDF的样式提取能力和pdfplumber的表格识别能力组合起来,再用一套规则去判断标题层级。这才是写pdf2md最根本的动机——把零散的工具按自己的需求拼成一条完整的转化流水线。
1.2 PDF转Markdown到底难在哪
很多人以为PDF转Markdown就是把里面的文字“读出来”再拼成文本,实际上远不止这么简单。PDF文件里存的文本,在绝大多数情况下是一堆“绘制指令”,每条指令告诉渲染器:在页面坐标的某个位置,用一种字体和字号,画出一段字符。它不区分正文、标题、页眉、页脚,也不告诉你哪些线条和文字组合起来是一张表格。换句话说,PDF就像一个只保留渲染结果的快照,原始文档的语义结构已经被丢弃了。
这带来三个层面的还原难度。第一是标题层级问题,Markdown里用#、##、###来表达文档结构,但PDF里只有“字号大小”和“是否加粗”这些外观特征,需要我们从版式信息里去反推哪个是一级标题、哪个是二级标题。第二是表格问题,PDF里的表格可能是由线条画出来的,也可能是用空白间距对齐的“隐式表格”,更复杂的情况是单元格里有多行文字、合并单元格,这些都需要识别和重建。第三是图片问题,PDF里的图片不一定按文档顺序存储,可能在文件末尾统一存放,需要通过图像的坐标信息判断它原来出现在页面的哪个位置,再在Markdown里正确地引用进来。
这些问题单独看都不算无解,但组合在一起,就要求转换器必须同时具备三个能力:位置解析、样式解析、语义推断。位置解析负责知道每个元素在页面哪个坐标,样式解析负责知道字号、字体、加粗、颜色等信息,语义推断则是基于前面两个信息,“猜”出这个元素在Markdown里应该是什么角色。这也是我把pdf2md做成规则引擎而非简单调用库函数的原因,只有在规则层面把三者打通,才能做到真正意义上的高保真。
1.3 技术栈选型:为什么锁定PyMuPDF加pdfplumber
技术选型时我主要考虑了三个Python库,分别是pdfplumber、PyMuPDF和pdfminer.six,三者各有侧重。pdfminer.six是最底层的解析库,能拿到非常细的文本数据,但API设计偏旧,输出格式也比较啰嗦,用起来效率不高。pdfplumber在pdfminer之上做了封装,提供了提取表格的专用接口,这个功能是所有库中最突出的,但它本身对样式信息的暴露不够直接,想拿到“某段文字的字号和加粗状态”要绕一些弯路。PyMuPDF是我用得最顺手的库,它用fitz这个模块名,可以直接读取文本的span信息,这里面包含bbox坐标、size字号、font字体名、flags标志位,样式识别非常方便。
我的最终选型是PyMuPDF加pdfplumber双引擎。PyMuPDF负责读样式、判断标题、提取图片和绘制整体文字流,pdfplumber专门负责表格区域识别和表格结构提取。有人可能问,为什么不用PyMuPDF直接提取表格?坦白说,PyMuPDF在处理简单文本场景很强,但面对表格线条、单元格边界这些需要大量几何计算的任务,它的抽象层次不够高。pdfplumber的extract_table接口则把“检测网格线”“合并列宽”“按坐标聚合成行”这些脏活累活都做完了,我只要把返回的二维数组转成Markdown的管道语法就行。
这里也解答一个常见疑问:为什么不用marker-pdf这类基于深度学习的转换模型?这类模型确实在某些数据集上效果惊艳,尤其是对复杂版面的理解能力远超规则引擎。但它的模型体积大,通常需要GPU环境或较长的离线推理时间,而且对中文文档的支持不一定稳定。我自己做pdf2md的目的是轻量、透明、可控,规则引擎虽然不能覆盖所有极端版面,但胜在快、可解释、可自定义,遇到特定排版还能随时调参数。在批量处理大量常规文档时,规则引擎反而是更务实的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与核心模块拆解
2.1 项目目录结构与模块划分
动手前我先把项目结构理清楚了。虽然第一篇开篇不需要做一个巨大的工程,但好的目录结构能让后续迭代省很多力气。我的pdf2md目录大致如下:
text复制pdf2md/
├── pdf2md.py # 主程序入口,封装转换流程
├── requirements.txt # 依赖清单
├── sample/ # 测试样例PDF
│ └── 演示文档.pdf
└── output/ # 转换输出目录
├── 演示文档.md
└── images/ # 提取出的图片统一放这里
核心逻辑全部放在pdf2md.py里,按函数拆分。主流程是:打开PDF文件,逐页处理,每一页都执行“抽取文本span、判断标题、抽取表格、提取图片”四个动作,最后按坐标顺序把结果拼成Markdown内容。之所以全部先用内存dict保存再统一输出,而不是边读边写文件,是为了避免图片和文本交叉出现时文件写入顺序混乱。
程序入口设计也很简单,支持命令行参数,把PDF路径传进去就能指定输出目录。后续如果要扩展成批量处理整个文件夹,只需在外面套一层循环遍历即可,不需要改动核心转换逻辑。这种分层方式让代码保持简洁,也方便把转换函数做成模块被其他项目复用。
2.2 标题识别与层级还原的幕后逻辑
标题识别是我最先攻克的部分,也是最依赖规则引擎的部分。PyMuPDF能返回每个文本span的字体大小、字体名称、加粗标志、坐标位置,这些都是判断标题的原始依据。我在实现时先统计整页所有span的字号分布,找出出现频率最高的字号作为正文基准字号。然后遍历每个span,检查三个特征是否满足标题条件:字号是否明显大于基准字号、字体是否加粗、文本是否居中或位于页面特定位置。
标题层级的映射规则我用了一个简单的公式。设正文基础字号为body_size,某段文字的字号为current_size,如果current_size大于等于body_size的1.5倍,通常对应一级标题;在1.3到1.5倍之间,对应二级标题;在1.2到1.3倍之间,对应三级标题。加粗的字号即使没有超过倍数阈值,也可以提升一级,因为很多文档的二级标题字号和正文差距并不大,主要靠加粗来区分。居中位置则能辅助识别,比如封面页的大标题通常居中,这种可以单独提升为一级标题。
这个规则有一个好处:完全基于文档自身的特征,不依赖固定字号绝对值。因为不同文档的字号基准差异很大,有的正文是10.5磅,有的正文是12磅,如果写死“超过20磅就是标题”,大概率会误判。通过动态计算正文基准字号,再按比例判断标题,能适配大多数常规排版文档。当然规则也不是万能的,后面我会在常见问题小节讲遇到极端版式时怎么排查和调整。
2.3 表格识别:从坐标网格到Markdown管道
表格是PDF转Markdown里最令人头疼的部分,好在pdfplumber帮了大忙。它的extract_table方法会通过分析页面上的横线和竖线,把所有网格交点计算出来,然后把每个单元格里的文本按行列关系填入一个二维数组。整个过程相当于把PDF里“画出来”的表格结构,翻译成程序能直接处理的行列数据模型。
拿到二维数组之后,转成Markdown表格并不难,难在两点。第一是合并单元格的处理,pdfplumber在遇到合并单元格时返回的列表里会出现None,因为那个位置并没有独立的单元格文本。我的处理策略是保留None并向上填充,让左对齐的语义尽量贴近视觉呈现。第二是表格区域与非表格区域的区分,pdfplumber的extract_table不一定每次都能命中,必须配合页面中的线条图元数量来判断这里是否存在真实表格。
对于没有线条的“隐式表格”,比如很多PPT转成的PDF靠多列对齐形成表格视觉,这种是extract_table的盲区。我目前的方案是通过文本列的x坐标聚类来推断列边界,如果检测到多个文本块在垂直方向重复对齐,就在这些文本块的间隙位置插入表格列分隔符。这个逻辑比网格线识别复杂一些,准确率也没有前者高,但对于大多数有边框的正式文档已经够用。后续版本我会考虑加入更多启发式规则来优化这个场景。
2.4 图片与链接的提取及引用策略
PDF里的图片处理有两个核心问题:一是怎么把图片数据从文件里抽出来,二是怎么确定图片在Markdown中的插入位置。第一个问题我用PyMuPDF的get_images和extract_image接口解决。每个页面都会维护一个图片列表,图片会关联一个xref编号,这相当于图片在PDF内部的唯一ID。通过xref调用extract_image,就能拿到图片的二进制数据和格式信息,再写到本地images目录下。
第二个问题稍微隐蔽。PDF的图片对象虽然属于某个页面,但不代表图片一定显示在页面固定位置。正确做法是遍历页面里的图像对象,读取它的显示矩阵和包围盒,计算出图片在页面上的实际坐标。我会把这个坐标和同一页文本块的坐标放在一起排序,这样图片就能插入到它对应的正文段落附近。如果图片在页面的上部区域,通常优先插到该页开头位置;如果图片在表格附近,则插到表格块之后。
引用路径方面我默认生成相对路径,比如
,这样整个输出目录可以整体移动或上传到Git仓库,不会因为绝对路径问题导致图片失效。代码里我还留了一个参数,可以选择把图片转成base64直接内嵌进Markdown文件。这样生成的md是单文件的,方便用邮件或聊天工具发送,代价是文件体积膨胀三分之一左右。两种模式我在实践中都会用到,看场景选择。
3. 源码实现与实操过程
3.1 环境准备与依赖安装
在给出源码之前,先把环境说明白。我的开发环境是Python 3.10,依赖库版本不是特别敏感,但建议至少用以下版本以上,避免一些旧版本API不兼容的问题。安装命令很简单:
bash复制pip install --upgrade pymupdf pdfplumber
如果网络环境比较慢,可以加上国内镜像源加速。安装完成后,可以用下面这两行命令快速验证依赖是否可用:
bash复制python -c "import fitz; print(fitz.__doc__)"
python -c "import pdfplumber; print(pdfplumber.__version__)"
PyMuPDF的导入名是fitz,这是历史遗留的命名,很多人第一次装完在import pymupdf时报错,就是因为这个原因。两个库版本我建议锁定在PyMuPDF 1.24.x和pdfplumber 0.11.x附近,这两个版本我实测兼容性最好。
3.2 主程序源码与关键函数解析
下面给出核心源码,我做了删减和注释,保留了最关键的转换链路。完整代码逻辑不复杂,重点看三个函数:extract_spans负责抽样式,convert_table负责抽表格,extract_images负责抽图片。主流程里通过坐标合并它们。
python复制import os
import re
from collections import Counter
import fitz # PyMuPDF
import pdfplumber
def extract_spans(page):
"""从PyMuPDF页面对象中提取所有文本span,包含坐标、字号、字体、加粗信息"""
spans = []
data = page.get_text("dict")
for block in data["blocks"]:
if block.get("type") != 0: # 0表示文本块,1表示图片块
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if not text:
continue
spans.append({
"text": text,
"size": round(span["size"], 1),
"font": span["font"],
"flags": span["flags"],
"bbox": span["bbox"],
})
return spans
def is_bold(span):
"""判断span是否加粗,优先看字体名,再看flags标志位"""
if "bold" in span["font"].lower():
return True
return bool(span["flags"] & 2 ** 4)
def detect_heading_level(span, body_size, page_width):
"""根据字号、加粗、居中判断标题层级,返回0表示正文,1/2/3表示标题层级"""
size = span["size"]
x0, y0, x1, y1 = span["bbox"]
width = x1 - x0
center_offset = abs((x0 + x1) / 2 - page_width / 2)
ratio = size / body_size
if ratio >= 1.5 or (ratio >= 1.2 and is_bold(span)):
return 1
elif ratio >= 1.3:
return 1
elif ratio >= 1.2:
return 2
elif is_bold(span) and center_offset < page_width * 0.05:
return 2
return 0
def normalize_text(text):
"""清理文本中的空白字符,避免干扰Markdown结构"""
return re.sub(r"\s+", " ", text).strip()
def extract_table_markdown(pdf_path, page_index, page_bbox):
"""使用pdfplumber提取表格并转换成Markdown表格语法"""
lines = []
with pdfplumber.open(pdf_path) as pdf:
page = pdf.pages[page_index]
tables = page.extract_tables()
for table in tables:
if not table:
continue
# 这里可以按表格在页面上的位置排序,简化处理直接全部输出
for row_idx, row in enumerate(table):
# 将None替换为空字符串,并清理内容
clean_row = ["" if cell is None else normalize_text(cell) for cell in row]
lines.append("| " + " | ".join(clean_row) + " |")
if row_idx == 0:
lines.append("|" + "---|" * len(row))
return "\n".join(lines)
def extract_images(page, doc, output_dir, page_index):
"""提取当前页内的图片,返回Markdown图片引用列表"""
images_md = []
image_info_list = page.get_images(full=True)
for img_info in image_info_list:
xref = img_info[0]
# 根据xref去重,避免同一张图被多次提取
if xref in extract_images.seen:
continue
extract_images.seen.add(xref)
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
ext = base_image["ext"]
img_filename = f"img_{page_index:03d}_{xref}.{ext}"
img_path = os.path.join(output_dir, "images", img_filename)
with open(img_path, "wb") as f:
f.write(image_bytes)
images_md.append(f"")
return images_md
extract_images.seen = set()
def convert_pdf_to_markdown(pdf_path, output_dir="output"):
"""主流程:逐页解析文本、标题、表格、图片,并拼接为Markdown"""
os.makedirs(output_dir, exist_ok=True)
os.makedirs(os.path.join(output_dir, "images"), exist_ok=True)
md_filename = os.path.splitext(os.path.basename(pdf_path))[0] + ".md"
md_path = os.path.join(output_dir, md_filename)
md_lines = []
doc = fitz.open(pdf_path)
for page_index, page in enumerate(doc):
page_width = page.rect.width
spans = extract_spans(page)
if not spans:
continue
# 动态估算正文基础字号:取出现频率最高的字号
size_counter = Counter([span["size"] for span in spans])
body_size = size_counter.most_common(1)[0][0]
# 按y坐标分组,再按x坐标排序,尽量还原阅读顺序
sorted_spans = sorted(spans, key=lambda s: (round(s["bbox"][1] / 5), s["bbox"][0]))
# 连续文本拼接,遇到标题层级则换行加入前缀
buffer = []
for span in sorted_spans:
text = normalize_text(span["text"])
level = detect_heading_level(span, body_size, page_width)
if level > 0:
if buffer:
md_lines.append(" ".join(buffer))
buffer = []
md_lines.append("#" * level + " " + text)
else:
buffer.append(text)
if buffer:
md_lines.append(" ".join(buffer))
# 表格放在页面文本之后,简化处理
table_md = extract_table_markdown(pdf_path, page_index, page.rect)
if table_md:
md_lines.append("")
md_lines.append(table_md)
# 图片引用统一附在页面末尾
images_md = extract_images(page, doc, output_dir, page_index)
if images_md:
md_lines.append("")
md_lines.extend(images_md)
md_lines.append("")
with open(md_path, "w", encoding="utf-8") as f:
f.write("\n".join(md_lines))
print(f"转换完成:{md_path}")
return md_path
if __name__ == "__main__":
import sys
pdf_file = sys.argv[1] if len(sys.argv) > 1 else "sample/演示文档.pdf"
convert_pdf_to_markdown(pdf_file)
这段代码去掉注释后大概一百来行,不算长,但已经覆盖了标题识别、表格转换、图片提取三条主线。有个细节需要说明:按y坐标分组时,我用的是round(bbox[1] / 5),意思是把top坐标除以5再取整,相当于把5磅高度内的文本行视为同一行。这样处理能避免因为行间距微小差异导致同一行被拆成多段,也能容忍一定的坐标噪声。如果你处理的PDF行距比较密,可以把这个除以5改成除以3或除以10,具体值取决于实际排版。
3.3 跑通一个样例:效果实测
我拿一份带标题、嵌套表格和插图的产品规格书做了测试。这份PDF共4页,结构不算复杂但很有代表性:第一页有封面大标题,第二页是产品参数总表,第三页有混合排版段落和图片,第四页是注意事项列表。运行命令很简单:
bash复制python pdf2md.py sample/演示文档.pdf
转换生成的Markdown开头部分大致是这样:
markdown复制# 产品规格书
## 产品概述
本文档适用于型号ABC-2000系列设备的安装与调试。
## 技术参数
| 参数名称 | 指标 | 备注 |
|---|---|---|
| 输入电压 | AC 220V ± 10% | 50Hz |
| 额定功率 | 1200W | 峰值功率1800W |
| 通信接口 | RS-485 | 支持Modbus协议 |

对比原PDF,标题层级从#到##的映射基本正确,表格的三列结构完整保留,图片被提取到images目录并正确引用。这里最让我欣慰的是表格没有变成一行行散落的字符串,这是用纯文本抽取方案最常见的翻车场景。当然这个样例比较规整,如果遇到带合并单元格的表格,输出的单元格里会出现为空的情况,这时需要人工检查一下第二行生成的表头分隔线是否对应正确。
3.4 如何调整参数适配自己的PDF
代码里有两个参数最值得关注,直接决定转换质量。第一个是正文基础字号body_size的估算方式。我用了最简单的“取最频字号”策略,但有些PDF里正文和标注的字号分布比较平均,最频值不一定准确。这种情况下,可以先打印出来看看所有字号的分布直方图,再手动指定body_size,或者改用第85百分位这类更稳健的统计量。第二个是标题检测阈值,代码里的1.2、1.3、1.5倍是个经验值,我在处理正规出版的电子书时效果不错,但遇到PPT转PDF时字号跨度本来就很大,这个比例就要适当放宽。
另外还要提醒一点,如果PDF里页眉页脚和正文混在一起,转换出的Markdown开头或结尾会多出重复的页眉页脚文本。我的办法是在extract_spans函数里根据y坐标过滤掉页面顶部和底部约5%区域的内容。具体阈值要看页面留白大小,但这招能省去后期很多清洗工作。如果不确定怎么调,我建议先跑一页看坐标输出,再决定过滤参数。
4. 常见问题与排查技巧实录
4.1 表格识别错乱怎么修
表格错乱是pdf2md使用中最常见的问题,表现形式多种多样:有的表格整体漏掉,有的列数和原PDF对不上,有的单元格里文本跑到其他列。我的排查顺序一般是这样的:先用pdfplumber自带的调试功能,把页面上的网格线画出来,看看表格线条是否被正确识别。pdfplumber的page.debug_tablefinder()可以生成一个表格区域可视化结果,直接确认是“线条检测失败”还是“文本归位失败”。
如果是网格线检测失败,通常是表格线颜色太浅、线宽太小、或者表格是图片形式的整体插入。对于最后一种情况,pdfplumber无能为力,只能靠OCR或者把整张表格当作图片处理。一个相对务实的折中方案是:检测到当前页有大量图片像素但文本稀疏时,把整页转成图片插入Markdown,放弃可编辑性,保住信息完整性。如果表格线颜色浅,可以调整pdfplumber的线条检测阈值,设置vertical_strategy为"lines"或"explicit"并手动传入线条位置。
如果是列和行归位错误,比如多列被合并成一列,这说明文本抽取时的坐标排序出了问题。我习惯先打开PDF的原始文本坐标,对照检查是不是pdfplumber把不同列的文本块当成了同一个单元格。此时可以调整table_settings里的text_x_tolerance和text_y_tolerance参数,适当减小这两个容差值,让相近的文本块被更严格地划分。需要说明的是,这些容差参数没有万能值,必须根据具体文档试错调整。
4.2 图片重复提取、缺失或位置不对
图片重复提取是最容易踩的坑。同一张图片在PDF中可能被多个页面引用,也可能在同一页插入多次,直接用get_images遍历会导致大量重复。我在代码里用xref去重,这能解决90%的重复问题。但如果PDF里同一张图在文件里被定义为两个不同的xref对象,即使视觉上完全一样,也会被当作两张图,这种情况就需要再用图片内容的MD5哈希去重。
图片缺失的情况通常和PDF的图片编码有关。某些PDF用了DCTDecode(也就是JPEG)或JPXDecode(JPEG 2000)编码,PyMuPDF的extract_image在大多数情况下都能处理,但个别特殊编码会抛出异常。我的建议是给extract_image包一层try-except,提取失败时跳过图片,并在日志里输出警告,不要让整个转换流程中断。图片位置不对则是因为图片坐标计算错误,需要回到页面对象里读取图片的显示矩阵,常见的坑是忽略了对PDF坐标系原点位置的处理,PyMuPDF的坐标原点和视觉上的左上角一致,但某些PDF会通过MediaBox或CropBox设置不同的原点,这会导致图片的y坐标偏移。
4.3 中文乱码与字体类问题
中文PDF在转换中偶尔会出现乱码,但说实话,现在的PDF标准如果没有特殊加密,PyMuPDF对中文文本的抽取已经非常成熟了,乱码的出现频率并不高。真出现乱码时,大概率是字体子集没有ToUnicode映射,这意味着PDF里存的字符编码无法正确映射到Unicode码点。这种问题在规则引擎层面很难彻底解决,因为信息本身就已经丢失了。
我的应对方案是前移处理:在转换前先用一行命令检测文本抽取质量,比如打印前100个字符,如果发现大量乱码,就判断这份PDF需要走OCR路线。用OCR的话,可以配合PaddleOCR或Tesseract,把页面渲染成高分辨率图片再做文字识别,这样能保住内容,但会丢失坐标和样式信息,相当于把“高保真转换”降级为“可读文本提取”。这个兜底方案保证了流程不会卡在某个顽固文件上,成本是从PDF读取变成了图像识别,速度会慢不少。
字体相关的另一个问题是:中文文档里加粗很容易被忽略。有些PDF用“伪加粗”方式处理中文标题,也就是通过笔画描边渲染,而不是选择真实的Bold字体。这样的span里字体名正常,flags也没有加粗位,我的is_bold函数判断不出来。这时候我会额外检查一个特征:文本的笔画宽度或字的渲染模式。但代码层面实现起来比较绕,更实用的做法是看x坐标宽度,伪加粗的文本宽度通常会比普通文本宽几个像素,设定一个宽度阈值也能辅助识别。
4.4 超大PDF的性能优化
处理上百页甚至上千页的大PDF时,性能问题就会浮现。瓶颈主要在两个地方:一是PyMuPDF逐页读取文本dict时,页面越复杂耗时越长;二是pdfplumber对每页表格分析时,线条检测算法的计算量不小。我实测过一份300页的扫描文档转PDF,纯文本页还好,但每页都跑一次pdfplumber的extract_tables,总耗时从几秒飙到几分钟。
性能优化的思路有两条。第一条是启用分页跳过策略,对于明显没有表格线的页面,直接用线条检测粗筛,没有线条就跳过extract_tables,只做文本提取。第二条是利用多进程并行处理。PDF每一页的转换相互独立,不存在跨页依赖,所以可以把页面列表分片后交给多个worker并行处理,再把结果按页号合并。在四核机器上,这个优化通常能带来2到3倍的加速。需要注意的是,图片提取涉及写文件,并行时要注意输出目录的并发写入安全,最好每页写到独立的子目录,最后统一合并,或者用进程锁保护写操作。
这里再分享一个我踩过的坑:PyMuPDF在并发场景下,同一个document对象不能同时被多个线程调用,否则会崩溃。解决办法是每个进程单独打开一次PDF文件,虽然会增加一点文件句柄开销,但稳定性好很多。
5. 写在最后:一点个人体会
这个工具从最开始的一团乱码,到现在能稳定处理常规文档,中间改了很多版。我自己最大的体会是:PDF转Markdown这类工具,没有一劳永逸的方案,只能尽量把规则做通用,再给使用者留出调整参数的入口。相比于追逐“100%准确”的神话,我更愿意把它做成一个“大概率准确加可微调”的实用工具。目前pdf2md已经能满足我自己整理技术文档、合同扫描件和论文笔记的需求,每次碰到一个新的疑难PDF,我就顺手把规则补强一点,慢慢就积累成了现在这套逻辑。
如果你也想搭一套类似的工具,我建议不要一上来就追求大而全,先让文本、标题、表格、图片四条链路各自跑通,再去做交叉排序和异常处理。这个系列后面我会接着写几个更深入的专题,比如复杂表格的合并单元格处理、页眉页脚智能识别、以及怎么把输出结果自动导入Obsidian这类笔记软件。先把第一版跑起来,遇到问题我们再一起对着PDF坐标慢慢调。
