如何使用正则表达式(regex)实现PDF发票文本的结构化分组解析
PDF发票提取文本的正则分组解析方案
前置预处理
正则匹配前必须先做文本归一化,否则字段间隔不固定的问题会大幅降低匹配成功率:
- 统一替换所有全角空格、不间断空格(
\u00A0)、制表符为半角空格 - 将连续3个及以上的半角空格压缩为2个半角空格作为字段分隔标记,避免单空格把描述内容拆碎
- 提前过滤页眉、页脚、总金额合计行、税率说明行等非明细内容,按行拆分待匹配文本
分场景正则规则
所有正则默认开启多行匹配模式,金额匹配兼容千分位逗号、2位小数的常见发票格式,站点编号规则可根据实际字符范围调整:
1. 带日期的常规服务记录
这类行首为日期字段,用如下正则匹配:
^(?P<Date>\d{4}[-/]\d{1,2}[-/]\d{1,2})\s+(?P<Site>[A-Z0-9-]+)\s+(?P<Description>[\u4e00-\u9fa5\w\s]+?)(?:\s{2,}(?P<Description_2>[\u4e00-\u9fa5\w\s]+?))?\s+(?P<Qty>\d+(?:\.\d+)?)\s+(?P<Price>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\s+(?P<Total>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)$
分组说明:
Date:锚定行首的YYYY-MM-DD/YYYY/MM/DD格式日期Site:匹配紧跟日期的站点编号,默认支持大写字母、数字、横杠组合Description:非贪婪匹配核心服务描述,遇到连续2个空格(补充描述分隔位)或后续数字字段自动终止Description_2:可选分组,匹配不定长的补充描述,无补充内容时返回空值Qty/Price/Total:分别匹配数量、单价、总价,兼容整数、小数、带千分位的金额格式
2. 无日期的站点租赁类记录
这类行首无日期,第一个字段为站点编号,用如下正则匹配:
^(?P<Site>[A-Z0-9-]+)\s+(?P<Description>站点租赁[\u4e00-\u9fa5\w\s]*?)(?:\s{2,}(?P<Description_2>[\u4e00-\u9fa5\w\s]+?))?\s+(?P<Qty>\d+(?:\.\d+)?)\s+(?P<Price>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\s+(?P<Total>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)$
匹配到这类行时,Date字段可统一填充对应账期的日期,默认留空即可。
匹配优化要点
- 不要尝试用单个正则覆盖所有行类型,分场景写正则的容错率远高于单条复杂正则
- 描述类字段必须用非贪婪匹配,靠后续数量、金额这类固定格式的数字字段做锚定终止,才能适配任意长度的描述内容
- 所有可选分组匹配后统一设置空值默认值,避免字段缺失导致程序报错
- 匹配完成后增加数值校验:
Qty * Price和Total的差值小于0.01(兼容浮点计算误差)则判定为匹配有效,校验不通过的行单独捞出人工复核,可覆盖99%的匹配异常
参考实现(Python)
import re def preprocess(raw_text: str) -> list[str]: # 替换特殊空白字符 text = raw_text.replace('\u00A0', ' ').replace('\t', ' ') # 保留2个连续空格作为字段分隔 text = re.sub(r' {3,}', ' ', text) # 拆分非空行 return [line.strip() for line in text.splitlines() if line.strip()] # 预编译正则 re_with_date = re.compile(r'^(?P<Date>\d{4}[-/]\d{1,2}[-/]\d{1,2})\s+(?P<Site>[A-Z0-9-]+)\s+(?P<Description>[\u4e00-\u9fa5\w\s]+?)(?:\s{2,}(?P<Description_2>[\u4e00-\u9fa5\w\s]+?))?\s+(?P<Qty>\d+(?:\.\d+)?)\s+(?P<Price>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\s+(?P<Total>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)$') re_no_date = re.compile(r'^(?P<Site>[A-Z0-9-]+)\s+(?P<Description>站点租赁[\u4e00-\u9fa5\w\s]*?)(?:\s{2,}(?P<Description_2>[\u4e00-\u9fa5\w\s]+?))?\s+(?P<Qty>\d+(?:\.\d+)?)\s+(?P<Price>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)\s+(?P<Total>\d{1,3}(?:,\d{3})*(?:\.\d{2})?)$') def parse_invoice_line(line: str) -> dict | None: # 优先匹配带日期行 match = re_with_date.match(line) if match: res = match.groupdict() res.setdefault('Description_2', '') return res # 匹配无日期租赁行 match = re_no_date.match(line) if match: res = match.groupdict() res['Date'] = '' res.setdefault('Description_2', '') return res # 匹配失败返回空,待人工处理 return None
内容的提问来源于stack exchange,提问作者Rander
相关产品推荐
相关产品推荐

