
点此进入系列专栏
如果你已经在真实 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 已经在尽力帮你“擦掉最明显的重影”
在这一层:
- 不要急着否定工具
- 不要急着“优化”
- 更不要急着写一套更狠的规则
因为真正该出手的地方,往往在更高一层的结构、融合和策略里。
网硕互联帮助中心





评论前必须登录!
注册