返回博客

PDF 里没有「段落」:我们如何从文字坐标重建段落、标题和列表

MD-TO 团队 •

做 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 坐标,前四位里包含了字号。仔细看会发现几个问题:

  1. 顺序不是阅读顺序。 第一个片段「Academic & Creative」的 y 是 188.6,在页面靠下的位置。「Download PDF」和「Try it now…」在同一行(y 都是 689.2),但「Download PDF」在右边,却排在前面。片段的顺序取决于 PDF 生成器写入绘图指令的顺序,跟人眼阅读的顺序没有必然关系。
  2. y 轴朝上。 PDF 的坐标原点在页面左下角,y 越大越靠上。标题的 y 是 751.4,页面高 841.9,所以它在页面顶部。
  3. 有很多空片段。 str 为空、只带一个 hasEOL 标记的片段穿插其中,还有宽度不为 0 的纯空格片段。
  4. 字体名没有意义。 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,拼成 Hello
  • Hello + 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 实测,字号分布如下:

字号字符数示例
11pt2146MD-to.com turns your Markdown into print…
10pt350代码块(含行号)
16pt184MD-to.com PDF Converter — Feature Guide
14pt119Academic & 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,也欢迎通过页面右下角的反馈按钮告诉我们,每一个反例都可能成为一条新的测试用例。