A Kotlin Multiplatform library for decoding and rendering TIFF images — Android, iOS, and
JVM/desktop — including multi-page/multi-directory TIFFs, on top of libtiff.
// fd: an already-open, seekable file descriptor
TiffRenderer(TiffSource.fromFileDescriptor(fd, size)).use { renderer ->
renderer.openPage(0).use { page ->
val bitmap = createTiffBitmap(page.width, page.height)
page.render(bitmap, renderMode = TiffRenderMode.FOR_DISPLAY)
}
}- Why TiffRenderer
- Install
- Getting started
- Lifecycle
- Limitations
- Codec support
- Testing
- Native libraries
- Requirements
- Sample app
- License
Android has no built-in TIFF decoder. PDF gets android.graphics.pdf.PdfRenderer, backed by
pdfium; TIFF gets nothing. TiffRenderer fills that gap. Its public API is deliberately modeled on
PdfRenderer's own shape — same method names, same lifecycle, same page/render-mode pattern — so
it's immediately familiar to any Android developer. It diverges only where the underlying reality
genuinely differs, documented inline where that happens.
TiffRenderer/TiffPage are one implementation shared across Android, iOS, and JVM — a single
platform-neutral C++ core, with only the innermost native call swapped per platform — so decoding
and rendering never drift between them. A few things worth knowing:
- Every TIFF directory is a
TiffPage.render()takes an optional destination clip and affine transform. TiffPage#retainRaster()opts a page into decode caching, so rendering the same page at multiple zoom levels doesn't redecode each time. Off by default — a cached raster is the page's full uncompressed pixel grid, hundreds of MB for a large scan.TiffRenderMode.FOR_DISPLAY(bilinear + mip minification) andFOR_PRINT(nearest-neighbor) are genuinely different resampling paths, not just labels.- On Android,
TiffRenderer(ParcelFileDescriptor)andTiffPage#render(Bitmap, Rect?, Matrix?, TiffRenderMode)take the platform's own types directly — no need to touch the cross-platformTiffSource/TiffBitmap/TiffRect/TiffTransformwrappers at all. - JPEG-in-TIFF and WebP are supported via vendored IJG libjpeg and libwebp; see Native libraries for exact pinned versions.
Versions before 2.0.0 were published to JitPack under
com.github.lucf15:TiffRenderer. That coordinate is no longer updated; migrate to the Maven Central one below.
Published to Maven Central as io.github.lucf15:tiffrenderer, a regular Kotlin Multiplatform
artifact (Android, JVM/desktop, iosArm64/iosSimulatorArm64):
// build.gradle.kts, in a Kotlin Multiplatform module
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.lucf15:tiffrenderer:<version>") // see the badge above for the latest
}
}
}A plain (non-KMP) Android app can depend on it directly instead:
// app/build.gradle.kts
dependencies {
implementation("io.github.lucf15:tiffrenderer:<version>")
}tiffrenderer's Android target pulls in io.github.lucf15:tiffrenderer-native (the compiled
.sos) transitively; nothing extra to add for that.
Any Kotlin Multiplatform project can depend on the Maven coordinate above from its own
iosMain/iosArm64/iosSimulatorArm64 source sets — no separate iOS-specific artifact. A pure
Swift/Xcode project with no Kotlin involved still needs a real .framework/XCFramework, which
this coordinate alone doesn't provide (Maven has no such artifact shape); build one from source
the way sample/iosApp does, via :lib:core's embedAndSignAppleFrameworkForXcode Gradle task.
Same coordinate, jvm() target — works from a plain JVM project too, not just KMP:
// build.gradle.kts
dependencies {
implementation("io.github.lucf15:tiffrenderer:<version>")
}Bundles native libraries for macOS (aarch64, x86_64), Linux (x86_64, aarch64), and Windows
(x86_64, aarch64); the right one is picked automatically at runtime based on the host JVM's
os.name/os.arch.
To build against a local clone instead of the published artifact, a Gradle composite build:
// settings.gradle.kts, in the app that wants to depend on this library
includeBuild("../path/to/TiffRenderer") {
dependencySubstitution {
substitute(module("io.github.lucf15:tiffrenderer")).using(project(":lib:core"))
}
}Building the library requires the Android NDK and CMake, versions pinned in
gradle/libs.versions.toml; Gradle fetches them automatically if they aren't already installed.
Building for iOS additionally requires Xcode. :lib:core's iOS cinterop tasks depend on
:lib:native's buildTiffCoreForIos task, so the native cross-compile
(lib/native/src/main/cpp/build-ios.sh) runs automatically as part of any Gradle iOS build — no
manual step needed.
libtiff, libjpeg, and libwebp are all vendored as git submodules. Clone with
--recurse-submodules, or run git submodule update --init --recursive afterwards, before
building any platform.
The snippet at the top of this README is the minimal case: open a source, open a page, render it. A few more common cases:
Rendering into a sub-region with a custom transform:
page.render(
bitmap,
destClip = TiffRect(0, 0, 512, 512),
transform = TiffTransform(floatArrayOf(2f, 0f, 0f, 0f, 2f, 0f)), // 2x scale
renderMode = TiffRenderMode.FOR_DISPLAY,
)Rendering the same page repeatedly (e.g. multiple zoom tiles) without redecoding each time:
renderer.openPage(0).use { page ->
page.retainRaster()
// every render() call below reuses the same decode
page.render(tile1, clip1, transform1, TiffRenderMode.FOR_DISPLAY)
page.render(tile2, clip2, transform2, TiffRenderMode.FOR_DISPLAY)
}On Android, everything above can take the platform's own ParcelFileDescriptor/Bitmap/Rect/
Matrix types directly instead of the cross-platform TiffSource/TiffBitmap/TiffRect/
TiffTransform wrappers:
// `pfd` must be a seekable ParcelFileDescriptor, e.g. from a content:// Uri via
// context.contentResolver.openFileDescriptor(uri, "r"). TiffRenderer takes ownership of it.
TiffRenderer(pfd).use { renderer ->
renderer.openPage(0).use { page ->
val bitmap = Bitmap.createBitmap(page.width, page.height, Bitmap.Config.ARGB_8888)
page.render(bitmap, Rect(0, 0, 512, 512), Matrix().apply { setScale(2f, 2f) })
}
}TiffTransform/the Matrix overload only ever represent an affine transform (6 components);
a genuinely non-affine (perspective) Matrix throws IllegalArgumentException, since neither
type can represent one.
import io.github.lucf15.tiffrenderer.TiffRenderer
import io.github.lucf15.tiffrenderer.TiffSource
import io.github.lucf15.tiffrenderer.TiffRenderMode
import java.io.File
TiffRenderer(TiffSource.fromFile(File("/path/to/file.tif"))).use { renderer ->
renderer.openPage(0).use { page ->
val bitmap = createTiffBitmap(page.width, page.height)
page.render(bitmap, renderMode = TiffRenderMode.FOR_DISPLAY)
val pixels = bitmap.toIntArray() // packed ARGB, row-major
}
}TiffBitmap's pixels live in a direct (off-heap) ByteBuffer, so the native render call writes
straight into it with no JVM-heap copy. toIntArray() above repacks into packed ARGB ints for
convenience; bitmap.toByteArray() returns the raw RGBA8888 bytes directly (e.g. for handing to
Skia's installPixels) without that per-pixel repacking.
A direct ByteBuffer is off-heap memory. The JVM only reclaims it when its wrapper object is
garbage-collected — there's no deterministic close()/free() for it. createTiffBitmap/
TiffBitmap(width, height) allocate a fresh buffer every call. That's fine for a one-off render,
but for a large page rendered repeatedly (e.g. on every resize or scroll frame), new buffers can
pile up faster than GC reclaims the old ones and spike memory well past the heap's own -Xmx. For
that case, allocate once and reuse:
val buffer = java.nio.ByteBuffer.allocateDirect(width * height * 4)
val bitmap = TiffBitmap.wrapping(buffer, width, height)
page.render(bitmap, renderMode = TiffRenderMode.FOR_DISPLAY) // render into `buffer` again on the next callTiffBitmap.wrapping requires a direct, writable buffer at least width * height * 4 bytes.
- One
TiffRendererper open document; close it when you're done, withclose()or.use {}. - Only one
TiffPagemay be open at a time perTiffRenderer, matching libtiff's own single-directory-cursor model: open, render, close before opening the next page. TiffRenderer(...),openPage(), andTiffPage#render()/retainRaster()can all throwTiffIOException(an uncheckedRuntimeException): a directory can fail to open, or a page's compression scheme can fail to decode, even after the document itself opened successfully — TIFF's per-directory codec independence means decode failures are genuinely per-page, not just per-file.
- Not thread safe. A
TiffRenderer/TiffPageinstance must not be called concurrently from more than one thread; a caller doing so (e.g. a scrolling UI rendering several pages at once) needs its own external lock around calls into the same instance. - Full page decoded into memory, always.
render()decodes the entire source page into an uncompressed RGBA raster before drawing any of it, even into a small destination bitmap — there's no tiled or streaming decode. UsedestClip/transformto control the destination size, not to reduce how much of the source gets decoded. - Decode is capped, not unbounded. Pages beyond ~250 million pixels (64-bit targets) or ~64
million pixels (32-bit Android ABIs —
armeabi-v7a/x86) throwTiffIOExceptionrather than attempting the decode, to fail cleanly instead of risking an OOM kill. TiffSourceis single-use. EachTiffSourceis consumed by theTiffRendererit's passed to, even if construction fails; passing the same instance to a secondTiffRendererthrowsIllegalStateException. If a source is created but never handed to aTiffRendererat all, callrelease()on it directly to free its underlying resource (e.g. a file descriptor) instead of leaving that to happen whenever the object is garbage-collected.- No color management, no >8-bit precision. Decoding goes through libtiff's
TIFFReadRGBAImageOriented, which flattens 16-bit-per-channel and floating-point TIFFs to plain 8-bit RGBA and ignores any embedded ICC profile. Scientific/medical TIFFs relying on higher precision, or documents expecting color-managed rendering, decode without error but lose that information silently.
This is a young library, and codec support is intentionally narrow in this first version:
| Codec | Supported | Notes |
|---|---|---|
| Uncompressed | ✅ | |
| PackBits | ✅ | |
| LZW | ✅ | |
| CCITT Group 3/4 (fax) | ✅ | |
| Deflate / ZIP | ✅ | via the platform's bundled zlib |
| JPEG-in-TIFF | ✅ | via vendored IJG libjpeg |
| WebP | ✅ | via vendored libwebp |
| Zstd | ❌ | would require vendoring libzstd |
| LERC | ❌ | would require vendoring LERC |
| LZMA | ❌ | would require vendoring liblzma |
| JBIG | ❌ | untested, no fixture available |
A TIFF using an unsupported codec opens fine (the compression tag is just metadata until
something actually tries to decode pixels) but TiffPage#render() throws TiffIOException once
decoding is actually attempted. It never silently misdecodes or crashes.
The bulk of the suite lives in one shared integrationTest source set and runs, unmodified,
against the real native decode path on all three targets — Android (on-device/emulator), iOS
(simulator), and JVM — not mocked or platform-specific:
TiffRendererLifecycleTest— construction, open/close state machine, the one-page-open-at-a-time invariant.TiffRendererCodecTest— the codec support matrix above: every supported codec must decode, every unsupported one must throwTiffIOExceptionspecifically fromrender(), never fromopenPage().TiffRendererRetainRasterTest—retainRaster()'s repeated-render consistency and cache invalidation across pages.TiffRendererCorruptInputTest— hostile/corrupt TIFFs (adversarial dimensions, truncated data) must surface asTiffIOException, never a crash.TiffRendererRenderTest— pixel-level render correctness (default fit-to-clip transform, explicit clip, custom transform).TiffRendererMinificationTest— downscaling a fine checkerboard must blend across the mip pyramid instead of aliasing to stark black/white.
Fixtures are real .tif files under lib/core/src/commonTest/resources/, generated by
lib/core/src/commonTest/tools/generate_fixtures.py.
A handful of tests are genuinely platform-specific and live outside integrationTest:
TiffRendererAndroidNativeOverloadTest— theBitmap/Matrix/Rectconvenience overloads, plus Android-only checks (recycled/immutable bitmaps,TiffSource.fromParcelFileDescriptor's non-seekable-fd rejection).TiffSourceByteArrayTest/TiffBitmapOverflowTest— JVM/iOS-specific edge cases.TiffRendererConcurrencyTest/TiffRendererNativeJvmParsingTest(JVM-only) — the per-document native lock under real thread contention, and the OS/arch native-library resolution logic.
./gradlew :lib:core:jvmTest # JVM, no device/emulator needed
./gradlew :lib:core:iosSimulatorArm64Test # iOS simulator
./gradlew :lib:core:connectedAndroidDeviceTest # Android, needs a running device/emulator
Vendored as pinned git submodules under lib/native/src/main/cpp/third_party/ (the same sources
back the Android, iOS, and JVM builds):
| Library | Version |
|---|---|
| libtiff | 4.7.2 |
| IJG libjpeg | 10 |
| libwebp | 1.6.0 |
All three bump automatically, including this table (see .github/workflows/libtiff-update-check.yml,
libwebp-update-check.yml, libjpeg-update-check.yml).
Each library's copyright notice and license terms are reproduced in
THIRD_PARTY_LICENSES.md.
| Target | Platform | Architectures | Notes |
|---|---|---|---|
| Android | Android | arm64-v8a, armeabi-v7a, x86_64, x86 |
minSdk 24+, compileSdk 37 |
| iOS | Device | arm64 (iosArm64) |
Deployment target 13.0+ |
| iOS | Simulator | arm64 (iosSimulatorArm64) |
No iosX64: Apple/Xcode dropped Intel simulator support |
| JVM/desktop | macOS | aarch64, x86_64 |
JDK 17+ |
| JVM/desktop | Linux | x86_64, aarch64 |
JDK 17+ |
| JVM/desktop | Windows | x86_64, aarch64 |
JDK 17+ |
sample/ is a small Compose Multiplatform app that doubles as a manual test harness for every
target: pick any TIFF via the system file picker (SAF on Android, UIDocumentPickerViewController
on iOS, JFileChooser on desktop) and page through it in an edge-to-edge scrolling viewer.
sample/androidApp, sample/iosApp, and sample/desktopApp are the three platform shells;
sample/shared is the actual UI, shared between them. Run the iOS app by opening
sample/iosApp/iosApp.xcodeproj in Xcode and hitting Run; run the desktop app via
./gradlew :sample:desktopApp:run.
Apache License 2.0. See LICENSE.