Files
Cyrene_v2/internal/runtime/documents/extract_pdf_test.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

338 lines
14 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)的对外事实:
//
// - 真 PDF(带文本层)按**魔数**认出并抽出正文(不靠扩展名、不靠声明 MIME);
// - 图片型/无文本层 PDF **诚实拒绝**(ErrNoTextLayer,文案说清「需要 OCR」);
// - 损坏/截断/畸形 xref ⇒ 明确错误、**不 panic**(真出错路径,不是 recover 兜出来的);
// - 加密 ⇒ 独立的哨兵(ErrPDFEncrypted,与「损坏」可区分);
// - 正文仍受安全上限(MaxSidecarBytes)与页数上限约束,且截断**如实标记**。
//
// 夹具 PDF **全部由 buildPDF 现场拼出**(不下载任何二进制):对象体是字面量,
// xref 的字节偏移由函数自己算准 —— 这既是「手写的极简合法 PDF」,也让每个用例
// 想要什么结构就拼什么结构(多页 / 无文本层 / 加密 / 断 xref)。
//
// 夹具正文一律用 ASCII:解析库对未声明 /ToUnicode 的字体按标准编码逐字节映射,
// 多字节字符在那里必然走样 —— 那是抽取库的能力边界,不是本包的口径,因此不在
// 这里断言(本包对抽出的正文只保证「合法 UTF-8」,见 cleanPDFText 的用例)。
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"unicode/utf8"
)
// pdfFixture 描述一份手工拼出来的极简 PDF(单页或多页)。
type pdfFixture struct {
// Contents 是各页的**完整内容流**(一页一条),用 textContent/imageContent 拼。
Contents []string
// DeclaredCount 覆盖 Pages 的 `/Count`(0 = 取 len(Contents)):用来造
// 「声明的页数与实际不符 / 声称 0 页」这类畸形件。
DeclaredCount int
// Encrypted 为真时追加一个 /Encrypt 对象并连进 trailer(加密件)。
Encrypted bool
// BreakXref 为真时把对象 1 的 xref 偏移写成 999999(指向文件之外),
// 造出「xref 表能读、指向的对象读不出来」这一类畸形 —— 解析器会在遍历
// 页树时抛 panic,正是本包必须兜住、且必须如实报成「损坏」的形态。
BreakXref bool
}
// textContent 拼一段「画一行文本」的页内容流(唯一的文本算子路径)。
func textContent(s string) string {
return "BT /F1 18 Tf 72 720 Td (" + pdfEscape(s) + ") Tj ET"
}
// imageContent 拼一段**不含任何文本算子**的页内容流:矩形填充。它就是
// 「图片型/扫描件页」在解析器眼里的样子(有页、有内容流、但抽不出文本)。
const imageContent = "q 0 0 1 rg 100 100 200 200 re f Q"
// pdfEscape 转义 PDF 字面量字符串里的三个特殊字符。
func pdfEscape(s string) string {
r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`)
return r.Replace(s)
}
// buildPDF 把夹具拼成一份**合法**的 PDF 字节流:对象体 + 算准偏移的 xref 表 +
// trailer + startxref + %%EOF,头是 `%PDF-1.4`(解析库只接受 1.0–1.7 的头)。
func buildPDF(t *testing.T, fix pdfFixture) []byte {
t.Helper()
count := fix.DeclaredCount
if count == 0 {
count = len(fix.Contents)
}
// 对象号:1 = Catalog、2 = Pages、3..= Page 们、之后 = Font、各页内容流、
// 最后(可选)= Encrypt。
fontID := 3 + len(fix.Contents)
firstContentID := fontID + 1
kids := make([]string, 0, len(fix.Contents))
for i := range fix.Contents {
kids = append(kids, fmt.Sprintf("%d 0 R", 3+i))
}
objs := make([]string, 0, firstContentID+len(fix.Contents)+1)
objs = append(objs, "<< /Type /Catalog /Pages 2 0 R >>")
objs = append(objs, fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), count))
for i := range fix.Contents {
objs = append(objs, fmt.Sprintf(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 %d 0 R >> >> /Contents %d 0 R >>",
fontID, firstContentID+i))
}
objs = append(objs, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
for _, content := range fix.Contents {
objs = append(objs, fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content))
}
encryptID := 0
if fix.Encrypted {
encryptID = len(objs) + 1
// 形状合法的标准加密字典(V=1/R=2/40 位、O 与 U 各 32 字节),只是
// O/U 是占位字节 —— 空口令必然对不上,因此它是一份「真加密」的件。
objs = append(objs, fmt.Sprintf(
"<< /Filter /Standard /V 1 /R 2 /Length 40 /P -1 /O (%s) /U (%s) >>",
strings.Repeat("A", 32), strings.Repeat("B", 32)))
}
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
offsets := make([]int, 0, len(objs))
for i, body := range objs {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, body)
}
if fix.BreakXref && len(offsets) > 0 {
offsets[0] = 999999
}
xrefOffset := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n", len(objs)+1)
buf.WriteString("0000000000 65535 f \n")
for _, off := range offsets {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R", len(objs)+1)
if fix.Encrypted {
fmt.Fprintf(&buf, " /Encrypt %d 0 R /ID [<>]", encryptID)
}
buf.WriteString(" >>\n")
fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", xrefOffset)
return buf.Bytes()
}
// TestExtract_PDFTextLayerExtracted 覆盖验收第 1 条:带文本层的**多页** PDF 能
// 抽出正文,页与页以换行分隔,页数随结果返回,种类是 pdf。
func TestExtract_PDFTextLayerExtracted(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{
textContent("Page one body"),
textContent("Page two body"),
}})
res, err := Extract(pdfName, PDFMIME, data)
if err != nil {
t.Fatalf("Extract(pdf) = %v, want nil", err)
}
for _, want := range []string{"Page one body", "Page two body"} {
if !strings.Contains(res.Text, want) {
t.Fatalf("正文缺少 %q: %q", want, res.Text)
}
}
if first, second, ok := strings.Cut(res.Text, "\n"); !ok ||
first != "Page one body" || second != "Page two body" {
t.Fatalf("页序/分隔不对: %q", res.Text)
}
if res.Kind != kindPDF || res.Pages != 2 {
t.Fatalf("Kind/Pages = (%q,%d), want (%q,2)", res.Kind, res.Pages, kindPDF)
}
if res.Truncated {
t.Fatal("Truncated = true, want false")
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
}
if res.ExtractorVersion != ExtractorVersion {
t.Fatalf("ExtractorVersion = %q, want %q", res.ExtractorVersion, ExtractorVersion)
}
}
// TestExtract_PDFJudgedByMagicNotByName 钉住「魔数唯一裁决」:同一份 PDF 字节
// 顶着 `.txt` 名、声明 `text/plain` 也照样按 PDF 抽(与存储侧的字节嗅探同源)。
func TestExtract_PDFJudgedByMagicNotByName(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{textContent("magic decides")}})
res, err := Extract(txtName, "text/plain", data)
if err != nil {
t.Fatalf("Extract(pdf bytes as .txt) = %v, want nil(魔数说了算)", err)
}
if res.Kind != kindPDF || !strings.Contains(res.Text, "magic decides") {
t.Fatalf("Result = (%q,%q), want 按 PDF 抽出正文", res.Kind, res.Text)
}
}
// TestExtract_PDFWithoutTextLayerIsHonest 覆盖验收第 2 条:图片型/无文本层 PDF
// **诚实拒绝** —— ErrNoTextLayer 哨兵 + 文案说清「需要 OCR(当前未做)」,
// 且绝不返回「空正文 + nil 错误」。
func TestExtract_PDFWithoutTextLayerIsHonest(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{imageContent}})
res, err := Extract(pdfName, PDFMIME, data)
if !errors.Is(err, ErrNoTextLayer) {
t.Fatalf("Extract(扫描件) = %v, want ErrNoTextLayer", err)
}
if res.Text != "" || res.Chars != 0 {
t.Fatalf("失败却回了一份正文: %+v", res)
}
msg := err.Error()
for _, want := range []string{"无文本层", "OCR", "图片型"} {
if !strings.Contains(msg, want) {
t.Fatalf("错误文案 %q 缺少 %q(必须让用户知道这是要 OCR 的图)", msg, want)
}
}
// 多页里只要有一页有文本层就算读到了(扫描件夹一页可复制文字不该整份报废)。
mixed := buildPDF(t, pdfFixture{Contents: []string{imageContent, textContent("scanned plus text")}})
if mres, merr := Extract(pdfName, PDFMIME, mixed); merr != nil || !strings.Contains(mres.Text, "scanned plus text") {
t.Fatalf("图文混排 = (%+v, %v), want 抽出那一页的文本", mres, merr)
}
}
// TestExtract_PDFDamagedIsErrorNotPanic 覆盖验收第 3 条:损坏/截断/畸形 xref
// 一律**报错且不 panic**。它走的全是真出错路径(缺 %%EOF、startxref 指不到
// xref、对象偏移指到文件之外),不是 recover 兜出来的假失败。
func TestExtract_PDFDamagedIsErrorNotPanic(t *testing.T) {
t.Parallel()
valid := buildPDF(t, pdfFixture{Contents: []string{textContent("body")}})
cases := []struct {
label string
data []byte
}{
{"只有头", []byte("%PDF-1.4\n")},
{"缺 %%EOF", []byte("%PDF-1.4\n1 0 obj\n<< >>\nendobj\n")},
{"startxref 指不到 xref", []byte("%PDF-1.4\nstartxref\n0\n%%EOF\n")},
{"截断掉一半", valid[:len(valid)/2]},
{"xref 指到文件之外", buildPDF(t, pdfFixture{Contents: []string{textContent("body")}, BreakXref: true})},
}
for _, tc := range cases {
res, err := Extract(pdfName, PDFMIME, tc.data)
if !errors.Is(err, ErrPDFDamaged) {
t.Fatalf("%s: err = %v, want ErrPDFDamaged", tc.label, err)
}
if errors.Is(err, ErrNoTextLayer) || errors.Is(err, ErrPDFEncrypted) {
t.Fatalf("%s: err = %v, 不该被归成「无文本层」或「加密」", tc.label, err)
}
if res.Text != "" || res.Chars != 0 || res.Pages != 0 {
t.Fatalf("%s: 失败却回了一份正文: %+v", tc.label, res)
}
}
}
// TestExtract_PDFZeroPagesIsDamaged 钉住边界:声明 0 页不是「没有文本层」(那是
// 扫描件),而是文件本身不成立 —— 绝不报成「需要 OCR」。
func TestExtract_PDFZeroPagesIsDamaged(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{DeclaredCount: -1})
if _, err := Extract(pdfName, PDFMIME, data); !errors.Is(err, ErrPDFDamaged) {
t.Fatalf("声明 %d 页: err = %v, want ErrPDFDamaged", -1, err)
}
}
// TestExtract_PDFEncryptedIsDistinct 钉住加密的**独立哨兵**:加密件报
// ErrPDFEncrypted(下一步动作是「解除密码」),与「损坏」可区分。
func TestExtract_PDFEncryptedIsDistinct(t *testing.T) {
t.Parallel()
data := buildPDF(t, pdfFixture{Contents: []string{textContent("secret")}, Encrypted: true})
res, err := Extract(pdfName, PDFMIME, data)
if !errors.Is(err, ErrPDFEncrypted) {
t.Fatalf("Extract(加密件) = %v, want ErrPDFEncrypted", err)
}
if errors.Is(err, ErrPDFDamaged) || errors.Is(err, ErrNoTextLayer) {
t.Fatalf("加密件被归成了别的失败: %v", err)
}
if res.Text != "" {
t.Fatalf("加密件却回了正文: %+v", res)
}
}
// TestExtract_PDFDeclaredWithoutMagicIsRejected 钉住反向:名字或声明指向 PDF、
// 字节却不是真 PDF 时,**绝不退回按纯文本抽** —— 否则「HTML 改名成 .pdf」就会以
// 文本身份进模型上下文(存储侧的同一条判定见 ContentAllowed 的用例)。
func TestExtract_PDFDeclaredWithoutMagicIsRejected(t *testing.T) {
t.Parallel()
cases := []struct {
label string
name string
mime string
data []byte
}{
{"HTML 改名成 .pdf", pdfName, "text/html", []byte("<!DOCTYPE html><html>hi</html>")},
{"纯文本改名成 .pdf", pdfName, "text/plain", []byte("这只是一段普通文本\n")},
{"声明 application/pdf 却是文本", txtName, PDFMIME, []byte("hi")},
}
for _, tc := range cases {
res, err := Extract(tc.name, tc.mime, tc.data)
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("%s: err = %v, want ErrUnsupported", tc.label, err)
}
if res.Text != "" {
t.Fatalf("%s: 失败却回了正文 %q", tc.label, res.Text)
}
}
}
// TestExtract_PDFHonoursSafetyCap 覆盖验收第 5 条(安全上限侧):正文撞上
// MaxSidecarBytes 即停,并把**没抽到的页**如实标成 Truncated —— 截断必须可见。
func TestExtract_PDFHonoursSafetyCap(t *testing.T) {
t.Parallel()
// 第一页正文恰好顶到安全上限(逐页整理首尾空白后正好是这串 A),第二页有真
// 文本:上限命中后第二页必须被丢掉,且这件事必须写进 Truncated。
huge := strings.Repeat("A", MaxSidecarBytes)
data := buildPDF(t, pdfFixture{Contents: []string{textContent(huge), textContent("second page never reached")}})
res, err := Extract(pdfName, PDFMIME, data)
if err != nil {
t.Fatalf("Extract(超大正文) = %v, want nil", err)
}
if !res.Truncated {
t.Fatal("Truncated = false, want true(页被丢掉了却不标记,就是静默截断)")
}
if len(res.Text) > MaxSidecarBytes {
t.Fatalf("正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes)
}
if strings.Contains(res.Text, "second page never reached") {
t.Fatal("正文里出现了上限之后那一页的内容(截断没有真的生效)")
}
if res.Chars != utf8.RuneCountInString(res.Text) {
t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text))
}
}
// TestExtract_PDFDeclaresFullPageCountWhenCapped 钉住「声明的页数」与「实际抽了几
// 页」不是一回事:撞上页数上限时 Truncated 必须为真,避免把前 N 页的正文冒充全文。
func TestExtract_PDFDeclaresFullPageCountWhenCapped(t *testing.T) {
t.Parallel()
if pages, capped := pdfPageWindow(maxPDFPages); pages != maxPDFPages || capped {
t.Fatalf("pdfPageWindow(%d) = (%d,%v), want (%d,false)", maxPDFPages, pages, capped, maxPDFPages)
}
if pages, capped := pdfPageWindow(maxPDFPages + 1); pages != maxPDFPages || !capped {
t.Fatalf("pdfPageWindow(%d) = (%d,%v), want (%d,true)", maxPDFPages+1, pages, capped, maxPDFPages)
}
if pages, capped := pdfPageWindow(1); pages != 1 || capped {
t.Fatalf("pdfPageWindow(1) = (%d,%v), want (1,false)", pages, capped)
}
}
// TestCleanPDFTextKeepsUTF8AndDropsNUL 钉住抽出的正文仍守本包的正文口径:
// 非法 UTF-8 替换、NUL 去掉,绝不因编码问题失败或把 NUL 送进上下文。
func TestCleanPDFTextKeepsUTF8AndDropsNUL(t *testing.T) {
t.Parallel()
raw := "前缀\x00中\xff间\x00后缀"
got := cleanPDFText(raw)
if !utf8.ValidString(got) {
t.Fatalf("cleanPDFText 结果不是合法 UTF-8: %q", got)
}
if strings.ContainsRune(got, 0) {
t.Fatalf("cleanPDFText 结果仍含 NUL: %q", got)
}
for _, want := range []string{"前缀", "中", "间", "后缀", utf8Replacement} {
if !strings.Contains(got, want) {
t.Fatalf("cleanPDFText(%q) = %q, 缺少 %q", raw, got, want)
}
}
}