feat: v0.2 - Complete text system (cell, multiCell, alignment)

Phase 1 - Refactoring:
- Modular architecture: fonts/, graphics/, objects/, output/
- Fixed Zig 0.15 API changes (ArrayListUnmanaged)
- Fixed memory issues in render()

Phase 2 - Text System:
- cell() with borders, fill, alignment
- cellAdvanced() with position control
- multiCell() with automatic word wrap
- ln() for line breaks
- getStringWidth() for text width calculation
- Page margins (setMargins, setCellMargin)
- Align enum (left, center, right)
- Border packed struct

New features:
- New Pdf API (cleaner than legacy Document)
- Document metadata (setTitle, setAuthor, setSubject)
- Color: RGB, CMYK, Grayscale support
- 52 unit tests passing
- New example: text_demo.zig

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 19:46:30 +01:00
parent 59c155331f
commit 2996289953
21 changed files with 4702 additions and 675 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ zig-out/
*.a
*.so
*.pdf
reference/

486
CLAUDE.md
View file

@ -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*

View file

@ -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);
}

509
docs/ARQUITECTURA_FPDF2.md Normal file
View file

@ -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 <hex>
├── 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
<</Type /Catalog /Pages 2 0 R>>
endobj
2 0 obj
<</Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 595.28 841.89]>>
endobj
3 0 obj
<</Type /Page /Parent 2 0 R /Contents 4 0 R /Resources <</Font <</F1 5 0 R>>>>>>
endobj
4 0 obj
<</Length 44>>
stream
BT /F1 12 Tf 100 700 Td (Hola mundo) Tj ET
endstream
endobj
5 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding>>
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000060 00000 n
0000000147 00000 n
0000000247 00000 n
0000000340 00000 n
trailer
<</Size 6 /Root 1 0 R>>
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/)

541
docs/ARQUITECTURA_ZPDF.md Normal file
View file

@ -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

326
docs/PLAN_MAESTRO_ZPDF.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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");

182
examples/text_demo.zig Normal file
View file

@ -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", .{});
}

523
src/content_stream.zig Normal file
View file

@ -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);
}

9
src/fonts/mod.zig Normal file
View file

@ -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;

301
src/fonts/type1.zig Normal file
View file

@ -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);
}

241
src/graphics/color.zig Normal file
View file

@ -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));
}

6
src/graphics/mod.zig Normal file
View file

@ -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;

190
src/objects/base.zig Normal file
View file

@ -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);
}

16
src/objects/mod.zig Normal file
View file

@ -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;

8
src/output/mod.zig Normal file
View file

@ -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;

264
src/output/producer.zig Normal file
View file

@ -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);
}

851
src/page.zig Normal file
View file

@ -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);
}

278
src/pdf.zig Normal file
View file

@ -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"));
}

View file

@ -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");
}