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