diff --git a/.gitignore b/.gitignore index dc960b8..b17a94f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ zig-out/ *.a *.so *.pdf +reference/ diff --git a/CLAUDE.md b/CLAUDE.md index 499fae4..a510606 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,8 +2,8 @@ > **Ultima actualizacion**: 2025-12-08 > **Lenguaje**: Zig 0.15.2 -> **Estado**: v0.1 - Core funcional, en desarrollo activo -> **Inspiracion**: gofpdf (Go), fpdf (PHP) +> **Estado**: v0.2 - Sistema de texto completo (cell, multiCell, alignment) +> **Fuente principal**: fpdf2 (Python) - https://github.com/py-pdf/fpdf2 ## Descripcion del Proyecto @@ -11,80 +11,140 @@ **Filosofia**: - Zero dependencias (100% Zig puro) -- API simple y directa +- API simple y directa inspirada en fpdf2 - Enfocado en generacion de facturas/documentos comerciales - Soporte para texto, tablas, imagenes y formas basicas - Calidad open source (doc comments, codigo claro) -**Objetivo**: Ser el pilar para generar PDFs en Zig con codigo 100% propio, replicando funcionalidad de librerias maduras como gofpdf. +**Objetivo**: Ser el pilar para generar PDFs en Zig con codigo 100% propio, replicando funcionalidad de librerias maduras como fpdf2/gofpdf. --- ## Estado Actual del Proyecto -### Implementacion v0.1 (Core Funcional) +### Implementacion v0.2 (Sistema de Texto Completo) | Componente | Estado | Archivo | |------------|--------|---------| -| **Document** | | | -| Document init/deinit | ✅ | `src/root.zig` | -| addPage (standard sizes) | ✅ | `src/root.zig` | -| addPageCustom | ✅ | `src/root.zig` | -| render() to buffer | ✅ | `src/root.zig` | -| saveToFile() | ✅ | `src/root.zig` | +| **Pdf (API Nueva)** | | | +| Pdf init/deinit | OK | `src/pdf.zig` | +| setTitle, setAuthor, setSubject | OK | `src/pdf.zig` | +| addPage (with options) | OK | `src/pdf.zig` | +| output() / render() | OK | `src/pdf.zig` | +| save() | OK | `src/pdf.zig` | +| **Document (Legacy)** | | | +| Document init/deinit | OK | `src/root.zig` | +| addPage (standard sizes) | OK | `src/root.zig` | +| render() / saveToFile() | OK | `src/root.zig` | | **Page** | | | -| Page init/deinit | ✅ | `src/root.zig` | -| setFont | ✅ | `src/root.zig` | -| drawText | ✅ | `src/root.zig` | -| setFillColor | ✅ | `src/root.zig` | -| setStrokeColor | ✅ | `src/root.zig` | -| setLineWidth | ✅ | `src/root.zig` | -| drawLine | ✅ | `src/root.zig` | -| drawRect | ✅ | `src/root.zig` | -| fillRect | ✅ | `src/root.zig` | -| drawFilledRect | ✅ | `src/root.zig` | +| Page init/deinit | OK | `src/page.zig` | +| setFont / getFont / getFontSize | OK | `src/page.zig` | +| setFillColor / setStrokeColor / setTextColor | OK | `src/page.zig` | +| setLineWidth | OK | `src/page.zig` | +| setXY / setX / setY / getX / getY | OK | `src/page.zig` | +| setMargins / setCellMargin | OK | `src/page.zig` | +| drawText | OK | `src/page.zig` | +| writeText | OK | `src/page.zig` | +| **cell()** | OK | `src/page.zig` | +| **cellAdvanced()** | OK | `src/page.zig` | +| **multiCell()** | OK | `src/page.zig` | +| **ln()** | OK | `src/page.zig` | +| **getStringWidth()** | OK | `src/page.zig` | +| **getEffectiveWidth()** | OK | `src/page.zig` | +| drawLine | OK | `src/page.zig` | +| drawRect / fillRect / drawFilledRect | OK | `src/page.zig` | +| rect (with RenderStyle) | OK | `src/page.zig` | | **Types** | | | -| PageSize enum (A4, Letter, etc.) | ✅ | `src/root.zig` | -| Font enum (14 Type1 fonts) | ✅ | `src/root.zig` | -| Color struct (RGB) | ✅ | `src/root.zig` | +| PageSize enum (A4, Letter, A3, A5, Legal) | OK | `src/objects/base.zig` | +| Orientation enum (portrait, landscape) | OK | `src/objects/base.zig` | +| Font enum (14 Type1 fonts) | OK | `src/fonts/type1.zig` | +| Color struct (RGB, CMYK, Grayscale) | OK | `src/graphics/color.zig` | +| **Align enum (left, center, right)** | OK | `src/page.zig` | +| **Border packed struct** | OK | `src/page.zig` | +| **CellPosition enum** | OK | `src/page.zig` | +| ContentStream | OK | `src/content_stream.zig` | +| OutputProducer | OK | `src/output/producer.zig` | ### Tests | Categoria | Tests | Estado | |-----------|-------|--------| -| Create empty document | 1 | ✅ | -| Add page | 1 | ✅ | -| Render minimal document | 1 | ✅ | -| Font names | 1 | ✅ | -| Color conversion | 1 | ✅ | -| Graphics operations | 1 | ✅ | -| **Total** | **6** | ✅ | +| root.zig (integration) | 8 | OK | +| page.zig (Page operations) | 18 | OK | +| content_stream.zig | 6 | OK | +| graphics/color.zig | 5 | OK | +| fonts/type1.zig | 5 | OK | +| objects/base.zig | 5 | OK | +| output/producer.zig | 5 | OK | +| **Total** | **52** | OK | ### Ejemplos | Ejemplo | Descripcion | Estado | |---------|-------------|--------| -| hello.zig | PDF minimo con texto y formas | ✅ | -| invoice.zig | Factura completa realista | ✅ | +| hello.zig | PDF minimo con texto y formas | OK | +| invoice.zig | Factura completa realista | OK | +| **text_demo.zig** | Demo sistema de texto (cells, tables, multiCell) | OK | + +--- + +## Arquitectura Modular + +``` +zpdf/ +├── CLAUDE.md # Este archivo - estado del proyecto +├── build.zig # Sistema de build +├── src/ +│ ├── root.zig # Exports publicos + Document legacy +│ ├── pdf.zig # Pdf facade (API nueva) +│ ├── page.zig # Page + sistema de texto +│ ├── content_stream.zig # Content stream (operadores PDF) +│ ├── fonts/ +│ │ ├── mod.zig # Exports de fonts +│ │ └── type1.zig # 14 fuentes Type1 + metricas +│ ├── graphics/ +│ │ ├── mod.zig # Exports de graphics +│ │ └── color.zig # Color (RGB, CMYK, Gray) +│ ├── objects/ +│ │ ├── mod.zig # Exports de objects +│ │ └── base.zig # PageSize, Orientation, Unit +│ └── output/ +│ ├── mod.zig # Exports de output +│ └── producer.zig # OutputProducer (serializa PDF) +├── examples/ +│ ├── hello.zig # Ejemplo basico +│ ├── invoice.zig # Factura ejemplo +│ └── text_demo.zig # Demo sistema de texto +└── docs/ + ├── PLAN_MAESTRO_ZPDF.md + ├── ARQUITECTURA_FPDF2.md + └── ARQUITECTURA_ZPDF.md +``` --- ## Roadmap -### Fase 1 - Core (COMPLETADO) +### Fase 1 - Core + Refactoring (COMPLETADO) - [x] Estructura documento PDF 1.4 - [x] Paginas (A4, Letter, A3, A5, Legal, custom) - [x] Texto basico (14 fuentes Type1 built-in) - [x] Lineas y rectangulos -- [x] Colores RGB +- [x] Colores RGB, CMYK, Grayscale - [x] Serializacion correcta +- [x] Refactoring modular (separar en archivos) +- [x] Arreglar errores Zig 0.15 (ArrayListUnmanaged) -### Fase 2 - Texto Avanzado (PENDIENTE) -- [ ] Multiples fuentes en mismo documento -- [ ] Alineacion (izquierda, centro, derecha) -- [ ] Word wrap automatico -- [ ] Interlineado configurable -- [ ] Texto multilinea +### Fase 2 - Sistema de Texto (COMPLETADO) +- [x] cell() - celda con bordes, relleno, alineacion +- [x] cellAdvanced() - cell con control de posicion +- [x] multiCell() - texto con word wrap automatico +- [x] ln() - salto de linea +- [x] getStringWidth() - ancho de texto +- [x] Alineacion (left, center, right) +- [x] Bordes configurables (Border packed struct) +- [x] Margenes de pagina +- [x] 18 tests para sistema de texto ### Fase 3 - Imagenes (PENDIENTE) - [ ] JPEG embebido @@ -96,147 +156,156 @@ - [ ] Helper para tablas - [ ] Numeracion de paginas - [ ] Headers/footers automaticos -- [ ] Margenes de pagina - [ ] Links/URLs ### Fase 5 - Avanzado (FUTURO) - [ ] Fuentes TTF embebidas -- [ ] Compresion de streams -- [ ] Metadatos documento (autor, titulo) +- [ ] Compresion de streams (zlib) - [ ] Bookmarks/outline - [ ] Forms (campos rellenables) --- -## Arquitectura - -### Estructura de Archivos - -``` -zpdf/ -├── CLAUDE.md # Este archivo - estado del proyecto -├── build.zig # Sistema de build -├── src/ -│ └── root.zig # Libreria principal (todo en uno por ahora) -└── examples/ - ├── hello.zig # Ejemplo basico - └── invoice.zig # Factura ejemplo -``` - -### Formato PDF - -Generamos PDF 1.4 (compatible con todos los lectores): - -``` -%PDF-1.4 -%[binary marker] -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [...] /Count N >> endobj -3 0 obj << /Type /Page ... >> endobj -4 0 obj << /Length ... >> stream ... endstream endobj -xref -0 N -trailer << /Size N /Root 1 0 R >> -startxref -... -%%EOF -``` - -### Fuentes Type1 Built-in - -PDF incluye 14 fuentes estandar que no necesitan embeber: -- **Helvetica**: helvetica, helvetica_bold, helvetica_oblique, helvetica_bold_oblique -- **Times**: times_roman, times_bold, times_italic, times_bold_italic -- **Courier**: courier, courier_bold, courier_oblique, courier_bold_oblique -- **Otros**: symbol, zapf_dingbats - -### Tamanos de Pagina - -| Nombre | Puntos | Milimetros | -|--------|--------|------------| -| A4 | 595 x 842 | 210 x 297 | -| A3 | 842 x 1191 | 297 x 420 | -| A5 | 420 x 595 | 148 x 210 | -| Letter | 612 x 792 | 216 x 279 | -| Legal | 612 x 1008 | 216 x 356 | - ---- - ## API Actual -### Crear Documento +### API Nueva (Pdf) + +```zig +const zpdf = @import("zpdf"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Crear documento + var doc = zpdf.Pdf.init(allocator, .{ + .page_size = .a4, + .orientation = .portrait, + }); + defer doc.deinit(); + + // Metadatos + doc.setTitle("Mi Documento"); + doc.setAuthor("zpdf"); + + // Agregar pagina + var page = try doc.addPage(.{}); + + // Configurar fuente y posicion + try page.setFont(.helvetica_bold, 24); + page.setXY(50, 800); + page.setMargins(50, 50, 50); + + // cell() - celda simple + try page.cell(0, 30, "Titulo", zpdf.Border.none, .center, false); + page.ln(35); + + // Tabla con cells + try page.setFont(.helvetica, 12); + page.setFillColor(zpdf.Color.light_gray); + try page.cell(150, 20, "Columna 1", zpdf.Border.all, .center, true); + try page.cell(150, 20, "Columna 2", zpdf.Border.all, .center, true); + page.ln(null); + + // multiCell - texto con word wrap + const texto_largo = "Este es un texto largo que se ajustara automaticamente..."; + try page.multiCell(400, null, texto_largo, zpdf.Border.all, .left, false); + + // Guardar + try doc.save("documento.pdf"); +} +``` + +### API Legacy (Document) ```zig const pdf = @import("zpdf"); var doc = pdf.Document.init(allocator); defer doc.deinit(); -``` -### Agregar Paginas - -```zig -// Tamano estandar var page = try doc.addPage(.a4); - -// Tamano personalizado (en puntos, 1pt = 1/72 inch) -var page = try doc.addPageCustom(500, 700); -``` - -### Texto - -```zig try page.setFont(.helvetica_bold, 24); -page.setFillColor(pdf.Color{ .r = 0, .g = 0, .b = 255 }); try page.drawText(50, 750, "Titulo"); -try page.setFont(.times_roman, 12); -page.setFillColor(pdf.Color.black); -try page.drawText(50, 700, "Texto normal"); -``` - -### Graficos - -```zig -// Linea -try page.setLineWidth(1); -page.setStrokeColor(pdf.Color.gray); -try page.drawLine(50, 600, 500, 600); - -// Rectangulo solo borde -try page.drawRect(50, 500, 200, 100); - -// Rectangulo relleno -page.setFillColor(pdf.Color.light_gray); -try page.fillRect(50, 400, 200, 100); - -// Rectangulo con borde y relleno -page.setFillColor(pdf.Color{ .r = 200, .g = 220, .b = 255 }); -page.setStrokeColor(pdf.Color.blue); -try page.drawFilledRect(50, 300, 200, 100); -``` - -### Guardar - -```zig -// A archivo try doc.saveToFile("documento.pdf"); - -// A buffer (para enviar por red, etc.) -const data = try doc.render(allocator); -defer allocator.free(data); ``` -### Colores Predefinidos +### Sistema de Texto ```zig -pdf.Color.black // (0, 0, 0) -pdf.Color.white // (255, 255, 255) -pdf.Color.red // (255, 0, 0) -pdf.Color.green // (0, 255, 0) -pdf.Color.blue // (0, 0, 255) -pdf.Color.gray // (128, 128, 128) -pdf.Color.light_gray // (200, 200, 200) +// cell(width, height, text, border, align, fill) +// width=0 extiende hasta margen derecho +// width=null ajusta al ancho del texto +try page.cell(100, 20, "Hello", Border.all, .left, false); +try page.cell(0, 20, "Full width", Border.none, .center, true); + +// cellAdvanced - control de posicion despues de la celda +try page.cellAdvanced(100, 20, "A", Border.all, .left, false, .right); // mover a la derecha +try page.cellAdvanced(100, 20, "B", Border.all, .left, false, .next_line); // nueva linea +try page.cellAdvanced(100, 20, "C", Border.all, .left, false, .below); // debajo (mismo X) + +// multiCell - word wrap automatico +try page.multiCell(200, 15, "Texto largo que se ajusta automaticamente al ancho especificado.", Border.all, .left, true); + +// ln(height) - salto de linea +page.ln(20); // salto de 20 puntos +page.ln(null); // salto del tamano de fuente actual + +// getStringWidth - ancho del texto +const width = page.getStringWidth("Hello World"); +``` + +### Bordes + +```zig +const Border = packed struct { + left: bool, + top: bool, + right: bool, + bottom: bool, +}; + +Border.none // sin bordes +Border.all // todos los bordes +Border{ .left = true, .bottom = true } // bordes especificos +Border.fromInt(0b1111) // desde entero (LTRB) +``` + +### Alineacion + +```zig +const Align = enum { left, center, right }; + +try page.cell(100, 20, "Left", Border.all, .left, false); +try page.cell(100, 20, "Center", Border.all, .center, false); +try page.cell(100, 20, "Right", Border.all, .right, false); +``` + +### Colores + +```zig +// Predefinidos +zpdf.Color.black +zpdf.Color.white +zpdf.Color.red +zpdf.Color.green +zpdf.Color.blue +zpdf.Color.light_gray +zpdf.Color.medium_gray + +// RGB (0-255) +zpdf.Color.rgb(41, 98, 255) + +// Hex +zpdf.Color.hex(0xFF8000) + +// CMYK (0.0-1.0) +zpdf.Color.cmyk(0.0, 1.0, 1.0, 0.0) + +// Grayscale (0.0-1.0) +zpdf.Color.gray(0.5) ``` --- @@ -254,12 +323,38 @@ $ZIG build $ZIG build test # Ejecutar ejemplos -$ZIG build hello && ./zig-out/bin/hello -$ZIG build invoice && ./zig-out/bin/invoice +$ZIG build && ./zig-out/bin/hello +$ZIG build && ./zig-out/bin/invoice +$ZIG build && ./zig-out/bin/text_demo ``` --- +## Fuentes Type1 Built-in + +PDF incluye 14 fuentes estandar que no necesitan embeber: + +| Familia | Variantes | +|---------|-----------| +| Helvetica | helvetica, helvetica_bold, helvetica_oblique, helvetica_bold_oblique | +| Times | times_roman, times_bold, times_italic, times_bold_italic | +| Courier | courier, courier_bold, courier_oblique, courier_bold_oblique | +| Otros | symbol, zapf_dingbats | + +--- + +## Tamanos de Pagina + +| Nombre | Puntos | Milimetros | +|--------|--------|------------| +| A4 | 595 x 842 | 210 x 297 | +| A3 | 842 x 1191 | 297 x 420 | +| A5 | 420 x 595 | 148 x 210 | +| Letter | 612 x 792 | 216 x 279 | +| Legal | 612 x 1008 | 216 x 356 | + +--- + ## Equipo y Metodologia ### Normas de Trabajo Centralizadas @@ -269,18 +364,6 @@ $ZIG build invoice && ./zig-out/bin/invoice /mnt/cello2/arno/re/recode/TEAM_STANDARDS/ ``` -**Archivos clave a leer**: -- `LAST_UPDATE.md` - **LEER PRIMERO** - Cambios recientes en normas -- `NORMAS_TRABAJO_CONSENSUADAS.md` - Metodologia fundamental -- `QUICK_REFERENCE.md` - Cheat sheet rapido - -### Estandares Zig Open Source (Seccion #24) - -- **Claridad**: Codigo autoexplicativo, nombres descriptivos -- **Doc comments**: `///` en todas las funciones publicas -- **Idiomatico**: snake_case, error handling explicito -- **Sin magia**: Preferir codigo explicito sobre abstracciones complejas - ### Control de Versiones ```bash @@ -293,22 +376,22 @@ main # Codigo estable --- -## Referencias - -### gofpdf (Referencia principal) -- Repo: https://github.com/go-pdf/fpdf -- Objetivo: Replicar funcionalidad core en Zig - -### PDF Reference -- PDF 1.4 Spec: https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf - -### Otros (Referencia) -- pdf-nano (Zig): https://github.com/GregorBudweiser/pdf-nano - ---- - ## Historial de Desarrollo +### 2025-12-08 - v0.2 (Sistema de Texto Completo) +- Refactoring modular completo (fonts/, graphics/, objects/, output/) +- Arreglados errores Zig 0.15 (ArrayListUnmanaged API) +- Sistema de texto completo: + - cell() con bordes, relleno, alineacion + - cellAdvanced() con control de posicion + - multiCell() con word wrap automatico + - ln() para saltos de linea + - getStringWidth() para calcular anchos + - Margenes de pagina configurables +- Nueva API Pdf (mas limpia que Document legacy) +- 52 tests unitarios pasando +- Nuevo ejemplo: text_demo.zig + ### 2025-12-08 - v0.1 (Core Funcional) - Estructura inicial del proyecto - Document, Page, Color, Font, PageSize types @@ -321,38 +404,13 @@ main # Codigo estable --- -## Notas de Desarrollo +## Referencias -### Proxima Sesion -1. Implementar word wrap automatico para texto largo -2. Agregar helper para tablas (simplificar invoice.zig) -3. Soporte para imagenes JPEG -4. Alineacion de texto (center, right) - -### Lecciones Aprendidas -- PDF es formato relativamente simple para generacion basica -- Content streams usan operadores PostScript-like -- Coordenadas PDF son desde bottom-left (Y aumenta hacia arriba) -- Las 14 fuentes Type1 estan garantizadas en todos los lectores PDF - -### Zig 0.15 Notas -- `std.ArrayList` cambio a `std.ArrayListUnmanaged` con allocator explicito -- `writer()` ahora requiere allocator como parametro -- `toOwnedSlice()` requiere allocator -- `deinit()` requiere allocator +- **fpdf2 (Python)**: https://github.com/py-pdf/fpdf2 - Fuente principal +- **PDF 1.4 Spec**: https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf +- **pdf-nano (Zig)**: https://github.com/GregorBudweiser/pdf-nano --- -## Proyectos Relacionados - -| Proyecto | Descripcion | Repo | -|----------|-------------|------| -| **zcatui** | TUI library (ratatui-style) | git.reugenio.com/reugenio/zcatui | -| **zsqlite** | SQLite wrapper | git.reugenio.com/reugenio/zsqlite | -| **zpdf** | PDF generator (este) | git.reugenio.com/reugenio/zpdf | -| **service-monitor** | Monitor de servicios | git.reugenio.com/reugenio/service-monitor | - ---- - -**© zpdf - Generador PDF para Zig** -*2025-12-08 - En desarrollo activo* +**zpdf - Generador PDF para Zig** +*v0.2 - 2025-12-08* diff --git a/build.zig b/build.zig index f0077a3..74b793b 100644 --- a/build.zig +++ b/build.zig @@ -61,4 +61,23 @@ pub fn build(b: *std.Build) void { run_invoice.step.dependOn(b.getInstallStep()); const invoice_step = b.step("invoice", "Run invoice example"); invoice_step.dependOn(&run_invoice.step); + + // Example: text_demo + const text_demo_exe = b.addExecutable(.{ + .name = "text_demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/text_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zpdf", .module = zpdf_mod }, + }, + }), + }); + b.installArtifact(text_demo_exe); + + const run_text_demo = b.addRunArtifact(text_demo_exe); + run_text_demo.step.dependOn(b.getInstallStep()); + const text_demo_step = b.step("text_demo", "Run text demo example"); + text_demo_step.dependOn(&run_text_demo.step); } diff --git a/docs/ARQUITECTURA_FPDF2.md b/docs/ARQUITECTURA_FPDF2.md new file mode 100644 index 0000000..78e42aa --- /dev/null +++ b/docs/ARQUITECTURA_FPDF2.md @@ -0,0 +1,509 @@ +# Análisis de Arquitectura: fpdf2 (Python) + +**Fecha:** 2025-12-08 +**Versión analizada:** fpdf2 v2.8.5 +**Repositorio:** https://github.com/py-pdf/fpdf2 + +--- + +## Resumen Ejecutivo + +fpdf2 es una librería Python para generación de PDFs que ha evolucionado durante 20+ años desde el FPDF original de PHP. Su arquitectura se basa en: + +1. **Una clase principal `FPDF`** que actúa como facade/builder +2. **Objetos PDF tipados** (`syntax.py`) que representan la estructura del documento +3. **Un OutputProducer** que serializa todo a bytes +4. **Sistemas modulares** para texto, gráficos, imágenes, etc. + +--- + +## Estructura de Archivos (por importancia) + +| Archivo | Líneas | Descripción | +|---------|--------|-------------| +| `fpdf.py` | 6094 | Clase principal FPDF - facade/builder | +| `drawing.py` | 5271 | Sistema de dibujo vectorial | +| `fonts.py` | 3365 | Gestión de fuentes (Type1 + TTF) | +| `output.py` | 2011 | Serialización final del PDF | +| `enums.py` | 1778 | Enumeraciones (Align, XPos, YPos, etc.) | +| `svg.py` | 1806 | Parser/renderer SVG | +| `pattern.py` | 1487 | Patrones de relleno | +| `html.py` | 1283 | Parser HTML a PDF | +| `table.py` | 925 | Sistema de tablas | +| `line_break.py` | 817 | Word wrap y saltos de línea | +| `image_parsing.py` | 715 | Parseo de imágenes (JPEG, PNG) | +| `text_region.py` | 737 | Regiones de texto (columnas) | +| `syntax.py` | 405 | Tipos básicos PDF | +| `graphics_state.py` | 393 | Estado gráfico (colores, líneas) | + +--- + +## Arquitectura de Clases + +### 1. Clase Principal: FPDF (`fpdf.py`) + +``` +FPDF(GraphicsStateMixin, TextRegionMixin) +│ +├── Estado del documento +│ ├── page: int # Página actual (1-indexed) +│ ├── pages: Dict[int, PDFPage] # Todas las páginas +│ ├── fonts: FontRegistry # Fuentes registradas +│ ├── links: dict # Enlaces internos +│ └── image_cache: ImageCache # Caché de imágenes +│ +├── Estado gráfico (de GraphicsStateMixin) +│ ├── draw_color: DeviceRGB # Color de trazo +│ ├── fill_color: DeviceRGB # Color de relleno +│ ├── text_color: DeviceRGB # Color de texto +│ ├── line_width: float # Grosor de línea +│ └── dash_pattern: dict # Patrón de línea discontinua +│ +├── Estado de texto +│ ├── font_family: str # Familia de fuente actual +│ ├── font_style: str # Estilo (B, I, BI) +│ ├── font_size_pt: float # Tamaño en puntos +│ ├── current_font: CoreFont|TTFFont +│ ├── underline: bool +│ └── strikethrough: bool +│ +├── Posicionamiento +│ ├── x, y: float # Posición actual +│ ├── l_margin, t_margin, r_margin, b_margin +│ ├── w, h: float # Dimensiones página (user units) +│ ├── w_pt, h_pt: float # Dimensiones página (points) +│ └── k: float # Factor de escala (unit -> points) +│ +└── Métodos principales + ├── add_page() # Nueva página + ├── set_font(family, style, size) + ├── cell(w, h, text, ...) # Celda con texto + ├── multi_cell(w, h, text, ...) # Celda multilínea + ├── text(x, y, text) # Texto en posición absoluta + ├── line(x1, y1, x2, y2) # Línea + ├── rect(x, y, w, h, style) # Rectángulo + ├── image(file, x, y, w, h) # Imagen + └── output(name) # Generar PDF final +``` + +### 2. Sistema de Tipos PDF (`syntax.py`) + +``` +PDFObject (base) +├── id: int # ID del objeto (asignado al serializar) +├── ref: str # "N 0 R" para referencias +├── serialize() -> str # Convierte a texto PDF +└── _build_obj_dict() -> dict # Construye diccionario de propiedades + +PDFContentStream(PDFObject) +├── _contents: bytes # Contenido del stream +├── compress: bool # Si aplicar FlateDecode +├── filter: Name # /FlateDecode o None +└── length: int # Longitud del contenido + +Otros tipos: +├── Name(str) # /NombrePDF +├── PDFString(str) # (texto) o +├── PDFArray(list) # [elem1 elem2] +├── PDFDate # D:YYYYMMDDHHmmSS +└── Raw(str) # Texto sin transformar +``` + +### 3. Sistema de Output (`output.py`) + +``` +OutputProducer +├── fpdf: FPDF # Referencia al documento +├── pdf_objs: list # Lista de todos los objetos PDF +├── obj_id: int # Contador de IDs +├── offsets: dict # Offset de cada objeto para xref +└── buffer: bytearray # Buffer de salida final + +Flujo de bufferize(): +1. Insertar PDFHeader +2. Crear páginas root (PDFPagesRoot) +3. Crear catálogo (PDFCatalog) +4. Añadir páginas (PDFPage + content streams) +5. Añadir anotaciones +6. Insertar recursos (fuentes, imágenes, etc.) +7. Añadir estructura de árbol +8. Añadir outline/bookmarks +9. Añadir metadata XMP +10. Añadir info dictionary +11. Añadir tabla xref y trailer +12. Serializar todo a buffer +``` + +### 4. Objetos de Página + +``` +PDFPage(PDFObject) +├── type = Name("Page") +├── parent: PDFPagesRoot # Referencia al padre +├── media_box: str # Dimensiones [0 0 W H] +├── contents: PDFContentStream # Stream de contenido +├── resources: PDFResources # Recursos usados +├── annots: list # Anotaciones +└── ... + +PDFPagesRoot(PDFObject) +├── type = Name("Pages") +├── count: int # Número de páginas +├── kids: PDFArray[PDFPage] # Array de páginas +└── media_box: str # Dimensiones por defecto +``` + +--- + +## Flujo de Generación de Contenido + +### Escribir texto con cell() + +```python +# 1. Usuario llama +pdf.cell(100, 10, "Hola mundo") + +# 2. FPDF.cell() hace: +# a. Normaliza el texto +# b. Preload de estilos de fuente (bold, italic, etc.) +# c. Crea TextLine con fragmentos +# d. Llama a _render_styled_text_line() + +# 3. _render_styled_text_line(): +# a. Calcula ancho y posición +# b. Genera comandos PDF para el content stream +# c. Llama a _out() para añadir al stream + +# 4. _out() añade al buffer de la página actual: +# "BT 100.00 700.00 Td (Hola mundo) Tj ET" +``` + +### Comandos PDF generados (Content Stream) + +``` +% Texto +BT % Begin Text +/F1 12 Tf % Font 1, 12pt +100.00 700.00 Td % Move to position +(Hola mundo) Tj % Show text +ET % End Text + +% Línea +100.00 700.00 m % Move to +200.00 700.00 l % Line to +S % Stroke + +% Rectángulo +100.00 700.00 50.00 -20.00 re % Rectangle +S % Stroke (o f para fill, B para ambos) + +% Colores +0 0 0 RG % Set stroke color RGB +0.5 0.5 0.5 rg % Set fill color RGB + +% Estado gráfico +q % Save state +... operaciones ... +Q % Restore state +``` + +--- + +## Sistema de Unidades + +```python +# Factor de escala k (de unit a points) +# 1 point = 1/72 inch + +unit_to_k = { + "pt": 1, + "mm": 72 / 25.4, # ~2.834645669 + "cm": 72 / 2.54, # ~28.34645669 + "in": 72, +} + +# Conversión: +# points = user_units * k +# user_units = points / k + +# Coordenadas Y se invierten: +# pdf_y = (page_height - user_y) * k +``` + +--- + +## Sistema de Fuentes + +### Fuentes Type1 (Core) + +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 +``` + +Métricas hardcodeadas en `fpdf/font/` como archivos `.pkl`. + +### Fuentes TTF + +1. Se parsea el archivo TTF con `fontTools` +2. Se hace subset (solo glifos usados) +3. Se embebe el subset en el PDF +4. Se crea tabla CMap para mapeo unicode + +--- + +## Sistema de Colores + +```python +# DeviceRGB (0-1 floats) +class DeviceRGB: + r: float # 0.0 - 1.0 + g: float + b: float + +# También soporta DeviceCMYK y DeviceGray + +# Serialización a PDF: +# Stroke: "R G B RG" (ej: "1 0 0 RG" = rojo) +# Fill: "R G B rg" (ej: "0 1 0 rg" = verde) +``` + +--- + +## Sistema de Imágenes + +```python +# Formatos soportados: +# - JPEG: Se embebe directamente (DCTDecode) +# - PNG: Se descomprime y recomprime (FlateDecode) +# Alpha channel se convierte a SMask +# - GIF: Se convierte a PNG +# - TIFF: Se convierte a PNG +# - SVG: Se renderiza a paths + +# Cada imagen es un PDFXObject: +PDFXObject(PDFContentStream) +├── type = Name("XObject") +├── subtype = Name("Image") +├── width, height: int +├── color_space: str # /DeviceRGB, /DeviceGray, /DeviceCMYK +├── bits_per_component: int # 8 +├── filter: Name # /DCTDecode, /FlateDecode +├── decode_parms: dict # Parámetros de decodificación +└── s_mask: PDFXObject # Máscara de transparencia (opcional) +``` + +--- + +## Content Streams - Operadores PDF Principales + +### Operadores de Gráficos + +| Operador | Descripción | Ejemplo | +|----------|-------------|---------| +| `m` | moveto | `100 200 m` | +| `l` | lineto | `200 200 l` | +| `c` | curveto (bezier) | `x1 y1 x2 y2 x3 y3 c` | +| `re` | rectangle | `x y w h re` | +| `h` | closepath | `h` | +| `S` | stroke | `S` | +| `f` | fill | `f` | +| `B` | fill + stroke | `B` | +| `n` | no-op (end path) | `n` | +| `q` | save state | `q` | +| `Q` | restore state | `Q` | +| `w` | line width | `0.5 w` | +| `J` | line cap | `0 J` (0=butt, 1=round, 2=square) | +| `j` | line join | `0 j` (0=miter, 1=round, 2=bevel) | +| `d` | dash pattern | `[3 2] 0 d` | +| `cm` | transform matrix | `1 0 0 1 tx ty cm` | + +### Operadores de Color + +| Operador | Descripción | Ejemplo | +|----------|-------------|---------| +| `RG` | stroke RGB | `1 0 0 RG` | +| `rg` | fill RGB | `0 1 0 rg` | +| `K` | stroke CMYK | `0 0 0 1 K` | +| `k` | fill CMYK | `0 0 0 1 k` | +| `G` | stroke gray | `0.5 G` | +| `g` | fill gray | `0.5 g` | + +### Operadores de Texto + +| Operador | Descripción | Ejemplo | +|----------|-------------|---------| +| `BT` | begin text | `BT` | +| `ET` | end text | `ET` | +| `Tf` | set font | `/F1 12 Tf` | +| `Td` | move text position | `100 200 Td` | +| `Tj` | show text | `(Hello) Tj` | +| `TJ` | show text with kerning | `[(H) -20 (ello)] TJ` | +| `Tc` | character spacing | `0.5 Tc` | +| `Tw` | word spacing | `2 Tw` | +| `Tz` | horizontal scaling | `100 Tz` | +| `TL` | leading | `14 TL` | +| `T*` | next line | `T*` | +| `Tr` | render mode | `0 Tr` (0=fill, 1=stroke, 2=both) | + +### Operadores de Imagen + +| Operador | Descripción | Ejemplo | +|----------|-------------|---------| +| `Do` | paint XObject | `/I1 Do` | + +--- + +## Estructura de un PDF Mínimo + +``` +%PDF-1.4 +%éëñ¿ + +1 0 obj +<> +endobj + +2 0 obj +<> +endobj + +3 0 obj +<>>>>> +endobj + +4 0 obj +<> +stream +BT /F1 12 Tf 100 700 Td (Hola mundo) Tj ET +endstream +endobj + +5 0 obj +<> +endobj + +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000060 00000 n +0000000147 00000 n +0000000247 00000 n +0000000340 00000 n + +trailer +<> + +startxref +448 +%%EOF +``` + +--- + +## Core Mínimo para Facturas + +Para generar facturas necesitamos: + +### Imprescindible (Fase 1) +- [x] Estructura básica PDF (header, catalog, pages, xref, trailer) +- [x] Fuentes Type1 (Helvetica, Times, Courier) +- [x] Texto: `text()`, `cell()` +- [x] Gráficos: `line()`, `rect()` +- [x] Colores RGB + +### Importante (Fase 2) +- [ ] `multi_cell()` con word wrap +- [ ] Alineación texto (left, center, right, justify) +- [ ] Imágenes JPEG/PNG (para logos) +- [ ] SetMargins, SetAutoPageBreak + +### Deseable (Fase 3) +- [ ] Tablas +- [ ] Headers/footers +- [ ] Links +- [ ] Numeración de páginas + +--- + +## Notas para Implementación en Zig + +### Equivalencias de Tipos + +| Python | Zig | +|--------|-----| +| `class FPDF` | `pub const Pdf = struct` | +| `str` | `[]const u8` | +| `float` | `f32` | +| `int` | `i32` | +| `list` | `std.ArrayList(T)` o `[]const T` | +| `dict` | `std.StringHashMap(V)` o `struct` | +| `bytes` | `[]u8` | +| `Optional[T]` | `?T` | +| `BytesIO` | `std.ArrayList(u8)` | +| Exception | `error` set | + +### Patrón de Content Stream + +```zig +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 write(self: *ContentStream, comptime fmt: []const u8, args: anytype) !void { + try std.fmt.format(self.buffer.writer(), fmt, args); + } + + pub fn moveTo(self: *ContentStream, x: f32, y: f32) !void { + try self.write("{d:.2} {d:.2} m\n", .{x, y}); + } + + pub fn lineTo(self: *ContentStream, x: f32, y: f32) !void { + try self.write("{d:.2} {d:.2} l\n", .{x, y}); + } + + pub fn stroke(self: *ContentStream) !void { + try self.write("S\n", .{}); + } +}; +``` + +### Factor de escala + +```zig +pub const Unit = enum { + pt, + mm, + cm, + in, + + pub fn toK(self: Unit) f32 { + return switch (self) { + .pt => 1.0, + .mm => 72.0 / 25.4, + .cm => 72.0 / 2.54, + .in => 72.0, + }; + } +}; +``` + +--- + +## Referencias + +- [PDF Reference 1.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf) +- [PDF 1.7 ISO 32000](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf) +- [fpdf2 Source Code](https://github.com/py-pdf/fpdf2) +- [fpdf2 Documentation](https://py-pdf.github.io/fpdf2/) diff --git a/docs/ARQUITECTURA_ZPDF.md b/docs/ARQUITECTURA_ZPDF.md new file mode 100644 index 0000000..b913858 --- /dev/null +++ b/docs/ARQUITECTURA_ZPDF.md @@ -0,0 +1,541 @@ +# 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 diff --git a/docs/PLAN_MAESTRO_ZPDF.md b/docs/PLAN_MAESTRO_ZPDF.md new file mode 100644 index 0000000..3938cc5 --- /dev/null +++ b/docs/PLAN_MAESTRO_ZPDF.md @@ -0,0 +1,326 @@ +# PLAN MAESTRO: zpdf - La Mejor Librería PDF en Zig + +**Fecha inicio:** 2025-12-08 +**Objetivo:** Crear la mejor librería PDF para Zig, basada en fpdf2 (Python) +**Filosofía:** Sin prisa, hacerlo perfecto + +--- + +## DECISIÓN ARQUITECTÓNICA + +### Fuente Principal: fpdf2 (Python) + +**Repositorio:** https://github.com/py-pdf/fpdf2 +**Documentación:** https://py-pdf.github.io/fpdf2/ + +**Por qué fpdf2:** +1. Arquitectura moderna y refinada (evolución de 20+ años de FPDF) +2. 1300+ tests = especificación ejecutable +3. UTF-8 nativo desde el diseño +4. Código Python limpio, fácil de traducir a Zig +5. Documentación exhaustiva +6. Features completos para documentos comerciales + +**Descartados:** +- go-pdf/fpdf: Arrastra diseño antiguo del PHP original +- UniPDF: Comercial, no podemos estudiar el código +- lopdf (Rust): Muy bajo nivel, más para manipular que crear + +--- + +## FASES DE IMPLEMENTACIÓN + +### FASE 0: Estudio y Documentación (ACTUAL) +- [ ] Clonar fpdf2 +- [ ] Leer y analizar fpdf.py completo +- [ ] Documentar arquitectura interna +- [ ] Identificar clases y métodos principales +- [ ] Mapear tipos Python → Zig +- [ ] Documentar el "core mínimo" necesario + +### FASE 1: Core PDF Engine +**Objetivo:** Generar PDF válido mínimo + +Componentes: +- [ ] PDFObject: Representación de objetos PDF (dict, array, stream, etc.) +- [ ] Document: Contenedor principal +- [ ] Page: Páginas individuales +- [ ] ContentStream: Comandos de dibujo +- [ ] Writer: Serialización a bytes PDF +- [ ] CrossReference: Tabla xref correcta + +Entregable: PDF vacío válido que abre en cualquier lector + +### FASE 2: Sistema de Texto +**Objetivo:** Texto con fuentes Type1 y posicionamiento + +Componentes: +- [ ] Font: Gestión de fuentes Type1 (14 estándar) +- [ ] TextState: Estado actual (fuente, tamaño, color) +- [ ] Cell(): Celda rectangular con texto +- [ ] MultiCell(): Texto con saltos de línea automáticos +- [ ] Write(): Texto fluido +- [ ] Text(): Texto en posición absoluta +- [ ] Alineación: left, center, right, justify + +Entregable: PDF con texto formateado, múltiples fuentes + +### FASE 3: Sistema de Gráficos +**Objetivo:** Líneas, formas, colores + +Componentes: +- [ ] Color: RGB, grayscale, CMYK +- [ ] Line(): Líneas +- [ ] Rect(): Rectángulos (stroke, fill, both) +- [ ] Circle/Ellipse(): Círculos y elipses +- [ ] Polygon(): Polígonos +- [ ] Bezier curves +- [ ] SetLineWidth, SetLineCap, SetLineJoin +- [ ] Transformaciones: translate, rotate, scale + +Entregable: PDF con gráficos vectoriales + +### FASE 4: Sistema de Imágenes +**Objetivo:** Embeber imágenes en PDF + +Componentes: +- [ ] JPEG: Embebido directo (DCTDecode) +- [ ] PNG: Con y sin alpha (FlateDecode) +- [ ] Image(): Posicionar y escalar imágenes +- [ ] Aspect ratio automático +- [ ] Caché de imágenes (no duplicar) + +Entregable: PDF con imágenes embebidas + +### FASE 5: Layout y Tablas +**Objetivo:** Helpers de alto nivel para documentos + +Componentes: +- [ ] Table: Helper para crear tablas +- [ ] Columns: Sistema de columnas +- [ ] SetMargins, SetAutoPageBreak +- [ ] Header/Footer callbacks +- [ ] Numeración de páginas +- [ ] Word wrap inteligente + +Entregable: Facturas completas con tablas + +### FASE 6: Features Avanzados +**Objetivo:** Completar la librería + +Componentes: +- [ ] Links internos y externos +- [ ] Bookmarks/Outline +- [ ] Metadata (título, autor, etc.) +- [ ] Compresión streams (zlib) +- [ ] UTF-8 con fuentes TrueType embebidas +- [ ] Encriptación básica (opcional) + +Entregable: Librería completa nivel producción + +--- + +## ARQUITECTURA ZPDF (Diseño Preliminar) + +``` +zpdf/ +├── src/ +│ ├── root.zig # Exports públicos +│ ├── document.zig # Document principal +│ ├── page.zig # Página individual +│ ├── objects.zig # Tipos PDF (dict, array, stream, etc.) +│ ├── writer.zig # Serialización PDF +│ ├── content_stream.zig # Comandos gráficos +│ ├── fonts/ +│ │ ├── font.zig # Interfaz Font +│ │ ├── type1.zig # Fuentes Type1 estándar +│ │ └── metrics.zig # Métricas de caracteres +│ ├── graphics/ +│ │ ├── color.zig # Colores RGB/CMYK/Gray +│ │ ├── path.zig # Paths vectoriales +│ │ └── transform.zig # Transformaciones +│ ├── text/ +│ │ ├── state.zig # Estado de texto +│ │ ├── layout.zig # Word wrap, alineación +│ │ └── cell.zig # Cell, MultiCell, Write +│ ├── image/ +│ │ ├── jpeg.zig # Parser/embebido JPEG +│ │ └── png.zig # Parser/embebido PNG +│ └── util/ +│ ├── buffer.zig # Buffer de bytes +│ └── encoding.zig # Encoding texto PDF +├── examples/ +│ ├── hello.zig +│ ├── invoice.zig +│ ├── table.zig +│ └── images.zig +├── tests/ +│ └── ... (muchos tests) +└── docs/ + ├── PLAN_MAESTRO_ZPDF.md # Este archivo + ├── ARQUITECTURA_FPDF2.md # Análisis de fpdf2 + └── API.md # Documentación API +``` + +--- + +## API OBJETIVO (Inspirada en fpdf2) + +```zig +const std = @import("std"); +const pdf = @import("zpdf"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Crear documento + var doc = try pdf.Document.init(allocator, .{ + .orientation = .portrait, + .unit = .mm, + .format = .a4, + }); + defer doc.deinit(); + + // Metadata + doc.setTitle("Factura #001"); + doc.setAuthor("ACME Corp"); + + // Nueva página + var page = try doc.addPage(); + + // Márgenes + page.setMargins(10, 10, 10); + + // Fuente + try page.setFont(.helvetica_bold, 24); + + // Colores + page.setTextColor(pdf.Color.rgb(0, 100, 200)); + + // Texto + try page.cell(.{ + .width = 0, // Hasta el margen + .height = 10, + .text = "FACTURA", + .align = .right, + }); + + // Salto de línea + page.ln(10); + + // Línea + page.setDrawColor(pdf.Color.gray); + try page.line(10, page.getY(), 200, page.getY()); + + // Imagen + try page.image("logo.png", .{ + .x = 10, + .y = 10, + .width = 40, + }); + + // Tabla + try page.table(.{ + .x = 10, + .y = 100, + .columns = &.{ + .{ .header = "Descripción", .width = 80, .align = .left }, + .{ .header = "Cant.", .width = 20, .align = .center }, + .{ .header = "Precio", .width = 30, .align = .right }, + .{ .header = "Total", .width = 30, .align = .right }, + }, + .rows = &.{ + &.{ "Producto A", "2", "10.00", "20.00" }, + &.{ "Producto B", "1", "25.00", "25.00" }, + }, + }); + + // Guardar + try doc.save("factura.pdf"); +} +``` + +--- + +## MAPEO TIPOS PYTHON → ZIG + +| Python (fpdf2) | Zig (zpdf) | +|----------------|------------| +| `class FPDF` | `pub const Document = struct` | +| `str` | `[]const u8` | +| `float` | `f32` | +| `int` | `i32` o `u32` | +| `list` | `std.ArrayList` o slice | +| `dict` | `std.StringHashMap` o struct | +| `bytes` | `[]u8` | +| `Optional[T]` | `?T` | +| `Union[A, B]` | `union(enum)` | +| Exception | `error` union | +| `with open()` | `std.fs.File` | +| `io.BytesIO` | `std.ArrayList(u8)` | + +--- + +## COMANDOS + +```bash +# Zig +ZIG=/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig + +# Build +$ZIG build + +# Tests +$ZIG build test + +# Ejemplos +$ZIG build hello +$ZIG build invoice + +# Ver PDF generado +evince hello.pdf +``` + +--- + +## REFERENCIAS + +### Especificación PDF +- PDF 1.4: https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf +- PDF 1.7 (ISO 32000): https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + +### Librerías de Referencia +- fpdf2 (Python): https://github.com/py-pdf/fpdf2 +- go-pdf/fpdf (Go): https://codeberg.org/go-pdf/fpdf +- FPDF original (PHP): https://github.com/Setasign/FPDF + +### Documentación fpdf2 +- Tutorial: https://py-pdf.github.io/fpdf2/Tutorial.html +- API Reference: https://py-pdf.github.io/fpdf2/fpdf/ + +--- + +## NOTAS DE DESARROLLO + +*Se irán añadiendo conforme avance el proyecto* + +### 2025-12-08 - Inicio del proyecto +- Decisión: usar fpdf2 como fuente principal +- Razón: arquitectura más moderna, mejor documentación, más tests +- Primer paso: clonar y estudiar fpdf2 + +--- + +## SI ESTA CONVERSACIÓN SE CORTA + +1. Leer este documento completo +2. Leer CLAUDE.md del proyecto +3. Continuar desde donde se quedó según las fases +4. El código de fpdf2 estará clonado en: `/mnt/cello2/arno/re/recode/zig/zpdf/reference/fpdf2/` +5. El análisis de arquitectura estará en: `docs/ARQUITECTURA_FPDF2.md` + +--- + +**Recuerda:** Sin prisa, lo importante es hacerlo perfecto. diff --git a/examples/hello.zig b/examples/hello.zig index 3d7ee48..ca68ad7 100644 --- a/examples/hello.zig +++ b/examples/hello.zig @@ -19,12 +19,12 @@ pub fn main() !void { // Title try page.setFont(.helvetica_bold, 36); - page.setFillColor(pdf.Color{ .r = 0, .g = 100, .b = 200 }); + page.setFillColor(pdf.Color.rgb(0, 100, 200)); try page.drawText(50, 750, "Hello, PDF!"); // Subtitle try page.setFont(.helvetica, 14); - page.setFillColor(pdf.Color.gray); + page.setFillColor(pdf.Color.medium_gray); try page.drawText(50, 710, "Generated with zpdf - Pure Zig PDF library"); // Draw a line @@ -45,7 +45,7 @@ pub fn main() !void { // Draw some shapes try page.setLineWidth(2); page.setStrokeColor(pdf.Color.blue); - page.setFillColor(pdf.Color{ .r = 230, .g = 240, .b = 255 }); + page.setFillColor(pdf.Color.rgb(230, 240, 255)); try page.drawFilledRect(50, 500, 200, 60); try page.setFont(.helvetica_bold, 14); @@ -54,7 +54,7 @@ pub fn main() !void { // Red rectangle page.setStrokeColor(pdf.Color.red); - page.setFillColor(pdf.Color{ .r = 255, .g = 230, .b = 230 }); + page.setFillColor(pdf.Color.rgb(255, 230, 230)); try page.drawFilledRect(280, 500, 200, 60); page.setFillColor(pdf.Color.red); @@ -62,7 +62,7 @@ pub fn main() !void { // Footer try page.setFont(.courier, 10); - page.setFillColor(pdf.Color.gray); + page.setFillColor(pdf.Color.medium_gray); try page.drawText(50, 50, "zpdf v0.1.0 - https://git.reugenio.com/reugenio/zpdf"); // Save diff --git a/examples/invoice.zig b/examples/invoice.zig index 6641302..b7d23c1 100644 --- a/examples/invoice.zig +++ b/examples/invoice.zig @@ -17,11 +17,11 @@ pub fn main() !void { // Company header try page.setFont(.helvetica_bold, 24); - page.setFillColor(pdf.Color{ .r = 41, .g = 98, .b = 255 }); // Blue + page.setFillColor(pdf.Color.rgb(41, 98, 255)); // Blue try page.drawText(50, 780, "ACME Corporation"); try page.setFont(.helvetica, 10); - page.setFillColor(pdf.Color.gray); + page.setFillColor(pdf.Color.medium_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"); @@ -41,7 +41,7 @@ pub fn main() !void { try page.drawLine(50, 720, 545, 720); // Client info box - page.setFillColor(pdf.Color{ .r = 245, .g = 245, .b = 245 }); + page.setFillColor(pdf.Color.rgb(245, 245, 245)); try page.fillRect(50, 640, 250, 70); try page.setFont(.helvetica_bold, 11); @@ -64,7 +64,7 @@ pub fn main() !void { const row_height: f32 = 25; // Header background - page.setFillColor(pdf.Color{ .r = 41, .g = 98, .b = 255 }); + page.setFillColor(pdf.Color.rgb(41, 98, 255)); try page.fillRect(col1, table_top - row_height, 495, row_height); // Header text @@ -93,7 +93,7 @@ pub fn main() !void { // Alternate row background if (i % 2 == 0) { - page.setFillColor(pdf.Color{ .r = 250, .g = 250, .b = 250 }); + page.setFillColor(pdf.Color.rgb(250, 250, 250)); try page.fillRect(col1, y, 495, row_height); } @@ -113,7 +113,7 @@ pub fn main() !void { // Table border try page.setLineWidth(0.5); - page.setStrokeColor(pdf.Color.gray); + page.setStrokeColor(pdf.Color.medium_gray); try page.drawRect(col1, y, 495, table_top - y - row_height); // Vertical lines @@ -145,7 +145,7 @@ pub fn main() !void { // Payment info const payment_y = totals_y - 100; - page.setFillColor(pdf.Color{ .r = 245, .g = 245, .b = 245 }); + page.setFillColor(pdf.Color.rgb(245, 245, 245)); try page.fillRect(50, payment_y - 50, 300, 70); try page.setFont(.helvetica_bold, 10); @@ -163,7 +163,7 @@ pub fn main() !void { try page.drawLine(50, 80, 545, 80); try page.setFont(.helvetica, 8); - page.setFillColor(pdf.Color.gray); + page.setFillColor(pdf.Color.medium_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"); diff --git a/examples/text_demo.zig b/examples/text_demo.zig new file mode 100644 index 0000000..29b8da9 --- /dev/null +++ b/examples/text_demo.zig @@ -0,0 +1,182 @@ +//! Text System Demo - Demonstrates cell(), multiCell(), alignment, etc. + +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 - Text System Demo\n", .{}); + + var doc = pdf.Pdf.init(allocator, .{}); + defer doc.deinit(); + + doc.setTitle("Text System Demo"); + doc.setAuthor("zpdf"); + + var page = try doc.addPage(.{}); + + // Set initial position at top of page with margins + page.setMargins(50, 50, 50); + page.setXY(50, 800); + + // Title + try page.setFont(.helvetica_bold, 24); + page.setFillColor(pdf.Color.rgb(41, 98, 255)); + try page.cell(0, 30, "Text System Demo", pdf.Border.none, .center, false); + page.ln(35); + + // Section 1: Basic cells + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "1. Basic Cells", pdf.Border.none, .left, false); + page.ln(25); + + try page.setFont(.helvetica, 12); + + // Row of cells with different alignments + page.setFillColor(pdf.Color.rgb(230, 230, 230)); + try page.cell(150, 20, "Left aligned", pdf.Border.all, .left, true); + try page.cell(150, 20, "Center", pdf.Border.all, .center, true); + try page.cell(150, 20, "Right aligned", pdf.Border.all, .right, true); + page.ln(25); + + // Section 2: Colored cells + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "2. Colored Cells", pdf.Border.none, .left, false); + page.ln(25); + + try page.setFont(.helvetica, 12); + + page.setFillColor(pdf.Color.rgb(255, 200, 200)); + try page.cell(100, 20, "Red", pdf.Border.all, .center, true); + + page.setFillColor(pdf.Color.rgb(200, 255, 200)); + try page.cell(100, 20, "Green", pdf.Border.all, .center, true); + + page.setFillColor(pdf.Color.rgb(200, 200, 255)); + try page.cell(100, 20, "Blue", pdf.Border.all, .center, true); + + page.setFillColor(pdf.Color.rgb(255, 255, 200)); + try page.cell(100, 20, "Yellow", pdf.Border.all, .center, true); + page.ln(30); + + // Section 3: MultiCell with word wrap + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "3. MultiCell with Word Wrap", pdf.Border.none, .left, false); + page.ln(25); + + try page.setFont(.helvetica, 11); + page.setFillColor(pdf.Color.rgb(245, 245, 245)); + + const long_text = + \\This is a demonstration of the multiCell function in zpdf. + \\It automatically wraps text to fit within the specified width. + \\ + \\You can include explicit line breaks using backslash-n, and the + \\text will flow naturally within the cell boundaries. This is + \\useful for paragraphs, descriptions, and any longer text content. + ; + + try page.multiCell(450, null, long_text, pdf.Border.all, .left, true); + page.ln(10); + + // Section 4: Table-like structure + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "4. Table Structure", pdf.Border.none, .left, false); + page.ln(25); + + // Table header + try page.setFont(.helvetica_bold, 11); + page.setFillColor(pdf.Color.rgb(41, 98, 255)); + page.setTextColor(pdf.Color.white); + try page.cell(150, 20, "Product", pdf.Border.all, .center, true); + try page.cell(100, 20, "Quantity", pdf.Border.all, .center, true); + try page.cell(100, 20, "Price", pdf.Border.all, .center, true); + try page.cell(100, 20, "Total", pdf.Border.all, .center, true); + page.ln(null); + + // Table rows + try page.setFont(.helvetica, 11); + page.setTextColor(pdf.Color.black); + + const products = [_]struct { name: []const u8, qty: []const u8, price: []const u8, total: []const u8 }{ + .{ .name = "Widget A", .qty = "10", .price = "5.00", .total = "50.00" }, + .{ .name = "Widget B", .qty = "5", .price = "12.50", .total = "62.50" }, + .{ .name = "Widget C", .qty = "3", .price = "25.00", .total = "75.00" }, + }; + + for (products, 0..) |product, i| { + if (i % 2 == 0) { + page.setFillColor(pdf.Color.white); + } else { + page.setFillColor(pdf.Color.rgb(245, 245, 245)); + } + + try page.cell(150, 18, product.name, pdf.Border.all, .left, true); + try page.cell(100, 18, product.qty, pdf.Border.all, .center, true); + try page.cell(100, 18, product.price, pdf.Border.all, .right, true); + try page.cell(100, 18, product.total, pdf.Border.all, .right, true); + page.ln(null); + } + + // Total row + try page.setFont(.helvetica_bold, 11); + page.setFillColor(pdf.Color.rgb(230, 230, 230)); + try page.cell(350, 20, "TOTAL", pdf.Border.all, .right, true); + try page.cell(100, 20, "187.50", pdf.Border.all, .right, true); + page.ln(30); + + // Section 5: Different fonts + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "5. Font Showcase", pdf.Border.none, .left, false); + page.ln(25); + + const fonts = [_]pdf.Font{ + .helvetica, + .helvetica_bold, + .helvetica_oblique, + .times_roman, + .times_bold, + .times_italic, + .courier, + .courier_bold, + }; + + const font_names = [_][]const u8{ + "Helvetica", + "Helvetica Bold", + "Helvetica Oblique", + "Times Roman", + "Times Bold", + "Times Italic", + "Courier", + "Courier Bold", + }; + + for (fonts, 0..) |font, i| { + try page.setFont(font, 11); + page.setFillColor(pdf.Color.black); + try page.cell(200, 16, font_names[i], pdf.Border.none, .left, false); + page.ln(null); + } + + // Footer + page.setXY(50, 50); + try page.setFont(.helvetica, 9); + page.setFillColor(pdf.Color.medium_gray); + try page.cell(0, 15, "Generated with zpdf - Pure Zig PDF Library", pdf.Border.none, .center, false); + + // Save + const filename = "text_demo.pdf"; + try doc.save(filename); + + std.debug.print("Created: {s}\n", .{filename}); + std.debug.print("Done!\n", .{}); +} diff --git a/src/content_stream.zig b/src/content_stream.zig new file mode 100644 index 0000000..8f413ec --- /dev/null +++ b/src/content_stream.zig @@ -0,0 +1,523 @@ +//! ContentStream - PDF content stream builder +//! +//! A content stream contains the sequence of instructions that describe +//! the appearance of a page. This module provides a builder for creating +//! content streams using PDF operators. +//! +//! Based on: fpdf2/fpdf/fpdf.py (_out method and drawing operations) +//! Reference: PDF 1.4 Spec, Chapter 4 "Graphics" and Chapter 5 "Text" + +const std = @import("std"); + +/// A builder for PDF content streams. +/// +/// Content streams contain operators that describe graphics and text. +/// Example output: +/// ``` +/// q +/// BT +/// /Helvetica 12 Tf +/// 100 700 Td +/// (Hello World) Tj +/// ET +/// Q +/// ``` +pub const ContentStream = struct { + buffer: std.ArrayListUnmanaged(u8), + allocator: std.mem.Allocator, + + const Self = @This(); + + /// Creates a new empty content stream. + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .buffer = .{}, + .allocator = allocator, + }; + } + + /// Frees all memory used by the content stream. + pub fn deinit(self: *Self) void { + self.buffer.deinit(self.allocator); + } + + /// Returns the content stream as bytes. + pub fn getContent(self: *const Self) []const u8 { + return self.buffer.items; + } + + /// Returns the length of the content stream. + pub fn getLength(self: *const Self) usize { + return self.buffer.items.len; + } + + /// Clears the content stream. + pub fn clear(self: *Self) void { + self.buffer.clearRetainingCapacity(); + } + + // ========================================================================= + // Low-level write operations + // ========================================================================= + + fn writer(self: *Self) std.ArrayListUnmanaged(u8).Writer { + return self.buffer.writer(self.allocator); + } + + /// Writes raw bytes to the stream. + pub fn writeRaw(self: *Self, bytes: []const u8) !void { + try self.buffer.appendSlice(self.allocator, bytes); + } + + /// Writes a formatted string to the stream. + pub fn writeFmt(self: *Self, comptime fmt: []const u8, args: anytype) !void { + try self.writer().print(fmt, args); + } + + // ========================================================================= + // Graphics State Operators (PDF Ref 4.3) + // ========================================================================= + + /// `q` - Save graphics state + pub fn saveState(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "q\n"); + } + + /// `Q` - Restore graphics state + pub fn restoreState(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "Q\n"); + } + + /// `w` - Set line width + pub fn setLineWidth(self: *Self, width: f32) !void { + try self.writeFmt("{d:.2} w\n", .{width}); + } + + /// `J` - Set line cap style (0=butt, 1=round, 2=square) + pub fn setLineCap(self: *Self, cap: LineCap) !void { + try self.writeFmt("{d} J\n", .{@intFromEnum(cap)}); + } + + /// `j` - Set line join style (0=miter, 1=round, 2=bevel) + pub fn setLineJoin(self: *Self, join: LineJoin) !void { + try self.writeFmt("{d} j\n", .{@intFromEnum(join)}); + } + + /// `M` - Set miter limit + pub fn setMiterLimit(self: *Self, limit: f32) !void { + try self.writeFmt("{d:.2} M\n", .{limit}); + } + + /// `d` - Set dash pattern + pub fn setDashPattern(self: *Self, dash: f32, gap: f32, phase: f32) !void { + if (dash == 0 and gap == 0) { + try self.buffer.appendSlice(self.allocator, "[] 0 d\n"); // Solid line + } else { + try self.writeFmt("[{d:.2} {d:.2}] {d:.2} d\n", .{ dash, gap, phase }); + } + } + + /// `cm` - Concatenate matrix to CTM (transformation) + pub fn transform(self: *Self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) !void { + try self.writeFmt("{d:.4} {d:.4} {d:.4} {d:.4} {d:.2} {d:.2} cm\n", .{ a, b, c, d, e, f }); + } + + // ========================================================================= + // Color Operators (PDF Ref 4.5) + // ========================================================================= + + /// `RG` - Set RGB stroke color (0.0-1.0) + pub fn setStrokeColorRgb(self: *Self, r: f32, g: f32, b: f32) !void { + try self.writeFmt("{d:.3} {d:.3} {d:.3} RG\n", .{ r, g, b }); + } + + /// `rg` - Set RGB fill color (0.0-1.0) + pub fn setFillColorRgb(self: *Self, r: f32, g: f32, b: f32) !void { + try self.writeFmt("{d:.3} {d:.3} {d:.3} rg\n", .{ r, g, b }); + } + + /// `G` - Set gray stroke color (0.0-1.0) + pub fn setStrokeColorGray(self: *Self, gray: f32) !void { + try self.writeFmt("{d:.3} G\n", .{gray}); + } + + /// `g` - Set gray fill color (0.0-1.0) + pub fn setFillColorGray(self: *Self, gray: f32) !void { + try self.writeFmt("{d:.3} g\n", .{gray}); + } + + /// `K` - Set CMYK stroke color (0.0-1.0) + pub fn setStrokeColorCmyk(self: *Self, c: f32, m: f32, y: f32, k: f32) !void { + try self.writeFmt("{d:.3} {d:.3} {d:.3} {d:.3} K\n", .{ c, m, y, k }); + } + + /// `k` - Set CMYK fill color (0.0-1.0) + pub fn setFillColorCmyk(self: *Self, c: f32, m: f32, y: f32, k: f32) !void { + try self.writeFmt("{d:.3} {d:.3} {d:.3} {d:.3} k\n", .{ c, m, y, k }); + } + + // ========================================================================= + // Path Construction Operators (PDF Ref 4.4) + // ========================================================================= + + /// `m` - Begin new subpath at point (x, y) + pub fn moveTo(self: *Self, x: f32, y: f32) !void { + try self.writeFmt("{d:.2} {d:.2} m\n", .{ x, y }); + } + + /// `l` - Append straight line to point (x, y) + pub fn lineTo(self: *Self, x: f32, y: f32) !void { + try self.writeFmt("{d:.2} {d:.2} l\n", .{ x, y }); + } + + /// `c` - Append cubic Bezier curve + pub fn curveTo(self: *Self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) !void { + try self.writeFmt("{d:.2} {d:.2} {d:.2} {d:.2} {d:.2} {d:.2} c\n", .{ x1, y1, x2, y2, x3, y3 }); + } + + /// `v` - Append cubic Bezier curve (first control point = current point) + pub fn curveToV(self: *Self, x2: f32, y2: f32, x3: f32, y3: f32) !void { + try self.writeFmt("{d:.2} {d:.2} {d:.2} {d:.2} v\n", .{ x2, y2, x3, y3 }); + } + + /// `y` - Append cubic Bezier curve (second control point = end point) + pub fn curveToY(self: *Self, x1: f32, y1: f32, x3: f32, y3: f32) !void { + try self.writeFmt("{d:.2} {d:.2} {d:.2} {d:.2} y\n", .{ x1, y1, x3, y3 }); + } + + /// `h` - Close current subpath + pub fn closePath(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "h\n"); + } + + /// `re` - Append rectangle to path + pub fn rectangle(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { + try self.writeFmt("{d:.2} {d:.2} {d:.2} {d:.2} re\n", .{ x, y, w, h }); + } + + // ========================================================================= + // Path Painting Operators (PDF Ref 4.4) + // ========================================================================= + + /// `S` - Stroke the path + pub fn stroke(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "S\n"); + } + + /// `s` - Close and stroke the path + pub fn closeAndStroke(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "s\n"); + } + + /// `f` - Fill the path (non-zero winding rule) + pub fn fill(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "f\n"); + } + + /// `f*` - Fill the path (even-odd rule) + pub fn fillEvenOdd(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "f*\n"); + } + + /// `B` - Fill and stroke the path + pub fn fillAndStroke(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "B\n"); + } + + /// `B*` - Fill and stroke (even-odd rule) + pub fn fillAndStrokeEvenOdd(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "B*\n"); + } + + /// `b` - Close, fill, and stroke + pub fn closeAndFillAndStroke(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "b\n"); + } + + /// `n` - End path without filling or stroking (no-op, used for clipping) + pub fn endPath(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "n\n"); + } + + // ========================================================================= + // Clipping Path Operators (PDF Ref 4.4.3) + // ========================================================================= + + /// `W` - Set clipping path (non-zero winding) + pub fn clip(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "W\n"); + } + + /// `W*` - Set clipping path (even-odd rule) + pub fn clipEvenOdd(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "W*\n"); + } + + // ========================================================================= + // Text Object Operators (PDF Ref 5.3) + // ========================================================================= + + /// `BT` - Begin text object + pub fn beginText(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "BT\n"); + } + + /// `ET` - End text object + pub fn endText(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "ET\n"); + } + + // ========================================================================= + // Text State Operators (PDF Ref 5.2) + // ========================================================================= + + /// `Tc` - Set character spacing + pub fn setCharSpacing(self: *Self, spacing: f32) !void { + try self.writeFmt("{d:.2} Tc\n", .{spacing}); + } + + /// `Tw` - Set word spacing + pub fn setWordSpacing(self: *Self, spacing: f32) !void { + try self.writeFmt("{d:.2} Tw\n", .{spacing}); + } + + /// `Tz` - Set horizontal scaling (100 = normal) + pub fn setHorizontalScaling(self: *Self, scale: f32) !void { + try self.writeFmt("{d:.2} Tz\n", .{scale}); + } + + /// `TL` - Set text leading (line spacing) + pub fn setTextLeading(self: *Self, leading: f32) !void { + try self.writeFmt("{d:.2} TL\n", .{leading}); + } + + /// `Tf` - Set font and size + pub fn setFont(self: *Self, font_name: []const u8, size: f32) !void { + try self.writeFmt("/{s} {d:.2} Tf\n", .{ font_name, size }); + } + + /// `Tr` - Set text rendering mode + pub fn setTextRenderMode(self: *Self, mode: TextRenderMode) !void { + try self.writeFmt("{d} Tr\n", .{@intFromEnum(mode)}); + } + + /// `Ts` - Set text rise (superscript/subscript) + pub fn setTextRise(self: *Self, rise: f32) !void { + try self.writeFmt("{d:.2} Ts\n", .{rise}); + } + + // ========================================================================= + // Text Positioning Operators (PDF Ref 5.3) + // ========================================================================= + + /// `Td` - Move text position + pub fn moveTextPosition(self: *Self, tx: f32, ty: f32) !void { + try self.writeFmt("{d:.2} {d:.2} Td\n", .{ tx, ty }); + } + + /// `TD` - Move text position and set leading + pub fn moveTextPositionWithLeading(self: *Self, tx: f32, ty: f32) !void { + try self.writeFmt("{d:.2} {d:.2} TD\n", .{ tx, ty }); + } + + /// `Tm` - Set text matrix + pub fn setTextMatrix(self: *Self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) !void { + try self.writeFmt("{d:.4} {d:.4} {d:.4} {d:.4} {d:.2} {d:.2} Tm\n", .{ a, b, c, d, e, f }); + } + + /// `T*` - Move to start of next line + pub fn nextLine(self: *Self) !void { + try self.buffer.appendSlice(self.allocator, "T*\n"); + } + + // ========================================================================= + // Text Showing Operators (PDF Ref 5.3) + // ========================================================================= + + /// `Tj` - Show text string + pub fn showText(self: *Self, str: []const u8) !void { + try self.buffer.append(self.allocator, '('); + try self.writePdfString(str); + try self.buffer.appendSlice(self.allocator, ") Tj\n"); + } + + /// `'` - Move to next line and show text + pub fn showTextNextLine(self: *Self, str: []const u8) !void { + try self.buffer.append(self.allocator, '('); + try self.writePdfString(str); + try self.buffer.appendSlice(self.allocator, ") '\n"); + } + + /// `"` - Set spacing, move to next line, show text + pub fn showTextWithSpacing(self: *Self, word_spacing: f32, char_spacing: f32, str: []const u8) !void { + try self.writeFmt("{d:.2} {d:.2} (", .{ word_spacing, char_spacing }); + try self.writePdfString(str); + try self.buffer.appendSlice(self.allocator, ") \"\n"); + } + + /// Writes a PDF string, escaping special characters. + fn writePdfString(self: *Self, str: []const u8) !void { + for (str) |c| { + switch (c) { + '(' => try self.buffer.appendSlice(self.allocator, "\\("), + ')' => try self.buffer.appendSlice(self.allocator, "\\)"), + '\\' => try self.buffer.appendSlice(self.allocator, "\\\\"), + '\n' => try self.buffer.appendSlice(self.allocator, "\\n"), + '\r' => try self.buffer.appendSlice(self.allocator, "\\r"), + '\t' => try self.buffer.appendSlice(self.allocator, "\\t"), + else => try self.buffer.append(self.allocator, c), + } + } + } + + // ========================================================================= + // XObject Operators (PDF Ref 4.7) + // ========================================================================= + + /// `Do` - Paint XObject (image, form, etc.) + pub fn paintXObject(self: *Self, name: []const u8) !void { + try self.writeFmt("/{s} Do\n", .{name}); + } + + // ========================================================================= + // High-level convenience methods + // ========================================================================= + + /// Draws a line from (x1, y1) to (x2, y2) + pub fn line(self: *Self, x1: f32, y1: f32, x2: f32, y2: f32) !void { + try self.moveTo(x1, y1); + try self.lineTo(x2, y2); + try self.stroke(); + } + + /// Draws a rectangle outline + pub fn rect(self: *Self, x: f32, y: f32, w: f32, h: f32, style: RenderStyle) !void { + try self.rectangle(x, y, w, h); + switch (style) { + .stroke => try self.stroke(), + .fill => try self.fill(), + .fill_stroke => try self.fillAndStroke(), + } + } + + /// Draws text at position (x, y) with current font + pub fn text(self: *Self, x: f32, y: f32, font_name: []const u8, font_size: f32, str: []const u8) !void { + try self.beginText(); + try self.setFont(font_name, font_size); + try self.moveTextPosition(x, y); + try self.showText(str); + try self.endText(); + } +}; + +// ============================================================================= +// Enums +// ============================================================================= + +pub const LineCap = enum(u8) { + butt = 0, + round = 1, + square = 2, +}; + +pub const LineJoin = enum(u8) { + miter = 0, + round = 1, + bevel = 2, +}; + +pub const TextRenderMode = enum(u8) { + fill = 0, + stroke = 1, + fill_stroke = 2, + invisible = 3, + fill_clip = 4, + stroke_clip = 5, + fill_stroke_clip = 6, + clip = 7, +}; + +pub const RenderStyle = enum { + stroke, + fill, + fill_stroke, +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "ContentStream basic operations" { + const allocator = std.testing.allocator; + + var cs = ContentStream.init(allocator); + defer cs.deinit(); + + try cs.saveState(); + try cs.setLineWidth(2.0); + try cs.moveTo(100, 200); + try cs.lineTo(300, 200); + try cs.stroke(); + try cs.restoreState(); + + const content = cs.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "q\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "2.00 w\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "100.00 200.00 m\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "300.00 200.00 l\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "S\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "Q\n") != null); +} + +test "ContentStream text operations" { + const allocator = std.testing.allocator; + + var cs = ContentStream.init(allocator); + defer cs.deinit(); + + try cs.beginText(); + try cs.setFont("Helvetica", 12); + try cs.moveTextPosition(100, 700); + try cs.showText("Hello World"); + try cs.endText(); + + const content = cs.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "BT\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "/Helvetica 12.00 Tf\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "(Hello World) Tj\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "ET\n") != null); +} + +test "ContentStream escapes special characters" { + const allocator = std.testing.allocator; + + var cs = ContentStream.init(allocator); + defer cs.deinit(); + + try cs.beginText(); + try cs.showText("Test (with) special \\chars"); + try cs.endText(); + + const content = cs.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "\\(") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "\\)") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "\\\\") != null); +} + +test "ContentStream colors" { + const allocator = std.testing.allocator; + + var cs = ContentStream.init(allocator); + defer cs.deinit(); + + try cs.setStrokeColorRgb(1.0, 0.0, 0.0); + try cs.setFillColorRgb(0.0, 1.0, 0.0); + try cs.setStrokeColorGray(0.5); + try cs.setFillColorGray(0.8); + + const content = cs.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "1.000 0.000 0.000 RG\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "0.000 1.000 0.000 rg\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "0.500 G\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "0.800 g\n") != null); +} diff --git a/src/fonts/mod.zig b/src/fonts/mod.zig new file mode 100644 index 0000000..824d780 --- /dev/null +++ b/src/fonts/mod.zig @@ -0,0 +1,9 @@ +//! Fonts module - font types and metrics +//! +//! Re-exports all font-related types. + +pub const type1 = @import("type1.zig"); +pub const Font = type1.Font; +pub const FontFamily = type1.FontFamily; +pub const FontState = type1.FontState; +pub const Encoding = type1.Encoding; diff --git a/src/fonts/type1.zig b/src/fonts/type1.zig new file mode 100644 index 0000000..78e0e25 --- /dev/null +++ b/src/fonts/type1.zig @@ -0,0 +1,301 @@ +//! Type1 Fonts - PDF Standard 14 Fonts +//! +//! PDF readers are required to have these 14 fonts built-in, +//! so they don't need to be embedded in the document. +//! +//! Based on: fpdf2/fpdf/fonts.py (CORE_FONTS) +//! Reference: PDF 1.4 Spec, Appendix H "Standard Type 1 Fonts" + +const std = @import("std"); + +/// Standard Type 1 fonts available in all PDF readers. +/// +/// These fonts are guaranteed to be available and don't need embedding. +/// Font metrics are defined in the PDF specification. +pub const Font = enum { + // Helvetica family (sans-serif) + helvetica, + helvetica_bold, + helvetica_oblique, + helvetica_bold_oblique, + + // Times family (serif) + times_roman, + times_bold, + times_italic, + times_bold_italic, + + // Courier family (monospace) + courier, + courier_bold, + courier_oblique, + courier_bold_oblique, + + // Symbol fonts + symbol, + zapf_dingbats, + + /// Returns the PostScript name used in PDF (e.g., "Helvetica-Bold"). + 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", + }; + } + + /// Returns the font family (helvetica, times, courier, symbol, zapfdingbats). + pub fn family(self: Font) FontFamily { + return switch (self) { + .helvetica, .helvetica_bold, .helvetica_oblique, .helvetica_bold_oblique => .helvetica, + .times_roman, .times_bold, .times_italic, .times_bold_italic => .times, + .courier, .courier_bold, .courier_oblique, .courier_bold_oblique => .courier, + .symbol => .symbol, + .zapf_dingbats => .zapf_dingbats, + }; + } + + /// Returns true if the font is bold. + pub fn isBold(self: Font) bool { + return switch (self) { + .helvetica_bold, .helvetica_bold_oblique, .times_bold, .times_bold_italic, .courier_bold, .courier_bold_oblique => true, + else => false, + }; + } + + /// Returns true if the font is italic/oblique. + pub fn isItalic(self: Font) bool { + return switch (self) { + .helvetica_oblique, .helvetica_bold_oblique, .times_italic, .times_bold_italic, .courier_oblique, .courier_bold_oblique => true, + else => false, + }; + } + + /// Returns true if the font is monospace. + pub fn isMonospace(self: Font) bool { + return switch (self) { + .courier, .courier_bold, .courier_oblique, .courier_bold_oblique => true, + else => false, + }; + } + + /// Returns the font encoding. Symbol and ZapfDingbats use their own encoding. + pub fn encoding(self: Font) Encoding { + return switch (self) { + .symbol, .zapf_dingbats => .builtin, + else => .win_ansi, + }; + } + + /// Returns the average character width for the font (in units of 1/1000 of text size). + /// Used for estimating text width. + pub fn avgCharWidth(self: Font) u16 { + return switch (self.family()) { + .helvetica => 513, + .times => 512, + .courier => 600, // Monospace: all chars same width + .symbol => 600, + .zapf_dingbats => 600, + }; + } + + /// Returns the character width for ASCII character (0-127). + /// Returns avgCharWidth for characters outside this range. + /// Width is in units of 1/1000 of text size. + pub fn charWidth(self: Font, char: u8) u16 { + if (char > 127) return self.avgCharWidth(); + + return switch (self.family()) { + .helvetica => if (self.isBold()) + helvetica_bold_widths[char] + else + helvetica_widths[char], + .times => if (self.isBold()) + times_bold_widths[char] + else + times_roman_widths[char], + .courier => 600, // Monospace + .symbol => symbol_widths[char], + .zapf_dingbats => zapf_dingbats_widths[char], + }; + } + + /// Calculates the width of a string in points. + pub fn stringWidth(self: Font, text: []const u8, font_size: f32) f32 { + var total: u32 = 0; + for (text) |c| { + total += self.charWidth(c); + } + return @as(f32, @floatFromInt(total)) * font_size / 1000.0; + } +}; + +/// Font families +pub const FontFamily = enum { + helvetica, + times, + courier, + symbol, + zapf_dingbats, +}; + +/// Font encoding types +pub const Encoding = enum { + win_ansi, // WinAnsiEncoding - standard Latin-1 + builtin, // Built-in encoding (Symbol, ZapfDingbats) +}; + +/// A font with a specific size, used for text rendering state. +pub const FontState = struct { + font: Font, + size: f32, + + pub fn init(font: Font, size: f32) FontState { + return .{ .font = font, .size = size }; + } + + /// Calculates the width of a string with this font/size. + pub fn stringWidth(self: FontState, text: []const u8) f32 { + return self.font.stringWidth(text, self.size); + } + + /// Returns the line height (typically 1.2x font size). + pub fn lineHeight(self: FontState) f32 { + return self.size * 1.2; + } +}; + +// ============================================================================= +// Character Width Tables (from PDF spec, in units of 1/1000 of text size) +// ============================================================================= + +// Helvetica widths for ASCII 0-127 +const helvetica_widths = [128]u16{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31 + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // 32-47 (space, !, ", etc.) + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // 48-63 (0-9, :, ;, <, =, >, ?) + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // 64-79 (@, A-O) + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 80-95 (P-Z, [, \, ], ^, _) + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // 96-111 (`, a-o) + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, // 112-127 (p-z, {, |, }, ~, DEL) +}; + +// Helvetica-Bold widths +const helvetica_bold_widths = [128]u16{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, +}; + +// Times-Roman widths +const times_roman_widths = [128]u16{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0, +}; + +// Times-Bold widths +const times_bold_widths = [128]u16{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0, +}; + +// Symbol widths (simplified - uses average for most) +const symbol_widths = [128]u16{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, + 549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, + 768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, + 500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, + 549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, +}; + +// ZapfDingbats widths (simplified) +const zapf_dingbats_widths = [128]u16{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933, + 911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537, + 577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831, + 923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826, + 815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762, + 762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0, +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Font pdfName" { + try std.testing.expectEqualStrings("Helvetica", Font.helvetica.pdfName()); + try std.testing.expectEqualStrings("Helvetica-Bold", Font.helvetica_bold.pdfName()); + try std.testing.expectEqualStrings("Times-Roman", Font.times_roman.pdfName()); + try std.testing.expectEqualStrings("Courier", Font.courier.pdfName()); +} + +test "Font isBold/isItalic" { + try std.testing.expect(!Font.helvetica.isBold()); + try std.testing.expect(Font.helvetica_bold.isBold()); + try std.testing.expect(!Font.helvetica.isItalic()); + try std.testing.expect(Font.helvetica_oblique.isItalic()); + try std.testing.expect(Font.helvetica_bold_oblique.isBold()); + try std.testing.expect(Font.helvetica_bold_oblique.isItalic()); +} + +test "Font isMonospace" { + try std.testing.expect(!Font.helvetica.isMonospace()); + try std.testing.expect(Font.courier.isMonospace()); + try std.testing.expect(Font.courier_bold.isMonospace()); +} + +test "Font charWidth" { + // Space in Helvetica is 278 + try std.testing.expectEqual(@as(u16, 278), Font.helvetica.charWidth(' ')); + // Courier is monospace, always 600 + try std.testing.expectEqual(@as(u16, 600), Font.courier.charWidth('A')); + try std.testing.expectEqual(@as(u16, 600), Font.courier.charWidth('i')); +} + +test "Font stringWidth" { + const font = Font.helvetica; + const width = font.stringWidth("Hello", 12.0); + try std.testing.expect(width > 0); + try std.testing.expect(width < 100); // Sanity check +} + +test "FontState" { + const state = FontState.init(.helvetica, 12.0); + try std.testing.expectEqual(Font.helvetica, state.font); + try std.testing.expectEqual(@as(f32, 12.0), state.size); + try std.testing.expectApproxEqAbs(@as(f32, 14.4), state.lineHeight(), 0.01); +} diff --git a/src/graphics/color.zig b/src/graphics/color.zig new file mode 100644 index 0000000..3c47590 --- /dev/null +++ b/src/graphics/color.zig @@ -0,0 +1,241 @@ +//! Color - PDF color types and utilities +//! +//! Supports RGB, CMYK, and Grayscale color spaces. +//! Colors are stored as u8 (0-255) for convenience but converted +//! to floats (0.0-1.0) when serializing to PDF. +//! +//! Based on: fpdf2/fpdf/drawing.py (DeviceRGB, DeviceGray, DeviceCMYK) + +const std = @import("std"); +const ContentStream = @import("../content_stream.zig").ContentStream; + +/// A color that can be RGB, CMYK, or Grayscale. +pub const Color = union(enum) { + /// RGB color (red, green, blue) + rgb_val: Rgb, + /// CMYK color (cyan, magenta, yellow, black) + cmyk_val: Cmyk, + /// Grayscale color + gray_val: u8, + + /// RGB color components (0-255) + pub const Rgb = struct { + r: u8, + g: u8, + b: u8, + }; + + /// CMYK color components (0-255, will be normalized to 0.0-1.0) + pub const Cmyk = struct { + c: u8, + m: u8, + y: u8, + k: u8, + }; + + // ========================================================================= + // Constructors + // ========================================================================= + + /// Creates an RGB color from 0-255 values. + pub fn rgb(r: u8, g: u8, b: u8) Color { + return .{ .rgb_val = .{ .r = r, .g = g, .b = b } }; + } + + /// Creates an RGB color from a hex value (0xRRGGBB). + pub fn hex(value: u24) Color { + return .{ .rgb_val = .{ + .r = @truncate(value >> 16), + .g = @truncate(value >> 8), + .b = @truncate(value), + } }; + } + + /// Creates a CMYK color from 0-255 values. + pub fn cmyk(c: u8, m: u8, y: u8, k: u8) Color { + return .{ .cmyk_val = .{ .c = c, .m = m, .y = y, .k = k } }; + } + + /// Creates a grayscale color from 0-255 (0=black, 255=white). + pub fn gray(value: u8) Color { + return .{ .gray_val = value }; + } + + // ========================================================================= + // Predefined Colors + // ========================================================================= + + pub const black = Color{ .gray_val = 0 }; + pub const white = Color{ .gray_val = 255 }; + pub const red = Color{ .rgb_val = .{ .r = 255, .g = 0, .b = 0 } }; + pub const green = Color{ .rgb_val = .{ .r = 0, .g = 255, .b = 0 } }; + pub const blue = Color{ .rgb_val = .{ .r = 0, .g = 0, .b = 255 } }; + pub const yellow = Color{ .rgb_val = .{ .r = 255, .g = 255, .b = 0 } }; + pub const cyan = Color{ .rgb_val = .{ .r = 0, .g = 255, .b = 255 } }; + pub const magenta = Color{ .rgb_val = .{ .r = 255, .g = 0, .b = 255 } }; + pub const dark_gray = Color{ .gray_val = 64 }; + pub const medium_gray = Color{ .gray_val = 128 }; + pub const light_gray = Color{ .gray_val = 192 }; + + // ========================================================================= + // Conversion + // ========================================================================= + + /// Converts a u8 (0-255) to a float (0.0-1.0). + fn toFloat(value: u8) f32 { + return @as(f32, @floatFromInt(value)) / 255.0; + } + + /// Returns RGB components as floats (0.0-1.0). + /// For non-RGB colors, converts to RGB first. + pub fn toRgbFloats(self: Color) struct { r: f32, g: f32, b: f32 } { + return switch (self) { + .rgb_val => |c| .{ + .r = toFloat(c.r), + .g = toFloat(c.g), + .b = toFloat(c.b), + }, + .gray_val => |g| .{ + .r = toFloat(g), + .g = toFloat(g), + .b = toFloat(g), + }, + .cmyk_val => |c| blk: { + // CMYK to RGB conversion + const k_f = toFloat(c.k); + const c_f = toFloat(c.c); + const m_f = toFloat(c.m); + const y_f = toFloat(c.y); + break :blk .{ + .r = (1.0 - c_f) * (1.0 - k_f), + .g = (1.0 - m_f) * (1.0 - k_f), + .b = (1.0 - y_f) * (1.0 - k_f), + }; + }, + }; + } + + /// Returns the grayscale value as a float (0.0-1.0). + /// For RGB colors, converts using luminance formula. + pub fn toGrayFloat(self: Color) f32 { + return switch (self) { + .gray_val => |g| toFloat(g), + .rgb_val => |c| blk: { + // Luminance formula: 0.299*R + 0.587*G + 0.114*B + const r = toFloat(c.r); + const g = toFloat(c.g); + const b = toFloat(c.b); + break :blk 0.299 * r + 0.587 * g + 0.114 * b; + }, + .cmyk_val => |c| blk: { + // Convert CMYK to RGB first, then to gray + const k_f = toFloat(c.k); + const c_f = toFloat(c.c); + const m_f = toFloat(c.m); + const y_f = toFloat(c.y); + const r = (1.0 - c_f) * (1.0 - k_f); + const g = (1.0 - m_f) * (1.0 - k_f); + const b = (1.0 - y_f) * (1.0 - k_f); + break :blk 0.299 * r + 0.587 * g + 0.114 * b; + }, + }; + } + + // ========================================================================= + // Content Stream Operations + // ========================================================================= + + /// Writes the stroke color command to the content stream. + pub fn writeStrokeColor(self: Color, cs: *ContentStream) !void { + switch (self) { + .rgb_val => |c| { + try cs.setStrokeColorRgb(toFloat(c.r), toFloat(c.g), toFloat(c.b)); + }, + .gray_val => |g| { + try cs.setStrokeColorGray(toFloat(g)); + }, + .cmyk_val => |c| { + try cs.setStrokeColorCmyk(toFloat(c.c), toFloat(c.m), toFloat(c.y), toFloat(c.k)); + }, + } + } + + /// Writes the fill color command to the content stream. + pub fn writeFillColor(self: Color, cs: *ContentStream) !void { + switch (self) { + .rgb_val => |c| { + try cs.setFillColorRgb(toFloat(c.r), toFloat(c.g), toFloat(c.b)); + }, + .gray_val => |g| { + try cs.setFillColorGray(toFloat(g)); + }, + .cmyk_val => |c| { + try cs.setFillColorCmyk(toFloat(c.c), toFloat(c.m), toFloat(c.y), toFloat(c.k)); + }, + } + } + + // ========================================================================= + // Comparison + // ========================================================================= + + /// Returns true if two colors are equal. + pub fn eql(self: Color, other: Color) bool { + return switch (self) { + .rgb_val => |a| switch (other) { + .rgb_val => |b| a.r == b.r and a.g == b.g and a.b == b.b, + else => false, + }, + .gray_val => |a| switch (other) { + .gray_val => |b| a == b, + else => false, + }, + .cmyk_val => |a| switch (other) { + .cmyk_val => |b| a.c == b.c and a.m == b.m and a.y == b.y and a.k == b.k, + else => false, + }, + }; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Color RGB creation" { + const c = Color.rgb(255, 128, 0); + try std.testing.expectEqual(Color.Rgb{ .r = 255, .g = 128, .b = 0 }, c.rgb_val); +} + +test "Color hex creation" { + const c = Color.hex(0xFF8000); + try std.testing.expectEqual(Color.Rgb{ .r = 255, .g = 128, .b = 0 }, c.rgb_val); +} + +test "Color toRgbFloats" { + const c = Color.rgb(255, 0, 128); + const floats = c.toRgbFloats(); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), floats.r, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 0.0), floats.g, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 0.5), floats.b, 0.02); +} + +test "Color gray to RGB" { + const c = Color.gray(128); + const floats = c.toRgbFloats(); + try std.testing.expectApproxEqAbs(@as(f32, 0.5), floats.r, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 0.5), floats.g, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 0.5), floats.b, 0.01); +} + +test "Color predefined colors" { + try std.testing.expectEqual(@as(u8, 0), Color.black.gray_val); + try std.testing.expectEqual(@as(u8, 255), Color.white.gray_val); + try std.testing.expectEqual(Color.Rgb{ .r = 255, .g = 0, .b = 0 }, Color.red.rgb_val); +} + +test "Color equality" { + try std.testing.expect(Color.black.eql(Color.gray(0))); + try std.testing.expect(Color.red.eql(Color.rgb(255, 0, 0))); + try std.testing.expect(!Color.red.eql(Color.blue)); +} diff --git a/src/graphics/mod.zig b/src/graphics/mod.zig new file mode 100644 index 0000000..2a0d87a --- /dev/null +++ b/src/graphics/mod.zig @@ -0,0 +1,6 @@ +//! Graphics module - colors, paths, transformations +//! +//! Re-exports all graphics-related types. + +pub const color = @import("color.zig"); +pub const Color = color.Color; diff --git a/src/objects/base.zig b/src/objects/base.zig new file mode 100644 index 0000000..74d6901 --- /dev/null +++ b/src/objects/base.zig @@ -0,0 +1,190 @@ +//! PDF Object Base Types +//! +//! PDF documents are composed of objects. This module defines the base +//! types and serialization utilities for PDF objects. +//! +//! Based on: fpdf2/fpdf/syntax.py +//! Reference: PDF 1.4 Spec, Chapter 3 "Objects" + +const std = @import("std"); + +/// Writes a PDF indirect object reference (e.g., "5 0 R"). +pub fn writeRef(writer: anytype, id: u32) !void { + try writer.print("{d} 0 R", .{id}); +} + +/// Writes a PDF name (e.g., "/Type"). +pub fn writeName(writer: anytype, name: []const u8) !void { + try writer.writeByte('/'); + for (name) |c| { + if (c < 33 or c > 126 or c == '#' or c == '/' or c == '(' or c == ')' or c == '<' or c == '>' or c == '[' or c == ']' or c == '{' or c == '}' or c == '%') { + try writer.print("#{X:0>2}", .{c}); + } else { + try writer.writeByte(c); + } + } +} + +/// Writes a PDF string literal (e.g., "(Hello World)"). +/// Escapes special characters as needed. +pub fn writeString(writer: anytype, text: []const u8) !void { + try writer.writeByte('('); + for (text) |c| { + switch (c) { + '(' => try writer.writeAll("\\("), + ')' => try writer.writeAll("\\)"), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => try writer.writeByte(c), + } + } + try writer.writeByte(')'); +} + +/// Writes a PDF hex string (e.g., "<48656C6C6F>"). +pub fn writeHexString(writer: anytype, data: []const u8) !void { + try writer.writeByte('<'); + for (data) |byte| { + try writer.print("{X:0>2}", .{byte}); + } + try writer.writeByte('>'); +} + +/// Writes a PDF array (e.g., "[1 2 3]"). +pub fn writeArray(writer: anytype, comptime T: type, items: []const T, writeItem: fn (anytype, T) anyerror!void) !void { + try writer.writeByte('['); + for (items, 0..) |item, i| { + if (i > 0) try writer.writeByte(' '); + try writeItem(writer, item); + } + try writer.writeByte(']'); +} + +/// Writes a PDF number, using integer format if possible. +pub fn writeNumber(writer: anytype, value: f32) !void { + const int_value = @as(i32, @intFromFloat(value)); + if (@as(f32, @floatFromInt(int_value)) == value) { + try writer.print("{d}", .{int_value}); + } else { + try writer.print("{d:.2}", .{value}); + } +} + +/// Page size definitions in points. +pub const PageSize = enum { + a4, + a3, + a5, + letter, + legal, + + /// Returns width and height in points. + pub fn dimensions(self: PageSize) struct { width: f32, height: f32 } { + return switch (self) { + .a4 => .{ .width = 595.28, .height = 841.89 }, + .a3 => .{ .width = 841.89, .height = 1190.55 }, + .a5 => .{ .width = 420.94, .height = 595.28 }, + .letter => .{ .width = 612, .height = 792 }, + .legal => .{ .width = 612, .height = 1008 }, + }; + } + + /// Returns MediaBox array string for PDF. + pub fn mediaBox(self: PageSize) []const u8 { + return switch (self) { + .a4 => "[0 0 595.28 841.89]", + .a3 => "[0 0 841.89 1190.55]", + .a5 => "[0 0 420.94 595.28]", + .letter => "[0 0 612 792]", + .legal => "[0 0 612 1008]", + }; + } +}; + +/// Page orientation +pub const Orientation = enum { + portrait, + landscape, +}; + +/// Unit of measurement +pub const Unit = enum { + pt, // Points (1/72 inch) + mm, // Millimeters + cm, // Centimeters + in, // Inches + + /// Returns the scale factor to convert to points. + pub fn scaleFactor(self: Unit) f32 { + return switch (self) { + .pt => 1.0, + .mm => 72.0 / 25.4, // ~2.834645669 + .cm => 72.0 / 2.54, // ~28.34645669 + .in => 72.0, + }; + } + + /// Converts a value from this unit to points. + pub fn toPoints(self: Unit, value: f32) f32 { + return value * self.scaleFactor(); + } + + /// Converts a value from points to this unit. + pub fn fromPoints(self: Unit, points: f32) f32 { + return points / self.scaleFactor(); + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "writeRef" { + var buf: [32]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try writeRef(fbs.writer(), 5); + try std.testing.expectEqualStrings("5 0 R", fbs.getWritten()); +} + +test "writeName" { + var buf: [32]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try writeName(fbs.writer(), "Type"); + try std.testing.expectEqualStrings("/Type", fbs.getWritten()); +} + +test "writeString" { + var buf: [64]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try writeString(fbs.writer(), "Hello (World)"); + try std.testing.expectEqualStrings("(Hello \\(World\\))", fbs.getWritten()); +} + +test "writeNumber integer" { + var buf: [32]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try writeNumber(fbs.writer(), 42.0); + try std.testing.expectEqualStrings("42", fbs.getWritten()); +} + +test "writeNumber float" { + var buf: [32]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try writeNumber(fbs.writer(), 3.14); + try std.testing.expectEqualStrings("3.14", fbs.getWritten()); +} + +test "PageSize dimensions" { + const a4 = PageSize.a4.dimensions(); + try std.testing.expectApproxEqAbs(@as(f32, 595.28), a4.width, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 841.89), a4.height, 0.01); +} + +test "Unit conversion" { + // 1 inch = 72 points + try std.testing.expectEqual(@as(f32, 72.0), Unit.in.toPoints(1.0)); + // 25.4 mm = 1 inch = 72 points + try std.testing.expectApproxEqAbs(@as(f32, 72.0), Unit.mm.toPoints(25.4), 0.01); +} diff --git a/src/objects/mod.zig b/src/objects/mod.zig new file mode 100644 index 0000000..bb649bd --- /dev/null +++ b/src/objects/mod.zig @@ -0,0 +1,16 @@ +//! Objects module - PDF object types and serialization +//! +//! Re-exports all object-related types. + +pub const base = @import("base.zig"); + +pub const writeRef = base.writeRef; +pub const writeName = base.writeName; +pub const writeString = base.writeString; +pub const writeHexString = base.writeHexString; +pub const writeArray = base.writeArray; +pub const writeNumber = base.writeNumber; + +pub const PageSize = base.PageSize; +pub const Orientation = base.Orientation; +pub const Unit = base.Unit; diff --git a/src/output/mod.zig b/src/output/mod.zig new file mode 100644 index 0000000..6b82607 --- /dev/null +++ b/src/output/mod.zig @@ -0,0 +1,8 @@ +//! Output module - PDF generation and serialization +//! +//! Re-exports all output-related types. + +pub const producer = @import("producer.zig"); +pub const OutputProducer = producer.OutputProducer; +pub const PageData = producer.PageData; +pub const DocumentMetadata = producer.DocumentMetadata; diff --git a/src/output/producer.zig b/src/output/producer.zig new file mode 100644 index 0000000..3f32bf5 --- /dev/null +++ b/src/output/producer.zig @@ -0,0 +1,264 @@ +//! OutputProducer - PDF Document Serialization +//! +//! Generates the final PDF bytearray from document structure. +//! Handles object numbering, xref table, and trailer. +//! +//! Based on: fpdf2/fpdf/output.py (OutputProducer class) +//! Reference: PDF 1.4 Spec, Chapter 3 "Syntax" + +const std = @import("std"); +const base = @import("../objects/base.zig"); +const Font = @import("../fonts/type1.zig").Font; + +/// Page data ready for serialization +pub const PageData = struct { + width: f32, + height: f32, + content: []const u8, + fonts_used: []const Font, +}; + +/// Generates a complete PDF document. +pub const OutputProducer = struct { + allocator: std.mem.Allocator, + buffer: std.ArrayListUnmanaged(u8), + obj_offsets: std.ArrayListUnmanaged(usize), + current_obj_id: u32, + + const Self = @This(); + + pub fn init(allocator: std.mem.Allocator) Self { + return .{ + .allocator = allocator, + .buffer = .{}, + .obj_offsets = .{}, + .current_obj_id = 0, + }; + } + + pub fn deinit(self: *Self) void { + self.buffer.deinit(self.allocator); + self.obj_offsets.deinit(self.allocator); + } + + /// Generates a complete PDF from the given pages. + pub fn generate(self: *Self, pages: []const PageData, metadata: DocumentMetadata) ![]u8 { + self.buffer.clearRetainingCapacity(); + self.obj_offsets.clearRetainingCapacity(); + self.current_obj_id = 0; + + const writer = self.buffer.writer(self.allocator); + + // PDF Header + try writer.writeAll("%PDF-1.4\n"); + try writer.writeAll("%\xE2\xE3\xCF\xD3\n"); // Binary marker + + // Collect all unique fonts used + var fonts_set = std.AutoHashMap(Font, void).init(self.allocator); + defer fonts_set.deinit(); + for (pages) |page| { + for (page.fonts_used) |font| { + try fonts_set.put(font, {}); + } + } + + // Convert to list + var fonts_list: std.ArrayListUnmanaged(Font) = .{}; + defer fonts_list.deinit(self.allocator); + var font_iter = fonts_set.keyIterator(); + while (font_iter.next()) |font| { + try fonts_list.append(self.allocator, font.*); + } + const fonts = fonts_list.items; + + // Calculate object IDs: + // 1 = Catalog + // 2 = Pages (root) + // 3 = Info (optional) + // 4..4+num_fonts-1 = Font objects + // 4+num_fonts..4+num_fonts+num_pages*2-1 = Page + Content objects + const catalog_id: u32 = 1; + const pages_root_id: u32 = 2; + const info_id: u32 = 3; + const first_font_id: u32 = 4; + const first_page_id: u32 = first_font_id + @as(u32, @intCast(fonts.len)); + + // Object 1: Catalog + try self.beginObject(catalog_id); + try writer.writeAll("<< /Type /Catalog "); + try writer.print("/Pages {d} 0 R ", .{pages_root_id}); + try writer.writeAll(">>\n"); + try self.endObject(); + + // Object 2: Pages root + try self.beginObject(pages_root_id); + try writer.writeAll("<< /Type /Pages\n"); + try writer.writeAll("/Kids ["); + for (0..pages.len) |i| { + const page_obj_id = first_page_id + @as(u32, @intCast(i * 2)); + try writer.print("{d} 0 R ", .{page_obj_id}); + } + try writer.writeAll("]\n"); + try writer.print("/Count {d}\n", .{pages.len}); + try writer.writeAll(">>\n"); + try self.endObject(); + + // Object 3: Info dictionary + try self.beginObject(info_id); + try writer.writeAll("<<\n"); + if (metadata.title) |title| { + try writer.writeAll("/Title "); + try base.writeString(writer, title); + try writer.writeByte('\n'); + } + if (metadata.author) |author| { + try writer.writeAll("/Author "); + try base.writeString(writer, author); + try writer.writeByte('\n'); + } + if (metadata.subject) |subject| { + try writer.writeAll("/Subject "); + try base.writeString(writer, subject); + try writer.writeByte('\n'); + } + if (metadata.creator) |creator| { + try writer.writeAll("/Creator "); + try base.writeString(writer, creator); + try writer.writeByte('\n'); + } + try writer.writeAll("/Producer (zpdf)\n"); + try writer.writeAll(">>\n"); + try self.endObject(); + + // Font objects + for (fonts, 0..) |font, i| { + const font_id = first_font_id + @as(u32, @intCast(i)); + try self.beginObject(font_id); + try writer.writeAll("<< /Type /Font /Subtype /Type1 /BaseFont /"); + try writer.writeAll(font.pdfName()); + if (font.encoding() == .win_ansi) { + try writer.writeAll(" /Encoding /WinAnsiEncoding"); + } + try writer.writeAll(" >>\n"); + try self.endObject(); + } + + // Page and Content objects + for (pages, 0..) |page, i| { + const page_obj_id = first_page_id + @as(u32, @intCast(i * 2)); + const content_obj_id = page_obj_id + 1; + + // Page object + try self.beginObject(page_obj_id); + try writer.writeAll("<< /Type /Page\n"); + try writer.print("/Parent {d} 0 R\n", .{pages_root_id}); + try writer.print("/MediaBox [0 0 {d:.2} {d:.2}]\n", .{ page.width, page.height }); + try writer.print("/Contents {d} 0 R\n", .{content_obj_id}); + + // Resources + try writer.writeAll("/Resources <<\n"); + try writer.writeAll(" /Font <<\n"); + for (fonts, 0..) |font, fi| { + const font_id = first_font_id + @as(u32, @intCast(fi)); + try writer.print(" /{s} {d} 0 R\n", .{ font.pdfName(), font_id }); + } + try writer.writeAll(" >>\n"); + try writer.writeAll(">>\n"); + + try writer.writeAll(">>\n"); + try self.endObject(); + + // Content stream + try self.beginObject(content_obj_id); + try writer.print("<< /Length {d} >>\n", .{page.content.len}); + try writer.writeAll("stream\n"); + try writer.writeAll(page.content); + if (page.content.len > 0 and page.content[page.content.len - 1] != '\n') { + try writer.writeByte('\n'); + } + try writer.writeAll("endstream\n"); + try self.endObject(); + } + + // Cross-reference table + const xref_offset = self.buffer.items.len; + try writer.writeAll("xref\n"); + const num_objects = self.current_obj_id + 1; + try writer.print("0 {d}\n", .{num_objects}); + + // Object 0 (free object) + try writer.writeAll("0000000000 65535 f \n"); + + // All other objects + for (self.obj_offsets.items) |offset| { + try writer.print("{d:0>10} 00000 n \n", .{offset}); + } + + // Trailer + try writer.writeAll("trailer\n"); + try writer.print("<< /Size {d} /Root {d} 0 R /Info {d} 0 R >>\n", .{ num_objects, catalog_id, info_id }); + try writer.writeAll("startxref\n"); + try writer.print("{d}\n", .{xref_offset}); + try writer.writeAll("%%EOF\n"); + + return try self.buffer.toOwnedSlice(self.allocator); + } + + fn beginObject(self: *Self, id: u32) !void { + // Ensure we have enough space in offsets + while (self.obj_offsets.items.len < id) { + try self.obj_offsets.append(self.allocator, 0); + } + if (self.obj_offsets.items.len == id) { + try self.obj_offsets.append(self.allocator, self.buffer.items.len); + } else { + self.obj_offsets.items[id - 1] = self.buffer.items.len; + } + self.current_obj_id = @max(self.current_obj_id, id); + try self.buffer.writer(self.allocator).print("{d} 0 obj\n", .{id}); + } + + fn endObject(self: *Self) !void { + try self.buffer.writer(self.allocator).writeAll("endobj\n"); + } +}; + +/// Document metadata +pub const DocumentMetadata = struct { + title: ?[]const u8 = null, + author: ?[]const u8 = null, + subject: ?[]const u8 = null, + creator: ?[]const u8 = null, +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "OutputProducer generates valid PDF" { + const allocator = std.testing.allocator; + + var producer = OutputProducer.init(allocator); + defer producer.deinit(); + + const pages = [_]PageData{ + .{ + .width = 595.28, + .height = 841.89, + .content = "BT /Helvetica 12 Tf 100 700 Td (Hello) Tj ET\n", + .fonts_used = &[_]Font{.helvetica}, + }, + }; + + const pdf = try producer.generate(&pages, .{ .title = "Test" }); + defer allocator.free(pdf); + + // Check PDF structure + try std.testing.expect(std.mem.startsWith(u8, pdf, "%PDF-1.4")); + try std.testing.expect(std.mem.endsWith(u8, pdf, "%%EOF\n")); + try std.testing.expect(std.mem.indexOf(u8, pdf, "/Type /Catalog") != null); + try std.testing.expect(std.mem.indexOf(u8, pdf, "/Type /Pages") != null); + try std.testing.expect(std.mem.indexOf(u8, pdf, "/Type /Page") != null); + try std.testing.expect(std.mem.indexOf(u8, pdf, "xref") != null); + try std.testing.expect(std.mem.indexOf(u8, pdf, "trailer") != null); +} diff --git a/src/page.zig b/src/page.zig new file mode 100644 index 0000000..4fe3a3d --- /dev/null +++ b/src/page.zig @@ -0,0 +1,851 @@ +//! PdfPage - A single page in a PDF document +//! +//! Pages contain content streams with drawing operations, +//! and track resources (fonts, images) used on the page. +//! +//! Based on: fpdf2/fpdf/output.py (PDFPage class) + +const std = @import("std"); +const ContentStream = @import("content_stream.zig").ContentStream; +const RenderStyle = @import("content_stream.zig").RenderStyle; +const Color = @import("graphics/color.zig").Color; +const Font = @import("fonts/type1.zig").Font; +const PageSize = @import("objects/base.zig").PageSize; + +/// Text alignment options +pub const Align = enum { + /// Left alignment (default) + left, + /// Center alignment + center, + /// Right alignment + right, +}; + +/// Border specification for cells +pub const Border = packed struct { + left: bool = false, + top: bool = false, + right: bool = false, + bottom: bool = false, + + pub const none = Border{}; + pub const all = Border{ .left = true, .top = true, .right = true, .bottom = true }; + + pub fn fromInt(val: u4) Border { + return @bitCast(val); + } +}; + +/// A single page in a PDF document. +pub const Page = struct { + allocator: std.mem.Allocator, + + /// Page dimensions in points + width: f32, + height: f32, + + /// Content stream for this page + content: ContentStream, + + /// Current graphics state + state: GraphicsState, + + /// Fonts used on this page (for resource dictionary) + fonts_used: std.AutoHashMap(Font, void), + + const Self = @This(); + + /// Graphics state for the page + pub const GraphicsState = struct { + /// Current font + font: Font = .helvetica, + /// Current font size in points + font_size: f32 = 12, + /// Stroke color (for lines and outlines) + stroke_color: Color = Color.black, + /// Fill color (for fills and text) + fill_color: Color = Color.black, + /// Line width + line_width: f32 = 1.0, + /// Current X position + x: f32 = 0, + /// Current Y position + y: f32 = 0, + /// Left margin + left_margin: f32 = 28.35, // 10mm default + /// Right margin + right_margin: f32 = 28.35, // 10mm default + /// Top margin + top_margin: f32 = 28.35, // 10mm default + /// Cell margin (horizontal padding inside cells) + cell_margin: f32 = 1.0, + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + /// Creates a new page with a standard size. + pub fn init(allocator: std.mem.Allocator, size: PageSize) Self { + const dims = size.dimensions(); + return initCustom(allocator, dims.width, dims.height); + } + + /// Creates a new page with custom dimensions (in points). + pub fn initCustom(allocator: std.mem.Allocator, width: f32, height: f32) Self { + return .{ + .allocator = allocator, + .width = width, + .height = height, + .content = ContentStream.init(allocator), + .state = .{}, + .fonts_used = std.AutoHashMap(Font, void).init(allocator), + }; + } + + /// Frees all resources. + pub fn deinit(self: *Self) void { + self.content.deinit(); + self.fonts_used.deinit(); + } + + // ========================================================================= + // Font Operations + // ========================================================================= + + /// Sets the current font and size. + pub fn setFont(self: *Self, font: Font, size: f32) !void { + self.state.font = font; + self.state.font_size = size; + try self.fonts_used.put(font, {}); + } + + /// Gets the current font. + pub fn getFont(self: *const Self) Font { + return self.state.font; + } + + /// Gets the current font size. + pub fn getFontSize(self: *const Self) f32 { + return self.state.font_size; + } + + // ========================================================================= + // Color Operations + // ========================================================================= + + /// Sets the fill color (used for text and shape fills). + pub fn setFillColor(self: *Self, color: Color) void { + self.state.fill_color = color; + } + + /// Sets the stroke color (used for lines and shape outlines). + pub fn setStrokeColor(self: *Self, color: Color) void { + self.state.stroke_color = color; + } + + /// Sets the text color (alias for setFillColor). + pub fn setTextColor(self: *Self, color: Color) void { + self.setFillColor(color); + } + + // ========================================================================= + // Line Style Operations + // ========================================================================= + + /// Sets the line width. + pub fn setLineWidth(self: *Self, width: f32) !void { + self.state.line_width = width; + try self.content.setLineWidth(width); + } + + // ========================================================================= + // Position Operations + // ========================================================================= + + /// Sets the current position. + pub fn setXY(self: *Self, x: f32, y: f32) void { + self.state.x = x; + self.state.y = y; + } + + /// Sets the current X position. + pub fn setX(self: *Self, x: f32) void { + self.state.x = x; + } + + /// Sets the current Y position. + pub fn setY(self: *Self, y: f32) void { + self.state.y = y; + } + + /// Gets the current X position. + pub fn getX(self: *const Self) f32 { + return self.state.x; + } + + /// Gets the current Y position. + pub fn getY(self: *const Self) f32 { + return self.state.y; + } + + // ========================================================================= + // Text Operations + // ========================================================================= + + /// Draws text at the specified position. + /// Note: PDF coordinates are from bottom-left, Y increases upward. + pub fn drawText(self: *Self, x: f32, y: f32, str: []const u8) !void { + try self.state.fill_color.writeFillColor(&self.content); + try self.content.text(x, y, self.state.font.pdfName(), self.state.font_size, str); + try self.fonts_used.put(self.state.font, {}); + } + + /// Draws text at the current position and updates the position. + pub fn writeText(self: *Self, str: []const u8) !void { + try self.drawText(self.state.x, self.state.y, str); + // Update X position (approximate based on font metrics) + self.state.x += self.state.font.stringWidth(str, self.state.font_size); + } + + /// Returns the width of the given string in current font and size. + pub fn getStringWidth(self: *const Self, str: []const u8) f32 { + return self.state.font.stringWidth(str, self.state.font_size); + } + + /// Returns the effective width available for content (page width minus margins). + pub fn getEffectiveWidth(self: *const Self) f32 { + return self.width - self.state.left_margin - self.state.right_margin; + } + + /// Sets page margins. + pub fn setMargins(self: *Self, left: f32, top: f32, right: f32) void { + self.state.left_margin = left; + self.state.top_margin = top; + self.state.right_margin = right; + } + + /// Sets the cell margin (horizontal padding inside cells). + pub fn setCellMargin(self: *Self, margin: f32) void { + self.state.cell_margin = margin; + } + + /// Performs a line break. The current X position goes back to the left margin. + /// Y position moves down by the given height (or current font size if null). + pub fn ln(self: *Self, h: ?f32) void { + self.state.x = self.state.left_margin; + self.state.y -= h orelse self.state.font_size; + } + + /// Prints a cell (rectangular area) with optional borders, background and text. + /// This is the main method for outputting text in a structured way. + /// + /// Parameters: + /// - w: Cell width. If 0, extends to right margin. If null, fits text width. + /// - h: Cell height. If null, uses current font size. + /// - str: Text to print. + /// - border: Border specification. + /// - align_h: Horizontal alignment (left, center, right). + /// - fill: If true, fills the cell background with current fill color. + /// - move_to: Where to move after the cell (right, next_line, below). + pub fn cell( + self: *Self, + w: ?f32, + h: ?f32, + str: []const u8, + border: Border, + align_h: Align, + fill: bool, + ) !void { + try self.cellAdvanced(w, h, str, border, align_h, fill, .right); + } + + /// Cell position after rendering + pub const CellPosition = enum { + /// Move to the right of the cell + right, + /// Move to the beginning of the next line + next_line, + /// Stay below the cell (same X, next line Y) + below, + }; + + /// Advanced cell function with position control. + pub fn cellAdvanced( + self: *Self, + w_opt: ?f32, + h_opt: ?f32, + str: []const u8, + border: Border, + align_h: Align, + fill: bool, + move_to: CellPosition, + ) !void { + const k = self.state.font_size; // Base unit + const h = h_opt orelse k; + + // Calculate width + var w: f32 = undefined; + if (w_opt) |width| { + if (width == 0) { + // Extend to right margin + w = self.width - self.state.right_margin - self.state.x; + } else { + w = width; + } + } else { + // Fit to text width + cell margins + w = self.getStringWidth(str) + 2 * self.state.cell_margin; + } + + const x = self.state.x; + const y = self.state.y; + + // Fill background + if (fill) { + try self.state.fill_color.writeFillColor(&self.content); + try self.content.rect(x, y - h, w, h, .fill); + } + + // Draw borders + if (border.left or border.top or border.right or border.bottom) { + try self.state.stroke_color.writeStrokeColor(&self.content); + + if (border.left) { + try self.content.line(x, y, x, y - h); + } + if (border.top) { + try self.content.line(x, y, x + w, y); + } + if (border.right) { + try self.content.line(x + w, y, x + w, y - h); + } + if (border.bottom) { + try self.content.line(x, y - h, x + w, y - h); + } + } + + // Draw text + if (str.len > 0) { + const text_width = self.getStringWidth(str); + + // Calculate X position based on alignment + const text_x = switch (align_h) { + .left => x + self.state.cell_margin, + .center => x + (w - text_width) / 2, + .right => x + w - self.state.cell_margin - text_width, + }; + + // Y position: vertically centered in cell + // PDF text baseline is at the given Y, so we need to adjust + const text_y = y - h + (h - self.state.font_size) / 2 + self.state.font_size * 0.8; + + try self.state.fill_color.writeFillColor(&self.content); + try self.content.text(text_x, text_y, self.state.font.pdfName(), self.state.font_size, str); + try self.fonts_used.put(self.state.font, {}); + } + + // Update position + switch (move_to) { + .right => { + self.state.x = x + w; + }, + .next_line => { + self.state.x = self.state.left_margin; + self.state.y = y - h; + }, + .below => { + self.state.y = y - h; + }, + } + } + + /// Multi-cell: prints text with automatic line breaks. + /// Text is wrapped at the cell width and multiple lines are stacked. + /// + /// Parameters: + /// - w: Cell width. If 0, extends to right margin. + /// - h: Height of each line. If null, uses current font size. + /// - str: Text to print (can contain \n for explicit line breaks). + /// - border: Border specification (applied to the whole block). + /// - align_h: Horizontal alignment. + /// - fill: If true, fills each line's background. + pub fn multiCell( + self: *Self, + w_param: f32, + h_opt: ?f32, + str: []const u8, + border: Border, + align_h: Align, + fill: bool, + ) !void { + const h = h_opt orelse self.state.font_size; + const w = if (w_param == 0) self.width - self.state.right_margin - self.state.x else w_param; + + // Available width for text (minus cell margins) + const text_width = w - 2 * self.state.cell_margin; + + const start_x = self.state.x; + const start_y = self.state.y; + var current_y = start_y; + var is_first_line = true; + var is_last_line = false; + + // Process text line by line (splitting on explicit newlines and word wrap) + var remaining = str; + while (remaining.len > 0) { + // Find next explicit newline + const newline_pos = std.mem.indexOf(u8, remaining, "\n"); + + // Get the current paragraph (up to newline or end) + const paragraph = if (newline_pos) |pos| remaining[0..pos] else remaining; + + // Wrap this paragraph + var para_remaining = paragraph; + while (para_remaining.len > 0 or (newline_pos != null and para_remaining.len == 0)) { + // Find how much text fits on this line + const line = self.wrapLine(para_remaining, text_width); + + // Check if this is the last line + const next_remaining = if (line.len < para_remaining.len) + std.mem.trimLeft(u8, para_remaining[line.len..], " ") + else + ""; + + is_last_line = next_remaining.len == 0 and (newline_pos == null or newline_pos.? + 1 >= remaining.len); + + // Determine borders for this line + var line_border = Border.none; + if (border.left) line_border.left = true; + if (border.right) line_border.right = true; + if (border.top and is_first_line) line_border.top = true; + if (border.bottom and is_last_line) line_border.bottom = true; + + // Print this line + self.state.x = start_x; + try self.cellAdvanced(w, h, line, line_border, align_h, fill, .next_line); + + current_y = self.state.y; + is_first_line = false; + para_remaining = next_remaining; + + // Handle empty paragraph (just a newline) + if (para_remaining.len == 0 and line.len == 0) break; + } + + // Move past the newline + if (newline_pos) |pos| { + remaining = if (pos + 1 < remaining.len) remaining[pos + 1 ..] else ""; + } else { + remaining = ""; + } + } + } + + /// Wraps text to fit within the given width, breaking at word boundaries. + /// Returns the portion of text that fits on one line. + fn wrapLine(self: *const Self, text: []const u8, max_width: f32) []const u8 { + if (text.len == 0) return text; + + // Check if entire text fits + if (self.getStringWidth(text) <= max_width) { + return text; + } + + // Find the last space that allows text to fit + var last_space: ?usize = null; + var i: usize = 0; + var current_width: f32 = 0; + + while (i < text.len) { + // charWidth returns units of 1/1000 of font size, convert to points + const char_width_units = self.state.font.charWidth(text[i]); + const char_width = @as(f32, @floatFromInt(char_width_units)) * self.state.font_size / 1000.0; + current_width += char_width; + + if (text[i] == ' ') { + if (current_width <= max_width) { + last_space = i; + } + } + + if (current_width > max_width) { + // If we found a space, break there + if (last_space) |space_pos| { + return text[0..space_pos]; + } + // No space found, break at current position (word is too long) + return if (i > 0) text[0..i] else text[0..1]; + } + + i += 1; + } + + return text; + } + + // ========================================================================= + // Graphics Operations + // ========================================================================= + + /// Draws a line from (x1, y1) to (x2, y2). + pub fn drawLine(self: *Self, x1: f32, y1: f32, x2: f32, y2: f32) !void { + try self.state.stroke_color.writeStrokeColor(&self.content); + try self.content.line(x1, y1, x2, y2); + } + + /// Draws a rectangle outline. + pub fn drawRect(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { + try self.state.stroke_color.writeStrokeColor(&self.content); + try self.content.rect(x, y, w, h, .stroke); + } + + /// Fills a rectangle. + pub fn fillRect(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { + try self.state.fill_color.writeFillColor(&self.content); + try self.content.rect(x, y, w, h, .fill); + } + + /// Draws a filled rectangle with stroke. + pub fn drawFilledRect(self: *Self, x: f32, y: f32, w: f32, h: f32) !void { + try self.state.fill_color.writeFillColor(&self.content); + try self.state.stroke_color.writeStrokeColor(&self.content); + try self.content.rect(x, y, w, h, .fill_stroke); + } + + /// Draws a rectangle with the specified style. + pub fn rect(self: *Self, x: f32, y: f32, w: f32, h: f32, style: RenderStyle) !void { + switch (style) { + .stroke => { + try self.state.stroke_color.writeStrokeColor(&self.content); + }, + .fill => { + try self.state.fill_color.writeFillColor(&self.content); + }, + .fill_stroke => { + try self.state.fill_color.writeFillColor(&self.content); + try self.state.stroke_color.writeStrokeColor(&self.content); + }, + } + try self.content.rect(x, y, w, h, style); + } + + // ========================================================================= + // Content Access + // ========================================================================= + + /// Returns the content stream as bytes. + pub fn getContent(self: *const Self) []const u8 { + return self.content.getContent(); + } + + /// Returns the list of fonts used on this page. + pub fn getFontsUsed(self: *const Self) []const Font { + var fonts: std.ArrayListUnmanaged(Font) = .{}; + var iter = self.fonts_used.keyIterator(); + while (iter.next()) |font| { + fonts.append(self.allocator, font.*) catch {}; + } + return fonts.toOwnedSlice(self.allocator) catch &[_]Font{}; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Page init" { + const allocator = std.testing.allocator; + + var page = Page.init(allocator, .a4); + defer page.deinit(); + + try std.testing.expectApproxEqAbs(@as(f32, 595.28), page.width, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 841.89), page.height, 0.01); +} + +test "Page setFont" { + const allocator = std.testing.allocator; + + var page = Page.init(allocator, .a4); + defer page.deinit(); + + try page.setFont(.helvetica_bold, 24); + try std.testing.expectEqual(Font.helvetica_bold, page.getFont()); + try std.testing.expectEqual(@as(f32, 24), page.getFontSize()); +} + +test "Page drawText" { + const allocator = std.testing.allocator; + + var page = Page.init(allocator, .a4); + defer page.deinit(); + + try page.setFont(.helvetica, 12); + try page.drawText(100, 700, "Hello World"); + + const content = page.getContent(); + try std.testing.expect(content.len > 0); + try std.testing.expect(std.mem.indexOf(u8, content, "BT") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "Hello World") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "ET") != null); +} + +test "Page graphics" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setLineWidth(2.0); + try pg.drawLine(0, 0, 100, 100); + try pg.drawRect(50, 50, 100, 50); + + pg.setFillColor(Color.light_gray); + try pg.fillRect(200, 200, 50, 50); + + const content = pg.getContent(); + try std.testing.expect(content.len > 0); +} + +test "Page cell" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setXY(50, 800); + + // Simple cell with border + try pg.cell(100, 20, "Hello", Border.all, .left, false); + + // Cell should move position to the right + try std.testing.expectApproxEqAbs(@as(f32, 150), pg.getX(), 0.01); + + const content = pg.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "Hello") != null); +} + +test "Page cell with fill" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setXY(50, 800); + pg.setFillColor(Color.light_gray); + + try pg.cell(100, 20, "Filled", Border.all, .center, true); + + const content = pg.getContent(); + // Should have rectangle fill command + try std.testing.expect(std.mem.indexOf(u8, content, "re") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "f") != null); +} + +test "Page ln" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + pg.setXY(100, 800); + pg.ln(12); + + try std.testing.expectApproxEqAbs(pg.state.left_margin, pg.getX(), 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 788), pg.getY(), 0.01); +} + +test "Page getStringWidth" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + + const width = pg.getStringWidth("Hello"); + try std.testing.expect(width > 0); + try std.testing.expect(width < 100); +} + +test "Page wrapLine" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + + // Short text that fits + const short = pg.wrapLine("Hello", 100); + try std.testing.expectEqualStrings("Hello", short); + + // Long text that needs wrapping + const long_text = "This is a very long text that should be wrapped"; + const wrapped = pg.wrapLine(long_text, 100); + try std.testing.expect(wrapped.len < long_text.len); + try std.testing.expect(wrapped.len > 0); +} + +test "Page multiCell" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setXY(50, 800); + + try pg.multiCell(200, null, "This is a test of the multiCell function with word wrapping.", Border.all, .left, false); + + const content = pg.getContent(); + try std.testing.expect(content.len > 0); + // Y should have moved down + try std.testing.expect(pg.getY() < 800); +} + +test "Border" { + try std.testing.expectEqual(false, Border.none.left); + try std.testing.expectEqual(false, Border.none.top); + try std.testing.expectEqual(true, Border.all.left); + try std.testing.expectEqual(true, Border.all.top); + try std.testing.expectEqual(true, Border.all.right); + try std.testing.expectEqual(true, Border.all.bottom); +} + +test "Border fromInt" { + const border = Border.fromInt(0b1111); + try std.testing.expectEqual(true, border.left); + try std.testing.expectEqual(true, border.top); + try std.testing.expectEqual(true, border.right); + try std.testing.expectEqual(true, border.bottom); + + const partial = Border.fromInt(0b0101); + try std.testing.expectEqual(true, partial.left); + try std.testing.expectEqual(false, partial.top); + try std.testing.expectEqual(true, partial.right); + try std.testing.expectEqual(false, partial.bottom); +} + +test "Page cell alignment" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + + // Test left alignment + pg.setXY(50, 800); + try pg.cell(100, 20, "Left", Border.none, .left, false); + + // Test center alignment + pg.setXY(50, 780); + try pg.cell(100, 20, "Center", Border.none, .center, false); + + // Test right alignment + pg.setXY(50, 760); + try pg.cell(100, 20, "Right", Border.none, .right, false); + + const content = pg.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "Left") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "Center") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "Right") != null); +} + +test "Page cell zero width extends to margin" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setMargins(50, 50, 50); + pg.setXY(50, 800); + + try pg.cell(0, 20, "Full width", Border.all, .center, false); + + // X should now be at right margin + const expected_x = pg.width - pg.state.right_margin; + try std.testing.expectApproxEqAbs(expected_x, pg.getX(), 0.01); +} + +test "Page cellAdvanced positions" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setMargins(50, 50, 50); + pg.setXY(50, 800); + + // Test move_to: right (default) + try pg.cellAdvanced(100, 20, "A", Border.none, .left, false, .right); + try std.testing.expectApproxEqAbs(@as(f32, 150), pg.getX(), 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 800), pg.getY(), 0.01); + + // Test move_to: next_line + pg.setXY(50, 800); + try pg.cellAdvanced(100, 20, "B", Border.none, .left, false, .next_line); + try std.testing.expectApproxEqAbs(@as(f32, 50), pg.getX(), 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 780), pg.getY(), 0.01); + + // Test move_to: below + pg.setXY(100, 800); + try pg.cellAdvanced(100, 20, "C", Border.none, .left, false, .below); + try std.testing.expectApproxEqAbs(@as(f32, 100), pg.getX(), 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 780), pg.getY(), 0.01); +} + +test "Page margins" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + pg.setMargins(30, 40, 50); + + try std.testing.expectApproxEqAbs(@as(f32, 30), pg.state.left_margin, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 40), pg.state.top_margin, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 50), pg.state.right_margin, 0.01); + + const effective = pg.getEffectiveWidth(); + try std.testing.expectApproxEqAbs(pg.width - 30 - 50, effective, 0.01); +} + +test "Page multiCell with explicit newlines" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setXY(50, 800); + + try pg.multiCell(200, 15, "Line 1\nLine 2\nLine 3", Border.none, .left, false); + + // Y should have moved down by 3 lines (approx 45 points) + try std.testing.expect(pg.getY() < 760); +} + +test "Page writeText updates position" { + const allocator = std.testing.allocator; + + var pg = Page.init(allocator, .a4); + defer pg.deinit(); + + try pg.setFont(.helvetica, 12); + pg.setXY(50, 800); + + const start_x = pg.getX(); + try pg.writeText("Hello"); + const end_x = pg.getX(); + + // X should have increased by the width of "Hello" + try std.testing.expect(end_x > start_x); +} diff --git a/src/pdf.zig b/src/pdf.zig new file mode 100644 index 0000000..89f1c67 --- /dev/null +++ b/src/pdf.zig @@ -0,0 +1,278 @@ +//! Pdf - Main facade for PDF document creation +//! +//! This is the main entry point for creating PDF documents. +//! Provides a high-level API similar to fpdf2's FPDF class. +//! +//! Based on: fpdf2/fpdf/fpdf.py (FPDF class) + +const std = @import("std"); +const Page = @import("page.zig").Page; +const ContentStream = @import("content_stream.zig").ContentStream; +const Color = @import("graphics/color.zig").Color; +const Font = @import("fonts/type1.zig").Font; +const PageSize = @import("objects/base.zig").PageSize; +const Orientation = @import("objects/base.zig").Orientation; +const Unit = @import("objects/base.zig").Unit; +const OutputProducer = @import("output/producer.zig").OutputProducer; +const PageData = @import("output/producer.zig").PageData; +const DocumentMetadata = @import("output/producer.zig").DocumentMetadata; + +/// A PDF document builder. +/// +/// Example usage: +/// ```zig +/// var pdf = Pdf.init(allocator, .{}); +/// defer pdf.deinit(); +/// +/// var page = try pdf.addPage(.{}); +/// try page.setFont(.helvetica_bold, 24); +/// try page.drawText(50, 750, "Hello, World!"); +/// +/// try pdf.save("hello.pdf"); +/// ``` +pub const Pdf = struct { + allocator: std.mem.Allocator, + + /// All pages in the document + pages: std.ArrayListUnmanaged(Page), + + /// Document metadata + title: ?[]const u8 = null, + author: ?[]const u8 = null, + subject: ?[]const u8 = null, + creator: ?[]const u8 = null, + + /// Default page settings + default_page_size: PageSize, + default_orientation: Orientation, + unit: Unit, + + const Self = @This(); + + /// Options for creating a PDF document. + pub const Options = struct { + /// Default page size + page_size: PageSize = .a4, + /// Default page orientation + orientation: Orientation = .portrait, + /// Unit of measurement for user coordinates + unit: Unit = .pt, + }; + + /// Options for adding a page. + pub const PageOptions = struct { + /// Page size (uses document default if null) + size: ?PageSize = null, + /// Page orientation (uses document default if null) + orientation: ?Orientation = null, + }; + + // ========================================================================= + // Initialization + // ========================================================================= + + /// Creates a new PDF document. + pub fn init(allocator: std.mem.Allocator, options: Options) Self { + return .{ + .allocator = allocator, + .pages = .{}, + .default_page_size = options.page_size, + .default_orientation = options.orientation, + .unit = options.unit, + }; + } + + /// Frees all resources. + pub fn deinit(self: *Self) void { + for (self.pages.items) |*page| { + page.deinit(); + } + self.pages.deinit(self.allocator); + } + + // ========================================================================= + // Document Metadata + // ========================================================================= + + /// Sets the document title. + pub fn setTitle(self: *Self, title: []const u8) void { + self.title = title; + } + + /// Sets the document author. + pub fn setAuthor(self: *Self, author: []const u8) void { + self.author = author; + } + + /// Sets the document subject. + pub fn setSubject(self: *Self, subject: []const u8) void { + self.subject = subject; + } + + /// Sets the document creator. + pub fn setCreator(self: *Self, creator: []const u8) void { + self.creator = creator; + } + + // ========================================================================= + // Page Management + // ========================================================================= + + /// Adds a new page to the document. + pub fn addPage(self: *Self, options: PageOptions) !*Page { + const size = options.size orelse self.default_page_size; + const orientation = options.orientation orelse self.default_orientation; + + const dims = size.dimensions(); + const width = if (orientation == .landscape) dims.height else dims.width; + const height = if (orientation == .landscape) dims.width else dims.height; + + const page = Page.initCustom(self.allocator, width, height); + try self.pages.append(self.allocator, page); + return &self.pages.items[self.pages.items.len - 1]; + } + + /// Adds a new page with custom dimensions (in the document's unit). + pub fn addPageCustom(self: *Self, width: f32, height: f32) !*Page { + const width_pt = self.unit.toPoints(width); + const height_pt = self.unit.toPoints(height); + + const page = Page.initCustom(self.allocator, width_pt, height_pt); + try self.pages.append(self.allocator, page); + return &self.pages.items[self.pages.items.len - 1]; + } + + /// Returns the number of pages. + pub fn pageCount(self: *const Self) usize { + return self.pages.items.len; + } + + /// Returns a page by index (0-based). + pub fn getPage(self: *Self, index: usize) ?*Page { + if (index < self.pages.items.len) { + return &self.pages.items[index]; + } + return null; + } + + // ========================================================================= + // Output + // ========================================================================= + + /// Renders the document to a byte buffer. + pub fn render(self: *Self) ![]u8 { + // Collect page data + var page_data: std.ArrayListUnmanaged(PageData) = .{}; + defer page_data.deinit(self.allocator); + + // Keep track of all font slices to free them after generation + var font_slices: std.ArrayListUnmanaged([]Font) = .{}; + defer { + for (font_slices.items) |slice| { + self.allocator.free(slice); + } + font_slices.deinit(self.allocator); + } + + for (self.pages.items) |*page| { + // Get fonts used - convert to owned slice + var fonts: std.ArrayListUnmanaged(Font) = .{}; + var iter = page.fonts_used.keyIterator(); + while (iter.next()) |font| { + try fonts.append(self.allocator, font.*); + } + const fonts_slice = try fonts.toOwnedSlice(self.allocator); + try font_slices.append(self.allocator, fonts_slice); + + try page_data.append(self.allocator, .{ + .width = page.width, + .height = page.height, + .content = page.getContent(), + .fonts_used = fonts_slice, + }); + } + + // Generate PDF + var producer = OutputProducer.init(self.allocator); + defer producer.deinit(); + + return try producer.generate(page_data.items, .{ + .title = self.title, + .author = self.author, + .subject = self.subject, + .creator = self.creator, + }); + } + + /// Saves the document to a file. + pub fn save(self: *Self, path: []const u8) !void { + const data = try self.render(); + defer self.allocator.free(data); + + const file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + + try file.writeAll(data); + } + + /// Outputs the document and returns the bytes. + pub fn output(self: *Self) ![]u8 { + return try self.render(); + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Pdf init" { + const allocator = std.testing.allocator; + + var pdf = Pdf.init(allocator, .{}); + defer pdf.deinit(); + + try std.testing.expectEqual(@as(usize, 0), pdf.pageCount()); +} + +test "Pdf addPage" { + const allocator = std.testing.allocator; + + var pdf = Pdf.init(allocator, .{}); + defer pdf.deinit(); + + _ = try pdf.addPage(.{}); + try std.testing.expectEqual(@as(usize, 1), pdf.pageCount()); + + _ = try pdf.addPage(.{ .size = .letter }); + try std.testing.expectEqual(@as(usize, 2), pdf.pageCount()); +} + +test "Pdf metadata" { + const allocator = std.testing.allocator; + + var pdf = Pdf.init(allocator, .{}); + defer pdf.deinit(); + + pdf.setTitle("Test Document"); + pdf.setAuthor("Test Author"); + + try std.testing.expectEqualStrings("Test Document", pdf.title.?); + try std.testing.expectEqualStrings("Test Author", pdf.author.?); +} + +test "Pdf render" { + const allocator = std.testing.allocator; + + var pdf = Pdf.init(allocator, .{}); + defer pdf.deinit(); + + var page = try pdf.addPage(.{}); + try page.setFont(.helvetica, 12); + try page.drawText(100, 700, "Hello"); + + const output = try pdf.render(); + defer allocator.free(output); + + try std.testing.expect(std.mem.startsWith(u8, output, "%PDF-1.4")); + try std.testing.expect(std.mem.endsWith(u8, output, "%%EOF\n")); +} diff --git a/src/root.zig b/src/root.zig index 5d00aaa..be1cfe0 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,522 +1,226 @@ //! zpdf - PDF generation library for Zig //! //! A pure Zig library for creating PDF documents with zero dependencies. -//! Focused on generating invoices and business documents. +//! Based on fpdf2 (Python) architecture. //! //! ## Quick Start //! //! ```zig -//! const pdf = @import("zpdf"); +//! const zpdf = @import("zpdf"); //! //! pub fn main() !void { -//! var doc = pdf.Document.init(allocator); -//! defer doc.deinit(); +//! var pdf = zpdf.Pdf.init(allocator, .{}); +//! defer pdf.deinit(); //! -//! var page = try doc.addPage(.a4); +//! var page = try pdf.addPage(.{}); //! try page.setFont(.helvetica_bold, 24); //! try page.drawText(50, 750, "Hello, PDF!"); //! -//! try doc.saveToFile("hello.pdf"); +//! try pdf.save("hello.pdf"); //! } //! ``` const std = @import("std"); -// ============================================================================ -// Public Types -// ============================================================================ +// ============================================================================= +// Module Re-exports +// ============================================================================= -/// 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) +/// Main PDF document facade +pub const pdf = @import("pdf.zig"); +pub const Pdf = pdf.Pdf; - 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 }, - }; - } -}; +/// Page representation +pub const page = @import("page.zig"); +pub const Page = page.Page; +pub const Align = page.Align; +pub const Border = page.Border; +pub const CellPosition = page.Page.CellPosition; -/// 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, +/// Content stream (low-level PDF operators) +pub const content_stream = @import("content_stream.zig"); +pub const ContentStream = content_stream.ContentStream; +pub const RenderStyle = content_stream.RenderStyle; +pub const LineCap = content_stream.LineCap; +pub const LineJoin = content_stream.LineJoin; +pub const TextRenderMode = content_stream.TextRenderMode; - 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", - }; - } -}; +/// Graphics (colors, etc.) +pub const graphics = @import("graphics/mod.zig"); +pub const Color = graphics.Color; -/// RGB color (0-255 per channel) -pub const Color = struct { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, +/// Fonts +pub const fonts = @import("fonts/mod.zig"); +pub const Font = fonts.Font; +pub const FontFamily = fonts.FontFamily; +pub const FontState = fonts.FontState; - 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 }; +/// Objects (base types, page sizes, units) +pub const objects = @import("objects/mod.zig"); +pub const PageSize = objects.PageSize; +pub const Orientation = objects.Orientation; +pub const Unit = objects.Unit; - /// 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, - }; - } -}; +/// Output (PDF generation) +pub const output = @import("output/mod.zig"); +pub const OutputProducer = output.OutputProducer; -// ============================================================================ -// Page -// ============================================================================ +// ============================================================================= +// Backwards Compatibility - Old API (Document) +// ============================================================================= -/// 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 +/// Legacy Document type (use Pdf instead for new code). +/// Provided for backwards compatibility with existing code. pub const Document = struct { - allocator: std.mem.Allocator, - pages: std.ArrayListUnmanaged(Page), + inner: Pdf, const Self = @This(); - /// Creates a new empty PDF document + /// Creates a new empty PDF document. pub fn init(allocator: std.mem.Allocator) Self { return .{ - .allocator = allocator, - .pages = .{}, + .inner = Pdf.init(allocator, .{}), }; } - /// Frees all resources + /// Frees all resources. pub fn deinit(self: *Self) void { - for (self.pages.items) |*page| { - page.deinit(); - } - self.pages.deinit(self.allocator); + self.inner.deinit(); } - /// Adds a new page with a standard size + /// 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]; + return try self.inner.addPage(.{ .size = size }); } - /// Adds a new page with custom dimensions (in points) + /// 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]; + return try self.inner.addPageCustom(width, height); } - /// Renders the document to a byte buffer + /// 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); + _ = allocator; // Use inner allocator + return try self.inner.render(); } - /// Saves the document to a file + /// 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); + return try self.inner.save(path); } }; -// ============================================================================ +// ============================================================================= // Tests -// ============================================================================ +// ============================================================================= -test "create empty document" { +test "zpdf re-exports" { + // Test that all types are accessible + _ = Pdf; + _ = Page; + _ = ContentStream; + _ = Color; + _ = Font; + _ = PageSize; + _ = Document; +} + +test "Document backwards compatibility" { const allocator = std.testing.allocator; var doc = Document.init(allocator); defer doc.deinit(); - try std.testing.expectEqual(@as(usize, 0), doc.pages.items.len); + var pg = try doc.addPage(.a4); + try pg.setFont(.helvetica_bold, 24); + try pg.drawText(50, 750, "Hello"); + try pg.drawLine(50, 740, 200, 740); + + const data = try doc.render(allocator); + defer allocator.free(data); + + try std.testing.expect(std.mem.startsWith(u8, data, "%PDF-1.4")); } -test "add page" { +test "new Pdf API" { const allocator = std.testing.allocator; - var doc = Document.init(allocator); - defer doc.deinit(); + var zpdf_doc = Pdf.init(allocator, .{ + .page_size = .a4, + .orientation = .portrait, + }); + defer zpdf_doc.deinit(); - _ = try doc.addPage(.a4); - try std.testing.expectEqual(@as(usize, 1), doc.pages.items.len); + zpdf_doc.setTitle("Test Document"); + zpdf_doc.setAuthor("zpdf"); - const dims = PageSize.a4.dimensions(); - try std.testing.expectEqual(@as(f32, 595), dims.width); - try std.testing.expectEqual(@as(f32, 842), dims.height); + var pg = try zpdf_doc.addPage(.{}); + try pg.setFont(.helvetica_bold, 24); + pg.setFillColor(Color.blue); + try pg.drawText(50, 750, "Hello zpdf!"); + + pg.setStrokeColor(Color.red); + try pg.setLineWidth(2); + try pg.drawLine(50, 740, 200, 740); + + pg.setFillColor(Color.light_gray); + try pg.fillRect(50, 600, 150, 100); + + const data = try zpdf_doc.output(); + defer allocator.free(data); + + try std.testing.expect(std.mem.startsWith(u8, data, "%PDF-1.4")); + try std.testing.expect(std.mem.indexOf(u8, data, "/Title (Test Document)") != null); } -test "render minimal document" { +test "Color types" { + const red = Color.rgb(255, 0, 0); + const floats = red.toRgbFloats(); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), floats.r, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 0.0), floats.g, 0.01); + try std.testing.expectApproxEqAbs(@as(f32, 0.0), floats.b, 0.01); + + const hex_color = Color.hex(0xFF8000); + try std.testing.expectEqual(@as(u8, 255), hex_color.rgb_val.r); + try std.testing.expectEqual(@as(u8, 128), hex_color.rgb_val.g); + try std.testing.expectEqual(@as(u8, 0), hex_color.rgb_val.b); +} + +test "Font metrics" { + const font = Font.helvetica; + try std.testing.expectEqualStrings("Helvetica", font.pdfName()); + + const width = font.stringWidth("Hello", 12.0); + try std.testing.expect(width > 0); +} + +test "ContentStream operators" { const allocator = std.testing.allocator; - var doc = Document.init(allocator); - defer doc.deinit(); + var cs = ContentStream.init(allocator); + defer cs.deinit(); - var page = try doc.addPage(.a4); - try page.drawText(100, 700, "Hello"); + try cs.saveState(); + try cs.setLineWidth(2.0); + try cs.setStrokeColorRgb(1.0, 0.0, 0.0); + try cs.moveTo(100, 200); + try cs.lineTo(300, 200); + try cs.stroke(); + try cs.restoreState(); - 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")); + const content = cs.getContent(); + try std.testing.expect(std.mem.indexOf(u8, content, "q\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "Q\n") != null); + try std.testing.expect(std.mem.indexOf(u8, content, "1.000 0.000 0.000 RG") != null); } -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); +// Import all module tests +comptime { + _ = @import("content_stream.zig"); + _ = @import("page.zig"); + _ = @import("pdf.zig"); + _ = @import("graphics/color.zig"); + _ = @import("fonts/type1.zig"); + _ = @import("objects/base.zig"); + _ = @import("output/producer.zig"); }