ASP.NET Core sample that reads a bill, receipt, or invoice and turns its labeled fields into a reviewable spreadsheet.
Upload a PDF or a PNG/JPG screenshot. Syncfusion Smart Data Extractor reads every key/value pair it can find. The page shows the document beside an editable grid, then loads the reviewed rows into a Syncfusion Spreadsheet so they can be saved as Excel, CSV, or PDF.
The sample is a single minimal API project.
Bills and invoices store useful data as labels and values (Invoice Number, Date, Total, and so on) rather than as a clean table. This sample shows a short path from that document to a file a person can correct and download:
- Accept a PDF or an image of a bill.
- Run OCR key/value extraction on the server.
- Let the user correct or delete fields before they are exported.
- Save the reviewed rows through the Spreadsheet server API.
There is no database, user account, approval workflow, or document archive. Extraction is in memory and the result stays in the browser until the spreadsheet is saved.
The header uses the Syncfusion Uploader. A file can be chosen or dropped on the header. Only one file is accepted, and the control does not upload it itself — the selected handler posts it to the extract API. Two header checkboxes are sent with the file:
- Enable Table (on by default) sets
DataExtractor.EnableTableDetection. - Image Enhance (off by default). When off, the OCR processor runs with
OcrImageEnhancementMode.None.
const body = new FormData();
body.append('file', file);
body.append('enableTable', $('enableTable').checked);
body.append('imageEnhance', $('imageEnhance').checked);
const res = await fetch(apiUrl('/api/extract'), { method: 'POST', body });
const { id } = await res.json();
// then poll GET /api/extract/{id} every 2 seconds until status is done or failedThe server accepts .pdf, .png, .jpg, and .jpeg, and rejects anything else. The request body limit is 20 MB.
Large documents can take a long time to process, so a single long-running request could time out. Extraction is therefore asynchronous:
POST /api/extractvalidates the file, converts an image to a one-page PDF, creates anExtractJob, queues it, and returns202 Acceptedwith{ id }.- A single background worker reads the queue and runs
DataExtractor.ExtractKeyValuePairsfor one job at a time. - The browser polls
GET /api/extract/{id}, which returns{ status, result, error }.statusisqueued,processing,done, orfailed. An unknown or expired id returns 404. - Finished jobs are removed from memory after 30 minutes, and the uploaded bytes are released as soon as a job finishes.
The extractor is created once on first use. The ONNX models it loads are large, so a new instance per request can fail with bad allocation on a small host. A failed allocation drops the instance, collects, and retries once. A semaphore keeps extractions one at a time.
app.MapPost("/api/extract", async ([FromForm] IFormFile file, [FromForm] bool enableTable, [FromForm] bool imageEnhance) =>
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".pdf" or ".png" or ".jpg" or ".jpeg"))
return Results.BadRequest("Only PDF, PNG and JPG files are supported.");
// ... copy the upload, build pdfBytes (images go through ImageToPdf) ...
var job = new ExtractJob { Id = Guid.NewGuid().ToString("N"), /* bytes, flags */ };
jobs[job.Id] = job;
await queue.Writer.WriteAsync(job);
return Results.Accepted(value: new { id = job.Id });
}).DisableAntiforgery();
app.MapGet("/api/extract/{id}", (string id) =>
jobs.TryGetValue(id, out var job)
? Results.Json(new { status = job.Status, result = job.Result, error = job.Error })
: Results.NotFound("Extraction job not found or expired. Please upload the file again."));The finished result has the shape { fields: [{ key, value }], pdf }. Keys are trimmed and a trailing : is removed; pairs with an empty key are dropped. pdf is a base64 data URL of the converted PDF and is set only for images. If extraction throws, the job ends as failed with the message Could not read the document: ....
- Preview. The Syncfusion PDF Viewer loads a PDF from the browser. For an image, it loads the PDF returned by the finished job.
- Review. A Syncfusion Grid lists
Key(shown as Field) andValue. Rows can be edited or deleted. A spinner covers the page while the job is queued ("Waiting in queue...") or running ("Extracting, please wait..."). Transient 502/503/504 poll responses are retried. - Export. Review complete copies the grid (as
FieldandValuecolumns) into a modal Spreadsheet namedExtracted Data. File > Save As posts the workbook toPOST /api/spreadsheet/save, which returns the generated file throughWorkbook.Save.
sheet = new ej.spreadsheet.Spreadsheet({
saveUrl: apiUrl('/api/spreadsheet/save'),
sheets: [{ name: 'Extracted Data', ranges: [{ dataSource: dialog.rows }] }]
});app.MapPost("/api/spreadsheet/save", ([FromForm] SaveSettings settings) =>
{
var file = (FileStreamResult)Workbook.Save(settings);
return Results.File(file.FileStream, file.ContentType, file.FileDownloadName);
});The app is served under the /aspnet-core-ocr-bill-data-processing base path. PathBase in appsettings.json is applied with UsePathBase, and the ASPNETCORE_PATHBASE environment variable overrides it.
Requests that arrive without the prefix are redirected into it. Browser-refresh WebSocket requests and /_framework requests are not redirected, so a Visual Studio or dotnet watch reload socket is not closed by that redirect.
The page detects the same prefix and prefixes API calls with it:
const basePath = (() => {
const path = location.pathname.replace(/\/+$/, '');
const marker = '/aspnet-core-ocr-bill-data-processing';
const at = path.toLowerCase().indexOf(marker);
return at >= 0 ? path.slice(0, at + marker.length) : '';
})();
const apiUrl = path => basePath + path;Set the SYNCFUSION_LICENSE_KEY environment variable to a valid license key.
Choose or drop a PDF / PNG / JPG
|
v
Uploader selected handler
|
+--> PDF: load the file in the PDF Viewer
|
+--> POST /api/extract (+ enableTable, imageEnhance) --> 202 { id }
|
+--> image: wrap it in a one-page PDF for preview
|
+--> background worker: DataExtractor.ExtractKeyValuePairs
|
+--> poll GET /api/extract/{id} every 2 s
|
+--> queued / processing / failed / done { fields, pdf? }
|
v
Grid: edit or delete rows
|
v
Review complete
|
v
Spreadsheet dialog
|
v
File > Save As --> POST /api/spreadsheet/save --> download
The first extraction is slower than later ones because the extractor loads its models on first use. Extractions run one at a time, in upload order.
aspnet-core-OCR-bill-data-processing/
├── README.md
└── src/
└── IncidentExtractor/ # ASP.NET Core minimal API (net10.0)
├── Program.cs # Path base, extract job queue + polling API, spreadsheet save, image-to-PDF
├── IncidentExtractor.csproj
├── appsettings.json # PathBase setting
├── appsettings.Development.json
├── web.config # Path-base environment setting
├── Properties/
│ └── launchSettings.json # http://localhost:5052
└── wwwroot/
└── index.html # Uploader, options, PDF Viewer, Grid, Spreadsheet
IncidentExtractor is the project name. The base path is aspnet-core-ocr-bill-data-processing.
Requirements:
- .NET 10 SDK
- 64-bit process. The project sets
PlatformTargettox64because the key/value model is about 280 MB and a 32-bit process cannot allocate it. - A Syncfusion license key. The server reads the
SYNCFUSION_LICENSE_KEYenvironment variable. Replace theSYNCFUSION_LICENSE_KEYplaceholder inwwwroot/index.html(ej.base.registerLicense) for the client-side controls.
From the repository root:
cd src/IncidentExtractor
dotnet runThen open:
http://localhost:5052/aspnet-core-ocr-bill-data-processing/
The launch profile also serves HTTPS at https://localhost:7216. Opening the site root redirects to the base path above.
On Linux, SkiaSharp.NativeAssets.Linux.NoDependencies 4.150.x is referenced so PDF rendering matches the SkiaSharp version used by Syncfusion. Keep that package on the same 4.150 line if you update Syncfusion packages.
Give the host enough memory for the ONNX models. If extraction fails with onnxruntime ... bad allocation, run the 64-bit build and avoid creating a new DataExtractor per request.