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:
commit
0e17e8790b
6 changed files with 1032 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
zig-cache/
|
||||
zig-out/
|
||||
.zig-cache/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.pdf
|
||||
186
CLAUDE.md
Normal file
186
CLAUDE.md
Normal 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
64
build.zig
Normal 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
74
examples/hello.zig
Normal 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
179
examples/invoice.zig
Normal 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
522
src/root.zig
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue