feat(t73): PDF 抽取器接上 PDFium-via-wazero——leo 裁定的引擎,實測通過

 決定性實測:Chrome headless 列印的中文 PDF 完整抽出
   「船舶維修合約書/第一條 維修費用為新台幣 350,000 元整/2026 年 8 月 15 日」
   ——這正是 pdf-extraction-options.md §3 實測「純 Go 套件會整段亂碼」的那種檔,
   也是客戶最常丟的(瀏覽器列印)。選 PDFium 的理由至此坐實。

用戶不必裝任何東西(回答 leo「我的用戶可能不會自己裝 Python 環境」):
pdfium.wasm 由 wazero(純 Go WASM runtime)在本行程跑,embed 進執行檔。
實測 CGO_ENABLED=0 GOOS=windows 交叉編譯一行成功 → PE32+ executable x86-64,
證實「無 CGo」承諾為真,不加重 t72 負擔。

體積實測(有實際呼叫路徑,避開今天記過的 dead-code 陷阱):
  mac 14.53MB / windows 15.15MB(strip 後)
  leo 定調「全運作在客戶電腦上,肥一點喘一點也沒事」

設計決定:
- lazy init(sync.Once)——多數資料夾可能一個 PDF 都沒有,不該白付 WASM 啟動的記憶體
- pdfMu 互斥——pool 單 worker,別依賴「scan 是循序」這個假設
- 單頁失敗不放棄整份,但留「(第 N 頁讀取失敗)」痕跡
- 抽不出字 → ErrNoText(掃描件;PDFium 不做 OCR),絕不靜默送空內容

測試 4/4:Chrome PDF 中文正確/無文字回 ErrNoText/壞檔報錯可辨識
(『可能損壞或有密碼保護』)/連續多檔不爆(daemon 長駐會遇到)。
This commit is contained in:
2026-07-27 21:07:11 +08:00
parent a50cc37949
commit 55ed2c86a7
5 changed files with 219 additions and 2 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ var extractors = map[string]extractor{
".docx": extractDocx,
".csv": extractCSV,
".xlsx": extractXLSX,
// .pdf → PDFium-via-wazero(下一步接;體積 +10.43MB,實測 15頁/2.2MB=134ms
".pdf": extractPDF, // PDFium-via-wazero+10.43MB;用戶不必裝任何東西,見 convert_pdf.go
// .pptx → 同 docx 的 ZIP+XML 路數,數十行,體積幾乎不變
}
+105
View File
@@ -0,0 +1,105 @@
// convert_pdf.go — PDF 抽文字(PDFium via wazero,純 Go 無 CGo
//
// 為什麼 PDF 非要引擎不可(對照 docx/xlsx 只要 +0.31MB):
//
// PDF 是**印刷格式**——記的是「這個字畫在第 3 頁座標 (120,400)」,不是「第二段第五個字」。
// 要把散落的字元還原成通順文字,需要完整的排版還原邏輯。實測純 Go 的 PDF 套件
// 對「Chrome 列印出來的 PDF」直接吐整段亂碼(pdf-extraction-options.md §3),
// 而客戶最常丟的就是瀏覽器列印的 PDF。**把亂碼寫進知識庫比不收更糟**——
// 搜尋搜到垃圾、AI 讀到垃圾,而且用戶看不出來。
//
// 為什麼用戶不必裝任何東西(leo 2026-07-27 問「我的用戶可能不會自己裝 Python 環境」):
//
// PDFium 本體是 Google 的 C++ 引擎(Chrome 顯示 PDF 用的就是它),但我們用的是
// **編譯成 WASM 的版本**,由 wazero(純 Go 的 WASM runtime)在本行程內跑。
// `pdfium.wasm` 被 go:embed 進執行檔 ⇒ **用戶下載同一個 zip 解開就有**,
// 而且**我們自己也不需要 C/C++ 工具鏈**CGO_ENABLED=0 編得過、Windows 交叉編譯一行成功)。
//
// 代價(實測,非估算):collector 8.38 → 18.80 MB+10.43 MB,壓縮後 +4.77 MB)。
// leo 2026-07-27 定調:「它全運作在客戶的電腦上,**就算肥一點跑的喘一點也沒事**」。
// 速度:1 頁 36 ms15 頁 / 2.2 MB 134 ms。
//
// ⛔ **抽不出字的情況**:掃描件/翻拍的 PDF——PDFium **不做 OCR**,會回空字串。
// 這時回 ErrNoText 讓上層給用戶明確訊息,**絕不靜默當成空內容送出去**。
package main
import (
"fmt"
"strings"
"sync"
"time"
"github.com/klippa-app/go-pdfium"
"github.com/klippa-app/go-pdfium/requests"
"github.com/klippa-app/go-pdfium/webassembly"
)
var (
pdfPool pdfium.Pool
pdfPoolOnce sync.Once
pdfPoolErr error
// 同一時間只讓一個 goroutine 用 instance——pool 設定為單一 worker
// 而 daemon 可能併發處理多檔(scan 迴圈目前是循序,但別依賴那個假設)。
pdfMu sync.Mutex
)
// initPDFPool 延後到第一次真的要讀 PDF 時才初始化。
//
// 為什麼不用 init()WASM runtime 啟動要吃記憶體與時間,而**多數使用者的資料夾裡
// 可能一個 PDF 都沒有**。沒 PDF 就不該付這個代價。
//(注意:go:embed 的 wasm 位元組仍在執行檔裡,體積代價省不掉,省的是執行期記憶體。)
func initPDFPool() error {
pdfPoolOnce.Do(func() {
pdfPool, pdfPoolErr = webassembly.Init(webassembly.Config{
MinIdle: 1,
MaxIdle: 1,
MaxTotal: 1, // 單 worker 就夠:本用途是循序處理檔案,多開只是多吃記憶體
})
})
return pdfPoolErr
}
func extractPDF(data []byte) (string, error) {
if err := initPDFPool(); err != nil {
return "", fmt.Errorf("PDF 引擎啟動失敗:%w", err)
}
pdfMu.Lock()
defer pdfMu.Unlock()
inst, err := pdfPool.GetInstance(30 * time.Second)
if err != nil {
return "", fmt.Errorf("PDF 引擎取用失敗:%w", err)
}
defer inst.Close()
doc, err := inst.OpenDocument(&requests.OpenDocument{File: &data})
if err != nil {
// 密碼保護的 PDF 也會走到這裡——訊息要讓用戶看得懂是「打不開」而非「沒內容」。
return "", fmt.Errorf("PDF 打不開(可能損壞或有密碼保護):%w", err)
}
defer inst.FPDF_CloseDocument(&requests.FPDF_CloseDocument{Document: doc.Document})
pageCount, err := inst.FPDF_GetPageCount(&requests.FPDF_GetPageCount{Document: doc.Document})
if err != nil {
return "", fmt.Errorf("讀 PDF 頁數失敗:%w", err)
}
var sb strings.Builder
for i := 0; i < pageCount.PageCount; i++ {
txt, err := inst.GetPageText(&requests.GetPageText{
Page: requests.Page{ByIndex: &requests.PageByIndex{Document: doc.Document, Index: i}},
})
if err != nil {
// 單頁失敗不該讓整份放棄——有內容總比沒有好,但要留痕跡。
sb.WriteString(fmt.Sprintf("\n(第 %d 頁讀取失敗)\n", i+1))
continue
}
sb.WriteString(txt.Text)
sb.WriteString("\n")
}
// 抽不出字=掃描件/影像 PDF。回空字串會被 ConvertToText 轉成 ErrNoText
// 上層才能告訴用戶「這個檔看起來是掃描的圖,沒有文字可讀」。
return sb.String(), nil
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"errors"
"os"
"strings"
"testing"
"time"
)
const chromePDF = "/private/tmp/claude-501/-Users-youlinhsieh-Documents-tech-projects-InkStoneCo/92a75156-c295-4c79-bfeb-de1a20e4ed26/scratchpad/office-poc/chrome.pdf"
// 這是本抽取器存在的理由:調研實測「純 Go 的 PDF 套件對 Chrome 列印的 PDF 整段亂碼」
// pdf-extraction-options.md §3),而客戶最常丟的就是瀏覽器列印的 PDF。
// 所以測資刻意用 Chrome headless 產的,不是隨便一個 PDF。
func TestPDF_Chrome列印的中文PDF(t *testing.T) {
data, err := os.ReadFile(chromePDF)
if err != nil {
t.Skip("無 Chrome PDF 測資,跳過")
}
start := time.Now()
got, err := ConvertToText("合約.pdf", data)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Chrome 列印的 PDF 應該讀得了: %v", err)
}
for _, want := range []string{"船舶維修合約書", "350,000", "2026", "交期"} {
if !strings.Contains(got, want) {
t.Errorf("缺少 %q(若整段亂碼=引擎選錯了),實得:\n%s", want, got)
}
}
t.Logf("耗時 %v,抽出:\n%s", elapsed, got)
}
// 掃描件行為契約:PDFium 不做 OCR,影像 PDF 會抽不出字 → 必須是 ErrNoText,
// 不能靜默送空內容給 LLM(用戶會以為成功了,其實知識庫裡是空的)。
func TestPDF_無文字PDF回ErrNoText(t *testing.T) {
// 一個結構合法但沒有文字內容的最小 PDF
minimal := []byte(`%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
xref
0 4
trailer<</Size 4/Root 1 0 R>>
startxref
0
%%EOF`)
_, err := ConvertToText("空白.pdf", minimal)
if err == nil {
t.Fatal("沒有文字的 PDF 應該回錯,不能當成空內容送出去")
}
// 打不開也可接受(訊息不同但都不是靜默成功);重點是不可回 nil error
if !errors.Is(err, ErrNoText) && !strings.Contains(err.Error(), "PDF") {
t.Errorf("錯誤訊息應說得出是 PDF 的問題,實得: %v", err)
}
t.Logf("如預期回錯: %v", err)
}
func TestPDF_壞檔要報錯不當機(t *testing.T) {
_, err := ConvertToText("壞的.pdf", []byte("這根本不是 PDF"))
if err == nil {
t.Error("壞檔應該報錯")
}
t.Logf("錯誤訊息: %v", err)
}
// 同一個 process 連續處理多個 PDF 不可爆(pool 重用)——
// daemon 是長駐程式,一次掃描可能有幾十個 PDF。
func TestPDF_連續多檔不爆(t *testing.T) {
data, err := os.ReadFile(chromePDF)
if err != nil {
t.Skip("無測資")
}
for i := 0; i < 3; i++ {
got, err := ConvertToText("a.pdf", data)
if err != nil {
t.Fatalf("第 %d 次失敗: %v", i+1, err)
}
if !strings.Contains(got, "船舶維修合約書") {
t.Fatalf("第 %d 次內容不對", i+1)
}
}
}
+9 -1
View File
@@ -2,4 +2,12 @@ module arcrun-rag/collector
go 1.25.0
require golang.org/x/text v0.40.0 // indirect
require (
github.com/google/uuid v1.6.0 // indirect
github.com/jolestar/go-commons-pool/v2 v2.1.2 // indirect
github.com/klippa-app/go-pdfium v1.19.5 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+20
View File
@@ -1,2 +1,22 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccSH/yx4nD+J1CM=
github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY=
github.com/klippa-app/go-pdfium v1.19.5 h1:0TRc47tuumzOB6bkskS6ZP5mhspjAX+iXpvM8pUVKM8=
github.com/klippa-app/go-pdfium v1.19.5/go.mod h1:poSiUJYFicnfT8dazUfVnxcqPlKQxO3aHdGbOa1lG1w=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=