Architecture Overview¶
Uncial is a set of small Gradle modules with one entry point (uncial-runtime) and a
deliberately thin contract layer (uncial-core) that everything else plugs into. The shape
of the graph is not incidental — two rules hold it together, and both exist to keep the
runtime independent of any particular OCR engine.
Module graph¶
graph TD
model[":sdk:model<br/>uncial-model"]
core[":sdk:core<br/>uncial-core"]
structure[":sdk:structure<br/>uncial-structure"]
raster[":sdk:raster<br/>uncial-raster"]
runtime[":sdk:runtime<br/>uncial-runtime"]
pdftext[":sdk:pdf-text<br/>uncial-pdf-text"]
tess[":sdk:engine-tesseract"]
vision[":sdk:engine-vision"]
fake[":sdk:engine-fake"]
langukr[":sdk:lang-ukr"]
langeng[":sdk:lang-eng"]
langdl[":sdk:lang-download"]
ios[":dist:ios-framework<br/>Uncial.framework"]
core --> model
structure --> model
raster --> core
pdftext --> core
tess --> core
vision --> core
fake --> core
langukr --> core
langeng --> core
langdl --> core
runtime --> model
runtime --> core
runtime --> raster
ios --> model
ios --> core
ios --> structure
ios --> runtime
ios --> vision
ios --> pdftext
Every edge above is a real declaration in a module's build.gradle.kts. Note what is
absent: runtime has no edge to any engine, and none to structure.
| Module | Targets | Third-party dependencies |
|---|---|---|
model |
android, jvm, ios | none — Kotlin stdlib only |
core |
android, jvm, ios | kotlinx-coroutines-core; on Android androidx.startup (api) + androidx.annotation |
structure |
android, jvm, ios | none; 100% commonMain |
raster |
android, jvm, ios | PDFBox on the JVM only (PdfRenderer and PDFKit are framework) |
runtime |
android, jvm, ios | none beyond core |
pdf-text |
android, jvm, ios | PDFBox (JVM), PDFBox-Android (Android) |
engine-tesseract |
android, jvm | Tesseract4Android (Android), Tess4J + JNA (JVM) |
engine-vision |
ios | none — Vision and PDFKit are system frameworks |
engine-fake |
android, jvm, ios | none |
lang-ukr / lang-eng |
android, jvm | none — the payload is a .traineddata asset/resource |
lang-download |
android, jvm | none — HttpURLConnection, shared via a jvmAndAndroid source set |
dist:ios-framework |
ios | the umbrella; re-exports the six modules above |
model appears in the signature of every other module, so it is kept small enough that
nobody wants to change it: no coroutines, no serialization, no IO.
Separation 1 — rasterization is not recognition¶
The proof-of-concept this SDK grew out of had a single ocr(bytes) call per platform.
core splits that into two roles that meet at Raster:
PageRasterizer.open(bytes) → RasterizedDocument.rasterize(page, options) → Raster
↓
TextRecognizer.recognize(raster, pageIndex, options) → OcrPage
| Android | iOS | JVM | |
|---|---|---|---|
PageRasterizer |
PdfRenderer |
PDFKit | PDFBox |
TextRecognizer |
Tesseract4Android | Vision | libtesseract via JNA |
Three things fall out of the split, and none of them needed extra design:
- Image input. A caller who already has pixels — a camera frame, a gallery pick, a
screenshot — constructs a
Rasterand callsextract(raster). No PDF is involved. - Testable recognition. An engine can be exercised against a PNG with none of the PDF machinery present, which is what the iOS integration tests do.
- Engine swapping. Either half can be replaced without touching the other:
UncialClientBuilder.rasterizerand.engineare independent knobs.
PageRasterizer.open is separate from RasterizedDocument.rasterize on purpose: the page
count is known
before any pixels are allocated, which is what makes a meaningful Flow<OcrProgress>
possible.
Raster lifetime¶
A 200 dpi A4 raster is several megabytes, and a 300-page document rasterized eagerly is not
survivable on a phone. ExtractionPipeline therefore rasterizes one page at a time and
calls Raster.release() in a finally immediately after recognition — not at the end of
the document. A raster the caller constructs is the caller's to release.
Separation 2 — the runtime never names an engine¶
// sdk/runtime/build.gradle.kts
commonMain.dependencies {
api(projects.sdk.model)
api(projects.sdk.core)
implementation(projects.sdk.raster)
}
That list is the module's most important property. There is no dependency on
engine-tesseract or engine-vision, so the runtime cannot be made to depend on either by
accident. It reaches an engine only through OcrEngineFactory, resolved at first use via
OcrEngineRegistry — see Engine registry.
What that buys:
- An Android consumer who only OCRs photographs does not drag in PDFBox-Android; an iOS consumer never sees Tesseract at all.
- A third-party engine is a first-class citizen: implement
OcrEngineFactory, register it (or pass it to the builder), and nothing in the SDK needs to change. - Tests inject
FakeOcrEnginethrough the same door the real engines use.
Never add an engine to runtime's dependencies
The one place the rule stops applying is :dist:ios-framework, and only because a
distribution artifact is exactly where the runtime and an engine are allowed to meet:
iOS consumers get one binary, not a set of Gradle modules.
The same indirection covers the digital text layer. DigitalTextExtractor is declared in
core, so the runtime can take the PDF text-layer fast path without depending on
pdf-text, which implements it. Absent that module the runtime simply has no extractor and
goes straight to OCR — and OcrOptions.preferDigitalLayer is inert rather than broken.
What the runtime actually does¶
UncialClient is the whole public entry point. ExtractionPipeline behind it:
- If a
DigitalTextExtractoris wired up andpreferDigitalLayeris set, read the PDF's text layer. If every requested page came back with lines, emit the document withExtractionSource.DigitalTextLayer— roughly a thousand times faster than OCR, and exact. - Otherwise open the PDF with the
PageRasterizer, reuse any page the text layer already covered, and rasterize + recognize the rest. Mixed results reportExtractionSource.Mixed. - Emit
OcrProgressper page and check cancellation at every page boundary.
structure is not part of this path at all. It is a pure function over the result —
DocumentStructure.reconstruct(document): List<DocBlock> — with its thresholds in
StructureOptions, and a caller who only wants raw text may never invoke it. That is why
runtime does not depend on it: a consumer who wants headings and paragraphs adds
uncial-structure and calls it themselves.
One coordinate model¶
Everything Uncial returns uses top-down pixels, origin at the page's top-left.
The sources do not agree natively: Vision reports bottom-up normalized coordinates, and PDF
is bottom-up in points. Each engine converts at its own edge, so no caller ever has to.
pdf-text additionally scales points by OcrOptions.renderScale, which makes a digital
page and an OCR'd page of the same document directly comparable.
Two rules that fall out of the PDF side: the crop box is the frame of reference, never
the media box (LegacyPDFStreamEngine expresses every TextPosition relative to it, and
all three rasterizers render it), and /Rotate 90|270 swaps both the rendered sides and the
reported page size.
Conventions that apply to every module¶
explicitApi()strict plus a committed ABI dump. Kotlin's built-inabiValidationreplaces the standalone binary-compatibility-validator;checkKotlinAbiruns as part ofbuild, so an API change fails the build untilupdateKotlinAbiis run and the diff reviewed. Theapi/*.apifiles are the authoritative public surface.- Convention plugins in
build-logic/convention/—uncial.kmp.base,uncial.target.{android,jvm,ios},uncial.language-data,uncial.publish. Modules declare intent, not build configuration. The target set is exactly android + jvm + iosArm64 + iosSimulatorArm64 + iosX64. They are plugin classes extendingBaseConventionPlugin, which fixes the order in which a module is configured. core/is what isinternal,source/is the surface, each nested one level deep in a semantic folder, with no.ktfile loose at a package root. It holds in every module — the SDK,:dist:ios-framework, both samples and the convention plugins.core/exists only where a module really has internal declarations, somodel,rasterandengine-fakehave none.- One file, one artifact. Every top-level
class/object/fun/valthat ispublicorinternallives in its own file, named after it —debuginDebug.kt. The exception is aprivatehelper serving exactly one artifact, which stays in that artifact's file rather than widening the surface. Note that on the JVM a file name is part of the binary API: splitting a file renames the synthetic<FileName>Ktclass Java callers see. - Every module carries its own
README.mdandCLAUDE.mdnext to the code — what the module is and what not to break when editing it. These pages are the published view; those two are the working one. - Archive names are rewritten from
:sdk:modeltouncial-model, so Gradle paths stay readable while the published coordinates match the artifact names. - A module's
consumer-rules/*.prois packaged into its AAR automatically byuncial.target.android— the consumer copies nothing. - No DI framework. The composition root is
UncialClientBuilder, hand-written. A DI container inside a library is a dependency forced on the consumer and a version-conflict source. kotlin.runCatchingis banned in the extraction path — it swallowsCancellationException. UserunCatchingOcrinruntime, ororElseOnFailure/runReportingFailureinengine-tesseract, which must not depend onruntime.