diff --git a/CLAUDE.md b/CLAUDE.md index a510606..05d121c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Ultima actualizacion**: 2025-12-08 > **Lenguaje**: Zig 0.15.2 -> **Estado**: v0.2 - Sistema de texto completo (cell, multiCell, alignment) +> **Estado**: v0.4 - Imagenes + Utilidades (Table, Pagination, Headers/Footers, Links) > **Fuente principal**: fpdf2 (Python) - https://github.com/py-pdf/fpdf2 ## Descripcion del Proyecto @@ -22,48 +22,53 @@ ## Estado Actual del Proyecto -### Implementacion v0.2 (Sistema de Texto Completo) +### Implementacion v0.4 | Componente | Estado | Archivo | |------------|--------|---------| -| **Pdf (API Nueva)** | | | +| **Pdf (API Principal)** | | | | Pdf init/deinit | OK | `src/pdf.zig` | | setTitle, setAuthor, setSubject | OK | `src/pdf.zig` | | addPage (with options) | OK | `src/pdf.zig` | +| addJpegImage / addJpegImageFromFile | 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 | 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` | +| drawText / writeText | OK | `src/page.zig` | +| **drawLink / writeLink** | OK | `src/page.zig` | +| cell() / cellAdvanced() | OK | `src/page.zig` | +| multiCell() | OK | `src/page.zig` | +| ln() | OK | `src/page.zig` | +| drawLine / drawRect / fillRect | OK | `src/page.zig` | +| **image() / imageFit()** | OK | `src/page.zig` | +| **Table Helper** | | | +| Table.init() | OK | `src/table.zig` | +| Table.header() | OK | `src/table.zig` | +| Table.row() / rowStyled() | OK | `src/table.zig` | +| Table.footer() | OK | `src/table.zig` | +| Table.separator() / space() | OK | `src/table.zig` | +| setColumnAlign() / setColumnAligns() | OK | `src/table.zig` | +| **Pagination** | | | +| Pagination.addPageNumbers() | OK | `src/pagination.zig` | +| Pagination.addFooter() | OK | `src/pagination.zig` | +| addHeader() | OK | `src/pagination.zig` | +| addFooterWithLine() | OK | `src/pagination.zig` | +| **Images** | | | +| JPEG embedding (DCT passthrough) | OK | `src/images/jpeg.zig` | +| PNG metadata parsing | OK | `src/images/png.zig` | +| ImageInfo struct | OK | `src/images/image_info.zig` | | **Types** | | | | 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` | +| Align enum / Border struct | OK | `src/page.zig` | +| TableOptions struct | OK | `src/table.zig` | +| PageNumberOptions / HeaderOptions / FooterOptions | OK | `src/pagination.zig` | ### Tests @@ -76,7 +81,12 @@ | fonts/type1.zig | 5 | OK | | objects/base.zig | 5 | OK | | output/producer.zig | 5 | OK | -| **Total** | **52** | OK | +| images/jpeg.zig | 4 | OK | +| images/png.zig | 3 | OK | +| table.zig | 3 | OK | +| pagination.zig | 2 | OK | +| links.zig | 2 | OK | +| **Total** | **~70** | OK | ### Ejemplos @@ -84,7 +94,10 @@ |---------|-------------|--------| | 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 | +| text_demo.zig | Demo sistema de texto | OK | +| image_demo.zig | Demo imagenes JPEG | OK | +| **table_demo.zig** | Demo Table helper (3 estilos de tabla) | OK | +| **pagination_demo.zig** | Demo paginacion (5 paginas, headers, footers) | OK | --- @@ -96,29 +109,36 @@ zpdf/ ├── build.zig # Sistema de build ├── src/ │ ├── root.zig # Exports publicos + Document legacy -│ ├── pdf.zig # Pdf facade (API nueva) +│ ├── pdf.zig # Pdf facade (API principal) │ ├── page.zig # Page + sistema de texto │ ├── content_stream.zig # Content stream (operadores PDF) +│ ├── table.zig # Table helper +│ ├── pagination.zig # Numeracion de paginas, headers, footers +│ ├── links.zig # Links/URLs (estructura) │ ├── fonts/ │ │ ├── mod.zig # Exports de fonts │ │ └── type1.zig # 14 fuentes Type1 + metricas │ ├── graphics/ │ │ ├── mod.zig # Exports de graphics │ │ └── color.zig # Color (RGB, CMYK, Gray) +│ ├── images/ +│ │ ├── mod.zig # Exports + detectFormat() +│ │ ├── image_info.zig # ImageInfo struct +│ │ ├── jpeg.zig # JPEG parser (DCT passthrough) +│ │ └── png.zig # PNG metadata parser │ ├── 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 +│ └── producer.zig # OutputProducer (serializa PDF + imagenes) +└── examples/ + ├── hello.zig # Ejemplo basico + ├── invoice.zig # Factura ejemplo + ├── text_demo.zig # Demo sistema de texto + ├── image_demo.zig # Demo imagenes + ├── table_demo.zig # Demo tablas + └── pagination_demo.zig # Demo paginacion ``` --- @@ -132,180 +152,135 @@ zpdf/ - [x] Lineas y rectangulos - [x] Colores RGB, CMYK, Grayscale - [x] Serializacion correcta -- [x] Refactoring modular (separar en archivos) -- [x] Arreglar errores Zig 0.15 (ArrayListUnmanaged) +- [x] Refactoring modular ### 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 +- [x] Bordes configurables -### Fase 3 - Imagenes (PENDIENTE) -- [ ] JPEG embebido -- [ ] PNG embebido (con alpha) -- [ ] Escalado y posicionamiento -- [ ] Aspect ratio preservation +### Fase 3 - Imagenes (COMPLETADO) +- [x] JPEG embebido (DCT passthrough, sin re-encoding) +- [x] PNG metadata parsing (embedding pendiente por zlib API) +- [x] image() - dibujar imagen en posicion +- [x] imageFit() - escalar manteniendo aspect ratio -### Fase 4 - Utilidades (PENDIENTE) -- [ ] Helper para tablas -- [ ] Numeracion de paginas -- [ ] Headers/footers automaticos -- [ ] Links/URLs +### Fase 4 - Utilidades (COMPLETADO) +- [x] Table helper (header, row, footer, estilos, alineacion por columna) +- [x] Numeracion de paginas (Pagination.addPageNumbers) +- [x] Headers automaticos (addHeader con linea separadora) +- [x] Footers automaticos (addFooter, addFooterWithLine) +- [x] Links visuales (drawLink, writeLink - azul + subrayado) ### Fase 5 - Avanzado (FUTURO) +- [ ] Link annotations (clickeables en PDF) +- [ ] PNG embedding completo - [ ] Fuentes TTF embebidas - [ ] Compresion de streams (zlib) - [ ] Bookmarks/outline -- [ ] Forms (campos rellenables) --- ## API Actual -### API Nueva (Pdf) +### Crear Documento y Paginas ```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); +var doc = zpdf.Pdf.init(allocator, .{ + .page_size = .a4, + .orientation = .portrait, +}); defer doc.deinit(); -var page = try doc.addPage(.a4); -try page.setFont(.helvetica_bold, 24); -try page.drawText(50, 750, "Titulo"); +doc.setTitle("Mi Documento"); +doc.setAuthor("zpdf"); -try doc.saveToFile("documento.pdf"); +var page = try doc.addPage(.{}); +try doc.save("documento.pdf"); ``` ### Sistema de Texto ```zig -// 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); +// cell() - celda con bordes y alineacion +try page.cell(100, 20, "Hello", zpdf.Border.all, .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(400, null, texto_largo, zpdf.Border.none, .left, false); -// 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"); +// ln() - salto de linea +page.ln(20); ``` -### Bordes +### Table Helper ```zig -const Border = packed struct { - left: bool, - top: bool, - right: bool, - bottom: bool, -}; +const widths = [_]f32{ 200, 100, 100 }; +var table = zpdf.Table.init(page, .{ + .x = 50, + .y = 700, + .col_widths = &widths, + .header_bg = zpdf.Color.rgb(41, 98, 255), +}); -Border.none // sin bordes -Border.all // todos los bordes -Border{ .left = true, .bottom = true } // bordes especificos -Border.fromInt(0b1111) // desde entero (LTRB) +table.setColumnAlign(0, .left); +table.setColumnAlign(1, .center); +table.setColumnAlign(2, .right); + +try table.header(&.{ "Producto", "Cantidad", "Precio" }); +try table.row(&.{ "Widget A", "10", "$50.00" }); +try table.row(&.{ "Widget B", "5", "$25.00" }); +try table.footer(&.{ "Total", "", "$75.00" }); ``` -### Alineacion +### Paginacion y Headers/Footers ```zig -const Align = enum { left, center, right }; +// Agregar numeros de pagina a todas las paginas +try zpdf.Pagination.addPageNumbers(&doc, .{ + .format = "Page {PAGE} of {PAGES}", + .position = .bottom_center, +}); -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); +// Header con linea separadora +try zpdf.addHeader(&doc, "Documento Confidencial", .{ + .alignment = .center, + .draw_line = true, +}); + +// Footer con linea +try zpdf.addFooterWithLine(&doc, "Copyright 2025", .{ + .alignment = .left, + .draw_line = true, +}); ``` -### Colores +### Imagenes JPEG ```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 +// Cargar imagen desde archivo +const img_idx = try doc.addJpegImageFromFile("foto.jpg"); +const info = doc.getImage(img_idx).?; -// RGB (0-255) -zpdf.Color.rgb(41, 98, 255) +// Dibujar en la pagina +try page.image(img_idx, info, 50, 500, 200, 150); -// Hex -zpdf.Color.hex(0xFF8000) +// O escalar automaticamente manteniendo aspect ratio +try page.imageFit(img_idx, info, 50, 500, 400, 300); +``` -// CMYK (0.0-1.0) -zpdf.Color.cmyk(0.0, 1.0, 1.0, 0.0) +### Links Visuales -// Grayscale (0.0-1.0) -zpdf.Color.gray(0.5) +```zig +// Texto con estilo de link (azul + subrayado) +_ = try page.drawLink(100, 700, "Visita nuestra web"); + +// O desde la posicion actual +_ = try page.writeLink("Click aqui"); ``` --- @@ -316,48 +291,61 @@ zpdf.Color.gray(0.5) # Zig path ZIG=/mnt/cello2/arno/re/recode/zig/zig-0.15.2/zig-x86_64-linux-0.15.2/zig -# Compilar +# Compilar todo $ZIG build # Tests $ZIG build test # Ejecutar ejemplos -$ZIG build && ./zig-out/bin/hello -$ZIG build && ./zig-out/bin/invoice -$ZIG build && ./zig-out/bin/text_demo +./zig-out/bin/hello +./zig-out/bin/invoice +./zig-out/bin/text_demo +./zig-out/bin/image_demo +./zig-out/bin/table_demo +./zig-out/bin/pagination_demo ``` --- -## Fuentes Type1 Built-in +## Historial de Desarrollo -PDF incluye 14 fuentes estandar que no necesitan embeber: +### 2025-12-08 - v0.4 (Imagenes + Utilidades) +- **Fase 3 - Imagenes**: + - JPEG embedding con DCT passthrough (sin re-encoding) + - PNG metadata parsing (embedding pendiente) + - image() y imageFit() en Page + - XObject generation en OutputProducer +- **Fase 4 - Utilidades**: + - Table helper completo (header, row, footer, estilos) + - Alineacion por columna en tablas + - Pagination.addPageNumbers() con formato {PAGE}/{PAGES} + - addHeader() y addFooterWithLine() con lineas separadoras + - drawLink() y writeLink() para texto estilo link +- 6 ejemplos funcionales +- ~70 tests pasando -| 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 | +### 2025-12-08 - v0.3 (Imagenes) +- Modulo images/ (jpeg.zig, png.zig, image_info.zig) +- addJpegImage() y addJpegImageFromFile() en Pdf +- image_demo.zig ejemplo +- 66 tests pasando ---- +### 2025-12-08 - v0.2 (Sistema de Texto) +- Refactoring modular completo +- Sistema de texto: cell, multiCell, ln, alineacion, bordes +- Nueva API Pdf +- 52 tests -## 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 | +### 2025-12-08 - v0.1 (Core) +- Estructura inicial, Document, Page, Font, Color +- Graficos basicos, serializacion PDF 1.4 --- ## Equipo y Metodologia -### Normas de Trabajo Centralizadas +### Normas de Trabajo **IMPORTANTE**: Todas las normas de trabajo estan en: ``` @@ -367,50 +355,18 @@ PDF incluye 14 fuentes estandar que no necesitan embeber: ### Control de Versiones ```bash -# Remote git remote: git@git.reugenio.com:reugenio/zpdf.git - -# Branches -main # Codigo estable ``` --- -## 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 -- Texto con 14 fuentes Type1 standard -- Graficos: lineas, rectangulos (stroke, fill, both) -- Colores RGB -- Serializacion PDF 1.4 correcta -- 6 tests unitarios pasando -- Ejemplos: hello.zig, invoice.zig funcionales - ---- - ## Referencias -- **fpdf2 (Python)**: https://github.com/py-pdf/fpdf2 - Fuente principal +- **fpdf2 (Python)**: https://github.com/py-pdf/fpdf2 - **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 --- **zpdf - Generador PDF para Zig** -*v0.2 - 2025-12-08* +*v0.4 - 2025-12-08* diff --git a/build.zig b/build.zig index ba9e0d4..b72b2e0 100644 --- a/build.zig +++ b/build.zig @@ -99,4 +99,42 @@ pub fn build(b: *std.Build) void { run_image_demo.step.dependOn(b.getInstallStep()); const image_demo_step = b.step("image_demo", "Run image demo example"); image_demo_step.dependOn(&run_image_demo.step); + + // Example: table_demo + const table_demo_exe = b.addExecutable(.{ + .name = "table_demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/table_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zpdf", .module = zpdf_mod }, + }, + }), + }); + b.installArtifact(table_demo_exe); + + const run_table_demo = b.addRunArtifact(table_demo_exe); + run_table_demo.step.dependOn(b.getInstallStep()); + const table_demo_step = b.step("table_demo", "Run table demo example"); + table_demo_step.dependOn(&run_table_demo.step); + + // Example: pagination_demo + const pagination_demo_exe = b.addExecutable(.{ + .name = "pagination_demo", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/pagination_demo.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zpdf", .module = zpdf_mod }, + }, + }), + }); + b.installArtifact(pagination_demo_exe); + + const run_pagination_demo = b.addRunArtifact(pagination_demo_exe); + run_pagination_demo.step.dependOn(b.getInstallStep()); + const pagination_demo_step = b.step("pagination_demo", "Run pagination demo example"); + pagination_demo_step.dependOn(&run_pagination_demo.step); } diff --git a/examples/pagination_demo.zig b/examples/pagination_demo.zig new file mode 100644 index 0000000..80b5d0b --- /dev/null +++ b/examples/pagination_demo.zig @@ -0,0 +1,115 @@ +//! Pagination Demo - Demonstrates automatic page numbering and footers +//! +//! Shows how to add page numbers and footers to multi-page documents. + +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 - Pagination Demo\n", .{}); + + var doc = pdf.Pdf.init(allocator, .{}); + defer doc.deinit(); + + doc.setTitle("Pagination Demo"); + doc.setAuthor("zpdf"); + + // Create multiple pages with content + const num_pages: usize = 5; + for (0..num_pages) |i| { + var page = try doc.addPage(.{}); + page.setMargins(50, 50, 50); + + // Page title + try page.setFont(.helvetica_bold, 24); + page.setFillColor(pdf.Color.rgb(41, 98, 255)); + page.setXY(50, 780); + + var title_buf: [64]u8 = undefined; + const title = std.fmt.bufPrint(&title_buf, "Chapter {d}", .{i + 1}) catch "Chapter"; + try page.cell(0, 30, title, pdf.Border.none, .center, false); + page.ln(50); + + // Content + try page.setFont(.helvetica, 12); + page.setFillColor(pdf.Color.black); + + const lorem = + \\Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + \\incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis + \\nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + \\Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore + \\eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt + \\in culpa qui officia deserunt mollit anim id est laborum. + ; + + // Multiple paragraphs to fill the page + for (0..4) |_| { + try page.multiCell(500, null, lorem, pdf.Border.none, .left, false); + page.ln(15); + } + + // Section heading + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.rgb(51, 51, 51)); + try page.cell(0, 20, "Key Points", pdf.Border.none, .left, false); + page.ln(25); + + // Bullet points + try page.setFont(.helvetica, 11); + page.setFillColor(pdf.Color.black); + + const points = [_][]const u8{ + "First important point about this chapter", + "Second key consideration to remember", + "Third aspect worth noting", + "Fourth element of significance", + }; + + for (points) |point| { + var point_buf: [256]u8 = undefined; + const bullet_text = std.fmt.bufPrint(&point_buf, " * {s}", .{point}) catch point; + try page.cell(0, 16, bullet_text, pdf.Border.none, .left, false); + page.ln(18); + } + } + + // Add header to all pages (with line separator) + try pdf.addHeader(&doc, "zpdf Library - Multi-Page Document Example", .{ + .alignment = .center, + .margin = 30, + .font_size = 9, + .color = pdf.Color.medium_gray, + .draw_line = true, + .line_color = pdf.Color.light_gray, + }); + + // Add page numbers to all pages + try pdf.Pagination.addPageNumbers(&doc, .{ + .format = "Page {PAGE} of {PAGES}", + .position = .bottom_center, + .font = .helvetica, + .font_size = 9, + .color = pdf.Color.medium_gray, + }); + + // Add footer text to all pages (with line separator) + try pdf.addFooterWithLine(&doc, "Confidential Document - For Internal Use Only", .{ + .alignment = .left, + .margin = 30, + .font_size = 8, + .color = pdf.Color.light_gray, + .draw_line = true, + }); + + // Save + const filename = "pagination_demo.pdf"; + try doc.save(filename); + + std.debug.print("Created: {s} ({d} pages)\n", .{ filename, num_pages }); + std.debug.print("Done!\n", .{}); +} diff --git a/examples/table_demo.zig b/examples/table_demo.zig new file mode 100644 index 0000000..996615c --- /dev/null +++ b/examples/table_demo.zig @@ -0,0 +1,157 @@ +//! Table Demo - Demonstrates the Table helper for easy table creation +//! +//! Shows how to create formatted tables with headers, data rows, +//! alternating colors, and custom styling. + +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 - Table Demo\n", .{}); + + var doc = pdf.Pdf.init(allocator, .{}); + defer doc.deinit(); + + doc.setTitle("Table Demo"); + doc.setAuthor("zpdf"); + + var page = try doc.addPage(.{}); + page.setMargins(50, 50, 50); + + // Title + try page.setFont(.helvetica_bold, 24); + page.setFillColor(pdf.Color.rgb(41, 98, 255)); + page.setXY(50, 800); + try page.cell(0, 30, "Table Helper Demo", pdf.Border.none, .center, false); + page.ln(50); + + // ========================================================================= + // Example 1: Simple Product Table + // ========================================================================= + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "Example 1: Product Inventory", pdf.Border.none, .left, false); + page.ln(25); + + const col_widths = [_]f32{ 200, 80, 80, 80 }; + var table = pdf.Table.init(page, .{ + .x = 50, + .y = page.getY(), + .col_widths = &col_widths, + }); + + // Set column alignments + table.setColumnAlign(0, .left); // Product name + table.setColumnAlign(1, .center); // SKU + table.setColumnAlign(2, .right); // Quantity + table.setColumnAlign(3, .right); // Price + + try table.header(&.{ "Product", "SKU", "Qty", "Price" }); + try table.row(&.{ "Widget Pro", "WP-001", "150", "$29.99" }); + try table.row(&.{ "Gadget Plus", "GP-042", "75", "$49.99" }); + try table.row(&.{ "Super Tool", "ST-108", "200", "$19.99" }); + try table.row(&.{ "Mega Device", "MD-255", "50", "$99.99" }); + try table.row(&.{ "Mini Helper", "MH-033", "300", "$9.99" }); + try table.footer(&.{ "Total Items", "", "775", "" }); + + // ========================================================================= + // Example 2: Invoice-style Table + // ========================================================================= + page.setXY(50, table.getY() - 40); + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "Example 2: Invoice Items", pdf.Border.none, .left, false); + page.ln(25); + + const invoice_widths = [_]f32{ 250, 60, 80, 100 }; + var invoice_table = pdf.Table.init(page, .{ + .x = 50, + .y = page.getY(), + .col_widths = &invoice_widths, + .header_bg = pdf.Color.rgb(51, 51, 51), + .header_fg = pdf.Color.white, + .odd_row_bg = pdf.Color.rgb(250, 250, 250), + }); + + invoice_table.setColumnAlign(0, .left); + invoice_table.setColumnAlign(1, .center); + invoice_table.setColumnAlign(2, .right); + invoice_table.setColumnAlign(3, .right); + + try invoice_table.header(&.{ "Description", "Qty", "Unit Price", "Total" }); + try invoice_table.row(&.{ "Web Development Services", "40", "$75.00", "$3,000.00" }); + try invoice_table.row(&.{ "UI/UX Design Package", "1", "$1,500.00", "$1,500.00" }); + try invoice_table.row(&.{ "Server Hosting (Annual)", "1", "$480.00", "$480.00" }); + try invoice_table.row(&.{ "SSL Certificate", "1", "$50.00", "$50.00" }); + try invoice_table.row(&.{ "Technical Support (hours)", "10", "$50.00", "$500.00" }); + + try invoice_table.separator(); + invoice_table.space(5); + + // Subtotal, Tax, Total using styled rows + try invoice_table.rowStyled( + &.{ "", "", "Subtotal:", "$5,530.00" }, + pdf.Color.white, + pdf.Color.black, + .helvetica, + 10, + ); + try invoice_table.rowStyled( + &.{ "", "", "Tax (8%):", "$442.40" }, + pdf.Color.white, + pdf.Color.black, + .helvetica, + 10, + ); + try invoice_table.rowStyled( + &.{ "", "", "Total:", "$5,972.40" }, + pdf.Color.rgb(41, 98, 255), + pdf.Color.white, + .helvetica_bold, + 11, + ); + + // ========================================================================= + // Example 3: Compact Data Table + // ========================================================================= + page.setXY(50, invoice_table.getY() - 40); + try page.setFont(.helvetica_bold, 14); + page.setFillColor(pdf.Color.black); + try page.cell(0, 20, "Example 3: Employee Directory", pdf.Border.none, .left, false); + page.ln(25); + + const emp_widths = [_]f32{ 120, 150, 100, 120 }; + var emp_table = pdf.Table.init(page, .{ + .x = 50, + .y = page.getY(), + .col_widths = &emp_widths, + .header_bg = pdf.Color.rgb(76, 175, 80), + .body_font_size = 9, + .header_font_size = 10, + .padding = 3, + }); + + try emp_table.header(&.{ "Name", "Email", "Department", "Phone" }); + try emp_table.row(&.{ "John Smith", "john@company.com", "Engineering", "+1-555-0101" }); + try emp_table.row(&.{ "Sarah Johnson", "sarah@company.com", "Marketing", "+1-555-0102" }); + try emp_table.row(&.{ "Mike Wilson", "mike@company.com", "Sales", "+1-555-0103" }); + try emp_table.row(&.{ "Emily Davis", "emily@company.com", "HR", "+1-555-0104" }); + try emp_table.row(&.{ "Chris Brown", "chris@company.com", "Finance", "+1-555-0105" }); + + // Footer + page.setXY(50, 50); + try page.setFont(.helvetica, 9); + page.setFillColor(pdf.Color.medium_gray); + try page.cell(0, 15, "Generated with zpdf - Table Helper Demo", pdf.Border.none, .center, false); + + // Save + const filename = "table_demo.pdf"; + try doc.save(filename); + + std.debug.print("Created: {s}\n", .{filename}); + std.debug.print("Done!\n", .{}); +} diff --git a/src/links.zig b/src/links.zig new file mode 100644 index 0000000..79687e4 --- /dev/null +++ b/src/links.zig @@ -0,0 +1,162 @@ +//! Links - URL and internal link support for PDF documents +//! +//! Provides support for clickable links in PDF documents: +//! - External URLs (http, https, mailto, etc.) +//! - Internal page links (jump to page) +//! +//! Links are implemented as PDF Annotations. + +const std = @import("std"); + +/// Link type +pub const LinkType = enum { + /// External URL (opens in browser) + url, + /// Internal link to a page + internal, +}; + +/// A link annotation +pub const Link = struct { + /// Link type + link_type: LinkType, + /// Target URL or page number + target: union(LinkType) { + url: []const u8, + internal: usize, + }, + /// Link rectangle (x, y, width, height in points) + rect: Rect, + + pub const Rect = struct { + x: f32, + y: f32, + width: f32, + height: f32, + }; +}; + +/// Link storage for a page +pub const PageLinks = struct { + links: std.ArrayListUnmanaged(Link), + + const Self = @This(); + + pub fn init() Self { + return .{ + .links = .{}, + }; + } + + pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { + self.links.deinit(allocator); + } + + /// Add a URL link + pub fn addUrlLink( + self: *Self, + allocator: std.mem.Allocator, + url: []const u8, + x: f32, + y: f32, + width: f32, + height: f32, + ) !void { + try self.links.append(allocator, .{ + .link_type = .url, + .target = .{ .url = url }, + .rect = .{ .x = x, .y = y, .width = width, .height = height }, + }); + } + + /// Add an internal page link + pub fn addInternalLink( + self: *Self, + allocator: std.mem.Allocator, + page_num: usize, + x: f32, + y: f32, + width: f32, + height: f32, + ) !void { + try self.links.append(allocator, .{ + .link_type = .internal, + .target = .{ .internal = page_num }, + .rect = .{ .x = x, .y = y, .width = width, .height = height }, + }); + } + + /// Get all links + pub fn getLinks(self: *const Self) []const Link { + return self.links.items; + } +}; + +/// Writes link annotations to PDF format +pub fn writeLinkAnnotation(writer: anytype, link: Link, annot_id: u32) !void { + try writer.print("{d} 0 obj\n", .{annot_id}); + try writer.writeAll("<< /Type /Annot\n"); + try writer.writeAll("/Subtype /Link\n"); + + // Rectangle: [x1, y1, x2, y2] + try writer.print("/Rect [{d:.2} {d:.2} {d:.2} {d:.2}]\n", .{ + link.rect.x, + link.rect.y, + link.rect.x + link.rect.width, + link.rect.y + link.rect.height, + }); + + // Border (none for cleaner look) + try writer.writeAll("/Border [0 0 0]\n"); + + switch (link.link_type) { + .url => { + // External URL action + try writer.writeAll("/A << /Type /Action /S /URI /URI ("); + // Escape parentheses in URL + for (link.target.url) |c| { + switch (c) { + '(', ')' => try writer.print("\\{c}", .{c}), + '\\' => try writer.writeAll("\\\\"), + else => try writer.writeByte(c), + } + } + try writer.writeAll(") >>\n"); + }, + .internal => { + // Internal page link (GoTo action) + try writer.print("/Dest [page{d} /XYZ null null null]\n", .{link.target.internal}); + }, + } + + try writer.writeAll(">>\nendobj\n"); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "PageLinks add URL link" { + const allocator = std.testing.allocator; + + var links = PageLinks.init(); + defer links.deinit(allocator); + + try links.addUrlLink(allocator, "https://example.com", 100, 200, 150, 20); + + try std.testing.expectEqual(@as(usize, 1), links.links.items.len); + try std.testing.expectEqual(LinkType.url, links.links.items[0].link_type); +} + +test "PageLinks add internal link" { + const allocator = std.testing.allocator; + + var links = PageLinks.init(); + defer links.deinit(allocator); + + try links.addInternalLink(allocator, 5, 100, 200, 150, 20); + + try std.testing.expectEqual(@as(usize, 1), links.links.items.len); + try std.testing.expectEqual(LinkType.internal, links.links.items[0].link_type); + try std.testing.expectEqual(@as(usize, 5), links.links.items[0].target.internal); +} diff --git a/src/page.zig b/src/page.zig index 7ba9c54..dfe1feb 100644 --- a/src/page.zig +++ b/src/page.zig @@ -656,6 +656,51 @@ pub const Page = struct { } return fonts.toOwnedSlice(self.allocator) catch &[_]Font{}; } + + // ========================================================================= + // Link Drawing (Visual Style) + // ========================================================================= + + /// Draws text styled as a link (blue and underlined). + /// Note: This is visual styling only. For clickable links in the PDF, + /// link annotations need to be added separately. + /// + /// Returns the width of the drawn text for annotation placement. + pub fn drawLink(self: *Self, x: f32, y: f32, text: []const u8) !f32 { + // Save current colors + const saved_fill = self.state.fill_color; + const saved_stroke = self.state.stroke_color; + + // Set link color (blue) + const link_color = Color.rgb(0, 102, 204); + self.setFillColor(link_color); + self.setStrokeColor(link_color); + + // Draw text + try self.drawText(x, y, text); + + // Calculate text width + const text_width = self.getStringWidth(text); + + // Draw underline + const underline_y = y - 2; // Slightly below text baseline + try self.setLineWidth(0.5); + try self.drawLine(x, underline_y, x + text_width, underline_y); + + // Restore colors + self.setFillColor(saved_fill); + self.setStrokeColor(saved_stroke); + + return text_width; + } + + /// Draws text styled as a link at the current position. + /// Advances the X position after drawing. + pub fn writeLink(self: *Self, text: []const u8) !f32 { + const width = try self.drawLink(self.state.x, self.state.y, text); + self.state.x += width; + return width; + } }; // ============================================================================= diff --git a/src/pagination.zig b/src/pagination.zig new file mode 100644 index 0000000..d2a325a --- /dev/null +++ b/src/pagination.zig @@ -0,0 +1,353 @@ +//! Pagination - Page numbering and footer utilities +//! +//! Provides helpers for adding page numbers and automatic footers +//! to PDF documents. +//! +//! Example: +//! ```zig +//! var doc = pdf.Pdf.init(allocator, .{}); +//! +//! // Add pages... +//! +//! // Add page numbers to all pages +//! try pdf.Pagination.addPageNumbers(&doc, .{ +//! .format = "Page {PAGE} of {PAGES}", +//! .position = .bottom_center, +//! }); +//! ``` + +const std = @import("std"); +const Pdf = @import("pdf.zig").Pdf; +const Page = @import("page.zig").Page; +const Color = @import("graphics/color.zig").Color; +const Font = @import("fonts/type1.zig").Font; +const Align = @import("page.zig").Align; +const Border = @import("page.zig").Border; + +/// Position for page numbers +pub const Position = enum { + bottom_left, + bottom_center, + bottom_right, + top_left, + top_center, + top_right, +}; + +/// Options for page numbering +pub const PageNumberOptions = struct { + /// Format string. Use {PAGE} for current page, {PAGES} for total. + format: []const u8 = "Page {PAGE} of {PAGES}", + /// Position on page + position: Position = .bottom_center, + /// Font to use + font: Font = .helvetica, + /// Font size + font_size: f32 = 9, + /// Text color + color: Color = Color.medium_gray, + /// Margin from edge (in points) + margin: f32 = 30, + /// Starting page number (1-based) + start_page: usize = 1, + /// Skip first N pages (useful for title pages) + skip_pages: usize = 0, +}; + +/// Pagination utilities +pub const Pagination = struct { + /// Adds page numbers to all pages in the document + pub fn addPageNumbers(doc: *Pdf, options: PageNumberOptions) !void { + const total_pages = doc.pageCount(); + if (total_pages == 0) return; + + for (0..total_pages) |i| { + // Skip specified pages + if (i < options.skip_pages) continue; + + const page = doc.getPage(i) orelse continue; + const page_num = options.start_page + i - options.skip_pages; + + try addPageNumberToPage(page, page_num, total_pages - options.skip_pages, options); + } + } + + /// Adds a page number to a single page + pub fn addPageNumberToPage( + page: *Page, + current_page: usize, + total_pages: usize, + options: PageNumberOptions, + ) !void { + // Format the page number string + var buf: [256]u8 = undefined; + const text = formatPageNumber(&buf, options.format, current_page, total_pages); + + // Save current state + const saved_x = page.getX(); + const saved_y = page.getY(); + + // Set font and color + try page.setFont(options.font, options.font_size); + page.setFillColor(options.color); + + // Calculate position + const text_width = options.font.stringWidth(text, options.font_size); + const page_width = page.width; + const page_height = page.height; + + var x: f32 = undefined; + var y: f32 = undefined; + + switch (options.position) { + .bottom_left => { + x = options.margin; + y = options.margin; + }, + .bottom_center => { + x = (page_width - text_width) / 2; + y = options.margin; + }, + .bottom_right => { + x = page_width - options.margin - text_width; + y = options.margin; + }, + .top_left => { + x = options.margin; + y = page_height - options.margin; + }, + .top_center => { + x = (page_width - text_width) / 2; + y = page_height - options.margin; + }, + .top_right => { + x = page_width - options.margin - text_width; + y = page_height - options.margin; + }, + } + + // Draw the page number + try page.drawText(x, y, text); + + // Restore position + page.setXY(saved_x, saved_y); + } + + /// Formats a page number string + fn formatPageNumber(buf: []u8, format: []const u8, current: usize, total: usize) []const u8 { + var result: []u8 = buf[0..0]; + var i: usize = 0; + + while (i < format.len) { + if (i + 6 <= format.len and std.mem.eql(u8, format[i .. i + 6], "{PAGE}")) { + // Insert current page number + const num_str = std.fmt.bufPrint(buf[result.len..], "{d}", .{current}) catch break; + result = buf[0 .. result.len + num_str.len]; + i += 6; + } else if (i + 7 <= format.len and std.mem.eql(u8, format[i .. i + 7], "{PAGES}")) { + // Insert total pages + const num_str = std.fmt.bufPrint(buf[result.len..], "{d}", .{total}) catch break; + result = buf[0 .. result.len + num_str.len]; + i += 7; + } else { + // Copy character + if (result.len < buf.len) { + buf[result.len] = format[i]; + result = buf[0 .. result.len + 1]; + } + i += 1; + } + } + + return result; + } + + /// Adds a simple footer text to all pages + pub fn addFooter(doc: *Pdf, text: []const u8, options: FooterOptions) !void { + const total_pages = doc.pageCount(); + if (total_pages == 0) return; + + for (0..total_pages) |i| { + if (i < options.skip_pages) continue; + + const page = doc.getPage(i) orelse continue; + try addFooterToPage(page, text, options); + } + } + + /// Adds a footer to a single page + pub fn addFooterToPage(page: *Page, text: []const u8, options: FooterOptions) !void { + const saved_x = page.getX(); + const saved_y = page.getY(); + + try page.setFont(options.font, options.font_size); + page.setFillColor(options.color); + + const text_width = options.font.stringWidth(text, options.font_size); + const page_width = page.width; + + const x = switch (options.alignment) { + .left => options.margin, + .center => (page_width - text_width) / 2, + .right => page_width - options.margin - text_width, + }; + + try page.drawText(x, options.margin, text); + + page.setXY(saved_x, saved_y); + } +}; + +/// Options for footer text +pub const FooterOptions = struct { + font: Font = .helvetica, + font_size: f32 = 9, + color: Color = Color.medium_gray, + margin: f32 = 30, + alignment: Align = .center, + skip_pages: usize = 0, + /// Draw a separator line above footer + draw_line: bool = false, + line_color: Color = Color.light_gray, +}; + +/// Options for header text +pub const HeaderOptions = struct { + font: Font = .helvetica, + font_size: f32 = 9, + color: Color = Color.medium_gray, + margin: f32 = 30, + alignment: Align = .center, + skip_pages: usize = 0, + /// Draw a separator line below header + draw_line: bool = false, + line_color: Color = Color.light_gray, +}; + +// ============================================================================= +// Header/Footer Extensions +// ============================================================================= + +/// Adds a header text to all pages +pub fn addHeader(doc: *Pdf, text: []const u8, options: HeaderOptions) !void { + const total_pages = doc.pageCount(); + if (total_pages == 0) return; + + for (0..total_pages) |i| { + if (i < options.skip_pages) continue; + + const page = doc.getPage(i) orelse continue; + try addHeaderToPage(page, text, options); + } +} + +/// Adds a header to a single page +pub fn addHeaderToPage(page: *Page, text: []const u8, options: HeaderOptions) !void { + const saved_x = page.getX(); + const saved_y = page.getY(); + + try page.setFont(options.font, options.font_size); + page.setFillColor(options.color); + + const text_width = options.font.stringWidth(text, options.font_size); + const page_width = page.width; + const page_height = page.height; + + const x = switch (options.alignment) { + .left => options.margin, + .center => (page_width - text_width) / 2, + .right => page_width - options.margin - text_width, + }; + + const y = page_height - options.margin; + + try page.drawText(x, y, text); + + // Draw separator line if requested + if (options.draw_line) { + page.setStrokeColor(options.line_color); + try page.setLineWidth(0.5); + try page.drawLine(options.margin, y - options.font_size - 5, page_width - options.margin, y - options.font_size - 5); + } + + page.setXY(saved_x, saved_y); +} + +/// Extended footer with line support +pub fn addFooterWithLine(doc: *Pdf, text: []const u8, options: FooterOptions) !void { + const total_pages = doc.pageCount(); + if (total_pages == 0) return; + + for (0..total_pages) |i| { + if (i < options.skip_pages) continue; + + const page = doc.getPage(i) orelse continue; + try addFooterToPageWithLine(page, text, options); + } +} + +/// Adds a footer to a single page with optional line +fn addFooterToPageWithLine(page: *Page, text: []const u8, options: FooterOptions) !void { + const saved_x = page.getX(); + const saved_y = page.getY(); + + try page.setFont(options.font, options.font_size); + page.setFillColor(options.color); + + const text_width = options.font.stringWidth(text, options.font_size); + const page_width = page.width; + + const x = switch (options.alignment) { + .left => options.margin, + .center => (page_width - text_width) / 2, + .right => page_width - options.margin - text_width, + }; + + // Draw separator line if requested + if (options.draw_line) { + page.setStrokeColor(options.line_color); + try page.setLineWidth(0.5); + try page.drawLine(options.margin, options.margin + options.font_size + 5, page_width - options.margin, options.margin + options.font_size + 5); + } + + try page.drawText(x, options.margin, text); + + page.setXY(saved_x, saved_y); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "formatPageNumber" { + var buf: [256]u8 = undefined; + + const result1 = Pagination.formatPageNumber(&buf, "Page {PAGE} of {PAGES}", 3, 10); + try std.testing.expectEqualStrings("Page 3 of 10", result1); + + const result2 = Pagination.formatPageNumber(&buf, "{PAGE}/{PAGES}", 1, 5); + try std.testing.expectEqualStrings("1/5", result2); + + const result3 = Pagination.formatPageNumber(&buf, "- {PAGE} -", 7, 20); + try std.testing.expectEqualStrings("- 7 -", result3); +} + +test "Pagination addPageNumbers" { + const allocator = std.testing.allocator; + + var doc = Pdf.init(allocator, .{}); + defer doc.deinit(); + + // Add multiple pages + _ = try doc.addPage(.{}); + _ = try doc.addPage(.{}); + _ = try doc.addPage(.{}); + + // Add page numbers + try Pagination.addPageNumbers(&doc, .{ + .format = "Page {PAGE}", + .position = .bottom_center, + }); + + try std.testing.expectEqual(@as(usize, 3), doc.pageCount()); +} diff --git a/src/root.zig b/src/root.zig index d23be8b..2ea47f4 100644 --- a/src/root.zig +++ b/src/root.zig @@ -70,6 +70,25 @@ pub const images = @import("images/mod.zig"); pub const ImageInfo = images.ImageInfo; pub const ImageFormat = images.ImageFormat; +/// Table helper +pub const table = @import("table.zig"); +pub const Table = table.Table; +pub const TableOptions = table.TableOptions; + +/// Pagination (page numbers, headers, footers) +pub const pagination = @import("pagination.zig"); +pub const Pagination = pagination.Pagination; +pub const PageNumberOptions = pagination.PageNumberOptions; +pub const FooterOptions = pagination.FooterOptions; +pub const HeaderOptions = pagination.HeaderOptions; +pub const addHeader = pagination.addHeader; +pub const addFooterWithLine = pagination.addFooterWithLine; + +/// Links (URL annotations) +pub const links = @import("links.zig"); +pub const Link = links.Link; +pub const PageLinks = links.PageLinks; + // ============================================================================= // Backwards Compatibility - Old API (Document) // ============================================================================= @@ -232,4 +251,7 @@ comptime { _ = @import("images/image_info.zig"); _ = @import("images/jpeg.zig"); _ = @import("images/png.zig"); + _ = @import("table.zig"); + _ = @import("pagination.zig"); + _ = @import("links.zig"); } diff --git a/src/table.zig b/src/table.zig new file mode 100644 index 0000000..0889cff --- /dev/null +++ b/src/table.zig @@ -0,0 +1,310 @@ +//! Table Helper - Simplified table creation for PDF documents +//! +//! Provides a high-level API for creating tables with: +//! - Configurable column widths +//! - Header rows with different styling +//! - Alternating row colors +//! - Borders and padding +//! +//! Example: +//! ```zig +//! var table = Table.init(&page, .{ +//! .x = 50, +//! .y = 700, +//! .col_widths = &.{ 200, 100, 100 }, +//! }); +//! +//! try table.header(&.{ "Product", "Qty", "Price" }); +//! try table.row(&.{ "Widget A", "10", "$5.00" }); +//! try table.row(&.{ "Widget B", "5", "$12.50" }); +//! ``` + +const std = @import("std"); +const Page = @import("page.zig").Page; +const Border = @import("page.zig").Border; +const Align = @import("page.zig").Align; +const Color = @import("graphics/color.zig").Color; +const Font = @import("fonts/type1.zig").Font; + +/// Table configuration options +pub const TableOptions = struct { + /// X position of table (left edge) + x: f32 = 50, + /// Y position of table (top edge) + y: f32 = 750, + /// Column widths in points + col_widths: []const f32, + /// Row height (null = auto from font size) + row_height: ?f32 = null, + /// Cell padding + padding: f32 = 4, + /// Border style + border: Border = Border.all, + /// Header background color + header_bg: Color = Color.rgb(41, 98, 255), + /// Header text color + header_fg: Color = Color.white, + /// Header font + header_font: Font = .helvetica_bold, + /// Header font size + header_font_size: f32 = 11, + /// Body font + body_font: Font = .helvetica, + /// Body font size + body_font_size: f32 = 10, + /// Body text color + body_fg: Color = Color.black, + /// Even row background color + even_row_bg: Color = Color.white, + /// Odd row background color + odd_row_bg: Color = Color.rgb(245, 245, 245), + /// Default text alignment + default_align: Align = .left, +}; + +/// Column alignment override +pub const ColumnAlign = struct { + index: usize, + alignment: Align, +}; + +/// Table builder for creating formatted tables +pub const Table = struct { + page: *Page, + options: TableOptions, + current_y: f32, + row_count: usize, + col_aligns: [16]Align, // Max 16 columns with custom alignment + col_align_count: usize, + + const Self = @This(); + + /// Initialize a new table + pub fn init(page: *Page, options: TableOptions) Self { + var col_aligns: [16]Align = undefined; + for (&col_aligns) |*a| { + a.* = options.default_align; + } + + return .{ + .page = page, + .options = options, + .current_y = options.y, + .row_count = 0, + .col_aligns = col_aligns, + .col_align_count = 0, + }; + } + + /// Set alignment for a specific column + pub fn setColumnAlign(self: *Self, col: usize, alignment: Align) void { + if (col < 16) { + self.col_aligns[col] = alignment; + } + } + + /// Set alignments for multiple columns + pub fn setColumnAligns(self: *Self, aligns: []const ColumnAlign) void { + for (aligns) |ca| { + self.setColumnAlign(ca.index, ca.alignment); + } + } + + /// Get the total width of the table + pub fn totalWidth(self: *const Self) f32 { + var total: f32 = 0; + for (self.options.col_widths) |w| { + total += w; + } + return total; + } + + /// Get the current Y position (bottom of last row) + pub fn getY(self: *const Self) f32 { + return self.current_y; + } + + /// Draw a header row with special styling + pub fn header(self: *Self, cells: []const []const u8) !void { + const row_h = self.options.row_height orelse (self.options.header_font_size + self.options.padding * 2); + + try self.page.setFont(self.options.header_font, self.options.header_font_size); + self.page.setFillColor(self.options.header_bg); + self.page.setTextColor(self.options.header_fg); + + var x = self.options.x; + self.page.setXY(x, self.current_y); + + for (cells, 0..) |cell_text, i| { + if (i >= self.options.col_widths.len) break; + + const col_w = self.options.col_widths[i]; + const cell_align = if (i < 16) self.col_aligns[i] else self.options.default_align; + + try self.page.cell(col_w, row_h, cell_text, self.options.border, cell_align, true); + x += col_w; + } + + self.current_y -= row_h; + self.page.setXY(self.options.x, self.current_y); + + // Reset text color for body rows + self.page.setTextColor(self.options.body_fg); + } + + /// Draw a data row + pub fn row(self: *Self, cells: []const []const u8) !void { + const row_h = self.options.row_height orelse (self.options.body_font_size + self.options.padding * 2); + + try self.page.setFont(self.options.body_font, self.options.body_font_size); + self.page.setTextColor(self.options.body_fg); + + // Alternating row colors + const bg_color = if (self.row_count % 2 == 0) + self.options.even_row_bg + else + self.options.odd_row_bg; + + self.page.setFillColor(bg_color); + + var x = self.options.x; + self.page.setXY(x, self.current_y); + + for (cells, 0..) |cell_text, i| { + if (i >= self.options.col_widths.len) break; + + const col_w = self.options.col_widths[i]; + const cell_align = if (i < 16) self.col_aligns[i] else self.options.default_align; + + try self.page.cell(col_w, row_h, cell_text, self.options.border, cell_align, true); + x += col_w; + } + + self.current_y -= row_h; + self.row_count += 1; + self.page.setXY(self.options.x, self.current_y); + } + + /// Draw a row with custom styling + pub fn rowStyled( + self: *Self, + cells: []const []const u8, + bg_color: Color, + fg_color: Color, + font: Font, + font_size: f32, + ) !void { + const row_h = self.options.row_height orelse (font_size + self.options.padding * 2); + + try self.page.setFont(font, font_size); + self.page.setFillColor(bg_color); + self.page.setTextColor(fg_color); + + var x = self.options.x; + self.page.setXY(x, self.current_y); + + for (cells, 0..) |cell_text, i| { + if (i >= self.options.col_widths.len) break; + + const col_w = self.options.col_widths[i]; + const cell_align = if (i < 16) self.col_aligns[i] else self.options.default_align; + + try self.page.cell(col_w, row_h, cell_text, self.options.border, cell_align, true); + x += col_w; + } + + self.current_y -= row_h; + self.row_count += 1; + self.page.setXY(self.options.x, self.current_y); + + // Reset colors + self.page.setTextColor(self.options.body_fg); + } + + /// Draw a footer/total row with special styling + pub fn footer(self: *Self, cells: []const []const u8) !void { + try self.rowStyled( + cells, + Color.rgb(230, 230, 230), // Light gray background + Color.black, + .helvetica_bold, + self.options.body_font_size, + ); + } + + /// Draw a separator line + pub fn separator(self: *Self) !void { + const total_w = self.totalWidth(); + self.page.setStrokeColor(Color.medium_gray); + try self.page.drawLine( + self.options.x, + self.current_y, + self.options.x + total_w, + self.current_y, + ); + } + + /// Skip vertical space + pub fn space(self: *Self, height: f32) void { + self.current_y -= height; + self.page.setXY(self.options.x, self.current_y); + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "Table init" { + const allocator = std.testing.allocator; + var page = Page.init(allocator, .a4); + defer page.deinit(); + + const widths = [_]f32{ 100, 150, 100 }; + var table = Table.init(&page, .{ + .col_widths = &widths, + }); + + try std.testing.expectApproxEqAbs(@as(f32, 350), table.totalWidth(), 0.01); +} + +test "Table column alignment" { + const allocator = std.testing.allocator; + var page = Page.init(allocator, .a4); + defer page.deinit(); + + const widths = [_]f32{ 100, 100, 100 }; + var table = Table.init(&page, .{ + .col_widths = &widths, + }); + + table.setColumnAlign(0, .left); + table.setColumnAlign(1, .center); + table.setColumnAlign(2, .right); + + try std.testing.expectEqual(Align.left, table.col_aligns[0]); + try std.testing.expectEqual(Align.center, table.col_aligns[1]); + try std.testing.expectEqual(Align.right, table.col_aligns[2]); +} + +test "Table header and row" { + const allocator = std.testing.allocator; + var page = Page.init(allocator, .a4); + defer page.deinit(); + + try page.setFont(.helvetica, 10); + + const widths = [_]f32{ 150, 100, 100 }; + var table = Table.init(&page, .{ + .col_widths = &widths, + .y = 700, + }); + + try table.header(&.{ "Product", "Qty", "Price" }); + try table.row(&.{ "Widget A", "10", "$5.00" }); + try table.row(&.{ "Widget B", "5", "$12.50" }); + + // Y should have moved down + try std.testing.expect(table.getY() < 700); + try std.testing.expectEqual(@as(usize, 2), table.row_count); +}