Skip to content

Quick Start

The smallest thing that works: build a client, extract a PDF, print the text.

Not on Maven Central yet

These snippets compile against the modules in this repository, or against the same artifacts installed with ./gradlew publishToMavenLocal. Nothing is released publicly yet — see Installation.


1. Check capabilities first

UncialClient.capabilities is null when no engine is available, and otherwise reports what this platform can actually do. Read it rather than assuming: Android, iOS and the JVM run different engines with different metadata, and the languages reported back can be a subset of the ones you asked for — Vision has no Ukrainian before iOS 16, and Tesseract has none without the matching model.

val capabilities = ocr.capabilities
if (capabilities == null) {
    // No engine module on the classpath, or the one that is cannot run here
    // (a JVM with no system libtesseract, for instance).
    // Every extract() call will fail with OcrError.Unsupported.
    return
}
println("engine     ${capabilities.engineId} ${capabilities.engineVersion.orEmpty()}")
println("languages  ${capabilities.languages.joinToString("+") { it.tesseractCode }}")
println("words      ${capabilities.wordLevel}")
println("confidence ${capabilities.confidence}")

UncialClient.isAvailable is the same check as a boolean, and hasDigitalTextLayerSupport says whether the digital fast path is wired up on this client.


2. Build a client

UncialClient { } is a function, not a constructor, and it never throws — a missing or unusable engine surfaces at the first extract call instead. That makes it safe to build one in an initializer or a DI graph.

Keep it: it holds a loaded recognition engine, which for ukr+eng is ~8 MB of language model you do not want to reload per document.

import io.github.andrewmalitchuk.uncial.runtime.source.client.UncialClient
import io.github.andrewmalitchuk.uncial.model.source.language.OcrLanguage
import io.github.andrewmalitchuk.uncial.pdftext.source.extractor.pdfTextExtractor

val ocr = UncialClient {
    languages = listOf(OcrLanguage.Ukrainian, OcrLanguage.English)
    renderDpi = 200
    preferDigitalLayer = true
    digitalTextExtractor = pdfTextExtractor()   // requires :sdk:pdf-text
}

No engine selection, no tessdata copying, no initialization: the Tesseract engine registers itself at process start through androidx.startup.

import io.github.andrewmalitchuk.uncial.runtime.source.client.UncialClient
import io.github.andrewmalitchuk.uncial.engine.tesseract.source.install.installTesseractEngine
import io.github.andrewmalitchuk.uncial.model.source.language.OcrLanguage
import io.github.andrewmalitchuk.uncial.pdftext.source.extractor.pdfTextExtractor

// The JVM has no androidx.startup, so the engine is registered explicitly. Once.
installTesseractEngine()

val ocr = UncialClient {
    languages = listOf(OcrLanguage.Ukrainian, OcrLanguage.English)
    renderDpi = 200
    preferDigitalLayer = true
    digitalTextExtractor = pdfTextExtractor()
}

The builder's recognition settings mirror OcrOptions one for one — languages, renderDpi, maxPageSide, preferDigitalLayer, recognitionQuality, rasterColor, includeWords, pageRange — or set options to supply a whole OcrOptions and ignore the individual properties. The remaining properties (engine, rasterizer, digitalTextExtractor, languageData, logger) are the composition root: Uncial ships no DI framework on purpose, so this hand-written builder is the injection point.

digitalTextExtractor is not wired automatically

preferDigitalLayer = true alone does nothing without an extractor. The module is optional because on Android it pulls in several MB of PDFBox-Android, which an app that only OCRs photographs should not pay for.


3. Extract

One call

extract returns a Result, and a failure always carries an OcrError — never a raw platform exception. Cancellation is never captured into the Result: cancelling the calling coroutine throws CancellationException as any other suspend function would.

val document = ocr.extract(pdfBytes).getOrElse { error ->
    System.err.println("extraction failed: $error")
    return
}

println("source ${document.source}, ${document.pageCount} pages, ${document.lines.size} lines")
document.lines.forEach { println(it.text) }

There is a second overload for an image you already have, with no PDF involved — build a Raster from your platform's image type (Raster(bitmap), Raster(bufferedImage), Raster(cgImage)) and pass that instead. The caller keeps ownership.

With progress

extractAsFlow emits per page, which is what makes a progress bar and cancellation possible on a 300-page scan. The flow is cold, it throws OcrError rather than emitting it, and cancelling the collector stops the work at the next page boundary — or sooner, since the Tesseract engine can interrupt a page in flight.

var document: OcrDocument? = null
ocr.extractAsFlow(pdfBytes).collect { progress ->
    when (progress) {
        is OcrProgress.Started ->
            println("processing ${progress.pageCount} pages (${progress.source})")
        is OcrProgress.Page ->
            println("  page ${progress.index + 1}/${progress.of} — " +
                "${progress.page.lines.size} lines (${(progress.fraction * 100).toInt()}%)")
        is OcrProgress.Done -> document = progress.document
        // OcrProgress gains subtypes in minor releases: always give the `when` an else.
        else -> Unit
    }
}

OcrProgress.Started carries the page count after pageRange is applied, and source is ExtractionSource.DigitalTextLayer, Ocr or Mixed. OcrProgress.Page carries the finished page, so results can be rendered incrementally.

Headings and paragraphs

Lines are not a document. :sdk:structure turns them into blocks:

DocumentStructure.reconstruct(document).forEach { block ->
    when (block) {
        is DocBlock.Heading -> println("${"#".repeat(block.level)} ${block.text}")
        is DocBlock.Paragraph -> println("${block.text}\n")
        else -> println(block.text)
    }
}

4. Close it

ocr.close()

close() blocks the calling thread

The bundled engines take a blocking native lock so a handle is never freed underneath work in flight — freeing one mid-recognition is a SIGSEGV, not an exception. Closing therefore waits out whatever is running: on Android that can be a whole page. Do not call it on the main thread — or inside a use { } block on the main thread — without expecting a stall.

It is safe to call at any time, including during an extraction, and safe to call twice. Afterwards every extract fails with OcrError.Unsupported.


iOS, from Swift

Do not write Swift against the raw Kotlin API. Kotlin's generated Obj-C surface is usable but unpleasant: ByteArray arrives as KotlinByteArray (so Swift cannot hand over a Data), the UncialClient { } builder becomes a lambda-taking function on a …Kt class, and Result does not survive the export at all.

:dist:ios-framework therefore ships UncialIos, exported to Swift as UncialBootstrap, which hides exactly those edges. It is the seed of the :sdk:swift module that PLAN.md §6.3 reserves.

import Uncial

// iOS has no androidx.startup, so Vision is registered by hand. Once, at launch.
UncialBootstrap.shared.start()

// Obj-C export drops Kotlin default arguments, so Swift passes every parameter.
let client = UncialBootstrap.shared.createClient(
    languages: [OcrLanguage.Companion.shared.Ukrainian,
                OcrLanguage.Companion.shared.English],
    renderDpi: 200,
    includeWords: false,
    preferDigitalLayer: true,
    onLog: { message in print("uncial \(message)") }
)

if let capabilities = client.capabilities {
    let languages = capabilities.languages.map { $0.tesseractCode }.joined(separator: "+")
    print("engine \(capabilities.engineId), languages \(languages)")
} else {
    print("no engine registered: \(UncialBootstrap.shared.registeredEngines())")
}

// Takes NSData so a Swift `Data` goes straight across, and throws rather than
// returning a Result. In Swift this is `try await`.
let document = try await UncialBootstrap.shared.extract(client: client, pdf: data)

for block in UncialBootstrap.shared.reconstruct(document: document) {
    if let heading = block as? DocBlockHeading {
        print(String(repeating: "#", count: Int(heading.level)) + " " + heading.text)
    } else {
        print(block.text)
    }
}

createClient wires pdfTextExtractor() in for you: PDFKit is a system framework, so on iOS the digital fast path costs nothing in binary size.

The Simulator recognizes nothing

Vision text recognition returns zero observations on the iOS Simulator — there is no Neural Engine, performRequests succeeds, error is nil, and results is empty. A green Simulator run says nothing about recognition quality. Only a device does.


Where the .traineddata comes from

Nothing above says where Tesseract's models live, because there are four different answers — a bundled uncial-lang-* artifact, the machine's own Tesseract install, a runtime download, or your own LanguageDataProvider. Vision needs none of them.

See Language data.


Next: Samples — the three runnable apps, and the only way to exercise recognition end to end on a developer machine.