技术文章

PDFlibPas:在 Delphi 中把 PDF 转为 Markdown 和 DOCX

PDFlibPas 无需 Office 自动化即可把 PDF 内容转换为两种可编辑格式。ExportPageMarkdownExportDocumentMarkdown 返回带有推断标题、有序/无序列表和管道表格的语义化 Markdown,而 SaveDOCXToFileSaveDOCXToStream 会写出一个 WordprocessingML 包,其中包含段落、标题、原生列表编号、检测到的表格、字体样式、分页符和定位好的 PNG 图片

这两条路径都完全用 Pascal 实现,可以在服务器上运行,不需要安装 Word,也不需要 COM。正是这个约束条件,让这项功能出现在了一个 PDF 库里,而不是某个桌面工具里

为什么"PDF 转 Word"真的很难?

因为一个 PDF 页面里根本不包含"段落"。它包含的是把一串串字形按坐标放置的文本显示操作符,顺序完全取决于生成端当初写出的顺序,没有任何义务表明两段文字属于同一个句子,更不用说属于同一个列表项。这种格式的设计目标是精确描述一张打印出来的页面,而它做到这一点的方式,恰恰是丢掉了产生这张页面的结构信息

因此每一个转换器都必须重新构建出生成端当初丢弃的信息。行的分组来自垂直间距和基线对齐。段落边界来自间距变化和缩进。标题是一行字体比正文更大或更粗、并且与后续内容明显分开的文本。列表是一连串以项目符号或数字模式开头的段落。表格是一组文本块构成的网格,其边缘在行与列之间对齐。以上每一项都是推断,而推断意味着:在遵循常规排版惯例的文档上效果不错,在不遵循的文档上效果就只能说一般

带标签的 PDF 是例外,而且是很重要的例外。当文档携带一棵结构树时,段落、标题、列表和表格的角色是被记录下来的,而不是被猜出来的,这也是为什么带标签 PDF 无障碍结构一文中的工作,同样能提升转换质量的原因。如果你能控制生成端,为你的输出打标签,是你能为日后需要转换这份文档的任何人做的、性价比最高的一件事

Markdown 导出,一次一页

当目标是一条文本管道——文档站点、搜索索引、给助手用的检索语料库——的时候,应该选择 Markdown 这条路径。选项是一个位掩码:PDF_MARKDOWN_INCLUDE_PAGE_MARKERSPDF_MARKDOWN_DETECT_HEADINGSPDF_MARKDOWN_PRESERVE_STYLESPDF_MARKDOWN_DEFAULT 则把三者组合在一起

var
  Pdf: TPDFlib;
  Md: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.LoadFromFile('handbook.pdf', '');

    // 单页,以字符串形式返回
    Md := Pdf.ExportPageMarkdown(1, PDF_MARKDOWN_DEFAULT);

    // 一段页面范围,以不带 BOM 的 UTF-8 流式写入磁盘
    Pdf.SaveMarkdownToFile('1-40',
      PDF_MARKDOWN_DETECT_HEADINGS or PDF_MARKDOWN_PRESERVE_STYLES,
      'handbook.md');
  finally
    Pdf.Free;
  end;
end;

页面标记在检索场景里物有所值。一段携带着来源页码的文本块可以被精确引用,跟随引用跳转的读者会恰好落在论断的出处。当 Markdown 是给人阅读的时候,就应该关掉页面标记,因为源版式带来的页面边界在这种场景下只是噪音

流式入口对大文档来说很重要。SaveMarkdownToStreamSaveMarkdownToFile 会一次一页地写出 UTF-8,不会缓冲完整输出,因此一本 900 页的手册不会先在内存里变成一个 900 页长的字符串。不带字节顺序标记(BOM)也是刻意为之:Markdown 文件里的 BOM 会让相当多的静态站点生成器和 diff 工具犯糊涂

机器上没有 Office 也能生成 DOCX

DOCX 写入器自己生成整个包:以原始 Deflate 加 CRC 校验写出的 ZIP 条目、WordprocessingML 各部分,以及把它们绑定在一起的关系文件。整个过程不会调用 Word,也就意味着转换可以运行在无头服务器上、服务账户里、容器里——凡是 Office 自动化未获授权、不稳定或者被禁止的地方,它都能跑

var
  Pdf: TPDFlib;
  Target: TFileStream;
begin
  Pdf := TPDFlib.Create;
  Target := TFileStream.Create('handbook.docx', fmCreate);
  try
    Pdf.LoadFromFile('handbook.pdf', '');
    Pdf.SaveDOCXToStream('1-40',
      PDF_DOCX_INCLUDE_IMAGES or PDF_DOCX_DETECT_HEADINGS or
      PDF_DOCX_PRESERVE_STYLES or PDF_DOCX_PRESERVE_PAGE_BREAKS,
      Target);
  finally
    Target.Free;
    Pdf.Free;
  end;
end;

图片数据是随每一页的处理过程写出的,而不是先收集起来再在最后统一追加,因此内存峰值只对应一页,而不是整份文档。显式的页面顺序会被保留,导出完成后,之前选中的 PDF 页面会被恢复,这一点在导出只是一个更长任务中的一步、而该任务出于其他原因已经选中了某一页时,就很重要

确定性打包能带来什么好处?

逐字节可重现性。相同的输入、相同的选项,两次转换会产出完全相同的包,这意味着你可以对输出做哈希来检测变化,对同一份生成文档的两次构建做 diff,也可以放心地大胆缓存,不用担心相同的输入产出了不同的制品

Office 自动化做不到这一点。它会内嵌时间戳、版本标识符和依赖具体机器的元数据,因此同一份文档转换两次,得到的结果会在一些细节上不同,足以让哈希失去意义。这也是面向可重现构建的确定性 PDF ID一文中所讨论的确定性文件标识符背后的同一个道理:当输出是可重现的,校验就从一次人工检查变成了一次简单比较

输出在哪些情况下好,在哪些情况下不好

应该对用户坦诚这一点,因为转换质量更多取决于输入本身,而不是转换器。带标签的 PDF,以及生成得干净整洁的商业文档——发票、报告、合同——转换效果很好:标题落地为标题,表格能保留下来,列表在 Word 里能正确重新编号。双栏的学术版式,只要列的几何结构规整,转换效果也算过得去。跨页的表格是靠推断重新拼接的,有时会被拆开。经过大量视觉设计的营销材料,文字是按视觉效果而不是阅读顺序摆放的,转换效果会很差,再多推断也弥补不了

扫描文档完全是另一回事。一个整页就是一张大图片的页面不包含任何文本对象,在文本层出现之前根本没有东西可以导出;生成这一文本层的 OCR 路径是前置条件,不是一个可选项。在跑一个大批量任务之前,先抽样十几份有代表性的文件看看输出结果,也可以考虑先按文本搜索与页面元素枚举一文中描述的方式枚举页面元素,看看这些页面里实际包含什么内容

对于助手和检索管道来说,Markdown 通常是更好的目标格式:标题成为分块边界,表格以管道表格的形式保持可读,页面标记为每个分块提供一个可引用的位置。对于人工编辑,答案是 DOCX,因为用户想要的不是文字本身,而是修改文字的能力

PDFlibPas 是一个面向 Delphi、C++Builder 和 Lazarus 的 PDF 库,配套提供 DLL 和 ActiveX 接口,因此同样的导出调用也能从 C#、C++ 或脚本宿主中使用。完整文档和试用版见 PDFlibPas Delphi PDF 库页面