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 与新的边界。
This commit is contained in:
1 parent
c4e09afd38
commit
f82c76df27
14 files changed
+926
-94
No files matched your search
@@ -129,20 +129,24 @@ func TestResolver_KeepsFullBodyAndRecordsSafetyTruncationInSidecar(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):PDF
|
||||
// 得到 unreadable + 诚实原因,清单/回传状态有来源;失败同样只抽一次。
|
||||
// TestResolver_UnreadableIsHonestAndCached 钉住失败可见(design/24 §4.4):图片型/
|
||||
// 无文本层 PDF 得到 unreadable + **诚实原因**(必须点明「需要 OCR」),清单/回传
|
||||
// 状态有来源;失败同样只抽一次。
|
||||
func TestResolver_UnreadableIsHonestAndCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
name := layout.writeUpload(t, "0123456789abcdef0123456789abcdef.pdf", []byte("%PDF-1.7\n"))
|
||||
name := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}}))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, body := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("Status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
|
||||
}
|
||||
if body != reasonNoTextLayer {
|
||||
t.Fatalf("body = %q, want %q", body, reasonNoTextLayer)
|
||||
if body != reasonPDFNoTextLayer {
|
||||
t.Fatalf("body = %q, want %q", body, reasonPDFNoTextLayer)
|
||||
}
|
||||
if !strings.Contains(body, "OCR") {
|
||||
t.Fatalf("原因文案 %q 没点明需要 OCR(用户要知道这份 PDF 得先转文字)", body)
|
||||
}
|
||||
if _, err := os.Stat(layout.sidecarPath(name)); err != nil {
|
||||
t.Fatalf("失败的抽取也应留下 sidecar(只抽一次同样适用于失败): %v", err)
|
||||
@@ -150,11 +154,53 @@ func TestResolver_UnreadableIsHonestAndCached(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join(layout.uploads, name)); err != nil {
|
||||
t.Fatalf("删除原文件: %v", err)
|
||||
}
|
||||
if _, body2 := resolveOne(t, r, name); body2 != reasonNoTextLayer {
|
||||
if _, body2 := resolveOne(t, r, name); body2 != reasonPDFNoTextLayer {
|
||||
t.Fatalf("第二次 body = %q, want 从 sidecar 读回同一原因", body2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_PDFPagesAndKindReachTheSidecar 钉住切片 B1 的 sidecar 元数据:
|
||||
// PDF 的抽取结论带 `kind=pdf` 与**页数** —— 两者随 sidecar 落盘、可被读侧取回,
|
||||
// 因此「这份 PDF 一共产出多少页」在缓存里是可查的事实(不是只留在日志里)。
|
||||
func TestResolver_PDFPagesAndKindReachTheSidecar(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
name := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{
|
||||
textContent("first page"),
|
||||
textContent("second page"),
|
||||
textContent("third page"),
|
||||
}}))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ref, body := resolveOne(t, r, name)
|
||||
if ref.Status != history.DocumentStatusExtracted || !strings.Contains(body, "third page") {
|
||||
t.Fatalf("解析 = (%+v, %q), want extracted 且含第三页正文", ref, body)
|
||||
}
|
||||
side, err := ReadSidecar(layout.dataDir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSidecar: %v", err)
|
||||
}
|
||||
if side.Kind != kindPDF || side.Pages != 3 {
|
||||
t.Fatalf("sidecar = (kind=%q, pages=%d), want (%q,3)", side.Kind, side.Pages, kindPDF)
|
||||
}
|
||||
raw, err := os.ReadFile(layout.sidecarPath(name))
|
||||
if err != nil {
|
||||
t.Fatalf("读 sidecar: %v", err)
|
||||
}
|
||||
header, _, ok := strings.Cut(string(raw), sidecarHeaderEnd)
|
||||
if !ok {
|
||||
t.Fatalf("sidecar 缺少元数据行: %q", raw)
|
||||
}
|
||||
var meta sidecarMeta
|
||||
if err := json.Unmarshal([]byte(header), &meta); err != nil {
|
||||
t.Fatalf("元数据行解码失败: %v (%q)", err, header)
|
||||
}
|
||||
if meta.Kind != kindPDF || meta.Pages != 3 || meta.Version != ExtractorVersion {
|
||||
t.Fatalf("落盘元数据 = (kind=%q, pages=%d, v=%q), want (%q,3,%q)",
|
||||
meta.Kind, meta.Pages, meta.Version, kindPDF, ExtractorVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolver_MissingFileIsUnreadable 钉住「文件不存在」不是 error、也不是
|
||||
// 静默:它是一条 unreadable 结论 + 诚实原因(回合照常继续)。
|
||||
func TestResolver_MissingFileIsUnreadable(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user