Skip to content

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:

dependencies {
    implementation("io.github.yuroyami:kitepdf-native-renderer:0.10.0")
}

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. Use 2.0 for "2× density" / retina thumbnails; 0.5 to shrink.
  • background: Color (default: Color.WHITE) : Fill color behind rendered content. Pass Color(255, 255, 255, 0) for transparency.

API:

  • renderToImage(page, scale, background): BufferedImage : Returns an AWT BufferedImage (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:

val pngBytes = withContext(Dispatchers.Default) {
    AwtPdfRasterizer.encodeToPng(page, scale = 1.5)
}

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: all 1.0) : RGBA fill color, each in [0.0, 1.0]. Pass A = 0.0 for a transparent background.

Returns:

  • NSData? : PNG bytes, or null if 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:

dependencies {
    implementation("io.github.yuroyami:kitepdf-native-renderer:0.10.0")
}

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. Call recycle() 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:

dependencies {
    implementation("io.github.yuroyami:kitepdf-skia-renderer:0.10.0")
}

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:

repositories {
    maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}

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 Skia Image (holds off-heap memory; call close() 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:

dependencies {
    implementation("io.github.yuroyami:kitepdf-native-renderer:0.10.0")
}

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:

dependencies {
    implementation("io.github.yuroyami:kitepdf-skia-renderer:0.10.0")
}

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:

dependencies {
    implementation("io.github.yuroyami:kitepdf-compose-viewer:0.10.0")
}

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, or null if encoding fails (shouldn't happen for bitmaps produced by KiteDocView).

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.5 is 4x faster and uses 4x less memory than scale = 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

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.