---
title: "OneOcr"
url: https://recursiveai.org/oneocr/docs/overview/
description: "The README: what OneOcr does, the packages, reading a page, the page model and its JSON, invoices and receipts, PDF, LLM vision with grounding, training, determinism, limits, the models and how they were trained, and the benchmark tables against Tesseract, PaddleOCR, docTR and Apple Vision with their method."
version: "1.0.0"
source: https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/README.md
---

# OneOcr

**Local OCR and document understanding for .NET: a pure-managed, deterministic reader with
small models of our own, a page model with boxes and calibrated confidences, invoices and
receipts as fields, and optional LLM vision that is checked against the page.** Pure C# with no
dependency beyond .NET itself (and the OneImage / OnePDF libraries for pixels and PDFs). No native
binaries, no `tessdata`, no network unless you configure a provider.

[![.NET 10](https://img.shields.io/badge/.NET-10.0-512BD4)](#install)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license)

Results against Tesseract, PaddleOCR, docTR and Apple Vision, per category, are in
[Benchmarks](#benchmarks). Every number there comes from one run of the release gate on one
machine; the method is written next to the tables so you can repeat it.

## Why OneOcr

- **Free, fast, local.** The recognizer (5 MB), the page detector (1.4 MB) and the two word models
  (1.4 MB and 1.3 MB) ship inside the `RecursiveAI.OneOcr.Models` package and run on the CPU through our own
  SIMD kernels. Scripts: Latin (including the EU languages, Turkish and Vietnamese), Greek and
  Cyrillic in one model.
- **A page, not a string.** The result is an `OcrPage`: blocks, lines, words and characters with
  boxes (and quads for rotated text), tables with row and column spans, key–value pairs, reading
  order, and a `Source` on every element. It serializes to a documented, versioned JSON format,
  `oneocr.page`, that shares its member names with
  [OnePDF's `onepdf.extraction`](https://www.nuget.org/packages/RecursiveAI.OnePdf)
  ([spec](https://recursiveai.org/oneocr/docs/page-json/)).
- **Confidence you can use.** A word's `Confidence` is the calibrated probability that the word
  is exactly right, calibrated per script on held-out data. The release gate requires an expected
  calibration error of at most 0.05 in every category.
- **Same bytes everywhere.** Text, boxes and confidences are bit-identical on AVX-512, AVX2,
  128-bit vectors (SSE and NEON) and the scalar path, and at every degree of parallelism. The
  rules are written down in [docs/spec/numerics.md](https://recursiveai.org/oneocr/docs/numerics/).
- **Documents as fields.** `ExtractInvoice()` and `ExtractReceipt()` return typed fields, each
  with the words and boxes it was read from; a small local model labels words on the page, so it
  cannot invent a value. Invoice fields carry their EN 16931 business-term ids.
- **LLM vision that is held to the page.** Opt-in providers (Anthropic, OpenAI, OpenAI-compatible
  servers, Gemini, Azure OpenAI) over plain `HttpClient`. Every value the model returns is
  checked against the words on the page and comes back `Grounded`, `GroundedNormalized`,
  `Derived` or `Ungrounded`, with boxes where it has them.
- **Train it on your documents.** Fine-tune the recognizer or a new extraction head from your
  own scans, on the CPU, deterministically and resumably, with a review queue for the lines a
  model and the reader disagree on.
- **Safe on files you did not write.** Model files, images, page JSON, PDFs, review files and LLM
  responses are read by bounds-checked managed code under limits checked before anything large is
  allocated, and each of those readers has a fuzz target. The only `unsafe` code is in the SIMD
  kernels, which run on buffers that safe callers validated. NativeAOT and trimming are
  supported.

## Install

```sh
dotnet add package RecursiveAI.OneOcr
```

`RecursiveAI.OneOcr` pulls in `RecursiveAI.OneOcr.Models` (the weights), so one install gives you
the one-liner. The rest is opt-in:

| Package | What it adds |
|---|---|
| `RecursiveAI.OneOcr` | Local OCR, the page model and `oneocr.page` JSON, local Markdown, invoice / receipt / custom extraction (inference). No networking code. |
| `RecursiveAI.OneOcr.Models` | The recognizer, page detector and word model, embedded and checked against their SHA-256 pins on every load. |
| `RecursiveAI.OneOcr.Models.Documents` | The invoice and receipt extraction model. |
| `RecursiveAI.OneOcr.Llm` | LLM vision: providers, modes, grounding, cost controls, routing. The only package that uses the network. |
| `RecursiveAI.OneOcr.Training` | `OcrTrainer`, `ExtractionTrainer` and the review queue. |
| `RecursiveAI.OneOcr.Pdf` | PDF pages: text layer plus OCR of image regions. |
| `RecursiveAI.OneOcr.Tool` | The `oneocr` command-line tool: `ocr`, `train`, `eval`, `review`. |

```sh
dotnet add package RecursiveAI.OneOcr.Llm
dotnet add package RecursiveAI.OneOcr.Pdf
dotnet add package RecursiveAI.OneOcr.Models.Documents
dotnet add package RecursiveAI.OneOcr.Training
dotnet tool install --global RecursiveAI.OneOcr.Tool
```

Target framework `net10.0`. Namespaces: `OneOcr` (core), `OneOcr.Documents`, `OneOcr.Decoding`,
`OneOcr.Models.Documents`, `OneOcr.Llm`, `OneOcr.Pdf`, `OneOcr.Training`. Pixels come from
[`RecursiveAI.OneImage`](https://www.nuget.org/packages/RecursiveAI.OneImage), so any `OneImage.Image`
(PNG, JPEG, WebP, TIFF and so on) is an input. Moving from Tesseract? See the
[migration guide](https://recursiveai.org/oneocr/docs/migrating-from-tesseract/).

## Quick start

<!-- sample: QuickStart -->
```csharp
// Once at startup: the recognizer, the page detector and the word model that ship in RecursiveAI.OneOcr.Models.
OcrEngine engine = OcrEngine.LoadBundled();

Image image = Image.Load("invoice.png");
string text = image.ToText(engine);              // plain text in reading order
OcrPage page = image.Ocr(engine);                // the page model: blocks, lines, words, characters, tables
string markdown = image.ToMarkdown(engine);      // headings, lists and pipe tables
```

An `OcrEngine` is immutable and thread-safe; load it once and share it. If you would rather not
pass it around, set a process-wide default once (`OcrEngine.ConfigureDefault`) and use the
extension methods without an engine. Until you do, `OcrEngine.Default` throws
`InvalidOperationException`.

<!-- sample: DefaultEngine -->
```csharp
OcrEngine.ConfigureDefault();                    // once; the bundled models become OcrEngine.Default

string text = Image.Load("page.png").ToText();   // the extension methods without an engine use the default
OcrPage page = Image.Load("page.png").Ocr();
```

From the command line (`dotnet tool install --global RecursiveAI.OneOcr.Tool`):

```sh
oneocr ocr --in page.png                      # text to stdout
oneocr ocr --in scans/ --out text/ --json     # one .txt and one .page.json per image
```

`ocr` reads PNG and JPEG with the bundled models and never touches the network; `--degree N` caps
the threads (the text does not depend on it). The same tool trains models, see
[Training](#training).

## Reading a page: `OcrEngine` and options

`ToText` and `Ocr` run the same pipeline; `ToText` returns the page's text and `Ocr` returns the
whole page model. The pipeline finds the text lines with the page detector, turns the page upright if it came in
rotated by 90, 180 or 270 degrees, straightens tilted lines, reads every line, decides word
breaks from the line's pixels as well as from the network, drops specks that were read as text,
works out reading order across columns and sidebars, and finds tables.

An engine carries its options (`engine.Options`; `engine.WithOptions(...)` returns a copy with
other ones). Every call also takes an `OcrOptions?`; `null` means the engine's, and a value
replaces the engine's options as a whole (nothing is merged). An option out of range throws
`ArgumentOutOfRangeException` naming `options`.

<!-- sample: Options -->
```csharp
OcrPage page = image.Ocr(engine, new OcrOptions
{
    Dpi = 300,                    // when the file does not say; otherwise estimated, with a diagnostic
    MaxDegreeOfParallelism = 2,   // 0 = min(4, cores); the result does not depend on it
    IncludeChars = false,         // skip per-character boxes
    DetectOrientation = true,     // 0/90/180/270 (the default)
    Deskew = true,                // tilts up to about 15 degrees (the default)
});

foreach (OcrBlock block in page.Blocks)
{
    foreach (OcrLine line in block.Lines)
    {
        foreach (OcrWord word in line.Words)
        {
            Console.WriteLine($"{word.Text}  {word.Confidence:F3}  ({word.Box.Left:F0}, {word.Box.Top:F0})");
        }
    }
}
```

Other `OcrOptions` switches are `SearchAngle` (re-reads a badly tilted photo at other page angles when the first reading is weak), `DropNoise`, `FixWordSpacing`, `FixDigitLookalikes` (reads the
letter `O` as `0` inside identifiers and dates) and `FixSeparators` (reads a middle dot, bullet or
vertical bar between words from its pixels when the network gave it as a hyphen or another small
mark). These two are the only steps after recognition that change a character. All are on by
default and documented on the type.

- **Errors.** Public methods throw only the typed exceptions (all derive from `OcrException`),
  argument exceptions, I/O exceptions and `OperationCanceledException`: `OcrFormatException`
  (bytes that are not what they claim to be), `OcrLimitException`, `OcrModelException`,
  `OcrProviderException` and `OcrBudgetException` (in the LLM package). Where something is
  recoverable the page says so in `page.Diagnostics` instead of throwing.
- **Threading and async.** Every local call takes a `CancellationToken`, checked between pages and
  between lines, and throws `OperationCanceledException`. Every local call also has an `Async`
  twin (`OcrAsync`, `ToTextAsync`, `ToMarkdownAsync`, `ExtractInvoiceAsync`, `ExtractReceiptAsync`,
  `ExtractAsync<T>`, and the same on PDF pages and documents). A twin runs the very same code on a
  thread-pool thread, so its result is byte-identical to the sync call, and a cancelled token ends
  the task as cancelled. Keep the `Image` alive until the task completes. `engine.Ocr(images)` and
  `engine.OcrAsync(images)` read several images in order, with consecutive `OcrPage.Index` values.
  The LLM package is async only.
- **Multi-page files.** `Ocr(Image)` reads one page and throws `ArgumentException` for an image
  with more than one frame, because silently reading the first frame of a TIFF loses pages.
  `OcrPages` reads every frame (`engine.OcrPages(path | stream | bytes | image)`, also as an
  extension on `Image`), and `ToText` and `ToMarkdown` on an image read all frames and join them
  with a blank line. The number of frames counts against `OcrLimits.MaxPagesPerCall`.
- **Untrusted images.** A file OneImage refuses surfaces as `OcrFormatException`, and a file over
  `OcrLimits.MaxImagePixels` (or too many frames) as `OcrLimitException`; the original exception
  is the inner one. The pixel and frame limits are applied before the pixels are decoded when you
  pass a path, a stream or bytes to `OcrPages`.
- **Resolution.** `OcrPages(path | stream | bytes)` takes the resolution a PNG (`pHYs`), JPEG
  (JFIF) or TIFF (`XResolution`) declares as `UnitsPerInch`. On an `Image` you loaded yourself the
  resolution is estimated from the size of the text, and a diagnostic says so; set
  `OcrOptions.Dpi` when you know it.

<!-- sample: AsyncOcr -->
```csharp
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
OcrPage page = await image.OcrAsync(engine, new OcrOptions { Deskew = false }, cts.Token);
string text = await image.ToTextAsync(engine, null, cts.Token);
IReadOnlyList<OcrPage> frames = await engine.OcrAsync([image, image], cancellationToken: cts.Token);
```
- **Decoding.** Page reading (`Ocr`, `ToMarkdown`) is always greedy CTC, because the word boxes come
  from the greedy alignment. `engine.WithDecoding(...)` sets the decoding the plain-text call
  `engine.ToText(image, engine.Decoding)` uses: beam search with a character language model or a
  lexicon.

<!-- sample: Decoding -->
```csharp
OcrEngine engine = OcrEngine.Load(modelPath).WithDecoding(new DecodeOptions
{
    Mode = CtcDecoding.Beam,
    BeamWidth = 8,
    LanguageModel = CharLanguageModel.Load(lmPath),
});
```

  Words that contain digits or code symbols are read exactly as greedy decoding reads them, so
  the language model never "corrects" an identifier. There is no word dictionary by default.
- **Your own model.** `OcrEngine.Load(path)` reads a model container (for example one you
  fine-tuned) and `.WithDetector(path)` adds a detector; without one the classical line finder
  runs.

## The page model and `oneocr.page` JSON

`OcrPage → OcrBlock → OcrLine → OcrWord → OcrChar`, plus `Regions` (roles such as title, list,
table or figure, where the detector reports them), `Tables` (cells with row and column spans that
point at words), `KeyValues` and `Diagnostics`.

| Member | Meaning |
|---|---|
| Coordinates | Top-left origin, y down, in page units: pixels for images, points for PDF pages. `Width`, `Height`, `UnitsPerInch`; PDF pages also carry `ToPdf`, the matrix back to PDF user space. |
| `Source` | `Ocr`, `TextLayer` or `Llm`, on every element. |
| `Confidence` | Calibrated probability that the word is exactly right. `RawConfidence` is the uncalibrated mean of the characters' best softmax probabilities. Lines and blocks average their words'. |
| Boxes and quads | Axis-aligned `OcrBox` everywhere; an `OcrQuad` where text was rotated or straightened. Character boxes from OCR are approximate (they come from the network's alignment) and say so. |
| `Orientation`, `SkewDegrees` | How far the page content was turned clockwise from upright (0, 90, 180 or 270) and how far it was straightened before reading. All coordinates stay in the image as you gave it, also on a page that came in rotated: boxes cover the words where they are in your image, and rotated text carries a quad that follows the text. |
| `Diagnostics` | What the reader decided on its own, as `OcrDiagnostic(Code, Severity, Message)`. Codes are stable (`OCR101` resolution estimated, `OCR102` resolution defaulted, `OCR103` page turned, `OCR104` page straightened, `OCR105` straightening skipped, `OCR106` angle searched, `OCR107` noise dropped, `OCR108` stray accents fixed, `OCR109` line skipped, `OCR110` no calibration, `OCR2xx` extraction, `OCR301` PDF, `OCR401` LLM). The JSON keeps the message only. |

<!-- sample: PageJson -->
```csharp
string json = OcrPageJson.ToJson([page], new OcrPageJsonOptions { Indented = true });
IReadOnlyList<OcrPage> again = OcrPageJson.Parse(json);   // checked against OcrLimits; throws OcrFormatException on bad input
```

The JSON is deterministic (the same pages give the same bytes), versioned (`"format":
"oneocr.page"`, `"version": 1`), and specified member by member in
[docs/spec/oneocr-page-v1.md](https://recursiveai.org/oneocr/docs/page-json/), including how it differs from
`onepdf.extraction` (y runs down here, boxes are `[left, top, right, bottom]`).

Every element is a sealed record that compares by value. You can build a page yourself with
object initializers (`new OcrWord { Text = ..., Box = ..., Confidence = ..., Source = ... }`);
required members are enforced by the compiler.

`page.ToMarkdown()` turns a page into Markdown without any model call: headings from text size,
lists, pipe tables from detected tables, headers, footers and page numbers dropped (see
`MarkdownOptions`). `page.ToText()` joins lines with a newline and table cells with a tab; it is what
`image.ToText()` returns. `page.Text` (and the JSON `text` member) puts a blank line between blocks;
`page.ToText(OcrTextLayout.Paragraphs)` is the same thing as a method.

## Invoices, receipts and your own document types

A small layout-aware model tags the words of a page; deterministic code assembles the fields from
the tagged words. Each `ExtractedField` has the exact text, a normalized value (an ISO date, a
plain decimal amount, an ISO currency code), the word indices and the box.

<!-- sample: Documents -->
```csharp
DocumentModels.Configure();                      // the extraction model from RecursiveAI.OneOcr.Models.Documents, once

Invoice invoice = image.ExtractInvoice(engine);
Console.WriteLine($"{invoice.Number?.Value}  {invoice.IssueDate?.Value}  {invoice.Total?.Value} {invoice.Currency?.Value}");
foreach (InvoiceLine line in invoice.Lines)
{
    Console.WriteLine($"{line.Description?.Text}  {line.Quantity?.Value} x {line.UnitPrice?.Value} = {line.Amount?.Value}");
}

Receipt receipt = image.ExtractReceipt(engine);
Console.WriteLine($"{receipt.Merchant?.Text}  {receipt.Total?.Value}");
```

- **Invoice** fields follow EN 16931 business terms (`BT-1` number, `BT-2` issue date, `BT-5`
  currency, `BT-27` seller name, `BT-44` buyer name, `BT-84` IBAN, `BT-106` subtotal,
  `BT-110` tax, `BT-112` total, `BT-115` amount due, and so on), plus line items (`BT-126` to
  `BT-153`) and the tax breakdown per rate. Look any field up with `invoice.Extraction.Get("BT-112")`.
- **Receipt** fields: merchant, date, time, subtotal, tax, tip, total, payment method and items.
- A field the model did not find is `null`, never a guess. `Confidence` on an extracted field is
  the mean probability of its words' tags; it is not a calibrated probability.
- `page.FindKeyValues()` pairs keys with values on any page and `page.WithKeyValues()` returns the
  page with the pairs filled in.

Extraction takes a `DocumentExtractionOptions` (`MaxDegreeOfParallelism`, `Strict`) and
`DocumentExtractor.Default` is the process-wide extractor (`DocumentExtractor.ConfigureDefault()`).

For your own type, give `Extract<T>` a source-generated `JsonTypeInfo<T>` (no reflection, so it
works under NativeAOT) and a model you trained for it (see [Training](#training)). A property is
filled from the model class with the same JSON name, so name properties after the classes
(`[JsonPropertyName("BT-112")]` for the invoice total). A property with no class keeps its
default and a diagnostic says so (`OcrDiagnosticCodes.PropertyUnmapped`); with
`DocumentExtractionOptions.Strict` it throws, and it always throws when no property maps.
`result.WordBoxes(name)` throws `KeyNotFoundException` for a name that is not a property:

<!-- sample: CustomType -->
```csharp
public sealed record PurchaseOrder(string? Number, string? Supplier, decimal? Total);

[JsonSerializable(typeof(PurchaseOrder))]
internal sealed partial class OrderContext : JsonSerializerContext;
```

<!-- sample: CustomExtraction -->
```csharp
DocumentExtractor extractor = DocumentExtractor.Load("purchase-orders.onm");   // a model you fine-tuned (see Training)
ExtractionResult<PurchaseOrder> result = image.Extract(OrderContext.Default.PurchaseOrder, engine, extractor);

Console.WriteLine($"{result.Value.Number}  {result.Value.Total}");
IReadOnlyList<OcrBox> where = result.WordBoxes("Total");   // where on the page the value was read
```

The model format and its inference are specified in [docs/spec/docextract-v0.md](https://recursiveai.org/oneocr/docs/docextract/).

## PDF

`RecursiveAI.OneOcr.Pdf` reads each page as one `OcrPage`: text-layer words wherever the PDF has
them (from [OnePDF](https://www.nuget.org/packages/RecursiveAI.OnePdf)), and OCR of image regions
that the text does not cover, rendered at 300 DPI through
[`RecursiveAI.OneImage.Pdf`](https://www.nuget.org/packages/RecursiveAI.OneImage.Pdf). Every word
keeps its `Source`.

<!-- sample: Pdf -->
```csharp
IReadOnlyList<OcrPage> pages = PdfOcr.Read("contract.pdf", engine, new PdfOcrOptions { RenderDpi = 300 });
foreach (OcrPage page in pages)
{
    Console.WriteLine($"page {page.Index + 1}: {page.Words.Count} words");
}

string markdown = pages.ToMarkdown();
```

Extension methods on OnePDF's `PdfPage` and `PdfDocument` (`Ocr`, `ToText`, `ToMarkdown`,
`ExtractInvoice`, `ExtractReceipt`, each with an `Async` twin) do the same on a document you already hold.

- A text layer is not trusted blindly. An invisible layer laid over a scan (a previous OCR pass)
  is re-read with our engine unless you set `TrustExistingOcrLayer`. Lines that look garbled
  (U+FFFD, private-use code points, control characters) are re-read from a render, and a
  cross-check of a few sampled lines drops the whole layer when it disagrees with what the page
  looks like.
- Page coordinates are PDF points, y down; `ToPdf` maps them back to PDF user space.
- Not yet: text drawn as outlined glyphs, annotations.

## LLM vision, held to the page

`RecursiveAI.OneOcr.Llm` sends a page image and the local reader's word list (id, text, box) to a
model and asks for text, Markdown, a page model or JSON for your schema in one call. Everything it
returns is checked against the page.

| Provider class | API |
|---|---|
| `AnthropicProvider` | Anthropic Messages |
| `OpenAiProvider` | OpenAI |
| `OpenAiCompatibleProvider` | Ollama, llama.cpp, vLLM, LM Studio and other OpenAI-style servers (a local model needs no key) |
| `GeminiProvider` | Google Gemini |
| `AzureOpenAiProvider` | Azure OpenAI (endpoint and deployment) |

There is no default model: you name the model string. Providers take an injected `HttpClient` (a
shared one that never follows redirects is used otherwise, so a key header cannot be sent to
another host), write requests with `Utf8JsonWriter` and read responses with `System.Text.Json` under
size and depth limits; no vendor SDKs, no reflection. Structured output uses each provider's own
mechanism (OpenAI `json_schema`, a single forced tool call for Anthropic, `responseSchema` for
Gemini), and the model is never offered tools to run.

<!-- sample: Llm -->
```csharp
OcrPage page = image.Ocr(engine);

LlmProvider provider = AnthropicProvider.FromEnvironment("your-model-name");   // reads ANTHROPIC_API_KEY, because you called it
using var llm = new LlmClient(provider, new LlmOptions { MaxCostPerCallUsd = 0.25m, SpendCapUsd = 5m });

LlmReadResult result = await llm.ReadAsync(image, page, new LlmReadOptions
{
    Outputs = LlmOutputs.Text | LlmOutputs.Json,   // one call produces every requested output
    JsonSchema = schemaJson,
    UngroundedPolicy = UngroundedPolicy.Flag,
});

foreach (GroundedField field in result.Fields)
{
    Console.WriteLine($"{field.Path} = {field.Value}  [{field.State}, {field.Confidence:F2}]  {field.Boxes.Count} box(es)");
}
```

**Outputs** (`LlmOutputs`, combine with `|`): `Text` (the model's transcription merged with the
local reading word by word: the local word wins where it is confident, the model's where the
reader is unsure, and each disagreement is listed), `Markdown` (the model's Markdown with the spans
where it disagrees with the local reader marked), `Json` (values for your schema, grounded) and
`PageModel` (the merged page, every word with its `Source`). `ExtractAsync<T>` takes a
source-generated `JsonTypeInfo<T>` instead of a schema string, derives the schema from it and is grounded the
same way: it returns `LlmReadResult<T>` with the `Value` and the grounded `Fields`.
Cost caps work with a local OpenAI-compatible server (host `localhost`, `127.x.x.x` or `[::1]`), which is priced at 0;
a server on another host needs an entry in the price table (`LlmPriceTable.With`).

**Grounding.** For every scalar the model returns it must cite the words it read. We check the
cited words against the value after normalizing dates, amounts, currency, whitespace and case; a
missing or wrong citation triggers a search of the page. Each value ends in one of five states:

| `GroundingState` | Meaning |
|---|---|
| `Grounded` | The value is the text of words on the page. Boxes provided. |
| `GroundedNormalized` | Equal to words on the page after normalization (for example "Oct 3" and `2026-10-03`). Boxes and `NormalizedValue` provided. |
| `Derived` | The model says it computed or inferred the value. Never boxed; confidence is high only when a sum, difference or product of the cited amounts reproduces it. |
| `Ungrounded` | Nothing on the page supports it: a possible hallucination. |
| `RoleConflict` | The value is on the page, but a local extraction model reads those words as a different field. Counts like `Ungrounded`. |

Ungrounded values are re-asked once in a second call that shows the mismatch (counted against
your spend), then `UngroundedPolicy` applies: `Flag` (default: keep and mark), `Null` or `Throw`.
The rules are in [docs/spec/grounding.md](https://recursiveai.org/oneocr/docs/grounding/). One risk grounding cannot
remove, and the spec says so: a value that is printed on the page but sits in the wrong role can
ground. The role check catches this when a local extraction model is available.

**Cost controls.** An estimate before you call, per-call and per-client USD caps from a dated
price table you can replace (a warning appears when it is older than 180 days), a concurrency
limit, bounded retries with backoff, and an opt-in result cache keyed by image, prompt, model and
schema.

<!-- sample: Estimate -->
```csharp
LlmEstimate estimate = llm.Estimate([new LlmPageInput(image, page)], "Read the invoice.", schemaJson);
Console.WriteLine($"about {estimate.InputTokens} input tokens, at most ${estimate.WorstCaseCostUsd}");
```

**Routing.** `LlmRouting.LlmOnly` (default), `LocalOnly`, or `LocalFirst`: the local extraction
model answers the fields it is confident about and the model is called, with a reduced schema, only
for the rest. When every field is local, no call is made and nothing is sent.

<!-- sample: LocalFirst -->
```csharp
LlmReadResult result = await llm.ReadAsync(image, page, new LlmReadOptions
{
    Outputs = LlmOutputs.Json,
    JsonSchema = schemaJson,
    Routing = LlmRouting.LocalFirst,                          // the model is called only for fields the local model is unsure of
    LocalExtractor = new DocumentFieldExtractor(extractor),
    ConfidenceThreshold = 0.9f,
});
Console.WriteLine($"{result.Calls} call(s), ${result.CostUsd}");
```

**Privacy.**

- Nothing leaves the machine unless you configure a provider; local methods never touch the
  network, even with a provider configured. There is no telemetry.
- API keys are passed explicitly or through an explicit `FromEnvironment()` call; nothing reads
  environment variables behind your back. Keys are kept in a type that never prints them and are
  scrubbed from error messages.
- `LlmOptions.Offline = true` makes every call throw.
- A redaction hook masks regions before upload, and masked words are left out of grounding:

<!-- sample: Redaction -->
```csharp
var options = new LlmOptions
{
    Offline = false,                                          // true makes every call throw OcrProviderException
    Redactor = (page, pageNumber) =>                          // boxes to mask before upload; masked words are not grounded
        [.. page.Words.Where(w => w.Text.StartsWith("DE", StringComparison.Ordinal) && w.Text.Length > 15).Select(w => w.Box)],
};
using var llm = new LlmClient(OpenAiProvider.FromEnvironment("your-model-name"), options);
LlmReadResult result = await llm.ReadAsync(image, image.Ocr(engine), new LlmReadOptions { Outputs = LlmOutputs.Markdown });
```

- `LlmProvider.NoRetention` is on by default and sends `store: false` to OpenAI. Anthropic, Gemini
  and Azure OpenAI have no per-request switch, so retention there is set by your account
  agreement with them.
- Document text is sent as delimited, untrusted data and the model gets no tools. Grounding stops
  invented values; the remaining risk (the model picking a different printed number) is described
  in the grounding spec.

## Training

Training in C# is fine-tuning from the shipped weights, on the CPU, with our own backward passes
(convolution with folded batch norm, ReLU, max pooling, bidirectional LSTM, linear layers, CTC and
the extraction transformer), AdamW and checkpoints. Given the same labels, settings and seed, the
weights are the same on every machine and at every degree of parallelism, and a run resumed from a
checkpoint ends with exactly the weights an uninterrupted run would have. Base models are trained
in PyTorch by [`training/`](https://github.com/recursive-ai-oss/oneocr-csharp/tree/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/training); every layer's forward and backward pass is tested against
PyTorch references.

**Recognition.** The local reader reads each page and the model you choose transcribes it. A line
becomes training data only when the two agree; disagreements go to a review queue you can export,
correct and import.

<!-- sample: TrainRecognizer -->
```csharp
var trainer = new OcrTrainer(new OcrTrainerOptions { ModelPath = "recognizer.onm", WorkDirectory = "run1" }, llm);
TrainingReport report = await trainer.RunAsync("scans/");
Console.WriteLine(OcrTrainer.ToJson(report));

LineReview.Export("run1", "review/");                       // lines the two readers disagreed on, with crops
// ...set "text" on the lines you checked in review/review.json, then:
LineReview.Import("run1", "review/review.json");
```

**Extraction.** Give a schema; the model reads your pages for its fields; only values that are
found on the page become labels (a page with a value that is not on the page is left out
entirely); a new tag head is trained on the shipped backbone, which is tuned gently along with it.

<!-- sample: TrainExtraction -->
```csharp
ExtractionSchema schema = ExtractionSchema.From(OrderContext.Default.PurchaseOrder);   // top-level scalar properties are the fields
var trainer = new ExtractionTrainer(
    new ExtractionTrainerOptions { ModelPath = "docextract.onm", Schema = schema, WorkDirectory = "po-run" }, llm);
ExtractionReport report = await trainer.RunAsync("purchase-orders/");

DocumentExtractor tuned = DocumentExtractor.Load(report.ModelPath);
```

Every run holds out a share of the documents (by content hash) and reports error rates or
per-field accuracy before and after, agreement with the labelling model, and a suggested
`LocalFirst` threshold. The held-out references are the labelling model's text or grounded values
(or your corrections), so the numbers measure agreement with them; read a few pages yourself before
you rely on them. The same runs are available from the command line:

```sh
oneocr train --model recognizer.onm --work run1 --docs scans/ \
    --provider openai --llm-model <model> --key-from-env OPENAI_API_KEY --budget-usd 2
oneocr review export --work run1 --out review/
oneocr review import --work run1 --file review/review.json
oneocr train --model recognizer.onm --work run1 --restart     # trains on the labels already in run1; no network
oneocr eval  --model run1/model.onm --work run1
oneocr train --extract schema.json --model docextract.onm --work po-run --docs purchase-orders/ \
    --provider openai-compatible --base-url http://localhost:8080/v1 --llm-model <local model>
```

`eval`, `review` and `train` without `--docs` never touch the network. `oneocr help` lists every
option.

**Provider terms.** Using a provider's output to train a model is governed by your own agreement
with that provider. As read on 2026-10-23: Anthropic's Commercial Terms of Service (section D.4)
bar using the services "to build a competing product or service, including to train competing AI
models"; OpenAI's Services Agreement bars using output "to develop artificial intelligence models
that compete with OpenAI's products and services", except a model "primarily intended to categorize,
classify, or organize data (e.g., embeddings or classifiers), if these models are not distributed
or made commercially available to third parties". A model you train for your own internal
extraction may fall under that exception; check your own agreement. A local open-weight model
behind the OpenAI-compatible provider avoids the question.

## Determinism

Text, boxes and confidences hash the same on every kernel path, every operating system and every
degree of parallelism. This is a design rule, not luck:

- Dot products and reductions use fused multiply-add with a fixed 16-lane accumulation order:
  element *k* always lands in partial sum *k mod 16*, whatever the vector width, and the 16
  partials reduce in one fixed tree. The scalar path uses `Math.FusedMultiplyAdd`; hardware without
  FMA takes the same scalar path (exact, slower).
- `exp`, `tanh`, `sigmoid`, `log` and softmax are our own polynomial implementations with a fixed
  operation order, never the platform's math library.
- Parallelism splits independent outputs; no sum crosses threads. Weights are stored as half
  precision and computed in single precision.
- Preprocessing, decoding and the page model are written down in [docs/spec/](https://github.com/recursive-ai-oss/oneocr-csharp/tree/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/docs/spec) so another
  implementation can reproduce them.

Tests check every vector path against the scalar reference by hash, over randomized shapes
including odd sizes and tails, at degree 1 and at the default degree.

<!-- sample: KernelPath -->
```csharp
Console.WriteLine(engine.KernelPath);                       // Avx512, Avx2, Vector128 or Scalar: the text is the same on all four
if (engine.PerformanceNotice is { } notice)
{
    Console.WriteLine(notice);                              // the scalar path is running on a CPU that could do better
}
```

## Limits and security

> Nothing leaves the machine unless you configure a provider; local methods never touch the network.

- **Limits before allocation.** `OcrLimits` bounds image pixels (134 million by default), pages per
  call, lines, words and characters per page, model file and tensor sizes, page-JSON size, LLM
  response size, schema size, JSON depth and review-import size. Each bound is checked against the
  declared or running value before the work it guards, and a breach is an `OcrLimitException` that
  names the limit. Recursion never follows input depth.

<!-- sample: Limits -->
```csharp
var limits = new OcrLimits { MaxImagePixels = 50_000_000, MaxLinesPerPage = 1000 };
OcrEngine engine = OcrEngine.Load(modelPath, limits);       // OcrLimitException before anything large is allocated
```

- **Untrusted bytes.** Model containers, images, page JSON, PDFs, LLM responses, JSON Schemas and
  review files are parsed by safe, bounds-checked code with checked arithmetic on every size and
  offset taken from the input, and each has a fuzz target under [`fuzz/`](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/fuzz/README.md). No crash
  or hang on any input is a release requirement.
- **`unsafe` only in the kernels.** `unsafe`, `Unsafe.*` and `MemoryMarshal` appear only under
  `src/OneOcr/Kernels/`, over buffers that safe callers validated.
- **No networking in the core package.** `HttpClient` appears only in `RecursiveAI.OneOcr.Llm`.
  There is no telemetry.
- **No runtime dependencies** beyond the .NET base class library, OneImage (pixels) and, for the
  PDF package, OnePDF and OneImage.Pdf.

## NativeAOT

The libraries and the tool are trim- and AOT-clean (no reflection, no runtime code generation).
A NativeAOT binary is compiled for one instruction-set level, and .NET's default for x64
(`x86-64-v2`) has no AVX2 or FMA. At that level the kernels fall back to their exact scalar
path: the text is the same, but a page takes about a hundred times longer. The
`RecursiveAI.OneOcr` package therefore sets `IlcInstructionSet` to `x86-64-v3` (AVX2 and FMA, any
x86-64 CPU since about 2015) for x64 NativeAOT publishes whose project has not chosen a level; a
value in your project or on the command line always wins. Use `x86-64-v4` for the AVX-512 kernels.
A binary built for v3 refuses to start on an older CPU. Arm needs nothing.

To see what a process runs, read `OcrEngine.KernelPath`; `OcrEngine.PerformanceNotice` is non-null
when the scalar path is running on a CPU that could run AVX2 and says how to fix it (an app
published below v3, or `DOTNET_EnableAVX2=0` in the environment). With `DOTNET_EnableHWIntrinsic=0` the scalar path runs too, but the CPU cannot be queried, so the notice stays null.
It never throws and is not part of the page, so page JSON is the same on every path.

The `oneocr` tool can be published the same way for fast starts in scripts:
`dotnet publish src/OneOcr.Tool -c Release -r linux-x64` (see [src/OneOcr.Tool](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/src/OneOcr.Tool/README.md)).

## Models and licences

Code and model weights are licensed `MIT OR Apache-2.0`. Weights are stored as half precision in
our own versioned container format ([docs/spec/model-container-v1.md](https://recursiveai.org/oneocr/docs/model-container/)),
which is the contract between the PyTorch exporter and the C# reader; user-trained models use the
same format.

| Model | Role | Architecture | Size | Package |
|---|---|---|---|---|
| `crnn-v0` 0.7.3 | Line recognizer: 622 classes (Latin, Greek, Cyrillic, punctuation, currency), per-script calibrated confidence | convolutions, 2-layer BiLSTM, CTC; 2.5 M parameters | 5.0 MB | `.Models` |
| `dbnet-lite-v1` 0.3.2 | Page detector: text lines (oriented), page orientation, layout classes | DBNet-style, 0.7 M parameters; [spec](https://recursiveai.org/oneocr/docs/detector/) | 1.4 MB | `.Models` |
| `charlm-v1` | Character n-gram model for word breaks and optional beam search; [spec](https://recursiveai.org/oneocr/docs/char-lm/) | interpolated Kneser–Ney, Latin + Greek + Cyrillic | 1.4 MB | `.Models` |
| `wordfreq-v2` | Word frequencies that take back a space the recognizer put inside a word; [spec](https://recursiveai.org/oneocr/docs/word-lm/) | counts of 219 k Latin-script words | 1.3 MB | `.Models` |
| `docextract-v0` | Invoice and receipt word tagger; [spec](https://recursiveai.org/oneocr/docs/docextract/) | character-CNN word embedding, 2-D position embedding, small transformer | about 4 MB | `.Models.Documents` |

Training-data attributions and font licences are in
[THIRD-PARTY-NOTICES.md](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/THIRD-PARTY-NOTICES.md).

## How the models were trained

Everything is reproducible from [`training/`](https://github.com/recursive-ai-oss/oneocr-csharp/tree/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/training) (a Python project; pinned fetch scripts,
generator seeds and configs). Weights start from random initialization or from our own earlier
checkpoints; no third-party pretrained weights are used, and no weights, images or training data are
committed to the repository.

- **Recognizer.** Text lines rendered from public-domain books (Project Gutenberg) in 77 open font
  families (SIL OFL, Apache-2.0 and Ubuntu Font Licence), in Latin, Greek and Cyrillic, then degraded
  (blur, noise, JPEG, low resolution, faint ink, perspective, uneven light) with exact ground
  truth; plus word crops of the CORD v2 receipts training split with its labels, synthetic
  receipt and interface text, text lines cut from screenshots of public-domain US federal web pages
  (labels from the page's own text), and photos of receipts generated with an image model and
  transcribed by a language model (see Labels). Held-out font families, held-out books and
  held-out web sites form the evaluation sets.
- **Detector.** Page images and text-line boxes from DocLayNet and PubLayNet (only articles under
  CC0 and CC BY), CORD v2 word quads, synthetic pages (including light-on-dark ones) rendered in
  Chromium with exact word boxes, and windows of public-domain US federal web pages with the
  browser's own line boxes.
- **Extraction.** Generated invoices and receipts with exact field truth, plus CORD v2 training
  labels.
- **Labels.** Labels for shipped weights come from exact-truth synthetic data, text in the page
  source, dataset-provided labels (CORD, DocLayNet, PubLayNet) and permissively licensed
  open-weight models. The one exception is the generated receipt photos in the recognizer's
  receipt fine-tune, which a commercial language model transcribed; it is listed in
  THIRD-PARTY-NOTICES.md. Apart from that, commercial LLM APIs appear only as benchmark rows for
  the LLM layer. Nobody hand-labels images.
- **Clean room.** Other OCR systems were studied only through their papers, public documentation and
  the output of their released binaries. Their source code and model files were never read or used.

## Benchmarks

**Accuracy result for 1.0, stated as measured: OneOcr is the best engine we measured on five of the ten
gated categories and behind the best on five.** It beats Tesseract on every category except
clean real PDFs, where Tesseract is ahead by about 0.1 point. It is ahead on clean print, degraded scans, phone photos and real
scan-degraded and layout PDFs; it trails the best engine (PaddleOCR or Apple Vision) on synthetic
screenshots and complex layout (CER and WER; it wins their reading order), real web screenshots,
clean real PDFs (narrowly) and generated photos of US receipts.

One run of the release gate on a held-out (sealed) test set that no tuning step saw, scorer v3.1,
engine at the 1.0 commit with the bundled models; AMD Ryzen 9 7950X3D, 32 threads, load 5–8 before
and after; competitor outputs measured once on the same pages. The clean real-PDF row uses a fresh
cut of 27 pages: the first cut was opened up as a development set during development, so it was
replaced before this run.

*Accuracy, error rate %, CER / WER / reading order (lower is better). For PaddleOCR, docTR and
Apple Vision each cell is the best of their published configurations (PaddleOCR PP-OCRv6 and v5
mobile/server, docTR default/mobile, Vision accurate with and without language correction);
Tesseract 5.3.4 uses its best page-segmentation mode per metric.*

| Category | OneOcr | Tesseract (best) | Tesseract (fast) | PaddleOCR | docTR | Apple Vision | OneOcr vs best |
|---|---|---|---|---|---|---|---|
| Clean 300 DPI print | 0.05 / 0.10 / 0.10 | 0.16 / 0.62 / 0.62 | 0.24 / 0.83 / 0.83 | 0.07 / 0.11 / 0.11 | 0.94 / 2.57 / 2.61 | 0.37 / 0.76 / 0.78 | win / win / win |
| Degraded scans | 0.16 / 0.45 / 0.48 | 0.93 / 2.67 / 2.77 | 1.22 / 3.30 / 3.44 | 0.39 / 1.17 / 1.18 | 1.64 / 4.51 / 13.23 | 0.21 / 0.65 / 0.66 | win / win / win |
| Screenshots and UI | 0.35 / 0.67 / 0.73 | 6.33 / 9.90 / 25.14 | 5.86 / 9.29 / 24.50 | 0.12 / 0.40 / 17.14 | 1.88 / 6.51 / 22.69 | 0.33 / 1.50 / 25.35 | loss / loss / win |
| Complex layout | 0.14 / 0.27 / 0.70 | 13.57 / 11.69 / 13.62 | 13.58 / 11.94 / 13.96 | 0.08 / 0.12 / 62.32 | 1.14 / 3.20 / 63.33 | 0.60 / 1.23 / 22.82 | loss / loss / win |
| Phone photos | 0.21 / 0.67 / 0.81 | 9.34 / 16.48 / 17.87 | 9.40 / 16.21 / 17.48 | 0.39 / 0.83 / 0.83 | 3.41 / 10.54 / 38.07 | 1.16 / 1.90 / 2.45 | win / win / win |
| Receipt photos, US (generated images; recall-CER / recall-WER) | 8.14 / 14.24 | 29.37 / 47.43 | 27.41 / 44.77 | 3.74 / 8.72 | 4.59 / 8.63 | 3.93 / 8.03 | loss / loss |
| Real web screenshots (CER / WER) | 1.98 / 3.14 | 8.62 / 10.93 | 8.76 / 11.66 | 0.88 / 1.63 | 2.89 / 5.16 | 0.81 / 1.63 | loss / loss |
| Real born-digital PDFs: clean | 0.57 / 0.97 / 1.63 | 0.47 / 0.94 / 1.48 | 0.45 / 0.94 / 1.49 | 0.47 / 0.91 / 1.42 | 0.99 / 2.00 / 2.64 | 0.95 / 1.59 / 2.23 | loss / loss / loss |
| Real born-digital PDFs: layout | 0.52 / 1.63 / 3.63 | 1.42 / 4.68 / 6.60 | 1.34 / 4.79 / 6.72 | 0.99 / 4.20 / 84.23 | 1.34 / 5.20 / 84.37 | 2.25 / 5.27 / 8.11 | win / win / win |
| Real PDF pages, scan-degraded | 1.64 / 3.76 / 10.15 | 4.31 / 9.26 / 11.97 | 4.73 / 9.92 / 12.65 | 2.22 / 6.39 / 64.78 | 2.86 / 8.84 / 70.44 | 6.98 / 11.28 / 19.53 | win / win / win |
| Rendered receipts (recall-CER / recall-WER; information) | 0.14 / 0.44 | 5.62 / 10.24 | 5.93 / 10.97 | 1.13 / 1.80 | 3.11 / 10.22 | 0.38 / 1.03 | ahead / ahead |

The four "Real" rows use real pages (rendered public web pages and PDFs with a verified text layer); they
are reported apart from the synthetic rows because their truth and their difficulty differ. Receipt
photos are judged on recall over the annotated words, and reading order is shown only where it is gated.

*Calibration and detection.*

| Category | ECE (target ≤ 0.05) | Word-box F1 at IoU 0.5: OneOcr | Tesseract (best) |
|---|---|---|---|
| Clean 300 DPI print | 0.009 | 0.999 | 0.999 |
| Degraded scans | 0.011 | 0.998 | 0.989 |
| Screenshots and UI | 0.012 | 0.991 | 0.797 |
| Complex layout | 0.012 | 0.997 | 0.935 |
| Phone photos | 0.014 | 0.968 | 0.816 |
| Receipt photos, US (generated) | 0.041 (ECE-m; partial truth) | - | - |
| Real web screenshots | 0.012 | 0.980 | 0.904 |
| Real born-digital PDFs: clean | 0.007 | 0.994 | 0.996 |
| Real born-digital PDFs: layout | 0.014 | 0.872 | 0.927 |
| Real PDF pages, scan-degraded | 0.006 | 0.867 | 0.912 |
| Rendered receipts | 0.012 | 0.984 | 0.945 |

*Speed and memory, milliseconds per page for the whole process (start-up and model load included),
NativeAOT build of the `oneocr` tool for x86-64-v4, median of 3 runs. Tesseract 5.3.4 runs one
process per page with one thread and its best page-segmentation mode. Development pages of the same
six kinds (30–100 per set); AMD Ryzen 9 7950X3D, 32 threads, nothing else running (load 3.8 before,
at most 3.4 during, 1.5 after).*

| Category | OneOcr, 1 thread | OneOcr, 4 threads | Tesseract fast | Tesseract best | OneOcr peak RSS, MB (target ≤ 300) |
|---|---|---|---|---|---|
| Clean 300 DPI print | 528 | 210 | 677 | 1384 | 118 |
| Degraded scans | 536 | 209 | 540 | 1120 | 140 |
| Complex layout | 698 | 259 | 898 | 1704 | 197 |
| Phone photos | 577 | 232 | 661 | 1162 | 152 |
| Screenshots and UI | 196 | 78 | 226 | 389 | 127 |
| Receipts | 222 | 101 | 289 | 425 | 130 |

On one thread OneOcr is faster than Tesseract with its fast models in every category (degraded
scans only just), and about twice as fast as Tesseract with its best models.

**Method.**

- **Metrics.** CER and WER are order-free: each truth word is paired with its closest leftover
  output word. Reading-order error is the edit distance over the word sequence divided by the
  number of truth words (it includes recognition errors, so read it next to WER). Text is
  normalized by NFC, typographic quotes and dashes folded to ASCII, ligatures split, whitespace
  collapsed; case and punctuation are kept; two apostrophes in a row count as one double quote. The truth
  word sequence is built from the truth's word boxes where it has them (words a page wraps at a hyphen stay
  separate, a punctuation mark touching a word joins it), the same for every engine; the older text-based
  scoring is reported beside it. Receipts have partial truth (CORD leaves store and
  payment blocks unannotated), so they are judged on recall-CER and recall-WER, the error over truth
  words only. Word-box F1 uses boxes tight to the printed ink. ECE uses 15 equal-width bins over
  word confidence, a word being correct when it matches its truth exactly.
- **Truth.** Never produced by an OCR engine: browser layout geometry, the geometry of our own
  transforms, a dataset's shipped annotation, or a PDF's text layer checked by two independent
  readers. Pages and truth are hashed in manifests; the generators and seeds are in
  [`bench/`](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/bench/README.md).
- **Engines.** Tesseract is the distribution's 5.3.4 binary with its own `tessdata_best` and
  `tessdata_fast` models, run as a black box; for each metric the best of page-segmentation modes
  3, 4, 6 and 11 is taken (the hardest baseline). PaddleOCR (PP-OCRv5 mobile and server, PP-OCRv6),
  docTR (default and mobile) and Apple Vision (accurate) run as released packages with their stock
  models. Their output is a comparison row, never training data.
- **Rule.** An engine is judged against the best value any measured engine reaches per metric. On
  clean print, an error within 0.05 percentage points of the best is reported as a tie.
- **Speed.** One process per run, the shipped `oneocr ocr` published NativeAOT for the host, three
  runs, mean; peak RSS from the operating system. Tesseract is timed the same way at one thread and
  at the same thread count as OneOcr.
- **Reproduce.** `python -m oneocr_bench gate` in [`bench/`](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/bench/README.md) prints the quality
  and calibration table for an evaluation directory; `bench/scripts/full_run.sh` runs the whole
  comparison on one machine.

## Not yet

Searchable-PDF output and int8 inference (planned after 1.0); CJK, Arabic and Indic scripts (the
page model already has right-to-left and vertical flags, but the local reader does not produce
them); handwriting; curved-page dewarping and scene text; text drawn as outlined glyphs in PDFs;
AWS Bedrock and Google Vertex providers; a built-in PII redactor (the redaction hook is yours to
fill); a word-level character whitelist.

## Where to go

- **Formats and rules** ([docs/spec/](https://github.com/recursive-ai-oss/oneocr-csharp/tree/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/docs/spec)): the [model container](https://recursiveai.org/oneocr/docs/model-container/),
  [page JSON](https://recursiveai.org/oneocr/docs/page-json/), [numeric rules](https://recursiveai.org/oneocr/docs/numerics/),
  [decoding](https://recursiveai.org/oneocr/docs/decoding/), [text normalization](https://recursiveai.org/oneocr/docs/text-normalization/),
  [Markdown](https://recursiveai.org/oneocr/docs/markdown/), [tables](https://recursiveai.org/oneocr/docs/tables/),
  [orientation](https://recursiveai.org/oneocr/docs/orientation/), [grounding](https://recursiveai.org/oneocr/docs/grounding/),
  [PDF bridge](https://recursiveai.org/oneocr/docs/pdf-bridge/), [review queue](https://recursiveai.org/oneocr/docs/review-queue/).
- **Migrating:** [from Tesseract](https://recursiveai.org/oneocr/docs/migrating-from-tesseract/).
- **Evidence:** [benchmark tooling](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/bench/README.md), [fuzzing](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/fuzz/README.md).
- **The API reference** is the XML documentation your IDE shows.

## License

Code and model weights are licensed under either of [MIT](https://github.com/recursive-ai-oss/oneocr-csharp/tree/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/LICENSE-MIT) or
[Apache-2.0](https://github.com/recursive-ai-oss/oneocr-csharp/tree/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/LICENSE-APACHE) at your option.

Copyright (c) Recursive AI, LLC. Third-party data and fonts: [THIRD-PARTY-NOTICES.md](https://github.com/recursive-ai-oss/oneocr-csharp/blob/8db430fb77c4d86d51686f15dfd9b5ccc20c99a7/THIRD-PARTY-NOTICES.md).
