Input: PDFs and Images¶
Uncial takes two kinds of input, and both end up as the same OcrDocument:
| Input | Call | What happens |
|---|---|---|
| PDF bytes | extract(bytes: ByteArray) |
Optional digital text layer first, then page-by-page rasterize + recognize. |
| An image you already have | extract(raster: Raster) |
One page, straight to the recognizer. No PDF machinery. |
The split exists because rasterization and recognition are separate roles inside the SDK
(PageRasterizer and TextRecognizer). Giving the recognizer pixels directly is therefore
almost free, and it is what makes camera frames, screenshots and PNG fixtures work.
Extracting a PDF¶
import io.github.andrewmalitchuk.uncial.runtime.source.client.UncialClient
import io.github.andrewmalitchuk.uncial.model.source.language.OcrLanguage
val ocr = UncialClient {
languages = listOf(OcrLanguage.Ukrainian, OcrLanguage.English)
renderDpi = 200
}
val document = ocr.extract(pdfBytes).getOrThrow()
println("${document.pageCount} pages, ${document.lines.size} lines")
ocr.close()
extract returns Result<OcrDocument>; a failure always carries an
OcrError. Keep one client for as long as you are extracting — it owns a
loaded engine and, on Tesseract, several MB of language model.
close() blocks
The bundled engines take a blocking native lock so a handle is never freed underneath
work in flight. close() therefore waits out whatever page is running. Do not call it
on the main thread without expecting a stall — see
Progress and cancellation.
The digital text layer fast path¶
A PDF produced by a word processor already contains its text, with coordinates. Running OCR over a raster of it is roughly a thousand times slower and strictly less accurate, so Uncial reads the text layer instead when it can.
This is not wired up by default. The reader lives in the optional uncial-pdf-text
module — on Android it pulls in several MB of PDFBox-Android, which an app that only OCRs
photographs should not pay for. You opt in by handing the client an extractor:
import io.github.andrewmalitchuk.uncial.pdftext.source.extractor.pdfTextExtractor
val ocr = UncialClient {
digitalTextExtractor = pdfTextExtractor()
preferDigitalLayer = true // the default
}
UncialClient.hasDigitalTextLayerSupport tells you whether an extractor is present on a
given client. With digitalTextExtractor left null, preferDigitalLayer is inert and OCR
always runs.
On iOS, UncialBootstrap.createClient(...) wires pdfTextExtractor() in for you — PDFKit is
a system framework, so the digital path costs nothing in binary size there.
What you get back¶
Every page and every document carries an ExtractionSource, so you never have to infer the
path from timings:
| Value | Meaning |
|---|---|
DigitalTextLayer |
Read from the PDF's embedded text. Exact; Confidence.Certain throughout. |
Ocr |
Recognized from pixels by an engine. |
Mixed |
Some pages came from the text layer, the rest were OCR'd. |
import io.github.andrewmalitchuk.uncial.model.source.text.ExtractionSource
when (document.source) {
ExtractionSource.DigitalTextLayer -> ui.hideScannedBadge()
ExtractionSource.Ocr -> ui.showScannedBadge()
ExtractionSource.Mixed -> ui.showPartiallyScannedBadge()
}
Mixed is the ordinary outcome for a scan whose cover page was OCR'd by the scanner: page 1
has text, the rest do not, so page 1 is reused and the others go through the engine. The
decision is per page, not per document.
Coverage is judged over the pages you asked for
When pageRange is set, "does the text layer cover everything?" is answered over the
selected pages only. pageRange = 2..4 on a document whose text layer covers pages 2–4
skips OCR entirely, even if pages 0 and 1 carry nothing.
Per-page source is on the page itself, which is how you split a Mixed document:
Narrowing a document with copy() keeps the old source
OcrDocument.source is derived from the pages only when the default argument runs,
and copy() does not re-run default arguments. A Mixed document narrowed with
document.copy(pages = …) still reports Mixed. Use the constructor instead:
Extracting an image you already have¶
Raster wraps the platform's native image type. Construct one and hand it to extract:
import android.graphics.Bitmap
import io.github.andrewmalitchuk.uncial.core.source.raster.Raster
suspend fun recognize(ocr: UncialClient, bitmap: Bitmap) {
val raster = Raster(bitmap)
try {
val document = ocr.extract(raster).getOrThrow()
println(document.text)
} finally {
// You created it, so you decide when it dies:
// release() calls Bitmap.recycle() on the bitmap you passed in.
raster.release()
}
}
import io.github.andrewmalitchuk.uncial.core.source.raster.Raster
import platform.CoreGraphics.CGImageRef
suspend fun recognize(ocr: UncialClient, image: CGImageRef) {
val raster = Raster(image) // retains the CGImage
try {
val document = ocr.extract(raster).getOrThrow()
println(document.text)
} finally {
raster.release() // releases it again
}
}
Both constructors take an optional sourceDpi. See below for when it matters.
An image extraction always produces exactly one page, and its source is always
ExtractionSource.Ocr. pageRange and preferDigitalLayer do not apply — there is no
document to page through and no text layer to read.
Raster lifetime and ownership¶
The rule is short: whoever created the Raster releases it.
- Rasters Uncial creates — one per page, inside
extract(bytes)— are released by the pipeline as soon as the recognizer is done with that page. A 200 dpi A4 raster is several MB, and holding 300 of them is not survivable on a phone, so they are freed per page rather than at the end of the document. You never see these. - Rasters you create are yours.
extract(raster)does not release the one you passed in; neither doesTextRecognizer.recognize. Callrelease()yourself, or let your own image lifecycle handle it.
What release() actually does differs by platform:
| Platform | Backing type | release() |
|---|---|---|
| Android | android.graphics.Bitmap |
Bitmap.recycle() — the one platform where this really matters. |
| JVM | java.awt.image.BufferedImage |
No-op; the GC reclaims it. |
| iOS | CGImageRef |
CGImageRelease of the retain the constructor took. |
Calling release() more than once is safe. Using the raster afterwards is not.
iOS: the constructor retains, and that is load-bearing
CGImageRef is a raw pointer in Kotlin/Native with no ARC — holding one in a Kotlin
field neither retains it nor keeps its owner alive. Raster's constructor calls
CGImageRetain and release() calls CGImageRelease, which is what stops a CGImage
taken from a short-lived UIImage from being freed while a recognizer is still reading
it. The flip side: on iOS, after release() the raster has no pixels and reading
image throws.
sourceDpi is null for images you supply¶
Raster.sourceDpi is the resolution the pixels were actually rendered at. It is not
OcrOptions.renderDpi: a rasterizer clamps the requested dpi whenever the page would exceed
maxPageSide, so on a large page the two differ. Tesseract takes source resolution as a
layout input, so the SDK passes the effective figure rather than the requested one.
For a raster the caller built from their own image there is no honest answer, so the default
is null — and the engine is told nothing and estimates instead, which is better than
asserting a number nobody rendered at. Pass sourceDpi yourself only if you genuinely know
it (a 300 dpi flatbed scan, a page you rendered at a known resolution).
val raster = Raster(scannedImage, sourceDpi = 300) // you know this
val unknown = Raster(cameraFrame) // sourceDpi == null, and that is correct
Photos are not scans¶
A photo of a page reaches the recognizer through the same Raster, but two things about it
are the caller's job, and both are silent failures rather than errors.
Orientation. A camera writes the sensor's pixels and records how the device was held
somewhere alongside them — an EXIF tag on Android, UIImage.imageOrientation on iOS.
Neither Tesseract nor Vision looks at either: Tesseract is handed a Bitmap and Vision is
handed a CGImage, and both of those are just pixels. So a portrait photo arrives on its
side, and a sideways page under PSM_AUTO mostly recognizes as nothing at all. Rotate the
pixels before wrapping them:
- Android — read
ExifInterface.TAG_ORIENTATIONand apply it with aMatrix. - iOS — redraw the
UIImageinto a context, which bakesimageOrientationinto the pixels.UncialBootstrap.extract(client:image:)does this for you; aRasteryou build fromuiImage.CGImageyourself does not.
Size. OcrOptions.maxPageSide guards the rasterizers, and a raster you supply never
goes through one — so nothing clamps a 48 MP phone photo. Downscale it yourself. Roughly
200 dpi across the page is plenty; on Android, BitmapFactory.Options.inSampleSize does it
without ever materializing the full image.
Neither engine does perspective correction, deskewing or binarization beyond its own
internal thresholding, so a photo taken at an angle or under uneven light will read worse
than a scan of the same page. Where that matters, run the image through a document scanner
first (ML Kit's on Android, VNDocumentCameraViewController on iOS) and hand Uncial its
output. Both sample apps show the plain camera-and-gallery path — see
Samples.
What is next¶
- Options —
renderDpi,maxPageSide,pageRangeand the rest. - Progress and cancellation —
extractAsFlowfor long documents. - Coordinates — what the boxes on a page mean.