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>
14 KiB
14 KiB
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
- Traducción fiel de fpdf2 - Misma arquitectura, adaptada a idiomas de Zig
- Zero dependencias - Todo en Zig puro (excepto zlib para compresión)
- API ergonómica - Aprovechamos las capacidades de Zig
- Comptime donde sea posible - Métricas de fuentes, validaciones
- 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
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
// 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
// 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
// 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
// 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
// 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
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
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
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)
- Separar
root.zigen múltiples archivos - Implementar
ContentStreamcomo struct separado - Implementar
PdfObjectbase - Arreglar xref table
- Tests exhaustivos
Fase 2: Sistema de Texto Completo
cell()con bordes y alineaciónmulti_cell()con word wrap- Métricas de fuentes Type1 (comptime)
- Cálculo de ancho de texto
Fase 3: Gráficos Completos
- Círculos, elipses, arcos
- Curvas Bezier
- Dash patterns
- Transformaciones (rotate, scale)
Fase 4: Imágenes
- Parser JPEG (headers para embeber directo)
- Parser PNG (con soporte alpha)
- Caché de imágenes
Fase 5: Features Avanzados
- Sistema de tablas
- Links internos y externos
- Bookmarks/outline
- Headers/footers
- Compresión de streams (zlib)
Referencias
ARQUITECTURA_FPDF2.md- Análisis detallado de fpdf2PLAN_MAESTRO_ZPDF.md- Plan general del proyecto/reference/fpdf2/- Código fuente de fpdf2 clonado