Phase 1 - Refactoring: - Modular architecture: fonts/, graphics/, objects/, output/ - Fixed Zig 0.15 API changes (ArrayListUnmanaged) - Fixed memory issues in render() Phase 2 - Text System: - cell() with borders, fill, alignment - cellAdvanced() with position control - multiCell() with automatic word wrap - ln() for line breaks - getStringWidth() for text width calculation - Page margins (setMargins, setCellMargin) - Align enum (left, center, right) - Border packed struct New features: - New Pdf API (cleaner than legacy Document) - Document metadata (setTitle, setAuthor, setSubject) - Color: RGB, CMYK, Grayscale support - 52 unit tests passing - New example: text_demo.zig 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
541 lines
14 KiB
Markdown
541 lines
14 KiB
Markdown
# Arquitectura zpdf - Diseño Basado en fpdf2
|
|
|
|
**Fecha:** 2025-12-08
|
|
**Basado en:** fpdf2 v2.8.5 (Python)
|
|
**Adaptado para:** Zig 0.15.2
|
|
|
|
---
|
|
|
|
## Filosofía de Diseño
|
|
|
|
1. **Traducción fiel de fpdf2** - Misma arquitectura, adaptada a idiomas de Zig
|
|
2. **Zero dependencias** - Todo en Zig puro (excepto zlib para compresión)
|
|
3. **API ergonómica** - Aprovechamos las capacidades de Zig
|
|
4. **Comptime donde sea posible** - Métricas de fuentes, validaciones
|
|
5. **Sin allocaciones ocultas** - El usuario controla la memoria
|
|
|
|
---
|
|
|
|
## Comparativa: Estado Actual vs Objetivo
|
|
|
|
### Estado Actual (root.zig)
|
|
|
|
```
|
|
Document
|
|
├── pages: ArrayList(Page)
|
|
└── render() -> []u8
|
|
|
|
Page
|
|
├── content: ArrayList(u8) # Content stream raw
|
|
├── current_font, current_font_size
|
|
├── stroke_color, fill_color
|
|
└── drawText(), drawLine(), drawRect(), fillRect()
|
|
```
|
|
|
|
**Problemas actuales:**
|
|
- Todo en un solo archivo (523 líneas)
|
|
- No hay sistema de objetos PDF tipado
|
|
- xref table tiene bugs (orden incorrecto)
|
|
- No hay separación clara de responsabilidades
|
|
- No soporta múltiples fuentes en un documento
|
|
|
|
### Objetivo (basado en fpdf2)
|
|
|
|
```
|
|
Pdf (facade principal)
|
|
├── pages: ArrayList(PdfPage)
|
|
├── fonts: FontRegistry
|
|
├── images: ImageCache
|
|
├── state: GraphicsState
|
|
├── output_producer: OutputProducer
|
|
└── métodos: addPage(), setFont(), cell(), line(), rect(), image()
|
|
|
|
PdfPage
|
|
├── index: usize
|
|
├── dimensions: PageDimensions
|
|
├── content_stream: ContentStream
|
|
├── resources: ResourceSet
|
|
└── annotations: ArrayList(Annotation)
|
|
|
|
ContentStream
|
|
├── buffer: ArrayList(u8)
|
|
└── métodos: moveTo(), lineTo(), text(), rect(), etc.
|
|
|
|
OutputProducer
|
|
├── pdf_objects: ArrayList(PdfObject)
|
|
├── offsets: HashMap(u32, usize)
|
|
└── bufferize() -> []u8
|
|
```
|
|
|
|
---
|
|
|
|
## Estructura de Archivos Propuesta
|
|
|
|
```
|
|
zpdf/
|
|
├── src/
|
|
│ ├── root.zig # Re-exports públicos
|
|
│ │
|
|
│ ├── pdf.zig # Facade principal (Pdf struct)
|
|
│ ├── page.zig # PdfPage
|
|
│ ├── content_stream.zig # ContentStream (operadores PDF)
|
|
│ │
|
|
│ ├── objects/
|
|
│ │ ├── mod.zig # Re-exports
|
|
│ │ ├── base.zig # PdfObject trait/interface
|
|
│ │ ├── catalog.zig # /Catalog
|
|
│ │ ├── pages.zig # /Pages (raíz)
|
|
│ │ ├── page.zig # /Page object
|
|
│ │ ├── font.zig # /Font objects
|
|
│ │ ├── stream.zig # Generic streams
|
|
│ │ └── xobject.zig # Images
|
|
│ │
|
|
│ ├── fonts/
|
|
│ │ ├── mod.zig
|
|
│ │ ├── type1.zig # Fuentes Type1 estándar
|
|
│ │ ├── metrics.zig # Métricas (comptime)
|
|
│ │ └── registry.zig # FontRegistry
|
|
│ │
|
|
│ ├── graphics/
|
|
│ │ ├── mod.zig
|
|
│ │ ├── color.zig # RGB, CMYK, Gray
|
|
│ │ ├── state.zig # GraphicsState
|
|
│ │ └── path.zig # Paths vectoriales
|
|
│ │
|
|
│ ├── text/
|
|
│ │ ├── mod.zig
|
|
│ │ ├── cell.zig # cell(), multi_cell()
|
|
│ │ ├── layout.zig # Word wrap, alineación
|
|
│ │ └── fragment.zig # Text fragments
|
|
│ │
|
|
│ ├── image/
|
|
│ │ ├── mod.zig
|
|
│ │ ├── jpeg.zig # Parser JPEG
|
|
│ │ └── png.zig # Parser PNG
|
|
│ │
|
|
│ ├── output/
|
|
│ │ ├── mod.zig
|
|
│ │ ├── producer.zig # OutputProducer
|
|
│ │ ├── xref.zig # Cross-reference table
|
|
│ │ └── writer.zig # Buffer writer helpers
|
|
│ │
|
|
│ └── util/
|
|
│ ├── mod.zig
|
|
│ └── encoding.zig # PDF string encoding
|
|
│
|
|
├── examples/
|
|
│ ├── hello.zig
|
|
│ ├── invoice.zig
|
|
│ └── table.zig
|
|
│
|
|
└── tests/
|
|
├── pdf_test.zig
|
|
├── content_stream_test.zig
|
|
└── ...
|
|
```
|
|
|
|
---
|
|
|
|
## API Objetivo Detallada
|
|
|
|
### Creación de Documento
|
|
|
|
```zig
|
|
const std = @import("std");
|
|
const zpdf = @import("zpdf");
|
|
|
|
pub fn main() !void {
|
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
|
defer _ = gpa.deinit();
|
|
const allocator = gpa.allocator();
|
|
|
|
// Crear documento con opciones
|
|
var pdf = zpdf.Pdf.init(allocator, .{
|
|
.orientation = .portrait, // o .landscape
|
|
.unit = .mm, // .pt, .cm, .in
|
|
.format = .a4, // o dimensiones custom
|
|
});
|
|
defer pdf.deinit();
|
|
|
|
// Metadata
|
|
pdf.setTitle("Factura #001");
|
|
pdf.setAuthor("ACME Corp");
|
|
pdf.setCreator("zpdf");
|
|
|
|
// Nueva página (usa formato por defecto)
|
|
try pdf.addPage(.{});
|
|
|
|
// O con opciones específicas
|
|
try pdf.addPage(.{
|
|
.orientation = .landscape,
|
|
.format = .letter,
|
|
});
|
|
}
|
|
```
|
|
|
|
### Texto
|
|
|
|
```zig
|
|
// Fuente
|
|
try pdf.setFont(.helvetica_bold, 24);
|
|
|
|
// Color de texto
|
|
pdf.setTextColor(zpdf.Color.rgb(0, 0, 128));
|
|
|
|
// Posición actual
|
|
pdf.setXY(50, 50);
|
|
|
|
// Texto simple en posición actual
|
|
try pdf.text("Hola mundo");
|
|
|
|
// Celda: rectángulo con texto
|
|
try pdf.cell(.{
|
|
.w = 100, // Ancho (0 = hasta margen derecho)
|
|
.h = 10, // Alto (null = altura de fuente)
|
|
.text = "Celda",
|
|
.border = .all, // o .{ .left = true, .bottom = true }
|
|
.align = .center, // .left, .right, .justify
|
|
.fill = true,
|
|
.new_x = .right, // Posición X después
|
|
.new_y = .top, // Posición Y después
|
|
});
|
|
|
|
// Celda multilínea con word wrap
|
|
try pdf.multiCell(.{
|
|
.w = 100,
|
|
.h = 10,
|
|
.text = "Este es un texto largo que se dividirá en varias líneas automáticamente.",
|
|
.border = .none,
|
|
.align = .justify,
|
|
});
|
|
|
|
// Salto de línea
|
|
pdf.ln(10); // Baja 10 unidades
|
|
```
|
|
|
|
### Gráficos
|
|
|
|
```zig
|
|
// Colores
|
|
pdf.setDrawColor(zpdf.Color.black);
|
|
pdf.setFillColor(zpdf.Color.rgb(200, 200, 200));
|
|
|
|
// Línea
|
|
pdf.setLineWidth(0.5);
|
|
try pdf.line(50, 100, 150, 100);
|
|
|
|
// Rectángulo
|
|
try pdf.rect(50, 110, 100, 50, .stroke); // Solo borde
|
|
try pdf.rect(50, 110, 100, 50, .fill); // Solo relleno
|
|
try pdf.rect(50, 110, 100, 50, .stroke_fill); // Ambos
|
|
|
|
// Rectángulo redondeado
|
|
try pdf.roundedRect(50, 170, 100, 50, 5, .stroke_fill);
|
|
|
|
// Círculo / Elipse
|
|
try pdf.circle(100, 250, 25, .fill);
|
|
try pdf.ellipse(100, 300, 40, 20, .stroke);
|
|
|
|
// Polígono
|
|
try pdf.polygon(&.{
|
|
.{ 100, 350 },
|
|
.{ 150, 400 },
|
|
.{ 50, 400 },
|
|
}, .fill);
|
|
```
|
|
|
|
### Imágenes
|
|
|
|
```zig
|
|
// Desde archivo
|
|
try pdf.image("logo.png", .{
|
|
.x = 10,
|
|
.y = 10,
|
|
.w = 50, // Ancho (null = automático)
|
|
.h = null, // Alto (null = mantener ratio)
|
|
});
|
|
|
|
// Desde bytes en memoria
|
|
try pdf.imageFromBytes(png_bytes, .{
|
|
.x = 10,
|
|
.y = 70,
|
|
.w = 100,
|
|
});
|
|
```
|
|
|
|
### Tablas
|
|
|
|
```zig
|
|
// Helper de alto nivel para tablas
|
|
var table = pdf.table(.{
|
|
.x = 50,
|
|
.y = 500,
|
|
.width = 500,
|
|
.columns = &.{
|
|
.{ .header = "Descripción", .width = 200, .align = .left },
|
|
.{ .header = "Cantidad", .width = 100, .align = .center },
|
|
.{ .header = "Precio", .width = 100, .align = .right },
|
|
.{ .header = "Total", .width = 100, .align = .right },
|
|
},
|
|
.header_style = .{
|
|
.font = .helvetica_bold,
|
|
.size = 10,
|
|
.fill_color = zpdf.Color.light_gray,
|
|
},
|
|
});
|
|
|
|
try table.addRow(&.{ "Producto A", "2", "10.00 €", "20.00 €" });
|
|
try table.addRow(&.{ "Producto B", "1", "25.00 €", "25.00 €" });
|
|
try table.addRow(&.{ "Producto C", "5", "5.00 €", "25.00 €" });
|
|
|
|
try table.render();
|
|
```
|
|
|
|
### Output
|
|
|
|
```zig
|
|
// Guardar a archivo
|
|
try pdf.save("documento.pdf");
|
|
|
|
// O obtener bytes
|
|
const bytes = try pdf.output();
|
|
defer allocator.free(bytes);
|
|
```
|
|
|
|
---
|
|
|
|
## Implementación de Tipos Clave
|
|
|
|
### Color
|
|
|
|
```zig
|
|
pub const Color = union(enum) {
|
|
rgb: struct { r: u8, g: u8, b: u8 },
|
|
cmyk: struct { c: u8, m: u8, y: u8, k: u8 },
|
|
gray: u8,
|
|
|
|
pub fn rgb(r: u8, g: u8, b: u8) Color {
|
|
return .{ .rgb = .{ .r = r, .g = g, .b = b } };
|
|
}
|
|
|
|
pub fn toStrokeCmd(self: Color) []const u8 {
|
|
// Returns "R G B RG" or "C M Y K K" etc.
|
|
}
|
|
|
|
pub fn toFillCmd(self: Color) []const u8 {
|
|
// Returns "r g b rg" or "c m y k k" etc.
|
|
}
|
|
|
|
// Colores predefinidos
|
|
pub const black = rgb(0, 0, 0);
|
|
pub const white = rgb(255, 255, 255);
|
|
pub const red = rgb(255, 0, 0);
|
|
pub const green = rgb(0, 255, 0);
|
|
pub const blue = rgb(0, 0, 255);
|
|
};
|
|
```
|
|
|
|
### ContentStream
|
|
|
|
```zig
|
|
pub const ContentStream = struct {
|
|
buffer: std.ArrayList(u8),
|
|
allocator: std.mem.Allocator,
|
|
|
|
pub fn init(allocator: std.mem.Allocator) ContentStream {
|
|
return .{
|
|
.buffer = std.ArrayList(u8).init(allocator),
|
|
.allocator = allocator,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *ContentStream) void {
|
|
self.buffer.deinit();
|
|
}
|
|
|
|
// Gráficos de bajo nivel
|
|
pub fn moveTo(self: *ContentStream, x: f32, y: f32) !void {
|
|
try self.buffer.writer().print("{d:.2} {d:.2} m\n", .{ x, y });
|
|
}
|
|
|
|
pub fn lineTo(self: *ContentStream, x: f32, y: f32) !void {
|
|
try self.buffer.writer().print("{d:.2} {d:.2} l\n", .{ x, y });
|
|
}
|
|
|
|
pub fn rect(self: *ContentStream, x: f32, y: f32, w: f32, h: f32) !void {
|
|
try self.buffer.writer().print("{d:.2} {d:.2} {d:.2} {d:.2} re\n", .{ x, y, w, h });
|
|
}
|
|
|
|
pub fn stroke(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("S\n");
|
|
}
|
|
|
|
pub fn fill(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("f\n");
|
|
}
|
|
|
|
pub fn strokeAndFill(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("B\n");
|
|
}
|
|
|
|
pub fn closePath(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("h\n");
|
|
}
|
|
|
|
pub fn saveState(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("q\n");
|
|
}
|
|
|
|
pub fn restoreState(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("Q\n");
|
|
}
|
|
|
|
// Texto
|
|
pub fn beginText(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("BT\n");
|
|
}
|
|
|
|
pub fn endText(self: *ContentStream) !void {
|
|
try self.buffer.appendSlice("ET\n");
|
|
}
|
|
|
|
pub fn setFont(self: *ContentStream, font_name: []const u8, size: f32) !void {
|
|
try self.buffer.writer().print("/{s} {d:.2} Tf\n", .{ font_name, size });
|
|
}
|
|
|
|
pub fn textPosition(self: *ContentStream, x: f32, y: f32) !void {
|
|
try self.buffer.writer().print("{d:.2} {d:.2} Td\n", .{ x, y });
|
|
}
|
|
|
|
pub fn showText(self: *ContentStream, text: []const u8) !void {
|
|
try self.buffer.append('(');
|
|
for (text) |c| {
|
|
switch (c) {
|
|
'(', ')', '\\' => {
|
|
try self.buffer.append('\\');
|
|
try self.buffer.append(c);
|
|
},
|
|
else => try self.buffer.append(c),
|
|
}
|
|
}
|
|
try self.buffer.appendSlice(") Tj\n");
|
|
}
|
|
|
|
// Colores
|
|
pub fn setStrokeColor(self: *ContentStream, color: Color) !void {
|
|
switch (color) {
|
|
.rgb => |c| {
|
|
const r = @as(f32, @floatFromInt(c.r)) / 255.0;
|
|
const g = @as(f32, @floatFromInt(c.g)) / 255.0;
|
|
const b = @as(f32, @floatFromInt(c.b)) / 255.0;
|
|
try self.buffer.writer().print("{d:.3} {d:.3} {d:.3} RG\n", .{ r, g, b });
|
|
},
|
|
.gray => |g| {
|
|
const v = @as(f32, @floatFromInt(g)) / 255.0;
|
|
try self.buffer.writer().print("{d:.3} G\n", .{v});
|
|
},
|
|
.cmyk => |c| {
|
|
// TODO: CMYK support
|
|
_ = c;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn setFillColor(self: *ContentStream, color: Color) !void {
|
|
switch (color) {
|
|
.rgb => |c| {
|
|
const r = @as(f32, @floatFromInt(c.r)) / 255.0;
|
|
const g = @as(f32, @floatFromInt(c.g)) / 255.0;
|
|
const b = @as(f32, @floatFromInt(c.b)) / 255.0;
|
|
try self.buffer.writer().print("{d:.3} {d:.3} {d:.3} rg\n", .{ r, g, b });
|
|
},
|
|
.gray => |g| {
|
|
const v = @as(f32, @floatFromInt(g)) / 255.0;
|
|
try self.buffer.writer().print("{d:.3} g\n", .{v});
|
|
},
|
|
.cmyk => |c| {
|
|
// TODO: CMYK support
|
|
_ = c;
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn setLineWidth(self: *ContentStream, width: f32) !void {
|
|
try self.buffer.writer().print("{d:.2} w\n", .{width});
|
|
}
|
|
};
|
|
```
|
|
|
|
### PdfObject Interface
|
|
|
|
```zig
|
|
pub const PdfObject = struct {
|
|
id: ?u32 = null,
|
|
vtable: *const VTable,
|
|
|
|
const VTable = struct {
|
|
serialize: *const fn (self: *const PdfObject, writer: anytype) anyerror!void,
|
|
};
|
|
|
|
pub fn ref(self: PdfObject) ![]const u8 {
|
|
if (self.id) |id| {
|
|
var buf: [32]u8 = undefined;
|
|
const len = std.fmt.bufPrint(&buf, "{d} 0 R", .{id}) catch unreachable;
|
|
return buf[0..len];
|
|
}
|
|
return error.ObjectNotAssignedId;
|
|
}
|
|
|
|
pub fn serialize(self: *const PdfObject, writer: anytype) !void {
|
|
try self.vtable.serialize(self, writer);
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Fases de Implementación
|
|
|
|
### Fase 1: Refactorización Core (PRÓXIMA)
|
|
|
|
1. Separar `root.zig` en múltiples archivos
|
|
2. Implementar `ContentStream` como struct separado
|
|
3. Implementar `PdfObject` base
|
|
4. Arreglar xref table
|
|
5. Tests exhaustivos
|
|
|
|
### Fase 2: Sistema de Texto Completo
|
|
|
|
1. `cell()` con bordes y alineación
|
|
2. `multi_cell()` con word wrap
|
|
3. Métricas de fuentes Type1 (comptime)
|
|
4. Cálculo de ancho de texto
|
|
|
|
### Fase 3: Gráficos Completos
|
|
|
|
1. Círculos, elipses, arcos
|
|
2. Curvas Bezier
|
|
3. Dash patterns
|
|
4. Transformaciones (rotate, scale)
|
|
|
|
### Fase 4: Imágenes
|
|
|
|
1. Parser JPEG (headers para embeber directo)
|
|
2. Parser PNG (con soporte alpha)
|
|
3. Caché de imágenes
|
|
|
|
### Fase 5: Features Avanzados
|
|
|
|
1. Sistema de tablas
|
|
2. Links internos y externos
|
|
3. Bookmarks/outline
|
|
4. Headers/footers
|
|
5. Compresión de streams (zlib)
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- `ARQUITECTURA_FPDF2.md` - Análisis detallado de fpdf2
|
|
- `PLAN_MAESTRO_ZPDF.md` - Plan general del proyecto
|
|
- `/reference/fpdf2/` - Código fuente de fpdf2 clonado
|