RemitExtract 0.1.1
dotnet add package RemitExtract --version 0.1.1
NuGet\Install-Package RemitExtract -Version 0.1.1
<PackageReference Include="RemitExtract" Version="0.1.1" />
<PackageVersion Include="RemitExtract" Version="0.1.1" />
<PackageReference Include="RemitExtract" />
paket add RemitExtract --version 0.1.1
#r "nuget: RemitExtract, 0.1.1"
#:package RemitExtract@0.1.1
#addin nuget:?package=RemitExtract&version=0.1.1
#tool nuget:?package=RemitExtract&version=0.1.1
RemitExtract
Point it at an invoice PDF, a QR-bill code, or a structured e-invoice XML file and get back a checksum-validated IBAN plus BIC, amount, and reference data ready to prefill a bank-transfer form.
Offline .NET library that extracts payment fields (IBAN, BIC, amount, currency, beneficiary
name, payment reference, US ABA routing number) from invoices, so an international
bank-transfer form can be prefilled. Everything runs locally: no cloud OCR, no external API
calls. Ships as a NuGet package (RemitExtract) plus a CLI sample that demonstrates it end to end.
Install
dotnet add package RemitExtract
Multi-targets net8.0 and net10.0. Packed with SourceLink
and a .snupkg symbol package, so you can step into the library's source from a debugger.
Dependencies
Four packages, all MIT or Apache-2.0, all offline: PdfPig 0.1.15 (Apache-2.0, PDF text
layer), ZXing.Net 0.16.11 (MIT, barcode decode/encode), ZXing.Net.Bindings.SkiaSharp
0.16.22 (MIT, ZXing's SkiaSharp bitmap binding), SkiaSharp 3.119.4 (MIT, cross-platform
image decode). Note the PdfPig nuget id is PdfPig, not UglyToad.PdfPig — the latter
id exists but only carries prerelease builds; the namespaces are UglyToad.PdfPig.* either
way. Package versions across the solution are centrally managed in Directory.Packages.props
(NuGet Central Package Management) — individual .csproj files reference packages without a
Version attribute.
Layout
RemitExtract.slnx
├── src/RemitExtract/ the library (NuGet package "RemitExtract")
│ ├── Core/ model, pipeline, validators, extraction vocabulary
│ ├── Sources/ PdfPig text layer, plain text, barcode decoder, IOcrEngine seam
│ └── Stages/ the six pipeline stages
├── src/RemitExtract.Sample/ CLI sample that references the library
│ └── samples/ DE / UK / US fixtures, an EPC GiroCode payload, and its QR image
├── src/RemitExtract.Sample.WebApi/ ASP.NET Core sample exposing POST /api/extract
├── tests/RemitExtract.Tests/ xUnit tests against the library directly
└── tests/RemitExtract.Sample.WebApi.Tests/ xUnit tests against the Web API sample
Build, test, pack
dotnet restore
dotnet build
dotnet test
dotnet pack src/RemitExtract/RemitExtract.csproj -c Release # -> RemitExtract.<version>.nupkg + .snupkg
Run the CLI sample
dotnet run --project src/RemitExtract.Sample -- src/RemitExtract.Sample/samples/invoice-de.txt --trace
dotnet run --project src/RemitExtract.Sample -- src/RemitExtract.Sample/samples/invoice-uk.txt
dotnet run --project src/RemitExtract.Sample -- src/RemitExtract.Sample/samples/invoice-us.txt
dotnet run --project src/RemitExtract.Sample -- src/RemitExtract.Sample/samples/invoice-de.txt --qr src/RemitExtract.Sample/samples/girocode.txt
dotnet run --project src/RemitExtract.Sample -- src/RemitExtract.Sample/samples/invoice-girocode.png --trace
The .txt fixtures stand in for OCR output so you can exercise the whole pipeline
without a scanner. Point it at a real digital PDF and the PdfPig path takes over.
invoice-girocode.png is the same GiroCode payload as girocode.txt, rendered as a QR
image — point the CLI straight at it and BarcodeDecodingStage decodes the code itself,
no --qr flag needed.
Run the Web API sample
A minimal ASP.NET Core host wraps the same pipeline behind one HTTP endpoint, listening on
http://localhost:5080.
dotnet run --project src/RemitExtract.Sample.WebApi
curl -X POST http://localhost:5080/api/extract \
-F "file=@src/RemitExtract.Sample/samples/invoice-de.txt"
The JSON response has one entry per field (empty fields are still present, just with a null
value), plus elapsedMs and any rejected candidates that failed validation — it's the same
six-stage pipeline as the CLI sample, run synchronously. GET / returns a small banner naming
the endpoint, and an OpenAPI document is served at /openapi/v1.json in the Development
environment (the default via launchSettings.json); the same environment also serves an
interactive Swagger UI at http://localhost:5080/swagger, so you can try the endpoint from a
browser instead of curl. This is a wiring demonstration for a real backend, not a production API
— no auth, no async job queue for slow OCR; see "Wiring into a web API" below for what a
production deployment needs on top of it.
What is real vs. what is a seam
| Component | State |
|---|---|
| IBAN mod-97 + country length table | Fully implemented, verified |
| US ABA routing checksum | Fully implemented, verified |
Amount parsing (1.234,56 vs 1,234.56) |
Fully implemented, verified |
| EPC069-12 GiroCode parser | Fully implemented |
| Swiss QR-bill parser | Fully implemented |
| Factur-X / ZUGFeRD CII XML | Implemented, namespace-agnostic |
| PDF text layer (PdfPig, words + boxes) | Implemented |
| Factur-X / ZUGFeRD PDF/A-3 attachment | Implemented via document.Advanced.TryGetEmbeddedFiles |
| BIC directory lookup | Seam — BicValidator.ExistsInDirectory returns false; wire to your SWIFT directory |
| OCR engine | Seam — IOcrEngine, currently NullOcrEngine |
Barcode decoding (GiroCode / Swiss QR-bill, from a .jpg/.jpeg/.png) |
Implemented — ZXingBarcodeDecoder (ZXing.Net + SkiaSharp); --qr manual injection still works too |
| Layout model (LiLT / local LLM) | Seam — register a stage at Order = 50 |
No model of any kind is called at runtime. The pipeline is deterministic C# plus one managed PDF reader — no network egress, no external service dependency. The seams above are all designed for local components too.
Adding a real OCR engine
Implement IOcrEngine and return TextLines with boxes. Two offline options:
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.19.2" />
<PackageReference Include="TesseractOCR" Version="5.3.5" />
Run it out-of-process (gRPC sidecar) in production. A native crash in ONNX Runtime or leptonica must not take down the transfer API pod, and OCR pods need to scale — and possibly get a GPU — independently of the API.
Adding a local layout model
public sealed class LocalModelStage : IExtractionStage
{
public int Order => 50; // after rules, before the validation gate
public async Task ExecuteAsync(ExtractionContext ctx, CancellationToken ct)
{
// Skip entirely if a structured payload already answered everything.
if (ctx.HasStructuredPayload) return;
// LiLT (ONNX, int8, ~130MB, ~300ms/page CPU) or a local LLM sidecar
// with GBNF-constrained JSON output. Emit FieldCandidate with
// Source = ExtractionSource.LocalModel so it always ranks below
// deterministic sources — and always passes back through the gate.
}
}
The contract matters more than the model: anything the model proposes is re-checked
by ValidationGateStage, so a hallucinated IBAN dies at mod-97 rather than
reaching a customer.
Extending coverage cheaply
Core/ExtractionVocabulary.cs is the highest-leverage file. RuleBasedFieldStage,
BeneficiaryNameStage, and ValidationGateStage all take an optional
IExtractionVocabulary constructor parameter — pass null (or nothing) for the
built-in 8-language default, or build a custom one with ExtractionVocabularyBuilder:
var vocabulary = new ExtractionVocabularyBuilder()
.AddLabels(TransferField.PaymentReference, "გადახდის დანიშნულება")
.AddCurrencyCode("XYZ")
.AddBicStopwords("SUBSCRIPTION")
.ReplaceLabels(TransferField.Amount, "amount due") // escape hatch: replaces the built-in set
.Build();
var pipeline = new ExtractionPipeline(
[
new EmbeddedPayloadStage(),
new TextAcquisitionStage(sources: [...], ocr: ...),
new RuleBasedFieldStage(vocabulary),
new BeneficiaryNameStage(vocabulary),
new ValidationGateStage(vocabulary),
]);
Add* methods are additive (they extend the built-in set); Replace*/Clear* methods
are the explicit escape hatch for a built-in label or stopword that's actively wrong for
your corridor. No deploy needed to add a corridor - build the vocabulary from a DB table
or config file at startup.
Notes on behaviour worth keeping
- A candidate that fails a checksum is dropped, not demoted. A blank field costs the customer seconds; a wrong prefilled IBAN costs a recall procedure.
- Beneficiary name is never auto-filled even from a QR code — it always requires explicit confirmation, because the invoice's company name frequently differs from the registered account holder and sanctions screening runs on the confirmed value.
- BIC country is cross-checked against IBAN country; a mismatch demotes the BIC to "empty" rather than prefilling a likely misread.
MaxPages = 3inPdfTextLayerSource— payment details live on page 1 or the last page, and this caps worst-case OCR cost.- The context holds no document bytes beyond the stage that reads them. Log extracted fields, never the document.
Wiring into a web API
The pipeline doesn't care that the sample host is a CLI. Swap the array literal in
Program.cs for DI registrations and it drops into any ASP.NET Core host — or any
other .NET host with a container — the same way:
services.AddSingleton<ITextSource, PdfTextLayerSource>();
services.AddSingleton<ITextSource, PlainTextSource>();
services.AddSingleton<IOcrEngine, GrpcOcrEngine>();
services.AddSingleton<IExtractionStage, EmbeddedPayloadStage>();
services.AddSingleton<IExtractionStage, TextAcquisitionStage>();
services.AddSingleton<IExtractionStage, RuleBasedFieldStage>();
services.AddSingleton<IExtractionStage, BeneficiaryNameStage>();
services.AddSingleton<IExtractionStage, ValidationGateStage>();
services.AddSingleton<ExtractionPipeline>();
ExtractionPipeline is stateless once built, so registering it as a singleton is
fine; give individual ITextSource/IOcrEngine implementations a shorter lifetime
if they hold per-request or per-connection state.
How you expose the pipeline from there depends on your host and callers. If extraction is fast (structured payload, digital PDF, no OCR), a plain synchronous endpoint that runs the pipeline and returns the result is the simplest option. If OCR is in play, it can take a few seconds, so callers that can't hold a request open that long may be better served by an async pattern — accept the file, return a job identifier, and let the caller poll or subscribe for the result however your stack already does that (webhooks, SignalR, polling, a message queue). Either way, treat local extraction like any other pipeline you're putting behind an API: cap the upload size, put a timeout on each stage so one slow step can't hang the whole request, and bound how many extractions run concurrently so a burst of uploads can't exhaust memory or CPU.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- PdfPig (>= 0.1.15)
- SkiaSharp (>= 3.119.4)
- SkiaSharp.NativeAssets.Linux.NoDependencies (>= 3.119.4)
- ZXing.Net (>= 0.16.11)
- ZXing.Net.Bindings.SkiaSharp (>= 0.16.22)
-
net8.0
- PdfPig (>= 0.1.15)
- SkiaSharp (>= 3.119.4)
- SkiaSharp.NativeAssets.Linux.NoDependencies (>= 3.119.4)
- ZXing.Net (>= 0.16.11)
- ZXing.Net.Bindings.SkiaSharp (>= 0.16.22)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.