From 0e17e8790b70323187e21c1511d3c9c2b09f8e4d Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 16:55:28 +0100 Subject: [PATCH] Initial commit: zpdf - PDF generation library for Zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 7 + CLAUDE.md | 186 +++++++++++++++ build.zig | 64 ++++++ examples/hello.zig | 74 ++++++ examples/invoice.zig | 179 +++++++++++++++ src/root.zig | 522 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1032 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 build.zig create mode 100644 examples/hello.zig create mode 100644 examples/invoice.zig create mode 100644 src/root.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc960b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +zig-cache/ +zig-out/ +.zig-cache/ +*.o +*.a +*.so +*.pdf diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a2b2a19 --- /dev/null +++ b/CLAUDE.md @@ -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* diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..f0077a3 --- /dev/null +++ b/build.zig @@ -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); +} diff --git a/examples/hello.zig b/examples/hello.zig new file mode 100644 index 0000000..3d7ee48 --- /dev/null +++ b/examples/hello.zig @@ -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", .{}); +} diff --git a/examples/invoice.zig b/examples/invoice.zig new file mode 100644 index 0000000..6641302 --- /dev/null +++ b/examples/invoice.zig @@ -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", .{}); +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..5d00aaa --- /dev/null +++ b/src/root.zig @@ -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); +}