Headless rendering (page → image)¶
Convert pages to raster images (PNG, JPEG) without a UI. Useful for thumbnails, server previews, CI screenshots, or embedding rendered previews in desktop apps.
Convenience rasterizers default to a 40-megapixel allocation ceiling and reject
non-finite or non-positive scales before allocating. Pass maxPixels when a
trusted print/export workflow deliberately needs a different ceiling.
PDF and EPUB
The names below say what they take. PdfPageRasterizer takes a PdfPage; EpubPageRasterizer, its twin in kitepdf-skia-renderer, takes an EpubPage and adds a theme argument for night mode. The platform-native rasterizers (AwtPdfRasterizer, AndroidPdfBitmapRenderer, ApplePdfRasterizer) are PDF only; for headless EPUB use the Skia renderer, or KitePageRasterizer from the Compose viewer, which takes any KitePage.
Which tool to use?¶
KitePDF offers three rendering paths, each fitting a different job:
| Use case | Artifact | Best for | Platform |
|---|---|---|---|
| JVM / Server | kitepdf-native-renderer (AWT) |
Minimal dependencies; built-in to JDK | JVM, CI pipelines |
| Apple platforms | kitepdf-native-renderer (CoreGraphics) |
Native system framework; no binary deps | iOS, macOS, tvOS |
| Android | kitepdf-native-renderer |
Native Bitmap API | Android |
| Cross-platform | kitepdf-skia-renderer |
One common API across JVM/Apple/Android/Linux | All except JS/wasmJs |
| Skia on web | kitepdf-skia-renderer (Skiko over WASM) |
Best fidelity on JS; includes images | JS, wasmJs |
| Canvas2D on web | kitepdf-native-renderer (Canvas2D) |
Minimal JS bundle; native acceleration | JS (lightweight viewers) |
| Compose viewers | kitepdf-compose-viewer + ImageBitmap.encodeToPng() |
Export rendered page from UI widget | All Compose platforms |
JVM / Server: AWT + ImageIO¶
The AWT rasterizer is zero-dependency; it ships with the JDK and needs no native binaries.
Install:
Basic usage:
import io.github.yuroyami.kitepdf.nativerenderer.AwtPdfRasterizer
import io.github.yuroyami.kitepdf.PdfDocument
import java.io.File
// Render page 0 to a PNG file on disk
val pdf = PdfDocument.openFile("sample.pdf")
val page = pdf.pages[0]
// Write PNG bytes directly to disk
val pngBytes = AwtPdfRasterizer.encodeToPng(page, scale = 2.0)
File("preview.png").writeBytes(pngBytes)
Parameters:
page: PdfPage: The page to render.scale: Double(default:1.0) : Multiplier on page dimensions. Use2.0for "2× density" / retina thumbnails;0.5to shrink.background: Color(default:Color.WHITE) : Fill color behind rendered content. PassColor(255, 255, 255, 0)for transparency.
API:
renderToImage(page, scale, background): BufferedImage: Returns an AWTBufferedImage(TYPE_INT_ARGB). Use this if you need to post-process, draw into another canvas, or store in a custom format.encodeToPng(page, scale, background): ByteArray: Returns PNG bytes ready to write to disk or send over HTTP.encodeToJpeg(page, scale, background): ByteArray: Returns JPEG bytes (TYPE_INT_RGB). JPEG doesn't support alpha; opaque background is used.
Server / CI usage
For CI jobs rendering many pages, parallelize on a thread pool to saturate CPU:
Apple platforms: CoreGraphics¶
On iOS, macOS, and tvOS, use ApplePdfRasterizer to render via native CoreGraphics + ImageIO.
Install:
// In your ios/macOS sourceSet
dependencies {
implementation("io.github.yuroyami:kitepdf-native-renderer:0.10.0")
}
Usage:
import io.github.yuroyami.kitepdf.nativerenderer.ApplePdfRasterizer
import io.github.yuroyami.kitepdf.PdfDocument
import io.github.yuroyami.kitepdf.openFile
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
val pdf = PdfDocument.openFile(path)
val page = pdf.pages[0]
// Render to PNG NSData
val pngData = ApplePdfRasterizer.renderToPngData(
page,
scale = 2.0,
backgroundR = 1.0, // RGBA, 0.0 to 1.0
backgroundG = 1.0,
backgroundB = 1.0,
backgroundA = 1.0,
) ?: return // null if CoreGraphics / encoder fails (extremely rare)
// Write to Documents folder
val docUrl = NSFileManager.defaultManager
.URLsForDirectory(NSDocumentDirectory, NSUserDomainMask).first() as NSURL
val fileUrl = docUrl.URLByAppendingPathComponent("preview.png")!!
pngData.writeToURL(fileUrl, atomically = true)
Parameters:
page: PdfPage: The page to render.scale: Double(default:1.0) : Multiplier on page dimensions (pt).backgroundR/G/B/A: Double(default: all1.0) : RGBA fill color, each in[0.0, 1.0]. PassA = 0.0for a transparent background.
Returns:
NSData?: PNG bytes, ornullif CoreGraphics allocation or PNG encoding fails (extremely rare; usually indicates OS-level resource exhaustion).
Android: Bitmap API¶
On Android, AndroidPdfBitmapRenderer returns an ARGB_8888 Bitmap for use with Canvas, ImageView, or disk caching.
Install:
Usage:
import io.github.yuroyami.kitepdf.nativerenderer.AndroidPdfBitmapRenderer
import io.github.yuroyami.kitepdf.PdfDocument
import android.graphics.Color
import android.graphics.Bitmap
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// Render off the main thread, then draw the result once it lands.
val imageBitmap = remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(pdf) {
val bitmap = withContext(Dispatchers.Default) {
AndroidPdfBitmapRenderer.renderToBitmap(
page,
scale = 1.5,
background = Color.WHITE
)
}
imageBitmap.value = bitmap.asImageBitmap()
}
imageBitmap.value?.let { Image(it, contentDescription = "Page thumbnail") }
Parameters:
page: PdfPage: The page to render.scale: Double(default:1.0) : Multiplier on page dimensions.background: Int(default:Color.WHITE) : Android color int (0xAARRGGBB).
Returns:
Bitmap: ARGB_8888 bitmap. You own the memory; the bitmap does not auto-recycle. Callrecycle()when done with large batches.
Bitmap allocation
Large pages at high scale can exhaust memory. Check the bitmap dimensions: (page.width * scale).toInt() x (page.height * scale).toInt() pixels.
Cross-platform: Skia (kitepdf-skia-renderer)¶
For a single API across JVM, Android, Apple, and Linux, use PdfPageRasterizer from kitepdf-skia-renderer.
Install:
Android needs an extra repository
On Android this module resolves org.jetbrains.skiko:skiko-android, which
JetBrains publishes to the Compose dev repository rather than to Maven
Central. Without it the build fails with
Could not find org.jetbrains.skiko:skiko-android. Add:
Every other target resolves from Maven Central alone. On Android, prefer
kitepdf-native-renderer: it draws through the
platform Bitmap API, needs no extra repository, and carries no Skia
runtime.
Usage:
import io.github.yuroyami.kitepdf.skia.PdfPageRasterizer
import io.github.yuroyami.kitepdf.PdfDocument
import org.jetbrains.skia.Color
import java.io.File
val pdf = PdfDocument.openFile("sample.pdf")
val page = pdf.pages[0]
// Render to Skia Image
val image = PdfPageRasterizer.renderToImage(
page,
scale = 2.0,
background = Color.WHITE
)
// Encode to PNG bytes and write to disk
val pngBytes = PdfPageRasterizer.encodeToPng(page, scale = 2.0)
File("preview.png").writeBytes(pngBytes)
// Clean up the image if you only needed bytes
image.close()
Parameters:
page: PdfPage: The page to render.scale: Double(default:1.0) : Multiplier on page dimensions.background: Int(default:Color.WHITE) : Skia color int (0xAARRGGBB).maxPixels: Long(default:40_000_000) : Allocation ceiling. Non-finite/non-positive scales and larger rasters fail before Skia allocates off-heap memory.
API:
renderToImage(page, scale, background, maxPixels): Image: Returns a SkiaImage(holds off-heap memory; callclose()when done).encodeToPng(page, scale, background, maxPixels): ByteArray: Convenience: render and encode in one call. Handles cleanup internally.
Off-heap memory
Skia images are backed by native memory. Always call image.close() when you're done, or use encodeToPng() which handles cleanup automatically.
Web: Canvas2D (kitepdf-native-renderer, JS)¶
For minimal bundle size on the web, use Canvas2D rendering via Canvas2dCanvas.
Install:
Usage:
import io.github.yuroyami.kitepdf.nativerenderer.Canvas2dCanvas
import io.github.yuroyami.kitepdf.PdfDocument
import io.github.yuroyami.kitepdf.core.render.KiteMatrix
import org.w3c.dom.CanvasRenderingContext2D
// In a <canvas> context
val canvas: CanvasRenderingContext2D = /* ... */
val pdfCanvas = Canvas2dCanvas(canvas)
val pdf = PdfDocument.open(/* ... */)
val page = pdf.pages[0]
val deviceCtm = KiteMatrix(scale, 0.0, 0.0, -scale, 0.0, page.height * scale)
page.renderTo(pdfCanvas, deviceCtm)
// To save as PNG: use the browser's canvas.toBlob() or toDataURL()
Embedded images arrive one frame late
The browser decodes JPEG and JP2 asynchronously, so the first pass over such an image paints a placeholder and the image appears on the next render. Raw-sample images draw immediately. Use Skia on JS when the very first paint must be complete.
Web: Skia over WASM (kitepdf-skia-renderer, JS/wasmJs)¶
For better image fidelity on the web (including embedded image XObjects), use Skia compiled to WASM.
Install:
Usage (same as JVM Skia):
import io.github.yuroyami.kitepdf.skia.PdfPageRasterizer
val pngBytes = PdfPageRasterizer.encodeToPng(page, scale = 1.5)
// Save via browser API
val blob = Blob(arrayOf(pngBytes), object : BlobPropertyBag {
override var type = "image/png"
})
// ... then download or upload
Bundle size trade-off
Skia over WASM (Skiko) adds about 5 MB to 10 MB to your JS bundle. For lightweight viewers, use Canvas2D instead.
Compose Multiplatform: Export from KiteDocView¶
If you're using the Compose viewer (kitepdf-compose-viewer), export the current rendered page as a PNG via ImageBitmap.encodeToPng().
Install:
Usage:
import androidx.compose.ui.graphics.ImageBitmap
import io.github.yuroyami.kitepdf.compose.encodeToPng
import java.io.File
// Assuming you've rendered a page into an ImageBitmap (e.g., via KiteDocView's onPageRendered)
val imageBitmap: ImageBitmap = /* ... */
val pngBytes = imageBitmap.encodeToPng() ?: return
// Write to disk
File("export.png").writeBytes(pngBytes)
- Returns:
ByteArray?: PNG bytes, ornullif encoding fails (shouldn't happen for bitmaps produced byKiteDocView).
Real-world example: Render all pages to PNG thumbnails¶
import io.github.yuroyami.kitepdf.nativerenderer.AwtPdfRasterizer
import io.github.yuroyami.kitepdf.PdfDocument
import java.io.File
fun renderThumbnails(pdfPath: String, outputDir: String) {
val pdf = PdfDocument.openFile(pdfPath)
val outDir = File(outputDir).apply { mkdirs() }
repeat(pdf.pageCount) { pageNum ->
val page = pdf.pages[pageNum]
val pngBytes = AwtPdfRasterizer.encodeToPng(page, scale = 0.5) // Half-size for quick previews
File(outDir, "page_$pageNum.png").writeBytes(pngBytes)
println("Rendered page $pageNum")
}
}
On a 100-page PDF, parallelizing with coroutines is faster:
import io.github.yuroyami.kitepdf.nativerenderer.AwtPdfRasterizer
import io.github.yuroyami.kitepdf.PdfDocument
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.coroutineScope
import java.io.File
suspend fun renderThumbnailsAsync(pdfPath: String, outputDir: String) {
val pdf = PdfDocument.openFile(pdfPath)
val outDir = File(outputDir).apply { mkdirs() }
coroutineScope {
repeat(pdf.pageCount) { pageNum ->
launch(Dispatchers.Default) {
val page = pdf.pages[pageNum]
val pngBytes = AwtPdfRasterizer.encodeToPng(page, scale = 0.5)
File(outDir, "page_$pageNum.png").writeBytes(pngBytes)
}
}
}
}
Thin lines¶
Every backend gives a thin stroke the same weight, so a page looks the same on each one.
- A line width of 0 is one device pixel wide, as ISO 32000-1, 8.4.3.2 asks.
- Any other line thinner than a fifth of a pixel widens to a fifth of a pixel. MuPDF does the same, so the 0.15-unit grid of an ECG report stays light grey and does not turn black.
MuPDF also draws a width of 0 at a fifth of a pixel, so a zero-width line is darker here than in mutool draw. In the Compose viewer, hairlineWidthPx sets the width of a zero-width stroke (see Compose viewer).
Clip edges on AWT¶
Java2D does not anti-alias a clip. So the AWT backend applies a clip path the way MuPDF does:
- A rectangular clip keeps every whole pixel that the rectangle touches.
- Any other clip path sends the paints inside it to a layer. When the clip ends, the layer composites onto the page through the anti-aliased coverage of the path.
A gradient, an image or a pattern stroke inside a curved clip therefore has smooth edges, as in mutool draw. Each curved clip costs one layer the size of the clip, so a page with hundreds of curved clips renders more slowly than a page with rectangular clips.
Performance tips¶
- Scale parameter: A page rendered at
scale = 0.5is 4x faster and uses 4x less memory thanscale = 1.0(area scales quadratically). - Batch rendering: Render many pages in parallel on a thread pool or coroutine dispatcher to saturate CPU cores.
- Platform choice: AWT on JVM and CoreGraphics on Apple are fast. Skia is also fast but has larger memory overhead.
- Background color: Transparent backgrounds (alpha = 0) may be slightly slower than opaque on some platforms.
Next steps¶
- Reading & extracting text from PDFs
- Building PDFs from scratch
- Viewing with Compose
Rendering without annotations¶
PdfPage.renderTo has an overload that takes a filter, so a page can render with some of its annotations or none of them:
page.renderTo(canvas, deviceCtm) { false } // page content only
page.renderTo(canvas, deviceCtm) { it.subtype != PdfAnnotation.Subtype.Highlight } // hide highlights
Use it for printing or exporting without markup, for a clean thumbnail, or in an editor that renders the page once and redraws only its annotation layer while a shape moves.