
简介一款 Python 编写的开源表格提取工具 Any2Excel主要解决从 PDF 原件、扫描件、复印件、彩色/黑白照片及截图中高效识别表格数据并导出为 Excel 的问题适合办公人员、数据整理者及 Python 自动化学习者使用。工具流程简洁将 PDF 每页转换为 JPG调用 OCR 识别图像随后清理样式并输出 Excel目前会提取 PDF 第一页内容代码覆盖图像预处理、OCR 调用和样式处理等完整环节便于二次开发与功能扩展。压缩包共 12 个文件大小仅 7KB以 Python 源码为主含 5 个 py 脚本并配有依赖清单、YAML 配置样例、开源许可和 README 说明目录结构清晰。已有 415 人学习/下载通过源码和配置样例既可快速上手批量提取图片/PDF 表格也能理解 OCR 工具链搭建与开源项目的组织方式适合作为实战模板或教学案例。1. 图像表格提取的难点不在认识字而在重建行列结构拿到一个 PDF、扫描件或手机拍的照片里面是一张表目标是把它变成 Excel。很多人第一反应是丢给 OCR 引擎去识别文字识别完了却发现字是出来了但不知道哪些字属于同一行、同一列表格彻底散架。图像表格提取的真正瓶颈不是“认字”而是“还原结构”——把像素里的一根根横线竖线、字符之间的几何位置重新映射成 row 和 col 的二维关系。这也是为什么很多通用 OCR 方案做表格提取经常翻车的原因。这篇文章写给需要把 PDF 原件、扫描件、复印件、照片、截图里的表格批量转成 Excel 的工程师覆盖从管线设计、引擎选型、代码实现到参数调优的完整路径核心思路是按“文本型 PDF 走解析、图像型走 OCR结构识别”的级联方案兼顾准确率和速度。新手可以照步骤跑通最小实现有经验的人也能在参数和坑位部分拿到有用的细节。2. 先分清 PDF 是文本原件还是图像扫描件再决定走哪条解析通道2.1 PDF 原件的表格是矢量对象不需要识别像素PDF 格式里存在两类完全不同的“表格”一类是排版时保留文字的文本型 PDF里面的每个字符都有坐标信息表格线常常是矢量矩形或线段另一类是扫描件页面本身是一张大图文字是像素的一部分。前者的正解是用 pdfplumber、PyMuPDF 这类 PDF 解析库直接读取字符位置和线条位置重建表格结构后者才需要 OCR。很多文章一上来就让用户跑 OCR面对原生 PDF 的做法反而是最慢且最不准确的路径——把清晰的矢量文字渲染成图片再做识别既浪费时间还会引入识别错误。判断一个 PDF 有没有文字层用 PyMuPDF 就能快速完成不需要把文件打开看。import fitz def has_text_layer(pdf_path: str) - bool: doc fitz.open(pdf_path) for page in doc: if len(page.get_text().strip()) 0: doc.close() return True doc.close() return False这段代码遍历每一页get_text()返回该页的纯文本内容。只要有一页存在非空文本就认为这个 PDF 是文本型原件。如果所有页面都提取不到字符那基本可以断定是纯扫描件后续走图像识别通道。把这个判断放在流程最前面能避免对原生 PDF 做无意义的 OCR 渲染。常见的误区是用页数、文件大小来判断是否扫描件实际上扫描后加了 OCR 层的 PDF 文件很大但又有文字层反过来有些图片型 PDF 体积并不大所以唯一可靠的标准就是文本提取结果。2.2 表格结构还原的两条路线规则解析与神经网络文本型 PDF 的表格还原常见做法是解析页面里的线条和字符坐标找横向线和纵向线用它们切分出单元格区域再把字符按坐标填充进对应单元格。pdfplumber 把这一套封装成了extract_table()底层依赖的就是线检测和字符坐标聚类。这个方案对有线表格非常精准速度快缺点是遇到无线表格或者线不闭合的 table 时容易把上下相邻的文本合并成一行。图像型 PDF、照片和截图需要走神经网络。目前主流有两类方案一类是检测表格线再做单元格分割代表是 PaddleOCR 的表格识别模型和 Table Transformer另一类是端到端的“图像到 HTML”模型直接输出表格的 HTML 结构再解析 HTML 转 Excel。PaddleOCR 里对中文表格支持比较成熟的方案是PaddleOCR的table模式输入版面图像输出带html标签的表格结构字符串再把 HTML 解析成二维数组。这个方案对有线表格和简单无线表格效果都可用推荐顺序是文本型 PDF 用 pdfplumber图像类用 PaddleOCR如果图像质量差且表格极复杂再考虑 Table Transformer 微调模型。引擎选型可以从维护成本、识别类型和部署环境三个维度对比。pdfplumber 是纯规则不需要 GPU服务器上装个 Python 包就能跑PaddleOCR 需要下载模型文件CPU 可跑但速度慢有 GPU 推理效率会好很多。下面的表给出了四类场景的推荐组合。输入类型推荐引擎优势注意点文本型 PDF有线表格pdfplumber准、快、无模型依赖表格线断裂时需调参文本型 PDF无线表格pdfplumber 坐标聚类保留上下左右结构跨行合并需要后处理扫描件 / 复印件PaddleOCR table 模式中文识别准确率高自带结构恢复需装模型推理耗时约秒级每页手机照片 / 截图PaddleOCR table 模式 预处理能处理倾斜、阴影透视和严重反光时仍需人工校验3. 用 Python 搭一个最小可用的表格提取链路从 PDF 到 Excel3.1 环境准备只装这四个库避免依赖冲突先定技术栈。PDF 解析用 pdfplumber扫描件 OCR 用 PaddleOCRExcel 写出用 openpyxl图像渲染用 PyMuPDF。PyMuPDF 在这里的职责是把扫描件 PDF 页面渲染成 PNG再喂给 OCR。安装命令如下建议先用虚拟环境隔离PaddleOCR 的依赖链比较重和 pdfplumber 之间没有冲突但和 opencv-python 的版本偶尔会有摩擦。pip install pdfplumber openpyxl pymupdf paddleocr安装完成后验证一下 PaddleOCR 是否能正常加载模型。首次运行会联网下载检测、识别、表格结构三个模型离线环境需要提前把模型文件拷到~/.paddlex/official_models目录或者在代码里显式指定model_dir参数。3.2 第一步文本型 PDF 用 pdfplumber 直接抽表原生 PDF 表格提取的代码路径比较直接。pdfplumber 的extract_table()方法会根据页面上的线条和字符位置返回一个二维列表每一行是一个列表每个元素是单元格文本。需要注意extract_table()只提取页面上第一个表格如果一页有多个 table要用page.find_tables()拿到全部表格对象分别调用extract()。import pdfplumber def extract_tables_from_pdf(pdf_path: str): all_tables [] with pdfplumber.open(pdf_path) as pdf: for page_index, page in enumerate(pdf.pages): tables page.find_tables() for table in tables: data table.extract() all_tables.append({ page: page_index 1, data: data }) return all_tablesfind_tables()内部通过检测页面的水平线和垂直线来确定表格边界extract()返回的是以页面坐标为基准的单元格文本二维数组。这个方案的隐含假设是表格有明确的线条结构如果表格线是虚线或者被图片遮挡find_tables()可能直接找不到表格。此时可以降低 pdfplumber 的线检测阈值find_tables(horizontal_strategytext)改为用文本行分布来猜测表格行位置但代价是列边界会变得不准。3.3 第二步扫描件和图片走 PaddleOCR 表格识别图像类输入的识别分成三个阶段版面方向分类、表格结构还原、单元格文本识别。PaddleOCR 的PaddleOCR类开启tableTrue后一次调用同时完成表格结构输出和单元格 OCR返回结果是包含res字段的字典其中的html字段是表格的 HTML 表示。把 HTML 解析成二维数组可以用 pandas 的read_html也可以手动用正则提取。from paddleocr import PaddleOCR import pandas as pd import io def extract_table_from_image(image_path: str): ocr PaddleOCR( langch, use_doc_orientation_classifyFalse, use_doc_unwarpingFalse, use_textline_orientationTrue ) result ocr.predict(image_path, tableTrue) html_content result[0][res][html] tables pd.read_html(io.StringIO(html_content)) return tables[0].values.tolist()这段代码最关键的参数是use_doc_orientation_classify它控制是否对整页图像做方向分类。手机拍照的内容如果画面旋转了 90 度这个开关能自动纠正但如果输入来自扫描仪且方向确定建议把它关掉能省一次模型推理的开销。use_doc_unwarping是去弯曲处理书页扫描的曲面畸变普通复印件和截图不需要关掉可以避免多余的图像变换导致结构识别变差。use_textline_orientation保持开启它处理的是单元格内数字、英文被旋转 90 度的情况比如 Excel 表头常见的竖排文字。pd.read_html()是纯 pandas 的 HTML 表格解析会把table标签转成 DataFrame对colspan跨列和rowspan跨行默认做空值填充。这一步得到的二维列表就是后续写入 Excel 的原始数据注意空值此时是nan写出前要统一替换成空字符串。3.4 第三步用 openpyxl 写出带边框的 Excel 文件表格数据写回 Excel 并不只是把二维数组塞进单元格。真实场景里用户最在意的三件事单元格边框是否保留、数字是不是“文本型数字”、合并单元格是否还原。常见做法是把表格识别得到的 rowspan 和 colspan 信息同时保留下来在写出时合并。下面的代码实现了二维数组加边框写出并对纯数字字符串做类型转换。from openpyxl import Workbook from openpyxl.styles import Border, Side from openpyxl.utils import get_column_letter def write_to_excel(table_data: list, output_path: str, sheet_name: str Sheet1): wb Workbook() ws wb.active ws.title sheet_name thin_border Border( leftSide(stylethin), rightSide(stylethin), topSide(stylethin), bottomSide(stylethin) ) for row_idx, row in enumerate(table_data, start1): for col_idx, cell_value in enumerate(row, start1): if isinstance(cell_value, float) and cell_value ! cell_value: cell_value if isinstance(cell_value, str) and cell_value.isdigit(): cell_value int(cell_value) cell ws.cell(rowrow_idx, columncol_idx, valuecell_value) cell.border thin_border for col_idx in range(1, len(table_data[0]) 1): ws.column_dimensions[get_column_letter(col_idx)].width 14 wb.save(output_path)写出时做了一次数字判断当单元格是纯数字字符串时转成int否则 Excel 打开后左上角会出现绿色三角用户如果想用公式对这些“文本数字”做求和会得到 0这也是很多网友反馈“excel 无法粘贴数据”的常见根源之一。cell_value ! cell_value是判断nan的惯用写法Python 里nan不等于任何值包括自身。列宽直接固定为 14 个字符宽度后续可以在 Excel 里手动调整暂不做自适应宽度计算。4. 参数调优与排错表格线断裂、倾斜矫正和合并单元格的实战处理4.1 pdfplumber 的表格线断裂与vertical_strategy参数选择文本型 PDF 最常见的翻车场景是表格竖线断了。源文件可能来自复印件转存或者制作 PDF 时表格线不是标准矩形而是多条短线拼接pdfplumber 默认的线检测策略会把断线当成两条独立的竖线处理导致列的切分点错乱。pdfplumber 的TableSettings支持vertical_strategy参数取值包括lines、lines_strict、text。默认的lines策略要求检测到的竖线数量足够多才成立断线场景下建议改成text即不依赖竖线而是根据字符分布推测列边界。custom_settings { vertical_strategy: text, horizontal_strategy: lines, text_x_tolerance: 3, }text_x_tolerance表示同一列内字符允许的横向偏移容差。默认值是 3 磅如果单元格内文字是居中排列的左右偏移很容易超过这个值导致同一列被拆成多列。调大这个值能解决列碎片化但调太大会让真正相邻的两列合并。经验值是 4 到 5超过 8 基本就失去区分列的能力了。调整策略后用page.find_tables(settingscustom_settings)重新提取先打印前两行结果人工确认列数是否符合预期再批量跑全量文件。4.2 扫描件识别前的图像预处理尺寸、灰度和纠偏扫描件和手机照片喂给 OCR 之前有三个变量会直接影响表格结构还原分辨率过低、图像倾斜、背景有杂点。PaddleOCR 内部自带检测模型能容忍一定的倾斜和模糊但过度依赖模型兜底会让识别速度明显变慢。建议自己先做轻量预处理统一把图像短边缩放到 1500 像素以上转灰度再做一次阈值二值化。OpenCV 的代码如下。import cv2 def preprocess_image(image_path: str, min_side: int 1500): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) h, w img.shape scale max(min_side / min(h, w), 1.0) img cv2.resize(img, (int(w * scale), int(h * scale))) _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) return binaryOtsu 自适应阈值会根据图像的灰度分布自动计算分割点对白底黑字的复印件效果很好。但注意两点如果原件是蓝章加黑字比如带公章的合同扫描件二进制化会把蓝色公章区域变成黑块反而干扰表格线检测这种情况就不要做二值化直接用灰度图喂给 OCR如果手机拍的是晚上开闪光灯的照片页面中心亮四周暗需要先做背景均匀化OpenCV 的cv2.medianBlur配合均衡化可以有帮助。图像预处理的价值是在源头减少 OCR 的负担但每次图像变换都会引入信息损失原则是“能不做就不做做就做轻量级的”。4.3 合并单元格、跨行跨列与空单元格的三种还原误区表格识别结果里最容易出错的是合并单元格。pdfplumber 对合并单元格的处理方式是被合并的位置返回None表现为表格数组里出现None或。PaddleOCR 的 HTML 输出则显式使用colspan和rowspan属性。两种来源写出 Excel 时的处理逻辑不一样。文本型 PDF 解析后的二维数组里None分为两种语义真正的空单元格和合并导致的空洞。如果不做区分直接写出合并区域会被拆成多个空单元格用户拿到 Excel 后还要手动合并这违背了自动化的初衷。区分方法是看None的分布形态如果一整行中间连续几个None两侧都有值大概率是水平合并如果一列连续多行None大概率是垂直合并。规则实现后可以自动重建合并区域但边界场景很多实际效果大约能覆盖七成场景。更稳的做法是解析时把合并信息一并返回pdfplumber 在高版本里通过table.cells可以拿到每个 cell 的坐标需要自己计算哪些 cell 覆盖了多个网格。PaddleOCR 的 HTML 输出转二维数组时pd.read_html默认会把被colspan或rowspan覆盖的单元格填充为nan且不保留合并位置。如需还原合并效果要用 BeautifulSoup 手动解析读取每个td的colspan和rowspan属性在写出 Excel 时调用ws.merge_cells()。真正生产环境里表格合并信息的完整还原是难点直接基于二维数组写 Excel 是简化方案好处是代码简单坏处是合并结构丢失。5. 验证准确率用一套小样本回归集卡住每次改动的效果5.1 构建一个包含典型变体的验证集表格提取工具改动代码后最怕的是“这个文件修好了另一个文件坏了”。解法是维护一个固定的样张回归集里面放 10 到 20 个有代表性的文件覆盖原生 PDF、扫描件、拍照件、带合并单元格的表格、竖排表头、无线表格六种类型。每次改动逻辑后跑一遍回归集比较输出 Excel 和预期结果的差异。比较不能用肉眼逐个看要写一个自动化对比脚本把预期结果存成标准 CSV把新输出读回来按单元格逐一比对。import csv def compare_cells(expected_csv: str, actual_csv: str): with open(expected_csv, encodingutf-8) as f: expected list(csv.reader(f)) with open(actual_csv, encodingutf-8) as f: actual list(csv.reader(f)) if len(expected) ! len(actual): return {row_count_mismatch: (len(expected), len(actual))} diff_cells [] for r in range(len(expected)): for c in range(min(len(expected[r]), len(actual[r]))): exp_val expected[r][c].strip() act_val actual[r][c].strip() if exp_val ! act_val: diff_cells.append((r, c, exp_val, act_val)) return {diff_cells: diff_cells}比对时把空白和换行符做一次strip()因为 PDF 解析出的单元格文本经常带首尾空格OCR 又可能在数字里加空格这些差异不是实质错误。评估指标用两个行数一致率和单元格级准确率。行数不一致通常是表格结构识别错了比如把两行并成一行这是最严重的错误单元格级不准是字符识别问题比如0被识别成O。两类错误的修复路径完全不同前者要调表格结构参数后者要换 OCR 模型或加图片预处理。5.2 级联策略的价值先判断再决策而不是一刀切生产环境里的输入是混合的最常见的情况是用户扔进来一个 zip里面既有电子签章的 PDF也有手机拍照的合同页。如果全部走 OCR原生 PDF 处理速度会慢三到五倍而且规则 PDF 的表格线可能比 OCR 结果的更整齐。所以最终工具要自动分流把has_text_layer()的结果作为分叉条件有文字层的文件进 pdfplumber 通道没有文字层的进 PaddleOCR 通道。照片和截图本身不是 PDF直接走图像通道截图这类电子生成图像对比度很高PaddleOCR 识别效果通常不错唯一要注意的是截图里经常有阴影或选中区域的蓝色高亮建议先做一次去色处理再识别。5.3 用并发提升批量处理吞吐量PaddleOCR 推理在 CPU 上是瓶颈处理一张图可能耗时 1 到 3 秒批量处理几百个文件时耗时会很可观。在单机场景下可以用 Python 的ThreadPoolExecutor做并发PaddleOCR 的推理是线程安全的模型可以提前初始化一次线程间共享。但并发数不是越大越好CPU 核心数少时线程切换反而拖慢速度经验值是设置为本机 CPU 逻辑核心数的一半。GPU 环境下并发提升不明显因为 GPU 显存和算力是共享的单卡场景串行或小并发即可。from concurrent.futures import ThreadPoolExecutor, as_completed def batch_process(image_paths: list, max_workers: int 4): ocr PaddleOCR(langch) results {} def process_one(path): return path, extract_table_from_image(path, ocr) with ThreadPoolExecutor(max_workersmax_workers) as executor: futures [executor.submit(process_one, p) for p in image_paths] for future in as_completed(futures): path, data future.result() results[path] data return results注意这段代码把PaddleOCR实例放到了函数外避免每个线程重复加载模型。PaddleOCR 首次加载要初始化多个模型文件耗时几秒到十几秒不等这个时间在批量任务中占比很大。如果每个线程各建一个实例内存会暴涨模型文件被重复加载到显存或内存里处理小数据集时甚至会出现“加载模型比识别图片还慢”的怪现象。extract_table_from_image内部不要再创建PaddleOCR实例改成接收外部传入的ocr对象。本文还有配套的精品资源点击获取