| name | pdfkmp |
| description | Use this skill when the user asks to generate a PDF document from Kotlin (Android, iOS, or Kotlin Multiplatform) using the PdfKmp library. Triggers: 'generate a PDF', 'export to PDF', 'create a PDF report', 'invoice PDF', 'PDF receipt', 'multi-page PDF', any time the user wants to author or modify code that calls into `com.conamobile:pdfkmp` (the `pdf { … }` DSL, `PdfDocument`, `PdfColor`, `TextStyle`, `column`/`row`/`box`/`card`/`table`, headers/footers/watermarks, hyperlinks, or i18n fonts). Covers vector PDF generation, the full DSL surface, common pitfalls, and the path to test/build (`./gradlew :pdfkmp:iosSimulatorArm64Test`). |
PdfKmp — author PDF documents from Kotlin
PdfKmp is a Kotlin Multiplatform PDF generator. Authors compose a tree of layout nodes via a Compose-style DSL; the library renders vector text and shapes to a real PDF on Android and iOS.
When in doubt about the API surface, read AGENTS.md at repo root or the full Samples.kt in pdfkmp/src/commonMain/kotlin/com/conamobile/pdfkmp/samples/Samples.kt — every feature is exercised there.
Mental model
pdf { // → PdfDocument
metadata { title = "…"; language = "en"; pdfACompliance = true }
encryption { ownerPassword = "…" } // optional — full JVM, partial iOS, no-op Android
attachment("file.xml", bytes) // optional — embed a file (JVM only)
defaultTextStyle = TextStyle(…) // optional document-wide defaults
defaultPagePadding = Padding.all(40.dp)
page(size = PageSize.A4) { // one logical page = one or more physical pages
spacing = 12.dp
pageBreakStrategy = PageBreakStrategy.Slice // or MoveToNextPage (default)
header { ctx -> … } // ctx.pageNumber/totalPages/isFirst/isLast/isEven/isOdd
footer { ctx -> … }
watermark { aligned(BoxAlignment.Center) { … } }
text("…") { align = TextAlign.Justify; maxLines = 2; overflow = TextOverflow.Ellipsis }
column { … } / row { … } / box { … } / card { … } // + dropShadow / rotation / opacity
columns(count = 2) { … } / grid(columns = 3) { … } / keepTogether { … }
table(columns = …, repeatHeader = true) { header { … }; row { cell(…) } }
image(bytes, width = …, contentScale = ContentScale.Fit, altText = "…")
vector(xml = svgString, width = …)
circle(diameter = …, fill = …)
ellipse(width = …, height = …)
qrCode("…") / barcode("…")
barChart(series, width, height) / lineChart(points, …) / pieChart(slices, diameter) / donutChart(…)
freeDraw(w, h) { path(fill = …) { moveTo(…); lineTo(…); close() } }
divider(thickness = …, style = LineStyle.Dashed)
bulletList(items = listOf(…))
numberedList(items = listOf(…), startAt = 1)
link(url = "https://…") { text("…") { color = PdfColor.Blue; underline = true } }
anchor("id") / linkToAnchor("id") { … } // internal cross-references
bookmark("…", level) / tableOfContents() // outline + auto-TOC
textField("name", width = …) / checkBox("name", checked = …) // AcroForm
richText { span("normal "); span("2") { script = TextScript.Superscript } }
}
}
pdf { … } returns a PdfDocument. Get bytes via .toByteArray(). Save via .save(StorageLocation.Cache, "report.pdf").
Imports cheat sheet
import com.conamobile.pdfkmp.pdf
import com.conamobile.pdfkmp.PdfDocument
import com.conamobile.pdfkmp.geometry.ContentScale
import com.conamobile.pdfkmp.geometry.PageSize
import com.conamobile.pdfkmp.geometry.Padding
import com.conamobile.pdfkmp.layout.BoxAlignment
import com.conamobile.pdfkmp.layout.HorizontalAlignment
import com.conamobile.pdfkmp.layout.HorizontalArrangement
import com.conamobile.pdfkmp.layout.PageBreakStrategy
import com.conamobile.pdfkmp.layout.VerticalAlignment
import com.conamobile.pdfkmp.layout.VerticalArrangement
import com.conamobile.pdfkmp.style.BorderSides
import com.conamobile.pdfkmp.style.BorderStroke
import com.conamobile.pdfkmp.style.CornerRadius
import com.conamobile.pdfkmp.style.DropShadow
import com.conamobile.pdfkmp.style.FontWeight
import com.conamobile.pdfkmp.style.LineStyle
import com.conamobile.pdfkmp.style.PdfColor
import com.conamobile.pdfkmp.style.PdfFont
import com.conamobile.pdfkmp.style.PdfPaint
import com.conamobile.pdfkmp.style.TableBorder
import com.conamobile.pdfkmp.style.TableColumn
import com.conamobile.pdfkmp.style.TextAlign
import com.conamobile.pdfkmp.style.TextDirection
import com.conamobile.pdfkmp.style.TextOverflow
import com.conamobile.pdfkmp.style.TextScript
import com.conamobile.pdfkmp.style.TextStyle
import com.conamobile.pdfkmp.storage.StorageLocation
import com.conamobile.pdfkmp.storage.save
import com.conamobile.pdfkmp.unit.dp
import com.conamobile.pdfkmp.unit.sp
import com.conamobile.pdfkmp.vector.VectorImage
import com.conamobile.pdfkmp.dsl.ChartSeries
import com.conamobile.pdfkmp.dsl.barChart
import com.conamobile.pdfkmp.dsl.lineChart
import com.conamobile.pdfkmp.dsl.pieChart
import com.conamobile.pdfkmp.dsl.donutChart
import com.conamobile.pdfkmp.barcode.QrErrorCorrection
import com.conamobile.pdfkmp.PdfLog
import com.conamobile.pdfkmp.sign.PdfSigner
import com.conamobile.pdfkmp.markdown.markdown
import com.conamobile.pdfkmp.markdown.MarkdownTheme
Always import — never use fully-qualified inline names. The library has explicitApi() on, so any new declaration in :pdfkmp itself must be public or internal.
Recipes
Hello world
val doc = pdf {
metadata { title = "Hello" }
page {
text("Hello, world!") {
fontSize = 24.sp; bold = true; color = PdfColor.Blue
}
}
}
val bytes = doc.toByteArray()
Invoice (table + totals + footer)
data class LineItem(val name: String, val qty: Int, val price: String, val total: String)
val items = listOf(
LineItem("Service A", 1, "$100", "$100"),
LineItem("Service B", 2, "$50", "$100"),
)
pdf {
metadata { title = "Invoice #1042" }
page {
spacing = 16.dp
row(horizontalArrangement = HorizontalArrangement.SpaceBetween) {
text("INVOICE") { fontSize = 28.sp; bold = true }
column(horizontalAlignment = HorizontalAlignment.End) {
text("#1042") { bold = true }
text("2026-04-30") { color = PdfColor.Gray; fontSize = 11.sp }
}
}
divider()
table(
columns = listOf(
TableColumn.Weight(3f),
TableColumn.Fixed(50.dp),
TableColumn.Fixed(80.dp),
TableColumn.Fixed(80.dp),
),
border = TableBorder(color = PdfColor.LightGray, width = 0.5.dp),
cellPadding = Padding.symmetric(horizontal = 10.dp, vertical = 8.dp),
) {
header(background = PdfColor.fromRgb(0xF5F5F5)) {
cell("Item") { bold = true }
cell("Qty", bold = true, horizontalAlignment = HorizontalAlignment.End)
cell("Price", bold = true, horizontalAlignment = HorizontalAlignment.End)
cell("Total", bold = true, horizontalAlignment = HorizontalAlignment.End)
}
items.forEach { item ->
row {
cell(item.name)
cell(item.qty.toString(), horizontalAlignment = HorizontalAlignment.End)
cell(item.price, horizontalAlignment = HorizontalAlignment.End)
cell(item.total, horizontalAlignment = HorizontalAlignment.End)
}
}
}
row(horizontalArrangement = HorizontalArrangement.End) {
card(
background = PdfColor.fromRgb(0xF5F5F5),
cornerRadius = 8.dp,
padding = Padding.all(12.dp),
) {
row(spacing = 24.dp) {
text("TOTAL") { bold = true }
text("$200") { bold = true; color = PdfColor.Blue }
}
}
}
spacer(height = 24.dp)
text("Thank you for your business.") {
fontSize = 11.sp; color = PdfColor.Gray; align = TextAlign.Center
}
}
}
Multi-page report (header + footer + slicing)
pdf {
metadata { title = "Annual Report" }
page {
pageBreakStrategy = PageBreakStrategy.Slice
header { ctx ->
row(horizontalArrangement = HorizontalArrangement.SpaceBetween) {
text("Annual 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 { _ ->
text("Confidential") {
fontSize = 10.sp; color = PdfColor.Gray; align = TextAlign.Center
}
}
text("Executive Summary") { fontSize = 22.sp; bold = true }
bodyParagraphs.forEach { text(it) }
}
}
Hero with gradient overlay + image
box(width = 480.dp, height = 200.dp, cornerRadius = 16.dp) {
image(bytes = heroBytes, width = 480.dp, height = 200.dp, contentScale = ContentScale.Crop)
box(width = 480.dp, height = 200.dp,
backgroundPaint = PdfPaint.linearGradient(
from = PdfColor(0f, 0f, 0f, 0f),
to = PdfColor(0f, 0f, 0f, 0.7f),
endX = 0f, endY = 200f,
),
) {}
aligned(BoxAlignment.BottomStart) {
column(padding = Padding.all(20.dp)) {
text("Title") { fontSize = 32.sp; bold = true; color = PdfColor.White }
}
}
}
Save to disk (cross-platform)
import com.conamobile.pdfkmp.storage.StorageLocation
import com.conamobile.pdfkmp.storage.save
val saved = doc.save(StorageLocation.Downloads, filename = "report.pdf")
println(saved.path)
StorageLocation options: Cache, AppFiles, AppExternalFiles (Android), Downloads, Documents, Temp, Custom("/abs/path/dir").
Non-Latin scripts
text("漢字 中文 日本語") { font = PdfFont.SystemCJK; fontSize = 18.sp }
text("مرحبًا") { font = PdfFont.SystemArabic; fontSize = 18.sp }
text("سلام دنیا") { font = PdfFont.SystemPersian; fontSize = 18.sp }
val noto = PdfFont.Custom("NotoCJK", bytesFromAssets)
pdf {
registerFont(noto)
page { text("永和九年") { font = noto } }
}
Extended API reference
These are the newer DSL entries. Verify exact signatures in ContainerScope.kt / Charts.kt / DocumentScope.kt; copy canonical usage from Samples.kt.
Advanced text
text(long) { align = TextAlign.Justify }
text(long) { maxLines = 2; overflow = TextOverflow.Ellipsis }
text(long) { minLinesBeforeBreak = 2; minLinesAfterBreak = 2 }
text("שלום עולם") { fontSize = 14.sp }
text("forced") { direction = TextDirection.Rtl }
richText { span("a"); span("2") { script = TextScript.Superscript } }
Layout & pagination
columns(count = 2, gap = 18.dp, spacing = 8.dp) { }
grid(columns = 3, spacing = 10.dp) { }
keepTogether { }
table(columns = …, repeatHeader = true) { … }
page(PageSize.A4.landscape) { … }
header { ctx -> if (ctx.isFirst) Unit else if (ctx.isEven) … else … }
Decorations (column / row / box / card)
card(dropShadow = DropShadow(offsetY = 4.dp, blur = 10.dp)) { … }
card(cornerRadius = 0.dp, border = BorderStroke(1.dp, PdfColor.Gray, LineStyle.Dashed)) { … }
box(rotation = -8f, opacity = 0.85f) { … }
Graphics
qrCode("https://…", size = 110.dp, errorCorrection = QrErrorCorrection.H)
barcode("INV-2026-00042", height = 50.dp)
val series = listOf(ChartSeries("Q1", 10f, PdfColor.Red), ChartSeries("Q2", 20f, PdfColor.Green))
barChart(series, width = 300.dp, height = 160.dp)
lineChart(listOf(3f, 7f, 4f, 9f), width = 300.dp, height = 120.dp, fillUnderLine = true)
pieChart(series, diameter = 160.dp); donutChart(series, diameter = 160.dp, holeRatio = 0.55f)
freeDraw(60.dp, 60.dp) { path(fill = PdfColor.Red) { moveTo(30f, 4f); lineTo(56f, 52f); lineTo(4f, 52f); close() } }
image(bytes, width = 300.dp, altText = "chart")
Navigation & document features
bookmark("Introduction", level = 0)
anchor("intro"); linkToAnchor(anchor = "intro") { text("← back") { color = PdfColor.Blue; underline = true } }
tableOfContents(maxLevel = 1)
textField("fullName", width = 300.dp); checkBox("agree", checked = true)
pdf {
metadata { language = "en"; pdfACompliance = true }
encryption { ownerPassword = "owner"; userPassword = "user"; allowPrinting = false }
attachment("factur-x.xml", xmlBytes, mimeType = "application/xml")
page { }
}
PdfLog.logger = { msg -> println("PdfKmp: $msg") }
JVM digital signing (jvmMain only)
val signed = PdfSigner.sign(pdfBytes, name = "Jane Doe", reason = "Approved") { content ->
myCmsService.signDetached(content)
}
val signed2 = PdfSigner.sign(pdfBytes, keyStore, alias = "mykey", password = pwd.toCharArray())
Markdown (pdfkmp-markdown artifact)
import com.conamobile.pdfkmp.markdown.markdown
page { markdown("# Title\n**bold** and [a link](https://example.com)\n- item", theme = MarkdownTheme()) }
Pitfalls (read before writing code)
TextAlign.Justify distributes real word spacing (last line of each paragraph stays ragged; also stretches space runs in richText).
- Hyperlinks, internal links, and the outline click on all three platforms now. Android has no native annotation API, so
finish() post-processes the bytes with a pure-Kotlin incremental update (info dict + link/GoTo annotations + outline); any parse surprise returns the original bytes unchanged.
- Coordinates are in PDF points, top-left origin. Don't flip Y.
- Layout sizing is intrinsic. Use
weighted(1f) inside a row/column to claim leftover space.
cornerRadiusEach wins over cornerRadius. Don't set both expecting them to combine.
- Header / footer / watermark are per-page, not document-wide. Either repeat them, or extract a helper.
PageContext.totalPages is exact — the renderer counts pages with a dry-run pass before the real render. ctx also has isFirst / isLast / isEven / isOdd for book-style chrome.
- Watermarks render BEHIND body content. For a stamp on top, draw at end of body or use a
box overlay.
bulletList / numberedList items are plain strings. For styled list items, build the list manually with column { row { circle(...); weighted(1f) { text(...) } } }, or use the pdfkmp-markdown module for inline-styled lists.
- Custom fonts are auto-collected from any
TextStyle.font = PdfFont.Custom(...) in the document tree. registerFont is only needed for fonts that no node references but should still be embedded.
- Forms are interactive only on Desktop/JVM.
textField / checkBox render as static visuals on Android and iOS.
- Encryption: full on JVM, partial on iOS (no "allow modification" flag), no-op on Android. Attachments, PDF/A, and
PdfSigner are JVM-only. PDF/A is best-effort, not full veraPDF conformance.
- Dashed / dotted borders require sharp corners — a non-zero
cornerRadius falls back to a solid outline.
- Mixed-style RTL in
richText keeps source span order, not visual reorder — author a whole RTL paragraph as one text(...) when segment order matters.
Common color helpers
PdfColor.Red / Green / Blue / Black / White / Gray / LightGray / DarkGray
PdfColor(r, g, b, a)
PdfColor.fromRgb(0xFF5722)
Verifying a change
./gradlew :pdfkmp:iosSimulatorArm64Test
./gradlew :pdfkmp:jvmTest
./gradlew :pdfkmp:compileIosMainKotlinMetadata
./gradlew :pdfkmp:assemble
./gradlew :sample:installDebug
When adding a feature, also extend Samples.kt and SamplesSmokeTest.kt so the new path is exercised end-to-end on iOS Simulator.
Repository layout
:pdfkmp — library (Android aar + iOS framework PdfKmp).
:sample — Android demo (Compose).
iosApp/ — iOS demo (SwiftUI). Build phase runs :pdfkmp:embedAndSignAppleFrameworkForXcode.
Reference files
pdfkmp/src/commonMain/kotlin/com/conamobile/pdfkmp/samples/Samples.kt — feature-by-feature example documents (incl. textAdvanced, longTable, barcodes, designExtras, navigation, newsletter, pageTemplates, formsAndAccessibility).
pdfkmp/src/commonMain/kotlin/com/conamobile/pdfkmp/dsl/ContainerScope.kt — all DSL functions available inside column/row/box (incl. QR/barcode, bookmarks, anchors, TOC, columns, grid, keepTogether, freeDraw, form fields).
pdfkmp/src/commonMain/kotlin/com/conamobile/pdfkmp/dsl/Charts.kt — chart DSL + ChartSeries.
pdfkmp/src/commonMain/kotlin/com/conamobile/pdfkmp/dsl/DocumentScope.kt — document-level encryption / attachment / pdfA / metadata.
pdfkmp-markdown/src/commonMain/kotlin/com/conamobile/pdfkmp/markdown/Markdown.kt — markdown(...) + MarkdownTheme.
AGENTS.md (root) — universal agent guide (this skill is the Claude Code-specific version).
README.md — end-user docs.
CLAUDE.md — repo conventions for agents working ON this codebase (not on USING the library).