feat(documents): PDF 文本层抽取(S6 第一段;扫描件仍诚实拒绝)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 依赖: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 与新的边界。
This commit is contained in:
AskaEth committed 2026-10-04 19:46:36 +08:00
1 parent c4e09afd38
commit f82c76df27
14 files changed
+926 -94

No files matched your search

+49 -23
View File
@@ -1,10 +1,12 @@
// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文本类文档
// Package documents 实现文档摄取层(design/24 §4):把一份已落盘的文档
// 抽成可注入模型可见上下文的正文,并把结果缓存进 sidecar(只抽一次)。
//
// 本期只处理纯文本类(design/24 §1.2 的 P0):抽取是**纯函数**、零新依赖、
// 不触网、不落盘。PDF/Office 按 §8-1 的默认裁决**诚实拒绝**(返回
// ErrNoTextLayer):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者会让
// 上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。
// 本期处理**纯文本类**(design/24 §1.2 的 P0)与 **PDF 的文本层**(切片 B1,
// design/24 §7 的 S6 第一段):抽取是**纯函数**、不触网、不落盘(sidecar 由
// 装配期写)。Office(.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)与
// **图片型/扫描件 PDF** 按 §8-1 的裁决**诚实拒绝**(返回 `ErrNoTextLayer`,
// 文案说清「需要 OCR」):绝不假装成功,也绝不返回「空正文 + nil 错误」——后者
// 会让上游把「读不到」误当成「读到了空文件」,正是 F2 要消灭的那种误解。
package documents
import (
@@ -26,7 +28,11 @@ const (
//
// v2(切片 T1 §7.2):抽取期**不再**按注入预算截断,只在安全上限
// (MaxSidecarBytes)处截断 —— 旧缓存里的正文可能少了一大截,必须重抽。
ExtractorVersion = "documents/v2"
//
// v3(切片 B1):**PDF 文本层开始能抽**(原先一律诚实拒绝),且失败原因的
// 分类与文案全变了(无文本层/加密/损坏各自成哨兵)—— 旧缓存里的 unreadable
// 结论已经是错的,必须重抽。
ExtractorVersion = "documents/v3"
// MaxSidecarBytes 是 sidecar 正文的**安全上限**(字节,8 MiB 文本)。
//
@@ -49,10 +55,11 @@ const (
// 目的是挡住被误存进来的二进制,不是做通用格式识别。
binaryProbeBytes = 512
// kindText / kindCSV / kindTSV 是 Result.Kind 的取值。
// kindText / kindCSV / kindTSV / kindPDF 是 Result.Kind 的取值。
kindText = "text"
kindCSV = "csv"
kindTSV = "tsv"
kindPDF = "pdf"
// csvDelim / tsvDelim 是摘要切列名用的分隔符。
csvDelim = ","
@@ -75,9 +82,11 @@ var (
// ErrUnsupported 报告内容不在可抽取的纯文本范围内(二进制、非文本类型)。
ErrUnsupported = errors.New("documents: 内容不在可抽取的纯文本范围内")
// ErrNoTextLayer 报告这是一份需要文本层解析的格式(PDF/Office):本期
// 按 design/24 §8-1 诚实拒绝,不引解析库(单二进制体积与失败面)。
ErrNoTextLayer = errors.New("documents: 无文本层(PDF/Office 本期不支持解析)")
// ErrNoTextLayer 报告这份内容的格式需要文本层,而我们读不出文本层:一类是
// **图片型/扫描件 PDF**(它本就没有文本层,要先栅格化再 OCR,纯 Go 没有可靠
// 的栅格化实现 —— 切片 B1 的口径),另一类是本期不解析的 **Office**
// (.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf)。
ErrNoTextLayer = errors.New("documents: 无文本层(图片型/扫描件 PDF 需 OCR;Office 本期不支持解析)")
)
// Result 是一次抽取的产物。它是抽取器的全部对外事实:正文、字符数、是否
@@ -92,8 +101,11 @@ type Result struct {
// 它与「注入被截断」是两件事:后者由装配期按注入预算判定并写进
// history.DocumentRef(切片 T1 §7.2)。
Truncated bool
// Kind 是抽取出内容的种类(text / csv / tsv)。
// Kind 是抽取出内容的种类(text / csv / tsv / pdf)。
Kind string
// Pages 是 PDF 的页数(其它种类恒为 0)。它随 sidecar 元数据落盘,用来让
// 「这份 PDF 有多大」这件事在缓存里可见(切片 B1)。
Pages int
// ExtractorVersion 是产出本结果的抽取器版本。
ExtractorVersion string
}
@@ -101,20 +113,36 @@ type Result struct {
// Extract 把一份文档的字节抽成可注入的正文。它是**纯函数**:不读盘、不写盘、
// 不依赖任何外部状态,因此 sidecar 命中与未命中得到逐字相同的结果。
//
// name 是服务端存储名(扩展名决定 CSV/TSV 特例与办公格式的诚实拒绝),mime 是
// name 是服务端存储名(扩展名决定 CSV/TSV 特例与 Office 的诚实拒绝),mime 是
// 服务端字节嗅探所得:按 design/24 §3.2 的口径它只作**否决**参考,绝不让一份
// 不被认可的文档变成可抽取。
//
// 返回的错误一律是上面两个哨兵错误的包装;绝不 panic,也绝不返回「空正文 +
// 分派顺序(切片 B1 起):
// 1. **魔数 `%PDF-`** → 走 PDF 文本层抽取(`extract_pdf.go`),扩展名与 MIME
// 都不参与 —— 字节说了算,与存储侧的字节嗅探同源;
// 2. 名字/嗅探指向 PDF、字节却不是真 PDF → 明确拒绝(绝不退回按纯文本抽);
// 3. Office 扩展名 → `ErrNoTextLayer`;
// 4. 非纯文本声明、明显二进制 → `ErrUnsupported`;
// 5. 其余按纯文本抽(CSV/TSV 另加摘要行)。
//
// 返回的错误一律是上面那些哨兵错误的包装;绝不 panic,也绝不返回「空正文 +
// nil 错误」。
func Extract(name, mime string, data []byte) (Result, error) {
ext := strings.ToLower(filepath.Ext(name))
base := baseMIME(mime)
if hasPDFMagic(data) {
return extractPDF(data)
}
if ext == PDFExt || base == PDFMIME {
// 「说得像 PDF、字节却不是」:这是**声明与内容不一致**,按不支持拒绝。
// 绝不能退回按纯文本抽 —— 那等于让一份改名成 .pdf 的 HTML/脚本以
// 「文本」身份进模型上下文。
return Result{}, fmt.Errorf("documents: extract %s (mime %q): 缺少 PDF 魔数: %w", ext, base, ErrUnsupported)
}
if noTextLayerExt(ext) {
return Result{}, fmt.Errorf("documents: extract %s: %w", ext, ErrNoTextLayer)
}
if base := baseMIME(mime); base == pdfMIME {
return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrNoTextLayer)
} else if base != "" && !textualMIME(base) {
if base != "" && !textualMIME(base) {
return Result{}, fmt.Errorf("documents: extract mime %q: %w", base, ErrUnsupported)
}
if looksBinary(data) {
@@ -137,12 +165,13 @@ func Extract(name, mime string, data []byte) (Result, error) {
return res, nil
}
// noTextLayerExt 报告扩展名是否属于**诚实拒绝**的办公/文档格式(design/24
// §8-1 的默认裁决):它们的正文在压缩流或文本层里,本期不引解析库,因此明确
// 报告读不到,而不是把二进制塞进上下文。
// noTextLayerExt 报告扩展名是否属于**读不到文本层**的格式(design/24 §8-1 的
// 默认裁决):**Office 一族**的正文在压缩流或文本层里,本期不引解析库,因此明确
// 报告读不到,而不是把二进制塞进上下文。PDF **不在**这里 —— 它的文本层由
// `extract_pdf.go` 真抽(抽不到文本层的图片型 PDF 由那条路径给出 ErrNoTextLayer)。
func noTextLayerExt(ext string) bool {
switch ext {
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".odt", ".ods", ".odp", ".rtf":
return true
default:
@@ -150,9 +179,6 @@ func noTextLayerExt(ext string) bool {
}
}
// pdfMIME 是拒绝对应的嗅探基类型。
const pdfMIME = "application/pdf"
// baseMIME 把一个媒体类型归一为基类型(去参数、小写)。S1 的存储早已只落
// 基类型,这里只是不让带参数的输入影响判定。
func baseMIME(mt string) string {