Files
arcrun-collector/convert_docx.go
T
Leo 66d4fef045 feat(t73/t16): 本地轉檔層骨架+docx 抽取器(leo 定的『收集端 Markitdown』)
leo 07-27 定的形狀:「本地任何檔案都透過一個機制把它轉成模型可讀,再把模型可讀
內容發給它」「能不能讀 PDF 根本不是 Arcrun 的工作」。

convert.go=調度層(工頭):認副檔名→派抽取器→統一吐純文字。
加新格式只要在 extractors 註冊一行,不動架構。
convert_docx.go=第一個抽取器,純標準庫 archive/zip+encoding/xml。

三個設計決定(都有理由,非隨手):
① ErrNoText 獨立錯誤——掃描件 PDF 抽不出字時絕不能靜默略過(leo 撞過的病),
   要能轉成使用者看得懂的訊息;與 ErrUnsupported 分開因為說法不同。
② NFKC 正規化——PDF 抽中文會出康熙部首變體,長得一樣但碼位不同=用戶搜不到
   自己的檔案。廉價保險,對其他來源同樣有效。
③ 只取 word/document.xml——頁首頁尾多是雜訊(頁碼/公司名),知識萃取不要。

測試 10/10 + 真 Word 檔實測:
  textutil 產生的 real.docx → 抽出「船舶維修合約/350,000/2026 年 8 月 15 日」全對
  同段落多 run 相連(Word 常把一句話切成多個 w:r)
  壞檔(舊版 .doc 改名)報錯且不歸類成 ErrNoText
  NFKC 康熙部首摺回正常字
註:全形「,」會被 NFKC 轉半形「,」=正常行為,對搜尋無害(反而統一)。

下一步:接 PDFium-via-wazero(+10.43MB)。未接進 extract_gemma 主流程。
2026-07-27 20:36:02 +08:00

83 lines
2.5 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.
// convert_docx.go — .docx 抽文字(純 Go 標準庫,零外部依賴)
//
// 為什麼不需要外部引擎(2026-07-27 POC 實測後定案):
//
// .docx/.xlsx/.pptx Office Open XML **本質是 ZIP,裡面是 XML**
// 文字明明白白寫在 <w:t> 標籤裡。Go 標準庫 archive/zip + encoding/xml 直接讀得到。
// 實測體積代價 **+0.31 MB**(對照 PDF 走 PDFium 是 +10.43 MB,差 33 倍)。
//
// 對比 PDF 為何非要引擎不可:PDF 是**印刷格式**,記的是「這個字畫在第 3 頁座標 (120,400)」,
// 要把散落字元還原成通順文字=需要整顆排版還原引擎。兩者不是同一個難度等級。
package main
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"strings"
)
// docx 內文的位置。註:頁首/頁尾/註腳另有 header*.xml/footnotes.xml
// 目前只取主文——知識萃取要的是內容,頁首頁尾多半是雜訊(頁碼、公司名)。
const docxMainPart = "word/document.xml"
func extractDocx(data []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
// .docx 不是 zip = 檔案壞了,或副檔名騙人(例如舊版 .doc 改名成 .docx)。
// 舊版 .doc 是完全不同的二進位格式(OLE),本抽取器讀不了。
return "", fmt.Errorf("docx 解壓失敗(可能是舊版 .doc 或檔案損壞):%w", err)
}
for _, f := range zr.File {
if f.Name != docxMainPart {
continue
}
rc, err := f.Open()
if err != nil {
return "", fmt.Errorf("讀 %s 失敗:%w", docxMainPart, err)
}
defer rc.Close()
return docxXMLText(rc)
}
return "", fmt.Errorf("docx 缺少 %s", docxMainPart)
}
// docxXMLText 串流解 XML 取文字。
//
// 只認三個標籤就夠:
// - w:p 段落 → 換行
// - w:tab 定位點 → tab
// - w:br 斷行 → 換行
//
// 其餘一律當文字節點收集。用 Local name 比對(不看 namespace 前綴),
// 因為不同產生器的前綴可能不同(w: / w14: 等)。
func docxXMLText(r io.Reader) (string, error) {
dec := xml.NewDecoder(r)
var sb strings.Builder
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("解析 document.xml 失敗:%w", err)
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "p":
sb.WriteString("\n")
case "tab":
sb.WriteString("\t")
case "br":
sb.WriteString("\n")
}
case xml.CharData:
sb.Write(t)
}
}
return sb.String(), nil
}