Architecture
How compose2pdf converts Compose content to PDF under the hood.
Pipeline overview
┌─────────────────────────────────────────────────┐
│ renderToPdf() │
│ │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ VECTOR mode │ │ RASTER mode │ │
│ │ │ │ │ │
│ │ Compose │ │ Compose │ │
│ │ ↓ │ │ ↓ │ │
│ │ Skia │ │ ImageComposeScene │ │
│ │ Picture │ │ ↓ │ │
│ │ Recorder │ │ BufferedImage │ │
│ │ ↓ │ │ ↓ │ │
│ │ SVGCanvas │ │ PDFBox │ │
│ │ ↓ │ │ LosslessFactory │ │
│ │ SVG string │ │ ↓ │ │
│ │ ↓ │ │ Embedded image │ │
│ │ SvgToPdf │ │ in PDF │ │
│ │ Converter │ │ │ │
│ │ ↓ │ │ │ │
│ │ PDFBox │ │ │ │
│ │ vector cmds │ │ │ │
│ └──────┬───────┘ └──────────┬────────────┘ │
│ │ │ │
│ └───────────┬───────────┘ │
│ ↓ │
│ PDDocument → ByteArray │
│ + Link annotations │
└─────────────────────────────────────────────────┘
Vector mode in detail
Step 1: Compose to SVG
ComposeToSvg.render() creates a CanvasLayersComposeScene (internal Compose API), renders the composable content, and records all draw commands via Skia’s PictureRecorder. The recorded Picture is then replayed onto Skia’s SVGCanvas, which produces an SVG string.
Key detail: text is emitted as positioned <text> elements (not converted to paths), preserving selectability.
Step 2: SVG to PDF
SvgToPdfConverter parses the SVG XML and dispatches each element to corresponding PDFBox drawing commands:
| SVG element | PDF operation |
|---|---|
<rect> |
Rectangle path (with optional rounded corners) |
<circle>, <ellipse> |
Approximated with 4 cubic Bezier curves |
<line>, <polyline>, <polygon> |
Line segments |
<path> |
Full SVG path parsing (M, L, C, S, Q, T, A, Z commands) |
<text> |
Font resolution + positioned glyphs |
<image> |
Base64 decode + PDFBox image embedding |
<g> |
Transform + opacity state management |
<use> |
Reference resolution from <defs> |
<clipPath> |
PDF clipping regions |
Step 3: Coordinate transform
SVG uses a Y-down coordinate system (origin at top-left). PDF uses Y-up (origin at bottom-left). The converter applies a Y-flip matrix to the entire page, then counter-flips text and images individually so they render right-side up.
Raster mode in detail
ImageComposeScene renders the composable to a Skia bitmap at the configured density. The bitmap is converted to a BufferedImage and embedded as a lossless PDF image via PDFBox’s LosslessFactory.
This mode produces pixel-perfect output but text is not selectable.
Font resolution
Skia’s SVG only carries font names, which is lossy — custom fonts aren’t installed
on the machine, and platform defaults resolve through Skia’s font manager rather than
the filesystem. So while rendering, ComposeFontStack records the composition’s
LocalFontFamilyResolver, giving the converter access to the exact typefaces Compose
laid the text out with. When the converter encounters a <text> element with
font-family, font-weight, and font-style attributes, FontResolver resolves
the font:
1. Bundled fonts (Inter Regular/Bold/Italic/BoldItalic)
↓ not found
2. Captured typefaces (every font Compose loaded — resource/file fonts,
theme typography; rebuilt into embeddable bytes from Skia's table API
by SkiaTypefaceEmbedder)
↓ not found
3. The composition's Skia FontCollection (the shaper's own lookup —
system fonts, glyph-fallback runs)
↓ not found
4. Skia's system FontMgr (family-name verified)
↓ not found
5. Platform font directories
- Exact filename match first
- Fuzzy search up to 3 directory levels
↓ not found
6. PDF Standard 14 fonts (Helvetica, Times, Courier) — glyphs wider than
the shaping font's measured advance are compressed (Tz) so substituted
text can never overlap, and a warning names the unresolved family
Resolved fonts are cached per document (for embedding) and globally (reconstructed
font bytes, file path lookups) using ConcurrentHashMap.
Variable fonts embed at their default instance; instances styled away from default
wght/wdth/slnt/ital axes are rejected, and the filesystem search skips
files with an fvar table entirely.
Link annotations
- During rendering,
PdfRendererprovides aPdfLinkCollectorviaCompositionLocal PdfLinkcomposables useonGloballyPositionedto measure their bounds and recordPdfLinkAnnotationobjects- After page rendering, annotations are converted from SVG coordinates (Y-down) to PDF coordinates (Y-up) and added as
PDAnnotationLinkobjects with invisible borders
Font subsetting
PDFBox’s PDType0Font.load() automatically subsets embedded fonts – only the glyphs actually used on each page are included in the PDF, keeping file sizes small.
Key implementation files
| File | Responsibility |
|---|---|
PdfRenderer.kt |
Orchestrates vector/raster pipelines |
ComposeToSvg.kt |
Compose content -> SVG string |
SvgToPdfConverter.kt |
SVG -> PDFBox vector commands |
SvgPathParser.kt |
Full SVG path data parser |
SvgShapeRenderer.kt |
Ellipse and rounded rect geometry |
SvgColorParser.kt |
CSS/SVG color parsing |
CoordinateTransform.kt |
SVG Y-down <-> PDF Y-up conversion |
FontResolver.kt |
Font family/weight/style -> PDFBox font |
Future: native Skia PDF backend
The current vector pipeline goes through an SVG intermediary: Compose → Skia Picture → SVGCanvas → SVG string → XML parse → PDFBox commands. This works well but introduces inherent limitations — gradients and some visual effects don’t survive the SVG round-trip.
Skiko PR #775 proposes adding a thin wrapper around Skia’s native PDF backend (SkDocument). If merged, this would enable a much simpler pipeline:
Current: Compose → Skia → SVG → parse → PDFBox → PDF
Future: Compose → Skia → PDF (direct)
Benefits of the native PDF backend:
- Full visual fidelity — gradients, shadows, blur, and all Skia effects preserved
- Faster rendering — no SVG serialization/parsing overhead
- Smaller file sizes — Skia’s PDF backend is optimized for compactness
- Simpler codebase — eliminates SvgToPdfConverter, SvgPathParser, SvgColorParser, CoordinateTransform
If native PDF rendering in Compose Desktop matters to you, please upvote JetBrains/skiko#775.
See also
- Vector vs Raster – User-facing comparison
- Supported Features – What works in each mode