ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

滴滴行程报销单PDF解析:从文本层判断到批量结构化提取

滴滴行程报销单PDF解析:从文本层判断到批量结构化提取

简介:这份PDF是一份真实的滴滴出行行程报销单样本,面向需要了解企业差旅报销流程、学习行程单数据结构或进行财务票据整理的人员。文档完整记录了一名员工2024年11月20日至12月4日在宁波市的13笔打车明细,涵盖快车、特惠快车与滴滴特快等车型,逐笔列明上车时间、起终点、里程、金额及备注,合计643.03元,并附有姓名、工号、部门、申请日期与行程人手机号等报销要素,可直观呈现企业出行费用的记录规范与核对逻辑。资源包共1个PDF文件,约93KB,内容跨两页,结构清晰便于查阅。目前已有453人学习下载,适合财务报销实操参考、票据信息提取练习及差旅费用分析等场景使用。

1. 从一张「滴滴出行行程报销单.pdf」说起:PDF 报销单到底难在哪

财务同事把一张「滴滴出行行程报销单.pdf」甩过来,让你把里面十几趟行程的日期、金额、车牌、起终点抠出来录进系统——这大概是很多做自动化的人都会遇到的场景。它看着像普通 PDF,实际是个混合体:抬头是排版文字,行程明细是表格,金额合计可能带水印,页脚还有二维码和电子章。直接复制粘贴,格式全乱;用通用 PDF 转文本工具,表格列会错位;想批量处理几十上百张,手工根本扛不住。这篇笔记就围绕「怎么把这类行程报销单 PDF 稳定解析成结构化数据」展开,讲清楚选型、实现、参数和踩坑,适合需要做票据信息提取、财务自动化、RPA 落地的从业者。核心不是教你调某个库,而是让你拿到一张新样式的报销单时,知道先看什么、用什么方案、哪里会翻车。

2. 先判断 PDF 是「文字型」还是「扫描型」:选型决定后面所有工作量

动手写代码之前,最值得花十分钟做的事,是搞清楚手里这批「滴滴出行行程报销单.pdf」到底是文字型还是扫描型。这个判断直接决定你是走文本层提取,还是走 OCR 识别,两条路的工作量和准确率差一个数量级。很多人上来就装 OCR 全家桶,结果发现 PDF 里本来就有完整文本层,白白多了一层识别误差。

2.1 用三行命令判断文本层是否存在

最省事的办法是用命令行工具先探一下。常见做法是用pdffonts看字体嵌入情况,用pdftotext看能不能直接抽出文字。

# 查看 PDF 内嵌字体,有输出通常说明是文字型 pdffonts "滴滴出行行程报销单.pdf" # 尝试直接抽取文本,看是否有可读内容 pdftotext -layout "滴滴出行行程报销单.pdf" - | head -50

pdffonts如果列出多行字体信息,说明文字是真实绘制的;如果输出为空或只有极少数条目,大概率是整页图片。pdftotext -layout会尽量保留版面布局,如果抽出来的内容能看出「行程时间」「金额」这些字段,说明文本层可用。注意-layout参数很关键,不加它表格会被压成一列,判断容易误判。

2.2 文字型走 pdfplumber,扫描型才上 OCR

判断完之后选型就清晰了。文字型 PDF 我一般用pdfplumber,它对表格线和字符坐标的支持比PyPDF2好得多,能拿到每个字符的 bounding box,做列对齐很稳。扫描型才考虑 OCR,常见组合是先把 PDF 每页渲染成图片,再走识别,但这条路要额外处理倾斜、印章遮挡和分辨率问题。

类型判断依据推荐方案主要风险
文字型pdffonts 有字体、pdftotext 可读pdfplumber 提取文本与表格表格线缺失导致列错位
扫描型无字体、文本层为空渲染图片后 OCR印章遮挡、分辨率不足
混合型部分页有文本、部分页是图逐页判断分流分流逻辑写错导致漏页

混合型在真实票据里并不少见,比如首页是文字、附页是截图。稳妥做法是逐页跑一次文本层检测,按页分流,而不是整份文件一刀切。

2.3 为什么我不建议一上来就用大模型 OCR

现在多模态模型识别票据确实方便,但对于「滴滴出行行程报销单.pdf」这种版式相对固定的单据,用通用大模型属于杀鸡用牛刀,而且带来两个实际问题:一是成本和延迟,批量几百张时账单很难看;二是结果不稳定,同一张图两次识别字段名可能不一样,下游解析要写一堆兼容逻辑。固定版式优先用规则加坐标,规则覆盖不到的长尾再考虑模型兜底,这个顺序能省很多返工。

3. 用 pdfplumber 把行程表格抠出来:坐标、列对齐与字段映射

确认是文字型之后,核心工作就变成「把表格里的每一行行程还原成结构化记录」。这一步的难点不在读 PDF,而在列对齐——报销单的表格经常没有完整竖线,或者金额列右对齐、日期列左对齐,靠简单的空格切分会错位。

3.1 最小可运行提取脚本

下面这段是我常用的起手式,先按页提取文字和表格,把原始结构打印出来观察,再决定怎么映射字段。

import pdfplumber PDF_PATH = "滴滴出行行程报销单.pdf" with pdfplumber.open(PDF_PATH) as pdf: for page_no, page in enumerate(pdf.pages, start=1): # 先看这一页有没有文本层 text = page.extract_text() or "" print(f"=== 第 {page_no} 页,文本长度 {len(text)} ===") # 按表格线提取,无表格线时返回空列表 tables = page.extract_tables() for t_idx, table in enumerate(tables): print(f"-- 表格 {t_idx},行数 {len(table)}") for row in table[:5]: # 先看前 5 行,避免刷屏 print(row)

这段代码的逻辑是「先观察再解析」。extract_text()用来确认文本层是否正常,extract_tables()依赖 PDF 里的表格线,如果返回空列表,说明这张表没有显式线条,需要改用字符坐标聚类。先打印前几行是为了确认列的顺序,不同版本报销单的列顺序可能不一样,硬编码字段下标是后面翻车的常见原因。

3.2 没有表格线时,用字符坐标做列聚类

当extract_tables()返回空,就得自己按 x 坐标切列。思路是把每个字符的横坐标收集起来,聚类成若干列中心,再把字符归到最近的列。

import pdfplumber from collections import defaultdict def cluster_columns(chars, tolerance=8): """按 x0 坐标把字符聚成列,tolerance 控制列宽容差""" xs = sorted(c["x0"] for c in chars) centers = [] for x in xs: if not centers or x - centers[-1] > tolerance: centers.append(x) # 每个字符归到最近的列中心 buckets = defaultdict(list) for c in chars: nearest = min(centers, key=lambda cx: abs(cx - c["x0"])) buckets[nearest].append(c) return buckets with pdfplumber.open("滴滴出行行程报销单.pdf") as pdf: page = pdf.pages[0] chars = page.chars buckets = cluster_columns(chars, tolerance=8) for col_x in sorted(buckets): line = "".join(c["text"] for c in sorted(buckets[col_x], key=lambda c: c["top"])) print(col_x, line[:40])

tolerance是最关键的参数:设太小,同一列会被拆成多列;设太大,相邻列会被合并。经验值是先取 5 到 10 之间试,观察打印出来的列内容是否语义完整。top排序保证同一列内文字按从上到下拼接。这个方法对右对齐的金额列同样有效,因为聚类看的是起始坐标分布,不依赖对齐方式。

3.3 字段映射要写成配置,不要写死在代码里

列切出来之后,下一步是把列映射到「行程时间、起点、终点、金额、车牌」这些字段。我的习惯是把映射写成字典配置,而不是在代码里写row[2]这种下标。

FIELD_MAP = { "行程时间": 0, "起点": 1, "终点": 2, "金额": 3, "车牌": 4, } def row_to_record(row): record = {} for field, idx in FIELD_MAP.items(): record[field] = (row[idx] or "").strip() if idx < len(row) else "" return record

这样做的价值在于:换一个版本的报销单,只改FIELD_MAP就能适配,不用动解析逻辑。金额字段还要额外做清洗,去掉货币符号和千分位逗号再转float,否则后续汇总会报类型错误。日期字段建议统一转成 ISO 格式,避免「2024/1/5」和「2024-01-05」混在一起排序出错。

4. 批量处理与结果校验:让解析结果能直接进财务系统

单张跑通只是开始,真实场景往往是几十上百张报销单批量处理,而且结果要能直接导入财务系统或对账表。这一章讲批量流水线怎么搭、结果怎么校验,以及导出格式怎么选。

4.1 批量流水线的目录约定与失败隔离

批量处理最怕一张坏文件把整个任务拖垮。我的做法是固定输入输出目录,单文件处理包在 try 里,失败的文件单独记录,不中断整体流程。

import os import traceback import pandas as pd from pdfplumber import open as pdf_open INPUT_DIR = "./invoices" OUTPUT_DIR = "./output" ERROR_LOG = "./output/errors.csv" os.makedirs(OUTPUT_DIR, exist_ok=True) all_records, errors = [], [] for fname in sorted(os.listdir(INPUT_DIR)): if not fname.lower().endswith(".pdf"): continue path = os.path.join(INPUT_DIR, fname) try: with pdf_open(path) as pdf: for page in pdf.pages: for table in page.extract_tables(): for row in table: rec = row_to_record(row) rec["来源文件"] = fname all_records.append(rec) except Exception as e: errors.append({"文件": fname, "错误": str(e)}) traceback.print_exc() pd.DataFrame(all_records).to_excel( os.path.join(OUTPUT_DIR, "行程汇总.xlsx"), index=False ) if errors: pd.DataFrame(errors).to_csv(ERROR_LOG, index=False, encoding="utf-8-sig")

来源文件字段一定要加,否则汇总后无法追溯某条记录来自哪张单子,对账时非常被动。错误日志用utf-8-sig编码,是为了用表格软件打开中文不乱码。失败隔离的意义在于:批量任务跑完你能一眼看到哪几张需要人工介入,而不是全部重跑。

4.2 用金额合计做交叉校验

解析完最有效的自检手段,是把明细金额加总,和报销单上的「合计金额」比对。差值为零或在一分钱以内,基本可以认为解析正确;差值明显,说明有行漏读或列错位。

def check_total(records, declared_total, tol=0.01): """records 为单张单据的明细,declared_total 为单据上的合计""" s = sum(float(r["金额"]) for r in records if r["金额"]) return abs(s - declared_total) <= tol, s

这个校验能抓出大部分静默错误,比如某一行金额被读成空、或者小数点位置错。tol设 0.01 是给浮点误差留余量,不要设太大,否则真错也被放过。建议把校验结果作为一列写进汇总表,人工复核时优先看校验不通过的单据。

4.3 导出格式怎么选:Excel 还是 CSV

给财务用的结果,我一般同时出两份:一份 Excel 给人看,一份 CSV 给系统导入。Excel 保留列宽和格式,方便人工核对;CSV 体积小、兼容性好,适合进对账脚本。注意 CSV 用utf-8-sig,Excel 打开中文才正常。如果下游系统要求固定列顺序,导出前按目标模板重排列,别指望对方适配你的顺序。

5. 避坑与排查:解析行程报销单最常见的五类翻车

这一章是我踩过的坑里挑出来最有代表性的五条,每条按「现象 → 原因 → 解决」写,遇到问题时可以对照排查。

5.1 表格提取返回空列表

现象:extract_tables()一直返回[],但extract_text()有内容。原因是这份 PDF 的表格没有绘制线条,或者线条是图片形式。解决办法是改用字符坐标聚类(见 3.2),或者用page.extract_table({"vertical_strategy": "text"})指定按文本对齐推断列。后者参数调起来比较玄学,建议先用坐标聚类兜底。

5.2 金额列和车牌列串位

现象:金额里混进了车牌号,或者车牌列出现金额。原因是列容差tolerance设得过大,相邻列被合并。解决是把tolerance调小到 5 左右重新观察列内容,或者改用「按表头文字定位列中心」的方式,让每列以表头为锚点,比纯坐标聚类更稳。

5.3 多页单据只解析了第一页

现象:一张报销单有多页行程,结果只出来第一页的数据。原因是循环只取了pdf.pages[0]。解决是遍历pdf.pages,并且注意跨页表格的表头可能重复出现,映射时要跳过表头行,否则汇总里会多出「行程时间」这种脏数据。

5.4 中文乱码或字段名对不上

现象:导出的 CSV 用表格软件打开是乱码,或者字段名变成问号。原因是编码没指定。解决是写 CSV 时显式encoding="utf-8-sig",读的时候也用同样编码。字段名对不上则多半是FIELD_MAP的键和下游系统要求不一致,导出前做一次重命名映射。

5.5 电子章或水印干扰文本提取

现象:某些页抽出来的文字里夹杂大量重复字符或乱码。原因是水印和电子章也是以文字或图形对象存在,被一并提取。解决是先按字符大小或颜色过滤,水印通常字号偏大、颜色偏浅,可以在page.chars里按size和non_stroking_color过滤掉再聚类。这一步需要先打印几个字符的属性观察,别凭猜设定阈值。

6. 进阶:把规则解析做成可回归的验证集

规则解析写多了会发现一个规律:每适配一种新版式,就可能弄坏旧版式。没有回归验证,改一处错一处,最后没人敢动代码。我的习惯是维护一个小型验证集,每次改解析逻辑都跑一遍,用断言卡住关键字段。

import pytest CASES = [ # (文件, 期望行程数, 期望合计金额) ("sample_a.pdf", 12, 386.50), ("sample_b.pdf", 8, 214.00), ] @pytest.mark.parametrize("fname,expect_rows,expect_total", CASES) def test_parse(fname, expect_rows, expect_total): records = parse_single(fname) # 复用前面的解析函数 assert len(records) == expect_rows ok, total = check_total(records, expect_total) assert ok, f"合计不符:解析 {total},期望 {expect_total}"

验证集不用大,每个版式留一两张脱敏样本就够,关键是覆盖「有表格线 / 无表格线 / 多页 / 带水印」这几种形态。断言优先卡行数和合计金额,这两个指标对解析错误最敏感,字段级断言可以只挑关键列。跑测试的成本很低,但能挡住绝大多数「改 A 坏 B」的回归。

再补一个实用技巧:把每次解析的中间结果(列聚类中心、字段映射后的原始行)落盘成 JSON,出问题时直接对比两次运行的差异,比重新读代码快得多。这个习惯帮我省过不少排查时间,尤其是版式微调导致列中心偏移几个像素的情况,肉眼看代码根本发现不了。

我自己的教训是:别等解析逻辑写完美了才建验证集,第一张单子跑通就该留样本。后面每加一种版式,先加用例再改代码,顺序反过来迟早要还债。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进