Headless rendering¶
Headless rendering turns a page into an image without a UI: for thumbnails, server previews or test screenshots.
Choose a renderer¶
| Renderer | Artifact | Targets | Takes |
|---|---|---|---|
AwtPdfRasterizer |
kitepdf-native-renderer |
JVM | PdfPage |
AndroidPdfBitmapRenderer |
kitepdf-native-renderer |
Android | PdfPage |
ApplePdfRasterizer |
kitepdf-native-renderer |
iOS, macOS, tvOS | PdfPage |
Canvas2dCanvas |
kitepdf-native-renderer |
JS | PdfPage |
PdfPageRasterizer |
kitepdf-skia-renderer |
JVM, Android, iOS, macOS, Linux, JS, Wasm | PdfPage |
EpubPageRasterizer |
kitepdf-skia-renderer |
JVM, Android, iOS, macOS, Linux, JS, Wasm | EpubPage |
KitePageRasterizer |
kitepdf-compose-viewer |
Every Compose target | Any KitePage |
kitepdf-native-renderer draws with the canvas of each platform, so it adds no graphics library to your app. kitepdf-skia-renderer draws with Skia, so the output is the same on every target. For an EPUB, an XPS document or any other page in a Compose app, use KitePageRasterizer.
The rasterizers share these rules:
scaleis pixels per point.1.0gives 72 dpi and2.0gives 144 dpi. For a given dpi, passdpi / 72.0.- The size of the image comes from the shown box of the page, with its crop box, its rotation and its user unit.
page.rasterGeometry(scale)gives the size before you render. - An image of more than 40 million pixels throws
IllegalArgumentExceptionbefore the rasterizer allocates memory. Ascalethat is not finite or not positive throws too. PassmaxPixelsfor another limit.
Warning
Keep a pixel limit when you render documents that you do not trust. A page can declare any size, and a large raster can stop your process.
JVM: AWT¶
AwtPdfRasterizer uses AWT and ImageIO, which the JDK has, so it needs no native library:
import io.github.yuroyami.kitepdf.PdfDocument
import io.github.yuroyami.kitepdf.nativerenderer.AwtPdfRasterizer
import io.github.yuroyami.kitepdf.openFile
import java.io.File
val pdf = PdfDocument.openFile("sample.pdf")
val pngBytes = AwtPdfRasterizer.encodeToPng(pdf.pages[0], scale = 2.0)
File("preview.png").writeBytes(pngBytes)
| Method | Returns |
|---|---|
renderToImage(page, scale, background) |
A BufferedImage of type TYPE_INT_ARGB, to change or to draw somewhere else |
encodeToPng(page, scale, background) |
PNG bytes |
encodeToJpeg(page, scale, background) |
JPEG bytes. JPEG has no alpha, so the image is opaque. |
background is a java.awt.Color and is white unless you pass one. Color(255, 255, 255, 0) gives a transparent PNG.
Android: Bitmap¶
AndroidPdfBitmapRenderer returns an ARGB_8888 Bitmap. Render off the main thread:
import android.graphics.Bitmap
import android.graphics.Color
import io.github.yuroyami.kitepdf.PdfPage
import io.github.yuroyami.kitepdf.nativerenderer.AndroidPdfBitmapRenderer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun thumbnail(page: PdfPage): Bitmap = withContext(Dispatchers.Default) {
AndroidPdfBitmapRenderer.renderToBitmap(page, scale = 1.5, background = Color.WHITE)
}
backgroundis an Android color int (0xAARRGGBB).- In Compose,
bitmap.asImageBitmap()shows the result in anImage. - The bitmap is yours. Call
recycle()on bitmaps that you no longer need when you render many pages.
Apple: CoreGraphics¶
ApplePdfRasterizer renders with CoreGraphics and encodes with ImageIO:
import io.github.yuroyami.kitepdf.PdfPage
import io.github.yuroyami.kitepdf.nativerenderer.ApplePdfRasterizer
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
import platform.Foundation.writeToURL
fun savePreview(page: PdfPage) {
val pngData = ApplePdfRasterizer.renderToPngData(page, scale = 2.0) ?: return
val documents = NSFileManager.defaultManager
.URLsForDirectory(NSDocumentDirectory, NSUserDomainMask)
.first() as NSURL
val fileUrl = documents.URLByAppendingPathComponent("preview.png") ?: return
pngData.writeToURL(fileUrl, atomically = true)
}
renderToPngDatareturns PNG bytes asNSData. It returns null when CoreGraphics cannot make the bitmap or the encoder.backgroundR,backgroundG,backgroundBandbackgroundAset the background, each from 0.0 to 1.0.backgroundA = 0.0gives a transparent PNG.- To draw into a view, make a
CoreGraphicsCanvasover the currentCGContextinstead.
Skia on every platform¶
kitepdf-skia-renderer draws with Skia through Skiko, the Kotlin binding of Skia:
Android needs an extra repository
On Android, this artifact needs org.jetbrains.skiko:skiko-android. JetBrains publishes it to the Compose dev repository, not to Maven Central. Without the repository, the build fails with Could not find org.jetbrains.skiko:skiko-android. Add it:
On Android, kitepdf-native-renderer needs no extra repository and adds no Skia library.
The JVM needs the native Skia library
On the JVM, Skiko loads a native library for each operating system and processor. A Compose Desktop app has it already. Without Compose, add the runtime for each system that runs your app, or skiko-awt-runtime-all for all of them. Use the Skiko version of KitePDF (0.153.0 for KitePDF 0.13.0):
import io.github.yuroyami.kitepdf.PdfDocument
import io.github.yuroyami.kitepdf.openFile
import io.github.yuroyami.kitepdf.skia.PdfPageRasterizer
import java.io.File
val pdf = PdfDocument.openFile("sample.pdf")
val page = pdf.pages[0]
// PNG bytes in one call
val pngBytes = PdfPageRasterizer.encodeToPng(page, scale = 2.0)
File("preview.png").writeBytes(pngBytes)
// A Skia Image, which holds memory outside the Java heap
val image = PdfPageRasterizer.renderToImage(page, scale = 2.0)
image.close()
renderToImagereturns a SkiaImage. Callclose()on it when you are done, because the garbage collector does not see its memory.encodeToPngcloses its image itself.backgroundis a Skia color int (0xAARRGGBB) and is white unless you pass one.0gives a transparent image.
EpubPageRasterizer has the same methods for an EpubPage, with one more parameter: theme draws the page in a reader theme, such as night mode.
Skia on the web¶
On JS and Wasm, the API is the same. Skia adds its WebAssembly module, skiko.wasm, to your app: about 9 MB before compression. When a small download matters more than the same output on every platform, use Canvas2D.
Web: Canvas2D¶
Canvas2dCanvas draws on the 2D context of an HTML canvas, with no extra download:
import io.github.yuroyami.kitepdf.PdfPage
import io.github.yuroyami.kitepdf.nativerenderer.Canvas2dCanvas
import io.github.yuroyami.kitepdf.rasterGeometry
import org.w3c.dom.CanvasRenderingContext2D
import org.w3c.dom.HTMLCanvasElement
fun drawPage(page: PdfPage, element: HTMLCanvasElement) {
val geometry = page.rasterGeometry(scale = 2.0)
element.width = geometry.widthPx
element.height = geometry.heightPx
val context = element.getContext("2d") as CanvasRenderingContext2D
page.renderTo(Canvas2dCanvas(context), geometry.deviceCtm)
}
To save the result as a PNG, use the browser's toBlob() or toDataURL() on the canvas.
An image that the decoders of KitePDF cannot read, such as a JPEG with damaged data, draws as a gray box, as on every canvas.
Many pages¶
Each page can render on its own thread, so you can render the pages of one document in parallel:
import io.github.yuroyami.kitepdf.PdfDocument
import io.github.yuroyami.kitepdf.nativerenderer.AwtPdfRasterizer
import io.github.yuroyami.kitepdf.openFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import java.io.File
suspend fun renderThumbnails(pdfPath: String, outputDir: String) {
val pdf = PdfDocument.openFile(pdfPath)
val outDir = File(outputDir).apply { mkdirs() }
coroutineScope {
pdf.pages.forEachIndexed { index, page ->
launch(Dispatchers.Default) {
// Half size, for quick previews
val pngBytes = AwtPdfRasterizer.encodeToPng(page, scale = 0.5)
File(outDir, "page_$index.png").writeBytes(pngBytes)
}
}
}
}
The memory of an image grows with its area. At scale = 0.5, an image has a quarter of the pixels that it has at scale = 1.0.
Cancel a render¶
Pass a KiteCancellation to renderTo to stop a long page. When the cancellation returns true, the page paints nothing more and returns with the canvas closed. What the page painted before stays on the canvas:
suspend fun renderOrStop(page: PdfPage, canvas: KiteCanvas, ctm: KiteMatrix) {
val job = currentCoroutineContext().job
page.renderTo(canvas, ctm, KiteCancellation { !job.isActive })
// A cancelled render leaves a part of the page. Do not show it.
ensureActive()
}
Every format checks the cancellation:
- A PDF page checks it every 32 operators.
- An EPUB page checks it before each box or line that it draws.
- An XPS or SVG page checks it before each element.
- A comic page checks it after it reads its image and before it decodes the image.
Annotations and forms¶
PdfPage.renderTo takes a filter, so a page can render with only some of its annotations, or none:
// The page content only
page.renderTo(canvas, deviceCtm) { false }
// Everything except highlights
page.renderTo(canvas, deviceCtm) { it.subtype != PdfAnnotation.Subtype.Highlight }
Use the filter to print or export without markup, for a clean thumbnail, or in an editor that renders the page once and then redraws only its annotations.
To draw a form with the values that the reader filled in, pass a PdfFormState (see Forms):
Memory¶
A PdfDocument keeps three caches, so that a page that draws again (at another zoom or after a scroll back) costs less:
| Cache | Budget property | Default | What it keeps |
|---|---|---|---|
| Decoded images | imageCacheBudgetBytes |
32 MiB | An image on many pages decodes once. |
| Parsed content | operationCacheBudgetBytes |
16 MiB | A page drawn again, or a form drawn many times, parses its content once. |
| Parsed fonts | fontCacheBudgetBytes |
32 MiB | A font parses once for all the pages, thumbnails and text extractions that use it. |
- Each cache drops the item that it used least recently first.
- An item larger than its whole budget is not kept, so it decodes or parses again each time.
- A JPEG or JPEG 2000 image without a mask keeps only its encoded data, so a scan counts as its file size. Each draw decodes it at the size that it draws, at a half, a quarter or an eighth of its pixels where it can. So a 600 dpi scan on a phone screen never exists at its full size.
- The content cache counts about 12 MB for a dense page of 100,000 operators. The font cache counts a font as about twice its embedded program, so a 10 MB CJK font counts as about 20 MB.
For a small heap, lower the budgets. When the app runs low on memory, call dropDecodedImageCache(), dropOperationCache() and dropFontCache():
val doc = PdfDocument.open(bytes)
doc.imageCacheBudgetBytes = 16L * 1024 * 1024
doc.operationCacheBudgetBytes = 8L * 1024 * 1024
doc.fontCacheBudgetBytes = 16L * 1024 * 1024
How the renderers draw¶
Thin lines¶
Every renderer 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 thin grid of an ECG report stays light gray and does not turn black.
MuPDF draws a width of 0 at a fifth of a pixel too, 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 smooth the edges of a clip. So the AWT renderer applies a clip as MuPDF does:
- A rectangular clip keeps every whole pixel that the rectangle touches.
- Any other clip sends the paints inside it to a layer. When the clip ends, the layer goes onto the page through the smoothed edge of the clip.
So a gradient, an image or a pattern inside a curved clip 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.
Glyphs on AWT¶
The AWT renderer places and caches glyphs as MuPDF does:
- A glyph snaps to a grid of part pixels. Along the text, an em under 24 pixels lands on a quarter pixel, an em under 48 pixels lands on half a pixel, and a larger em lands on a whole pixel. Across the text, an em of 8 pixels or more lands on a whole pixel, so a line of text sits on one pixel row.
- The renderer fills each glyph once for each size, position on the grid and color. It copies those pixels for every other use on the page.
Turned or skewed text, a glyph with an em over 256 pixels, and text in a blend mode other than Normal are filled as paths. They still snap to the same grid.
Raster steps¶
Some effects work on pixels, not on shapes. For example, an SVG filter blurs, moves or recolors the pixels of an element. A canvas gives such an effect a raster step (KiteCanvas.rasterStep): the canvas paints the content into a raster of its box, the effect changes the pixels in plain Kotlin, and the canvas draws the result back.
Every canvas of KitePDF has raster steps. A step can also read the backdrop (the pixels under its box), but only some canvases can give it:
| Canvas | Gives the backdrop |
|---|---|
AwtCanvas |
Always, also in a group or under a curved clip |
Canvas2dCanvas |
Always, because each of its layers is a canvas of its own |
SkiaCanvas |
Outside every group and soft mask layer, on a canvas that Skia can read back |
CoreGraphicsCanvas |
On a bitmap context, outside every group layer |
AndroidNativeCanvas |
In AndroidPdfBitmapRenderer, outside every group and soft mask layer |
ComposeCanvas |
In KitePageRasterizer, outside every group and soft mask layer |
KiteRasterCanvas |
Always, also in a group, because it draws into memory in plain Kotlin |
A PDF transparency group that is not isolated, and whose paints blend, needs the backdrop (ISO 32000-1, 11.4.8). The renderer paints such a group over a copy of the backdrop and alone, and works out the group from the two rasters. A knockout group of that kind (11.4.6) costs three renders of its box for each of its objects. A knockout group that would render more than 100 million pixels paints as a plain layer instead.
When an effect needs a backdrop that the canvas cannot give, the group paints as a plain layer. An SVG filter needs no backdrop: as in a browser, it reads BackgroundImage as transparent. A box larger than the pixel budget of the canvas works at a lower resolution and reads no backdrop. A canvas of your own runs no step unless you add one, and its caller then draws the content without the effect.