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:
parent
59c155331f
commit
2996289953
21 changed files with 4702 additions and 675 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ zig-out/
|
||||||
*.a
|
*.a
|
||||||
*.so
|
*.so
|
||||||
*.pdf
|
*.pdf
|
||||||
|
reference/
|
||||||
|
|
|
||||||
486
CLAUDE.md
486
CLAUDE.md
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
> **Ultima actualizacion**: 2025-12-08
|
> **Ultima actualizacion**: 2025-12-08
|
||||||
> **Lenguaje**: Zig 0.15.2
|
> **Lenguaje**: Zig 0.15.2
|
||||||
> **Estado**: v0.1 - Core funcional, en desarrollo activo
|
> **Estado**: v0.2 - Sistema de texto completo (cell, multiCell, alignment)
|
||||||
> **Inspiracion**: gofpdf (Go), fpdf (PHP)
|
> **Fuente principal**: fpdf2 (Python) - https://github.com/py-pdf/fpdf2
|
||||||
|
|
||||||
## Descripcion del Proyecto
|
## Descripcion del Proyecto
|
||||||
|
|
||||||
|
|
@ -11,80 +11,140 @@
|
||||||
|
|
||||||
**Filosofia**:
|
**Filosofia**:
|
||||||
- Zero dependencias (100% Zig puro)
|
- Zero dependencias (100% Zig puro)
|
||||||
- API simple y directa
|
- API simple y directa inspirada en fpdf2
|
||||||
- Enfocado en generacion de facturas/documentos comerciales
|
- Enfocado en generacion de facturas/documentos comerciales
|
||||||
- Soporte para texto, tablas, imagenes y formas basicas
|
- Soporte para texto, tablas, imagenes y formas basicas
|
||||||
- Calidad open source (doc comments, codigo claro)
|
- 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
|
## Estado Actual del Proyecto
|
||||||
|
|
||||||
### Implementacion v0.1 (Core Funcional)
|
### Implementacion v0.2 (Sistema de Texto Completo)
|
||||||
|
|
||||||
| Componente | Estado | Archivo |
|
| Componente | Estado | Archivo |
|
||||||
|------------|--------|---------|
|
|------------|--------|---------|
|
||||||
| **Document** | | |
|
| **Pdf (API Nueva)** | | |
|
||||||
| Document init/deinit | ✅ | `src/root.zig` |
|
| Pdf init/deinit | OK | `src/pdf.zig` |
|
||||||
| addPage (standard sizes) | ✅ | `src/root.zig` |
|
| setTitle, setAuthor, setSubject | OK | `src/pdf.zig` |
|
||||||
| addPageCustom | ✅ | `src/root.zig` |
|
| addPage (with options) | OK | `src/pdf.zig` |
|
||||||
| render() to buffer | ✅ | `src/root.zig` |
|
| output() / render() | OK | `src/pdf.zig` |
|
||||||
| saveToFile() | ✅ | `src/root.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** | | |
|
||||||
| Page init/deinit | ✅ | `src/root.zig` |
|
| Page init/deinit | OK | `src/page.zig` |
|
||||||
| setFont | ✅ | `src/root.zig` |
|
| setFont / getFont / getFontSize | OK | `src/page.zig` |
|
||||||
| drawText | ✅ | `src/root.zig` |
|
| setFillColor / setStrokeColor / setTextColor | OK | `src/page.zig` |
|
||||||
| setFillColor | ✅ | `src/root.zig` |
|
| setLineWidth | OK | `src/page.zig` |
|
||||||
| setStrokeColor | ✅ | `src/root.zig` |
|
| setXY / setX / setY / getX / getY | OK | `src/page.zig` |
|
||||||
| setLineWidth | ✅ | `src/root.zig` |
|
| setMargins / setCellMargin | OK | `src/page.zig` |
|
||||||
| drawLine | ✅ | `src/root.zig` |
|
| drawText | OK | `src/page.zig` |
|
||||||
| drawRect | ✅ | `src/root.zig` |
|
| writeText | OK | `src/page.zig` |
|
||||||
| fillRect | ✅ | `src/root.zig` |
|
| **cell()** | OK | `src/page.zig` |
|
||||||
| drawFilledRect | ✅ | `src/root.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** | | |
|
| **Types** | | |
|
||||||
| PageSize enum (A4, Letter, etc.) | ✅ | `src/root.zig` |
|
| PageSize enum (A4, Letter, A3, A5, Legal) | OK | `src/objects/base.zig` |
|
||||||
| Font enum (14 Type1 fonts) | ✅ | `src/root.zig` |
|
| Orientation enum (portrait, landscape) | OK | `src/objects/base.zig` |
|
||||||
| Color struct (RGB) | ✅ | `src/root.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
|
### Tests
|
||||||
|
|
||||||
| Categoria | Tests | Estado |
|
| Categoria | Tests | Estado |
|
||||||
|-----------|-------|--------|
|
|-----------|-------|--------|
|
||||||
| Create empty document | 1 | ✅ |
|
| root.zig (integration) | 8 | OK |
|
||||||
| Add page | 1 | ✅ |
|
| page.zig (Page operations) | 18 | OK |
|
||||||
| Render minimal document | 1 | ✅ |
|
| content_stream.zig | 6 | OK |
|
||||||
| Font names | 1 | ✅ |
|
| graphics/color.zig | 5 | OK |
|
||||||
| Color conversion | 1 | ✅ |
|
| fonts/type1.zig | 5 | OK |
|
||||||
| Graphics operations | 1 | ✅ |
|
| objects/base.zig | 5 | OK |
|
||||||
| **Total** | **6** | ✅ |
|
| output/producer.zig | 5 | OK |
|
||||||
|
| **Total** | **52** | OK |
|
||||||
|
|
||||||
### Ejemplos
|
### Ejemplos
|
||||||
|
|
||||||
| Ejemplo | Descripcion | Estado |
|
| Ejemplo | Descripcion | Estado |
|
||||||
|---------|-------------|--------|
|
|---------|-------------|--------|
|
||||||
| hello.zig | PDF minimo con texto y formas | ✅ |
|
| hello.zig | PDF minimo con texto y formas | OK |
|
||||||
| invoice.zig | Factura completa realista | ✅ |
|
| 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
|
## Roadmap
|
||||||
|
|
||||||
### Fase 1 - Core (COMPLETADO)
|
### Fase 1 - Core + Refactoring (COMPLETADO)
|
||||||
- [x] Estructura documento PDF 1.4
|
- [x] Estructura documento PDF 1.4
|
||||||
- [x] Paginas (A4, Letter, A3, A5, Legal, custom)
|
- [x] Paginas (A4, Letter, A3, A5, Legal, custom)
|
||||||
- [x] Texto basico (14 fuentes Type1 built-in)
|
- [x] Texto basico (14 fuentes Type1 built-in)
|
||||||
- [x] Lineas y rectangulos
|
- [x] Lineas y rectangulos
|
||||||
- [x] Colores RGB
|
- [x] Colores RGB, CMYK, Grayscale
|
||||||
- [x] Serializacion correcta
|
- [x] Serializacion correcta
|
||||||
|
- [x] Refactoring modular (separar en archivos)
|
||||||
|
- [x] Arreglar errores Zig 0.15 (ArrayListUnmanaged)
|
||||||
|
|
||||||
### Fase 2 - Texto Avanzado (PENDIENTE)
|
### Fase 2 - Sistema de Texto (COMPLETADO)
|
||||||
- [ ] Multiples fuentes en mismo documento
|
- [x] cell() - celda con bordes, relleno, alineacion
|
||||||
- [ ] Alineacion (izquierda, centro, derecha)
|
- [x] cellAdvanced() - cell con control de posicion
|
||||||
- [ ] Word wrap automatico
|
- [x] multiCell() - texto con word wrap automatico
|
||||||
- [ ] Interlineado configurable
|
- [x] ln() - salto de linea
|
||||||
- [ ] Texto multilinea
|
- [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)
|
### Fase 3 - Imagenes (PENDIENTE)
|
||||||
- [ ] JPEG embebido
|
- [ ] JPEG embebido
|
||||||
|
|
@ -96,147 +156,156 @@
|
||||||
- [ ] Helper para tablas
|
- [ ] Helper para tablas
|
||||||
- [ ] Numeracion de paginas
|
- [ ] Numeracion de paginas
|
||||||
- [ ] Headers/footers automaticos
|
- [ ] Headers/footers automaticos
|
||||||
- [ ] Margenes de pagina
|
|
||||||
- [ ] Links/URLs
|
- [ ] Links/URLs
|
||||||
|
|
||||||
### Fase 5 - Avanzado (FUTURO)
|
### Fase 5 - Avanzado (FUTURO)
|
||||||
- [ ] Fuentes TTF embebidas
|
- [ ] Fuentes TTF embebidas
|
||||||
- [ ] Compresion de streams
|
- [ ] Compresion de streams (zlib)
|
||||||
- [ ] Metadatos documento (autor, titulo)
|
|
||||||
- [ ] Bookmarks/outline
|
- [ ] Bookmarks/outline
|
||||||
- [ ] Forms (campos rellenables)
|
- [ ] 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
|
## 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
|
```zig
|
||||||
const pdf = @import("zpdf");
|
const pdf = @import("zpdf");
|
||||||
|
|
||||||
var doc = pdf.Document.init(allocator);
|
var doc = pdf.Document.init(allocator);
|
||||||
defer doc.deinit();
|
defer doc.deinit();
|
||||||
```
|
|
||||||
|
|
||||||
### Agregar Paginas
|
|
||||||
|
|
||||||
```zig
|
|
||||||
// Tamano estandar
|
|
||||||
var page = try doc.addPage(.a4);
|
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);
|
try page.setFont(.helvetica_bold, 24);
|
||||||
page.setFillColor(pdf.Color{ .r = 0, .g = 0, .b = 255 });
|
|
||||||
try page.drawText(50, 750, "Titulo");
|
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");
|
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
|
```zig
|
||||||
pdf.Color.black // (0, 0, 0)
|
// cell(width, height, text, border, align, fill)
|
||||||
pdf.Color.white // (255, 255, 255)
|
// width=0 extiende hasta margen derecho
|
||||||
pdf.Color.red // (255, 0, 0)
|
// width=null ajusta al ancho del texto
|
||||||
pdf.Color.green // (0, 255, 0)
|
try page.cell(100, 20, "Hello", Border.all, .left, false);
|
||||||
pdf.Color.blue // (0, 0, 255)
|
try page.cell(0, 20, "Full width", Border.none, .center, true);
|
||||||
pdf.Color.gray // (128, 128, 128)
|
|
||||||
pdf.Color.light_gray // (200, 200, 200)
|
// 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
|
$ZIG build test
|
||||||
|
|
||||||
# Ejecutar ejemplos
|
# Ejecutar ejemplos
|
||||||
$ZIG build hello && ./zig-out/bin/hello
|
$ZIG build && ./zig-out/bin/hello
|
||||||
$ZIG build invoice && ./zig-out/bin/invoice
|
$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
|
## Equipo y Metodologia
|
||||||
|
|
||||||
### Normas de Trabajo Centralizadas
|
### Normas de Trabajo Centralizadas
|
||||||
|
|
@ -269,18 +364,6 @@ $ZIG build invoice && ./zig-out/bin/invoice
|
||||||
/mnt/cello2/arno/re/recode/TEAM_STANDARDS/
|
/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
|
### Control de Versiones
|
||||||
|
|
||||||
```bash
|
```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
|
## 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)
|
### 2025-12-08 - v0.1 (Core Funcional)
|
||||||
- Estructura inicial del proyecto
|
- Estructura inicial del proyecto
|
||||||
- Document, Page, Color, Font, PageSize types
|
- Document, Page, Color, Font, PageSize types
|
||||||
|
|
@ -321,38 +404,13 @@ main # Codigo estable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notas de Desarrollo
|
## Referencias
|
||||||
|
|
||||||
### Proxima Sesion
|
- **fpdf2 (Python)**: https://github.com/py-pdf/fpdf2 - Fuente principal
|
||||||
1. Implementar word wrap automatico para texto largo
|
- **PDF 1.4 Spec**: https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.4.pdf
|
||||||
2. Agregar helper para tablas (simplificar invoice.zig)
|
- **pdf-nano (Zig)**: https://github.com/GregorBudweiser/pdf-nano
|
||||||
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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Proyectos Relacionados
|
**zpdf - Generador PDF para Zig**
|
||||||
|
*v0.2 - 2025-12-08*
|
||||||
| 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*
|
|
||||||
|
|
|
||||||
19
build.zig
19
build.zig
|
|
@ -61,4 +61,23 @@ pub fn build(b: *std.Build) void {
|
||||||
run_invoice.step.dependOn(b.getInstallStep());
|
run_invoice.step.dependOn(b.getInstallStep());
|
||||||
const invoice_step = b.step("invoice", "Run invoice example");
|
const invoice_step = b.step("invoice", "Run invoice example");
|
||||||
invoice_step.dependOn(&run_invoice.step);
|
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
509
docs/ARQUITECTURA_FPDF2.md
Normal 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
541
docs/ARQUITECTURA_ZPDF.md
Normal 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
326
docs/PLAN_MAESTRO_ZPDF.md
Normal 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.
|
||||||
|
|
@ -19,12 +19,12 @@ pub fn main() !void {
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
try page.setFont(.helvetica_bold, 36);
|
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!");
|
try page.drawText(50, 750, "Hello, PDF!");
|
||||||
|
|
||||||
// Subtitle
|
// Subtitle
|
||||||
try page.setFont(.helvetica, 14);
|
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");
|
try page.drawText(50, 710, "Generated with zpdf - Pure Zig PDF library");
|
||||||
|
|
||||||
// Draw a line
|
// Draw a line
|
||||||
|
|
@ -45,7 +45,7 @@ pub fn main() !void {
|
||||||
// Draw some shapes
|
// Draw some shapes
|
||||||
try page.setLineWidth(2);
|
try page.setLineWidth(2);
|
||||||
page.setStrokeColor(pdf.Color.blue);
|
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.drawFilledRect(50, 500, 200, 60);
|
||||||
|
|
||||||
try page.setFont(.helvetica_bold, 14);
|
try page.setFont(.helvetica_bold, 14);
|
||||||
|
|
@ -54,7 +54,7 @@ pub fn main() !void {
|
||||||
|
|
||||||
// Red rectangle
|
// Red rectangle
|
||||||
page.setStrokeColor(pdf.Color.red);
|
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);
|
try page.drawFilledRect(280, 500, 200, 60);
|
||||||
|
|
||||||
page.setFillColor(pdf.Color.red);
|
page.setFillColor(pdf.Color.red);
|
||||||
|
|
@ -62,7 +62,7 @@ pub fn main() !void {
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
try page.setFont(.courier, 10);
|
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");
|
try page.drawText(50, 50, "zpdf v0.1.0 - https://git.reugenio.com/reugenio/zpdf");
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ pub fn main() !void {
|
||||||
|
|
||||||
// Company header
|
// Company header
|
||||||
try page.setFont(.helvetica_bold, 24);
|
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.drawText(50, 780, "ACME Corporation");
|
||||||
|
|
||||||
try page.setFont(.helvetica, 10);
|
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, 765, "123 Business Street, City 12345");
|
||||||
try page.drawText(50, 753, "Tel: +34 123 456 789 | Email: info@acme.com");
|
try page.drawText(50, 753, "Tel: +34 123 456 789 | Email: info@acme.com");
|
||||||
try page.drawText(50, 741, "CIF: B12345678");
|
try page.drawText(50, 741, "CIF: B12345678");
|
||||||
|
|
@ -41,7 +41,7 @@ pub fn main() !void {
|
||||||
try page.drawLine(50, 720, 545, 720);
|
try page.drawLine(50, 720, 545, 720);
|
||||||
|
|
||||||
// Client info box
|
// 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.fillRect(50, 640, 250, 70);
|
||||||
|
|
||||||
try page.setFont(.helvetica_bold, 11);
|
try page.setFont(.helvetica_bold, 11);
|
||||||
|
|
@ -64,7 +64,7 @@ pub fn main() !void {
|
||||||
const row_height: f32 = 25;
|
const row_height: f32 = 25;
|
||||||
|
|
||||||
// Header background
|
// 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);
|
try page.fillRect(col1, table_top - row_height, 495, row_height);
|
||||||
|
|
||||||
// Header text
|
// Header text
|
||||||
|
|
@ -93,7 +93,7 @@ pub fn main() !void {
|
||||||
|
|
||||||
// Alternate row background
|
// Alternate row background
|
||||||
if (i % 2 == 0) {
|
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);
|
try page.fillRect(col1, y, 495, row_height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,7 +113,7 @@ pub fn main() !void {
|
||||||
|
|
||||||
// Table border
|
// Table border
|
||||||
try page.setLineWidth(0.5);
|
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);
|
try page.drawRect(col1, y, 495, table_top - y - row_height);
|
||||||
|
|
||||||
// Vertical lines
|
// Vertical lines
|
||||||
|
|
@ -145,7 +145,7 @@ pub fn main() !void {
|
||||||
// Payment info
|
// Payment info
|
||||||
const payment_y = totals_y - 100;
|
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.fillRect(50, payment_y - 50, 300, 70);
|
||||||
|
|
||||||
try page.setFont(.helvetica_bold, 10);
|
try page.setFont(.helvetica_bold, 10);
|
||||||
|
|
@ -163,7 +163,7 @@ pub fn main() !void {
|
||||||
try page.drawLine(50, 80, 545, 80);
|
try page.drawLine(50, 80, 545, 80);
|
||||||
|
|
||||||
try page.setFont(.helvetica, 8);
|
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, 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");
|
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
182
examples/text_demo.zig
Normal 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
523
src/content_stream.zig
Normal 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
9
src/fonts/mod.zig
Normal 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
301
src/fonts/type1.zig
Normal 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
241
src/graphics/color.zig
Normal 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
6
src/graphics/mod.zig
Normal 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
190
src/objects/base.zig
Normal 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
16
src/objects/mod.zig
Normal 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
8
src/output/mod.zig
Normal 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
264
src/output/producer.zig
Normal 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
851
src/page.zig
Normal 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
278
src/pdf.zig
Normal 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"));
|
||||||
|
}
|
||||||
600
src/root.zig
600
src/root.zig
|
|
@ -1,522 +1,226 @@
|
||||||
//! zpdf - PDF generation library for Zig
|
//! zpdf - PDF generation library for Zig
|
||||||
//!
|
//!
|
||||||
//! A pure Zig library for creating PDF documents with zero dependencies.
|
//! 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
|
//! ## Quick Start
|
||||||
//!
|
//!
|
||||||
//! ```zig
|
//! ```zig
|
||||||
//! const pdf = @import("zpdf");
|
//! const zpdf = @import("zpdf");
|
||||||
//!
|
//!
|
||||||
//! pub fn main() !void {
|
//! pub fn main() !void {
|
||||||
//! var doc = pdf.Document.init(allocator);
|
//! var pdf = zpdf.Pdf.init(allocator, .{});
|
||||||
//! defer doc.deinit();
|
//! defer pdf.deinit();
|
||||||
//!
|
//!
|
||||||
//! var page = try doc.addPage(.a4);
|
//! var page = try pdf.addPage(.{});
|
||||||
//! try page.setFont(.helvetica_bold, 24);
|
//! try page.setFont(.helvetica_bold, 24);
|
||||||
//! try page.drawText(50, 750, "Hello, PDF!");
|
//! try page.drawText(50, 750, "Hello, PDF!");
|
||||||
//!
|
//!
|
||||||
//! try doc.saveToFile("hello.pdf");
|
//! try pdf.save("hello.pdf");
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
// ============================================================================
|
// =============================================================================
|
||||||
// Public Types
|
// Module Re-exports
|
||||||
// ============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// Standard page sizes in PDF points (1 point = 1/72 inch)
|
/// Main PDF document facade
|
||||||
pub const PageSize = enum {
|
pub const pdf = @import("pdf.zig");
|
||||||
a4, // 595 x 842 points (210 x 297 mm)
|
pub const Pdf = pdf.Pdf;
|
||||||
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)
|
|
||||||
|
|
||||||
pub fn dimensions(self: PageSize) struct { width: f32, height: f32 } {
|
/// Page representation
|
||||||
return switch (self) {
|
pub const page = @import("page.zig");
|
||||||
.a4 => .{ .width = 595, .height = 842 },
|
pub const Page = page.Page;
|
||||||
.a3 => .{ .width = 842, .height = 1191 },
|
pub const Align = page.Align;
|
||||||
.a5 => .{ .width = 420, .height = 595 },
|
pub const Border = page.Border;
|
||||||
.letter => .{ .width = 612, .height = 792 },
|
pub const CellPosition = page.Page.CellPosition;
|
||||||
.legal => .{ .width = 612, .height = 1008 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/// PDF standard Type1 fonts (built into all PDF readers)
|
/// Content stream (low-level PDF operators)
|
||||||
pub const Font = enum {
|
pub const content_stream = @import("content_stream.zig");
|
||||||
helvetica,
|
pub const ContentStream = content_stream.ContentStream;
|
||||||
helvetica_bold,
|
pub const RenderStyle = content_stream.RenderStyle;
|
||||||
helvetica_oblique,
|
pub const LineCap = content_stream.LineCap;
|
||||||
helvetica_bold_oblique,
|
pub const LineJoin = content_stream.LineJoin;
|
||||||
times_roman,
|
pub const TextRenderMode = content_stream.TextRenderMode;
|
||||||
times_bold,
|
|
||||||
times_italic,
|
|
||||||
times_bold_italic,
|
|
||||||
courier,
|
|
||||||
courier_bold,
|
|
||||||
courier_oblique,
|
|
||||||
courier_bold_oblique,
|
|
||||||
symbol,
|
|
||||||
zapf_dingbats,
|
|
||||||
|
|
||||||
pub fn pdfName(self: Font) []const u8 {
|
/// Graphics (colors, etc.)
|
||||||
return switch (self) {
|
pub const graphics = @import("graphics/mod.zig");
|
||||||
.helvetica => "Helvetica",
|
pub const Color = graphics.Color;
|
||||||
.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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/// RGB color (0-255 per channel)
|
/// Fonts
|
||||||
pub const Color = struct {
|
pub const fonts = @import("fonts/mod.zig");
|
||||||
r: u8 = 0,
|
pub const Font = fonts.Font;
|
||||||
g: u8 = 0,
|
pub const FontFamily = fonts.FontFamily;
|
||||||
b: u8 = 0,
|
pub const FontState = fonts.FontState;
|
||||||
|
|
||||||
pub const black = Color{ .r = 0, .g = 0, .b = 0 };
|
/// Objects (base types, page sizes, units)
|
||||||
pub const white = Color{ .r = 255, .g = 255, .b = 255 };
|
pub const objects = @import("objects/mod.zig");
|
||||||
pub const red = Color{ .r = 255, .g = 0, .b = 0 };
|
pub const PageSize = objects.PageSize;
|
||||||
pub const green = Color{ .r = 0, .g = 255, .b = 0 };
|
pub const Orientation = objects.Orientation;
|
||||||
pub const blue = Color{ .r = 0, .g = 0, .b = 255 };
|
pub const Unit = objects.Unit;
|
||||||
pub const gray = Color{ .r = 128, .g = 128, .b = 128 };
|
|
||||||
pub const light_gray = Color{ .r = 200, .g = 200, .b = 200 };
|
|
||||||
|
|
||||||
/// Convert to PDF color values (0.0 - 1.0)
|
/// Output (PDF generation)
|
||||||
pub fn toFloats(self: Color) struct { r: f32, g: f32, b: f32 } {
|
pub const output = @import("output/mod.zig");
|
||||||
return .{
|
pub const OutputProducer = output.OutputProducer;
|
||||||
.r = @as(f32, @floatFromInt(self.r)) / 255.0,
|
|
||||||
.g = @as(f32, @floatFromInt(self.g)) / 255.0,
|
|
||||||
.b = @as(f32, @floatFromInt(self.b)) / 255.0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// =============================================================================
|
||||||
// Page
|
// Backwards Compatibility - Old API (Document)
|
||||||
// ============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// A single page in a PDF document
|
/// Legacy Document type (use Pdf instead for new code).
|
||||||
pub const Page = struct {
|
/// Provided for backwards compatibility with existing code.
|
||||||
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
|
|
||||||
pub const Document = struct {
|
pub const Document = struct {
|
||||||
allocator: std.mem.Allocator,
|
inner: Pdf,
|
||||||
pages: std.ArrayListUnmanaged(Page),
|
|
||||||
|
|
||||||
const Self = @This();
|
const Self = @This();
|
||||||
|
|
||||||
/// Creates a new empty PDF document
|
/// Creates a new empty PDF document.
|
||||||
pub fn init(allocator: std.mem.Allocator) Self {
|
pub fn init(allocator: std.mem.Allocator) Self {
|
||||||
return .{
|
return .{
|
||||||
.allocator = allocator,
|
.inner = Pdf.init(allocator, .{}),
|
||||||
.pages = .{},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frees all resources
|
/// Frees all resources.
|
||||||
pub fn deinit(self: *Self) void {
|
pub fn deinit(self: *Self) void {
|
||||||
for (self.pages.items) |*page| {
|
self.inner.deinit();
|
||||||
page.deinit();
|
|
||||||
}
|
|
||||||
self.pages.deinit(self.allocator);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a new page with a standard size
|
/// Adds a new page with a standard size.
|
||||||
pub fn addPage(self: *Self, size: PageSize) !*Page {
|
pub fn addPage(self: *Self, size: PageSize) !*Page {
|
||||||
const page = Page.init(self.allocator, size);
|
return try self.inner.addPage(.{ .size = size });
|
||||||
try self.pages.append(self.allocator, page);
|
|
||||||
return &self.pages.items[self.pages.items.len - 1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
pub fn addPageCustom(self: *Self, width: f32, height: f32) !*Page {
|
||||||
const page = Page.initCustom(self.allocator, width, height);
|
return try self.inner.addPageCustom(width, height);
|
||||||
try self.pages.append(self.allocator, page);
|
|
||||||
return &self.pages.items[self.pages.items.len - 1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the document to a byte buffer
|
/// Renders the document to a byte buffer.
|
||||||
pub fn render(self: *Self, allocator: std.mem.Allocator) ![]u8 {
|
pub fn render(self: *Self, allocator: std.mem.Allocator) ![]u8 {
|
||||||
var output: std.ArrayListUnmanaged(u8) = .{};
|
_ = allocator; // Use inner allocator
|
||||||
errdefer output.deinit(allocator);
|
return try self.inner.render();
|
||||||
|
|
||||||
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
|
/// Saves the document to a file.
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saves the document to a file
|
|
||||||
pub fn saveToFile(self: *Self, path: []const u8) !void {
|
pub fn saveToFile(self: *Self, path: []const u8) !void {
|
||||||
const data = try self.render(self.allocator);
|
return try self.inner.save(path);
|
||||||
defer self.allocator.free(data);
|
|
||||||
|
|
||||||
const file = try std.fs.cwd().createFile(path, .{});
|
|
||||||
defer file.close();
|
|
||||||
|
|
||||||
try file.writeAll(data);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// =============================================================================
|
||||||
// Tests
|
// 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;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var doc = Document.init(allocator);
|
var doc = Document.init(allocator);
|
||||||
defer doc.deinit();
|
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;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var doc = Document.init(allocator);
|
var zpdf_doc = Pdf.init(allocator, .{
|
||||||
defer doc.deinit();
|
.page_size = .a4,
|
||||||
|
.orientation = .portrait,
|
||||||
|
});
|
||||||
|
defer zpdf_doc.deinit();
|
||||||
|
|
||||||
_ = try doc.addPage(.a4);
|
zpdf_doc.setTitle("Test Document");
|
||||||
try std.testing.expectEqual(@as(usize, 1), doc.pages.items.len);
|
zpdf_doc.setAuthor("zpdf");
|
||||||
|
|
||||||
const dims = PageSize.a4.dimensions();
|
var pg = try zpdf_doc.addPage(.{});
|
||||||
try std.testing.expectEqual(@as(f32, 595), dims.width);
|
try pg.setFont(.helvetica_bold, 24);
|
||||||
try std.testing.expectEqual(@as(f32, 842), dims.height);
|
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;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
var doc = Document.init(allocator);
|
var cs = ContentStream.init(allocator);
|
||||||
defer doc.deinit();
|
defer cs.deinit();
|
||||||
|
|
||||||
var page = try doc.addPage(.a4);
|
try cs.saveState();
|
||||||
try page.drawText(100, 700, "Hello");
|
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);
|
const content = cs.getContent();
|
||||||
defer allocator.free(pdf_data);
|
try std.testing.expect(std.mem.indexOf(u8, content, "q\n") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, content, "Q\n") != null);
|
||||||
// Check PDF header
|
try std.testing.expect(std.mem.indexOf(u8, content, "1.000 0.000 0.000 RG") != null);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "font names" {
|
// Import all module tests
|
||||||
try std.testing.expectEqualStrings("Helvetica", Font.helvetica.pdfName());
|
comptime {
|
||||||
try std.testing.expectEqualStrings("Times-Bold", Font.times_bold.pdfName());
|
_ = @import("content_stream.zig");
|
||||||
try std.testing.expectEqualStrings("Courier", Font.courier.pdfName());
|
_ = @import("page.zig");
|
||||||
}
|
_ = @import("pdf.zig");
|
||||||
|
_ = @import("graphics/color.zig");
|
||||||
test "color conversion" {
|
_ = @import("fonts/type1.zig");
|
||||||
const white = Color.white;
|
_ = @import("objects/base.zig");
|
||||||
const floats = white.toFloats();
|
_ = @import("output/producer.zig");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue