[控场AI]
· 5 分钟阅读· 2,945 字

200页PDF只返回47字节:一个隐藏层的转换陷阱

200页PDF只返回47字节:一个隐藏层的转换陷阱

200页招股书只转出47字节,揭示PDF是容器而非格式,需两遍提取与密度检查。

一份14MB、200页的财务招股书经PDF转Markdown接口处理后只返回47字节,仅剩一个标题。原因是该PDF由扫描流水线生成,正文位于不可见的OCR文本层,而提取器读到的却是不含可提取文本的矢量轮廓层。作者的修复方案很朴素:跑两遍提取,第一遍走快速文本流通道,若整份文档的每页平均字符数低于约50的阈值,再回退到显式从OCR/不可见层拉取。文章由此引出一个更根本的心智模型——PDF不是一种格式,而是一个可同时容纳文本、图像、矢量轮廓、不可见无障碍层、注释与嵌入文件的容器,因此“PDF转Markdown”本身就有歧义。两遍逻辑上线后,同一份文件可稳定转换为约18万字符的干净Markdown,而密度阈值这类快慢路径切换点需要持续校准,不应写死。

一个开发者给PDF转Markdown接口喂了一份真实的财务招股书,结果只拿回一个标题和一片空白。文件14MB,200页,输出47字节。他先怀疑文件损坏,再怀疑加密,最后真正打开了那个PDF。

RSS来源截图

文本没丢,它藏在了你读不到的层里

问题不在文本缺失。文本就在那里,但被渲染成了一个没有可提取文本流的矢量轮廓层。这份PDF由一套扫描仪式的流水线生成:先把页面栅格化为图像,再叠加一层不可见的OCR文本层用于无障碍访问。

提取器读到的是轮廓层——标题、书签——所以只返回了一个标题,而把真正承载正文的不可见层当成了文档主体。

这在财务文件、扫描合同、政府档案里并不罕见。一份看起来“正常”的PDF,内部可能同时存在栅格图像、矢量轮廓、不可见OCR层、注释和嵌入文件。任何声称“支持PDF”的转换器,只要没有文档级密度检查,离返回一个自信的空字符串就只有一次奇怪输入的距离。

OCR文本层(有时写作 invisible text layer / hidden OCR layer)是扫描件处理流水线为弥补“图像不可检索”而普遍加入的一层结构。它的做法是:在栅格化后的页面图像上,按字符位置叠加一层真实可选的文本对象,通过设置渲染模式为不可见(Tr 3)、透明填充色或与背景同色,使其在视觉上与底图重合但不被读者察觉。这样复制粘贴、Ctrl+F 查找、屏幕阅读器都能正常工作。

问题在于,这一层对文本提取器和人眼来说优先级完全不同——人眼看到的是图像,提取器看到的是一层正常文本对象,但很多解析器会优先走页面内容流的绘制指令,遇到只包含矢量轮廓的层就“心满意足”地返回结果。判断文件是否属于此类,一个廉价办法是用 pdftotext 提取后对比字数与页数,或者用 PyMuPDF 遍历每页的 get_text() 与 get_text("rawdict") 的差异。

为什么单页判断会误伤

修复方案本身很朴素:跑两遍提取。

第一遍走快速文本流通道;如果得到的每页字符密度低于某个阈值,再触发第二遍回退通道,显式地从OCR/不可见层拉取文本。

难点全在阈值上。阈值太低,那些本来就稀疏的页面——封面、章节分隔页、以图为主的幻灯片——会频繁触发慢速路径,拖垮整体吞吐。阈值太高,又会漏掉恰恰需要回退的那类文档。

作者最终采用的做法是:衡量整份文档的每页平均字符数,而不是逐页判断。一份199页密集、只有1页空白分隔的招股书不应该触发回退;一份平均每页低于约50字符的文档几乎可以确定是图像型,走慢速路径是值得的。

这个细节很关键。逐页判断会在文档内部制造大量“局部触发”,把一次转换拆成快慢两套逻辑的反复切换;而文档级均值把判断拉到一个更稳定的粒度上,既避免了噪声误触,也保住了对整体图像型文档的识别能力。

字符密度阈值(characters-per-page threshold)是文档类型判别中最常用的启发式指标之一,但它天然依赖整份文档的统计特性。经验上,纯文本页通常每页 1500–3000 字符,图像型扫描件若无 OCR 层则接近 0,未加 OCR 的图文混排可能在几百上下。选取约 50 字符/页作为均值分界线,本质是在“确信空白”和“确信失败”之间留出一段安全带——低于它的文档,无论外观如何,回退一次慢速路径的成本都远低于误判为空结果的代价。

需要留意的是,均值对极度不均衡的文档并不总能反映真相:一份 20 页里 1 页正文、19 页插图的画册均值同样很低,会触发回退。因此更稳健的做法是同时观测中位数、非空页比例、最长连续低密度页数等辅助指标,而不是只信一个均值。阈值本身也应做成可配置项并记录触发日志,便于后续按输入分布漂移重新校准。

PDF不是一种格式,是一个容器

这次事故最值得带走的一课,是对“PDF”这个词本身的重新理解。

PDF不是一种格式,是一个容器。它可以装文本、图像、矢量轮廓、不可见无障碍层、注释、嵌入文件、JavaScript,以及以上任意组合。同一份文件里,不同页面可能走完全不同的生成路径,最终却共享一个后缀名。

这意味着,“PDF转Markdown”这个需求本身就带着隐含的歧义:你到底想转哪一层?是书签结构,是可见的栅格像素,还是隐藏在下面的OCR结果?大多数工具默认只处理其中一层,而用户默认以为它们处理的是全部。

一旦接受“容器”这个心智模型,两遍提取就不再是一个补丁,而是这类工具的基本架构。密度检查也不只是性能优化,而是一种必要的自我怀疑机制——它让转换器在返回空结果之前,先问自己一句:我真的读到正文了吗?

从规范角度看,PDF 由 COS 对象模型(Carousel Object Structure)组织:间接对象、交叉引用表、文档目录、页面树、内容流、资源字典共同构成一个可增量修改的文件。同一页的内容流通常由若干可绘制指令组成,而“层”并不是 PDF 规范里的一等概念——所谓图层、图像层、OCR 层,都是工具在写入时通过 可选内容组(Optional Content Group, OCG)、独立 XObject、或者干脆堆叠多个内容流的方式模拟出来的。

这解释了为什么“提取哪一层”没有标准答案:解析器只能看到对象和指令序列,看不到“这是正文、那是装饰”。/Contents 数组可以有多段流,/Resources 里的 /XObject 可以是 Image、Form 或 PS,/Annots 里还能挂注释、链接、表单域。一个负责任的转换器至少要枚举这些入口,按可信度排序尝试,而不是假设第一段流就是正文。

工程上的可复用模式

抛开PDF不谈,这个案例其实展示了一种通用的工程模式:先走快路径,用轻量指标判断结果是否可疑,再决定是否启动慢路径。

关键在于指标的选择。这里用字符密度,是因为它能低成本地区分“真正稀疏”和“提取失败”这两种外观相似的状态。如果只看“字符数为零”或“字符数极少”,你就无法区分一份只有标题的封面页和一份整体提取失败的图像型文档。

作者提到阈值是他持续在调的部分,这也很真实。快慢路径的切换点永远不是一次性设定好的常量,而是随着输入分布变化需要持续校准的参数。把它写成可观测、可调整的配置,比写死在代码里要负责任得多。

两遍逻辑上线后,那份200页招股书现在能转换成约18万字符的干净Markdown。修复很小,但对“PDF”这个词的理解变了。

给工具使用者的提醒

如果你在构建或使用任何文档转换接口,有几点值得留意:

  • 空输出不一定是文件坏,可能是层选错了;
  • 对“支持PDF”的宣称保持警惕,问清楚它读的是哪一层;
  • 在自己的流水线里加入结果合理性检查,哪怕只是均值级别的;
  • 对扫描件、财务文件、政府档案这类高概率图像型文档,预留回退通道。

真正的问题从来不是PDF太难解析,而是一个容器被当成了格式。

#PDF转Markdown#PDF提取失败#OCR文本层#文档密度检查#两遍提取#PDF格式陷阱#文档转换工程
分享:

相关推荐