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("hi")}, {"纯文本改名成 .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) } } }