Initial commit: zpdf - PDF generation library for Zig

- Pure Zig implementation, zero dependencies
- PDF 1.4 format output
- Standard Type1 fonts (Helvetica, Times, Courier)
- Text rendering with colors
- Graphics primitives (lines, rectangles)
- Hello world and invoice examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 16:55:28 +01:00
commit 0e17e8790b
6 changed files with 1032 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
zig-cache/
zig-out/
.zig-cache/
*.o
*.a
*.so
*.pdf

186
CLAUDE.md Normal file
View file

@ -0,0 +1,186 @@
# zpdf - Generador PDF para Zig
> **Fecha creación**: 2025-12-08
> **Versión Zig**: 0.15.2
> **Estado**: En desarrollo inicial
## Descripción del Proyecto
Librería pura Zig para generación de documentos PDF. Sin dependencias externas, compila a un binario único.
**Filosofía**:
- Zero dependencias (100% Zig)
- API simple y directa
- Enfocado en generación de facturas/documentos comerciales
- Soporte para texto, tablas, imágenes y formas básicas
## Arquitectura
```
zpdf/
├── CLAUDE.md # Este archivo
├── build.zig # Sistema de build
├── src/
│ ├── root.zig # Exports públicos
│ ├── document.zig # Documento PDF principal
│ ├── page.zig # Páginas
│ ├── stream.zig # Content streams
│ ├── text.zig # Renderizado de texto
│ ├── graphics.zig # Líneas, rectángulos, etc.
│ ├── image.zig # Imágenes embebidas
│ ├── fonts.zig # Fuentes Type1 básicas
│ └── writer.zig # Serialización PDF
└── examples/
├── hello.zig # PDF mínimo
└── invoice.zig # Factura ejemplo
```
## Formato PDF
Usamos PDF 1.4 (compatible con todos los lectores). Estructura básica:
```
%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >> endobj
4 0 obj << /Length ... >> stream ... endstream endobj
xref
0 5
0000000000 65535 f
...
trailer << /Size 5 /Root 1 0 R >>
startxref
...
%%EOF
```
## API Objetivo
```zig
const std = @import("std");
const pdf = @import("zpdf");
pub fn main() !void {
var allocator = std.heap.page_allocator;
var doc = pdf.Document.init(allocator);
defer doc.deinit();
var page = try doc.addPage(.a4);
// Texto
try page.setFont(.helvetica_bold, 24);
try page.drawText(50, 750, "Factura #001");
try page.setFont(.helvetica, 12);
try page.drawText(50, 700, "Cliente: Empresa S.L.");
// Línea
try page.setLineWidth(0.5);
try page.drawLine(50, 690, 550, 690);
// Rectángulo
try page.setFillColor(.{ .r = 240, .g = 240, .b = 240 });
try page.fillRect(50, 600, 500, 20);
// Tabla (helper)
try page.drawTable(.{
.x = 50,
.y = 580,
.columns = &.{ 200, 100, 100, 100 },
.headers = &.{ "Descripción", "Cantidad", "Precio", "Total" },
.rows = &.{
&.{ "Producto A", "2", "10.00", "20.00" },
&.{ "Producto B", "1", "25.00", "25.00" },
},
});
// Guardar
try doc.save("factura.pdf");
}
```
## Funcionalidades Planificadas
### Fase 1 - Core (Actual)
- [ ] Estructura documento PDF 1.4
- [ ] Páginas (A4, Letter, custom)
- [ ] Texto básico (fuentes Type1 built-in)
- [ ] Líneas y rectángulos
- [ ] Serialización correcta
### Fase 2 - Texto Avanzado
- [ ] Múltiples fuentes en mismo documento
- [ ] Colores (RGB, CMYK, grayscale)
- [ ] Alineación (izquierda, centro, derecha)
- [ ] Word wrap automático
### Fase 3 - Imágenes
- [ ] JPEG embebido
- [ ] PNG embebido (con alpha)
- [ ] Escalado y posicionamiento
### Fase 4 - Utilidades
- [ ] Helper para tablas
- [ ] Numeración de páginas
- [ ] Headers/footers
## Fuentes Type1 Built-in
PDF incluye 14 fuentes estándar que no necesitan embeber:
- Helvetica, Helvetica-Bold, Helvetica-Oblique, Helvetica-BoldOblique
- Times-Roman, Times-Bold, Times-Italic, Times-BoldItalic
- Courier, Courier-Bold, Courier-Oblique, Courier-BoldOblique
- Symbol, ZapfDingbats
## Tamaños de Página
```zig
pub const PageSize = enum {
a4, // 595 x 842 points (210 x 297 mm)
letter, // 612 x 792 points (8.5 x 11 inches)
legal, // 612 x 1008 points
a3, // 842 x 1191 points
a5, // 420 x 595 points
};
```
## Referencias
- [PDF Reference 1.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf)
- [pdf-nano (Zig)](https://github.com/GregorBudweiser/pdf-nano) - Referencia minimalista
---
## Equipo y Metodología
### Normas de Trabajo
**IMPORTANTE**: Todas las normas de trabajo están en:
```
/mnt/cello2/arno/re/recode/TEAM_STANDARDS/
```
### Control de Versiones
```bash
# Remote
git remote: git@git.reugenio.com:reugenio/zpdf.git
# Branches
main # Código estable
develop # Desarrollo activo
```
### Zig Path
```bash
ZIG=/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig
```
---
## Notas de Desarrollo
*Se irán añadiendo conforme avance el proyecto*

64
build.zig Normal file
View file

@ -0,0 +1,64 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// zpdf module
const zpdf_mod = b.createModule(.{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
});
// Tests
const unit_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = optimize,
}),
});
const run_unit_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);
// Example: hello
const hello_exe = b.addExecutable(.{
.name = "hello",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/hello.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zpdf", .module = zpdf_mod },
},
}),
});
b.installArtifact(hello_exe);
const run_hello = b.addRunArtifact(hello_exe);
run_hello.step.dependOn(b.getInstallStep());
const hello_step = b.step("hello", "Run hello example");
hello_step.dependOn(&run_hello.step);
// Example: invoice
const invoice_exe = b.addExecutable(.{
.name = "invoice",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/invoice.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zpdf", .module = zpdf_mod },
},
}),
});
b.installArtifact(invoice_exe);
const run_invoice = b.addRunArtifact(invoice_exe);
run_invoice.step.dependOn(b.getInstallStep());
const invoice_step = b.step("invoice", "Run invoice example");
invoice_step.dependOn(&run_invoice.step);
}

74
examples/hello.zig Normal file
View file

@ -0,0 +1,74 @@
//! Minimal PDF example - Hello World
const std = @import("std");
const pdf = @import("zpdf");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
std.debug.print("zpdf - Hello World example\n", .{});
// Create document
var doc = pdf.Document.init(allocator);
defer doc.deinit();
// Add a page
var page = try doc.addPage(.a4);
// Title
try page.setFont(.helvetica_bold, 36);
page.setFillColor(pdf.Color{ .r = 0, .g = 100, .b = 200 });
try page.drawText(50, 750, "Hello, PDF!");
// Subtitle
try page.setFont(.helvetica, 14);
page.setFillColor(pdf.Color.gray);
try page.drawText(50, 710, "Generated with zpdf - Pure Zig PDF library");
// Draw a line
try page.setLineWidth(1);
page.setStrokeColor(pdf.Color.light_gray);
try page.drawLine(50, 700, 545, 700);
// Body text
try page.setFont(.times_roman, 12);
page.setFillColor(pdf.Color.black);
try page.drawText(50, 670, "This PDF was generated entirely in Zig with zero external dependencies.");
try page.drawText(50, 655, "The zpdf library supports:");
try page.drawText(70, 635, "- Multiple fonts (Helvetica, Times, Courier)");
try page.drawText(70, 620, "- Colors (RGB)");
try page.drawText(70, 605, "- Lines and rectangles");
try page.drawText(70, 590, "- Multiple pages");
// Draw some shapes
try page.setLineWidth(2);
page.setStrokeColor(pdf.Color.blue);
page.setFillColor(pdf.Color{ .r = 230, .g = 240, .b = 255 });
try page.drawFilledRect(50, 500, 200, 60);
try page.setFont(.helvetica_bold, 14);
page.setFillColor(pdf.Color.blue);
try page.drawText(60, 540, "Shapes work too!");
// Red rectangle
page.setStrokeColor(pdf.Color.red);
page.setFillColor(pdf.Color{ .r = 255, .g = 230, .b = 230 });
try page.drawFilledRect(280, 500, 200, 60);
page.setFillColor(pdf.Color.red);
try page.drawText(290, 540, "Multiple colors!");
// Footer
try page.setFont(.courier, 10);
page.setFillColor(pdf.Color.gray);
try page.drawText(50, 50, "zpdf v0.1.0 - https://git.reugenio.com/reugenio/zpdf");
// Save
const filename = "hello.pdf";
try doc.saveToFile(filename);
std.debug.print("Created: {s}\n", .{filename});
std.debug.print("Done!\n", .{});
}

179
examples/invoice.zig Normal file
View file

@ -0,0 +1,179 @@
//! Invoice PDF example - Demonstrates a realistic use case
const std = @import("std");
const pdf = @import("zpdf");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
std.debug.print("zpdf - Invoice example\n", .{});
var doc = pdf.Document.init(allocator);
defer doc.deinit();
var page = try doc.addPage(.a4);
// Company header
try page.setFont(.helvetica_bold, 24);
page.setFillColor(pdf.Color{ .r = 41, .g = 98, .b = 255 }); // Blue
try page.drawText(50, 780, "ACME Corporation");
try page.setFont(.helvetica, 10);
page.setFillColor(pdf.Color.gray);
try page.drawText(50, 765, "123 Business Street, City 12345");
try page.drawText(50, 753, "Tel: +34 123 456 789 | Email: info@acme.com");
try page.drawText(50, 741, "CIF: B12345678");
// Invoice title
try page.setFont(.helvetica_bold, 28);
page.setFillColor(pdf.Color.black);
try page.drawText(400, 780, "FACTURA");
try page.setFont(.helvetica, 12);
try page.drawText(400, 760, "No: FAC-2025-0001");
try page.drawText(400, 745, "Fecha: 08/12/2025");
// Separator line
try page.setLineWidth(1);
page.setStrokeColor(pdf.Color.light_gray);
try page.drawLine(50, 720, 545, 720);
// Client info box
page.setFillColor(pdf.Color{ .r = 245, .g = 245, .b = 245 });
try page.fillRect(50, 640, 250, 70);
try page.setFont(.helvetica_bold, 11);
page.setFillColor(pdf.Color.black);
try page.drawText(60, 695, "CLIENTE:");
try page.setFont(.helvetica, 11);
try page.drawText(60, 680, "Empresa Cliente S.L.");
try page.drawText(60, 667, "Calle Principal 45, 2o B");
try page.drawText(60, 654, "08001 Barcelona");
try page.drawText(60, 641, "NIF: B87654321");
// Table header
const table_top: f32 = 610;
const col1: f32 = 50;
const col2: f32 = 300;
const col3: f32 = 370;
const col4: f32 = 430;
const col5: f32 = 490;
const row_height: f32 = 25;
// Header background
page.setFillColor(pdf.Color{ .r = 41, .g = 98, .b = 255 });
try page.fillRect(col1, table_top - row_height, 495, row_height);
// Header text
try page.setFont(.helvetica_bold, 10);
page.setFillColor(pdf.Color.white);
try page.drawText(col1 + 5, table_top - 18, "DESCRIPCION");
try page.drawText(col2 + 5, table_top - 18, "CANT.");
try page.drawText(col3 + 5, table_top - 18, "PRECIO");
try page.drawText(col4 + 5, table_top - 18, "IVA");
try page.drawText(col5 + 5, table_top - 18, "TOTAL");
// Table rows
const items = [_]struct { desc: []const u8, qty: []const u8, price: []const u8, vat: []const u8, total: []const u8 }{
.{ .desc = "Servicio de consultoria", .qty = "10h", .price = "50.00", .vat = "21%", .total = "605.00" },
.{ .desc = "Desarrollo web", .qty = "1", .price = "1500.00", .vat = "21%", .total = "1815.00" },
.{ .desc = "Mantenimiento mensual", .qty = "1", .price = "200.00", .vat = "21%", .total = "242.00" },
.{ .desc = "Hosting anual", .qty = "1", .price = "120.00", .vat = "21%", .total = "145.20" },
};
try page.setFont(.helvetica, 10);
page.setFillColor(pdf.Color.black);
var y = table_top - row_height;
for (items, 0..) |item, i| {
y -= row_height;
// Alternate row background
if (i % 2 == 0) {
page.setFillColor(pdf.Color{ .r = 250, .g = 250, .b = 250 });
try page.fillRect(col1, y, 495, row_height);
}
// Row content
page.setFillColor(pdf.Color.black);
try page.setFont(.helvetica, 10);
try page.drawText(col1 + 5, y + 8, item.desc);
try page.drawText(col2 + 5, y + 8, item.qty);
try page.drawText(col3 + 5, y + 8, item.price);
try page.drawText(col4 + 5, y + 8, item.vat);
try page.drawText(col5 + 5, y + 8, item.total);
// Row border
page.setStrokeColor(pdf.Color.light_gray);
try page.drawLine(col1, y, col1 + 495, y);
}
// Table border
try page.setLineWidth(0.5);
page.setStrokeColor(pdf.Color.gray);
try page.drawRect(col1, y, 495, table_top - y - row_height);
// Vertical lines
try page.drawLine(col2, y, col2, table_top - row_height);
try page.drawLine(col3, y, col3, table_top - row_height);
try page.drawLine(col4, y, col4, table_top - row_height);
try page.drawLine(col5, y, col5, table_top - row_height);
// Totals section
const totals_y = y - 40;
try page.setFont(.helvetica, 11);
page.setFillColor(pdf.Color.black);
try page.drawText(380, totals_y, "Subtotal:");
try page.drawText(480, totals_y, "2,320.00");
try page.drawText(380, totals_y - 18, "IVA (21%):");
try page.drawText(480, totals_y - 18, "487.20");
// Total line
try page.setLineWidth(1);
page.setStrokeColor(pdf.Color.black);
try page.drawLine(380, totals_y - 30, 545, totals_y - 30);
try page.setFont(.helvetica_bold, 14);
try page.drawText(380, totals_y - 48, "TOTAL:");
try page.drawText(475, totals_y - 48, "2,807.20 EUR");
// Payment info
const payment_y = totals_y - 100;
page.setFillColor(pdf.Color{ .r = 245, .g = 245, .b = 245 });
try page.fillRect(50, payment_y - 50, 300, 70);
try page.setFont(.helvetica_bold, 10);
page.setFillColor(pdf.Color.black);
try page.drawText(60, payment_y + 5, "FORMA DE PAGO:");
try page.setFont(.helvetica, 10);
try page.drawText(60, payment_y - 10, "Transferencia bancaria");
try page.drawText(60, payment_y - 25, "IBAN: ES12 1234 5678 9012 3456 7890");
try page.drawText(60, payment_y - 40, "Vencimiento: 30 dias");
// Footer
try page.setLineWidth(0.5);
page.setStrokeColor(pdf.Color.light_gray);
try page.drawLine(50, 80, 545, 80);
try page.setFont(.helvetica, 8);
page.setFillColor(pdf.Color.gray);
try page.drawText(50, 65, "Esta factura ha sido generada electronicamente y es valida sin firma.");
try page.drawText(50, 55, "ACME Corporation - Inscrita en el Registro Mercantil de Madrid, Tomo 12345, Folio 67, Hoja M-123456");
// Page number
try page.drawText(500, 55, "Pagina 1/1");
// Save
const filename = "invoice.pdf";
try doc.saveToFile(filename);
std.debug.print("Created: {s}\n", .{filename});
std.debug.print("Done!\n", .{});
}

522
src/root.zig Normal file
View file

@ -0,0 +1,522 @@
//! zpdf - PDF generation library for Zig
//!
//! A pure Zig library for creating PDF documents with zero dependencies.
//! Focused on generating invoices and business documents.
//!
//! ## Quick Start
//!
//! ```zig
//! const pdf = @import("zpdf");
//!
//! pub fn main() !void {
//! var doc = pdf.Document.init(allocator);
//! defer doc.deinit();
//!
//! var page = try doc.addPage(.a4);
//! try page.setFont(.helvetica_bold, 24);
//! try page.drawText(50, 750, "Hello, PDF!");
//!
//! try doc.saveToFile("hello.pdf");
//! }
//! ```
const std = @import("std");
// ============================================================================
// Public Types
// ============================================================================
/// Standard page sizes in PDF points (1 point = 1/72 inch)
pub const PageSize = enum {
a4, // 595 x 842 points (210 x 297 mm)
a3, // 842 x 1191 points (297 x 420 mm)
a5, // 420 x 595 points (148 x 210 mm)
letter, // 612 x 792 points (8.5 x 11 inches)
legal, // 612 x 1008 points (8.5 x 14 inches)
pub fn dimensions(self: PageSize) struct { width: f32, height: f32 } {
return switch (self) {
.a4 => .{ .width = 595, .height = 842 },
.a3 => .{ .width = 842, .height = 1191 },
.a5 => .{ .width = 420, .height = 595 },
.letter => .{ .width = 612, .height = 792 },
.legal => .{ .width = 612, .height = 1008 },
};
}
};
/// PDF standard Type1 fonts (built into all PDF readers)
pub const Font = enum {
helvetica,
helvetica_bold,
helvetica_oblique,
helvetica_bold_oblique,
times_roman,
times_bold,
times_italic,
times_bold_italic,
courier,
courier_bold,
courier_oblique,
courier_bold_oblique,
symbol,
zapf_dingbats,
pub fn pdfName(self: Font) []const u8 {
return switch (self) {
.helvetica => "Helvetica",
.helvetica_bold => "Helvetica-Bold",
.helvetica_oblique => "Helvetica-Oblique",
.helvetica_bold_oblique => "Helvetica-BoldOblique",
.times_roman => "Times-Roman",
.times_bold => "Times-Bold",
.times_italic => "Times-Italic",
.times_bold_italic => "Times-BoldItalic",
.courier => "Courier",
.courier_bold => "Courier-Bold",
.courier_oblique => "Courier-Oblique",
.courier_bold_oblique => "Courier-BoldOblique",
.symbol => "Symbol",
.zapf_dingbats => "ZapfDingbats",
};
}
};
/// RGB color (0-255 per channel)
pub const Color = struct {
r: u8 = 0,
g: u8 = 0,
b: u8 = 0,
pub const black = Color{ .r = 0, .g = 0, .b = 0 };
pub const white = Color{ .r = 255, .g = 255, .b = 255 };
pub const red = Color{ .r = 255, .g = 0, .b = 0 };
pub const green = Color{ .r = 0, .g = 255, .b = 0 };
pub const blue = Color{ .r = 0, .g = 0, .b = 255 };
pub const gray = Color{ .r = 128, .g = 128, .b = 128 };
pub const light_gray = Color{ .r = 200, .g = 200, .b = 200 };
/// Convert to PDF color values (0.0 - 1.0)
pub fn toFloats(self: Color) struct { r: f32, g: f32, b: f32 } {
return .{
.r = @as(f32, @floatFromInt(self.r)) / 255.0,
.g = @as(f32, @floatFromInt(self.g)) / 255.0,
.b = @as(f32, @floatFromInt(self.b)) / 255.0,
};
}
};
// ============================================================================
// Page
// ============================================================================
/// A single page in a PDF document
pub const Page = struct {
allocator: std.mem.Allocator,
width: f32,
height: f32,
content: std.ArrayListUnmanaged(u8),
// Current graphics state
current_font: Font = .helvetica,
current_font_size: f32 = 12,
stroke_color: Color = Color.black,
fill_color: Color = Color.black,
line_width: f32 = 1.0,
const Self = @This();
fn init(allocator: std.mem.Allocator, size: PageSize) Self {
const dims = size.dimensions();
return .{
.allocator = allocator,
.width = dims.width,
.height = dims.height,
.content = .{},
};
}
fn initCustom(allocator: std.mem.Allocator, width: f32, height: f32) Self {
return .{
.allocator = allocator,
.width = width,
.height = height,
.content = .{},
};
}
fn deinit(self: *Self) void {
self.content.deinit(self.allocator);
}
// ========================================================================
// Text Operations
// ========================================================================
/// Sets the current font and size
pub fn setFont(self: *Self, font: Font, size: f32) !void {
self.current_font = font;
self.current_font_size = size;
// Font selection is done when drawing text
}
/// Draws text at the specified position (x, y from bottom-left)
pub fn drawText(self: *Self, x: f32, y: f32, text: []const u8) !void {
const writer = self.content.writer(self.allocator);
// Begin text object
try writer.writeAll("BT\n");
// Set font
try writer.print("/{s} {d} Tf\n", .{ self.current_font.pdfName(), self.current_font_size });
// Set text color
const c = self.fill_color.toFloats();
try writer.print("{d:.3} {d:.3} {d:.3} rg\n", .{ c.r, c.g, c.b });
// Position and draw
try writer.print("{d:.2} {d:.2} Td\n", .{ x, y });
// Escape special PDF characters in text
try writer.writeByte('(');
for (text) |char| {
switch (char) {
'(', ')', '\\' => {
try writer.writeByte('\\');
try writer.writeByte(char);
},
else => try writer.writeByte(char),
}
}
try writer.writeAll(") Tj\n");
// End text object
try writer.writeAll("ET\n");
}
/// Sets the text/fill color
pub fn setFillColor(self: *Self, color: Color) void {
self.fill_color = color;
}
/// Sets the stroke color for lines and shapes
pub fn setStrokeColor(self: *Self, color: Color) void {
self.stroke_color = color;
}
// ========================================================================
// Graphics Operations
// ========================================================================
/// Sets the line width for stroke operations
pub fn setLineWidth(self: *Self, width: f32) !void {
self.line_width = width;
const writer = self.content.writer(self.allocator);
try writer.print("{d:.2} w\n", .{width});
}
/// Draws a line from (x1, y1) to (x2, y2)
pub fn drawLine(self: *Self, x1: f32, y1: f32, x2: f32, y2: f32) !void {
const writer = self.content.writer(self.allocator);
// Set stroke color
const c = self.stroke_color.toFloats();
try writer.print("{d:.3} {d:.3} {d:.3} RG\n", .{ c.r, c.g, c.b });
// Draw line
try writer.print("{d:.2} {d:.2} m\n", .{ x1, y1 });
try writer.print("{d:.2} {d:.2} l\n", .{ x2, y2 });
try writer.writeAll("S\n");
}
/// Draws a rectangle outline
pub fn drawRect(self: *Self, x: f32, y: f32, width: f32, height: f32) !void {
const writer = self.content.writer(self.allocator);
// Set stroke color
const c = self.stroke_color.toFloats();
try writer.print("{d:.3} {d:.3} {d:.3} RG\n", .{ c.r, c.g, c.b });
// Draw rectangle
try writer.print("{d:.2} {d:.2} {d:.2} {d:.2} re\n", .{ x, y, width, height });
try writer.writeAll("S\n");
}
/// Fills a rectangle
pub fn fillRect(self: *Self, x: f32, y: f32, width: f32, height: f32) !void {
const writer = self.content.writer(self.allocator);
// Set fill color
const c = self.fill_color.toFloats();
try writer.print("{d:.3} {d:.3} {d:.3} rg\n", .{ c.r, c.g, c.b });
// Fill rectangle
try writer.print("{d:.2} {d:.2} {d:.2} {d:.2} re\n", .{ x, y, width, height });
try writer.writeAll("f\n");
}
/// Draws a filled rectangle with stroke
pub fn drawFilledRect(self: *Self, x: f32, y: f32, width: f32, height: f32) !void {
const writer = self.content.writer(self.allocator);
// Set colors
const fc = self.fill_color.toFloats();
const sc = self.stroke_color.toFloats();
try writer.print("{d:.3} {d:.3} {d:.3} rg\n", .{ fc.r, fc.g, fc.b });
try writer.print("{d:.3} {d:.3} {d:.3} RG\n", .{ sc.r, sc.g, sc.b });
// Draw and fill rectangle
try writer.print("{d:.2} {d:.2} {d:.2} {d:.2} re\n", .{ x, y, width, height });
try writer.writeAll("B\n"); // Fill and stroke
}
};
// ============================================================================
// Document
// ============================================================================
/// A PDF document
pub const Document = struct {
allocator: std.mem.Allocator,
pages: std.ArrayListUnmanaged(Page),
const Self = @This();
/// Creates a new empty PDF document
pub fn init(allocator: std.mem.Allocator) Self {
return .{
.allocator = allocator,
.pages = .{},
};
}
/// Frees all resources
pub fn deinit(self: *Self) void {
for (self.pages.items) |*page| {
page.deinit();
}
self.pages.deinit(self.allocator);
}
/// Adds a new page with a standard size
pub fn addPage(self: *Self, size: PageSize) !*Page {
const page = Page.init(self.allocator, size);
try self.pages.append(self.allocator, page);
return &self.pages.items[self.pages.items.len - 1];
}
/// Adds a new page with custom dimensions (in points)
pub fn addPageCustom(self: *Self, width: f32, height: f32) !*Page {
const page = Page.initCustom(self.allocator, width, height);
try self.pages.append(self.allocator, page);
return &self.pages.items[self.pages.items.len - 1];
}
/// Renders the document to a byte buffer
pub fn render(self: *Self, allocator: std.mem.Allocator) ![]u8 {
var output: std.ArrayListUnmanaged(u8) = .{};
errdefer output.deinit(allocator);
const writer = output.writer(allocator);
// Track object positions for xref
var obj_positions: std.ArrayListUnmanaged(usize) = .{};
defer obj_positions.deinit(allocator);
// PDF Header
try writer.writeAll("%PDF-1.4\n");
try writer.writeAll("%\xE2\xE3\xCF\xD3\n"); // Binary marker
// Count objects we'll create
const num_pages = self.pages.items.len;
// Objects: catalog, pages, (page + content) * num_pages, fonts
const num_fonts: usize = 1; // We'll use one font resource dict
const total_objects = 2 + (num_pages * 2) + num_fonts;
// Object 1: Catalog
try obj_positions.append(allocator, output.items.len);
try writer.writeAll("1 0 obj\n");
try writer.writeAll("<< /Type /Catalog /Pages 2 0 R >>\n");
try writer.writeAll("endobj\n");
// Object 2: Pages
try obj_positions.append(allocator, output.items.len);
try writer.writeAll("2 0 obj\n");
try writer.writeAll("<< /Type /Pages /Kids [");
for (0..num_pages) |i| {
const page_obj_num = 3 + (i * 2);
try writer.print("{d} 0 R ", .{page_obj_num});
}
try writer.print("] /Count {d} >>\n", .{num_pages});
try writer.writeAll("endobj\n");
// Font resource object (after pages)
const font_obj_num = 3 + (num_pages * 2);
try obj_positions.append(allocator, output.items.len);
try writer.print("{d} 0 obj\n", .{font_obj_num});
try writer.writeAll("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n");
try writer.writeAll("endobj\n");
// Pages and content streams
for (self.pages.items, 0..) |*page, i| {
const page_obj_num = 3 + (i * 2);
const content_obj_num = page_obj_num + 1;
// Page object
try obj_positions.append(allocator, output.items.len);
try writer.print("{d} 0 obj\n", .{page_obj_num});
try writer.print("<< /Type /Page /Parent 2 0 R ", .{});
try writer.print("/MediaBox [0 0 {d:.0} {d:.0}] ", .{ page.width, page.height });
try writer.print("/Contents {d} 0 R ", .{content_obj_num});
// Font resources - include all standard fonts
try writer.writeAll("/Resources << /Font << ");
inline for (std.meta.fields(Font)) |field| {
const font: Font = @enumFromInt(field.value);
try writer.print("/{s} << /Type /Font /Subtype /Type1 /BaseFont /{s} >> ", .{ font.pdfName(), font.pdfName() });
}
try writer.writeAll(">> >> ");
try writer.writeAll(">>\n");
try writer.writeAll("endobj\n");
// Content stream
try obj_positions.append(allocator, output.items.len);
try writer.print("{d} 0 obj\n", .{content_obj_num});
try writer.print("<< /Length {d} >>\n", .{page.content.items.len});
try writer.writeAll("stream\n");
try writer.writeAll(page.content.items);
try writer.writeAll("\nendstream\n");
try writer.writeAll("endobj\n");
}
// Cross-reference table
const xref_pos = output.items.len;
try writer.writeAll("xref\n");
try writer.print("0 {d}\n", .{total_objects + 1});
try writer.writeAll("0000000000 65535 f \n");
// Write object positions
// We need to sort by object number
// Object 1 (catalog), 2 (pages), font, then pages/contents
try writer.print("{d:0>10} 00000 n \n", .{obj_positions.items[0]}); // obj 1
try writer.print("{d:0>10} 00000 n \n", .{obj_positions.items[1]}); // obj 2
// Page objects and content streams
for (0..num_pages) |i| {
const page_pos_idx = 3 + (i * 2);
const content_pos_idx = page_pos_idx + 1;
if (page_pos_idx < obj_positions.items.len) {
try writer.print("{d:0>10} 00000 n \n", .{obj_positions.items[page_pos_idx]});
}
if (content_pos_idx < obj_positions.items.len) {
try writer.print("{d:0>10} 00000 n \n", .{obj_positions.items[content_pos_idx]});
}
}
// Font object
try writer.print("{d:0>10} 00000 n \n", .{obj_positions.items[2]});
// Trailer
try writer.writeAll("trailer\n");
try writer.print("<< /Size {d} /Root 1 0 R >>\n", .{total_objects + 1});
try writer.writeAll("startxref\n");
try writer.print("{d}\n", .{xref_pos});
try writer.writeAll("%%EOF\n");
return output.toOwnedSlice(allocator);
}
/// Saves the document to a file
pub fn saveToFile(self: *Self, path: []const u8) !void {
const data = try self.render(self.allocator);
defer self.allocator.free(data);
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
try file.writeAll(data);
}
};
// ============================================================================
// Tests
// ============================================================================
test "create empty document" {
const allocator = std.testing.allocator;
var doc = Document.init(allocator);
defer doc.deinit();
try std.testing.expectEqual(@as(usize, 0), doc.pages.items.len);
}
test "add page" {
const allocator = std.testing.allocator;
var doc = Document.init(allocator);
defer doc.deinit();
_ = try doc.addPage(.a4);
try std.testing.expectEqual(@as(usize, 1), doc.pages.items.len);
const dims = PageSize.a4.dimensions();
try std.testing.expectEqual(@as(f32, 595), dims.width);
try std.testing.expectEqual(@as(f32, 842), dims.height);
}
test "render minimal document" {
const allocator = std.testing.allocator;
var doc = Document.init(allocator);
defer doc.deinit();
var page = try doc.addPage(.a4);
try page.drawText(100, 700, "Hello");
const pdf_data = try doc.render(allocator);
defer allocator.free(pdf_data);
// Check PDF header
try std.testing.expect(std.mem.startsWith(u8, pdf_data, "%PDF-1.4"));
// Check PDF trailer
try std.testing.expect(std.mem.endsWith(u8, pdf_data, "%%EOF\n"));
}
test "font names" {
try std.testing.expectEqualStrings("Helvetica", Font.helvetica.pdfName());
try std.testing.expectEqualStrings("Times-Bold", Font.times_bold.pdfName());
try std.testing.expectEqualStrings("Courier", Font.courier.pdfName());
}
test "color conversion" {
const white = Color.white;
const floats = white.toFloats();
try std.testing.expectEqual(@as(f32, 1.0), floats.r);
try std.testing.expectEqual(@as(f32, 1.0), floats.g);
try std.testing.expectEqual(@as(f32, 1.0), floats.b);
const black = Color.black;
const black_floats = black.toFloats();
try std.testing.expectEqual(@as(f32, 0.0), black_floats.r);
}
test "graphics operations" {
const allocator = std.testing.allocator;
var doc = Document.init(allocator);
defer doc.deinit();
var page = try doc.addPage(.a4);
try page.setLineWidth(2.0);
try page.drawLine(0, 0, 100, 100);
try page.drawRect(50, 50, 100, 50);
page.setFillColor(Color.light_gray);
try page.fillRect(200, 200, 50, 50);
// Content should have been written
try std.testing.expect(page.content.items.len > 0);
}