Kotlin Multiplatform PDF generator for Android and iOS — vector-first, type-safe, DSL-driven.
| Android | iOS |
![]() |
![]() |
The same Samples.brochure() document rendered on Android and iOS — pixel-identical vector output. Try it: Samples.brochure().toByteArray().
PdfKmp lets you build PDF documents from a Compose-style DSL that runs identically on Android and iOS. Text becomes glyph paths, shapes become path operators — every page stays sharp at any zoom level. The library ships the Inter font for cross-platform Latin parity and exposes opt-in references to system CJK / Arabic / Persian fonts so non-Latin scripts render natively.
Under the hood — native PDF stacks, no third-party engines:
| Platform | Backend |
|---|---|
| Android | android.graphics.pdf.PdfDocument + android.graphics.Canvas |
| iOS | UIGraphicsBeginPDFContextToData + Core Graphics (CGContext) |
Every DSL node funnels into these system PDF APIs, so the resulting bytes are real, native, vector PDFs — readable in Preview, Adobe Reader, Chrome, and any spec-compliant viewer.
- Installation
- Hello world
- Document & pages
- Text
- Rich text (multi-style spans)
- Layout — column / row / box / weighted
- Decorations — backgrounds, corners, borders, gradients
- Dividers, lines and shapes
- Lists
- Tables
- Images
- Vector / SVG
- Header, footer, page numbers, watermark
- Hyperlinks
- i18n fonts (CJK / Arabic / Persian) and custom fonts
- Compose Multiplatform Resources
- PDF viewer —
KmpPdfViewer/KmpPdfLauncher - Saving the document
- What to do after save — view, share, open
- Page break strategies
- Bundled samples
- Sample apps
- Contributing
- License
PdfKmp is published to Maven Central. The library exposes:
- an Android
aar(io.github.conamobiledev:pdfkmp-android), - an iOS framework named
PdfKmpfor arm64, x64, and simulator-arm64, - common Kotlin metadata (
io.github.conamobiledev:pdfkmp), - an optional Compose Multiplatform Resources companion (
io.github.conamobiledev:pdfkmp-compose-resources), - an optional Compose Multiplatform PDF viewer screen (
io.github.conamobiledev:pdfkmp-viewer).
Make sure Maven Central is in your repository list:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}Pick the section that matches your project. The two paths are mutually exclusive — KMP projects depend on pdfkmp only (Gradle resolves the right platform variant automatically, including for Android targets), while plain Android projects depend on pdfkmp-android instead. Inside each path, use whichever dependency style your project already uses; the libs.versions.toml form is recommended for new projects.
Depend on pdfkmp from commonMain. Do not add pdfkmp-android separately — Gradle picks the Android variant of pdfkmp for your Android target on its own.
With a version catalog:
# gradle/libs.versions.toml
[versions]
pdfkmp = "1.0.1"
[libraries]
pdfkmp = { module = "io.github.conamobiledev:pdfkmp", version.ref = "pdfkmp" }
pdfkmp-compose-resources = { module = "io.github.conamobiledev:pdfkmp-compose-resources", version.ref = "pdfkmp" } # optional
pdfkmp-viewer = { module = "io.github.conamobiledev:pdfkmp-viewer", version.ref = "pdfkmp" } # optional// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.pdfkmp)
implementation(libs.pdfkmp.compose.resources) // optional
implementation(libs.pdfkmp.viewer) // optional
}
}
}Without a version catalog:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.conamobiledev:pdfkmp:1.0.1")
implementation("io.github.conamobiledev:pdfkmp-compose-resources:1.0.1") // optional
implementation("io.github.conamobiledev:pdfkmp-viewer:1.0.1") // optional
}
}
}Both companions are opt-in:
pdfkmp-compose-resources— add it only if your project uses Compose Multiplatform Resources (Res.drawable.*) and you want typed resource references inside the PdfKmp DSL. Full usage in the Compose Multiplatform Resources section.pdfkmp-viewer— Compose Multiplatform PDF viewer screen with topbar, search, share, save, hyperlinks, gesture-driven zoom & text selection. Skip it if you only need to generate PDFs and let users view them in their system default reader. Full usage in the PDF viewer section.
The core pdfkmp artifact itself stays Compose-free — non-Compose consumers don't pay for either companion.
For projects that don't use Kotlin Multiplatform (plain com.android.application or com.android.library), depend on pdfkmp-android. KMP consumers should use the section above instead — they should not add this artifact.
With a version catalog:
# gradle/libs.versions.toml
[versions]
pdfkmp = "1.0.1"
[libraries]
pdfkmp-android = { module = "io.github.conamobiledev:pdfkmp-android", version.ref = "pdfkmp" }
pdfkmp-viewer = { module = "io.github.conamobiledev:pdfkmp-viewer", version.ref = "pdfkmp" } # optional// app/build.gradle.kts
dependencies {
implementation(libs.pdfkmp.android)
implementation(libs.pdfkmp.viewer) // optional — Compose viewer screen
}Without a version catalog:
// app/build.gradle.kts
dependencies {
implementation("io.github.conamobiledev:pdfkmp-android:1.0.1")
implementation("io.github.conamobiledev:pdfkmp-viewer:1.0.1") // optional
}pdfkmp-viewer brings in Compose Multiplatform — skip it if your Android project still uses the legacy View system. The viewer assumes Compose hosting (a ComponentActivity with setContent { … } or an embedded ComposeView).
- JDK 17+
- Android Gradle Plugin 8.x,
compileSdk34+ - Xcode 16+ when targeting iOS via Kotlin Multiplatform
R8 is fully supported — no additional keep rules required.
import com.conamobile.pdfkmp.pdf
import com.conamobile.pdfkmp.storage.StorageLocation
import com.conamobile.pdfkmp.storage.save
import com.conamobile.pdfkmp.style.PdfColor
import com.conamobile.pdfkmp.unit.sp
// 1. Build the document
// Use `pdf { }` for a synchronous build, or `pdfAsync { }` (suspend) when
// the tree contains typed `Res.drawable.*` references — see Compose
// Multiplatform Resources below.
val document = pdf {
metadata { title = "Hello, PdfKmp" }
page {
text("Hello, world!") {
fontSize = 24.sp
bold = true
color = PdfColor.Blue
}
}
}
// 2. Pick what to do with it:
val bytes: ByteArray = document.toByteArray() // raw bytes
// or
val saved = document.save(StorageLocation.Cache, "hello.pdf") // suspend, returns SavedPdf
println(saved.path) // absolute filesystem path you can hand to a viewer / share intentAfter save(...) you get a SavedPdf with a real filesystem path. The next sections walk through every feature; jump to What to do after save — view, share, open for ready-made snippets that hand the file to a PDF viewer or share sheet.
Every document opens with pdf { ... }. Inside, configure metadata, document-wide defaults, and add one or more pages:
pdf {
metadata {
title = "Quarterly Report"
author = "PdfKmp"
subject = "Sales summary for Q1"
keywords = listOf("sales", "q1", "report")
}
// Defaults inherited by every page until overridden inside the page block.
defaultTextStyle = TextStyle(fontSize = 12.sp, color = PdfColor.DarkGray)
defaultPagePadding = Padding.symmetric(horizontal = 40.dp, vertical = 56.dp)
defaultPageBreakStrategy = PageBreakStrategy.MoveToNextPage
page(size = PageSize.A4) {
spacing = 12.dp
text("First page")
}
page(size = PageSize.Letter) {
padding = Padding.all(20.dp) // override default for this page
text("Second page — Letter format")
}
}Available page sizes include PageSize.A4, PageSize.A5, PageSize.Letter, PageSize.Legal, plus PageSize.custom(widthPt, heightPt). Use Padding.all, Padding.symmetric, or pass each side explicitly.
Text takes a string plus an optional configuration block. Every property starts at the inherited textStyle and can be overridden:
text("Heading") {
fontSize = 28.sp
fontWeight = FontWeight.Bold // or `bold = true`
fontStyle = FontStyle.Italic // or `italic = true`
color = PdfColor.Black
letterSpacing = 1.5.sp
lineHeight = 32.sp // 0 keeps the font's natural value
underline = true
strikethrough = false
align = TextAlign.Center // Start / Center / End / Justify
font = PdfFont.Default // Inter (bundled)
}Color helpers (more in PdfColor.kt):
PdfColor.Red / Green / Blue / Black / White / Gray / LightGray / DarkGray
PdfColor(r, g, b, a) // floats in 0..1
PdfColor.fromRgb(0xFF5722) // hex literal as Long
PdfColor.fromHex("#FF5722") // hex string — RGB / RRGGBB / AARRGGBB
PdfColor.Blue.withAlpha(0.2f) // copy with new opacityWhen a single paragraph mixes weights, colors, or decorations and must wrap as one block of prose, use richText { ... }:
richText {
span("This sentence has a ")
span("highlighted") { color = PdfColor.Red; bold = true }
span(" word and an ")
span("italic phrase") { italic = true }
span(" inside it.")
}The wrapper packs every span into one paragraph, wraps lines at the parent's width, and respects per-span styling end-to-end.
PdfKmp uses Compose-flavoured layout primitives.
column(
spacing = 8.dp,
verticalArrangement = VerticalArrangement.Top, // Top / Center / Bottom / SpaceBetween / ...
horizontalAlignment = HorizontalAlignment.Start, // Start / Center / End
) {
text("First")
text("Second")
text("Third")
}row(
spacing = 12.dp,
horizontalArrangement = HorizontalArrangement.SpaceBetween,
verticalAlignment = VerticalAlignment.Center,
) {
text("Left")
text("Right")
}Layered children — first added is at the bottom, last on top.
box(width = 400.dp, height = 200.dp, cornerRadius = 12.dp) {
image(bytes = heroBytes, contentScale = ContentScale.Crop)
aligned(BoxAlignment.BottomStart) {
text("Hero title") { color = PdfColor.White; fontSize = 28.sp }
}
}Inside a row or column, weighted(n) claims a fractional share of the leftover space along the main axis:
row(spacing = 12.dp) {
weighted(1f) { text("33%") }
weighted(2f) { text("66%") }
}Explicit gap when the container's spacing isn't enough:
spacer(height = 24.dp)Every container (column, row, box, card) accepts the same decoration vocabulary.
card(
background = PdfColor.White,
cornerRadius = 12.dp,
padding = Padding.all(16.dp),
border = BorderStroke(1.dp, PdfColor.LightGray),
) {
text("Material-style card")
}card { ... } is shorthand for a decorated column.
When corners differ — tabs, asymmetric tiles — pass cornerRadiusEach:
box(
width = 240.dp,
height = 60.dp,
background = PdfColor.Blue,
cornerRadiusEach = CornerRadius.top(16.dp), // top-only rounded; bottom flat
) { aligned(BoxAlignment.Center) { text("Active tab") { color = PdfColor.White } } }
// Fully asymmetric:
cornerRadiusEach = CornerRadius(
topLeft = 24.dp, topRight = 4.dp,
bottomLeft = 4.dp, bottomRight = 24.dp,
)Outline only the sides you want — Material-style accent quotes, progress steppers:
card(
cornerRadius = 0.dp,
padding = Padding.all(12.dp),
borderEach = BorderSides(
bottom = BorderStroke(2.dp, PdfColor.Blue),
left = BorderStroke(2.dp, PdfColor.Blue),
),
) {
text("Accent quote — bottom + left only")
}Pass a PdfPaint (linear or radial) via backgroundPaint. Coordinates are local to the container — (0, 0) is its top-left:
box(
width = 480.dp, height = 80.dp, cornerRadius = 12.dp,
backgroundPaint = PdfPaint.linearGradient(
from = PdfColor.Blue, to = PdfColor.Red,
endX = 480f, endY = 0f,
),
) {
aligned(BoxAlignment.Center) {
text("Sunrise banner") { color = PdfColor.White; bold = true }
}
}
// Radial:
backgroundPaint = PdfPaint.radialGradient(
from = PdfColor.White, to = PdfColor(0.1f, 0.1f, 0.4f),
centerX = 240f, centerY = 60f, radius = 240f,
)
// Multi-stop:
backgroundPaint = PdfPaint.LinearGradient(
startX = 0f, startY = 0f, endX = 480f, endY = 0f,
stops = listOf(
GradientStop(0.00f, PdfColor.Red),
GradientStop(0.33f, PdfColor.Green),
GradientStop(0.66f, PdfColor.Blue),
GradientStop(1.00f, PdfColor.Red),
),
)Containers with a non-zero cornerRadius already clip children to the rounded shape. For square-cornered fixed-size containers (e.g. box(width = 200.dp, height = 80.dp)), pass clipToBounds = true to make sure overflowing children don't paint past the rectangle:
box(
width = 200.dp,
height = 80.dp,
background = PdfColor.LightGray,
clipToBounds = true, // sharp-rect clip; default is false
) {
text(longText) // anything past 80dp tall gets clipped
}Available on column, row, box, and card.
A thin horizontal rule that spans the parent's width:
divider(thickness = 0.5.dp, color = PdfColor.Gray) // solid
divider(thickness = 1.dp, color = PdfColor.Gray, style = LineStyle.Dashed)
divider(thickness = 1.5.dp, color = PdfColor.Red, style = LineStyle.Dotted)Vector circles and ellipses with solid fills, gradient paints, or stroke-only outlines:
circle(diameter = 60.dp, fill = PdfColor.Red)
circle(
diameter = 80.dp,
fillPaint = PdfPaint.radialGradient(
from = PdfColor.White, to = PdfColor.Blue,
centerX = 40f, centerY = 40f, radius = 40f,
),
)
ellipse(width = 100.dp, height = 60.dp, fill = PdfColor.Green)
circle(diameter = 60.dp, strokeColor = PdfColor.Black, strokeWidth = 2.dp)bulletList(
items = listOf(
"First item.",
"Wrapped continuation lines line up under the first text line, not under the bullet.",
"Third item.",
),
)
bulletList(items = listOf("Step", "Skip", "Stomp"), bullet = "→")
numberedList(
items = listOf("Author", "Render", "Ship"),
startAt = 1,
)table(
columns = listOf(
TableColumn.Fixed(70.dp), // fixed width
TableColumn.Weight(2f), // share of remaining
TableColumn.Weight(1f),
),
border = TableBorder(color = PdfColor.fromRgb(0xCFD8DC), width = 1.dp),
cornerRadius = 10.dp,
cellPadding = Padding.symmetric(horizontal = 12.dp, vertical = 10.dp),
) {
header(background = PdfColor.fromRgb(0xECEFF1)) {
cell("ID")
cell("Customer")
cell("Status", horizontalAlignment = HorizontalAlignment.End)
}
users.forEachIndexed { i, user ->
val zebra = if (i % 2 == 0) PdfColor.White else PdfColor.fromRgb(0xF7F9FA)
row(background = zebra) {
cell(user.id) { color = PdfColor.Gray }
cell {
text(user.name) { bold = true }
text(user.email) { fontSize = 10.sp; color = PdfColor.Gray }
}
cell(
value = user.status,
horizontalAlignment = HorizontalAlignment.End,
verticalAlignment = VerticalAlignment.Center,
)
}
}
}Cells can wrap arbitrary node trees — stack a title + subtitle, embed an icon, etc. Border configuration: TableBorder.None, or set showOutline / showHorizontalLines / showVerticalLines independently.
PNG and JPEG are decoded everywhere; WebP / HEIF where the platform supports them.
image(bytes = imageBytes, width = 300.dp, contentScale = ContentScale.Fit)
// Square crop:
image(
bytes = imageBytes,
width = 200.dp,
height = 200.dp,
contentScale = ContentScale.Crop,
)
// Width given, height derived from intrinsic aspect ratio:
image(bytes = imageBytes, width = 480.dp)
// Intrinsic pixel size (1px → 1pt):
image(bytes = imageBytes)ContentScale.FillBounds stretches to the destination box.
Compose Multiplatform Resources users — pass a typed
Res.drawable.*reference straight in viaimage(Res.drawable.cover_photo, width = 480.dp)from thepdfkmp-compose-resourcesmodule. The DSL stays sync; bytes are read duringpdfAsync's preflight pass.
Both Android <vector> XML and W3C <svg> are accepted by VectorImage.parse(...). Vectors stay vector inside the PDF — no rasterisation, sharp at any zoom.
val star = VectorImage.parse(starXml) // parse once, reuse many times
vector(image = star, width = 64.dp)
vector(image = star, width = 64.dp, tint = PdfColor.Red) // override fill
vector(image = star, width = 64.dp, strokeMode = VectorStrokeMode.Disabled)
// Inline parsing for one-offs:
vector(xml = """<svg ...>...</svg>""", width = 48.dp)Supported features include solid fills, linear and radial gradients, elliptical arcs (A/a SVG path commands), and <g transform="translate/rotate/scale"> group transforms.
Compose Multiplatform Resources users — skip the manual
VectorImage.parse(...)and pass a typedRes.drawable.*reference directly:vector(Res.drawable.logo, width = 64.dp, tint = PdfColor.Blue). See the Compose Multiplatform Resources section for the full integration.
Every logical page can declare a header, footer, and watermark. Headers and footers receive a PageContext carrying the current page number and the total page count — accurate even when content slices across multiple physical pages, because the renderer does a counting dry-run before the real pass.
page {
pageBreakStrategy = PageBreakStrategy.Slice
header { ctx ->
row(horizontalArrangement = HorizontalArrangement.SpaceBetween) {
text("Quarterly Report") { bold = true; fontSize = 12.sp }
text("Page ${ctx.pageNumber} of ${ctx.totalPages}") {
fontSize = 11.sp; color = PdfColor.Gray
}
}
divider(thickness = 0.5.dp, color = PdfColor.LightGray)
}
footer { ctx ->
divider(thickness = 0.5.dp, color = PdfColor.LightGray, style = LineStyle.Dashed)
text("conamobile · pdfkmp") {
fontSize = 10.sp; color = PdfColor.Gray; align = TextAlign.Center
}
}
watermark {
aligned(BoxAlignment.Center) {
text("DRAFT") {
fontSize = 120.sp; bold = true
color = PdfColor(0.92f, 0.92f, 0.95f)
}
}
}
// … body content
}Header is rendered inside the top page padding band, footer inside the bottom padding band, watermark behind the body content of every physical page.
link(url) { … } wraps any content in a clickable region:
link(url = "https://github.com/conamobiledev/PdfKmp") {
text("github.com/conamobiledev/PdfKmp") {
color = PdfColor.Blue; underline = true
}
}
link(url = "mailto:hello@example.com") {
text("hello@example.com") { color = PdfColor.Blue; underline = true }
}
// Whole card clickable:
link(url = "https://example.com") {
card(border = BorderStroke(1.dp, PdfColor.Blue)) {
text("Visit site →") { color = PdfColor.Blue; bold = true }
}
}On iOS the rectangles produce real PDF link annotations via UIGraphicsSetPDFContextURLForRect. Android's PdfDocument does not expose annotation APIs in v1, so on Android the visual styling stands in for the click target — the rectangle is recorded but readers cannot dispatch clicks. Both behaviours are intentional and the iOS output works in any reader (Preview, Adobe Reader, Chrome's PDF viewer).
PdfKmp ships Inter for Latin text. For non-Latin scripts, use the cross-platform PdfFont.System* references — each one is a comma-separated candidate chain that resolves to whichever font the running platform has installed:
text("漢字、ひらがな、カタカナ — 中文 / 日本語 / 한국어") {
font = PdfFont.SystemCJK // → "Noto Sans CJK SC, PingFang SC"
}
text("مرحبًا بكم في PdfKmp") {
font = PdfFont.SystemArabic // → "Noto Sans Arabic, Geeza Pro"
}
text("سلام دنیا") {
font = PdfFont.SystemPersian // → "Noto Naskh Arabic, Tahoma, Geeza Pro"
}If a platform is missing every candidate the renderer falls back to Inter, which lacks those glyphs. Register a .ttf / .otf to guarantee coverage:
val notoCjk = PdfFont.Custom(name = "NotoSansCJK", bytes = readFromAssets("NotoSansCJK.ttf"))
pdf {
registerFont(notoCjk)
page {
text("永和九年") { font = notoCjk }
}
}Custom fonts referenced anywhere in the document are picked up automatically — registerFont is only needed for fonts that no current node references but should still be embedded.
The optional pdfkmp-compose-resources module wires Compose Multiplatform's typed Res.drawable.* accessors into the PdfKmp DSL. You point at a resource by reference — no string paths, no manual byte decoding, no VectorImage.parse(...) boilerplate, no runBlocking. The companion pdfAsync { ... } builder runs a one-shot suspend preflight before layout; inside the block the DSL stays fully synchronous and reads exactly like the eager pdf { ... } API.
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.conamobiledev:pdfkmp:1.0.1")
implementation("io.github.conamobiledev:pdfkmp-compose-resources:1.0.1")
}
}
}Pulls in org.jetbrains.compose.components:components-resources transitively. The core :pdfkmp artifact stays Compose-free — non-Compose consumers don't pay for it.
Place a typed Res.drawable.* reference exactly where you would normally call vector(image = …) or image(bytes = …). The library auto-detects vector XML vs raster bytes from the file's leading magic bytes and dispatches to the correct rendering path:
import com.conamobile.pdfkmp.composeresources.drawable
import com.conamobile.pdfkmp.composeresources.image
import com.conamobile.pdfkmp.composeresources.vector
import com.conamobile.pdfkmp.pdfAsync
import myapp.generated.resources.Res
import myapp.generated.resources.logo
import myapp.generated.resources.cover_photo
// `suspend` because pdfAsync awaits the resource preflight pass.
// The DSL block itself — page { ... }, row { ... } — stays synchronous.
suspend fun buildReport(): PdfDocument = pdfAsync {
metadata { title = "Quarterly Report" }
page {
// `drawable` accepts both vector XML and raster bytes.
// Auto-detect happens at preflight, before layout.
drawable(Res.drawable.logo, width = 64.dp, tint = PdfColor.Blue)
drawable(Res.drawable.cover_photo, width = 480.dp)
// Or be explicit when the format is known up front:
vector(Res.drawable.logo, width = 24.dp, tint = PdfColor.Blue)
image(Res.drawable.cover_photo, width = 480.dp, contentScale = ContentScale.Fit)
}
}pdfAsync is pdf with a one-shot suspend preflight pass tacked onto the front. The DSL inside is identical — every container scope (page, column, row, box, card, table, richText, link, …) has the same shape and same options. Only the top-level entry differs:
pdf { } |
pdfAsync { } |
|
|---|---|---|
| Function shape | non-suspend | suspend |
drawable / vector / image (Res.drawable.X) |
throws at render time | works |
| All other DSL features | identical | identical |
| When to reach for it | document is built from in-memory bytes or pre-parsed VectorImages |
the tree contains one or more Res.drawable.* references |
pdf { } is preserved verbatim — existing code keeps working with zero changes. Reach for pdfAsync only when you actually want the typed-resource overloads.
Limitation. Headers, footers, and watermarks are invoked per physical page during the render pass, after the preflight is over. They cannot hold deferred resource references — load anything async-loaded in the page body, or pre-resolve outside the DSL and pass the parsed result into the header / footer factory.
For preloading or custom caching outside the DSL, every DrawableResource exposes three suspend extensions — toVectorImage(), toBytes(), toPdfDrawable() — IDE auto-complete will surface them with full KDoc.
The optional pdfkmp-viewer module ships a complete Compose Multiplatform PDF viewer screen — drop it in and you get a polished topbar, in-document text search, share, save-to-Downloads, hyperlink launcher, page indicator, and gesture-driven zoom / pan / text selection. The screen ships in two flavours so you can wire it up from anywhere — Compose UI tree or arbitrary imperative code.
Skip this section if you only need to generate PDFs and let users view them in their system default reader (the manual platform recipes in What to do after save below stay valid). Add the viewer when you want a unified, on-brand viewing experience inside your app.
See Installation above for full Gradle wiring. In short: add io.github.conamobiledev:pdfkmp-viewer alongside pdfkmp in your commonMain dependencies.
For Compose-based navigation graphs (NavHost / Voyager / Decompose). PascalCase, top-level, integrates with the host's back stack and theming.
import com.conamobile.pdfkmp.viewer.KmpPdfViewer
@Composable
fun InvoiceDetail(navController: NavController) {
KmpPdfViewer(
uri = "https://example.com/invoice.pdf",
title = "Invoice 2026 Q1",
onBack = { navController.popBackStack() },
)
}
// PdfKmp DSL document — text selection + clickable hyperlinks light up automatically
val document = remember {
pdf {
page {
text("Hello, world!") { fontSize = 18.sp; bold = true }
link(url = "https://example.com") {
text("Visit our site") { color = PdfColor.Blue; underline = true }
}
}
}
}
KmpPdfViewer(
document = document,
title = "Hello",
fileName = "hello.pdf",
onBack = { navController.popBackStack() },
)For fire-and-forget launches outside a @Composable scope: click handlers, LaunchedEffect, suspend functions, notification taps, etc. The launcher hosts KmpPdfViewer inside an Activity (Android) / UIViewController (iOS) and dismisses on back.
import com.conamobile.pdfkmp.viewer.KmpPdfLauncher
Button(onClick = {
scope.launch {
val pdf = pdfAsync { /* … build PDF … */ }
KmpPdfLauncher.open(pdf, title = "Invoice")
}
})
// URI directly — bytes fetched on a background dispatcher
KmpPdfLauncher.open(
uri = "content://com.example.docs/123",
title = "Document",
fileName = "document.pdf",
)Both APIs accept four input shapes — pick whichever matches what your code already has:
| Overload | Use when | Selection / hyperlinks |
|---|---|---|
uri: String |
content://, file://, http(s)://, asset / bundle paths |
disabled — opaque bytes |
document: PdfDocument |
from PdfKmp's pdf { } DSL |
enabled |
bytes: ByteArray |
raw %PDF-… from disk / network / file picker |
disabled |
source: PdfSource (composable only) |
you constructed a PdfSource.Document(bytes, runs, links) yourself |
enabled when source is Document |
- Topbar — Material-style Minimal Mono on Android, Classic iOS Native on iOS. Picks the right variant per platform via expect/actual.
- Search — long-press the search chip → topbar morphs into an inline
TextField. Matches yellow-highlight in the page; previous / next chevrons cycle through results; auto-scrolls to the active match. - Share — Material 3 share affordance. Hands the bytes to
Intent.ACTION_SEND(Android) /UIActivityViewController(iOS). - Save to Downloads —
MediaStore.Downloadson Android (Toast on success) /NSDocumentDirectoryon iOS (alert on success). - Hyperlinks —
link(url) { … }blocks in the DSL produce real clickable hotspots. Tapping opens the URL in the system browser. - Text selection — invisible
SelectionContaineroverlay backed by the captured glyph positions. Long-press → drag handles → Copy. - Gestures — pinch zoom (1× → 5×), single-finger pan when zoomed, free 2D pan, double-tap toggle, focal-point anchoring.
- Page indicator — auto-fades pill that switches to the next page once it crosses the viewport midpoint.
Every action button has a matching showSearch / showShare / showDownload / showBack / showPageIndicator Boolean — pass false to hide individual affordances without un-wiring callbacks. Behaviour toggles zoomEnabled, doubleTapToZoom, textSelectable, hyperlinksEnabled follow the same shape.
When the all-in-one shape doesn't fit (custom topbar, multi-FAB layouts, bottom-sheet share), the underlying composables stay public:
PdfViewer(...)— viewer surface only, no topbar / search.PdfViewerTopBar(...)/PdfViewerTopBarMinimalMono(...)/PdfViewerTopBarClassicIos(...)— topbar variants.PdfSearchBar(...)— morphing search field.PdfShareFab(...)/PdfSaveFab(...)— Material 3 FAB convenience composables.rememberPdfShareAction()/rememberPdfSaveAction()/rememberPdfUrlLauncher()— action factories.searchPdfText(textRuns, query)— substring scanner over captured text runs.
The full surface — every parameter, every overload, every tradeoff — lives in pdfkmp-viewer/README.md.
- Text selection / hyperlinks only on PdfKmp-built documents. External PDFs (network, file picker, etc.) are bitmap-only because the bytes don't carry text-position metadata. Adding general PDF parsing would require a heavy dependency (PdfBox-Android, PDF.js) — not in scope.
- iOS Liquid Glass. iOS 26's default toolbar wraps every action in a translucent floating capsule. The library's iOS topbar bypasses this with a custom flat shell so the design matches the handoff. Apps that prefer the Liquid Glass look can drop down to
PdfViewerand host their own SwiftUI navigation bar. - Compose-only. No legacy View / UIKit-only entry point. Both APIs assume Compose Multiplatform is already set up in the host.
PdfKmp gives you three ways to handle the generated PDF, in order of convenience:
import com.conamobile.pdfkmp.storage.StorageLocation
import com.conamobile.pdfkmp.storage.save
val doc = pdf { /* ... */ }
val saved = doc.save(StorageLocation.Cache, filename = "report.pdf")
println(saved.path) // absolute filesystem path
println(saved.name) // "report.pdf"
println(saved.location) // StorageLocation.Cachesave is a suspend fun — call it from a coroutine on Android, an async on iOS, or wrap it in runBlocking for one-shot scripts. It returns a SavedPdf with three fields:
| Field | Type | Description |
|---|---|---|
path |
String |
Absolute path to the file on disk. Use this for File(path) / URL(fileURLWithPath:). |
name |
String |
The filename you supplied (e.g. "report.pdf"). |
location |
StorageLocation |
The location you saved to — useful when the same code branches by destination. |
val bytes: ByteArray = doc.toByteArray()
// upload over the network, attach to an email, post to S3, …// Inside iosMain:
import com.conamobile.pdfkmp.toNSData
val data: NSData = doc.toNSData()
// hand off to UIDocumentInteractionController, PDFDocument(data:), etc.| Location | Android | iOS | Use case |
|---|---|---|---|
Cache |
app-internal cache | Caches/ |
temporary preview, can be cleaned by the OS |
AppFiles |
app-internal files | Library/ |
persistent, app-private |
AppExternalFiles |
scoped external (no permission) | falls back to Documents/ |
larger files, app-scoped |
Downloads |
shared Downloads/ via MediaStore |
shared Files app Downloads/ |
user can find it from system file manager |
Documents |
shared Documents/ |
iCloud Documents/ |
user-visible documents |
Temp |
cacheDir/tmp/ |
tmp/ |
one-shot temp file you'll delete after sharing |
Custom("/abs/path/dir") |
any writable directory | any writable directory | full control |
Android storage permissions:
Cache,AppFiles,AppExternalFiles, andTempneed no permission on any Android version.Downloads/Documentsuse the system MediaStore on Android 10+ which also needs no permission. ForCustompaths outside your app sandbox, you supply your own permissions / Storage Access Framework handling.
iOS sandboxing: Every
StorageLocationresolves inside your app's sandbox; iCloudDocumentsrequires theiCloud Documentscapability in your Xcode project.
Most apps don't stop at "I have a file on disk" — you want to show the PDF to the user, share it to another app, or let them email it.
For Compose-based apps, the optional pdfkmp-viewer module gives you the entire flow in a single call. Topbar, search, share, save-to-Downloads, hyperlinks, gestures — all built in:
KmpPdfLauncher.open(saved.path, title = "Invoice") // imperative
// or
KmpPdfViewer(uri = saved.path, title = "Invoice", onBack = { … }) // composableSee PDF viewer — KmpPdfViewer / KmpPdfLauncher above for the full feature list and lower-level building blocks.
If you'd rather stick with platform primitives — say you don't want to pull in Compose — the rest of this section walks through hand-rolled Android PdfRenderer + iOS PDFKit + share-sheet recipes.
Android's system PdfRenderer rasterises a page to a Bitmap:
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import androidx.compose.foundation.Image
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.asImageBitmap
import java.io.File
@Composable
fun PdfPagePreview(saved: SavedPdf, pageIndex: Int = 0) {
val bitmap by produceState<Bitmap?>(null, saved.path, pageIndex) {
val pfd = ParcelFileDescriptor.open(File(saved.path), ParcelFileDescriptor.MODE_READ_ONLY)
PdfRenderer(pfd).use { renderer ->
renderer.openPage(pageIndex).use { page ->
val bmp = Bitmap.createBitmap(page.width * 2, page.height * 2, Bitmap.Config.ARGB_8888)
bmp.eraseColor(android.graphics.Color.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
value = bmp
}
}
}
bitmap?.let { Image(bitmap = it.asImageBitmap(), contentDescription = null) }
}The :sample Android app does exactly this — see SampleActivity.kt.
Public PDF viewers (Adobe Reader, Drive, etc.) need a content:// URI, not a file path. Use a FileProvider:
<!-- AndroidManifest.xml -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.pdfprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider><!-- res/xml/file_paths.xml -->
<paths>
<cache-path name="cache_pdfs" path="." />
<files-path name="internal_pdfs" path="." />
<external-files-path name="external_pdfs" path="." />
</paths>import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
fun sharePdf(context: Context, saved: SavedPdf) {
val file = File(saved.path)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.pdfprovider",
file,
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share PDF"))
}
// "Open with" — let user pick any installed PDF viewer:
fun openInPdfViewer(context: Context, saved: SavedPdf) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.pdfprovider",
File(saved.path),
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
}PDFKit.PDFView displays the document as vector — zoom is unlimited, glyphs stay sharp. From SwiftUI:
import PDFKit
import SwiftUI
import PdfKmp // The Kotlin framework
struct PdfPreviewView: View {
let savedPath: String
@State private var document: PDFDocument?
var body: some View {
Group {
if let document = document {
PdfPreview(document: document)
} else {
ProgressView("Rendering…")
}
}
.onAppear {
document = PDFDocument(url: URL(fileURLWithPath: savedPath))
}
}
}
struct PdfPreview: UIViewRepresentable {
let document: PDFDocument
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.autoScales = true
view.displayMode = .singlePageContinuous
view.displayDirection = .vertical
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
uiView.document = document
}
}Pass the path you got from save(...).path (Kotlin) — Swift code reads it via interop and turns it into a URL.
import UIKit
func sharePdf(savedPath: String, presenter: UIViewController) {
let url = URL(fileURLWithPath: savedPath)
let activity = UIActivityViewController(
activityItems: [url],
applicationActivities: nil,
)
presenter.present(activity, animated: true)
}This brings up the standard share sheet — AirDrop, Messages, Mail, "Save to Files", any installed PDF viewer.
On Compose Multiplatform, the
pdfkmp-viewermodule covers this end-to-end — sameexpect/actualshape sketched here, plus the topbar / search / share chrome already wired up. The hand-rolled snippets above are the "I want a custom UI" escape hatch.
Long content overflows pages naturally. Two strategies control the split:
page {
pageBreakStrategy = PageBreakStrategy.MoveToNextPage // default
// Whole element moves to a new page if it would not fit.
}
page {
pageBreakStrategy = PageBreakStrategy.Slice
// Text splits at line boundaries; tall images cut at the page edge
// and continue seamlessly on the next page.
}Set the document-wide default via defaultPageBreakStrategy, override per page on PageScope.pageBreakStrategy.
Samples.kt ships ready-to-render demo documents you can call straight from any consumer — handy as smoke tests, copy-paste starting points, or fixtures for golden-file comparisons. Every entry returns a PdfDocument; pipe it through .toByteArray() or .save(...) and you have a real PDF in seconds.
| Function | What it shows |
|---|---|
Samples.brochure() |
The hero document at the top of this README — multi-page marketing layout |
Samples.helloWorld() |
Minimal one-page document |
Samples.typography() |
Text styling + decorations + alignment + richText spans |
Samples.rowAndColumn() |
Layout primitives with weighted children |
Samples.columnSpaceBetween() |
Column with VerticalArrangement.SpaceBetween |
Samples.tableShowcase() |
Tables, bullet lists, numbered lists |
Samples.vectorShowcase() |
Vector / SVG icons + circles + ellipses |
Samples.vectorAdvanced() |
Gradient fills, elliptical arcs, group transforms |
Samples.customDesigns(imageBytes) |
Cards with gradients, corner radii, per-side borders |
Samples.pageChrome() |
Header, footer, page numbers, watermark, hyperlinks, i18n fonts |
Samples.longBody() |
Page break strategy MoveToNextPage |
Samples.slicedBody() |
Page break strategy Slice |
Samples.customPadding() |
Per-page padding override |
Samples.withImage(imageBytes) |
Raster image with ContentScale.Fit + .Crop |
Samples.slicedImage(imageBytes) |
Tall image sliced across multiple physical pages |
Samples.showcase() |
Every v1 feature in a single PDF |
Three samples (withImage, slicedImage, customDesigns) take a ByteArray so the demo media stays in the consumer app's assets rather than the library jar — see :sample/src/androidMain/assets/sample.png for the bytes used by the bundled apps.
The repo ships two sample apps that mirror each other feature-for-feature:
:sample— Compose Android app, structured as a single-target KMP module (androidTarget()only) so it can pull resources fromcommonMain/composeResources/for thepdfAsync { drawable(Res.drawable.X) }showcase. Detail screen drops straight intoKmpPdfViewer(...)— five lines for a complete viewer with topbar, search, share, save, hyperlinks, and gestures. Run with./gradlew :sample:installDebug.iosApp/iosApp.xcodeproj— SwiftUI iOS app. Detail view follows the Classic iOS Native handoff with chevron + label, centered title, and three iOS-blue trailing icons; PDFKit handles rendering. Open in Xcode and Run; the build phase calls:pdfkmp:embedAndSignAppleFrameworkForXcodeautomatically.
Both apps surface every demo from Samples (in :pdfkmp/src/commonMain/kotlin/.../samples/Samples.kt) plus ComposeResourcesDemo (Android sample only — iOS demo coming with the next iOS sample refresh) so you can eyeball each feature on real devices.
Bug reports, feature requests and pull requests are all welcome — open an issue at https://github.com/ConaMobileDev/PdfKmp/issues or send a PR.
When working on the library locally:
# Run the full test suite (canonical surface — exercises every backend on iOS Simulator)
./gradlew :pdfkmp:iosSimulatorArm64Test
# Build all platform artefacts
./gradlew :pdfkmp:assemble
# Install the Android sample on a connected device
./gradlew :sample:installDebugFor repo conventions and AI-agent guidance see CLAUDE.md (working on this codebase) and AGENTS.md / .claude/skills/pdfkmp/SKILL.md (using the library).
This library — the public API, the layout engine, the Android and iOS rendering backends, every test, the sample apps, and this README — was authored end-to-end with Claude Code, Anthropic's coding agent. Bug reports, suggestions, and PRs welcome.
Apache License 2.0 — see LICENSE.
The bundled Inter font is licensed separately under the SIL Open Font License 1.1 — see pdfkmp/fonts/Inter-LICENSE.txt.

