A fast, stateless PDF generation library for Zig.
  • Zig 92.9%
  • Python 7.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 54a4b610a3 README: point install URLs at git.urverk.org
Upstream moved off Codeberg to the self-hosted Forgejo instance. Forgejo
uses the same URL layout, so the archive and clone paths are unchanged
apart from the host and org.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 23:46:38 +02:00
examples Breaking API: writeLine -> writeBlock, writeAt anchor + honest Rect 2026-07-20 16:43:15 +02:00
scripts Validate encrypted PDFs against qpdf inside the test suite 2026-07-21 17:28:16 +02:00
src Validate encrypted PDFs against qpdf inside the test suite 2026-07-21 17:28:16 +02:00
test Make clipPath test scenario self-explanatory 2026-07-20 17:43:16 +02:00
tools Remove auto-generated documentation 2026-01-21 16:54:44 +01:00
.gitignore Add invoice throughput benchmark with threaded mode 2026-07-20 15:47:07 +02:00
.gitmodules Initial commit: zpdf skeleton with dev tools 2026-01-14 15:19:20 +01:00
build.zig Hygiene + pre-1.0 features: transforms, clipping, limitations docs 2026-07-20 17:08:41 +02:00
build.zig.zon Pre-1.0 hardening: perf, concurrency, correctness fixes 2026-07-20 15:47:29 +02:00
CLAUDE.md Rename library: zapdf -> platen 2026-07-20 10:10:27 +02:00
context.md Update context.md: 0.9.0 hold decision, prune old session logs 2026-07-21 09:13:22 +02:00
DESIGN.md Validate encrypted PDFs against qpdf inside the test suite 2026-07-21 17:28:16 +02:00
LICENSE Added license 2026-02-04 22:40:15 +01:00
PERFORMANCE.md PERFORMANCE.md: present-tense executive summary, dated history 2026-07-21 09:23:35 +02:00
platen.svg Logo: convert wordmark text to outlines 2026-07-20 11:26:42 +02:00
README.md README: point install URLs at git.urverk.org 2026-07-24 23:46:38 +02:00
TODO.md Add AES-256 (R6) PDF encryption 2026-07-21 13:58:20 +02:00

platen

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): y is the baseline of the text; opts.anchor chooses which part of the text sits at x (.left default, .center, .right - so right-aligning a value at a column edge is doc.writeAt(.{ .x = right_edge, .y = y }, text, style, .{ .anchor = .right })).
  • Shapes and images (drawRect, drawImage, drawSvg, ...): rect.y is the top edge.
  • Regions (write, writeBlock, writeStyled): y is the top of the region; the baseline of the first line is placed from font metrics according to valign.

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(&region, 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(&region, "Centered heading", style, .{ .alignment = .center });
_ = try doc.writeBlock(&region, "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(&region, &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(&region, text, style, .{ .valign = .top });

// Ascender aligned (b, d, l tops touch top edge)
_ = try doc.write(&region, text, style, .{ .valign = .top_ascent });

// Legacy half-leading (includes line-height whitespace above glyphs)
_ = try doc.write(&region, 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(&region, 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(&region, &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(&region, 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
});
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.rng to a seeded std.Random for 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 });
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, dash
  • FillStyle - color
  • ShapeStyle - 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 tracking
  • Rect - 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, .justify
  • VAlign - Vertical: .top (optical cap-height), .top_ascent, .top_leading, .middle, .bottom
  • TextDecoration - .none, .underline, .strikethrough, .underline_strikethrough
  • LineBreaking - .greedy (fast) or .optimal (Knuth-Plass for justified text)
  • WriteOptions / LineOptions / ParagraphOptions - Text layout options
  • WriteResult - Result with lines_written, overflow text, and bounding rect
  • TextMetrics / 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 families
  • FontFamily - .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, .zapfdingbats
  • EmbeddedFont - Loaded TTF/OTF font
  • EmbeddedFontFamily - Font family with regular/bold/italic/bold-italic variants (from loadFontFamily)
  • 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, .square
  • LineJoin - .miter, .round, .bevel
  • DashPattern - 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 points
  • RadialGradient - Circular gradient between two circles
  • ColorStop - Color at position (offset 0.0-1.0, color)

Navigation:

  • Destination - Page location for internal links
  • Outline - Table of contents builder (add(), addChild())
  • OutlineItem - Individual outline entry
  • PageLabel / PageLabelStyle - Page numbering (roman, arabic, etc.)

OpenType Features:

GSUB (Glyph Substitution):

  • liga - Standard ligatures (fi, fl, ff, etc.) - enabled by default
  • clig - Contextual ligatures
  • dlig - Discretionary ligatures
  • hlig - Historical ligatures
  • calt - Contextual alternates (e.g., -> to arrow) - enabled by default
  • tnum - Tabular figures (monospaced numbers)
  • pnum - Proportional figures
  • frac - Fractions (1/2 to ½)
  • zero - Slashed zero
  • case - Case-sensitive forms
  • ss01-ss20 - Stylistic sets 1-20
  • cv01-cv99 - Character variants 1-99

GPOS (Glyph Positioning):

  • kern - Kerning - enabled by default
  • mark - Mark positioning (diacritics on base glyphs) - enabled by default
  • mkmk - 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