- Zig 92.9%
- Python 7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
TableDirectory.parse reinterpreted the caller's bytes as []const TableEntry via @ptrCast/@alignCast. Two problems: - Alignment: font data can be arbitrarily aligned. @embedFile yields alignment 1, so any consumer embedding a .ttf crashed with "incorrect alignment" inside loadFontFamilyFromMemory. Loading from a file happened to work because the read buffer was suitably aligned. - Endianness: table entries are big-endian on disk, so a native-endian struct view would have read garbage. It never did — `find` decodes the fields from `data`, and the slice was only ever used for its length. Store the entry count instead. No behavior change beyond no longer crashing on unaligned input. Found embedding Courier Prime for reveal's PDF export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| examples | ||
| scripts | ||
| src | ||
| test | ||
| tools | ||
| .gitignore | ||
| .gitmodules | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| PERFORMANCE.md | ||
| platen.svg | ||
| README.md | ||
| TODO.md | ||
A fast, self-contained PDF generation library written in Zig. Supports text layout, TrueType/OpenType font embedding, color emoji fonts, images, vector graphics, and PDF/A archival output. Pure Zig — no dependencies at all.
Features
- Text rendering with standard PDF fonts (Helvetica, Times, Courier, Symbol, ZapfDingbats)
- TrueType/OpenType font embedding with automatic subsetting
- Unicode via CID fonts (Latin, Greek, Cyrillic, CJK glyphs, emoji; see Limitations)
- OpenType features: ligatures, kerning, mark positioning, stylistic sets, character variants, and more
- Color fonts: COLR v0/v1 (layered, gradients, sweep gradients), sbix/CBDT/EBDT bitmap strikes, and OT-SVG — full color emoji support
- Text flow: word wrapping, justified text, Knuth-Plass optimal line breaking
- Graphics: lines, rectangles, circles, ellipses, polygons
- Path API: moveTo, lineTo, curveTo, quadTo for custom shapes
- Images: JPEG and PNG embedding (with alpha channel support)
- SVG embedding: shapes, paths, text, gradients, transforms,
<use>/<defs> - Transforms: translate/rotate/scale with graphics-state push/pop; page rotation
- Clipping: rectangle and path clipping
- Gradients: linear and radial
- Transparency: fill/stroke opacity for shapes and fill opacity for text
- Document metadata: title, author, subject, keywords
- Links: internal (TOC navigation) and external (URLs)
- PDF/A conformance: PDF/A-1b and PDF/A-2b archival formats (VeraPDF validated)
- Encryption: AES-256 (PDF 2.0 / security handler R6), password + permissions
- Compression: FlateDecode for smaller file sizes
Performance
Two benchmarks on Apple Silicon (release build), measuring different workloads: one large document, and many small documents.
One large document
110 pages in five 20-page sections — simple text, OpenType shaping (ligatures/kerning), Unicode with diacritics, justified text flow, vector graphics (~1500 shapes/page) — plus 10 pages of color emoji. See the generated document (621 KB). Run it with zig build perf -Doptimize=ReleaseFast.
| Operation | Time |
|---|---|
| Font loading | <1ms |
| Simple text | 2ms |
| OpenType shaping | 12ms |
| Unicode/marks | 6ms |
| Text flow | 2ms |
| Drawing shapes | 13ms |
| Color emoji | 4ms |
| PDF output | 33ms |
| Total | 74ms (~1500 pages/sec) |
Setting doc.compress_threads = 8 compresses streams (page contents, font subsets) on worker threads during output(): PDF output drops to 19ms and the total to ~59ms on the same hardware, with byte-identical output. Requires a thread-safe allocator.
Many small documents (invoice throughput)
1000 unique single-page A4 invoices, generated one complete PDF at a time (single-threaded): 3-10 line items each, embedded Inter regular + bold with full OpenType shaping, the platen wordmark as vector SVG, per-document font subsetting. See a generated sample. Run it with zig build bench-invoice -Doptimize=ReleaseFast.
| Mode | Latency | Throughput |
|---|---|---|
| Uncompressed, 1 thread | 0.50ms | ~2000 PDFs/sec |
| Uncompressed, 1 thread, shared assets | 0.32ms | ~3100 PDFs/sec |
| Uncompressed, 8 threads | 0.11ms | ~9100 PDFs/sec |
| Uncompressed, 8 threads, shared assets | 0.05ms | ~19000 PDFs/sec |
| Compressed (level 1), 1 thread | 0.94ms | ~1060 PDFs/sec |
By default every document pays the full stateless cost: fonts are re-parsed, shaped, and subset per PDF, and the SVG is re-parsed per PDF. Shared assets (--shared in the benchmark) parse fonts and SVGs once via caller-owned FontFace/Svg and reuse them across documents and threads — output stays byte-identical.
Because documents share no state, they can be rendered concurrently without locking — one Document per thread scales close to linearly across cores (--threads=N in the benchmark). A single Document is not thread-safe; keep each document on one thread.
See PERFORMANCE.md for optimization details.
Quick Start
const std = @import("std");
const platen = @import("platen");
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
// Create document. `init.io` is used for any file I/O the document
// performs (loadFont/loadImage/loadSvg). In tests, pass `std.testing.io`.
var doc = platen.Document.init(allocator, init.io);
defer doc.deinit();
// Add a page and write text
try doc.addPage(.{});
const style = platen.TextStyle{ .font = platen.Font.helvetica, .size = 24 };
_ = try doc.writeAt(.{ .x = 50, .y = 50 }, "Hello, World!", style, .{});
// Save to file
const pdf_data = try doc.output();
defer allocator.free(pdf_data);
try std.Io.Dir.cwd().writeFile(init.io, .{ .sub_path = "hello.pdf", .data = pdf_data });
}
Coordinates & anchoring
All coordinates are in points (1/72 inch) with the origin at the top-left of the page, y growing downward. Anchoring differs by content type, following typographic convention:
- Text (
writeAt):yis the baseline of the text;opts.anchorchooses which part of the text sits atx(.leftdefault,.center,.right- so right-aligning a value at a column edge isdoc.writeAt(.{ .x = right_edge, .y = y }, text, style, .{ .anchor = .right })). - Shapes and images (
drawRect,drawImage,drawSvg, ...):rect.yis the top edge. - Regions (
write,writeBlock,writeStyled):yis the top of the region; the baseline of the first line is placed from font metrics according tovalign.
Installation
Add platen as a dependency in your build.zig.zon:
.dependencies = .{
.platen = .{
.url = "https://git.urverk.org/urverk/platen/archive/main.tar.gz",
.hash = "...",
},
},
Versioned release tags will return with 1.0.0; until then, track main.
Then in your build.zig:
const platen_dep = b.dependency("platen", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("platen", platen_dep.module("platen"));
Or clone and build directly:
git clone https://git.urverk.org/urverk/platen.git
cd platen
zig build # Build CLI tool
zig build test # Run tests
Usage Examples
Text with Multiple Fonts
try doc.addPage(.{});
const title = platen.TextStyle{ .font = platen.Font.helvetica, .size = 16, .bold = true };
const body = platen.TextStyle{ .font = platen.Font.times, .size = 12 };
const code = platen.TextStyle{ .font = platen.Font.courier, .size = 10 };
_ = try doc.writeAt(.{ .x = 50, .y = 50 }, "Title", title, .{});
_ = try doc.writeAt(.{ .x = 50, .y = 80 }, "Body text in Times Roman", body, .{});
_ = try doc.writeAt(.{ .x = 50, .y = 100 }, "Monospace code", code, .{});
Custom Fonts (TrueType/OpenType)
For fonts used by a single document. (To reuse a parsed font across many documents or threads, see Font faces.)
// Load a TTF or OTF font (default: identity/Unicode encoding)
const font = try doc.loadFont("fonts/MyFont.ttf", .identity);
// Or specify encoding explicitly
const latin_font = try doc.loadFont("fonts/Latin.ttf", .win_ansi);
try doc.addPage(.{});
const style = platen.TextStyle{ .font = font, .size = 14 };
_ = try doc.writeAt(.{ .x = 50, .y = 50 }, "Custom font with Unicode: 日本語 🎉", style, .{});
Font Families (Bold/Italic Variants)
// Load a font family with all variants
const gentium = try doc.loadFontFamily(.{
.regular = "fonts/Gentium-Regular.ttf",
.bold = "fonts/Gentium-Bold.ttf",
.italic = "fonts/Gentium-Italic.ttf",
.bold_italic = "fonts/Gentium-BoldItalic.ttf",
});
try doc.addPage(.{});
// Use bold/italic flags - library selects correct variant automatically
const body = platen.TextStyle{ .font = gentium, .size = 12 };
const emphasis = platen.TextStyle{ .font = gentium, .size = 12, .italic = true };
const strong = platen.TextStyle{ .font = gentium, .size = 12, .bold = true };
const strong_emphasis = platen.TextStyle{ .font = gentium, .size = 12, .bold = true, .italic = true };
_ = try doc.writeAt(.{ .x = 50, .y = 50 }, "Regular text", body, .{});
_ = try doc.writeAt(.{ .x = 50, .y = 70 }, "Italic text", emphasis, .{});
_ = try doc.writeAt(.{ .x = 50, .y = 90 }, "Bold text", strong, .{});
_ = try doc.writeAt(.{ .x = 50, .y = 110 }, "Bold italic text", strong_emphasis, .{});
Text Flow with Word Wrapping
try doc.addPage(.{});
// Define a region for text
var region = platen.Region{
.x = 72,
.y = 72,
.width = 468, // page width minus margins
.height = 700,
};
const text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit...";
const style = platen.TextStyle{ .font = platen.Font.helvetica, .size = 11 };
// Incremental text with word wrapping (cursor advances on same line)
const result = try doc.write(®ion, text, style, .{});
if (result.overflow) |_| {
// Handle text that didn't fit
try doc.addPage(.{});
// ... continue with remaining text
}
// Block-based text with alignment (cursor moves to next line)
_ = try doc.writeBlock(®ion, "Centered heading", style, .{ .alignment = .center });
_ = try doc.writeBlock(®ion, "Right-aligned text", style, .{ .alignment = .right });
// Styled text for mixed formatting within a paragraph
var st = doc.createStyledText();
defer st.deinit();
try st.append("Normal text ", .{});
try st.append("bold text ", .{ .bold = true });
try st.append("colored text", .{ .color = platen.Color.blue });
_ = try doc.writeStyled(®ion, &st, style, .{ .alignment = .justify });
Optical Top Alignment
The default .top vertical alignment uses cap-height optical alignment — capital letters touch the top edge of the region, using real font metrics from the OS/2 table. This produces tighter, more visually precise layouts than the traditional half-leading approach.
// Default: cap-height aligned (capitals touch top edge)
_ = try doc.write(®ion, text, style, .{ .valign = .top });
// Ascender aligned (b, d, l tops touch top edge)
_ = try doc.write(®ion, text, style, .{ .valign = .top_ascent });
// Legacy half-leading (includes line-height whitespace above glyphs)
_ = try doc.write(®ion, text, style, .{ .valign = .top_leading });
Measure Text Before Rendering
Use write() or writeStyled() with .measure_only = true to measure without rendering.
Measurement is side-effect free - the region's cursor is unchanged after the call.
var region = platen.Region{
.x = 50,
.y = 100,
.width = 200,
.height = 10000, // Large height for measurement
.padding = .{ .left = 8, .right = 8, .top = 8, .bottom = 8 },
};
// Define text style
const style = platen.TextStyle{ .font = platen.Font.helvetica, .size = 11 };
const line_height = style.size * 1.2; // Default line height multiplier
// Measure plain text (cursor unchanged after this call)
const result = try doc.write(®ion, text, style, .{ .measure_only = true });
// Or measure styled text
var styled = doc.createStyledText();
try styled.append("Hello ", .{});
try styled.append("World", .{ .color = platen.Color.blue });
const styled_result = try doc.writeStyled(®ion, &styled, style, .{ .measure_only = true });
// Calculate height from lines_written
const content_height = @as(f64, @floatFromInt(result.lines_written)) * line_height;
const box_height = content_height + 16; // Add padding
// Render (no need to reset cursor - measure_only didn't change it)
if (box_height <= region.remainingHeight()) {
_ = try doc.write(®ion, text, style, .{});
}
Colors
// Named color presets
const color1 = platen.Color.red;
const color2 = platen.Color.navy;
const color3 = platen.Color.coral;
// From RGB (0-255 range) - the common case
const color4 = platen.Color.rgb(128, 64, 192);
// From RGB floats (0.0-1.0 range)
const color5 = platen.Color.rgbf(0.5, 0.25, 0.75);
// From hex string
const color6 = platen.Color.hex("#FF6600").?;
const color7 = platen.Color.hex("F60").?; // short form
// Greyscale (0 = black, 1 = white)
const color8 = platen.Color.greyscale(0.5);
// CMYK for print
const color9 = platen.Color.cmyk(1, 0, 0, 0); // cyan
Available presets: black, white, red, green, blue, cyan, magenta, yellow, orange, pink, purple, brown, navy, teal, coral, salmon, gold, silver, maroon, olive, lime, indigo, violet, turquoise, skyblue, forestgreen, and many more.
Drawing Shapes
try doc.addPage(.{});
// Line with explicit style
try doc.drawLine(
.{ .x = 50, .y = 100 },
.{ .x = 200, .y = 100 },
.{ .color = platen.Color.black, .width = 1.5 },
);
// Rectangle with stroke and fill
try doc.drawRect(
.{ .x = 50, .y = 120, .width = 100, .height = 50 },
.{
.stroke = .{ .color = platen.Color.black },
.fill = .{ .color = platen.Color.lightgray },
},
);
// Rounded rectangle (stroke only)
try doc.drawRoundedRect(
.{ .x = 50, .y = 180, .width = 100, .height = 50 },
10, // corner radius
.{ .stroke = .{ .color = platen.Color.blue, .width = 2 } },
);
// Circle (fill only)
try doc.drawCircle(.{ .x = 200, .y = 200 }, 30, .{ .fill = .{ .color = platen.Color.red } });
// Polygon
const points = [_]platen.Point{
.{ .x = 300, .y = 150 },
.{ .x = 350, .y = 200 },
.{ .x = 250, .y = 200 },
};
try doc.drawPolygon(&points, .{ .stroke = .{}, .fill = .{} });
Path API
// Create a custom path
var path = doc.createPath();
defer path.deinit();
try path.moveTo(.{ .x = 100, .y = 100 });
try path.lineTo(.{ .x = 200, .y = 100 });
try path.curveTo(
.{ .x = 250, .y = 100 }, // control point 1
.{ .x = 250, .y = 200 }, // control point 2
.{ .x = 200, .y = 200 }, // end point
);
try path.lineTo(.{ .x = 100, .y = 200 });
try path.closePath();
try doc.drawPath(&path, .{
.stroke = .{ .color = platen.Color.blue },
.fill = .{ .color = platen.Color.lightblue },
});
Images
// Load JPEG or PNG
const img = try doc.loadImage("photo.jpg");
try doc.addPage(.{});
// Draw at position with explicit size
try doc.drawImage(img, .{ .x = 50, .y = 50, .width = 200, .height = 150 });
// Auto aspect ratio (omit width or height for auto-scale)
try doc.drawImage(img, .{ .x = 50, .y = 250, .width = 200 }); // height auto
SVG Embedding
// Load SVG file
const svg = try doc.loadSvg("diagram.svg");
try doc.addPage(.{});
// Draw at original size (omit width/height)
try doc.drawSvg(svg, .{ .x = 50, .y = 50 });
// Scaled
try doc.drawSvg(svg, .{ .x = 300, .y = 50, .width = 150, .height = 150 });
Gradients
// Define color stops
const stops = [_]platen.ColorStop{
.{ .offset = 0.0, .color = platen.Color.red },
.{ .offset = 1.0, .color = platen.Color.blue },
};
// Linear gradient (from point to point)
const linear = platen.LinearGradient{
.x0 = 50, .y0 = 50, // start point
.x1 = 250, .y1 = 50, // end point
.stops = &stops,
};
const linear_idx = try doc.addLinearGradient(linear);
try doc.fillRectGradient(50, 50, 200, 100, linear_idx);
// Radial gradient (from inner circle to outer circle)
const radial_stops = [_]platen.ColorStop{
.{ .offset = 0.0, .color = platen.Color.yellow },
.{ .offset = 1.0, .color = platen.Color.red },
};
const radial = platen.RadialGradient{
.x0 = 150, .y0 = 250, .r0 = 0, // inner circle (center, radius 0)
.x1 = 150, .y1 = 250, .r1 = 50, // outer circle (same center, radius 50)
.stops = &radial_stops,
};
const radial_idx = try doc.addRadialGradient(radial);
try doc.fillRectGradient(100, 200, 100, 100, radial_idx);
Page Sizes
// Available sizes: A3, A4, A5, Letter, Legal, Tabloid
// Default is A4 with 72pt margins
// Add page with defaults (A4, 72pt margins)
try doc.addPage(.{});
// Add page with specific size
try doc.addPage(.{ .size = platen.PageSize.Letter });
// Add page with size and custom margins
try doc.addPage(.{
.size = platen.PageSize.A4,
.margins = .{ .left = 50, .right = 50, .top = 72, .bottom = 72 },
});
// Landscape orientation
try doc.addPage(.{ .size = platen.PageSize.Letter.landscape() });
// Custom size
try doc.addPage(.{ .size = .{ .width = 500, .height = 700 } });
// Page navigation (for TOC, footers, etc.)
const total = doc.pageCount();
doc.selectPage(0); // go back to first page
try doc.insertPage(0, .{}); // insert page at beginning
Document Metadata
// Set document properties (all fields optional)
doc.setMetadata(.{
.title = "Annual Report 2026",
.author = "Finance Department",
.subject = "Q4 Financial Results",
.keywords = "finance, report, 2026, quarterly",
.creator = "MyApp v1.0",
// .producer defaults to "platen"
// .creation_date and .mod_date auto-generated if not set
});
Links
try doc.addPage(.{});
const body = platen.TextStyle{ .font = platen.Font.helvetica, .size = 12 };
const heading = platen.TextStyle{ .font = platen.Font.helvetica, .size = 16, .bold = true };
// Write text and get its bounding rectangle
const rect = try doc.writeAt(.{ .x = 50, .y = 50 }, "Click here for Zig documentation", body, .{});
// Add external link (URL)
try doc.addExternalLink(rect, "https://ziglang.org/documentation/");
// Internal links for TOC navigation
const ch1_result = try doc.writeAt(.{ .x = 50, .y = 100 }, "Chapter 1: Introduction", heading, .{});
const chapter1_dest = doc.destination(ch1_result.y).?; // save this location
// Later, create a link to that chapter
try doc.addPage(.{});
const toc_rect = try doc.writeAt(.{ .x = 50, .y = 50 }, "Go to Chapter 1", body, .{});
try doc.addLink(toc_rect, chapter1_dest);
Table of Contents (Outline)
// Create outline builder
var outline = doc.createOutline();
// Add top-level items
const ch1 = try outline.add("Chapter 1: Introduction", doc.destination(0).?);
const ch2 = try outline.add("Chapter 2: Details", doc.destination(100).?);
// Add nested items
_ = try outline.addChild(ch1, "Section 1.1: Background", doc.destination(50).?);
_ = try outline.addChild(ch1, "Section 1.2: Overview", doc.destination(75).?);
// Set the outline on the document
doc.setOutline(&outline);
PDF/A Archival Format
var doc = platen.Document.init(allocator, init.io);
defer doc.deinit();
// Enable PDF/A-1b conformance (archival format)
doc.setConformance(.pdfa_1b);
// Set required metadata
doc.setMetadata(.{
.title = "Archival Document",
.author = "Organization Name",
});
// Add content as usual
try doc.addPage(.{});
const style = platen.TextStyle{ .font = platen.Font.helvetica, .size = 12 };
_ = try doc.writeAt(.{ .x = 50, .y = 50 }, "This document conforms to PDF/A-1b.", style, .{});
// Output includes XMP metadata, ICC color profile, and PDF/A identification
const pdf_data = try doc.output();
Available conformance levels:
.pdfa_1b- PDF/A-1b (ISO 19005-1): No transparency, all fonts embedded.pdfa_2b- PDF/A-2b (ISO 19005-2): Allows transparency, JPEG2000, layers
Note: PDF/A-1b rejects transparency (opacity < 1.0) with error.TransparencyNotAllowed.
Encryption
AES-256 encryption (PDF 2.0, security-handler revision 6):
doc.encryption = .{
.user_password = "open-me", // "" = encrypted at rest, opens without a prompt
.owner_password = "full-control", // defaults to user_password
.permissions = .{ .printing = false, .copying = false },
};
All content streams - page contents, fonts, images - are encrypted with AES-256-CBC. Notes:
- Forces PDF 2.0; rejected under PDF/A (
error.EncryptionNotAllowedInPdfA). - Metadata strings (title, author) are not encrypted in this version
(
/StrF /Identity); only content streams are. - Permissions are advisory - viewers honor them; only the user password gates decryption.
- Each document pays a fixed ~5ms password-hardening cost (the R6 key
derivation, by design); content encryption itself is negligible. Set
doc.rngto a seededstd.Randomfor deterministic output in tests.
Font faces (parse once, use anywhere)
FontFace is a parsed font file — caller-owned and shareable, exactly
like Image and Svg. Create it once and reference it from any number
of documents on any threads; each document transparently keeps a private
clone for its subsetting state. There is no per-document load call:
// Once, at startup:
var inter = try platen.FontFace.fromFile(io, allocator, "Inter-Regular.ttf");
defer inter.deinit();
var inter_bold = try platen.FontFace.fromFile(io, allocator, "Inter-Bold.ttf");
defer inter_bold.deinit();
// Optional configuration - before first use (frozen afterwards):
try inter.setFeature(.dlig, true); // default feature flags
try inter.warmup("Common text, pre-fills kerning"); // perf only
// Per document (any thread) - just reference the faces in styles:
const body = platen.TextStyle{ .font = .{ .face = &inter }, .size = 11 };
const strong = platen.TextStyle{
.font = .{ .family = .{ .regular = &inter, .bold = &inter_bold } },
.size = 11,
.bold = true, // resolves the bold face
};
The three font concepts: standard fonts (platen.Font.helvetica,
built into every viewer), a FontFace (one custom font file), and a
FontFamily (a set of faces so .bold/.italic pick a variant).
Caller-owned images and SVGs
Like FontFace, images and SVGs can be created directly and shared
across any number of documents and threads - decode once, draw
everywhere. One deinit() call frees them; call it after every document
that used the asset has been deinitialized:
const logo = try platen.Image.fromMemory(allocator, png_bytes); // borrows bytes
defer logo.deinit();
const art = try platen.Svg.fromMemory(allocator, svg_text); // copies
defer art.deinit();
// Any document, any thread - drawImage/drawSvg register them as borrowed:
try doc_a.drawImage(logo, .{ .x = 50, .y = 50, .width = 100, .height = 0 });
try doc_b.drawSvg(art, .{ .x = 50, .y = 200, .width = 80, .height = 80 });
The ownership rule is uniform across all asset types: anything obtained
from a Document (doc.loadFont, doc.loadImage, doc.loadSvg) lives and
dies with that Document — using it in another document is an error
(ForeignFont/ForeignImage/ForeignSvg). Anything you create yourself
(FontFace.fromMemory, Image.fromMemory, Svg.fromMemory) is yours and
can be used in any number of documents, on any threads; you manage its
lifetime.
The Foreign* errors catch logic errors while everything is alive.
They cannot catch lifetime errors: a pointer used after its owner was
deinitialized is dangling, and dereferencing it is undefined behavior
before any check could run - standard raw-pointer rules, same as any
freed pointer in Zig. Keep assets alive as long as anything references
them.
Transforms & Clipping
// Diagonal watermark across the page
try doc.pushState();
try doc.rotateAt(.{ .x = 297, .y = 420 }, -45); // degrees, positive = clockwise
_ = try doc.writeAt(.{ .x = 297, .y = 420 }, "DRAFT", watermark_style, .{ .anchor = .center });
try doc.popState();
// Rotate a shape 30 degrees about its center
try doc.pushState();
try doc.rotateAt(.{ .x = 160, .y = 130 }, 30);
try doc.drawRect(.{ .x = 100, .y = 100, .width = 120, .height = 60 }, .{ .stroke = .{} });
try doc.popState();
// Clip drawing to a rectangle
try doc.pushState();
try doc.clipRect(.{ .x = 100, .y = 520, .width = 100, .height = 60 });
try doc.drawCircle(.{ .x = 150, .y = 550 }, 50, .{ .fill = .{ .color = platen.Color.orange } });
try doc.popState();
// Rotate the whole page for the viewer
try doc.addPage(.{ .rotation = .r90 });
Transforms compose in call order (like Canvas/Cairo) and apply to everything
drawn while active - text, shapes, images, SVGs. Every pushState must be
balanced by popState before output().
API Reference
Document Lifecycle
| Method | Description |
|---|---|
Document.init(allocator, io) |
Create a new PDF document. io is std.Io (use init.io from main, or std.testing.io in tests) |
deinit() |
Free all document resources |
output() -> []u8 |
Generate PDF output as bytes |
Pages & Layout
| Method | Description |
|---|---|
addPage(PageOptions) |
Add a new page with size and margins |
insertPage(index, PageOptions) |
Insert page at index (shifts others) |
selectPage(index) |
Select page for subsequent operations |
pageCount() -> usize |
Get total number of pages |
getPageRegion() -> ?Region |
Get drawable region for current page |
getPageIndex() -> usize |
Get current page index (0-based) |
setMetadata(DocumentMetadata) |
Set title, author, subject, keywords, etc. |
Fonts (document-owned)
Methods on Document; the returned Font lives and dies with this
document. For fonts shared across documents, see
Caller-owned assets below.
| Method | Description |
|---|---|
loadFont(path, encoding) -> Font |
Load TTF/OTF font from file (.identity = Unicode, .win_ansi = legacy) |
loadFontFromMemory(data, encoding) -> Font |
Load TTF/OTF font from memory buffer (borrows data) |
loadFontFamily(FontFamilyOptions) -> Font |
Load font family from files |
loadFontFamilyFromMemory(FontFamilyMemoryOptions) -> Font |
Load font family from memory buffers |
setFeature(Feature, bool) |
Set an OpenType feature for all fonts in this document (current and future) |
getFontMetricsStyled(TextStyle) -> FontMetrics |
Get metrics (ascender, descender, etc.) for a style |
Text Output
| Method | Description |
|---|---|
writeAt(Point, str, TextStyle, WriteAtOptions) -> Rect |
Write text at absolute position |
write(*Region, str, TextStyle, WriteOptions) -> WriteResult |
Incremental write with word wrapping (cursor advances) |
writeBlock(*Region, str, TextStyle, BlockOptions) -> WriteResult |
Wrapped block with alignment (cursor to next line) |
writeStyled(*Region, *StyledText, TextStyle, ParagraphOptions) -> WriteResult |
Write styled text with wrapping and alignment |
createStyledText() -> StyledText |
Create builder for mixed formatting (bold, colors, etc.) |
Text Measurement
| Method | Description |
|---|---|
write(*Region, str, style, .{ .measure_only = true }) |
Measure layout without rendering (cursor unchanged) |
writeBlock(*Region, str, style, .{ .measure_only = true }) |
Measure aligned layout without rendering |
writeStyled(*Region, *StyledText, style, .{ .measure_only = true }) |
Measure styled layout without rendering |
Text Styling
Text styling is done via TextStyle struct passed to text methods:
const style = platen.TextStyle{
.font = platen.Font.helvetica, // or .times, .courier, or embedded font
.size = 12,
.bold = true,
.italic = false,
.color = platen.Color.navy,
.decoration = .underline,
.opacity = 0.5, // fill alpha: 0.0 transparent .. 1.0 opaque
};
opacity (default 1.0) applies a non-stroking alpha to the glyphs and their
decorations, using the same transparency mechanism as shapes. Values below 1.0
require transparency support and are rejected by PDF/A-1b
(error.TransparencyNotAllowed).
For mixed styles within a paragraph, use StyledText:
var st = doc.createStyledText();
try st.append("Normal ", .{});
try st.append("bold ", .{ .bold = true });
try st.append("red", .{ .color = platen.Color.red });
Navigation & Links
| Method | Description |
|---|---|
destination(y) -> ?Destination |
Get destination at y position on current page |
addLink(Rect, Destination) |
Add internal link (jumps to page/position) |
addExternalLink(Rect, url) |
Add external link (opens URL) |
createOutline() -> Outline |
Create outline builder for table of contents |
setOutline(*Outline) |
Set the document outline (takes ownership) |
setPageLabel(page_index, PageLabel) |
Set page numbering style |
Images & SVG (document-owned)
Methods on Document; loaded assets live and die with this document.
drawImage/drawSvg also accept caller-owned assets (below).
| Method | Description |
|---|---|
loadImage(path) -> *Image |
Load JPEG or PNG image from file |
loadImageFromMemory(data) -> *Image |
Load image from memory buffer (borrows data) |
loadSvg(path) -> *Svg |
Load SVG from file |
loadSvgFromMemory(data) -> *Svg |
Load SVG from memory buffer |
drawImage(*Image, Rect) |
Draw image (omit width/height for auto-scale) |
drawSvg(*Svg, Rect) |
Draw SVG (omit width/height for original size) |
Caller-owned assets
Created by you, not by a document: shareable across any number of
documents and threads, freed by you with a single deinit() after every
document that used them is gone. Using a document-owned asset in
another document errors (ForeignFont/ForeignImage/ForeignSvg).
| Function | Description |
|---|---|
FontFace.fromMemory(alloc, bytes) -> FontFace |
Parse a font face (borrows bytes; identity encoding) |
FontFace.fromFile(io, alloc, path) -> FontFace |
Parse a font face from a file (owns the bytes) |
FontFace.setFeature(Feature, bool) |
Default OpenType flags for this face (before first use; FaceFrozen after) |
FontFace.warmup(text) |
Optional: pre-fill the kerning cache clones inherit (before first use) |
FontFace.deinit() |
Free the face (after all documents using it are deinitialized) |
Image.fromMemory(alloc, bytes) -> *Image |
Decode an image once (borrows bytes) |
Svg.fromMemory(alloc, text) -> *Svg |
Parse an SVG once (copies the text) |
Image.deinit() / Svg.deinit() |
Free the asset (single call) |
Faces are used directly in styles - no per-document call:
.font = .{ .face = &face } or
.font = .{ .family = .{ .regular = &r, .bold = &b } }.
Drawing Shapes
| Method | Description |
|---|---|
drawLine(Point, Point, StrokeStyle) |
Draw a line |
drawRect(Rect, ShapeStyle) |
Draw rectangle |
drawRoundedRect(Rect, radius, ShapeStyle) |
Draw rounded rectangle |
drawCircle(Point, radius, ShapeStyle) |
Draw circle (center + radius) |
drawEllipse(Rect, ShapeStyle) |
Draw ellipse (bounding box) |
drawPolygon([]Point, ShapeStyle) |
Draw closed polygon |
drawRegion(Region) |
Draw region border and background |
Style types:
StrokeStyle- color, width, cap, join, dashFillStyle- colorShapeStyle- optional stroke + optional fill
Path API
| Method | Description |
|---|---|
createPath() -> Path |
Create path builder |
drawPath(*Path, ShapeStyle) |
Render path |
pushState() / popState() |
Save/restore graphics state (transform + clip); must balance |
translate(dx, dy) |
Translate subsequent content (user space, y down) |
rotateAt(center, degrees) |
Rotate about a point; positive = clockwise |
scaleAt(center, sx, sy) |
Scale about a point |
clipRect(Rect) / clipPath(*Path) |
Clip subsequent content until state pop |
Path methods:
| Method | Description |
|---|---|
moveTo(Point) |
Move without drawing |
lineTo(Point) |
Line to point |
curveTo(cp1, cp2, end) |
Cubic Bezier curve (3 Points) |
quadTo(cp, end) |
Quadratic Bezier curve (2 Points) |
closePath() |
Close subpath |
deinit() |
Free path resources |
Gradients
| Method | Description |
|---|---|
addLinearGradient(LinearGradient) -> usize |
Register linear gradient |
addRadialGradient(RadialGradient) -> usize |
Register radial gradient |
fillRectGradient(Rect, gradient_idx) |
Fill rect with gradient |
StyledText Builder
Create with doc.createStyledText(), then:
| Method | Description |
|---|---|
append(text, TextStyleOptions) |
Add text span with style |
isEmpty() -> bool |
Check if empty |
totalLength() -> usize |
Get total text length |
deinit() |
Free resources |
Unit Conversions
All measurements in platen are in points (1 pt = 1/72 inch).
const x = platen.inches(1.5); // 108 points
const y = platen.mm(25.4); // 72 points (1 inch)
const z = platen.cm(2.54); // 72 points (1 inch)
// Constants
platen.pt_per_inch // 72.0
platen.pt_per_mm // 2.834...
platen.pt_per_cm // 28.34...
Types Reference
Page & Layout:
PageSize- Predefined sizes:.A3,.A4,.A5,.Letter,.Legal,.Tabloid(use.landscape()for landscape)Region- Drawable area with position, size, and remaining height trackingRect- Simple rectangle (x, y, width, height)Point- 2D point (x, y)Margins/Padding- Edge spacing (top, right, bottom, left)
Text:
Align- Horizontal:.left,.center,.right,.justifyVAlign- Vertical:.top(optical cap-height),.top_ascent,.top_leading,.middle,.bottomTextDecoration-.none,.underline,.strikethrough,.underline_strikethroughLineBreaking-.greedy(fast) or.optimal(Knuth-Plass for justified text)WriteOptions/LineOptions/ParagraphOptions- Text layout optionsWriteResult- Result with lines_written, overflow text, and bounding rectTextMetrics/FontMetrics- Measurement results
Fonts:
Font- Union type:.family(FontFamily),.symbol,.zapf_dingbats,.embedded(*EmbeddedFont),.embedded_family(*EmbeddedFontFamily)Font.helvetica,Font.times,Font.courier- Convenience constants for standard font familiesFontFamily-.helvetica,.times,.courier(resolved to bold/italic via TextStyle)StandardFont-.helvetica,.helvetica_bold,.helvetica_italic,.helvetica_bold_italic,.times_roman,.times_bold,.times_italic,.times_bold_italic,.courier,.courier_bold,.courier_italic,.courier_bold_italic,.symbol,.zapfdingbatsEmbeddedFont- Loaded TTF/OTF fontEmbeddedFontFamily- Font family with regular/bold/italic/bold-italic variants (fromloadFontFamily)TextStyle- Complete text style (font, size, bold, italic, color, decoration)
Graphics:
StrokeStyle- Stroke settings (color, width, cap, join, dash)FillStyle- Fill settings (color)ShapeStyle- Combined stroke and/or fill (.{ .stroke = .{} },.{ .fill = .{} },.{ .stroke = .{}, .fill = .{} })LineCap-.butt,.round,.squareLineJoin-.miter,.round,.bevelDashPattern- Line dash pattern (dash, gap, phase)Point- 2D point (x, y coordinates)Rect- Rectangle (x, y, width, height)Color- RGB/CMYK/grayscale color (see Colors section)
Gradients:
LinearGradient- Axial gradient between two pointsRadialGradient- Circular gradient between two circlesColorStop- Color at position (offset 0.0-1.0, color)
Navigation:
Destination- Page location for internal linksOutline- Table of contents builder (add(), addChild())OutlineItem- Individual outline entryPageLabel/PageLabelStyle- Page numbering (roman, arabic, etc.)
OpenType Features:
GSUB (Glyph Substitution):
liga- Standard ligatures (fi, fl, ff, etc.) - enabled by defaultclig- Contextual ligaturesdlig- Discretionary ligatureshlig- Historical ligaturescalt- Contextual alternates (e.g., -> to arrow) - enabled by defaulttnum- Tabular figures (monospaced numbers)pnum- Proportional figuresfrac- Fractions (1/2 to ½)zero- Slashed zerocase- Case-sensitive formsss01-ss20- Stylistic sets 1-20cv01-cv99- Character variants 1-99
GPOS (Glyph Positioning):
kern- Kerning - enabled by defaultmark- Mark positioning (diacritics on base glyphs) - enabled by defaultmkmk- Mark-to-mark positioning (stacked diacritics) - enabled by default
// Enable/disable features on embedded fonts
const font = try doc.loadFont("fonts/Inter.ttf", .identity);
font.embedded.setFeature(.dlig, true); // Enable discretionary ligatures
font.embedded.setFeature(.ss01, true); // Enable stylistic set 1
font.embedded.setFeature(.cv11, true); // Enable character variant 11
font.embedded.setFeature(.liga, false); // Disable standard ligatures
Limitations
Known gaps, documented so you don't discover them the hard way:
- No bidirectional text or joining scripts. Arabic, Hebrew, and other RTL scripts render in logical (wrong visual) order, and Arabic contextual forms (init/medi/fina) are not applied.
- No CJK line breaking. CJK glyphs render correctly, but flowed text
(
write,writeBlock) only breaks at spaces, so long CJK runs will not wrap. - No interactive features. AcroForm fields, digital signatures, and file attachments are out of scope for 1.0.
Requirements
- Zig 0.16+
- No dependencies — compression uses
std.compress.flate
License
MIT