Initial word table SQLite importer

This commit is contained in:
赵义仑
2026-06-24 16:21:57 +08:00
commit 9fab4c73ed
7 changed files with 1074 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.DS_Store
bin/
*.sqlite
*.sqlite3
*.db
*.csv

201
HOWTO.md Normal file
View File

@@ -0,0 +1,201 @@
# HOWTO: Word 表格导入 SQLite 并导出字段化 CSV
这份说明面向实际使用:把报备表 `.docx` 导入 SQLite然后把自动识别出的字段内容导出成 CSV方便用 WPS/Excel 查看。
## 1. 准备文件
需要两个文件:
- `wordtable2sqlite`macOS 可执行文件。
- `要导入的 .docx`:例如重要服务事项报备表。
在 Windows 上使用时,对应可执行文件是:
- `wordtable2sqlite-windows-amd64.exe`
## 2. 导入单个 Word 文件
macOS 示例:
```bash
./bin/wordtable2sqlite -reset -db report.sqlite input.docx
```
Windows 示例:
```powershell
.\wordtable2sqlite-windows-amd64.exe -reset -db report.sqlite input.docx
```
参数含义:
- `-db report.sqlite`:指定输出的 SQLite 数据库文件。
- `-reset`:导入前删除旧数据库,适合重新生成结果。
- `input.docx`:要导入的 Word 文件路径。
执行成功后会看到类似输出:
```text
imported input.docx: 1 tables, 31 cells, 15 fields
database: report.sqlite
```
含义是:
- `tables`:识别到几张 Word 表格。
- `cells`:保存了多少个原始单元格。
- `fields`:自动抽取了多少个字段化内容。
## 3. 批量导入多个 Word 文件
把多个 `.docx` 导入到同一个数据库:
```bash
./bin/wordtable2sqlite -reset -db reports.sqlite *.docx
```
如果是后续追加导入,不想清空旧数据,就不要加 `-reset`
```bash
./bin/wordtable2sqlite -db reports.sqlite another.docx
```
## 4. 数据库里有什么表
导入后会生成 4 张主要表:
- `documents`:每个导入的 Word 文件。
- `tables`:每个 Word 文件里的表格。
- `cells`:原始单元格内容,保留行号、列号、合并单元格信息。
- `fields`:字段化内容,最适合业务人员查看和检索。
一般看业务内容,优先看 `fields`
## 5. 查看字段化内容
在 macOS/Linux 终端中:
```bash
sqlite3 report.sqlite
```
进入 SQLite 后执行:
```sql
.headers on
.mode line
SELECT key, value FROM fields ORDER BY id;
```
`.mode line` 适合看长文本。每条记录会显示成:
```text
key = 事件过程
value = ...
```
如果想看表格模式:
```sql
.headers on
.mode box
SELECT key, value FROM fields ORDER BY id;
```
## 6. 导出字段化内容 CSV
这是最常用的导出方式:
```bash
sqlite3 -header -csv report.sqlite "SELECT key, value FROM fields ORDER BY id;" > report-fields.csv
```
生成的 `report-fields.csv` 可以直接用 WPS/Excel 打开。
如果打开后中文乱码,使用 WPS/Excel 的“从文本/CSV 导入”,编码选择:
```text
UTF-8
```
## 7. 导出原始单元格 CSV
如果要检查 Word 表格的原始行列结构,导出 `cells`
```bash
sqlite3 -header -csv report.sqlite "SELECT table_index, row_index, cell_index, col_index, col_span, text FROM cells ORDER BY table_index, row_index, cell_index;" > report-cells.csv
```
字段含义:
- `table_index`:第几张表,从 0 开始。
- `row_index`:第几行,从 0 开始。
- `cell_index`:该行第几个单元格。
- `col_index`:逻辑列号。
- `col_span`:横向合并了几列。
- `text`:单元格文字。
## 8. 常用查询
查所有字段名:
```sql
SELECT key FROM fields ORDER BY id;
```
查某个字段:
```sql
SELECT value FROM fields WHERE key = '工单编号';
```
按文件查看字段:
```sql
SELECT d.filename, f.key, f.value
FROM fields f
JOIN documents d ON d.id = f.document_id
ORDER BY d.id, f.id;
```
查原始单元格:
```sql
SELECT row_index, cell_index, text
FROM cells
ORDER BY table_index, row_index, cell_index;
```
## 9. 推荐查看方式
给业务人员看:
1. 先导出 `report-fields.csv`
2. 用 WPS/Excel 打开。
3. 每一行就是一个字段:`key` 是字段名,`value` 是字段内容。
给开发或调试看:
1. 打开 `report.sqlite`
2. 查看 `cells` 判断 Word 原始表格结构。
3. 查看 `fields` 判断字段抽取是否符合预期。
## 10. 本次样例命令
本次样例数据库:
```bash
/Users/zhaoyilun/Documents/Codex/2026-06-24/wo-x/outputs/sample-baiyin.sqlite
```
导出字段化 CSV
```bash
sqlite3 -header -csv /Users/zhaoyilun/Documents/Codex/2026-06-24/wo-x/outputs/sample-baiyin.sqlite "SELECT key, value FROM fields ORDER BY id;" > /Users/zhaoyilun/Documents/Codex/2026-06-24/wo-x/outputs/sample-baiyin-fields.csv
```
导出原始单元格 CSV
```bash
sqlite3 -header -csv /Users/zhaoyilun/Documents/Codex/2026-06-24/wo-x/outputs/sample-baiyin.sqlite "SELECT table_index, row_index, cell_index, col_index, col_span, text FROM cells ORDER BY table_index, row_index, cell_index;" > /Users/zhaoyilun/Documents/Codex/2026-06-24/wo-x/outputs/sample-baiyin-cells.csv
```

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# wordtable2sqlite
`.docx` 里的 Word 表格内容导入 SQLite 的最小 Go 工具。
详细操作说明见:[HOWTO.md](HOWTO.md)
## 特点
- 不依赖 Word、WPS、Python 或 LibreOffice。
- 直接解析 `.docx` 内部的 `word/document.xml`
- SQLite 使用纯 Go 驱动 `modernc.org/sqlite`,方便编译成单文件可执行程序。
- 保底保存所有表格单元格到 `cells`
- 对常见报备表这种“标题行 + 表头行 + 数据行”的结构,自动生成 `fields`,便于按字段检索。
## 编译
```bash
go build -o bin/wordtable2sqlite .
```
给非技术用户使用时,分发 `bin/wordtable2sqlite` 这个可执行文件即可。
## 使用
```bash
./bin/wordtable2sqlite -reset -db data/report.sqlite input.docx
```
批量导入多个文件:
```bash
./bin/wordtable2sqlite -db data/report.sqlite *.docx
```
参数:
- `-db`:输出 SQLite 文件路径,默认 `word_tables.sqlite`
- `-reset`:导入前删除旧数据库
- `-quiet`:不输出每个文件的摘要
## 数据表
- `documents`:每个导入的 Word 文件。
- `tables`Word 文档中的表格。
- `cells`:每个表格单元格,包含表格序号、行号、列号、跨列数和文本。
- `fields`:自动推断出的字段,常用于报备表检索。
常用查询:
```sql
SELECT key, value
FROM fields
ORDER BY id;
```
```sql
SELECT table_index, row_index, cell_index, text
FROM cells
ORDER BY table_index, row_index, cell_index;
```

18
go.mod Normal file
View File

@@ -0,0 +1,18 @@
module wordtable2sqlite
go 1.26
require modernc.org/sqlite v1.40.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

49
go.sum Normal file
View File

@@ -0,0 +1,49 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

606
main.go Normal file
View File

@@ -0,0 +1,606 @@
package main
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/xml"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
)
type Table struct {
Index int
Rows []Row
}
type Row struct {
Index int
Cells []Cell
}
type Cell struct {
RowIndex int
CellIndex int
ColIndex int
ColSpan int
VMerge string
Text string
}
type Field struct {
RecordIndex int
RowIndex int
ColIndex int
Key string
Value string
SourceKind string
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("wordtable2sqlite", flag.ContinueOnError)
dbPath := fs.String("db", "word_tables.sqlite", "SQLite database path")
reset := fs.Bool("reset", false, "remove the target database before importing")
quiet := fs.Bool("quiet", false, "suppress per-file summary output")
if err := fs.Parse(args); err != nil {
return err
}
docxPaths := fs.Args()
if len(docxPaths) == 0 {
return errors.New("usage: wordtable2sqlite -db output.sqlite [-reset] file1.docx [file2.docx ...]")
}
if *reset {
if err := os.Remove(*dbPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("reset database: %w", err)
}
}
if dir := filepath.Dir(*dbPath); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create database directory: %w", err)
}
}
db, err := sql.Open("sqlite", *dbPath)
if err != nil {
return fmt.Errorf("open sqlite database: %w", err)
}
defer db.Close()
ctx := context.Background()
for _, docxPath := range docxPaths {
before, err := dbStats(ctx, db)
if err != nil {
return err
}
if err := importDocument(ctx, db, docxPath); err != nil {
return err
}
after, err := dbStats(ctx, db)
if err != nil {
return err
}
if !*quiet {
fmt.Printf("imported %s: %d tables, %d cells, %d fields\n",
docxPath,
after.Tables-before.Tables,
after.Cells-before.Cells,
after.Fields-before.Fields,
)
}
}
if !*quiet {
fmt.Printf("database: %s\n", *dbPath)
}
return nil
}
type stats struct {
Tables int
Cells int
Fields int
}
func dbStats(ctx context.Context, db *sql.DB) (stats, error) {
if err := ensureSchema(ctx, db); err != nil {
return stats{}, err
}
var s stats
for _, item := range []struct {
query string
dst *int
}{
{`SELECT COUNT(*) FROM tables`, &s.Tables},
{`SELECT COUNT(*) FROM cells`, &s.Cells},
{`SELECT COUNT(*) FROM fields`, &s.Fields},
} {
if err := db.QueryRowContext(ctx, item.query).Scan(item.dst); err != nil {
return stats{}, err
}
}
return s, nil
}
func importDocument(ctx context.Context, db *sql.DB, docxPath string) error {
if err := ensureSchema(ctx, db); err != nil {
return err
}
tables, err := readDocxTables(docxPath)
if err != nil {
return err
}
sum, err := fileSHA256(docxPath)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx,
`INSERT INTO documents(path, filename, sha256, imported_at) VALUES (?, ?, ?, ?)`,
docxPath,
filepath.Base(docxPath),
sum,
time.Now().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("insert document: %w", err)
}
documentID, err := res.LastInsertId()
if err != nil {
return err
}
for tableIndex, table := range tables {
cellCount := 0
for _, row := range table.Rows {
cellCount += len(row.Cells)
}
res, err := tx.ExecContext(ctx,
`INSERT INTO tables(document_id, table_index, row_count, cell_count) VALUES (?, ?, ?, ?)`,
documentID,
tableIndex,
len(table.Rows),
cellCount,
)
if err != nil {
return fmt.Errorf("insert table %d: %w", tableIndex, err)
}
tableID, err := res.LastInsertId()
if err != nil {
return err
}
for rowIndex, row := range table.Rows {
for cellIndex, cell := range row.Cells {
if _, err := tx.ExecContext(ctx,
`INSERT INTO cells(document_id, table_id, table_index, row_index, cell_index, col_index, col_span, v_merge, text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
documentID,
tableID,
tableIndex,
rowIndex,
cellIndex,
cell.ColIndex,
cell.ColSpan,
cell.VMerge,
cell.Text,
); err != nil {
return fmt.Errorf("insert cell table=%d row=%d cell=%d: %w", tableIndex, rowIndex, cellIndex, err)
}
}
}
for _, field := range inferFields(table) {
if _, err := tx.ExecContext(ctx,
`INSERT INTO fields(document_id, table_id, table_index, record_index, row_index, col_index, key, value, source_kind)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
documentID,
tableID,
tableIndex,
field.RecordIndex,
field.RowIndex,
field.ColIndex,
field.Key,
field.Value,
field.SourceKind,
); err != nil {
return fmt.Errorf("insert field %q: %w", field.Key, err)
}
}
}
return tx.Commit()
}
func ensureSchema(ctx context.Context, db execer) error {
statements := []string{
`PRAGMA foreign_keys = ON`,
`CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
filename TEXT NOT NULL,
sha256 TEXT NOT NULL,
imported_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
table_index INTEGER NOT NULL,
row_count INTEGER NOT NULL,
cell_count INTEGER NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS cells (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
table_id INTEGER NOT NULL REFERENCES tables(id) ON DELETE CASCADE,
table_index INTEGER NOT NULL,
row_index INTEGER NOT NULL,
cell_index INTEGER NOT NULL,
col_index INTEGER NOT NULL,
col_span INTEGER NOT NULL,
v_merge TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
table_id INTEGER NOT NULL REFERENCES tables(id) ON DELETE CASCADE,
table_index INTEGER NOT NULL,
record_index INTEGER NOT NULL,
row_index INTEGER NOT NULL,
col_index INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
source_kind TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_cells_doc_table ON cells(document_id, table_index, row_index, cell_index)`,
`CREATE INDEX IF NOT EXISTS idx_fields_doc_key ON fields(document_id, key)`,
}
for _, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("schema statement failed: %w", err)
}
}
return nil
}
type execer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func readDocxTables(path string) ([]Table, error) {
zr, err := zip.OpenReader(path)
if err != nil {
return nil, fmt.Errorf("open docx zip: %w", err)
}
defer zr.Close()
for _, file := range zr.File {
if file.Name != "word/document.xml" {
continue
}
rc, err := file.Open()
if err != nil {
return nil, err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return nil, err
}
return parseDocumentXML(data)
}
return nil, errors.New("word/document.xml not found in docx")
}
func parseDocumentXML(data []byte) ([]Table, error) {
decoder := xml.NewDecoder(bytes.NewReader(data))
var tables []Table
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return tables, nil
}
if err != nil {
return nil, err
}
start, ok := token.(xml.StartElement)
if !ok || start.Name.Local != "tbl" {
continue
}
table, err := parseTable(decoder)
if err != nil {
return nil, err
}
table.Index = len(tables)
tables = append(tables, table)
}
}
func parseTable(decoder *xml.Decoder) (Table, error) {
var table Table
for {
token, err := decoder.Token()
if err != nil {
return table, err
}
switch t := token.(type) {
case xml.StartElement:
if t.Name.Local == "tr" {
row, err := parseRow(decoder, len(table.Rows))
if err != nil {
return table, err
}
table.Rows = append(table.Rows, row)
}
case xml.EndElement:
if t.Name.Local == "tbl" {
return table, nil
}
}
}
}
func parseRow(decoder *xml.Decoder, rowIndex int) (Row, error) {
row := Row{Index: rowIndex}
logicalCol := 0
for {
token, err := decoder.Token()
if err != nil {
return row, err
}
switch t := token.(type) {
case xml.StartElement:
if t.Name.Local == "tc" {
cell, err := parseCell(decoder)
if err != nil {
return row, err
}
cell.RowIndex = rowIndex
cell.CellIndex = len(row.Cells)
cell.ColIndex = logicalCol
if cell.ColSpan < 1 {
cell.ColSpan = 1
}
logicalCol += cell.ColSpan
row.Cells = append(row.Cells, cell)
}
case xml.EndElement:
if t.Name.Local == "tr" {
return row, nil
}
}
}
}
func parseCell(decoder *xml.Decoder) (Cell, error) {
cell := Cell{ColSpan: 1}
var b strings.Builder
inText := false
tcDepth := 1
for {
token, err := decoder.Token()
if err != nil {
return cell, err
}
switch t := token.(type) {
case xml.StartElement:
switch t.Name.Local {
case "tc":
tcDepth++
case "gridSpan":
if tcDepth == 1 {
if span, err := strconv.Atoi(attr(t, "val")); err == nil && span > 0 {
cell.ColSpan = span
}
}
case "vMerge":
if tcDepth == 1 {
cell.VMerge = attr(t, "val")
if cell.VMerge == "" {
cell.VMerge = "continue"
}
}
case "t":
inText = true
case "tab":
b.WriteByte('\t')
case "br", "cr":
b.WriteByte('\n')
}
case xml.CharData:
if inText {
b.Write([]byte(t))
}
case xml.EndElement:
switch t.Name.Local {
case "t":
inText = false
case "p":
if b.Len() > 0 && !strings.HasSuffix(b.String(), "\n") {
b.WriteByte('\n')
}
case "tc":
tcDepth--
if tcDepth == 0 {
cell.Text = normalizeCellText(b.String())
return cell, nil
}
}
}
}
}
func attr(start xml.StartElement, local string) string {
for _, a := range start.Attr {
if a.Name.Local == local {
return a.Value
}
}
return ""
}
func normalizeCellText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(strings.Join(strings.Fields(line), " "))
if line != "" {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func inferFields(table Table) []Field {
headerIndex := detectHeaderRow(table)
if headerIndex >= 0 {
return inferHeaderFields(table, headerIndex)
}
return inferKeyValueFields(table)
}
func detectHeaderRow(table Table) int {
for i := 0; i < len(table.Rows)-1; i++ {
row := table.Rows[i]
if isTitleRow(row) {
continue
}
if nonEmptyCellCount(row) >= 2 && nonEmptyCellCount(table.Rows[i+1]) >= 2 {
return i
}
}
return -1
}
func isTitleRow(row Row) bool {
nonEmpty := nonEmptyCellCount(row)
if nonEmpty != 1 || len(row.Cells) == 0 {
return false
}
return len(row.Cells) == 1 || row.Cells[0].ColSpan > 1
}
func nonEmptyCellCount(row Row) int {
count := 0
for _, cell := range row.Cells {
if strings.TrimSpace(cell.Text) != "" {
count++
}
}
return count
}
func inferHeaderFields(table Table, headerIndex int) []Field {
headers := table.Rows[headerIndex].Cells
var fields []Field
recordIndex := 0
for rowIndex := headerIndex + 1; rowIndex < len(table.Rows); rowIndex++ {
row := table.Rows[rowIndex]
if nonEmptyCellCount(row) == 0 {
continue
}
for i, header := range headers {
if i >= len(row.Cells) {
continue
}
key := cleanFieldKey(header.Text)
if key == "" {
continue
}
value := strings.TrimSpace(row.Cells[i].Text)
fields = append(fields, Field{
RecordIndex: recordIndex,
RowIndex: rowIndex,
ColIndex: row.Cells[i].ColIndex,
Key: key,
Value: value,
SourceKind: "header_row",
})
}
recordIndex++
}
return fields
}
func inferKeyValueFields(table Table) []Field {
var fields []Field
recordIndex := 0
for rowIndex, row := range table.Rows {
cells := nonEmptyCells(row)
if len(cells) < 2 || len(cells)%2 != 0 {
continue
}
for i := 0; i < len(cells); i += 2 {
key := cleanFieldKey(cells[i].Text)
if key == "" {
continue
}
fields = append(fields, Field{
RecordIndex: recordIndex,
RowIndex: rowIndex,
ColIndex: cells[i+1].ColIndex,
Key: key,
Value: cells[i+1].Text,
SourceKind: "key_value",
})
}
recordIndex++
}
return fields
}
func nonEmptyCells(row Row) []Cell {
out := make([]Cell, 0, len(row.Cells))
for _, cell := range row.Cells {
if strings.TrimSpace(cell.Text) != "" {
out = append(out, cell)
}
}
return out
}
func cleanFieldKey(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ": \t\r\n")
return strings.TrimSpace(s)
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}

134
main_test.go Normal file
View File

@@ -0,0 +1,134 @@
package main
import (
"archive/zip"
"context"
"database/sql"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestParseDocumentXMLExtractsTableCells(t *testing.T) {
xml := `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:tbl>
<w:tr>
<w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>报备表</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:p><w:r><w:t>序号</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>事件过程</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:p><w:r><w:t>1</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>起因</w:t><w:br/><w:t>处理经过</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>
</w:body>
</w:document>`
tables, err := parseDocumentXML([]byte(xml))
if err != nil {
t.Fatalf("parseDocumentXML returned error: %v", err)
}
if len(tables) != 1 {
t.Fatalf("got %d tables, want 1", len(tables))
}
if got := tables[0].Rows[0].Cells[0].Text; got != "报备表" {
t.Fatalf("title cell text = %q, want 报备表", got)
}
if got := tables[0].Rows[0].Cells[0].ColSpan; got != 2 {
t.Fatalf("title cell colspan = %d, want 2", got)
}
if got := tables[0].Rows[2].Cells[1].Text; got != "起因\n处理经过" {
t.Fatalf("multiline text = %q, want 起因\\n处理经过", got)
}
}
func TestInferHeaderFieldsSkipsTitleRow(t *testing.T) {
table := Table{
Rows: []Row{
{Cells: []Cell{{Text: "报备表", ColSpan: 2}}},
{Cells: []Cell{{Text: "序号", ColSpan: 1}, {Text: "事件过程", ColSpan: 1}}},
{Cells: []Cell{{Text: "1", ColSpan: 1}, {Text: "客户反映设备位置不合理", ColSpan: 1}}},
},
}
fields := inferFields(table)
if len(fields) != 2 {
t.Fatalf("got %d fields, want 2", len(fields))
}
if fields[0].Key != "序号" || fields[0].Value != "1" || fields[0].SourceKind != "header_row" {
t.Fatalf("unexpected first field: %#v", fields[0])
}
if fields[1].Key != "事件过程" || fields[1].Value != "客户反映设备位置不合理" {
t.Fatalf("unexpected second field: %#v", fields[1])
}
}
func TestImportDocumentWritesSQLiteTables(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "import.sqlite")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer db.Close()
xml := `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:tbl>
<w:tr><w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>报备表</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>序号</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>事件过程</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>1</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>客户反映设备位置不合理</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
</w:body>
</w:document>`
docxPath := filepath.Join(t.TempDir(), "sample.docx")
if err := writeTestDocx(docxPath, []byte(xml)); err != nil {
t.Fatalf("write test docx: %v", err)
}
if err := importDocument(context.Background(), db, docxPath); err != nil {
t.Fatalf("importDocument: %v", err)
}
var cells int
if err := db.QueryRow(`SELECT COUNT(*) FROM cells`).Scan(&cells); err != nil {
t.Fatalf("count cells: %v", err)
}
if cells != 5 {
t.Fatalf("got %d cells, want 5", cells)
}
var value string
if err := db.QueryRow(`SELECT value FROM fields WHERE key = '事件过程'`).Scan(&value); err != nil {
t.Fatalf("query field: %v", err)
}
if value != "客户反映设备位置不合理" {
t.Fatalf("field value = %q", value)
}
}
func writeTestDocx(path string, documentXML []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
zw := zip.NewWriter(f)
defer zw.Close()
w, err := zw.Create("word/document.xml")
if err != nil {
return err
}
_, err = w.Write(documentXML)
return err
}