Posted in

为什么主流Go PDF库都不支持扫描件OCR转MD?我们用Tesseract+Go-Cgo桥接实现端到端闭环

第一章:为什么主流Go PDF库都不支持扫描件OCR转MD?

主流 Go PDF 库(如 unidoc, gofpdf, pdfcpu, github.com/pdfcpu/pdfcpu/pkg/api)本质上是 PDF 解析与生成工具,其设计目标聚焦于结构化 PDF 的元数据读写、页面提取、文本坐标定位及矢量内容渲染。它们能可靠提取由 Word/LaTeX 导出的“可选中文本”(text-selectable)PDF 中的 Unicode 字符流,但对扫描件——即每页本质为一张 RGB/A 图像(/XObject 类型 /Image)——完全无文本层可言,自然无法调用 page.GetText() 或类似方法获取内容。

扫描件与 OCR 的技术断层

PDF 规范本身不定义 OCR 行为;OCR 是独立的计算机视觉任务,需依赖图像预处理、文字区域检测(Text Detection)、字符识别(OCR Engine)及版面分析(Layout Analysis)三阶段流水线。主流 Go PDF 库未集成任何图像处理能力,亦不绑定 Tesseract、PaddleOCR 或 EasyOCR 等引擎,更缺乏 PDF → 图像帧 → OCR → 结构化 Markdown 的端到端抽象。

Go 生态中 OCR 链路的碎片化现状

组件类型 典型 Go 库 是否支持 PDF 输入? 是否输出 Markdown?
PDF 解析 pdfcpu ✅ ❌(仅输出文本/JSON)
图像 OCR github.com/otiai10/gosseract ❌(需先解码为 *image.RGBA) ❌(仅纯文本)
版面分析 无成熟纯 Go 实现(需调用 Python/PaddleOCR API) — —

要实现扫描件 PDF → Markdown,必须手动串联:

  1. 用 pdfcpu 提取每页为 PNG/JPEG:
    pdfcpu extract -mode image input.pdf output_dir/  # 生成 page_001.png 等
  2. 用 gosseract 逐图调用 Tesseract(需本地安装):
    client := gosseract.NewClient()
    client.SetLanguage("chi_sim+eng") // 支持中英混合
    client.SetPageSegMode(gosseract.PSM_AUTO_OSD) // 自动检测方向与版面
    client.SetImage("output_dir/page_001.png")
    text, _ := client.Text() // 原始识别文本,无标题/列表语义
  3. 手动注入 Markdown 语义(如通过正则识别 ^第[一二三四]\s+章$ 转 ##),或接入 LayoutParser 类服务。

这种跨域协作非单一库职责,也超出 PDF 规范范畴——因此,不支持并非缺陷,而是边界清晰的合理分工。

第二章:PDF解析与扫描件识别的技术瓶颈分析

2.1 PDF文档结构分类:矢量文本 vs 位图图像的底层差异

PDF并非单一格式容器,而是由对象流构成的混合结构。核心差异源于内容生成方式与渲染路径:

渲染模型本质区别

  • 矢量文本:以字体描述(/Font字典)、字符编码(Tj操作符)和坐标变换矩阵定义,可无限缩放不失真;
  • 位图图像:以/XObject嵌入原始像素阵列(如/DeviceRGB采样),分辨率绑定,缩放引发插值失真。

关键结构对比

特性 矢量文本 位图图像
存储形式 路径指令 + 字体资源引用 像素数据流 + 颜色空间定义
缩放行为 重绘路径,保锐度 双线性插值,出现模糊/锯齿
文本可检索性 支持/ActualText语义标注 需OCR后处理
# 示例:提取PDF中对象类型的PyPDF2判断逻辑
from pypdf import PdfReader
reader = PdfReader("sample.pdf")
for obj in reader.resolved_objects.values():
    if "/Font" in obj.get("/Type", ""):
        print("→ 发现矢量字体资源")  # /Font字典标识文本渲染依赖
    elif "/XObject" in obj.get("/Type", "") and obj.get("/Subtype") == "/Image":
        print("→ 发现位图图像对象")  # /Image子类型明确为光栅化内容

该代码通过解析PDF间接对象字典的/Type与/Subtype字段,区分两类底层结构。/Font的存在表明文本使用矢量字形轮廓,而/XObject+/Image组合则锁定像素级图像数据流,是识别PDF内容本质的关键元数据锚点。

2.2 Go原生PDF库(pdfcpu、unidoc、gofpdf)的架构限制实测验证

核心能力对比

库名 PDF读取 表单填充 加密/解密 并发安全 商业许可
pdfcpu ✅ ⚠️(仅基础字段) ✅ ✅ MIT
unidoc ✅ ✅ ✅ ❌(需显式锁) 商业为主
gofpdf ❌(仅生成) ❌ ❌ ✅ MIT

并发写入瓶颈实测

// gofpdf 在高并发下生成PDF时触发内部状态竞争
pdf := gofpdf.New("P", "mm", "A4", "")
for i := 0; i < 100; i++ {
    go func() {
        pdf.AddPage() // ⚠️ 非线程安全:共享pageCount、curX等字段
        pdf.Cell(40, 10, "test") // panic: concurrent map writes
    }()
}

gofpdf 使用全局状态机管理页面与坐标,未封装 per-Goroutine 上下文,导致 AddPage() 内部修改 pdf.pageCount 和 pdf.curX 时发生竞态。

架构约束本质

graph TD
    A[PDF处理请求] --> B{库类型}
    B -->|解析/修改| C[pdfcpu: 基于AST重写]
    B -->|生成| D[gofpdf: 状态机流式写入]
    B -->|全功能| E[unidoc: 面向对象DOM模型]
    C --> F[内存占用随PDF复杂度线性增长]
    D --> G[不可逆写入,无法回溯修改]
    E --> H[需显式资源释放,GC不自动回收PDF objects]

2.3 OCR预处理关键路径:DPI校准、二值化与倾斜校正的Go实现难点

OCR质量高度依赖输入图像的物理可读性。DPI校准决定像素语义密度,二值化影响字符连通性,倾斜校正则保障几何对齐——三者构成不可逆的串行依赖链。

DPI感知的图像重采样

Go标准库无DPI元数据支持,需手动解析JPEG/TIFF头或依赖golang.org/x/image/tiff提取XResolution/YResolution字段。未校准DPI易导致后续二值化阈值漂移。

自适应二值化陷阱

// 使用Otsu算法(需第三方库如 github.com/esimov/pigo)
func otsuBinarize(img image.Gray) *image.Gray {
    hist := make([]int, 256)
    bounds := img.Bounds()
    for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
        for x := bounds.Min.X; x < bounds.Max.X; x++ {
            hist[img.GrayAt(x, y).Y]++
        }
    }
    // Otsu阈值计算逻辑(省略)→ 返回二值图
}

逻辑分析:Otsu在灰度直方图上搜索类间方差最大分割点;参数hist需完整覆盖0–255灰度级,缺失会导致阈值偏移超15%。

倾斜角检测的精度瓶颈

方法 精度(±°) Go生态支持 实时性
Hough变换 0.5 ❌(需cgo) 低
Radon变换 0.3 ⚠️(纯Go实现稀疏) 中
投影法 1.2 ✅ 高
graph TD
    A[原始图像] --> B[DPI校准→物理尺寸归一化]
    B --> C[自适应二值化→消除光照不均]
    C --> D[投影法粗估倾斜角]
    D --> E[Radon精调→亚像素级校正]

2.4 Tesseract引擎在Linux/macOS/Windows跨平台调用的ABI兼容性挑战

Tesseract 的 C++ API 默认不提供稳定的 ABI —— 同一版本在不同编译器(GCC/Clang/MSVC)、标准库(libstdc++/libc++/MSVCRT)及架构(x86_64/arm64)下,std::string、std::vector 等类型的内存布局与异常传播机制互不兼容。

核心冲突点

  • C++ 异常跨越 DLL/SO 边界可能崩溃(尤其 Windows MSVC)
  • std::shared_ptr 的控制块分配器不一致导致双重释放
  • RTTI 类型信息在 dylib 间不可互认(macOS 默认 -fvisibility=hidden)

推荐实践:C-Foreign Interface 层

// tesseract_capi.h —— 稳定 ABI 的唯一入口
typedef struct TessAPI* TessHandle;
TessHandle TessCreate();
void TessSetTextImage(TessHandle h, const uint8_t* pixels, int w, int h, int bytes_per_line);
const char* TessGetUTF8Text(TessHandle h); // 返回内部 malloc 内存,调用方负责 free()

此 C 接口规避了 C++ ABI 问题:所有参数为 POD 类型;字符串通过 const char* + 显式内存所有权契约传递;无异常、无模板、无虚函数表。Linux/macOS 使用 dlopen() 加载 .so/.dylib,Windows 使用 LoadLibrary() + GetProcAddress(),完全解耦编译环境。

平台 动态库扩展 符号可见性控制 运行时依赖
Linux .so -fPIC -shared libtesseract.so + libleptonica.so
macOS .dylib -dynamiclib -fvisibility=hidden libtesseract.dylib
Windows .dll __declspec(dllexport) tesseract.dll + leptonica.dll
graph TD
    A[应用进程] -->|dlopen/LoadLibrary| B[Tesseract C API]
    B --> C{ABI边界}
    C --> D[Linux: libstdc++ v3.4.27]
    C --> E[macOS: libc++ 15.0]
    C --> F[Windows: MSVCRT v143]
    D & E & F --> G[统一C接口:无异常/无STL对象]

2.5 Cgo桥接中内存生命周期管理与GC安全边界实践

Cgo调用C代码时,Go运行时无法追踪C分配的内存,易引发悬垂指针或过早释放。

GC安全边界核心原则

  • Go堆对象传入C前需显式 C.CString 或 C.malloc;
  • C返回的指针若指向Go内存,必须用 runtime.Pinner 固定(Go 1.22+)或 unsafe.Pointer + runtime.KeepAlive 延续生命周期;
  • 所有C分配内存必须由C侧 free() 或Go侧 C.free() 配对释放。

典型错误模式与修复

func badExample() *C.char {
    s := "hello"
    return C.CString(s) // ❌ 返回后s可能被GC回收,且未free
}

逻辑分析:C.CString 复制字符串到C堆,但函数返回后无任何引用维持该内存,亦无释放逻辑,造成内存泄漏。参数 s 是临时栈变量,其内容无关,但返回值是独立C堆指针,需手动管理。

场景 安全做法 风险点
Go → C 传字符串 defer C.free(unsafe.Pointer(p)) 忘记defer导致泄漏
C → Go 回传Go内存地址 pinner.Pin(&x); defer pinner.Unpin() 未Pin则GC可能移动/回收
graph TD
    A[Go调用C函数] --> B{内存归属判断}
    B -->|C分配| C[Go负责free或C侧释放]
    B -->|指向Go对象| D[Pin + KeepAlive保障存活]
    C --> E[避免跨CGO调用边界裸传指针]

第三章:Tesseract+Go-Cgo端到端桥接设计

3.1 Cgo封装策略:静态链接vs动态加载与符号导出最佳实践

静态链接:零依赖但体积敏感

使用 -ldflags="-s -w" 剥离调试信息,配合 #cgo LDFLAGS: -lfoo -L./lib 引入预编译静态库(.a)。

// export.h —— 必须显式声明为 extern "C" 可导出符号
#ifdef __cplusplus
extern "C" {
#endif
int add(int a, int b);  // 符号将被 Go 通过 C.add 调用
#ifdef __cplusplus
}
#endif

add 函数需在 export.c 中定义并编译进 .a;-fvisibility=hidden 默认隐藏符号,故必须用 __attribute__((visibility("default"))) 显式导出(若启用 visibility 控制)。

动态加载:灵活更新但需运行时保障

handle, _ := syscall.LoadLibrary("libfoo.so")
proc, _ := syscall.GetProcAddress(handle, "add")
// 调用需手动类型转换与参数封包
方式 启动开销 更新便利性 符号控制粒度
静态链接 低 需重编译 编译期全量
动态加载 高 替换 SO 即可 运行时按需解析
graph TD
    A[Go 代码] -->|cgo import| B[头文件声明]
    B --> C{链接策略}
    C -->|static| D[libfoo.a + 符号全量嵌入]
    C -->|dynamic| E[dlopen + dlsym 按需解析]

3.2 OCR结果结构化映射:Page→Block→Para→Line→Word→Box的Go struct建模

OCR原始输出(如PaddleOCR、Tesseract JSON)天然呈现树状层级:单页含多区块,区块内分段落,段落由行组成,行由词构成,词附带坐标框(Box)。Go语言需精准建模该嵌套语义。

核心结构设计原则

  • 值类型优先(Box为[4]float64)以避免nil指针
  • 层级间用切片聚合(非指针切片),保障内存局部性
  • Word内嵌Box,Line持有[]Word,依此类推

Go Struct 定义示例

type Box [4]float64 // [x0, y0, x1, y1]

type Word struct {
    Text string `json:"text"`
    Box  Box    `json:"box"`
}

type Line struct {
    Words []Word `json:"words"`
}

type Para struct {
    Lines []Line `json:"lines"`
}

type Block struct {
    Para  []Para `json:"para"`
    Class string `json:"class"` // "text"/"table"/"image"
}

type Page struct {
    Blocks []Block `json:"blocks"`
    Width  int     `json:"width"`
    Height int     `json:"height"`
}

逻辑分析:Box采用数组而非结构体,减少GC压力;Page不嵌[]Word而严格遵循Page→Block→Para→Line→Word路径,确保语义可追溯。字段标签统一使用json:保证序列化兼容性。

层级映射验证表

层级 字段名 类型 是否可空 说明
Box Box [4]float64 ❌ 坐标必存在
Word Text string ✅ 可能为空字符串(如仅符号)
Block Class string ✅ 用于下游布局分析
graph TD
  A[Page] --> B[Block]
  B --> C[Para]
  C --> D[Line]
  D --> E[Word]
  E --> F[Box]

3.3 多语言混合识别与置信度阈值动态熔断机制实现

在真实OCR场景中,文档常混杂中、英、日、数字及符号。静态阈值(如固定0.7)易导致中文漏检或英文误拒。

动态阈值生成策略

基于当前文本块的语言分布熵实时计算熔断阈值:

  • 熵值高 → 语言混杂 → 降低阈值(宽松识别)
  • 熵值低 → 单一语言 → 提升阈值(严控置信)
def calc_dynamic_threshold(lang_probs: List[float]) -> float:
    # lang_probs: [zh_prob, en_prob, ja_prob, num_prob]
    entropy = -sum(p * math.log(p + 1e-8) for p in lang_probs)
    # 熵∈[0, 1.386], 映射为阈值∈[0.55, 0.85]
    return max(0.55, min(0.85, 0.85 - entropy * 0.22))

逻辑说明:lang_probs由轻量语言分类器输出;1e-8防log(0);系数0.22经A/B测试校准,平衡召回与精度。

熔断决策流程

graph TD
    A[输入文本块] --> B{语言概率分布}
    B --> C[计算Shannon熵]
    C --> D[映射动态阈值]
    D --> E[逐字符置信度过滤]
    E --> F[保留≥阈值的识别结果]
语言组合 平均熵 推荐阈值 典型场景
纯中文 0.02 0.83 政府公文
中英混排 0.91 0.66 技术手册页眉
英+数字+符号 1.24 0.59 表格单元格

第四章:从扫描PDF到结构化Markdown的流水线构建

4.1 PDF图像页提取:使用pdfcpu解包+image/png无损转换流水线

PDF中嵌入的扫描图像页常需高保真导出,避免OCR预处理失真。pdfcpu 提供语义清晰的解包能力,配合标准PNG编码可实现比特级无损。

核心流水线设计

# 提取第5页为原始图像流(跳过渲染,保留原始DCT/JPX/Flate编码)
pdfcpu extract -mode images -pages 5 input.pdf ./tmp/
# 将原始JPEG/XObject直接转为PNG(无重压缩)
convert ./tmp/image_5.jpg -define png:preserve-colormap=true -depth 8 output.png

-mode images 直接解析PDF对象树中的 /XObject 子类型(/Image),绕过光栅化;-define png:preserve-colormap=true 防止调色板索引误转。

关键参数对照表

参数 作用 安全性
-pages 5 精确指定页码,避免批量解包引入噪声 ⭐⭐⭐⭐⭐
-depth 8 强制位深对齐,防止alpha通道截断 ⭐⭐⭐⭐
graph TD
    A[PDF文件] --> B[pdfcpu extract -mode images]
    B --> C[原始图像流 JPEG/JP2/CCITT]
    C --> D[ImageMagick convert → PNG]
    D --> E[无损PNG:CRC校验一致]

4.2 OCR结果语义还原:基于行高/间距/缩进的段落合并与标题层级推断

OCR输出常为扁平化文本行序列,缺乏段落结构与标题语义。需通过视觉线索重建逻辑层次。

行间距离建模

将相邻行垂直间距归一化为行高倍数,设定阈值判定分段:

def is_paragraph_break(prev_bottom, curr_top, line_height):
    gap = curr_top - prev_bottom
    return gap > line_height * 1.8  # 超1.8倍行高视为段落分隔

line_height取当前页中位数行高以抗局部噪声;1.8经PDF/扫描件混合样本调优,平衡漏判与误判。

缩进驱动的标题识别

缩进量(px) 推断层级 置信度
0 H1 0.92
24–36 H2 0.85
48–72 H3 0.76

层级融合决策流

graph TD
    A[原始OCR行] --> B{行高比 >1.8?}
    B -->|是| C[强制段落切分]
    B -->|否| D{缩进 <12px?}
    D -->|是| E[候选标题]
    D -->|否| F[正文段落]

4.3 表格区域识别与Markdown表格生成:OpenCV轮廓检测+行列对齐算法

核心流程概览

graph TD
    A[灰度化+二值化] --> B[形态学增强横/竖线]
    B --> C[霍夫直线检测或轮廓筛选]
    C --> D[行列交点聚类与网格重建]
    D --> E[单元格内容OCR定位 → Markdown对齐]

关键预处理步骤

  • 使用 cv2.adaptiveThreshold 替代全局阈值,适应光照不均的扫描件;
  • 水平/垂直结构元素(cv2.getStructuringElement)尺寸需大于平均字符高度,保留表格线而滤除文字噪声。

行列对齐核心代码

# 基于轮廓面积与宽高比筛选表格外框
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
table_contour = max([c for c in contours if cv2.contourArea(c) > 5000 and 0.1 < cv2.boundingRect(c)[2]/cv2.boundingRect(c)[3] < 10], key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(table_contour)

cv2.contourArea(c) > 5000 排除噪点与小图标;宽高比约束(0.1–10) 确保捕获矩形表格而非单列文本块;max(..., key=cv2.contourArea) 选取最大有效区域,适配多表文档。

步骤 输出目标 典型参数
轮廓过滤 单一表格ROI minArea=5000, aspectRatio=[0.1,10]
网格重建 行列交点坐标 cv2.kmeans 聚类水平/垂直线位置
Markdown生成 对齐的|---|分隔行 基于列宽中位数自动计算对齐宽度

4.4 元信息注入与可追溯性设计:PDF源页码、OCR置信度、坐标锚点嵌入

为保障文档智能解析结果的可验证性与可调试性,系统在结构化输出阶段同步注入三类关键元信息。

嵌入式元数据结构

采用 xmp:CustomMetadata 命名空间扩展,在每段文本节点中嵌入:

  • pdf:page(源PDF页码,1-based)
  • ocr:confidence(浮点型,0.0–1.0)
  • bbox:x1,y1,x2,y2(归一化坐标,相对页面宽高)
def inject_provenance(text_node, page_num, conf, bbox):
    text_node.set("data-pdf-page", str(page_num))          # 源页码,整型字符串
    text_node.set("data-ocr-conf", f"{conf:.3f}")         # 置信度保留三位小数
    text_node.set("data-bbox", ",".join(map(str, bbox)))  # 归一化坐标四元组

该函数将溯源字段作为 HTML/XML 自定义属性写入 DOM 节点,不破坏语义结构,且兼容后续 XPath/XSLT 提取。

元信息组合策略

字段 类型 用途 示例
data-pdf-page string 定位原始扫描页 "42"
data-ocr-conf string 评估识别可靠性 "0.987"
data-bbox string 支持像素级重绘与比对 "0.12,0.33,0.85,0.41"
graph TD
    A[OCR引擎输出] --> B{置信度过滤}
    B -->|≥0.85| C[注入完整元信息]
    B -->|<0.85| D[标记低置信并保留坐标]
    C & D --> E[JSONL导出含全部data-*属性]

第五章:我们用Tesseract+Go-Cgo桥接实现端到端闭环

构建可复用的OCR封装层

我们基于 Tesseract 4.1.3 的 C API(libtesseract.so / tesseract.dll)编写了轻量级 Go 封装,通过 Cgo 调用 TessBaseAPIInit3、TessBaseAPISetImage 和 TessBaseAPIGetUTF8Text 等核心函数。关键设计是将 *C.TessBaseAPI 指针封装为 Go 结构体 OCRClient,并实现 defer 安全的 Close() 方法,避免内存泄漏。该封装已发布为开源模块 github.com/our-team/go-tess4,支持多语言模型热加载与 DPI 自适应缩放。

图像预处理流水线集成

原始扫描件常含噪点、倾斜与低对比度问题。我们在 Go 层调用 OpenCV 的 C API(通过 gocv 绑定),构建四步预处理链:灰度转换 → 自适应二值化(cv2.adaptiveThreshold)→ 透视校正(基于霍夫直线检测定位四边形)→ 锐化(cv2.filter2D)。以下为关键代码片段:

// 预处理后传入Tesseract
img := gocv.IMRead("invoice.jpg", gocv.IMReadColor)
preprocessed := PreprocessImage(img)
cimg := (*C.uint8_t)(unsafe.Pointer(preprocessed.Ptr()))
api.SetImage(cimg, C.int(preprocessed.Cols()), C.int(preprocessed.Rows()), 3, C.int(preprocessed.Step()))

多线程并发识别性能实测

在 16 核 Intel Xeon E5-2680 v4 服务器上,我们对 10,000 张 A4 发票图像(平均尺寸 2480×3508 px)进行压测。启用 runtime.GOMAXPROCS(12) 后,单实例吞吐达 87.3 页/秒,CPU 利用率稳定在 92%,内存占用峰值 3.2GB。下表为不同并发数下的吞吐对比:

Goroutines 吞吐(页/秒) 平均延迟(ms) 内存增长(MB)
4 32.1 124 890
8 61.7 130 1520
12 87.3 115 2140
16 86.9 184 3260

实时结果结构化提取

识别文本并非终点——我们嵌入规则引擎与轻量 NER 模型(ONNX Runtime 加载的 distilBERT-finetuned 模型),从 OCR 输出中精准抽取“开票日期”、“金额(大写/小写)”、“销售方税号”三类字段。例如,对 Tesseract.GetUTF8Text() 返回的字符串执行正则锚定 + 语义校验双路验证,错误率由纯正则的 18.7% 降至 2.3%。

端到端闭环流程图

flowchart LR
    A[PDF扫描件] --> B{Go主程序}
    B --> C[PDF转PNG序列]
    C --> D[OpenCV预处理]
    D --> E[Tesseract C API识别]
    E --> F[文本结构化抽取]
    F --> G[MySQL写入+ES索引]
    G --> H[Webhook推送至ERP]
    H --> I[审计日志归档]

错误传播与降级策略

当 Tesseract 初始化失败(如缺失 chi_sim.traineddata),服务自动切换至备用 OCR 引擎(PaddleOCR 的 HTTP 接口),响应时间增加 120ms 但保障 SLA ≥ 99.95%。所有异常路径均注入 OpenTelemetry trace,并在 Jaeger 中标记 ocr_fallback=true tag。

模型热更新机制

通过 inotify 监听 /usr/local/share/tessdata/ 目录变更,检测到新 .traineddata 文件后,触发 api.Init() 重建实例池,全程无请求中断。实测单次模型热更耗时 412ms,期间旧实例持续服务。

生产环境资源隔离配置

在 Kubernetes 中为 OCR 服务单独设置 LimitRange:memory: 4Gi、cpu: 1200m,并通过 securityContext.privileged: false 禁用特权模式;同时挂载只读的 /usr/share/tessdata ConfigMap,防止运行时篡改模型文件。

质量监控看板指标

Prometheus 暴露 ocr_total_requests、ocr_error_rate、tess_api_init_duration_seconds 等 17 项指标,Grafana 看板实时展示各区域识别准确率热力图(按发票类型、扫描设备型号、地域维度下钻)。

专攻高并发场景,挑战百万连接与低延迟极限。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注