Best Practices
Tips for producing high-quality PDFs with compose2pdf.
Use PdfRoundedCornerShape for non-uniform corners
Standard RoundedCornerShape works for uniform corners, but when corners differ, use PdfRoundedCornerShape:
// Good: correct in PDF
Modifier.clip(PdfRoundedCornerShape(topStart = 24.dp, bottomEnd = 24.dp))
// Bad: all corners will look the same in vector mode
Modifier.clip(RoundedCornerShape(topStart = 24.dp, bottomEnd = 24.dp))
If you have an existing Shape, wrap it with .asPdfSafe().
Choose density wisely
| Use case | Recommended density |
|---|---|
| Vector mode (default) | Density(2f) – the default, good balance |
| Raster mode, screen viewing | Density(2f) |
| Raster mode, print quality | Density(3f) |
| Maximum raster quality | Density(4f) – large files, high memory |
Higher density in vector mode primarily improves sub-pixel positioning. In raster mode, it directly controls image resolution.
Prefer vector mode for text documents
RenderMode.VECTOR (the default) produces:
- Selectable, searchable text
- Crisp rendering at any zoom level
- Smaller file sizes (10-100 KB typical)
- Embedded, subsetted fonts
Reserve RenderMode.RASTER for content that relies on visual effects not supported in vector conversion (e.g., gradients).
Think in points for print
Compose Dp equals PDF points (1/72 inch). Design with physical dimensions in mind:
| Measurement | Value |
|---|---|
| 1 inch | 72 dp |
| 1 cm | ~28.35 dp |
| 1 mm | ~2.835 dp |
| 10pt font | 10.sp |
| 12pt font | 12.sp |
The content area for A4 with normal margins is 451 x 698 dp (about 6.3 x 9.7 inches).
Ship your fonts with the app
Any font Compose lays text out with is embedded automatically, so use whatever typeface your design calls for. For deterministic output, prefer fonts loaded from your own resources over installed system fonts:
InterFontFamily(the default) — zero setup, identical output everywhere- A custom
FontFamilyfromFont(resource = ...)static files — your brand typeface, identical output everywhere, nothing to install on servers null(system fonts) — embeds whatever the host resolves (San Francisco on macOS, Roboto/DejaVu on Linux), so output varies by machine
Declare every weight/style you use from a static font file — bold of a variable-only font can’t be embedded and falls back to a compressed standard font.
Structure multi-page layouts with composables
Extract shared page elements into reusable composable functions:
@Composable
fun PageChrome(
pageIndex: Int,
totalPages: Int,
content: @Composable ColumnScope.() -> Unit,
) {
Column(Modifier.fillMaxSize()) {
Header()
Spacer(Modifier.height(24.dp))
Column(Modifier.weight(1f), content = content)
Footer(pageIndex + 1, totalPages)
}
}
// Usage
renderToPdf(pages = 5) { pageIndex ->
PageChrome(pageIndex, 5) {
// Page-specific content
}
}
Use auto-pagination for flowing content
By default, renderToPdf automatically splits content across pages. Place content items as direct children for best results:
renderToPdf(config = PdfPageConfig.A4WithMargins) {
// Each direct child is a "keep-together" unit
ReportHeader()
DataTable(items) // won't be split across pages
SummarySection() // pushed to next page if needed
}
Avoid wrapping everything in a single Column — the library can only keep direct children together. Content using fillMaxHeight() or Modifier.weight() won’t auto-paginate well.
Use manual pagination for fixed layouts
When you need full control (headers/footers, page numbers, page-specific content), use the manual multi-page API:
val itemsPerPage = 20
val pageCount = (items.size + itemsPerPage - 1) / itemsPerPage
renderToPdf(pages = pageCount) { pageIndex ->
val pageItems = items.drop(pageIndex * itemsPerPage).take(itemsPerPage)
// Render pageItems
}
Use streaming for large documents or servers
For large PDFs or server-side rendering, use the OutputStream overload to avoid holding the final PDF bytes in memory:
// Stream to a file
FileOutputStream("report.pdf").use { out ->
renderToPdf(out, config = PdfPageConfig.A4WithMargins) {
LargeReport(data)
}
}
// Stream to an HTTP response (Ktor)
call.respondOutputStream(ContentType.Application.Pdf) {
renderToPdf(this) { ReportContent() }
}
The ByteArray variant is simpler for small documents. Use OutputStream when memory matters.
See Server-side & Ktor for more patterns.
Serialize concurrent renders
renderToPdf is not thread-safe. If generating PDFs concurrently:
val mutex = Mutex()
suspend fun generatePdf(content: @Composable () -> Unit): ByteArray {
return mutex.withLock {
renderToPdf { content() }
}
}
See also
- Troubleshooting – Common issues and fixes
- Supported Features – What works in each mode