PdfPecker.Ocr
1.25.0
dotnet add package PdfPecker.Ocr --version 1.25.0
NuGet\Install-Package PdfPecker.Ocr -Version 1.25.0
<PackageReference Include="PdfPecker.Ocr" Version="1.25.0" />
<PackageVersion Include="PdfPecker.Ocr" Version="1.25.0" />
<PackageReference Include="PdfPecker.Ocr" />
paket add PdfPecker.Ocr --version 1.25.0
#r "nuget: PdfPecker.Ocr, 1.25.0"
#:package PdfPecker.Ocr@1.25.0
#addin nuget:?package=PdfPecker.Ocr&version=1.25.0
#tool nuget:?package=PdfPecker.Ocr&version=1.25.0

PdfPecker.Ocr
Text recognition (OCR) for .NET with no native dependencies: reads printed text from images and scanned PDFs, and makes scanned PDFs searchable by adding an invisible text layer.
PdfPecker.Ocr runs PaddleOCR's PP-OCRv6 small detection and recognition models, and its PP-OCR mobile v2.0 line orientation classifier, on a neural network runtime of its own, written in C#. It does not ship native binaries, call ONNX Runtime or Tesseract, or need a GPU or a network connection. Its only dependency is the PdfPecker package, which it uses to decode images, render PDF pages and write the text layer.
using PdfPecker.Ocr;
OcrPage page = ImageOcr.Recognize(File.ReadAllBytes("receipt.png"));
Console.WriteLine(page.Text);
Install
dotnet add package PdfPecker.Ocr
The package targets net8.0, net9.0 and net10.0, so it works on .NET 8 and later, on Windows, Linux and macOS,
on x64 and Arm64. It is trimmable and works in Native AOT applications.
Native AOT compiles for a baseline processor unless told otherwise, and the network kernels then run without AVX2, FMA
and AVX-512, which on x64 makes a page two and a half to four times slower than under the JIT (which picks them at run
time). Name the processors you deploy to in your application's project, for example
<IlcInstructionSet>x86-64-v3</IlcInstructionSet> for AVX2 and FMA, or native for the machine that builds it.
Quick start
using PdfPecker.Ocr;
// An image file: PNG, JPEG, TIFF, WebP, BMP or GIF
OcrPage page = ImageOcr.Recognize(File.ReadAllBytes("scan.jpg"));
Console.WriteLine(page.Text);
// Lines and words with their positions in page pixels (origin top left) and a confidence from 0 to 1
foreach (OcrLine line in page.Lines)
{
Console.WriteLine($"{line.Box.X:0},{line.Box.Y:0} {line.Confidence:P0} {line.Text}");
foreach (OcrWord word in line.Words)
Console.WriteLine($" {word.Text} at {word.Quad.TopLeft}");
}
// Pixels you already have, such as a camera frame or a bitmap from another library
OcrPage frame = ImageOcr.RecognizeRgba(rgba, width, height);
OcrPage gray = ImageOcr.RecognizeGray(luminance, width, height);
// A scanned PDF: the text of every page, and hOCR for tools that read it
OcrDocument document = PdfOcr.Recognize(File.ReadAllBytes("scanned.pdf"));
Console.WriteLine(document.Text);
File.WriteAllText("scanned.hocr", document.ToHocr());
// Make a scanned PDF searchable: the pages look the same, and their text can be selected, searched and copied.
// Pages that already have text are left as they are unless PageMode is Force.
byte[] searchable = PdfOcr.MakeSearchable(File.ReadAllBytes("scanned.pdf"));
// Every byte array method also takes a stream, and has a file version, as in PdfPecker
OcrDocument fromFile = PdfOcr.RecognizeFile("scanned.pdf");
PdfOcr.MakeSearchableFile("scanned.pdf", "searchable.pdf");
OcrPage photo = ImageOcr.RecognizeFile("photo.png");
// Images straight to one searchable PDF, a page per image
byte[] pdf = ImageOcr.ToSearchablePdf(Directory.GetFiles("scans", "*.png").Select(File.ReadAllBytes));
// Options: render PDF pages at 200 dpi, drop lines below 60% confidence, use two threads
var options = new OcrOptions { Dpi = 200, MinConfidence = 0.6f, MaxDegreeOfParallelism = 2 };
OcrDocument report = PdfOcr.Recognize(File.ReadAllBytes("report.pdf"), options);
The methods are static and thread-safe, so documents can be recognised in parallel. The models are loaded once, on
first use, and shared. Pages are read one after another, each with up to MaxDegreeOfParallelism threads (at most the
number of processors) working inside the networks; the threads and buffers are kept for the next call, so a stream of
pages allocates little after the first. The largest buffer is released after 2 seconds without a call, and the rest
after 30.
How it works
- Input. Images are decoded and PDF pages rendered (at
OcrOptions.Dpi, 300 by default) by PdfPecker. - Detection. The PP-OCRv6 small detection model marks where text is; the probability map it returns is turned into one quadrilateral per text line, so slanted lines keep their angle.
- Orientation. Each line is cut out and straightened, and a small classifier (PP-OCR mobile v2.0) turns lines
that are upside down (
OcrOptions.DetectRotation). - Recognition. The PP-OCRv6 small recognition model reads each line, and its output is decoded into characters with a confidence and a position along the line, from which the words and their boxes come.
- Dots. Runs of dots that the recogniser cannot count, such as dot leaders, are counted on the page (see Languages).
- Layout. Lines are put into reading order, top to bottom and left to right, column by column.
- Searchable PDF. The text is written as invisible text over each word, scaled to the word's width, and stamped onto the original pages, so the scan itself is not changed.
The runtime that executes the models is the package's own: a graph executor with SIMD kernels (AVX2, FMA, AVX-512
where present, and Arm64 Advanced SIMD) for convolutions, matrix products and the other operations these models use,
with buffers planned when the model loads, so a steady stream of pages allocates little. Results are the same whatever
MaxDegreeOfParallelism is. The model weights are embedded in the assembly as 24-bit floats (32-bit floats rounded to
15 mantissa bits, which keeps the outputs within 1e-3 of ONNX Runtime's), generated from the published ONNX files by
tools/ModelBuilder.
Other runtimes and GPUs
The package itself runs only on the CPU. OcrOptions.Accelerator lets a separate package run some or all of the three
networks somewhere else, such as on a GPU, while everything around them (decoding, layout, the text layer) stays the
same. An OcrAccelerator creates an IOcrNetwork for each OcrNetworkKind it handles, a float tensor in and out that
computes what the matching ONNX model computes, and returns null for the ones the built-in runtime should run, which
also lets it fall back to the CPU when no device is there. Another runtime computes in its own order and precision, so
its results can differ slightly from the built-in runtime's. Create one accelerator and reuse it: the networks it
creates are kept with the pooled threads and buffers, per accelerator instance.
var options = new OcrOptions { Accelerator = myAccelerator };
OcrDocument document = PdfOcr.Recognize(File.ReadAllBytes("scanned.pdf"), options);
Languages
The recognition model reads Latin script, including the accented letters of Western, Central and Northern European
languages (æ ø å ä ö ü ß é ç ñ ł ő č ž and others), Greek, Chinese (simplified and traditional) and Japanese
(kana and kanji), as well as digits, punctuation and common symbols. Its character list has no Cyrillic, Korean,
Arabic, Hebrew, Devanagari or Thai letters, so text in those scripts is not recognised.
PP-OCRv6 saw few accented European capitals in training. It read Æ as "AE", Ø as O or a zero, Å as A, Ê and Ô as E
and O, œ as æ, Þ and Ð as P and D, curly quotation marks as straight ones, en dashes as hyphens and § as S, most of
all in capitals, monospaced fonts and poor scans. The package retrains the model's output weights for 75 of those
letters and marks (tools/HeadTuner), on text in eleven languages, which leaves everything before the last layer and
every other character as the original model has them. On test lines in fonts left out of that training, clean and
degraded, the character error rate is now:
| Language | Before | After |
|---|---|---|
| Danish | 2.40% | 0.65% |
| Norwegian | 2.61% | 0.58% |
| Swedish | 4.27% | 1.68% |
| Finnish | 0.32% | 0.00% |
| Icelandic | 3.38% | 0.05% |
| German | 0.85% | 0.43% |
| French | 1.43% | 0.34% |
| Spanish | 1.91% | 1.02% |
| Portuguese | 0.03% | 0.00% |
| Italian | 1.58% | 0.07% |
| English | 0.32% | 0.32% |
Some punctuation is limited by the model: „ (the low opening quotation mark of Danish and German) and ¡ are not in
its character list and are left out, and … comes out as three full stops.
The recogniser emits at most one character per 8 pixels of its 48-pixel-high input, so it cannot count a run of dots
set closer than that, and the detector does not see a dot leader in a table of contents as text at all. The package
counts such dots on the page instead: where the recogniser read a run of dots, between a title and its page number on
one row, or after the last text on a row, a run of evenly spaced round dots on the baseline is counted, and the text
beside a leader is read again without it (a page number the detector missed on a poor scan is read on its own). Only
dots are counted, never letters. A leader of spaced dots (. . . .) comes out without its spaces, an ellipsis
character (…) is read as three full stops, and a row of underscores is not counted.
Limitations
- Printed text only. The models are trained on printed and screen text. Handwriting is not supported.
- Accuracy depends on the scan. Blurred, very low resolution (below about 150 dpi for body text), heavily compressed or noisy scans read worse, as do decorative fonts, text on busy backgrounds and very small print. The confidence of each line and word says how sure the model was.
- Rotation. Lines that are slanted, on their side or upside down are read, so pages turned by 90 or 180 degrees are read too; each line's quad says which way its text runs. Vertical writing, such as Japanese in columns, is not supported.
- Layout. Reading order is worked out from the positions of lines. Tables come out as lines of text, not as cells, and complex magazine layouts can come out in a different order than a person would read them. On a scan skewed by two degrees or more, a table can come out column by column, because the layout step does not yet correct small angles; the words themselves are read correctly.
- No language model. Each line is read on its own, without a dictionary, so a misread character is not corrected from the words around it.
- Size. Pages are limited to
OcrOptions.MaxPixels(100 million pixels by default), so that a hostile file cannot exhaust memory: a PDF page or image file with more pixels is read at a lower resolution, and a raw pixel buffer that large is refused with aPdfLimitException. An image file, or an image on a PDF page, is still decoded at its full size first, which PdfPecker limits to 100 million pixels per image whateverMaxPixelsis, so loweringMaxPixelsdoes not lower the memory a large image can take while it is decoded.
Performance
Measured with bench/TesseractComparison on its synthetic corpus of 65 pages (A4 invoices, letters, two-column
articles, tables, small print, a monospaced log and identifiers in English, Danish, German and French, at 300 and
150 dpi, 27 degraded variants, and Tesseract's two sample images), against Tesseract 5.5.4 called in the same process
and RapidOCR running the same models on ONNX Runtime. The machine was a 4-vCPU cloud VM (Xeon at 2.1 GHz with AVX-512),
otherwise idle, measured on 2026-11-09 (RapidOCR's rows on 2026-11-08, when other jobs were running); times are the
median over pages of each page's median after a warm-up, with no image decoding.
| PdfPecker.Ocr | RapidOCR + ONNX Runtime | Tesseract 5.5.4, tessdata_fast |
Tesseract 5.5.4, tessdata_best (English pages) |
|
|---|---|---|---|---|
| Character error rate, in reading order | 1.7% | 23.3% | 10.4% | 15.2% |
| Character error rate, ignoring reading order | 0.4% | 6.4% | 3.3% | 2.2% |
| Word bag F1 | 0.996 | 0.960 | 0.971 | 0.973 |
| Time per page, 1 thread | 1.75 s | 5.20 s | 0.61 s | 1.38 s |
| Time per page, 4 threads | 0.48 s | 1.91 s | 0.57 s | 0.83 s |
| CPU time per page | 1.8 s | 5.0 s | 0.6 s | 1.6 s |
| Peak memory, one A4 page at 300 dpi in a fresh process | 193 MB | 548 MB | 67 MB | 80 MB |
PdfPecker.Ocr reads the most text correctly, it reads every page faster than ONNX Runtime does with the same models, and
at four threads it is as fast as Tesseract with tessdata_fast, but Tesseract needs about a third of its CPU time and
a third of its memory. A page of long lines takes more time but hardly more memory (4.9 s and 193 MB at one thread for a
monospaced log of 36 lines of up to 80 characters). Most of the reading order penalty of the other engines is their
order of table columns (Tesseract) and of article columns (RapidOCR); the method, the results per page group and the
causes of each engine's errors are in bench/TesseractComparison/README.md. The corpus is rendered, not scanned, which
flatters every engine. Cloud machines differ too much in processor to compare a run with this table; to compare two
versions, run bench/compare-versions.sh on one machine.
Memory. Reading an A4 page at 300 dpi takes about 190 to 200 MB beyond the .NET runtime's own: the models (30 MB,
kept once loaded) and the detector's working memory (about 77 MB of native memory, kept for 2 seconds after a call; the
threads for 30). Recognising pixels allocates under 1 MB per page once warm, so the garbage collector's settings
make no difference there. Rendering PDF pages does allocate: about 117 MB of short-lived pixel buffers per scanned A4
page at 300 dpi, in PdfPecker's renderer. A process that reads PDFs all day keeps less memory resident with
System.GC.ConserveMemory set to 5 (in runtimeconfig.json, or DOTNET_GCConserveMemory=5): on the benchmark machine,
making a six-page scanned PDF searchable three times at four threads peaked at 698 MB instead of 1,086 MB with the
default settings, in the same time. Server GC peaked at 848 MB and was about 10% slower there, so the workstation
collector, the default for console apps, suits this work.
Licence
PdfPecker.Ocr's code is licensed under the MIT license. The model weights embedded in the package are
derived from PaddleOCR's models, converted to ONNX by the RapidOCR authors, and are licensed under the Apache License,
Version 2.0; THIRD-PARTY-NOTICES.md has the attribution, the notice of the changes made to
them and the licence text. The polygon offset used by text detection is a port of Clipper, under the Boost Software
License 1.0, whose text is in the same file. All three licences allow commercial use. The package's licence expression
is MIT AND Apache-2.0 AND BSL-1.0.
Contributing
Bug reports with a sample image or PDF are the most useful thing; see CONTRIBUTING.md. Security problems go through the security policy.
| 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 is compatible. 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. |
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.