零依赖文件去重、OCR与 docx 表格自动填充

最近遇到一个需求:从一个文件夹里找出重复的文件,再把每个文件的文字内容,按结构填写进一个 docx 表格。

细想之下,这件事其实是三个相互独立的小问题

  1. 找重复:如何高效判断两个文件的内容完全一致;
  2. 读文字:文件是图片和 PDF,需要先把其中文字提取出来;
  3. 填表格:把提取出来的文字按规则填进 docx 表格模板。

这套方案用 Go 实现,OCR 部分借助 macOS 系统能力,零第三方依赖,拷贝即用。下文会给出完整思路与核心代码(字段示例均为占位,可按你的实际文档替换),并重点交代实现要点与踩过的坑。


一、先想清楚要做什么

需求看起来是一件事,但动手前要把它切成三段来想——每一段的解决路径完全不同:

1
2
3
4
5
扫描目录(图片/PDF)
→ 逐个 OCR 提取文字
→ 按字段规则解析成结构化记录
→ 排序、去重
→ 按记录批量填充 docx 表格

「找文件重复」则是另一条独立主线,但它与 OCR 之间还有一层配合关系:同一份材料可能同时存在 PDF 与 JPG 两种格式,字节完全不同,哈希判重发现不了它们。这类「格式不同但内容相同」的重复,只能放到 OCR 之后按内容里的核心标识去重——这个判断划定了两个工具的职责边界。

技术选型方面,去重与填表用 Go:可编译成单文件、无第三方依赖,zip 与哈希都是标准库原生能力;OCR 用 Swift,因为 Vision 框架仅苹果生态可用,且自带的高质量中文识别是当下性价比最高的方案(详见第三节)。


二、找重复文件:先按大小粗筛,再算哈希

核心思路:避免对每个文件都做完整哈希

最朴素的去重是对每个文件算一遍完整哈希,但哈希一个 1GB 的文件很慢,而且绝大多数文件根本走不到这一步。于是方案分两步:

  1. 按文件大小分组:大小不同,内容必然不同,直接排除;
  2. 只对同大小的文件计算 SHA-256,哈希一致才判定为重复。

「粗筛在前、精算在后」,实际进入哈希环节的文件少了一个数量级,核心逻辑不过几行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type group struct {
size int64
hash string
}
byKey := map[group][]string{}

// 递归扫描 src 目录,空文件(size=0)不哈希,统一归入 size=0 组
for _, path := range walk(src) {
info, _ := os.Stat(path)
g := group{size: info.Size()}
if info.Size() > 0 {
h := sha256.New()
f, _ := os.Open(path)
io.Copy(h, f) // 流式读取,大文件也不占内存
g.hash = string(h.Sum(nil))
}
byKey[g] = append(byKey[g], path)
}

// 每组保留第一个,其余移动,重命名为 大小_序号_原名 防冲突
for key, paths := range byKey {
if len(paths) < 2 {
continue
}
for i, dup := range paths[1:] {
dest := filepath.Join(dst,
fmt.Sprintf("%d_%d_%s", key.size, i, filepath.Base(dup)))
os.Rename(dup, dest) // 同一分区上是原子移动
}
}

几个值得注意的设计点

空文件是特例:大小为 0 的文件无需哈希,统一按「大小 0」归入一组即可。副作用是——所有空文件会被看成同一组重复,多余的会被移走。这是用简化换来的便利,实践中完全可接受,但需要意识到它的存在。

保留规则要确定:每一组重复文件里「保留谁」必须有一个稳定规则。这里用的是扫描顺序:保留第一个遇到的,其余都算多余副本。

移动前先重命名:多余副本移到目标目录时,直接同名搬移会互相覆盖,所以在原名前加上「大小_序号」前缀;序号是组内第几个副本,天然唯一。

预览优先,默认不执行:移动是破坏性操作,工具默认处于「预览」模式,只打印每一对「源 → 目标」的清单,确认无误后再显式开启执行。

移动而非复制:同一磁盘分区内 rename 是原子的系统调用,不产生额外 IO;跨分区会失败,失败仅记日志并继续,不影响其他组。

工具边界

这个工具只能找字节完全相同的副本。任何「看起来一样但字节不同」的情况(不同格式、重新扫描)都查不出来。它不能解决全部去重问题——剩下的交给第五节的「按标识去重」。


三、OCR:直接信任系统 Vision 框架

选型:为什么不用 tesseract

在 macOS 上做中文 OCR,最容易被忽略的最优解是系统自带的 Vision 框架

  • 内置高质量中文识别,印刷体效果稳定,无需训练模型;
  • 零安装:不用装 tesseract 或各种语言包;
  • 识别语言直接声明为「中文 + 英文」,中英混排的文档也能处理。

代价是必须用 Swift(或 ObjC)调用,因此方案里需要一个小的 Swift 脚本充当助手,由 Go 主程序负责调用。

识别请求的关键配置

Vision 识别请求的配置很短,几个关键项直接决定了识别质量:

1
2
3
4
5
6
7
8
9
10
11
12
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate // 高精度模式(慢但准)
request.recognitionLanguages = ["zh-Hans", "en-US"] // 中英混排更稳
request.usesLanguageCorrection = true // 语言校正,修正形近字

let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try handler.perform([request])

// Vision 坐标原点在左下角:maxY 越大越靠上,据此还原阅读顺序
return (request.results ?? [])
.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY }
.compactMap { $0.topCandidates(1).first?.string }

两个绕不开的预处理

统一输入形式:Vision 的识别请求只接受 CGImage(位图),需要先做格式转换:

  • 图片:直接解码成位图;
  • PDF:先用 PDF 渲染管线把每一页画成位图,这里有个关键参数——按 2 倍分辨率渲染
1
2
3
4
let scale: CGFloat = 2.0   // 2 倍分辨率,扫描件小字号识别率明显提升
let width = Int(rect.width * scale)
let height = Int(rect.height * scale)
// 创建位图上下文 → 白底填充 → ctx.scaleBy(x: scale, y: scale) → page.draw

扫描件的字号本来就偏小,1 倍渲染经常错字,升到 2 倍后识别率提升非常明显,代价只是速度略降。

恢复阅读顺序:识别结果是「一组带坐标的文本块」,且坐标系原点在左下角,与常规的左上角习惯相反。因此要按 Y 坐标从大到小排序(见上面代码里的 sorted),才能还原成「从上到下」的阅读顺序。这是后续字段解析能按序进行的前提,也是最容易做错的地方。

分页标记:防止字段跨页粘连

PDF 是多页的,识别完每一页要在结果里追加一个页码标记:

1
2
lines.append(contentsOf: ocr(img))
lines.append("===PAGE \(i + 1)===") // 分页标记,Go 端读到就截断当前字段

Go 端读到这个标记就把正在收集的字段截断、重置状态,否则页尾的半句话会与下一页页首的内容粘连,字段就废了。

进程间的协作约定

  • 识别结果逐行打到标准输出,Go 端逐行读取;
  • 一切错误(打不开文件、识别失败)打到标准错误,绝不混进结果流;
  • 编译自动化:Go 程序首次运行时若发现助手可执行文件不存在,会自动完成编译(并开启优化开关,识别大文件时速度相差好几倍),对用户完全透明。

需要正视的局限

  • 只支持 macOS(系统框架强绑定);
  • 手写体识别不稳定,印刷体效果最好;
  • 排序只用了 Y 坐标,不含左右关系。横排的「标签:值」没问题,但多栏排版会出现左右栏文字串行——这是使用本方案前必须评估的硬边界。

四、docx:把它当成一个 ZIP 包来改

docx 的本质

.docx 不是一个二进制格式,而是一个 ZIP 压缩包。正文在包内的 word/document.xml 里,样式、字体、页眉页脚是另外几个独立的 XML。所以「填表格」本质上就是 两个动作

  1. 解包,改 word/document.xml 里的表格;
  2. 重新打包,其余文件原样复制回去。

了解这个本质之后,就不再需要任何第三方 docx 库——一个 zip 库 + 字符串处理就够了。

解析:把「配置」和「业务」分离

整套方案里最值得借鉴的是「配置与业务分离」:把一切与具体文档字段相关的内容抽成文件顶部的配置项,解析逻辑本身保持完全通用。配置分两类:

1
2
3
4
5
6
7
8
9
10
11
// ============================================
// 占位配置:请按你的实际文档字段与表格列名替换
// ============================================
var fieldLabels = []labelSec{
{"名称", secName}, // 第 2 列
{"编号", secCode}, // 第 3 列
{"日期", secDate}, // 第 6 列
}

// 遇到以这些文本开头的行,结束当前字段收集(页眉/落款等杂文本)
var breaks = []string{"标题", "前言", "说明", "签名", "落款"}
  • 字段标签:表格每一列对应正文里以什么词开头。命中标签前缀的行,就开始收集该字段;
  • 分隔词:正文里一些与数据无关的杂文本。命中的行代表当前字段到此结束。

字段解析是一个状态机:命中标签 → 进入收集态;命中分隔词 → 结束收集。这样正文里一行放不下的多行内容(如人名列表换行)也能被正确合并进同一字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func sectionFor(t string) (int, string) {
for _, d := range fieldLabels {
if strings.HasPrefix(t, d.label) {
// 去掉行首「标签:」前缀,返回字段类型与取值
return d.sec, reColon.ReplaceAllString(t[len(d.label):], "")
}
}
return secNone, ""
}

// 逐行处理:标签 → 进入该字段;分隔词 → 结束当前字段
for _, t := range lines {
if isBreak(t) {
cur = secNone
continue
}
if sec, val := sectionFor(t); sec != secNone {
cur = sec
collect(sec, val) // 按字段类型清洗后收集
continue
}
collect(cur, t) // 后续行持续追加到当前字段
}

三个 OCR 特有的坑

标签被断行:排版原因,OCR 经常把标签拆成两行,比如「名」和「称:xxx」。拼回去的规则很朴素——当前行恰好是某标签的前缀,且下一行以标签剩余部分开头,就合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for i := 0; i < len(lines); i++ {
t := lines[i]
if i+1 < len(lines) {
for _, L := range labelFrags {
if len(t) < len(L) && strings.HasPrefix(L, t) &&
strings.HasPrefix(lines[i+1], L[len(t):]) {
t = L + lines[i+1][len(L)-len(t):]
i++ // 合并后跳过下一行
break
}
}
}
out = append(out, t)
}

这个规则简单但极其有效。

形近字混淆:OCR 常把数字 0 认成字母 O(反过来也一样)。编码类字段提取出来后,统一做一次字符纠正,效果立竿见影。

字段清洗分层降级:以「带年份前缀的编码类字段」为例,提取按优先级逐级退让——

1
2
3
4
reYear  = regexp.MustCompile(`(20|19)\d{2}`)      // 年份 19xx/20xx
reNo = regexp.MustCompile(`\d{6,9}`) // 6~9 位纯数字
reABC = regexp.MustCompile(`[A-Za-z0-9]{8,}`) // 字母+数字混合串
reColon = regexp.MustCompile(`^[^::]*[::]\s*`) // 去掉行首「标签:」前缀

提取顺序是:优先匹配标签后面的值 → 退而从整行里找字母数字混合串 → 再退而求其次找纯数字串。每层用不同正则,识别质量差时也能兜住一部分结果。

两个兜底策略,进一步压低失败率

  • 文件名兜底:文件名往往自带元信息(如「某某-2026-ABC123.jpg」)。当 OCR 失败或没读全时,从文件名里提取年份与编码作为次要来源。方案不依赖它,却能把空字段的比例降下一截;
  • 空白过滤:目录里混入的无关图片(截图、照片)OCR 出来也「有字」,却拼不出完整字段。规则是:主字段为空、或除主字段外一无所获的内容,一律判为无效文件,不进表格,只在日志里报一行——这一步直接决定最终表格的干净程度

写入:模板的三种定位点

填充不是「整体替换」,而是把模板 XML 切成三块——列网格结束处、第一个数据行(表头)、表格结束标记之后——再在表头后逐行插入生成的数据行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定位模板三处:列网格结束 → 表头行结束 → 表格结束
gridEnd := strings.Index(doc, "</w:tblGrid>") + len("</w:tblGrid>")
head := doc[:gridEnd] // ① 表格属性 + 列宽定义

rest := doc[gridEnd:]
trEnd := strings.Index(rest, "</w:tr>") + len("</w:tr>")
header := rest[:trEnd] // ② 表头行保留

tail := doc[strings.Index(doc, "</w:tbl>"):] // ③ 表格后的内容原样保留

// 在表头后插入 N 行数据,再拼上尾部
var b strings.Builder
b.WriteString(head)
b.WriteString(header)
for i, c := range records {
b.WriteString(row(i+1, c))
}
b.WriteString(tail)

这个「切三块、插中间」的写法,比正则替换健壮得多。

写入前要理解的两个 Office 单位

写单元格时有两个单位容易踩坑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// w:sz 的单位是半磅:24 半磅 = 12 磅(小四)
const runFmt = `<w:r><w:rPr><w:rFonts w:ascii="宋体" w:eastAsia="宋体"` +
` w:hAnsi="宋体" w:hint="eastAsia"/><w:sz w:val="24"/>` +
`<w:szCs w:val="24"/></w:rPr><w:t xml:space="preserve">%s</w:t></w:r>`

func cell(w int, v string) string {
// 空值也生成完整结构(段落存在、无 run),保证边框与行高完整
p := fmt.Sprintf(
`<w:tc><w:tcPr><w:tcW w:w="%d" w:type="dxa"/></w:tcPr><w:p><w:pPr>…</w:pPr>`, w)
if v != "" {
p += fmt.Sprintf(runFmt, xmlEscape(v)) // 写 w:t 前必须 XML 转义
}
return p + `</w:p></w:tc>`
}
  • 字号按半磅记24 不是 24 磅,而是 24 半磅 = 12 磅。写错整体字号会大一号;
  • 列宽按 twips 记:1 twip = 1/20 磅,模板里抄下来的列宽值不能当像素直接用。

此外,OCR 拿到的文本可能含 & < > 等 XML 特殊字符,写入前必须转义(标准库 encoding/xml 足矣),否则 Word 会直接提示「文件已损坏」。

重新打包:只动一个文件

重新打包的规则很简单:ZIP 里所有条目,除了正文 XML 用新内容,其余一律按原始字节流复制

1
2
3
4
5
6
7
8
for _, f := range zin.File {
w, _ := zw.CreateHeader(&f.FileHeader)
if f.Name == "word/document.xml" {
w.Write(newDoc) // 正文用新内容
} else {
io.Copy(w, rc) // 其余(样式/字体/页眉/媒体)原样复制
}
}

这样模板的样式、字体、水印、页眉页脚全部原样保留,生成文档的外观与模板完全一致。注意每个生成单元格都要补齐属性块,即使值是空的也要保持完整结构,否则表格的边框与行高会残缺。


五、最后一道工序:排序与去重

填表前的最后一步,对识别结果做两件整理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 先按核心标识降序排列:带年份前缀的标识,新的排前面;空标识自然沉底
sort.SliceStable(records, func(i, j int) bool {
return records[i].Code > records[j].Code
})

// 同标识只保留扫描顺序靠前的一条(同一材料的不同格式各识别了一份)
seen := map[string]bool{}
kept := records[:0]
for _, rec := range records {
if rec.Code != "" && seen[rec.Code] {
continue // 丢弃的这条在日志里说明
}
if rec.Code != "" {
seen[rec.Code] = true
}
kept = append(kept, rec)
}
records = kept
  • 排序:核心标识通常带年份前缀,降序即「新的在前」;没有标识的记录(空串)自然沉到表格末尾,不会被误插进中间;
  • 去重:同一份材料的 PDF 与 JPG 各识别了一次,标识必然相同,同标识只保留一条——这正是第一节所说的「格式不同但内容相同」的重复,只有在这一步才除得掉。

两个操作都遵循「先排序、再去重、保留靠前一条」的顺序,保证结果稳定可复现。


六、踩坑清单(快速回顾)

把整个过程中值得记住的点浓缩成一份对照清单:

  1. 先粗筛再精算:按大小分组后只哈希同大小的文件,避免全量哈希的开销;
  2. 空文件是一组:size=0 不哈希,所有空文件视为同组;
  3. 破坏性操作默认预览:移动类操作先列清单,确认后再执行;
  4. PDF 要 2 倍渲染:1 倍渲染的小字号识别率明显下降;
  5. Vision 坐标系左下角:按 maxY 降序才能还原从上到下的阅读顺序;
  6. 分页要截断:跨页字段会粘连,必须用分页标记重置状态;
  7. 把业务抽成配置:字段标签、分隔词都在文件顶部配置,逻辑与业务解耦;
  8. 清洗分层降级:标签匹配 → 整行匹配 → 降级匹配,逐级退让;
  9. 文件名是第二数据源:OCR 读不到时从文件名提取,兜底但不过度依赖;
  10. 无效内容要过滤:拼不出完整字段的文档直接排除,保证表干净;
  11. 模板切三块插中间:属性 + 表头 + 尾部,比正则替换稳健;
  12. 记清两个 Office 单位:字号半磅、列宽 twips,错了整体变形;
  13. XML 必须转义:特殊字符不转义,Word 直接报损坏;
  14. 重打包只动正文:其余条目字节级原样复制,保留外观。

结语

这套方案最大的收获,不是某一处技巧,而是把一个大需求拆成三个无依赖的小工具:文件去重、系统 OCR、docx 模板填充。每一件单独来看都很朴素,组合起来却解决了一个真实的批量归档问题。

如果你也要处理类似的「批量识别 + 填表」需求,直接套用这三段式骨架,把字段配置换成自己的文档结构,剩下的交给状态机与分层清洗去兜底。最后切记:任何自动化方案,都要留一条「日志可查、结果可人工复核」的退路。


零依赖文件去重、OCR与 docx 表格自动填充
https://bubao.github.io/posts/b57f3b22.html
作者
一念
发布于
2026年9月17日
许可协议