JVM¶
The JVM target runs Tesseract through Tess4J and rasterizes with PDFBox. It has the richest capability surface of the three platforms — and it is the one artifact that is deliberately not self-contained.
It needs a system Tesseract¶
The JVM engine binds to the operating system's libtesseract. That is an acceptable trade
for a JVM artifact — but it has to be documented, and OcrEngineFactory.isAvailable() has
to be honest about it, because the failure otherwise surfaces as an UnsatisfiedLinkError
from inside JNA.
brew install tesseract tesseract-lang # macOS
apt-get install tesseract-ocr tesseract-ocr-ukr # Debian / Ubuntu
isAvailable() returns true only when both halves are present:
- a
libtesseractin a directory the SDK knows to look in, and - models — either a system
tessdatadirectory, or atessdata/resource on the classpath (which is what theuncial-lang-*artifacts ship).
If it returns false, UncialClient.capabilities is null and every extract fails with
OcrError.Unsupported rather than crashing in native code.
Finding the native library¶
Tess4J reaches libtesseract through JNA, which searches the system loader path plus
jna.library.path. Homebrew installs into /opt/homebrew/lib, which is in neither — so on
a stock Apple Silicon Mac, Tess4J fails to load a library that is plainly installed.
JnaLibraryPath fixes that by scanning a fixed list of directories and appending the ones
that actually contain a libtesseract to jna.library.path, preserving anything already
set:
Library names recognized: libtesseract.dylib, libtesseract.5.dylib, libtesseract.so,
libtesseract.so.5, tesseract.dll.
If your installation is somewhere else, set -Djna.library.path=... yourself — the existing
value is merged, never replaced. The EngineInit error names the directories that were
searched.
Registering the engine¶
There is no androidx.startup off Android, so call this once — from main, a DI module, or
a test fixture:
installTesseractEngine() // Auto page segmentation
installTesseractEngine(TesseractPageSegmentation.SparseText) // or pick a mode
Idempotent. tesseractEngine(pageSegmentation) returns the factory directly if you would
rather pass it to the UncialClient builder explicitly.
Where language data comes from¶
The JVM default is a chain:
public actual fun defaultTessDataProvider(): LanguageDataProvider =
SystemTessDataProvider() + ClasspathTessDataProvider()
SystemTessDataProvider¶
Looks for a directory whose tessdata/ subdirectory holds at least one .traineddata, in
this order:
- any
extraSearchPathspassed to the constructor $TESSDATA_PREFIX— and, if that points straight at atessdatadirectory, its parent as well/opt/homebrew/share/usr/local/share/usr/share/usr/share/tesseract-ocr/5/usr/share/tesseract-ocr/4.00
TESSDATA_PREFIX has meant two different things
Across Tesseract versions it has meant both "the parent of tessdata/" and "tessdata/
itself", and Homebrew sets it to neither. Both shapes are accepted and normalized to the
parent, which is what Tesseract's datapath wants.
ClasspathTessDataProvider¶
This is how the uncial-lang-* artifacts work on the JVM: the model ships as a resource
under tessdata/, and Tesseract cannot read a resource — it needs a real directory. So the
model is unpacked once and reused.
Cache location: ~/.cache/uncial/tessdata-<user> by default, where <user> is
user.name with everything outside [A-Za-z0-9_.-] replaced by _. If user.home is
unset the fallback is java.io.tmpdir, then the working directory. Pass a File to the
constructor to choose your own.
The directory is created with mode rwx------ in the same call that creates it, and if it
already exists and belongs to another user the provider refuses it with
OcrError.EngineInit rather than handing its contents to libtesseract in native code. (The
old predictable /tmp path let another account plant a .traineddata.) On Windows, which
has no POSIX permissions, the per-user home directory carries that guarantee instead.
Extraction is compared by size, not timestamp — an upgraded artifact ships a different model under the same name — and goes through a temp file plus a move, so a crashed or concurrent run cannot leave a half-written model that looks complete.
How the chain resolves¶
public operator fun LanguageDataProvider.plus(
fallback: LanguageDataProvider,
): LanguageDataProvider
available()is the union of both providers' answers.materialize()picks the provider covering the most of the requested languages, earlier providers winning ties, and falls forward to the next candidate if one fails. Tesseract takes exactly onedatapath, so the languages cannot be split across providers.
Net effect: a developer machine with brew install tesseract-lang keeps using its own
models; a server with only the JAR falls back to the ones uncial-lang-* ships.
Two datapath conventions
LanguageDataProvider.materialize returns the parent of tessdata/, which is what
the Tesseract4Android wrapper wants. Tess4J hands the path straight to libtesseract 5,
which treats it as the tessdata directory itself — so the JVM engine descends one
level before calling in. If you write your own provider, follow the documented contract
(return the parent); the engine does the adapting.
The richest capability surface¶
The JVM engine talks to libtesseract's C API directly rather than going through Tess4J's high-level wrapper, and that API reports things the other two bindings do not:
| 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 |
So on the JVM, OcrLine.language, OcrLine.skewDegrees and OcrPage.orientation carry
real measured values. This is a property of the binding, not of Tesseract: the Android
wrapper's ResultIterator simply does not expose them. See
Capabilities.
Two consequences:
- The JVM is the practical place to run a golden corpus. Android and the JVM run the same
engine over the same
.traineddata, so results agree — but only the JVM can be driven from a plain test. RecognitionQualityis ignored by both Tesseract engines. Their only equivalent isOEM_TESSERACT_LSTM_COMBINED, which needs legacy data that neithertessdata_fastnortessdata_bestships, so asking for it fails at init instead of running faster.
Do not route the JVM engine back through Tess4J's getWords
That path goes via lept4j, whose bundled bindings are pinned to a Leptonica ABI, and
it dies with symbol not found: pixFindBaselinesGen against Homebrew's Leptonica 1.85.
The direct C API call is deliberate.
Running OCR from the command line¶
:samples:cli is the harness that exercises recognition end to end on a developer machine:
./gradlew :samples:cli:run --args="capabilities"
./gradlew :samples:cli:run --args="fixture /tmp/scan.pdf --pages 6"
./gradlew :samples:cli:run --args="ocr /tmp/scan.pdf --words"
fixture generates a scanned (image-only) Ukrainian PDF on purpose: a normally
generated PDF carries its own text, so Uncial takes the digital fast path and proves nothing
about recognition.
Other notes¶
- Compiled for Java 11. The SDK compiles on JDK 21 and targets bytecode 11, so consumers on Java 11 can use the JVM artifact.
Raster.release()is a no-op on the JVM: the GC reclaims aBufferedImagelike any other object. Build one fromImageIOoutput withRaster(image, sourceDpi)to recognize an image with no PDF involved.close()blocks here too — the recognizer takes a blocking lock around its native calls, so closing waits out the native call currently executing.- Publishing the JVM artifact is still an open question (
PLAN.md§11); nothing is published yet, not even tomavenLocal.