PDF 是一种面向打印的版式格式,它记录的是每个字符在页面上的绝对坐标,而不是像 HTML 或 XML 那样的逻辑结构。这决定了 PDF 转 XML 天然不是一对一的映射,转换过程中必须重建文档的逻辑结构。很多失败的转换案例都源于对这一点的忽视:要么文本顺序完全错乱,要么表格变成一堆散落的文字,要么中文字符直接变成了乱码。这篇文章把转换过程中最容易踩的坑逐一拆解,并给出对应的解决方案。

为什么 PDF 转 XML 容易丢格式:先理解 PDF 的存储原理
PDF 文件内部并不存在段落、标题、表格这类逻辑概念,它由一系列绘制指令组成。引擎渲染时会按照内容流的顺序,把每个字符绘制到指定的坐标位置。也就是说,PDF 只知道某个字符在页面上的 x 和 y 坐标,并不知道它属于哪个段落。当我们用工具提取文本时,软件需要根据坐标反推逻辑结构,这一步就是格式丢失的根源。
举个例子,一份双栏排版的论文,左栏末尾和右栏开头的内容在坐标上可能紧挨着,简单的按 y 坐标排序会把两栏内容混在一起。再比如表格,PDF 只存储单元格内文字的坐标,表格线有时是矢量路径,有时干脆没有,想完整还原行列关系必须做额外的分析。理解了这一点就明白,选对工具和策略远比简单调用一个函数重要。
另一个常见问题是字体编码。部分 PDF 使用了自定义字体编码或没有正确嵌入 ToUnicode 表,导致提取出来的文本是乱码。这种情况在扫描件或第三方工具生成的 PDF 中尤其常见,遇到时基本只能走 OCR 路线,纯文本提取无法解决。
主流转换工具对比与选择建议
目前可用的工具大致分三类:Java 系的 PDFBox 和 iText、Python 系的 pdfminer.six 和 PyMuPDF,以及命令行工具 pdftotext。它们的能力差异主要体现在布局分析、坐标输出和 XML 结构化程度上。
pdfminer.six 的特点是提供精细的布局分析参数,可以按坐标模式(LTTextLineHorizontal、LTTextBox 等)输出文本块,还能拿到每个字符的精确位置,非常适合需要自己控制 XML 结构的场景。PyMuPDF 性能极好,提取速度快,内置的 get_text("xml") 甚至能直接输出带坐标信息的 XML,缺点是底层库 AGPL 授权,商用需要注意许可问题。PDFBox 是 Apache 基金会项目,授权宽松,功能均衡,适合嵌入 Java 服务端流程。iText 功能最强但同样有 AGPL 授权限制。
一个实用的选择思路:如果只是简单地把文本抽出来塞进自定义 XML,pdfminer.six 或 PyMuPDF 足够;如果需要保留坐标信息用于后续版面分析,PyMuPDF 的 XML 输出直接可用;如果要处理海量文档并部署在商业系统里,PDFBox 是最稳妥的选择。盲目追求功能最强的工具,反而可能被授权问题拖累。
保留格式的关键策略:结构化输出设计
工具只负责提供原料,XML 的结构设计决定了最终格式的保留程度。推荐的做法是不要只输出纯文本,而是把页面、文本块、字体、字号、坐标这些信息一并写入 XML。后续无论是还原样式还是做数据分析,都有据可依。下面是一个用 pdfminer.six 实现的完整示例:
from pdfminer.high_level import extract_pages
from pdfminer.layout import LTTextContainer, LTTextLine, LTChar
import xml.etree.ElementTree as ET
def pdf_to_xml(pdf_path, xml_path):
# 定义 XML 根节点
root = ET.Element("document")
root.set("source", pdf_path)
for page_num, page_layout in enumerate(extract_pages(pdf_path), start=1):
page_el = ET.SubElement(root, "page", number=str(page_num))
for element in page_layout:
if not isinstance(element, LTTextContainer):
continue
block_el = ET.SubElement(page_el, "text-block",
x0=("%.1f" % element.x0),
y0=("%.1f" % element.y0),
x1=("%.1f" % element.x1),
y1=("%.1f" % element.y1))
for line in element:
if not isinstance(line, LTTextLine):
continue
line_el = ET.SubElement(block_el, "line")
# 记录该行最大字号,作为标题判断依据之一
max_size = 0
for char in line:
if isinstance(char, LTChar) and char.size > max_size:
max_size = char.size
line_el.set("font-size", ("%.1f" % max_size))
line_el.text = line.get_text().strip()
tree = ET.ElementTree(root)
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
pdf_to_xml("input.pdf", "output.xml")
这段代码把每一页拆成文本块,文本块再拆成行,每行记录了坐标和字号。有了字号信息,判断标题就变得简单:通常正文行字号集中在某个值,明显偏大的行可以标记为标题。坐标信息则可以用来重建双栏阅读顺序——按 x 坐标先把页面分栏,再在各栏内部按 y 坐标排序,两栏内容就不会混在一起了。
表格处理是另一个重点。pdfminer 没有内置表格识别,如果文档中表格较多,建议引入 camelot 或 pdfplumber 做表格抽取,再把结果合并进 XML。pdfplumber 的 extract_tables 方法能返回行列结构的二维列表,直接映射成 XML 的嵌套节点即可。两种方案混用时注意统一坐标系,pdfminer 的原点在左下角,pdfplumber 也在左下角,但有些工具是左上角原点,做坐标运算前务必确认。
常见问题的针对性处理
中文乱码是提问频率最高的问题。如果 PDF 正确嵌入了字体和 ToUnicode 表,pdfminer 和 PyMuPDF 都能正常提取;如果提取结果是乱码,先尝试换一个工具交叉验证,两个工具都乱码基本说明字体表缺失,此时只能 OCR。PyMuPDF 集成了 OCR 调用接口,配合 Tesseract 可以直接在提取流程中兜底。
空格丢失或多余也是高频问题。PDF 中空格有时不是字符而是字符间距,导致提取结果单词粘连。解决思路是根据字符间距离动态补空格:相邻字符的 x 坐标差明显大于字符宽度时插入空格。pdfminer 的 LAParams 提供了 char_margin、word_margin 等参数,调优这几个值通常能明显改善断词效果,具体最优值因文档而异,建议拿几页样本反复测试。
最后提醒一点:转换结果的校验不可省略。建议用抽取出的文本与 PDF 阅读器中的原文做抽样比对,重点检查数字、日期这类关键数据,因为坐标偏移导致的字符丢失往往就藏在这些细节里。自动化流程中可以加入字符数对账的校验步骤,提取的字符总量与页面对象数量偏离过大时触发告警,能省掉不少人工排查的功夫。