- 依赖:github.com/ledongthuc/pdf(**BSD-3-Clause**,非 MIT —— 子代理核了 LICENSE 并纠正了任务书的写法;零传递依赖;无 cgo/无外部进程;体积 strip 后 +285.5 KiB / +0.74%,在 detached worktree 里量取,产出写到仓库外)。被否候选:pdfcpu 无文本抽取、rsc.io/pdf 停更、pdfium/poppler/mupdf 需 cgo 撞红线。
- 实现:魔数 %PDF- 是唯一入口判定(名字/声明指向 PDF 而字节无魔数 ⇒ ErrUnsupported,绝不退回纯文本抽);逐页 GetPlainText(页间仅去首尾空白);ExtractorVersion v2→v3(所有 sidecar 一次性重抽);页数上限 5000 + 字节安全上限复用;新增哨兵 ErrPDFEncrypted / ErrPDFDamaged。
- 诚实边界:无文本层(扫描件)⇒ ErrNoTextLayer 且文案含「图片型/无文本层 PDF,需要 OCR(当前未做)」;加密/损坏各自可判、绝不 panic、不回空正文假装成功。
- 白名单与成对裁决:.pdf 纳入(22→23),且 ContentAllowed 做双向裁决(.pdf 只收嗅探为 application/pdf;PDF 字节只收在 .pdf 名下);读侧真 PDF 如实下发 application/pdf,仍 attachment + nosniff(不可内联)。
- 1 处 panic 兜底已如实登记:该库对「xref 指向文件外」畸形件会在 NumPage() 直接 panic(已用仓库外程序复现),单二进制常驻后端不能因 587 字节上传件崩掉 ⇒ defer recover 折成 ErrPDFDamaged;损坏类主用例仍走库的真出错路径。
- 主会话独立验收:隔离 worktree(B1 的 14 文件)build/vet 绿、全树 59 包 0 FAIL、PDF 用例 13 PASS、双平台交叉编译绿;变异独立复现(魔数裁决短路 ⇒ 魔数/成对裁决用例红)。
- ⚠️ 契约漂移(已排 C2):Docs 仍写「22 项白名单 / 嗅探仅放行 text/plain」,与本切片的 23 项 + 成对裁决不一致 ⇒ 需升 v0.61 登记 .pdf 与新的边界。
193 lines
8.7 KiB
Go
193 lines
8.7 KiB
Go
package documents
|
||
|
||
// 本文件实现 **PDF 文本层**抽取(切片 B1;design/24 §4 路线①、§7 的 S6 第一段)。
|
||
//
|
||
// 能力边界(主人 2026-10-04 夜裁决):
|
||
// - **文本层 PDF 能读、且要读**:读出文档对象图与页内容流里的文本算子
|
||
// (`Tj` / `TJ` / `'` / `"`),把正文交给既有的 sidecar 链路,与纯文本类
|
||
// 走同一条注入路径;
|
||
// - **扫描件/图片型 PDF 读不了**:那类文件没有文本层,必须先栅格化再 OCR,而
|
||
// 纯 Go 没有可靠的栅格化实现(pdfium/poppler/mupdf 都是 cgo 或原生库)⇒ 会撞
|
||
// 「单二进制 + linux/darwin 交叉编译」与「零跨进程 IPC」两条红线 ⇒ **本切片
|
||
// 不做**;但拒绝文案必须说清「这是图片型/无文本层 PDF,需要 OCR 才能读」。
|
||
//
|
||
// 判定只用**魔数**(文件头 `%PDF-`),不看文件名、也不看声明的 MIME —— 与全项目
|
||
// 「字节嗅探是唯一裁决」同源(存储侧的 `ContentAllowed` 是同一条口径的另一半)。
|
||
//
|
||
// 失败一律显式、且**分类可判**(`errors.Is`):无文本层 → `ErrNoTextLayer`、
|
||
// 加密打不开 → `ErrPDFEncrypted`、损坏/截断/版本不受支持 → `ErrPDFDamaged`。
|
||
// **绝不 panic、绝不返回空正文假装成功**(design/24 §4.4)。
|
||
//
|
||
// 依赖:`github.com/ledongthuc/pdf`(纯 Go、零传递依赖、BSD-3-Clause「Go Authors」;
|
||
// 见 `LICENSE` 原文与切片 B1 记录的许可/体积增量)。无 cgo、无外部进程,因此
|
||
// `GOOS=linux/darwin` 交叉编译照旧,也不触「单二进制 + 零跨进程 IPC」两条红线。
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/ledongthuc/pdf"
|
||
)
|
||
|
||
// PDF 抽取的两个哨兵错误。调用方(摄取层把结论折成注入模型的原因短句)一律用
|
||
// `errors.Is` 分类,绝不按错误文本匹配 —— 与 `ErrUnsupported` / `ErrNoTextLayer`
|
||
// 同一口径。
|
||
var (
|
||
// ErrPDFEncrypted 报告这是一份加密 PDF,且用空口令打不开。
|
||
ErrPDFEncrypted = errors.New("documents: PDF 已加密(空口令无法打开)")
|
||
// ErrPDFDamaged 报告 PDF 损坏、被截断,或它的版本/结构不被解析器接受。
|
||
ErrPDFDamaged = errors.New("documents: PDF 损坏或无法解析")
|
||
)
|
||
|
||
const (
|
||
// pdfMagic 是 PDF 的魔数(文件头前 5 字节)。判定只看字节本身。
|
||
pdfMagic = "%PDF-"
|
||
|
||
// maxPDFPages 是单份 PDF 的**页数上限**(安全阀):页数来自 trailer 的
|
||
// `/Count`,畸形文件可以声明成任意大;没有这个上限,一份几百字节的恶意
|
||
// PDF 就能让抽取循环跑上亿次。抽到上限即停,并置 Truncated —— 与撞上
|
||
// `MaxSidecarBytes` 同一语义:「正文到此为止」绝不等于「原文件到此为止」。
|
||
maxPDFPages = 5000
|
||
|
||
// pdfPageJoin 分隔页与页之间的正文。
|
||
pdfPageJoin = "\n"
|
||
|
||
// pdfTrailerProbeBytes 是「加密判定」的扫描窗口:PDF 的 trailer(或 xref
|
||
// 流对象)一定在文件末尾附近,`/Encrypt` 键因此落在这最后一段里。
|
||
pdfTrailerProbeBytes = 2048
|
||
|
||
// pdfEncryptKey 是 trailer 里标记加密的键名。
|
||
pdfEncryptKey = "/Encrypt"
|
||
)
|
||
|
||
// hasPDFMagic 报告字节流是否以 PDF 魔数开头。它是本包对 PDF 的**唯一入口判定**:
|
||
// 扩展名与声明的 MIME 都不参与(与存储侧的字节嗅探同一裁决口径)。
|
||
func hasPDFMagic(data []byte) bool {
|
||
return bytes.HasPrefix(data, []byte(pdfMagic))
|
||
}
|
||
|
||
// extractPDF 把一份 PDF 的文本层抽成正文。data 是整份文件字节(调用方已按上限
|
||
// 读过一遍)。
|
||
//
|
||
// 失败分类:无文本层(整份文件一页正文都没有)→ `ErrNoTextLayer`;加密 →
|
||
// `ErrPDFEncrypted`;其余(格式非法、截断、页对象图坏掉)→ `ErrPDFDamaged`。
|
||
func extractPDF(data []byte) (res Result, err error) {
|
||
// 解析库用 panic 传递内部解析错误,并在它的 NewReader / GetPlainText 上把
|
||
// panic 收成 error —— **但遍历类接口(NumPage / Page)本身没有收**,畸形
|
||
// xref 能把 panic 直接抛到调用方。单二进制后端绝不能因为一份上传文件整个
|
||
// 进程崩掉,因此这里再兜一层:兜到的 panic 一律按「损坏」如实上报,
|
||
// **绝不吞成成功**(拒绝路径仍然是真出错路径,见本文件顶部的失败口径)。
|
||
defer func() {
|
||
if rec := recover(); rec != nil {
|
||
res = Result{}
|
||
err = fmt.Errorf("documents: pdf 解析器 panic: %v: %w", rec, ErrPDFDamaged)
|
||
}
|
||
}()
|
||
reader, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
|
||
if err != nil {
|
||
return Result{}, pdfOpenError(data, err)
|
||
}
|
||
total := reader.NumPage()
|
||
if total <= 0 {
|
||
// 声明 0 页或页树读不出:这不是「没文本层」(那是扫描件),而是文件本身
|
||
// 不成立 —— 归为损坏,绝不报成「需要 OCR」。
|
||
return Result{}, fmt.Errorf("documents: pdf 声明页数 %d: %w", total, ErrPDFDamaged)
|
||
}
|
||
window, pageCapped := pdfPageWindow(total)
|
||
body, sizeCapped, err := pdfText(reader, window)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
if strings.TrimSpace(body) == "" {
|
||
return Result{}, fmt.Errorf("documents: pdf 共 %d 页无文本层: %w", total, ErrNoTextLayer)
|
||
}
|
||
res = Result{
|
||
Kind: kindPDF,
|
||
Pages: total,
|
||
Text: body,
|
||
Truncated: pageCapped || sizeCapped,
|
||
ExtractorVersion: ExtractorVersion,
|
||
}
|
||
if len(res.Text) > MaxSidecarBytes {
|
||
res.Text = prefixBytes(res.Text, MaxSidecarBytes)
|
||
res.Truncated = true
|
||
}
|
||
res.Chars = utf8.RuneCountInString(res.Text)
|
||
return res, nil
|
||
}
|
||
|
||
// pdfPageWindow 把声明的页数收敛到安全上限内,并报告是否被收敛(被收敛即
|
||
// 「正文只抽了前 maxPDFPages 页」,抽取结论必须如实标记 Truncated)。
|
||
func pdfPageWindow(total int) (int, bool) {
|
||
if total > maxPDFPages {
|
||
return maxPDFPages, true
|
||
}
|
||
return total, false
|
||
}
|
||
|
||
// pdfText 逐页抽正文,页与页之间以换行分隔。命中正文安全上限(MaxSidecarBytes)
|
||
// 即停止并报告 capped:既不让一份超大 PDF 把堆撑爆,也绝不静默丢掉后面的页。
|
||
//
|
||
// 页序号从 1 起(解析库的口径)。某一页解析失败就整份报告损坏 —— 「抽到一半
|
||
// 的正文」配不上「这份 PDF 读得到」这个结论,宁可诚实失败也不给半份正文。
|
||
func pdfText(reader *pdf.Reader, pages int) (string, bool, error) {
|
||
var body strings.Builder
|
||
capped := false
|
||
for i := 1; i <= pages; i++ {
|
||
if body.Len() >= MaxSidecarBytes {
|
||
capped = true
|
||
break
|
||
}
|
||
text, err := reader.Page(i).GetPlainText(nil)
|
||
if err != nil {
|
||
return "", false, fmt.Errorf("documents: pdf 第 %d 页: %w: %w", i, ErrPDFDamaged, err)
|
||
}
|
||
// 逐页去掉首尾空白:解析库在文本对象边界会补换行,不整理就会在页之间
|
||
// 摞出空行;页内文本本身一字不动。整页只有空白 ⇒ 该页没有文本层,跳过。
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
continue
|
||
}
|
||
if body.Len() > 0 {
|
||
body.WriteString(pdfPageJoin)
|
||
}
|
||
body.WriteString(text)
|
||
}
|
||
return cleanPDFText(body.String()), capped, nil
|
||
}
|
||
|
||
// cleanPDFText 让抽出的正文满足本包的正文口径:合法 UTF-8(非法字节替换为
|
||
// U+FFFD,绝不因编码问题失败或 panic),并去掉 NUL 字节(PDF 字符串允许 NUL,
|
||
// 它进模型可见文本只是噪声)。
|
||
func cleanPDFText(s string) string {
|
||
return strings.ReplaceAll(sanitizeUTF8(s), "\x00", "")
|
||
}
|
||
|
||
// pdfOpenError 把解析器的打开错误折成两个哨兵之一:空口令打不开的加密文件是
|
||
// 「加密」,其余(缺 `%%EOF`、`startxref`/xref 读不出、头非法、版本不受支持)
|
||
// 是「损坏」。
|
||
//
|
||
// 解析器只对「口令不对」给了哨兵(`pdf.ErrInvalidPassword`),AES-256 一类
|
||
// **加密方式不受支持**的现代加密 PDF 只回一条普通错误;因此补一次 trailer 判据,
|
||
// 免得把「加密」如实报成「损坏」(两种失败对用户的下一步动作完全不同)。
|
||
func pdfOpenError(data []byte, err error) error {
|
||
if errors.Is(err, pdf.ErrInvalidPassword) || pdfTrailerMentionsEncrypt(data) {
|
||
return fmt.Errorf("documents: pdf 打开: %w: %w", ErrPDFEncrypted, err)
|
||
}
|
||
return fmt.Errorf("documents: pdf 打开: %w: %w", ErrPDFDamaged, err)
|
||
}
|
||
|
||
// pdfTrailerMentionsEncrypt 报告文件末尾的 trailer 区是否声明了 `/Encrypt`。
|
||
// 它只是**辅助判据**(仅在打开失败后用来区分加密与损坏),因此宁可宽一点:
|
||
// 尾部出现过该键名即认作加密,判错也只会把分类偏向「加密」那一侧。
|
||
func pdfTrailerMentionsEncrypt(data []byte) bool {
|
||
tail := data
|
||
if len(tail) > pdfTrailerProbeBytes {
|
||
tail = tail[len(tail)-pdfTrailerProbeBytes:]
|
||
}
|
||
return bytes.Contains(tail, []byte(pdfEncryptKey))
|
||
}
|