ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

iTextSharp 7 PDF表格抽取:坐标聚类还原行列与导出

iTextSharp 7 PDF表格抽取:坐标聚类还原行列与导出

简介:面向需要在 C#/.NET 项目中实现 PDF 表格数据提取的开发者,这份资料以 iTextSharp7 为核心,提供 net40 与 netstandard1.6 两套库文件,并附带 iText.kernel 源码和读取表格数据的示例源码,配套项目可直接运行以查看抽取效果。它适用于报表解析、票据信息整理、后台文档批处理等场景,也方便中级开发者对照源码理解 PDF 表格定位、单元格识别与数据读取的实现思路。压缩包约 17.97MB,文件总数及类型明细上游暂未提供;从内容构成看,主要涉及动态链接库、C# 源码与示例项目文件。已有 395 人学习,读者可据此搭建可运行的表格提取样例,研究版本兼容和源码调用方式,并参考原文实现路径减少自行摸索的成本。

1. PDF 表格复制出来就散架,iTextSharp 7 的坐标还原思路能救回来吗

把一份对账单 PDF 拖进记事本,全选、复制、粘贴,原本整整齐齐的表格立刻塌成一列竖排文字——这是 PDF 表格抽取最常见的翻车现场。PDF 规范里压根没有「表格」这个对象,页面里只有一条条带坐标的绘制指令,行和列是肉眼脑补出来的结构。iTextSharp 7 这份配套源码包干的事,就是把散落的文本片段按 baseline 的 Y 坐标聚成行、按 X 方向的间隙切成列,再拼回一张二维表。它适合手头堆着几十上百份 PDF 报表要转结构化数据的 C# 后端,也适合还困在 iTextSharp 5.x 老 API 里想迁移的人,更适合那些不满足于调一个黑匣子接口、想搞清楚「表格识别」底层到底在算什么的人。下面从拆包一路走到能落库。

2. 拆包与跑通:从 itext7 依赖装起,先让一页 PDF 吐出文本块

2.1 先认清版本:iTextSharp 7 不是 iTextSharp 5 的升级补丁

压缩包解开后一般能看到三块东西:lib目录下的 dll、一个可直接编译的 C# 示例工程、以及几份用来验证的样例 PDF。动手之前先解决一个血泪经验级别的认知问题——包名和命名空间全变了。NuGet 上叫iTextSharp的那个包是 5.x 时代的老东西,命名空间是iTextSharp.text.pdf,网上能搜到的绝大多数中文博客都是这个版本;而 iTextSharp 7 对应的 NuGet 包名就叫itext7,命名空间拆成了iText.Kernel.Pdf、iText.Kernel.Pdf.Canvas.Parser、iText.Layout这么几套。把 5.x 的代码原样贴进 7 的工程,第一行 using 就会标红。

老 API 和新 API 的差别不是改个名字那么简单,PdfTextExtractor的入参从「reader + 页码」变成了「PdfPage 对象」,这个变化直接影响到你能不能按页做流式处理。对照关系大致是这样:

能力iTextSharp 5.x 写法iTextSharp 7 写法
打开文档new PdfReader(path)new PdfReader(path)+new PdfDocument(reader)
取页数reader.NumberOfPagespdf.GetNumberOfPages(),页码从 1 开始
取某页reader.GetPageN(i)pdf.GetPage(i)
抽文本PdfTextExtractor.GetTextFromPage(reader, i)PdfTextExtractor.GetTextFromPage(page, strategy)
监听绘制事件ITextExtractionStrategyIRenderListener+PdfCanvasProcessor
区域过滤LocationTextExtractionStrategy+TextRenderFilterFilteredTextEventListener+TextRegionEventFilter

提示:iTextSharp 7 采用 AGPL 授权,闭源商用需要单独购买商业授权;打包进内部工具自己用没问题,但对外交付的产品要先确认授权口径,这是选型阶段就该问清楚的事,不要等上线前才想起来。

2.2 建工程、装依赖、离线引用 dll

新工程用 SDK 风格最省事,命令行三行搞定。注意包名别手滑打成iTextSharp,那样装进来的是 5.x,后面所有代码都对不上:

dotnet new console -n PdfTableReader cd PdfTableReader # 主包,包名是小写的 itext7,不是 iTextSharp dotnet add package itext7 # 只有当你要把中文写回 PDF 时才需要,纯读取可以不装 dotnet add package itext7.font-asian dotnet restore

工程文件里最终会出现PackageReference节点。我一般不在博客里写死版本号,因为 7 到 8 之间有过几次 API 微调,装当前稳定版即可,装完看一眼obj/project.assets.json里实际解析出来的版本,后面排查兼容性问题时这个信息很有用。

如果目标机器在内网、只能走压缩包里的 dll,那就得改成手工引用。把lib整个目录拷进解决方案,然后在 csproj 里加Reference节点,HintPath用相对路径,Private设成 true 保证 dll 会被复制到输出目录:

<ItemGroup> <Reference Include="iText.Kernel"> <HintPath>..\lib\iText.Kernel.dll</HintPath> <Private>true</Private> </Reference> <Reference Include="iText.IO"> <HintPath>..\lib\iText.IO.dll</HintPath> <Private>true</Private> </Reference> <Reference Include="iText.Layout"> <HintPath>..\lib\iText.Layout.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>

这里有两个参数值得说清楚。一是目标框架,net8.0和net472都能跑,但老框架下要注意System.Text.Encoding.CodePages的注册,读 GBK 编码的元数据时会用到。二是BouncyCastle依赖,iTextSharp 7 处理加密 PDF 时依赖它,如果项目里别的地方也引了不同版本的 BouncyCastle,运行时会抛TypeInitializationException或者签名不匹配,表现是「代码明明编译过了,一 new PdfReader 就崩」,翻车点非常隐蔽。解决办法是在 csproj 里显式绑定版本,或者干脆用itext7.bouncy-castle-adapter这个适配包,把冲突收敛掉。

编译通过不代表能读到东西,先跑一个最小验证,确认这份 PDF 到底有没有文本层。

2.3 最小可跑:先把字读出来,确认不是扫描件

在写复杂的坐标聚类之前,先用官方自带的策略把文字吐出来看一眼。这一步的核心目的不是拿结果,而是排雷:如果这里输出是空的,后面所有行列算法都白搭。

using System; using System.Text; using iText.Kernel.Pdf; using iText.Kernel.Pdf.Canvas.Parser; using iText.Kernel.Pdf.Canvas.Parser.Listener; // 控制台默认不是 UTF-8,中文会变问号,先设编码 Console.OutputEncoding = Encoding.UTF8; string path = args.Length > 0 ? args[0] : "sample.pdf"; // PdfDocument 关闭时会连带关闭 PdfReader,两个 using 一起写没问题 using var reader = new PdfReader(path); using var pdf = new PdfDocument(reader); int pages = pdf.GetNumberOfPages(); Console.WriteLine($"总页数:{pages}"); for (int i = 1; i <= pages; i++) // 注意:页码从 1 开始,不是 0 { var page = pdf.GetPage(i); // LocationTextExtractionStrategy 内部已按行聚合,但输出只剩字符串,X 坐标全丢了 var strategy = new LocationTextExtractionStrategy(); string text = PdfTextExtractor.GetTextFromPage(page, strategy); Console.WriteLine($"----- page {i} ({text.Length} chars) -----"); Console.WriteLine(text); }

几个参数和细节值得留意。GetNumberOfPages()返回的是逻辑页数,和阅读器里看到的页码一致,循环从 1 开始,写成 0 会直接抛越界。LocationTextExtractionStrategy是官方给的通用策略,它会按基线把同一行的文字拼起来,所以输出看起来「像那么回事」,但它的产物是一个纯字符串,列与列之间只剩几个空格,你没法知道某个金额到底落在第几列——这正是必须自己写监听器的原因。using的释放顺序也别乱,PdfDocument包着PdfReader,正常的 using 嵌套已经保证了先关文档再关 reader,手工Close()时反过来就会抛「文档已关闭」。

跑通之后你会看到两种典型结果:文字成段出现、行列位置大致可辨,说明有文本层,可以往下走;或者整页只有页码、正文全空,那就是扫描件,得先过 OCR,别在坐标算法上浪费时间。

3. 两次聚类还原行列:IRenderListener 采集 + Y 轴分行 + X 轴切列

3.1 拿到每个文本块的 X/Y/宽高

iTextSharp 7 的解析器会把页面内容流翻译成一串事件,文本、图片、路径各是一类。我们只关心文本事件,实现IRenderListener就能拿到每一次绘制文字的完整信息,包括基线起止点、字号、字体。下面这个采集器是整个方案的地基:

using System.Collections.Generic; using iText.Kernel.Geom; using iText.Kernel.Pdf.Canvas.Parser.Data; using iText.Kernel.Pdf.Canvas.Parser.Listener; public sealed class TextChunk { public string Text { get; set; } = ""; public float X { get; set; } // 左边界(pt,原点在页面左下角) public float Right { get; set; } // 右边界 public float Y { get; set; } // 基线 Y public float Height { get; set; } // 上升线到下降线的距离 public float FontSize { get; set; } public float Width => Right - X; } public sealed class ChunkCollector : IRenderListener { public List<TextChunk> Chunks { get; } = new List<TextChunk>(); public void BeginTextBlock() { } public void EndTextBlock() { } public void RenderImage(ImageRenderInfo renderInfo) { } // 图片事件不处理 public void RenderText(TextRenderInfo info) { string text = info.GetText(); if (string.IsNullOrWhiteSpace(text)) return; // 基线是线段,起点和终点给出这段文字的水平跨度 var baseline = info.GetBaseline(); Vector s = baseline.GetStartPoint(); Vector e = baseline.GetEndPoint(); // Vector.I1 / I2 分别是 x / y 分量索引 float x1 = s.Get(Vector.I1), x2 = e.Get(Vector.I1); Chunks.Add(new TextChunk { Text = text, X = System.Math.Min(x1, x2), // 右到左排版时 start 可能大于 end Right = System.Math.Max(x1, x2), Y = s.Get(Vector.I2), Height = info.GetAscentLine().GetStartPoint().Get(Vector.I2) - info.GetDescentLine().GetStartPoint().Get(Vector.I2), FontSize = info.GetFontSize() }); } }

关键点是坐标单位。PDF 用户空间以点(pt)为单位,1 pt = 1/72 英寸,A4 纸大约是 595×842 pt,原点在左下角,所以Y 越大越靠上,排序时要降序。Get(Vector.I1)取横坐标、Get(Vector.I2)取纵坐标,这个索引常量很容易记混,写错的表现是「行聚类全乱」。Min/Max那两行不是多余的保护,遇到右对齐的金额列或者旋转文本时,GetStartPoint()未必是左端,不做归一化会出现负数宽度,后面的间隙计算全废。

挂载监听器不需要走PdfTextExtractor,直接用PdfCanvasProcessor逐页喂进去:

var collector = new ChunkCollector(); var processor = new PdfCanvasProcessor(collector); processor.ProcessPageContent(page); // 一次处理一页,处理完就能做聚合

按页处理而不是整本一次性收集,是控制内存的第一步。一本两百页的报表如果全部 chunk 堆在内存里,加上字符串对象,几百兆很轻松。

3.2 行聚类:Y 容差怎么定

拿到一堆无序的文本块之后,第一步是把它们按纵向位置归成行。思路很朴素:按 Y 降序排,逐个往下扫,如果当前块的 Y 和正在累积的这一行的平均 Y 差值在容差之内,就归入同一行,否则另起一行。

using System; using System.Collections.Generic; using System.Linq; public static List<List<TextChunk>> GroupRows(List<TextChunk> chunks, float tolerance) { var rows = new List<List<TextChunk>>(); // Y 降序:PDF 原点在左下,越靠上 Y 越大 foreach (var c in chunks.OrderByDescending(c => c.Y).ThenBy(c => c.X)) { var current = rows.LastOrDefault(); if (current != null && Math.Abs(current.Average(r => r.Y) - c.Y) <= tolerance) { current.Add(c); } else { rows.Add(new List<TextChunk> { c }); } } return rows; }

容差是这段代码唯一需要调的参数,取值直接影响结果。取太小(比如 0.5pt),同一行里字号略有差异的单元格会被拆成两行,表格瞬间变成双倍行数;取太大(比如 15pt),相邻两行会被合并,数据串行。我一般用当前页所有文本块字号的中位数乘以 0.4 到 0.6,A4 报表常见 9pt 正文对应 3.6 到 5.4pt 的容差,覆盖得住上下标和轻微的基线偏移。

另一个细节是参考值用Average(r => r.Y)而不是current[0].Y。用首元素做锚点,在长表格里会逐行漂移:第一行的锚点偏低,第二行跟着偏,几十行累积下来能偏出一整个字高,最后几行全部错位。用均值相当于每加一个块就重新校准一次,稳定性好很多。

3.3 列切分与列对齐:X 间隙阈值 + 最近列吸附

行分好之后,每行内部按 X 排序,从左往右扫,比较「当前块的左边界」和「前一个块的右边界」之间的空隙。空隙大于阈值就认为跨过了列边界,切一个新单元格;小于阈值说明还是同一个单元格里的词间距,用空格拼起来。

using System.Text; public static List<string> SplitCells(List<TextChunk> row, float gapThreshold) { var cells = new List<string>(); var sb = new StringBuilder(); var ordered = row.OrderBy(c => c.X).ToList(); for (int i = 0; i < ordered.Count; i++) { if (i > 0) { float gap = ordered[i].X - ordered[i - 1].Right; if (gap > gapThreshold) { cells.Add(sb.ToString().Trim()); sb.Clear(); } else if (gap > 0.5f) { sb.Append(' '); // 同一单元格内的词间距,补一个空格 } } sb.Append(ordered[i].Text); } cells.Add(sb.ToString().Trim()); return cells; }

gapThreshold是第二个要调的参数。经验值取平均字符宽度的 1.2 到 2.0 倍,中文报表大约 8 到 14pt。阈值偏小,带空格的单元格(比如「上海 分公司」)会被切成两列;阈值偏大,相邻的窄列(序号列、单位列)会粘在一起。比较稳的做法是先按页统计所有相邻块间隙的分布,找出那个明显的断层值——真实表格的列间距通常是 20pt 以上,字间距在 2pt 以内,中间是空的,肉眼都能看出来。

切完列还有一个绕不开的问题:行与行的单元格数量对不上。合并单元格会让某行只切出 3 个格,而表头有 5 列,按索引硬对就会把第 4 列的值塞进第 3 列。我的做法是给每列算一个「列中心」,用表头行的单元格中心作为基准,数据行的每个单元格吸附到最近的列中心,中间缺的位置补空串:

// 依据表头行的单元格中心建立列基准 var colCenters = headerCells .Select(c => (c.X + c.Right) / 2f) .ToArray(); // 数据行按中心距离吸附,距离超过半列宽就视为无效匹配 int IndexOf(TextChunk c) { float center = (c.X + c.Right) / 2f; int best = 0; float min = float.MaxValue; for (int i = 0; i < colCenters.Length; i++) { float d = Math.Abs(colCenters[i] - center); if (d < min) { min = d; best = i; } } return best; }

跨页表格要单独处理:续页通常没有表头,得拿上一页的列基准来吸附,否则第二页的列位置和第一页差几个点,数据就串行了。实际项目里我会把列基准存成「文档级」的,只在首页建立一次,后续页面全部复用。

4. 排查手册:五类读表翻车现场的现象、成因与定位手法

4.1 提取为空或只有零星几个字

现象一:控制台打印出来整页空白,或者只有页码和页眉。原因通常是三种。加密 PDF,new PdfReader(path)直接抛异常,或者在不给密码的情况下只能拿到极少内容;扫描件,页面主体是一张 JPEG,压根没有文本层;字体子集化且缺失 ToUnicode 映射,GetText()返回空串。解决办法是分级判断:先捕获密码异常,用new PdfReader(path, new ReaderProperties().SetPassword(Encoding.UTF8.GetBytes(pwd)))补上密码;再看页面的图片事件次数,写个空实现的RenderImage计数器,如果一页里图片事件有 1 到 2 次且文本块为 0,基本就是扫描件,转 OCR 流程;最后看字体,如果GetText()返回的是(cid:123)这种形式,说明字体没有回映射表,只能换工具或者走版面分析。

现象二:某一页正常,下一页全空。大概率是该页用了不同的内容流组织方式,或者被注释层覆盖。用PdfCanvasProcessor逐页处理时加个页级 try/catch,把异常的页号记下来,别让一页坏掉整批任务。

4.2 行列错位:容差参数与页边距干扰

现象三:一行被拆成两行,或者两列粘成一列。前者是 Y 容差太小,后者是 X 间隙阈值太大,两个参数互相独立,改一个不要动另一个。定位手法是把中间结果 dump 出来,每行打印Y 值 + 单元格数量,看看到底哪一行开始异常。我的习惯是先固定间隙阈值,把容差从 1pt 试到 8pt,找出单元格数量稳定的那个区间,再回头调间隙。

现象四:表头和数据整体错位一列。八成是页眉页脚混进来了。PDF 的页码、公司抬头、水印都在正文上方或下方,它们的 Y 值和表格首行很接近,聚类时会被并进同一行。解决办法是用区域过滤把正文框出来:

using iText.Kernel.Geom; using iText.Kernel.Pdf.Canvas.Parser.Filter; using iText.Kernel.Pdf.Canvas.Parser.Listener; // 只保留正文矩形区域内的文字,绕开页眉页脚 var rect = new Rectangle(36, 60, 523, 700); // x, y, width, height,单位 pt var filter = new TextRegionEventFilter(rect); var strategy = new FilteredTextEventListener(new LocationTextExtractionStrategy(), filter); string text = PdfTextExtractor.GetTextFromPage(page, strategy);

Rectangle的四个参数分别是左下角 x、左下角 y、宽、高,注意原点在左下角,所以 y=60 指的是「距页面底部 60pt」,不是距顶部。这个坐标系坑过一次就记住了。

4.3 中文乱码、句柄泄漏与大文件 OOM

现象五:提取出来的中文在控制台是问号或方块。这多半不是提取失败,而是输出端编码问题。方法开头加一句Console.OutputEncoding = Encoding.UTF8;就能解决大半。如果落盘成文件,用new UTF8Encoding(true)带 BOM 写,Excel 打开才不会乱码。

现象六:批量跑几百个文件后报「文件正被另一进程使用」,或者内存一路涨到几个 G 被系统杀掉。前者是PdfDocument没释放,异常路径上尤其容易漏——try 块里抛异常,using 虽然会释放,但如果中间又new了一个 reader 就重叠了。后者是一次性把所有页的 chunk 都缓存下来了。我的做法是:单个文件内按页处理,处理完一页立刻转成字符串行,chunk 列表清空;文件级别用using保证释放;批量并发度控制在 CPU 核数的一半,别贪多,PDF 解析是 CPU 和 IO 混合型,开满反而更慢。

5. 封装成可复用组件:DataTable 映射、CSV 导出与批量并发

5.1 从字符串行到 DataTable:表头识别与列名清洗

提取结果最终要落到DataTable或者实体列表里,中间隔着一层列名映射。真实报表的表头经常有合并单元格、换行、带括号单位,直接拿来当列名会在DataTable.Columns.Add时抛重复列名异常。我一般做三步清洗:去掉换行和多余空格、括号里的单位截出来单独记、重复列名追加序号兜底。

using System.Data; using System.Text.RegularExpressions; public static DataTable ToTable( IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string>> rows) { var dt = new DataTable(); var used = new HashSet<string>(); foreach (var raw in headers) { // 去换行、压空格,再兜底一个列名 string name = Regex.Replace(raw ?? "", @"\s+", " ").Trim(); if (string.IsNullOrEmpty(name)) name = $"C{dt.Columns.Count + 1}"; if (!used.Add(name)) name = $"{name}_{dt.Columns.Count + 1}"; dt.Columns.Add(name, typeof(string)); } foreach (var r in rows) { var dr = dt.NewRow(); for (int i = 0; i < dt.Columns.Count; i++) dr[i] = i < r.Count ? r[i] : string.Empty; // 列数不足补空 dt.Rows.Add(dr); } return dt; }

这里所有列都先声明成string,不要在第一遍就转decimal或DateTime。PDF 里的数字常带千分位、货币符号、全角括号,强行转换会在某一行炸掉整批任务。正确做法是原样存字符串,落库前用decimal.TryParse配合清洗规则再转一次,失败的记进异常清单人工核对。

5.2 CSV 导出:转义和 BOM 是两个必踩的坑

导出 CSV 看着最简单,实际最容易在交付环节翻车。单元格里只要有逗号、双引号或者换行,不做转义就会把整张表撑歪;中文在 Excel 里乱码,基本是没写 BOM。这两件事写在一个小函数里就够了:

using System.IO; using System.Text; public static void WriteCsv(string path, DataTable dt) { // 带 BOM 的 UTF-8,Excel 双击打开中文才正常 using var writer = new StreamWriter(path, false, new UTF8Encoding(true)); writer.WriteLine(string.Join(",", dt.Columns .Cast<DataColumn>() .Select(c => Escape(c.ColumnName)))); foreach (DataRow row in dt.Rows) { writer.WriteLine(string.Join(",", row.ItemArray .Select(v => Escape(v?.ToString() ?? "")))); } } static string Escape(string v) { if (v.IndexOfAny(new[] { ',', '"', '\n', '\r' }) < 0) return v; return "\"" + v.Replace("\"", "\"\"") + "\""; }

Escape里的规则是 RFC 4180 的老规矩:含特殊字符就整体加双引号,内部的双引号翻倍。别小看这一步,金额里带千分位逗号是常态,不转义的话下游解析会直接错列。

5.3 批量处理:并发度、失败隔离与结果落盘

单文件跑通之后,真正的活是处理一整个目录。批量脚本有三条纪律:单个文件失败不能拖垮整批、并发度要克制、每个文件的处理结果要单独落盘方便回溯。

using System; using System.IO; using System.Threading.Tasks; var files = Directory.GetFiles(inputDir, "*.pdf", SearchOption.TopDirectoryOnly); // 并发度取核数一半,PDF 解析吃内存,开满容易 OOM var options = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; Parallel.ForEach(files, options, file => { string name = Path.GetFileNameWithoutExtension(file); try { var rows = Extract(file); // 前面几章的完整流程 WriteCsv(Path.Combine(outputDir, name + ".csv"), rows); Console.WriteLine($"OK {name} rows={rows.Rows.Count}"); } catch (Exception ex) { // 失败单独记日志,不抛出去中断整批 File.AppendAllText(Path.Combine(outputDir, "_failed.log"), $"{name}\t{ex.GetType().Name}\t{ex.Message}{Environment.NewLine}"); } });

失败日志里我坚持记三项:文件名、异常类型、异常消息。只记消息的话,遇到「索引越界」根本不知道是列对齐出的问题还是页码写错了;带上类型,BadPasswordException和NullReferenceException一眼能分开,前者补密码,后者回去查代码。

6. 进阶校验:用字符数守恒和列数守恒给提取结果上保险

调参调到「看起来对了」是最危险的状态。表格抽取没有 100% 的正确率,关键是你要知道自己错在哪、错了多少。我常用的自检是三组守恒:字符数守恒、列数守恒、字段格式守恒。

字符数守恒是拿自定义监听器拼出来的所有字符数,和官方LocationTextExtractionStrategy的输出字符数比。后者是成熟实现,可以作为基准。两者差异超过 1%,说明你的采集器漏了事件或者过滤掉了本该保留的内容。

static (int mine, int baseline) CharCount(PdfPage page) { var collector = new ChunkCollector(); new PdfCanvasProcessor(collector).ProcessPageContent(page); int mine = collector.Chunks.Sum(c => c.Text.Length); int baseline = PdfTextExtractor .GetTextFromPage(page, new LocationTextExtractionStrategy()) .Count(ch => !char.IsWhiteSpace(ch)); return (mine, baseline); }

差异在 1% 以内可以继续;超过 5% 基本可以确定有问题,先去看是不是页眉页脚过滤把正文也切掉了。

列数守恒更适合做回归。同一批报表格式固定,每页切出来的列数应该恒定。跑一批之后统计列数的众数,偏离众数的页就是可疑页,把它们单独挑出来人工看,比随机抽样效率高得多。字段格式守恒则是最后一道关:金额列用正则^-?[\d,]+\.\d{2}$过一遍,日期列用^\d{4}-\d{2}-\d{2}$过一遍,命中率低于 95% 说明列对齐错了。

调参这块我整理过一张表,按这个顺序试基本不用反复横跳:

参数起始值调整方向异常表现
行聚类容差字号中位数 × 0.5行被拆开就调大,行被合并就调小行数翻倍或减半
列间隙阈值平均字符宽 × 1.5列粘连调大,单元格被切开调小列数忽多忽少
正文裁剪框左右 36pt,上下 60pt表头被切掉就减小上边距首行缺失
并发度CPU 核数 / 2出现 OOM 就减半内存曲线锯齿上升

顺序很重要:先定裁剪框,再定行容差,最后定列阈值。反过来调会互相干扰,改完一个另一个又不对,纯属自己给自己找麻烦。

从那以后我每接一个 PDF 批次,都强制先抽 20 页跑一遍守恒校验,再谈准确率——跳过这一步直接上批量,翻车只是时间问题。压缩包里那份源码工程把采集、分行、切列、导出这几段都拆成了独立文件,照着第 2 章的命令建个工程,把lib里的 dll 引用进去,拿自带的样例 PDF 跑一遍,就能看到每一行切出来的单元格数量,比读十篇博客都直观。希望帮到你。

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

RELATED READING

延伸阅读

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