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)) }