- 依赖: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 与新的边界。
338 lines
14 KiB
Go
338 lines
14 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|