A pure-Kotlin document engine for Kotlin Multiplatform: read, create, edit and
render PDFs, and read reflowable EPUB 2/3, from commonMain.
Documentation · a guide for each task, plus the generated API reference.
KitePDF ships its own PDF engine: lexer, xref parser, content-stream interpreter, font engine, filters, encryption, writer and editor. The same core reads reflowable EPUB 2/3 books. There is no platform PDF engine underneath, no JNI and no native binary, so one code path runs on Android, iOS, JVM, Kotlin/Native, JS and Wasm.
The engine modules hold three expect declarations in total: a mutex, a thread
id, and the deflate/inflate hook. All three live in kitepdf-core. Everything
else is shared by every target. Drawing a page to a screen is a separate, opt-in
artifact. KitePDF is pre-1.0, and the API changes between minor versions.
import io.github.yuroyami.kitepdf.PdfDocument
import io.github.yuroyami.kitepdf.writer.PdfBuilder
import io.github.yuroyami.kitepdf.writer.StandardFont
val bytes = PdfBuilder()
.page { text(StandardFont.Helvetica, 24.0, 72.0, 700.0, "Hello from PdfBuilder") }
.build()
val doc = PdfDocument.open(bytes)
doc.pageCount // 1
doc.pages[0].extractText() // "Hello from PdfBuilder"KitePDF.open(bytes) is a one-argument alias for PdfDocument.open(bytes). The
docs use PdfDocument: it also carries the password overload, openOrNull and
edit().
Seven artifacts are published, all at 0.3.1. Add one document artifact. Add one
renderer only when you draw pages.
| Artifact | Add it when |
|---|---|
io.github.yuroyami:kitepdf |
You want both formats. This is the usual choice. It has no code of its own: it is api(kitepdf-pdf) + api(kitepdf-epub) and a 12-line marker file. |
io.github.yuroyami:kitepdf-pdf |
You want PDF only, with no EPUB reflow engine on the classpath. |
io.github.yuroyami:kitepdf-epub |
You want EPUB only. |
io.github.yuroyami:kitepdf-core |
Never add it yourself. It holds geometry, KiteCanvas, the font engine, the stream filters and the hyphenation data, and it arrives with any of the three artifacts above. |
io.github.yuroyami:kitepdf-compose-viewer |
You draw with Compose Multiplatform. It gives you PdfView, EpubView and the viewer state. |
io.github.yuroyami:kitepdf-native-renderer |
You want page-to-image through the platform's own canvas: AWT, android.graphics, CoreGraphics, Canvas2D. |
io.github.yuroyami:kitepdf-skia-renderer |
You want page-to-image through Skia/Skiko, with one API across JVM, Android, Apple, Linux and web. |
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.yuroyami:kitepdf:0.3.1")
}
}
}That one line covers reading, text extraction, form filling, editing, redaction,
encryption and building PDFs, plus the EPUB reader. Its runtime dependencies are
kotlin-stdlib and KiteImage, which supplies the JPEG, PNG, GIF, JPX, JBIG2 and
CCITT decoders the core calls into. The same artifact works in a plain Android or
JVM project: add it to your ordinary dependencies { } block.
All three renderer modules depend on :kitepdf-pdf with implementation instead
of api, and their public signatures still name types from it:
rememberPdfViewState(document: PdfDocument),
AwtPdfRasterizer.encodeToPng(page: PdfPage, …). A renderer on its own puts
those classes on the runtime classpath but not the compile classpath, and the
build fails with unresolved references to PdfDocument and PdfPage. Declare
both lines:
implementation("io.github.yuroyami:kitepdf:0.3.1") // or kitepdf-pdf
implementation("io.github.yuroyami:kitepdf-skia-renderer:0.3.1") // exactly one rendererThe three renderers are alternative backends for the same KiteCanvas interface.
Choose the one that matches how your app already draws.
open throws when it cannot parse the file. openOrNull returns null instead.
Both read the xref chain first. When that chain is unusable, they scan the whole
file for N G obj headers, so truncated and lightly damaged files still open.
import io.github.yuroyami.kitepdf.text.search
val doc = PdfDocument.open(bytes) // or open(bytes, "secret")
val maybe = PdfDocument.openOrNull(bytes) // null instead of a throw
doc.version // "1.7"
doc.info.title // Info dictionary
doc.outlines // bookmark tree
doc.pages[3].label // "iv", from /PageLabels
val page = doc.pages[0]
page.extractText() // plain string
page.structuredText.blocks // each block holds lines, each line holds spans
page.search("invoice") // List<PdfSearchHit>
doc.search("invoice") // Sequence<PdfSearchHit>, lazily across pagesEvery span carries bounds, plus per-character edge positions for building
selection rectangles. Extraction uses the font's /ToUnicode CMap when the font
has one. PdfDocument also exposes xmp (parsed, or null), attachments,
permissions, viewerPreferences, pageMode, pageLayout, language,
articleThreads, optionalContent, markInfo, documentJavaScripts,
acroForm, formFields and resolveDestination.
doc.edit() returns a PdfEditor. It stages your changes and writes them two
ways. saveIncremental() appends an update section to the original bytes.
saveRewritten() rebuilds the file from a reachability walk. Redaction requires
saveRewritten(): saveIncremental() throws instead of writing a file that
still contains the redacted content.
import io.github.yuroyami.kitepdf.core.Rectangle
val filled = doc.edit().apply {
setTextFieldValue(doc.formField("ApplicantName")!!, "Jane Doe")
setCheckbox(doc.formField("AgreeToTerms")!!, checked = true)
setChoiceValue(doc.formField("Country")!!, "Norway")
}.saveIncremental()
val redacted = doc.edit().apply {
redactRegions(doc.pages[0], listOf(Rectangle(72.0, 700.0, 320.0, 720.0)))
}.saveRewritten()Text and choice fields get a new /AP /N appearance stream, built from the
field's /DA. Checkboxes and radio groups switch /AS to an existing appearance
state and clear their siblings. All four field types clear /NeedAppearances.
Redaction deletes the covered text and images from the content stream instead of
painting over them. Three gaps remain, listed under Limits.
PdfBuilder writes a file page by page, as in the first example. The rest of the
writer:
.setInfo(title = "Report", author = "Jane Doe")fills the Info dictionary.drawImage(logo, x = 400.0, y = 700.0, width = 96.0, height = 48.0)draws an image insidepage { }. Create the image withPdfImage.rgba(pixels, width = 128, height = 64), fromio.github.yuroyami.kitepdf.writer.- All 14 standard fonts are available, with widths from the URW++ AFM metrics.
EmbeddedFont.load(bytes)loads a custom font. It subsets the TrueType outlines by default and emits a CIDFontType2/Identity-H font with a matching/ToUnicode. CFF outlines also work.PdfBuilder.encrypt(userPassword, ownerPassword)writes an AES-256/R6 encrypted file. On the read side the engine handles RC4, AES-128 and AES-256, across revisions R2 to R6.
import io.github.yuroyami.kitepdf.epub.EpubDocument
val book = EpubDocument.open(bytes, pageWidth = 400.0, pageHeight = 640.0)
book.tableOfContents // nav.xhtml on EPUB 3, toc.ncx on EPUB 2
book.search("chapter") // Sequence<KiteSearchHit>
book.withFontSize(15.0) // repaginates; the original stays valid| Area | What the reader handles |
|---|---|
| CSS | A full cascade over the parsed HTML: selectors, specificity and a UA stylesheet. Reflow then paginates the result to the page size you ask for. |
| Fonts | Embedded TTF, OTF, WOFF and WOFF2, including obfuscated files |
| Hyphenation | Full Liang pattern sets for German, French, Spanish, Italian, Portuguese and Dutch |
| CJK | Per-character breaking, inter-character justification, kinsoku line-break rules |
| Layout | Ruby, bidi, Arabic joining, floats, tables, SVG, vertical writing modes |
Compose Multiplatform, through kitepdf-compose-viewer:
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Composable
import io.github.yuroyami.kitepdf.compose.*
@Composable
fun Viewer(doc: PdfDocument) {
val state = rememberPdfViewState(doc)
PdfView(
state = state,
layout = PdfLayout.Paged(Orientation.Horizontal),
zoomSpec = PdfZoomSpec(maxZoom = 6f),
renderSpec = PdfRenderSpec.Rasterized(),
onLinkTap = { _ -> false }, // return true once you have handled the action
)
}Headless, through either rasterizer artifact:
import io.github.yuroyami.kitepdf.nativerenderer.AwtPdfRasterizer
import io.github.yuroyami.kitepdf.skia.PdfPageRasterizer
val awtPng = AwtPdfRasterizer.encodeToPng(doc.pages[0], scale = 2.0) // JVM only
val skiaPng = PdfPageRasterizer.encodeToPng(doc.pages[0], scale = 2.0) // any Skiko targetAwtPdfRasterizer is the JVM entry point of kitepdf-native-renderer; the Apple,
Android and JS backends have their own. EpubView, rememberEpubViewState and
EpubPageRasterizer are the EPUB equivalents.
The four document artifacts share one target set. The renderers do not, and that difference is the usual cause of a first build that will not resolve.
| Artifact | Where it runs |
|---|---|
kitepdf, -pdf, -epub, -core |
Android (minSdk 21), JVM, iOS arm64, simulator arm64 and x64, macOS arm64, tvOS, watchOS, Linux x64 and arm64, Windows (mingwX64), Android Native, JS (browser and Node), wasmJs (browser and Node), wasmWasi (Node) |
-compose-viewer |
Android (minSdk 24), JVM, iOS arm64 and simulator arm64, macOS arm64, JS and wasmJs (browser) |
-native-renderer |
Android (minSdk 29), JVM, iOS arm64, simulator arm64 and x64, macOS arm64, tvOS, JS (browser) |
-skia-renderer |
Android (minSdk 21, see note), JVM, iOS arm64, simulator arm64 and x64, macOS arm64, tvOS, Linux x64 and arm64, JS and wasmJs (browser) |
On Android, -skia-renderer pulls org.jetbrains.skiko:skiko-android, which
JetBrains publishes to https://maven.pkg.jetbrains.space/public/p/compose/dev
rather than to Maven Central, so add that repository. Everything else here
resolves from Maven Central alone, and -native-renderer is the recommended
Android renderer.
The full matrix, and the reason behind each gap, is on the Platform support page.
JVM/AWT and Skia are the two complete renderers. Every row below is a limit you may reach.
| Limit | What it means for you |
|---|---|
| Page edits are not cumulative | editPageContent, stampPage and redactRegions each rebuild the page from the original document's snapshot, so a second call on the same page discards the first. Use one of the three per page per PdfEditor. |
| Redaction skips a shared form | A Form XObject is guarded by object number alone, so the same form reached under a second CTM keeps its content. |
| Redaction leaves the field entry | /AcroForm /Fields is not pruned when a widget annotation is removed. |
| Redaction keeps vector paths | Only text and images are removed. Paths inside the region pass through the operator filter untouched. |
| Annotations are read-only | They parse and appear on PdfPage.annotations, but there is no authoring API. The only annotation KitePDF writes is the widget for PdfSigner's own signature field. |
PdfSigner runs no cryptography |
It stages the signature field, reserves /Contents and patches /ByteRange. It cannot validate a signature. Your application supplies the CMS blob. |
| Writing encrypts more narrowly than reading | PdfBuilder creates AES-256/R6 only, and editing an encrypted document requires AES-128 or AES-256. RC4 documents open and decrypt, but you cannot edit them. |
| CoreGraphics drops Standard-14 text | It paints embedded glyph outlines only, so text in a standard font does not appear. It also ignores per-image alpha. |
Android and CoreGraphics drop RAW images |
Neither has a path for ImageXObject.Kind.RAW, so both draw a gray placeholder. RAW is what a successful JPEG, JPX or JBIG2 decode produces, which covers most images in most files. |
| Canvas2D drops every image | It draws a placeholder in place of each one. |
| The Compose canvas drops rotation | It reads translation and scale magnitudes from the CTM, so rotation and shear are lost. |
| Three rasterizers use the wrong page box | AwtPdfRasterizer, AndroidPdfBitmapRenderer and ApplePdfRasterizer size their output from the raw MediaBox and apply a plain Y-flip, so they ignore /Rotate, /CropBox and non-zero box origins. PdfPageRasterizer (Skia) and the Compose viewer use the rotated, cropped box and are correct. /UserUnit is parsed and then applied nowhere. |
| Chained image filters are not unwound | /Filter [/ASCII85Decode /DCTDecode] hands still-ASCII85-encoded bytes to the JPEG decoder. |
| Shading meshes are approximated | Coons (type 6) and tensor (type 7) patch meshes tessellate to a fixed 8×8 grid of flat-colored quads, and the tensor patch's four interior control points are read for stream alignment and then discarded. Triangle meshes (types 4 and 5) use fixed depth-3 subdivision. |
| ICC profiles are not applied | An /ICCBased stream is mapped to Gray, RGB or CMYK by its /N count alone. Rendering intents and overprint are ignored. |
| Structured text has no word segmentation | You get blocks, lines and spans, where a span is one text-drawing run. |
| There is no structure tree | markInfo reports whether a document declares itself tagged. /StructTreeRoot is not parsed. |
| EPUB fixed layout needs a fully fixed book | A hybrid book that mixes fixed and reflowable spine items uses the reflow path for the whole book. |
| English hyphenation is reduced | It ships a small common-word pattern set rather than the full hyph-en-us data. |
| CI does not cover every target | Pull requests run JVM tests only. Pushes to main also run the core, PDF and EPUB suites on the iOS simulator, macOS and JS/Node. Nothing in CI exercises Android rendering, Canvas2D, wasm or Linux/Windows native. |
769 tests across 172 test files. A differential harness compares the JVM/AWT backend page by page against MuPDF, and only that backend. See DIFFTEST.md. A local run over 36 pages reports a mean absolute error of 0.0062 and a worst page of 0.0281. The PDF corpus is not committed, so a clean checkout and CI do not reproduce that run.
If a PDF renders incorrectly, please open an issue with the file attached. Every rendering fix ships with a regression test.
sample/ is a Compose Multiplatform sample. It opens a PDF and exercises the
API. The desktop entry point runs standalone. The Android and iOS entry points
are meant for your own host project.
Apache-2.0. One file, Encodings.kt, carries character encoding tables from
MuPDF by Artifex Software and keeps its AGPL-3.0
attribution in the file header. Standard-14 font metrics come from URW++ AFM
files.