
On-device OCR converting PDFs or images into structured documents—pages, lines, words, headings and paragraphs with bounding boxes and confidences; engine-agnostic, digital-text fast-path, progress, cancellation, language downloads.
Uncial is an on-device OCR SDK for Kotlin Multiplatform that turns a PDF or a raster image into a structured document — pages, lines and words, each with a box and a confidence, plus Heading and Paragraph blocks — rather than a blob of text.
Everything happens on the device. There is no networking in the recognition path, no camera, and no UI: Uncial is a library, not a scanner app. It runs Tesseract on Android and the JVM and Apple Vision on iOS, behind one API, and it reports the differences between them instead of pretending the platforms are equal.
It was built for Ukrainian, which is the reason it exists at all — ML Kit does not recognize it, so an off-the-shelf answer was not available.
Full documentation: andrew-malitchuk.github.io/uncial-kmp
val ocr = UncialClient {
languages = listOf(OcrLanguage.Ukrainian, OcrLanguage.English)
renderDpi = 200
}
// One call, typed failures — no exception escapes as a surprise.
val document: OcrDocument = ocr.extract(pdfBytes).getOrThrow()
// Or page by page, with progress and cancellation.
ocr.extractAsFlow(pdfBytes).collect { progress ->
when (progress) {
is OcrProgress.Started -> ui.showTotal(progress.pageCount)
is OcrProgress.Page -> ui.update(progress.fraction)
is OcrProgress.Done -> render(progress.document)
else -> Unit
}
}
// Geometry back to semantics — optional, and a separate artifact.
DocumentStructure.reconstruct(document).forEach { block ->
when (block) {
is DocBlock.Heading -> println("#".repeat(block.level) + " " + block.text)
is DocBlock.Paragraph -> println(block.text)
else -> Unit
}
}On Android the engine registers itself through androidx.startup; everywhere else call installTesseractEngine() or installVisionEngine() once.
BoundingBox and a Confidence, plus reconstructed headings and paragraphs.ExtractionSource.Mixed.Raster, with no PDF machinery involved.extractAsFlow emits per page and honours structured concurrency — cancelling throws CancellationException, never a swallowed error.UncialClient.capabilities states what the engine on this platform can actually do, including which requested languages it had to drop..traineddata.uncial-engine-fake is published, so a consumer's unit tests never need Tesseract installed.| Feature | Description |
|---|---|
| PDF extraction | Rasterize and recognize, or read the embedded text layer when there is one. |
| Image extraction | Recognize a Raster built from a Bitmap, BufferedImage or CGImage. |
| Document structure | Median-type-size heading detection, paragraph joining, hyphenation repair, running-header and page-number removal. |
| Word-level boxes | Per-word geometry and confidence, where the engine supports it. |
| Coordinates | One model everywhere: top-down pixels, origin at the page's top-left — each engine converts at its own edge. |
| Typed errors | A sealed OcrError (NoLanguageData, RenderFailed, EngineInit, Unsupported, InvalidInput, RecognitionFailed), never null plus a printed reason. |
| Page ranges and quality |
OcrOptions covers dpi, maxPageSide, page range, colour, recognition quality, word inclusion — validated at construction. |
| Language data download |
uncial-lang-download fetches models at runtime and verifies SHA-256 before they reach the native layer. |
| Swift interop | An iOS umbrella framework plus UncialBootstrap, which hides Result, Flow and ByteArray — none of which survive Obj-C export. |
The asymmetry is reported, not hidden — read UncialClient.capabilities at runtime:
| words | confidence | per-line language | orientation | skew | |
|---|---|---|---|---|---|
| Tesseract (JVM) | yes | yes | yes | yes | yes |
| Tesseract (Android) | yes | yes | no | no | no |
| Vision (iOS) | yes¹ | yes | no | no | no |
¹ Derived from boundingBoxForRange, not native. The JVM column is richer because libtesseract's C API exposes what the Tesseract4Android Java wrapper does not — a real asymmetry between two bindings of the same engine. Vision also has no Ukrainian before iOS 16, and the engine drops what it cannot do rather than substituting silently.
| Aspect | Details |
|---|---|
| Framework | Kotlin Multiplatform 2.4.20 — no UI framework, no DI container, no networking |
| Targets |
android, jvm, iosArm64, iosSimulatorArm64, iosX64
|
| Engines | Tesseract4Android (Android), Tess4J + JNA against system libtesseract (JVM), Apple Vision (iOS) |
| Rasterizers |
PdfRenderer (Android), PDFKit (iOS), PDFBox (JVM) |
| Digital text layer | PDFBox-Android, PDFKit, PDFBox — optional, in its own artifact |
| Concurrency | kotlinx-coroutines 1.11.0; every extraction is suspending and cancellable |
| Public API |
explicitApi() strict, with a committed ABI dump per module (checkKotlinAbi runs as part of build) |
| Build System | Gradle 9.7.1 with class-based convention plugins in build-logic/convention
|
| Toolchain | JDK 21 toolchain, JVM target 11, AGP 9.3.0 |
| Floors | Android minSdk 24, iOS 15 |
| Tests | 172 tests, run across jvm + androidHostTest + iosSimulatorArm64 (394 executions) |
Twelve published modules with one entry point and a deliberately thin contract layer:
┌─────────────────────────────────────────────────┐
│ runtime (UncialClient) │ ← the only entry point
├─────────────────────────────────────────────────┤
│ core (contracts: engine, raster, │ ← what everything plugs into
│ recognizer, language, log) │
├─────────────────────────────────────────────────┤
│ model (pages, lines, words, boxes, errors) │ ← the vocabulary
├──────────────┬──────────────┬───────────────────┤
│ raster │ pdf-text │ structure │ ← optional capabilities
├──────────────┴──────────────┴───────────────────┤
│ engine-tesseract │ engine-vision │ engine-fake │ ← reached via the registry only
├─────────────────────────────────────────────────┤
│ lang-ukr │ lang-eng │ lang-download │ ← language data
├─────────────────────────────────────────────────┤
│ dist:ios-framework → Uncial.xcframework │ ← distribution
└─────────────────────────────────────────────────┘
Two rules hold the graph together:
OcrEngineRegistry; runtime's dependency list has no edge to any of them. The one place that rule stops applying is :dist:ios-framework, because an iOS consumer gets one binary rather than a module graph.PageRasterizer and TextRecognizer are separate roles meeting at Raster, which is what makes image input, PNG-based tests and engine swapping possible.Module breakdown:
sdk/model — the vocabulary every other module's signatures are written in; Kotlin stdlib onlysdk/core — engine, rasterizer, recognizer, language-data and logging contractssdk/runtime — UncialClient, its builder and OcrProgress
sdk/raster, sdk/pdf-text, sdk/structure — rasterization, the digital text layer, and structure reconstructionsdk/engine-* (3 modules) — Tesseract, Vision, and a deterministic fakesdk/lang-* (3 modules) — bundled Ukrainian and English models, and the runtime downloaderdist/ios-framework — the iOS umbrella, XCFramework and Swift packagesamples/ — a JVM CLI, an Android Compose app, an iOS SwiftUI appbuild-logic/convention — the Gradle convention pluginsEvery module carries its own README.md and CLAUDE.md.
Status: prepared, not published. All 12 modules produce signed, Central-valid artifacts and the aggregated bundle builds, but nothing has been uploaded yet. Until then, consume the SDK from
mavenLocal()or as an included build. See PUBLISHING.md.
gradle/gradle-daemon-jvm.properties)brew install tesseract tesseract-lang, for the JVM path only# Clone the repository
git clone https://github.com/andrew-malitchuk/uncial-kmp.git
# Everything: compile, test, ABI check
./gradlew build
# Tests on jvm + androidHostTest + iosSimulatorArm64
./gradlew allTests
# Publish to ~/.m2 for local consumption
./gradlew publishToMavenLocal
# Run OCR for real, on the JVM
./gradlew :samples:cli:run --args="fixture /tmp/scan.pdf --pages 6"
./gradlew :samples:cli:run --args="ocr /tmp/scan.pdf --words"
# The Android sample — R8 on, which is the build that proves the consumer rules
./gradlew :samples:android-app:assembleReleaseFor iOS, open samples/ios-app/UncialSample.xcodeproj in Xcode and run — its first build phase links the framework for you.
dependencies {
implementation("io.github.andrew-malitchuk:uncial-runtime:<version>")
implementation("io.github.andrew-malitchuk:uncial-engine-tesseract:<version>") // Android / JVM
implementation("io.github.andrew-malitchuk:uncial-lang-ukr:<version>") // language data
implementation("io.github.andrew-malitchuk:uncial-structure:<version>") // optional
implementation("io.github.andrew-malitchuk:uncial-pdf-text:<version>") // optional
}uncial-engine-tesseract's POM references tesseract4android, which is published on
JitPack only — add its repository, scoped to com.github.adaptech-cz, or resolution fails.
In Xcode: File → Add Package Dependencies…, then the repository URL:
https://github.com/andrew-malitchuk/uncial-kmp.git
"Up to Next Major" from <version>. Then:
import Uncial
UncialBootstrap.shared.start()
let client = UncialBootstrap.shared.createClient(...)Package.swift at the repository root is a binary target pinned by SHA-256 to a zipped
XCFramework attached to the matching GitHub Release — SwiftPM downloads and verifies it, no
Gradle or Kotlin toolchain needed on the consumer side. uncial-engine-vision is Apple
Vision under the hood, bundled into the framework; there is no separate engine artifact to
add. The samples/ios-app/UncialSample.xcodeproj build phase that links the framework
directly is for developing inside this repository — a real consumer always goes through
the Swift package.
Flow contracts, sealed errors, rasterizer/recognizer splitandroidx.startup, runtime downloader:sdk:swift — a hand-written Swift layer over the exported Obj-C APIThis started as a tool for a specific problem: Ukrainian documents that ML Kit will not read. If you need a language, a layout or a platform that is not covered, open an issue and let's discuss it.
| Problem | Solution |
|---|---|
isAvailable() returns false on the JVM |
No system libtesseract. brew install tesseract tesseract-lang, then re-run :samples:cli:run --args="capabilities". |
OcrError.NoLanguageData |
The provider returns the parent of tessdata/. Add uncial-lang-ukr / -eng, or point a LanguageDataProvider at your own models. |
| Ukrainian comes back as Latin nonsense on iOS | Vision has no Ukrainian before iOS 16, and none at RecognitionQuality.Fast. Read capabilities rather than assuming. |
| No text at all from a camera photo | Apply EXIF orientation before building the Raster; a sideways page under PSM_AUTO yields nothing, not garbage. |
| Recognition works in debug, fails in a release APK | Consumer R8 rules travel inside each AAR — verify with assembleRelease, not installDebug. |
| The iOS Simulator recognizes nothing | It has no Neural Engine. Vision returns zero observations there; only a device proves recognition. |
| Gradle sync issues | JDK 25 is not supported by AGP/KGP. The build pins JDK 21; set JAVA_HOME if a tool bypasses that. |
Contributions are welcome. Please follow the standard pull request process:
Before submitting: ./gradlew build must pass, and an intentional API change needs ./gradlew updateKotlinAbi with the diff reviewed. Each module's CLAUDE.md documents the invariants that module expects you to keep.
Built with Kotlin Multiplatform, Tesseract, Apple Vision, PDFBox and PDFKit.
Apache 2.0 License
Copyright (c) [2026] [Andrew Malitchuk]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Uncial is an on-device OCR SDK for Kotlin Multiplatform that turns a PDF or a raster image into a structured document — pages, lines and words, each with a box and a confidence, plus Heading and Paragraph blocks — rather than a blob of text.
Everything happens on the device. There is no networking in the recognition path, no camera, and no UI: Uncial is a library, not a scanner app. It runs Tesseract on Android and the JVM and Apple Vision on iOS, behind one API, and it reports the differences between them instead of pretending the platforms are equal.
It was built for Ukrainian, which is the reason it exists at all — ML Kit does not recognize it, so an off-the-shelf answer was not available.
Full documentation: andrew-malitchuk.github.io/uncial-kmp
val ocr = UncialClient {
languages = listOf(OcrLanguage.Ukrainian, OcrLanguage.English)
renderDpi = 200
}
// One call, typed failures — no exception escapes as a surprise.
val document: OcrDocument = ocr.extract(pdfBytes).getOrThrow()
// Or page by page, with progress and cancellation.
ocr.extractAsFlow(pdfBytes).collect { progress ->
when (progress) {
is OcrProgress.Started -> ui.showTotal(progress.pageCount)
is OcrProgress.Page -> ui.update(progress.fraction)
is OcrProgress.Done -> render(progress.document)
else -> Unit
}
}
// Geometry back to semantics — optional, and a separate artifact.
DocumentStructure.reconstruct(document).forEach { block ->
when (block) {
is DocBlock.Heading -> println("#".repeat(block.level) + " " + block.text)
is DocBlock.Paragraph -> println(block.text)
else -> Unit
}
}On Android the engine registers itself through androidx.startup; everywhere else call installTesseractEngine() or installVisionEngine() once.
BoundingBox and a Confidence, plus reconstructed headings and paragraphs.ExtractionSource.Mixed.Raster, with no PDF machinery involved.extractAsFlow emits per page and honours structured concurrency — cancelling throws CancellationException, never a swallowed error.UncialClient.capabilities states what the engine on this platform can actually do, including which requested languages it had to drop..traineddata.uncial-engine-fake is published, so a consumer's unit tests never need Tesseract installed.| Feature | Description |
|---|---|
| PDF extraction | Rasterize and recognize, or read the embedded text layer when there is one. |
| Image extraction | Recognize a Raster built from a Bitmap, BufferedImage or CGImage. |
| Document structure | Median-type-size heading detection, paragraph joining, hyphenation repair, running-header and page-number removal. |
| Word-level boxes | Per-word geometry and confidence, where the engine supports it. |
| Coordinates | One model everywhere: top-down pixels, origin at the page's top-left — each engine converts at its own edge. |
| Typed errors | A sealed OcrError (NoLanguageData, RenderFailed, EngineInit, Unsupported, InvalidInput, RecognitionFailed), never null plus a printed reason. |
| Page ranges and quality |
OcrOptions covers dpi, maxPageSide, page range, colour, recognition quality, word inclusion — validated at construction. |
| Language data download |
uncial-lang-download fetches models at runtime and verifies SHA-256 before they reach the native layer. |
| Swift interop | An iOS umbrella framework plus UncialBootstrap, which hides Result, Flow and ByteArray — none of which survive Obj-C export. |
The asymmetry is reported, not hidden — read UncialClient.capabilities at runtime:
| words | confidence | per-line language | orientation | skew | |
|---|---|---|---|---|---|
| Tesseract (JVM) | yes | yes | yes | yes | yes |
| Tesseract (Android) | yes | yes | no | no | no |
| Vision (iOS) | yes¹ | yes | no | no | no |
¹ Derived from boundingBoxForRange, not native. The JVM column is richer because libtesseract's C API exposes what the Tesseract4Android Java wrapper does not — a real asymmetry between two bindings of the same engine. Vision also has no Ukrainian before iOS 16, and the engine drops what it cannot do rather than substituting silently.
| Aspect | Details |
|---|---|
| Framework | Kotlin Multiplatform 2.4.20 — no UI framework, no DI container, no networking |
| Targets |
android, jvm, iosArm64, iosSimulatorArm64, iosX64
|
| Engines | Tesseract4Android (Android), Tess4J + JNA against system libtesseract (JVM), Apple Vision (iOS) |
| Rasterizers |
PdfRenderer (Android), PDFKit (iOS), PDFBox (JVM) |
| Digital text layer | PDFBox-Android, PDFKit, PDFBox — optional, in its own artifact |
| Concurrency | kotlinx-coroutines 1.11.0; every extraction is suspending and cancellable |
| Public API |
explicitApi() strict, with a committed ABI dump per module (checkKotlinAbi runs as part of build) |
| Build System | Gradle 9.7.1 with class-based convention plugins in build-logic/convention
|
| Toolchain | JDK 21 toolchain, JVM target 11, AGP 9.3.0 |
| Floors | Android minSdk 24, iOS 15 |
| Tests | 172 tests, run across jvm + androidHostTest + iosSimulatorArm64 (394 executions) |
Twelve published modules with one entry point and a deliberately thin contract layer:
┌─────────────────────────────────────────────────┐
│ runtime (UncialClient) │ ← the only entry point
├─────────────────────────────────────────────────┤
│ core (contracts: engine, raster, │ ← what everything plugs into
│ recognizer, language, log) │
├─────────────────────────────────────────────────┤
│ model (pages, lines, words, boxes, errors) │ ← the vocabulary
├──────────────┬──────────────┬───────────────────┤
│ raster │ pdf-text │ structure │ ← optional capabilities
├──────────────┴──────────────┴───────────────────┤
│ engine-tesseract │ engine-vision │ engine-fake │ ← reached via the registry only
├─────────────────────────────────────────────────┤
│ lang-ukr │ lang-eng │ lang-download │ ← language data
├─────────────────────────────────────────────────┤
│ dist:ios-framework → Uncial.xcframework │ ← distribution
└─────────────────────────────────────────────────┘
Two rules hold the graph together:
OcrEngineRegistry; runtime's dependency list has no edge to any of them. The one place that rule stops applying is :dist:ios-framework, because an iOS consumer gets one binary rather than a module graph.PageRasterizer and TextRecognizer are separate roles meeting at Raster, which is what makes image input, PNG-based tests and engine swapping possible.Module breakdown:
sdk/model — the vocabulary every other module's signatures are written in; Kotlin stdlib onlysdk/core — engine, rasterizer, recognizer, language-data and logging contractssdk/runtime — UncialClient, its builder and OcrProgress
sdk/raster, sdk/pdf-text, sdk/structure — rasterization, the digital text layer, and structure reconstructionsdk/engine-* (3 modules) — Tesseract, Vision, and a deterministic fakesdk/lang-* (3 modules) — bundled Ukrainian and English models, and the runtime downloaderdist/ios-framework — the iOS umbrella, XCFramework and Swift packagesamples/ — a JVM CLI, an Android Compose app, an iOS SwiftUI appbuild-logic/convention — the Gradle convention pluginsEvery module carries its own README.md and CLAUDE.md.
Status: prepared, not published. All 12 modules produce signed, Central-valid artifacts and the aggregated bundle builds, but nothing has been uploaded yet. Until then, consume the SDK from
mavenLocal()or as an included build. See PUBLISHING.md.
gradle/gradle-daemon-jvm.properties)brew install tesseract tesseract-lang, for the JVM path only# Clone the repository
git clone https://github.com/andrew-malitchuk/uncial-kmp.git
# Everything: compile, test, ABI check
./gradlew build
# Tests on jvm + androidHostTest + iosSimulatorArm64
./gradlew allTests
# Publish to ~/.m2 for local consumption
./gradlew publishToMavenLocal
# Run OCR for real, on the JVM
./gradlew :samples:cli:run --args="fixture /tmp/scan.pdf --pages 6"
./gradlew :samples:cli:run --args="ocr /tmp/scan.pdf --words"
# The Android sample — R8 on, which is the build that proves the consumer rules
./gradlew :samples:android-app:assembleReleaseFor iOS, open samples/ios-app/UncialSample.xcodeproj in Xcode and run — its first build phase links the framework for you.
dependencies {
implementation("io.github.andrew-malitchuk:uncial-runtime:<version>")
implementation("io.github.andrew-malitchuk:uncial-engine-tesseract:<version>") // Android / JVM
implementation("io.github.andrew-malitchuk:uncial-lang-ukr:<version>") // language data
implementation("io.github.andrew-malitchuk:uncial-structure:<version>") // optional
implementation("io.github.andrew-malitchuk:uncial-pdf-text:<version>") // optional
}uncial-engine-tesseract's POM references tesseract4android, which is published on
JitPack only — add its repository, scoped to com.github.adaptech-cz, or resolution fails.
In Xcode: File → Add Package Dependencies…, then the repository URL:
https://github.com/andrew-malitchuk/uncial-kmp.git
"Up to Next Major" from <version>. Then:
import Uncial
UncialBootstrap.shared.start()
let client = UncialBootstrap.shared.createClient(...)Package.swift at the repository root is a binary target pinned by SHA-256 to a zipped
XCFramework attached to the matching GitHub Release — SwiftPM downloads and verifies it, no
Gradle or Kotlin toolchain needed on the consumer side. uncial-engine-vision is Apple
Vision under the hood, bundled into the framework; there is no separate engine artifact to
add. The samples/ios-app/UncialSample.xcodeproj build phase that links the framework
directly is for developing inside this repository — a real consumer always goes through
the Swift package.
Flow contracts, sealed errors, rasterizer/recognizer splitandroidx.startup, runtime downloader:sdk:swift — a hand-written Swift layer over the exported Obj-C APIThis started as a tool for a specific problem: Ukrainian documents that ML Kit will not read. If you need a language, a layout or a platform that is not covered, open an issue and let's discuss it.
| Problem | Solution |
|---|---|
isAvailable() returns false on the JVM |
No system libtesseract. brew install tesseract tesseract-lang, then re-run :samples:cli:run --args="capabilities". |
OcrError.NoLanguageData |
The provider returns the parent of tessdata/. Add uncial-lang-ukr / -eng, or point a LanguageDataProvider at your own models. |
| Ukrainian comes back as Latin nonsense on iOS | Vision has no Ukrainian before iOS 16, and none at RecognitionQuality.Fast. Read capabilities rather than assuming. |
| No text at all from a camera photo | Apply EXIF orientation before building the Raster; a sideways page under PSM_AUTO yields nothing, not garbage. |
| Recognition works in debug, fails in a release APK | Consumer R8 rules travel inside each AAR — verify with assembleRelease, not installDebug. |
| The iOS Simulator recognizes nothing | It has no Neural Engine. Vision returns zero observations there; only a device proves recognition. |
| Gradle sync issues | JDK 25 is not supported by AGP/KGP. The build pins JDK 21; set JAVA_HOME if a tool bypasses that. |
Contributions are welcome. Please follow the standard pull request process:
Before submitting: ./gradlew build must pass, and an intentional API change needs ./gradlew updateKotlinAbi with the diff reviewed. Each module's CLAUDE.md documents the invariants that module expects you to keep.
Built with Kotlin Multiplatform, Tesseract, Apple Vision, PDFBox and PDFKit.
Apache 2.0 License
Copyright (c) [2026] [Andrew Malitchuk]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.