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 FontFamily from Font(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