跳到主要内容

PDF → Markdown 引擎,开源

PDF 转 Markdown 那一页上的转换器,不是一个恰好免费的在线服务。它是一个跑在你浏览器里的 TypeScript 库,以 @mindmax/pdf-markdown 发布,Apache-2.0 许可;你在 MindMax 里打开 PDF 时跑的也是同一份代码。这一页说它做什么、明确不做什么,以及靠什么防止自己悄悄变差。

包@mindmax/pdf-markdown v0.1.0
源码github.com/woodpeng/mindmax-pdf-markdown
许可Apache-2.0,覆盖引擎而非 MindMax 应用,边界见开源总页
运行时依赖0 个
体积gzip 25.1 KB
运行环境Node 18+ 与任意浏览器;不用 fs,不用 window
守着它的22 份文档、170 个测试、97.3% 语句覆盖

它重建什么​

从 PDF 里直接复制文字,拿到的是生产方写进文件的顺序,而不是人读的顺序。这个库反过来做:拿 pdf.js 报出来的文字片段和它们的位置,把版面当成结构的来源。

  • 阅读顺序。 双栏论文先读左栏。跨页断开的段落仍然是一段,为分栏而断开的段落会接回去。
  • 标题。 级别来自字号、字重和编号(3.1、第X章、Chapter 4);PDF 自带书签时用书签校正,没有书签时用 Tagged PDF 的结构树。
  • 表格。 从文字落在哪还原行列——列间距、对齐、重复的列起点——写成 Markdown 表格,而不是被绞成一行行碎句。
  • 列表和链接。 项目符号变成 -,编号列表保持编号;PDF 里某个词指向的链接会还原。PDF 没存链接标注的地方,不会凭空造一个。
  • 页眉页脚。 跑动页眉、页脚、页码、水印都去掉。Tagged PDF 会把它们标成 /Artifact,优先用这个;绝大多数 PDF 没有标记,退回几何判断。

出来的那串文字同时也是所有锚点的落点。buildPdfTextMap 记录了每一页从哪里开始、每个文字片段落在哪里,所以读者在 PDF 里高亮一句话,能按偏移量拿回同一句话——不是再搜一遍文字然后祈祷它只出现一次。

它明确不做​

不做为什么
识别扫描件扫描件没有文字层。叫它 OCR 的每一步都会是模型调用、账单和另一个产品。这个库的做法是直接告诉你这页是空的。
还原公式PDF 存的是定位好的字形,不是公式。x² 到手里就是 x2。库会检出哪些页发生了这件事、共有几页,而不是假装不上模型也能拿回 LaTeX。
带出图片插图留在 PDF 里。抽图是另一件事,失败方式也完全不同。
上传任何东西包里没有任何网络调用。在浏览器里跑,文件不会离开这个标签页。

这四件恰好是问得最多的四件,所以四条都正面写出来。一个声称做到其中两件、实际两件都没做到的转换器,比一个说清自己停在哪里的转换器更糟。

一页纸怎么变成 Markdown​

步骤发生什么
1. 采集每页一次 pdf.js 调用,取文字片段、变换矩阵,以及 Tagged PDF 上的 /Artifact 标记。
2. 去家具先把 Artifact 标记的片段抹空,再按跨页重复去掉页眉、页脚、页码。片段序号保持不变,偏移量才有效。
3. 重排成行按基线把字形聚成行,按间距和缩进把行聚成段。断行处的连字符词接回去。
4. 排出页内顺序检出分栏的列间距,逐栏读;通栏的块把页面切成上下段。
5. 判定块类型字号和编号判标题,符号判列表,几何判表格,字号和分隔线判脚注。
6. 输出Markdown,以及"哪些字符来自 PDF、哪些是拼装时写的"这份映射。这一步不做任何"顺手排得好看点"的重排,因为那会挪动每一个偏移量。

两种调用方式​

多数调用方要的是一篇文档;也有人要的是结构化的正文和映射。这两种需求不该合进一个两边都做不好的 API。

import { convertPdfToMarkdown } from '@mindmax/pdf-markdown';

// 手上已经有一个打开好的 pdf.js 文档
const result = convertPdfToMarkdown(pdfDocument);

if (result.ok) {
result.markdown; // .md 文件内容
result.links; // 还原了多少个链接
result.textLayerIssues; // 有公式或未映射字形的页,没有则为 null
} else {
result.reason; // 'scanned' | 'empty'
}
import { buildPdfTextMap } from '@mindmax/pdf-markdown';

// 要的是正文本身,不是文件
const map = buildPdfTextMap(pagesOfTextItems);
map.text; // 所有锚点都指向这串 Markdown 形状的正文
map.pages; // 每页:从哪开始,以及每个片段在其中的区间

CANONICAL_TEXT_VERSION 存在的理由和这些偏移量一样:你把偏移量存下来,就得知道哪次升级会挪动它们。它只在同样输入下 map.text 真的变了时才跳版本——Markdown 渲染可以改而不跳它。

上面两段都没有 import pdf.js。Pdf*Like 都是结构化类型,浏览器构建、Node legacy 构建、测试里的假对象都能满足,所以这个包不会往你的 bundle 里塞第二份 pdf.js。

为什么语料是重点​

仓库里带着 22 份真实文档,以及每一份转出来的 Markdown 原文。其中 16 份 CC-BY、5 份美国联邦政府作品、1 份中国官方文件——每一份都可以再分发,所以快照能放在仓库里,测试在哪都能跑。

一份 PDF 转坏了,修法不是"调一下启发式再碰运气"。这份文档连同一个描述错在哪的失败断言一起进语料,测试变红,改代码直到变绿,用例永久留下。整个贡献模型就这一条,也是为什么一份附了文档的 bug 报告,比一段很长的文字描述有用得多。

逐文档结果表就是这份语料加上数字:每份文档转出来什么、被多少条断言检查、还有哪些缺陷没修。它由测试跑出来的 JSON 直接渲染,没有一个手打、会过期的数。

谁在维护​

引擎由 MindMax 团队公开维护,而且 MindMax 自己依赖的就是发布到 npm 的这个包,不是私有副本——应用要修的问题,先在这里发版。许可覆盖这个引擎,不覆盖外面的应用;这条线以及团队在安全、治理、连续性上的承诺,统一写在开源总页。

试用与贡献​

看输出最快的方式就是这个转换器本身:它在你的浏览器里跑这个库,不用装、不用注册。只想要一个"丢进 PDF、吐出 Markdown"、周围什么都没有的版本,用 playground,源码在仓库的 examples/browser 目录。

两条贡献规则比其余都重要。改动会让语料里某份文档的输出变化时,得在 diff 里写清理由;修好一份文档不能弄坏另一份——CI 在每个 PR 上跑全部文档。其余的在 CONTRIBUTING.md。

只想报告一份转坏的文档,用语料用例模板,它只问一件让问题可修的事:这份文档,或者你有权分享的其中一页。

引用本页