Files
Cyrene_v2/internal/runtime/documents/extract_pdf.go
T
AskaEth f82c76df27
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
- 依赖: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 与新的边界。
2026-10-04 19:46:36 +08:00

193 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}