用 C# 把每周 6 小时的 PDF 录入干到 20 分钟:一份可复制的桌面 OCR 方案

0 阅读2分钟

先说结论

"把纸质单据 PDF 自动转成 Excel"这类需求,在 .NET 桌面端完全可以用纯离线方案做到够用级别,不需要云 API,也不需要训练模型。最近交付的一个真实项目:客户每周 6 小时的手工录入,压到了 20 分钟。本文把技术选型和关键代码整理出来,链路可以直接复用。

处理链路

PDF 文件夹 → 自动分类 → 文本版直接抽取 / 扫描件渲染 PNG 后 OCR
          → 字段规则抽取(JSON 模板)→ 结果复核表 → ClosedXML 导出 Excel

第一步:判断 PDF 是文本版还是扫描件

用 PdfPig(NuGet:UglyToad.PdfPig,Apache 2.0):

using UglyToad.PdfPig;

static (bool IsTextPdf, string Text) TryExtractText(string pdfPath)
{
    using var doc = PdfDocument.Open(pdfPath);
    var sb = new System.Text.StringBuilder();
    foreach (var page in doc.GetPages())
        sb.AppendLine(page.Text);

    var text = sb.ToString();
    int letterCount = text.Count(char.IsLetterOrDigit);
    return (letterCount > 50, text);   // 有效字符太少 = 扫描件
}

这个判断看着粗糙,实测很稳:扫描件里偶尔有水印字符,但有效字符数会远低于阈值。

第二步:扫描件渲染 + OCR

渲染用 PDFium(PdfiumViewer 封装 + 原生运行时包),300 DPI 输出 PNG:

using PdfiumViewer;

using var pdf = PdfDocument.Load(pdfPath);
for (int i = 0; i < pdf.PageCount; i++)
{
    using var img = pdf.Render(i, 2000, 2800, 300, 300, forPrinting: false);
    img.Save(Path.Combine(tempDir, $"page_{i}.png"),
             System.Drawing.Imaging.ImageFormat.Png);
}

OCR 做了双引擎可切换:

引擎 A:Windows.Media.Ocr(系统内置,免费,支持中文)。坑点:

  1. TFM 要用 net8.0-windows10.0.19041.0 以上才有 WinRT 投影;
  2. 机器要装中文语言包;
  3. 输入图像有最大边长限制,用 OcrEngine.MaxImageDimension 属性动态缩放,别硬编码。
using Windows.Globalization;
using Windows.Graphics.Imaging;
using Windows.Media.Ocr;

static async Task<string> OcrWithWindowsAsync(string imagePath)
{
    var engine = OcrEngine.TryCreateFromLanguage(new Language("zh-Hans-CN"))
              ?? OcrEngine.TryCreateFromUserProfileLanguages()
              ?? throw new InvalidOperationException("本机无中文 OCR 引擎");

    await using var fs = File.OpenRead(imagePath);
    var decoder = await BitmapDecoder.CreateAsync(fs.AsRandomAccessStream());

    var transform = new BitmapTransform();
    double maxDim = engine.MaxImageDimension;
    if (decoder.OrientedPixelWidth > maxDim || decoder.OrientedPixelHeight > maxDim)
    {
        double scale = maxDim / Math.Max(decoder.OrientedPixelWidth, decoder.OrientedPixelHeight);
        transform.ScaledWidth  = (uint)(decoder.OrientedPixelWidth  * scale);
        transform.ScaledHeight = (uint)(decoder.OrientedPixelHeight * scale);
    }

    var bmp = await decoder.GetSoftwareBitmapAsync(
        BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied,
        transform, ExifOrientationMode.IgnoreExifOrientation,
        ColorManagementMode.DoNotColorManage);

    var result = await engine.RecognizeAsync(bmp);
    return string.Join(Environment.NewLine, result.Lines.Select(l => l.Text));
}

引擎 B:Tesseract 5(NuGet:Tesseract),离线打包 chi_sim.traineddata:

using Tesseract;

static string OcrWithTesseract(string imagePath)
{
    using var engine = new TesseractEngine(@".\tessdata", "chi_sim", EngineMode.Default);
    using var pix = Pix.LoadFromFile(imagePath);
    using var page = engine.Process(pix);
    return page.GetText();
}

选型判断:环境可控用 Windows OCR 零成本起步;机器环境杂、或某些版式效果差,Tesseract 兜底。单据里有金额和往来单位,客户普遍不接受数据出内网——这也是不上云 OCR 的核心理由,不只是省钱。

第三步:并发与 UI

OCR 是 CPU 密集操作,Parallel.ForEachAsync + SemaphoreSlim 限流,进度用 IProgress<T> 回 UI 线程:

var semaphore = new SemaphoreSlim(Math.Max(2, Environment.ProcessorCount / 2));
var progress = new Progress<string>(msg => StatusText.Text = msg);

await Parallel.ForEachAsync(pdfFiles, ct, async (file, token) =>
{
    await semaphore.WaitAsync(token);
    try
    {
        var (isText, text) = TryExtractText(file);
        if (!isText) text = await RenderAndOcrAsync(file, token);

        lock (_results) _results.Add(ExtractFields(file, text));
        ((IProgress<string>)progress).Report($"完成:{Path.GetFileName(file)}");
    }
    finally { semaphore.Release(); }
});

第四步:字段抽取用规则模板,别碰模型

金额、日期、单号的抽取:每家厂商一份 JSON 模板(正则 + 锚点 + 校验),新厂商加模板即可。比训练模型便宜两个数量级,而且"配模板"本身就是可以持续收费的服务动作。

导出用 ClosedXML(MIT,避开 EPPlus 的商用授权配置):

using ClosedXML.Excel;

using var wb = new XLWorkbook();
var wsx = wb.Worksheets.Add("汇总");
wsx.Cell(1, 1).InsertTable(_records);
wsx.Columns().AdjustToContents();
wb.SaveAs(outPath);

工程边界(诚实版)

  • 清晰印刷体:够用;手写单、倾斜、低分辨率翻拍:两个引擎都翻车,必须留人工复核通道;
  • 关键字段必须有格式校验(金额小数点、单号正则),可疑行标黄让人确认。

延伸

同一套字段抽取逻辑放 .NET WebAPI 后端,就能接微信小程序(拍照上传识别)或 MAUI APP(现场扫描)。桌面端、小程序、APP 三端的定制开发我都在做,这类"重复录入自动化"的活儿尤其多——觉得有用的话点个关注,后面会写这套工具的授权机制设计(年费模式怎么落地)。