云计算百科
云计算领域专业知识百科平台

《PDF解析工程实录》第 16 章|为什么同一段文字会被读出来好几次:内容流的“重复绘制”与 pdfplumber 的去重逻辑

封面


点此进入系列专栏


如果你已经在真实 PDF 上跑过内容流解析,大概率会遇到过这样一种非常诡异的现象:页面上明明只有一段文字,你肉眼看也只看到一次,但解析出来的结果里,同样的字符、同样的位置、同样的文本,出现了两次甚至三次。

提取出来的文字有重复 图:提取出重复文字的示例

更让人迷惑的是:

  • 它们的 bbox 几乎完全重合
  • 字体、字号、坐标都对得上
  • 仿佛是系统自己在“复读”

很多人第一次看到这个结果,第一反应是:

“是不是 pdfminer 有 bug?”

但实际上,这类问题往往不是解析库的问题,而是你第一次真正撞上了 PDF 内容流的真实世界。


一个必须先接受的事实:PDF 允许“画同一个字很多次”

在 PDF 的语义里,“文本”不是一个抽象概念,而只是绘制指令的一种结果。

PDF 并不关心:

  • 你是不是在写一句话
  • 你是不是已经画过这个字
  • 这个字是不是已经在页面上出现过

它只关心一件事:

“现在,请在这个位置,用这个字体,把这个 glyph 画出来。”

只要内容流里出现了多条这样的指令,解析器就会老老实实地把它们读出来。

#mermaid-svg-85ZpKbStFUcpZw2a{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-85ZpKbStFUcpZw2a .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-85ZpKbStFUcpZw2a .error-icon{fill:#552222;}#mermaid-svg-85ZpKbStFUcpZw2a .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-85ZpKbStFUcpZw2a .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-85ZpKbStFUcpZw2a .marker{fill:#333333;stroke:#333333;}#mermaid-svg-85ZpKbStFUcpZw2a .marker.cross{stroke:#333333;}#mermaid-svg-85ZpKbStFUcpZw2a svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-85ZpKbStFUcpZw2a p{margin:0;}#mermaid-svg-85ZpKbStFUcpZw2a .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-85ZpKbStFUcpZw2a .cluster-label text{fill:#333;}#mermaid-svg-85ZpKbStFUcpZw2a .cluster-label span{color:#333;}#mermaid-svg-85ZpKbStFUcpZw2a .cluster-label span p{background-color:transparent;}#mermaid-svg-85ZpKbStFUcpZw2a .label text,#mermaid-svg-85ZpKbStFUcpZw2a span{fill:#333;color:#333;}#mermaid-svg-85ZpKbStFUcpZw2a .node rect,#mermaid-svg-85ZpKbStFUcpZw2a .node circle,#mermaid-svg-85ZpKbStFUcpZw2a .node ellipse,#mermaid-svg-85ZpKbStFUcpZw2a .node polygon,#mermaid-svg-85ZpKbStFUcpZw2a .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-85ZpKbStFUcpZw2a .rough-node .label text,#mermaid-svg-85ZpKbStFUcpZw2a .node .label text,#mermaid-svg-85ZpKbStFUcpZw2a .image-shape .label,#mermaid-svg-85ZpKbStFUcpZw2a .icon-shape .label{text-anchor:middle;}#mermaid-svg-85ZpKbStFUcpZw2a .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-85ZpKbStFUcpZw2a .rough-node .label,#mermaid-svg-85ZpKbStFUcpZw2a .node .label,#mermaid-svg-85ZpKbStFUcpZw2a .image-shape .label,#mermaid-svg-85ZpKbStFUcpZw2a .icon-shape .label{text-align:center;}#mermaid-svg-85ZpKbStFUcpZw2a .node.clickable{cursor:pointer;}#mermaid-svg-85ZpKbStFUcpZw2a .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-85ZpKbStFUcpZw2a .arrowheadPath{fill:#333333;}#mermaid-svg-85ZpKbStFUcpZw2a .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-85ZpKbStFUcpZw2a .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-85ZpKbStFUcpZw2a .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-85ZpKbStFUcpZw2a .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-85ZpKbStFUcpZw2a .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-85ZpKbStFUcpZw2a .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-85ZpKbStFUcpZw2a .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-85ZpKbStFUcpZw2a .cluster text{fill:#333;}#mermaid-svg-85ZpKbStFUcpZw2a .cluster span{color:#333;}#mermaid-svg-85ZpKbStFUcpZw2a div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-85ZpKbStFUcpZw2a .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-85ZpKbStFUcpZw2a rect.text{fill:none;stroke-width:0;}#mermaid-svg-85ZpKbStFUcpZw2a .icon-shape,#mermaid-svg-85ZpKbStFUcpZw2a .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-85ZpKbStFUcpZw2a .icon-shape p,#mermaid-svg-85ZpKbStFUcpZw2a .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-85ZpKbStFUcpZw2a .icon-shape rect,#mermaid-svg-85ZpKbStFUcpZw2a .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-85ZpKbStFUcpZw2a .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-85ZpKbStFUcpZw2a .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-85ZpKbStFUcpZw2a :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

PDF 内容流:绘制指令序列

同一字符可能被多次绘制

描边+填充(stroke + fill)

阴影/加粗模拟(offset draw)

图层叠加/透明度(overprint/alpha)

导出/转换器 bug(重复写入)

内容流抽取:拿到多份 LTChar/char dict

表现:同位置、同字符出现多次(肉眼可能看不出来)

图:重复字符从哪来

在真实 PDF 中,下面这些情况都非常常见:

  • 描边 + 填充 同一段文字先描边画一次,再填充画一次
  • 阴影 / 高亮 / 伪加粗效果 同一个字用极小的坐标偏移重复绘制
  • 透明度叠加 看起来像一次绘制,实际是多次叠加
  • 某些导出工具的“保守策略” 为了保证显示一致性,重复输出内容流

从 PDF 规范的角度看,这些行为完全合法。

所以当你在内容流里看到:

“同一位置、同一字符,被画了不止一次”

这并不是异常,而是 PDF 世界的常态。


为什么你“看不见”,但内容流“看得见”

一个很容易让工程师误判的问题是:

“我在页面上只看到一次,为什么解析出来有多次?”

原因很简单:

  • PDF 渲染器在视觉层会做合成
  • 人眼只感知最终结果
  • 但内容流记录的是过程,而不是结果

换句话说:

内容流看到的是“你怎么画的”,而不是“你最后看到了什么”。

这也是为什么在前面的章节里我反复强调:内容流是“绘制世界”,而不是“语义世界”。


那为什么这个问题在工程里这么烦人?

因为一旦你把这些“重复绘制”的字符直接当成真实文本来用,后果会非常具体:

  • 文本重复
  • 段落被拉长
  • 相同句子出现多次
  • 下游 RAG / 搜索召回质量明显下降

而且,这类问题往往不是全页性的,而是:

  • 只在某些段落出现
  • 只在某些字体或样式下出现
  • 随着 PDF 来源变化,忽隐忽现

这也是为什么:

“内容流重复”几乎是所有 PDF 解析系统,迟早都会撞上的一堵墙。


pdfplumber 是怎么处理这个问题的?

这里必须说一句非常重要的话:

pdfplumber 在字符级去重这件事上,已经做得相当成熟了。

它并不是“没看到重复”,而是明确意识到这件事存在,并且主动尝试解决。

具体代码在这,可能需要主动调用。

#mermaid-svg-7GjKIprGb9BTLA6D{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7GjKIprGb9BTLA6D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7GjKIprGb9BTLA6D .error-icon{fill:#552222;}#mermaid-svg-7GjKIprGb9BTLA6D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7GjKIprGb9BTLA6D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7GjKIprGb9BTLA6D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7GjKIprGb9BTLA6D .marker.cross{stroke:#333333;}#mermaid-svg-7GjKIprGb9BTLA6D svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7GjKIprGb9BTLA6D p{margin:0;}#mermaid-svg-7GjKIprGb9BTLA6D .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-7GjKIprGb9BTLA6D .cluster-label text{fill:#333;}#mermaid-svg-7GjKIprGb9BTLA6D .cluster-label span{color:#333;}#mermaid-svg-7GjKIprGb9BTLA6D .cluster-label span p{background-color:transparent;}#mermaid-svg-7GjKIprGb9BTLA6D .label text,#mermaid-svg-7GjKIprGb9BTLA6D span{fill:#333;color:#333;}#mermaid-svg-7GjKIprGb9BTLA6D .node rect,#mermaid-svg-7GjKIprGb9BTLA6D .node circle,#mermaid-svg-7GjKIprGb9BTLA6D .node ellipse,#mermaid-svg-7GjKIprGb9BTLA6D .node polygon,#mermaid-svg-7GjKIprGb9BTLA6D .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7GjKIprGb9BTLA6D .rough-node .label text,#mermaid-svg-7GjKIprGb9BTLA6D .node .label text,#mermaid-svg-7GjKIprGb9BTLA6D .image-shape .label,#mermaid-svg-7GjKIprGb9BTLA6D .icon-shape .label{text-anchor:middle;}#mermaid-svg-7GjKIprGb9BTLA6D .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7GjKIprGb9BTLA6D .rough-node .label,#mermaid-svg-7GjKIprGb9BTLA6D .node .label,#mermaid-svg-7GjKIprGb9BTLA6D .image-shape .label,#mermaid-svg-7GjKIprGb9BTLA6D .icon-shape .label{text-align:center;}#mermaid-svg-7GjKIprGb9BTLA6D .node.clickable{cursor:pointer;}#mermaid-svg-7GjKIprGb9BTLA6D .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7GjKIprGb9BTLA6D .arrowheadPath{fill:#333333;}#mermaid-svg-7GjKIprGb9BTLA6D .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7GjKIprGb9BTLA6D .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7GjKIprGb9BTLA6D .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7GjKIprGb9BTLA6D .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7GjKIprGb9BTLA6D .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7GjKIprGb9BTLA6D .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7GjKIprGb9BTLA6D .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7GjKIprGb9BTLA6D .cluster text{fill:#333;}#mermaid-svg-7GjKIprGb9BTLA6D .cluster span{color:#333;}#mermaid-svg-7GjKIprGb9BTLA6D div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7GjKIprGb9BTLA6D .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7GjKIprGb9BTLA6D rect.text{fill:none;stroke-width:0;}#mermaid-svg-7GjKIprGb9BTLA6D .icon-shape,#mermaid-svg-7GjKIprGb9BTLA6D .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7GjKIprGb9BTLA6D .icon-shape p,#mermaid-svg-7GjKIprGb9BTLA6D .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7GjKIprGb9BTLA6D .icon-shape rect,#mermaid-svg-7GjKIprGb9BTLA6D .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7GjKIprGb9BTLA6D .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7GjKIprGb9BTLA6D .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7GjKIprGb9BTLA6D :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

输入:chars 列表

按 key 排序key=(upright,text,fontname,size,…)

按 key 分组(text一致 + 字体属性一致)

对每组:先按 doctop 聚类(tolerance)

对每个 y-cluster:再按 x0 聚类(tolerance)

每个 (y,x) cluster 只保留一个取 pos_key=(doctop,x0) 最小的那个

输出去重后的 chars

按原始输入顺序回排sorted(deduped, key=chars.index)

图:pdfplumber去重算法流程

pdfplumber 的核心思路非常清晰,而且完全工程导向:

如果多个字符在“视觉上几乎完全重合”, 那它们很可能只是同一次显示效果的不同绘制。

去重的核心依据

pdfplumber 并不会仅仅根据字符内容(比如 Unicode)来去重,因为:

  • 不同字符可能重叠(例如标注、注音)
  • 相同字符在不同位置是完全合理的

它关注的是:

  • 字符的内容
  • 字符之间的空间关系

简化理解的话,它在做的是:

“如果两个字符在空间上几乎是同一个东西,那就只留一个。”

pdfplumber 并没有使用 bbox IOU 这种“视觉语义强”的指标,而是采用了更符合内容流特性的做法:按 y 轴、x 轴分别聚类,只在数值上非常接近时才判为重复。


一个非常工程化、也非常理性的判断

pdfplumber 的去重逻辑,背后其实有一个很重要的工程取舍:

宁可偶尔保留一点重复,也不要激进到误删真实文本。

这点非常关键。因为在内容流里:“看起来重合”的字符和“确实是重复绘制”的字符,在数值层面,有时候差别并不大。

所以 pdfplumber 的策略是:

  • 以空间重合为主要信号
  • 在“明显重复”的情况下才去重
  • 对边界情况保持克制

这也是为什么:

  • 它解决了大多数重复绘制问题
  • 但你在极端 PDF 上,偶尔仍能看到残留重复

而这恰恰是一个成熟工程系统的表现,而不是缺陷。

需要强调的是,pdfplumber 的去重并不是基于“视觉语义”的判断,它并不会尝试理解阴影、描边或视觉效果,而是站在内容流的世界里,用字符属性 + 数值距离做出一个极其克制的决策。

你可能会想:

“那我是不是可以自己再写一套更狠的去重逻辑?”

从个人实际经验的角度出发。暂时没有遇到这套逻辑解决不了的badcase。所以估计你也照着用就好了,遇到了问题再去看怎么解决。

一个贯穿内容流解析的认知

这一章,其实想帮你建立一个非常重要的直觉:

内容流里出现“奇怪结果”, 很多时候不是算法问题, 而是 PDF 本来就允许这样画。

pdfplumber 的价值,并不在于它“完美解决了所有问题”,而在于,它非常清楚:

  • 哪些地方该动
  • 哪些地方不该太激进
  • 哪些问题应该交给更高层处理

小结:重复文本不是 bug,而是 PDF 的生活方式

所以,回到最开始的问题:

为什么同一段文字会被读出来好几次?

答案并不神秘:

  • PDF 允许重复绘制
  • 内容流忠实记录绘制过程
  • 解析器只是在如实转述
  • pdfplumber 已经在尽力帮你“擦掉最明显的重影”

在这一层:

  • 不要急着否定工具
  • 不要急着“优化”
  • 更不要急着写一套更狠的规则

因为真正该出手的地方,往往在更高一层的结构、融合和策略里。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 《PDF解析工程实录》第 16 章|为什么同一段文字会被读出来好几次:内容流的“重复绘制”与 pdfplumber 的去重逻辑
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!