PDF 里没有「段落」:我们如何从文字坐标重建段落、标题和列表
做 md-to.com 的 PDF 转 Markdown 之前,我们以为难点在「解析 PDF」。真正做起来才发现,PDF.js 已经把解析做得很好了,难的是解析之后:PDF 里根本没有段落、标题、列表这些概念。
Word 文档记录的是「这是一个二级标题,内容是……」,HTML 记录的是 <h2>、<p>、<li>。而 PDF 记录的是:「用 16 号字,在坐标 (89.8, 751.4) 画出这串字符」。它是一份给打印机看的绘图指令,不是一份给人看的结构化文档。
所以 PDF 转 Markdown 本质上是一个逆向推理问题:只看文字画在哪、画多大,倒推出作者当初写的结构。本文记录我们的推理规则、第一版实现踩过的坑,以及这套方法做不到的地方。
如果你只想知道怎么用这个工具,可以看《如何免费在线将 PDF 转换为 Markdown?》。
PDF.js 到底给了我们什么
在浏览器里读取 PDF,我们用的是 Mozilla 的 PDF.js。调用 page.getTextContent() 后,得到的是一组文字片段(text item)。下面是一份 4 页英文产品说明 PDF 第一页的真实输出,节选并做了简化:
{ "str": "Academic & Creative", "fontName": "g_d0_f1", "transform": [14, 0, 0, 14, 89.8, 188.6] }
{ "str": "", "fontName": "g_d0_f1", "transform": [16, 0, 0, 16, 89.8, 751.4], "hasEOL": true }
{ "str": "MD-to.com PDF Converter — Feature Guide", "transform": [16, 0, 0, 16, 89.8, 751.4] }
{ "str": "MD-to.com turns your Markdown into print-ready PDFs instantly — no plugins, no", "transform": [11, 0, 0, 11, 89.8, 730.9] }
{ "str": "sign-up, no server uploads.", "transform": [11, 0, 0, 11, 89.8, 711.5] }
{ "str": "Download PDF", "transform": [11, 0, 0, 11, 376.7, 689.2] }
{ "str": "Try it now: paste any Markdown on the left and click", "transform": [11, 0, 0, 11, 89.8, 689.2] }
transform 是一个 2D 变换矩阵,最后两位是文字的 x、y 坐标,前四位里包含了字号。仔细看会发现几个问题:
- 顺序不是阅读顺序。 第一个片段「Academic & Creative」的 y 是 188.6,在页面靠下的位置。「Download PDF」和「Try it now…」在同一行(y 都是 689.2),但「Download PDF」在右边,却排在前面。片段的顺序取决于 PDF 生成器写入绘图指令的顺序,跟人眼阅读的顺序没有必然关系。
- y 轴朝上。 PDF 的坐标原点在页面左下角,y 越大越靠上。标题的 y 是 751.4,页面高 841.9,所以它在页面顶部。
- 有很多空片段。
str为空、只带一个hasEOL标记的片段穿插其中,还有宽度不为 0 的纯空格片段。 - 字体名没有意义。
fontName是 PDF.js 内部生成的标识g_d0_f1,配套的styles里只有fontFamily: "sans-serif"这样的通用字体族。在这一层,拿不到「这段字是不是粗体」。
第 4 点后面还会提到,它直接废掉了我们第一版的一个功能。
整体流水线
整个转换在浏览器里完成,分成三层:
PDF.js(解析)
└─ pdfjs-adapter:坐标归一化,把片段转成 { str, x, y, width, height }
└─ pdf-reader:逐页读取、进度、取消、单页失败隔离
└─ pdf-extraction(纯函数):
片段 → 行 → 过滤页眉页脚 → 统计正文字号 → 段落 / 标题 / 列表 → 转义 → Markdown
第一版所有逻辑都写在一个文件里,直接操作 PDF.js 的原始对象,很难测试。重构时,我们把「推理结构」这一步做成了不依赖 PDF.js 的纯函数:输入一组带坐标的片段,输出 Markdown。这样就可以手写坐标来构造各种边界情况做单元测试,后面会讲到。
坐标归一化
适配层做的第一件事,是把坐标从 PDF 空间(y 朝上)转换到视口空间(y 朝下,跟屏幕一致):
const viewport = page.getViewport({ scale: 1 });
const transform = pdfjs.Util.transform(viewport.transform, item.transform);
return {
str: item.str,
x: transform[4],
y: transform[5],
width: item.width,
// 字号取变换矩阵纵向分量的长度,缩放过的文字也能得到实际显示大小
height: Math.hypot(transform[2], transform[3]) || item.height,
};
转换后,y 越大越靠下,「从上到下排序」就是普通的升序。字号用变换矩阵的纵向分量计算,被整体缩放过的文字也能得到真实的显示大小。
第一步:把文字片段拼成行
先把明显无效的片段(空字符串、坐标不是有限数、高度为 0)过滤掉,然后按 y 升序、x 升序排序。依次扫描时,如果当前片段与上一行的 y 差值不超过 max(2, 较小字号 × 0.3),就归入同一行。
容差是必需的:同一行里的上标、不同字体的基线、生成器的浮点误差,都会让 y 有一点偏差。用字号的 30% 作为容差,既能容纳这些偏差,又不会把上下两行误合并。
片段之间要不要加空格?
这是一个比想象中更麻烦的问题。PDF 生成器拆分片段的方式五花八门:有的一个单词一个片段,有的一个字一个片段,有的把单词从中间拆开(字距调整、换字体都可能导致拆分)。第一版直接用空字符串拼接,结果英文单词经常粘在一起;改成空格拼接,又会把被拆开的单词断成两半。
现在的规则是看片段之间的实际间距:
function separator(left: string, right: string, gap?: number, height = 12) {
// 一侧已经有空白,不再加
if (!left || !right || /\s$/.test(left) || /^\s/.test(right)) return '';
// 间距小于字号的 12%:同一个单词被拆成了两个片段
if (gap !== undefined && gap <= Math.max(0.5, height * 0.12)) return '';
// 标点前、开括号后不加
if (/^[,。!?、;:)》」』】〕,.!?;:)]/u.test(right) || /[(《「『【〔(]$/u.test(left))
return '';
// 两个中文/日文字符之间不加(韩文不在其中,见下文)
if (cjk.test(left.at(-1)!) && cjk.test(right[0])) return '';
return ' ';
}
于是:
Hel+lo,间距为 0,拼成HelloHello+world,间距 6,拼成Hello world这是同一段+中文内容。拼成这是同一段中文内容。,中间没有多余空格
后面合并跨行的段落时,用的也是这个函数(只是不传间距)。所以中文 PDF 转出来,不会出现「每行末尾多一个空格」的问题。
韩文:同属「中日韩」,规则却相反
这条规则最初写的是「中日韩字符之间不加空格」,把韩文(Hangul)也算了进去。直到写韩文版教程时实测才发现问题:韩文和中日文不一样,词与词之间是用空格分隔的。 于是 보고서를 + 작성합니다. 被拼成了 보고서를작성합니다.,无论是换行处,还是同一行里分开绘制的两个词之间,空格都丢了。
修复方法是把韩文从「不加空格」的字符集里移出去,让它和英文走同一套规则:同一行内看实际间距,跨行合并时补一个空格。
但跨行这一步有一个无法消除的歧义。韩文排版有两种换行方式:按词(어절)换行,或者按字换行(浏览器默认允许在两个韩文音节之间断行)。我们用 Chrome 分别生成了两种 PDF,对比 PDF.js 返回的数据:
按字换行:"이번 분기에는 신규 사용자가 크게 늘었" / "고 전환율도 …"
按词换行:"이번 분기에는 신규 사용자가 크게" / "늘었고 전환율도 …"
两种情况下,行尾都没有空格字符,行宽也没有可靠的差异。PDF 里根本没有记录「这一行是在词中间断开的」这个信息。 只能二选一:韩文排版通常按词换行,所以我们选择跨行时补空格。代价是在按字换行的 PDF 里,断行处会多出一个空格,比如 늘었 고。这个取舍也写进了韩文版教程,提醒读者检查。
第二步:过滤页眉页脚
页眉、页脚和页码混进正文,是直接复制 PDF 文字最常见的问题。
第一版:删掉所有纯数字短行
第一版的规则很简单:只要某一行是 1 到 4 位纯数字,或者形如 Page 3 of 10、- 3 -,就当作页码删除。
这个规则误删得很严重:正文里单独成行的年份 2026、表格里的数值 128,统统消失了。而真正的页眉,比如每页顶部重复的文档标题,因为不是数字,反而一个也没删掉。
现在:只删「页面边缘」且「多页重复」的内容
现在的判断分两步。第一步,给每一行算一个「边缘特征」:
function marginKey(line: Line, page: PdfPageText): string | undefined {
const relative = line.y / page.height;
// 只考虑页面顶部或底部 5% 以内、且不超过 80 个字符的行
if (!(relative < 0.05 || relative > 0.95) || line.text.length > 80) return;
const edge = relative < 0.05 ? 'top' : 'bottom';
const number =
line.text.match(/^(?:page\s+)?(\d+)(?:\s+of\s+\d+)?$/i) ||
line.text.match(/^-\s*(\d+)\s*-$/);
if (number) {
// 数字必须等于当前物理页码,才可能是页码
return Number(number[1]) === page.number ? `${edge}:page-number` : undefined;
}
return `${edge}:${Math.round(relative * 100)}:${line.text}`;
}
第二步,统计每个特征在多少页上出现过。只有出现页数达到 max(2, 总页数的 60%) 的特征,才会被删除。
这样处理后:
- 每页顶部都有的「Annual Report 2026」,特征相同且在多页出现,会被删除
- 第 3 页底部的
3、第 4 页底部的4,都等于各自的物理页码,统一归为bottom:page-number,会被删除 - 页面边缘偶然出现的年份或金额,不等于页码,会保留
- 正文中间的任何内容,都不在边缘范围内,会保留
这套规则是偏保守的:页码从封面之后才开始计数(物理第 3 页印着「1」),或者用罗马数字编页码时,页码不会被删除。我们的取舍是宁可漏删,不能误删:漏删的页码一眼就能看到,删掉的正文内容用户可能根本发现不了。为此界面上也会提示「已过滤 N 行重复页眉/页脚」,让用户知道发生了什么。
第三步:用字号判断标题
标题识别的第一步,是确定「正文字号」是多少。
按字符数加权,而不是按行数
统计字号时,我们按字符数加权,而不是按行数。原因是:一个页码、一个短标题都算一行,如果按行数统计,一份标题和页码多、正文段落长的文档,算出来的「正文字号」可能是错的。按字符数计算,正文的权重自然最大。
我们用上文那份 4 页的产品说明 PDF 实测,字号分布如下:
| 字号 | 字符数 | 示例 |
|---|---|---|
| 11pt | 2146 | MD-to.com turns your Markdown into print… |
| 10pt | 350 | 代码块(含行号) |
| 16pt | 184 | MD-to.com PDF Converter — Feature Guide |
| 14pt | 119 | Academic & Creative |
正文字号是 11pt。然后用每行字号与正文字号的比值决定标题级别:
| 比值 | 结果 |
|---|---|
| ≥ 2 | # |
| ≥ 1.6 | ## |
| > 1.3 | ### |
| ≤ 1.3 | 正文 |
套用到这份 PDF 上:16pt 的文档标题和章节标题,比值是 1.45,都识别成了 ###;14pt 的小标题,比值 1.27,没有超过 1.3,被当成了正文。这就是我们在上一篇文章的实测中看到的现象。
为什么不把阈值调低一点?因为没有对所有文档都合适的阈值。调到 1.2,这份文档的 14pt 小标题能被识别,但另一份正文 10pt、引用 12pt 的文档,所有引用都会变成标题。我们选择让阈值偏保守,同时提供「识别标题」开关,误判严重时可以关掉。
第一版的粗体检测:一段从没生效过的代码
第一版还有一条规则:字号和正文一样,但字体是粗体、长度较短、前面有空行,就当作小标题,输出成 **加粗**。判断粗体的方式是检查字体名里有没有 bold、heavy、black。
重构时我们才意识到,这段代码几乎从来没有生效过。前面提到,PDF.js 返回的 fontName 是 g_d0_f1 这样的内部标识,不包含字体的真实名称。所以「字体名包含 bold」这个条件基本不可能成立。
我们删掉了这段代码,而不是保留一个看起来存在、实际不工作的功能。要可靠地识别粗体,需要深入 PDF.js 的字体对象去读真实字体名,这是后续可以做的改进。
第四步:段落——行距和缩进
把行合并成段落,主要看两个信号。
行距。 当前行与上一行的 y 间距超过 1.8 × 行高,就认为中间有段落间距,另起一段。一般正文的行距在 1.2 到 1.5 倍行高之间,段落之间通常会多留一些空白,1.8 倍正好在两者之间。
首行缩进。 很多文档(尤其是中文文档)段落之间没有额外间距,只靠首行缩进来区分段落。规则是:当前行比上一行向右偏移超过一个字高,就另起一段。
注意这里只看「向右」。段落的第一行缩进,第二行回到左边距,这是同一段的延续,不能因为 x 变了就再切一次。第一版只看行距,靠首行缩进分段的文档会被合并成一整段;重构时加上缩进判断,又一度出现「首行缩进的段落被拆成两段」的问题:第一行一段,后面几行又一段。
第一段首行 ← 向右缩进:新段落开始
第一段续行 ← 回到左边距:同一段的延续
第二段首行 ← 向右缩进:新段落开始
第二段续行
输出:
第一段首行第一段续行
第二段首行第二段续行
第五步:列表——项目符号和悬挂缩进
列表项的开头比较容易识别:以 •、‣、◦、●、○、-、*、+ 加空格开头的行是无序列表;以 数字. 或 数字) 加空格开头的行是有序列表。
难的是列表项折行。PDF 里一个较长的列表项通常这样排版:
• First item that wraps
onto a second line ← 悬挂缩进,与项目符号后的文字对齐
and a third
• Second item
Independent text ← 回到左边距,已经不是列表了
这里有两种相反的错误。第一版的做法是:只要没有段落间距,就把下一行拼到上一个块后面。续行确实并进了列表项,但紧跟在列表后面的「Independent text」也被吞进了最后一个列表项。重构过程中改成按块的类型分别处理后,又一度走向另一个极端:「onto a second line」变成一个独立段落,夹在两个列表项中间,列表也被切成了两个。
现在的规则是:上一个块是列表项,当前行没有段落间距,并且 x 比该列表项的项目符号更靠右(超过 max(1, 字高 × 0.2)),就把当前行并入这个列表项。如果 x 回到了项目符号的位置或更左,就是一个新段落,不会被列表吞掉。
上面的例子会输出:
- First item that wraps onto a second line and a third
- Second item
Independent text
开启「保留换行」时,列表项内部的换行会输出为 Markdown 硬换行(行尾两个空格),并在下一行补上与列表标记等宽的缩进,保证续行仍然属于这个列表项。例如 10. Step one 折行后,渲染出来仍是一个 <ol start="10"> 里的同一个 <li>。
第六步:转义——PDF 里的字就是字
PDF 里的文字都是字面内容。正文里写着 #tag,它就是一个话题标签,不是一级标题;写着 *text*,就是带星号的文字,不是斜体。
第一版没有做任何转义,PDF 里的文字原样拼进 Markdown,于是:
- 以
#开头的行变成了标题 <b>literal</b>被当成 HTML 渲染成粗体- 一行
--- | ---会把上一行变成表格的表头
但也不能过度转义
最直接的修复思路,是把所有 Markdown 特殊字符都加上反斜杠。重构时我们一度也是这么做的,然后遇到了一个意外:文献引用 [1] 在预览里被渲染成了公式。
原因是站点的 Markdown 渲染器支持 KaTeX 数学公式,而 \[ ... \] 正是块级公式的分隔符。普通的转义规则会把 [1] 转成 \[1\],在一般的 Markdown 渲染器里没问题,到了我们的渲染器里却正好变成了一个公式。
所以现在的原则是:只转义真正会触发语法的字符和位置,普通文字原样保留,读起来也更干净。
export function escapePdfText(text: string) {
const escaped = text
.replace(/[\\`*$]/g, '\\$&')
// 只转义可能触发强调的下划线,snake_case 原样保留
.replace(/(?<![\p{L}\p{N}])_|_(?![\p{L}\p{N}])/gu, '\\_')
.replace(/~(?=~)|(?<=~)~/g, '\\~')
// `]` 只在可能构成链接、图片、脚注时转义;`[` 从不转义
.replace(/\](?=[([:])/g, '\\]')
.replace(/\^(?=\[)/g, '\\^')
.replace(/<(?=[\p{L}/!?])/gu, '\\<')
.replace(/&(?=#\d+;|#x[\da-f]+;|[a-z][\da-z]*;)/gi, '\\&')
// 行首:标题、引用、列表、Setext 标题下划线、分隔线
.replace(/^(?:#|>|[-+](?=\s|$)|[-=](?=[-=\s]*$))/, '\\$&')
.replace(/^(\d+)([.)])(?=\s|$)/, '$1\\$2');
// 形如 `--- | ---` 的行会把上一行变成表格表头
return /^[\s|:\\-]*\|[\s|:\\-]*$/.test(escaped) && escaped.includes('-')
? escaped.replace(/\|/g, '\\|')
: escaped;
}
比如 snake_case、C#、AT&T、5 > 3、[1]、1.5 倍 这些普通文字都不会被改动;而行首的 1. 、# 、- 会被转义,避免变成列表或标题。这也是为什么转换结果里会出现 ### 1\. 简介 这样的写法。
为了验证转义规则,我们写了一个测试:准备一批容易出问题的字符串,比如 See [1] and [2]、Price $5 and $10、__init__、<div>not html</div>、1. not a list,转义后用站点自己的 Markdown 渲染器渲染,断言输出和原文逐字一致。用通用渲染器测试是不够的,[1] 变公式这个问题,只有在我们自己的渲染器里才会出现。
第七步:页与页之间
第一版先把所有页面的行拼成一个数组,再统一判断段落。这里有一个隐蔽的 bug:每一页的坐标都是从头开始计算的。
第 1 页最后一行的 y 是 680,第 2 页第一行的 y 恰好也是 680。在第一版看来,这两行之间的距离是 0,于是把两页的内容合并成了同一段:
Page A body Page B body
现在,每一页都单独推理结构,页与页之间直接用空行分隔,从不跨页比较坐标。
这个选择也有代价:一个跨页的段落,会在分页处被切成两段。比如上一篇文章实测时,就出现了「(great for reference」和「sheets)」被分开的情况。我们权衡后认为,跨页段落被切开,用户删掉一个空行就能修复;把两页无关的内容错误合并,用户更难发现,也更难修复。
工程上的细节
结构推理之外,还有几件事影响实际体验:
- 单页失败不影响整份文档。 某一页读取出错时,这一页记为
failed,其余页面继续转换。状态栏会列出读取失败的页码、没有文字的页码(扫描件或空白页),而不是给一个笼统的「转换失败」,或者悄悄输出一份不完整的结果。 - 可以取消。 读取过程中的每一个异步步骤都和一个「取消」Promise 做
Promise.race。用户点击取消后立即返回,并确保 PDF.js 的加载任务只被销毁一次,已经获取的页面都会被释放。每读完一页还会通过setTimeout(0)让出主线程,页数很多时页面也不会卡死。 - Worker 本地加载。 第一版从 CDN 加载 PDF.js 的 Worker 脚本。现在改为随项目一起打包,版本始终和安装的 PDF.js 一致,也不再依赖第三方 CDN 是否可用。
怎么测试一个「猜结构」的算法
启发式规则最怕「修好一个,弄坏另一个」。我们的测试分两层。
纯函数测试:直接手写坐标,构造各种边界情况。例如「首行缩进不应拆开段落」:
const item = (str, y = 100, x = 30, width = str.length * 6) =>
({ str, y, x, width, height: 12 });
assert.equal(
render([
item('第一段首行', 100, 54),
item('第一段续行', 115),
item('第二段首行', 130, 54),
item('第二段续行', 145),
]),
'第一段首行第一段续行\n\n第二段首行第二段续行',
);
上文提到的每一个坑,包括误删年份、跨页合并、列表吞掉段落、[1] 变公式、--- | --- 生成表格,都有一个对应的测试用例。
真实 PDF 测试:在测试中用代码直接拼出最小的合法 PDF 文件(几个对象加一个 xref 表),再交给真正的 PDF.js 解析。例如构造两页文字坐标完全相同的 PDF,验证它们不会被合并;构造一个中间有空白页的三页 PDF,验证空白页被标记为 no-text,页面底部的页码被删除,而正文里的 2026 和 123456789 被保留。这一层测试能发现「纯函数没问题,但 PDF.js 实际返回的数据和我们假设的不一样」这类问题。
这套方法做不到什么
基于坐标的启发式规则,能力边界很清楚:
- 表格。 我们只按行拼接文字,不分析单元格边框,也不分析列对齐,所以表格会变成一行行文字。多行单元格的内容还可能和其他列交错。
- 多栏排版。 分行时,同一高度的文字会被拼成一行,不区分左栏和右栏。双栏论文转出来,左右两栏的文字会交错在一起。
- 跨行连字符。 英文排版中
inter-/national这样的断词,我们会输出inter- national。因为无法可靠地区分「排版断词」和「本来就带连字符的词」,我们选择不去猜。 - 代码、公式、图片。 代码的缩进、公式的结构、图片本身都不在文字层里,或者无法从坐标还原。
- 扫描件。 页面上没有文字层,只有图片,需要 OCR。
这些问题,MinerU、Marker、Docling 等工具用版面分析模型和 OCR 模型来解决,效果确实更好。但它们需要本地 Python 环境,或者把文件上传到服务器处理。我们选择了另一个方向:在浏览器里用规则完成,零上传、零安装、几秒出结果,覆盖最常见的文字型 PDF。两类工具解决的是不同的问题。
后续方向
有几个改进我们在考虑:
- 读取真实字体名:从 PDF.js 的字体对象里拿到真实的字重信息,重新支持粗体小标题和行内加粗。
- 跨页段落合并:上一页最后一行没有句末标点,下一页第一行以小写字母开头时,尝试合并两段。
- 多栏检测:统计一页中文字 x 坐标的分布,找到贯穿整页的竖直空白带,把页面切成多栏后分别处理。
- 简单表格识别:多行文字的 x 坐标呈现稳定的列对齐时,尝试还原成 Markdown 表格。
每一项都会让「猜」变得更激进,也会带来新的误判。按照我们的原则,它们只有在测试能覆盖足够多的反例之后才会上线。
总结
回头看,这次重构修复的大多数 bug,都来自同一个错误假设:把 PDF 当成一份有结构的文档来读。 实际上,PDF 只是一组带坐标的绘图指令。所以每一条规则都要回答一个问题:如果猜错了,用户付出的代价是什么?
- 页眉页脚:宁可漏删,不能误删正文
- 标题阈值:宁可少识别,也不要把引用、注释都变成标题
- 分页:宁可切开跨页段落,也不要错误合并两页内容
- 转义:只转义真正会触发语法的字符,
[1]就是[1] - 粗体检测:不工作的功能,比没有这个功能更糟
如果你手上有文字型的 PDF,可以到 md-to.com/zh-cn/pdf-to-markdown/ 试试。转换在浏览器本地完成,文件不会上传。遇到转换效果不好的 PDF,也欢迎通过页面右下角的反馈按钮告诉我们,每一个反例都可能成为一条新的测试用例。
相关工具
免费 Markdown 转 PDF 转换器 - 在线将 MD 转换为 PDF 文件
免费在线将 Markdown 转换为高质量 PDF 文件。智能分页引擎自动处理代码块截断和表头跨页问题,20 多种专业模板可选(GitHub 风格、学术、商务),支持 GFM 语法、KaTeX 数学公式、语法高亮,100% 浏览器本地处理,无需注册,保护隐私。
PDF 转 Markdown 在线工具 - 免费 PDF 转 MD 格式转换器
在线将 PDF 文档转换为 Markdown 格式,免费将 PDF 转为 MD 格式,智能提取文本、标题、列表等结构。无需注册,100% 浏览器端处理,保护隐私!
免费 Markdown 转 LaTeX 工具 - 在线将 MD 转换为 TeX
在线免费将 Markdown 转换为 LaTeX。将 MD 文档转换为 TeX 格式,适合学术论文和技术文档。无需注册!