diff --git a/dev/issues/completed/0020-pdf-generation.md b/dev/issues/completed/0020-pdf-generation.md
new file mode 100644
index 00000000..1ade0637
--- /dev/null
+++ b/dev/issues/completed/0020-pdf-generation.md
@@ -0,0 +1,312 @@
+# 0020 — PDF Generation
+
+## Metadata
+
+| Campo | Valor |
+|-------|-------|
+| **ID** | 0020 |
+| **Estado** | pendiente |
+| **Prioridad** | media |
+| **Tipo** | feature |
+
+## Dependencias
+
+Ninguna.
+
+---
+
+## Objetivo
+
+Crear funciones para generar y construir PDFs programaticamente: reportes, facturas, exports de datos, conversiones desde HTML/Markdown. Complementa las funciones de lectura de PDF ya existentes (`pdf_to_markdown_py_core`, `extract_pdf_text_py_core`, `extract_pdf_bookmarks_py_core`) con la capacidad inversa de crear documentos.
+
+## Contexto
+
+- El registry tiene 3 funciones para **leer** PDFs y 2 pipelines relacionados (`export_analysis_pdfs_bash_pipelines`, `notebook_to_pdf_bash_infra`), pero **cero** funciones para **generar** PDFs desde datos o contenido.
+- Casos de uso frecuentes que hoy requieren codigo ad-hoc: generar reportes de ejecuciones, exportar tablas de datos como PDF, crear facturas, convertir documentacion markdown a PDF distribuible.
+- Python tiene las mejores librerias de generacion de PDF (reportlab, fpdf2, weasyprint) sin necesidad de binarios externos como wkhtmltopdf.
+- Go puede cubrir casos simples de reportes sin dependencias pesadas, usando `go-pdf/fpdf`.
+- El dominio natural es `infra` porque generar archivos es I/O, igual que las funciones de export/deploy existentes.
+
+## Arquitectura
+
+### Python (dominio infra) — generacion completa
+
+```
+python/functions/infra/
+├── pdf_create.py — NEW: crear documento PDF nuevo
+├── pdf_create.md — NEW
+├── pdf_add_page.py — NEW: añadir pagina con tamaño/orientacion
+├── pdf_add_page.md — NEW
+├── pdf_add_text.py — NEW: añadir bloque de texto con estilo
+├── pdf_add_text.md — NEW
+├── pdf_add_table.py — NEW: añadir tabla con datos, headers, anchos
+├── pdf_add_table.md — NEW
+├── pdf_add_image.py — NEW: añadir imagen desde archivo o bytes
+├── pdf_add_image.md — NEW
+├── pdf_add_header_footer.py — NEW: header/footer recurrente con numeros de pagina
+├── pdf_add_header_footer.md — NEW
+├── pdf_from_html.py — NEW: HTML string → PDF
+├── pdf_from_html.md — NEW
+├── pdf_from_markdown.py — NEW: Markdown → HTML → PDF
+├── pdf_from_markdown.md — NEW
+├── pdf_merge.py — NEW: fusionar multiples PDFs en uno
+├── pdf_merge.md — NEW
+├── pdf_save.py — NEW: guardar PDF a archivo o retornar bytes
+├── pdf_save.md — NEW
+
+python/types/infra/
+├── pdf_doc.py — NEW: tipo PDFDoc
+├── pdf_doc.md — NEW
+├── pdf_page.py — NEW: tipo PDFPage
+├── pdf_page.md — NEW
+├── pdf_style.py — NEW: tipo PDFStyle
+├── pdf_style.md — NEW
+```
+
+### Go (dominio infra) — reportes simples
+
+```
+functions/infra/
+├── pdf_simple_report.go — NEW: generar PDF simple desde titulo + secciones + tablas
+├── pdf_simple_report.md — NEW
+```
+
+### Patron pure core / impure shell
+
+Todas las funciones de generacion son impuras (escriben estado en un objeto documento mutable y/o hacen I/O a disco). No hay funciones puras en esta issue porque la generacion de PDFs es inherentemente stateful.
+
+Los tipos (`PDFDoc`, `PDFPage`, `PDFStyle`) son product types que definen la estructura de datos, no tienen I/O.
+
+## Diseño
+
+### Tipos
+
+```python
+@dataclass
+class PDFStyle:
+ font_family: str = "Helvetica"
+ font_size: float = 12.0
+ color: str = "#000000" # hex color
+ alignment: str = "left" # left, center, right, justify
+ line_height: float = 1.2
+ margin_top: float = 0.0
+ margin_bottom: float = 2.0
+
+@dataclass
+class PDFPage:
+ width: float = 210.0 # mm (A4 default)
+ height: float = 297.0 # mm (A4 default)
+ orientation: str = "portrait" # portrait, landscape
+
+@dataclass
+class PDFDoc:
+ pages: list # lista de paginas con contenido
+ title: str = ""
+ author: str = ""
+ subject: str = ""
+ default_font: str = "Helvetica"
+ margin_left: float = 20.0 # mm
+ margin_right: float = 20.0
+ margin_top: float = 20.0
+ margin_bottom: float = 20.0
+```
+
+### Funciones Python
+
+| Funcion | ID | Purity | Firma (simplificada) |
+|---------|-----|--------|---------------------|
+| `pdf_create` | `pdf_create_py_infra` | impure | `(title, author, page_size, margins) -> PDFDoc` |
+| `pdf_add_page` | `pdf_add_page_py_infra` | impure | `(doc, orientation, width, height) -> PDFDoc` |
+| `pdf_add_text` | `pdf_add_text_py_infra` | impure | `(doc, text, style, x, y) -> PDFDoc` |
+| `pdf_add_table` | `pdf_add_table_py_infra` | impure | `(doc, headers, rows, col_widths, style) -> PDFDoc` |
+| `pdf_add_image` | `pdf_add_image_py_infra` | impure | `(doc, image_path_or_bytes, x, y, width, height) -> PDFDoc` |
+| `pdf_add_header_footer` | `pdf_add_header_footer_py_infra` | impure | `(doc, header_text, footer_text, page_numbers) -> PDFDoc` |
+| `pdf_from_html` | `pdf_from_html_py_infra` | impure | `(html_string, output_path, page_size) -> str` |
+| `pdf_from_markdown` | `pdf_from_markdown_py_infra` | impure | `(markdown_string, output_path, css) -> str` |
+| `pdf_merge` | `pdf_merge_py_infra` | impure | `(pdf_paths, output_path) -> str` |
+| `pdf_save` | `pdf_save_py_infra` | impure | `(doc, output_path) -> str \| bytes` |
+
+### Funcion Go
+
+| Funcion | ID | Purity | Firma (simplificada) |
+|---------|-----|--------|---------------------|
+| `pdf_simple_report` | `pdf_simple_report_go_infra` | impure | `(title string, sections []Section, outputPath string) (string, error)` |
+
+La funcion Go recibe un titulo, una lista de secciones (cada una con titulo, texto y tabla opcional) y genera un PDF completo en un solo paso. No expone el builder granular de Python — es una funcion de alto nivel para reportes rapidos sin dependencia de Python.
+
+## Tareas
+
+### Fase 1: Tipos y builder basico
+
+- [ ] **1.1** Crear tipos `PDFDoc`, `PDFPage`, `PDFStyle` en `python/types/infra/` con `.py` y `.md`
+- [ ] **1.2** `pdf_create` — inicializa documento fpdf2, retorna wrapper PDFDoc
+- [ ] **1.3** `pdf_add_page` — añade pagina con orientacion y tamaño configurables
+- [ ] **1.4** `pdf_add_text` — escribe texto con fuente, tamaño, color, posicion
+- [ ] **1.5** `pdf_save` — serializa documento a archivo o retorna bytes
+- [ ] **1.6** Tests basicos: crear documento, añadir pagina, escribir texto, guardar, verificar que el PDF resultante es valido
+
+### Fase 2: Contenido rico
+
+- [ ] **2.1** `pdf_add_table` — tabla con headers, filas, anchos de columna, bordes, colores alternados
+- [ ] **2.2** `pdf_add_image` — imagen desde path o bytes, redimensionado automatico si se especifica solo ancho o alto
+- [ ] **2.3** `pdf_add_header_footer` — header/footer recurrente, numero de pagina con formato configurable (`Pagina {n} de {total}`)
+- [ ] **2.4** `pdf_merge` — fusionar N archivos PDF en uno usando PyPDF2 (ya es dependencia del registry)
+- [ ] **2.5** Tests: tabla con datos reales, imagen embebida, merge de 3 PDFs
+
+### Fase 3: Conversiones
+
+- [ ] **3.1** `pdf_from_html` — HTML a PDF usando weasyprint. Soporta CSS inline y externo.
+- [ ] **3.2** `pdf_from_markdown` — Markdown a HTML (via `markdown` o `mistune`) y luego a PDF con weasyprint. CSS por defecto incluido.
+- [ ] **3.3** Tests: HTML con tabla y estilos a PDF, markdown con headers y listas a PDF
+- [ ] **3.4** Documentar instalacion de dependencias de weasyprint (librerias de sistema: pango, cairo, gdk-pixbuf)
+
+### Fase 4: Go y cleanup
+
+- [ ] **4.1** `pdf_simple_report` en Go con `go-pdf/fpdf` — titulo, secciones con texto, tablas opcionales
+- [ ] **4.2** Tests Go con verificacion de output valido
+- [ ] **4.3** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
+- [ ] **4.4** Verificar `go vet -tags fts5` y tests Python
+
+---
+
+## Ejemplo de uso
+
+### Factura
+
+```python
+from infra import pdf_create, pdf_add_page, pdf_add_text, pdf_add_table
+from infra import pdf_add_image, pdf_save
+from infra import PDFStyle
+
+doc = pdf_create(title="Factura #2024-0042", author="Mi Empresa S.L.")
+doc = pdf_add_page(doc)
+
+# Logo
+doc = pdf_add_image(doc, "assets/logo.png", x=15, y=10, width=40)
+
+# Datos empresa
+header = PDFStyle(font_size=10, color="#666666")
+doc = pdf_add_text(doc, "Mi Empresa S.L.\nCIF: B12345678\nCalle Principal 42", style=header, x=120, y=10)
+
+# Titulo
+titulo = PDFStyle(font_size=22, font_family="Helvetica-Bold", color="#333333")
+doc = pdf_add_text(doc, "FACTURA #2024-0042", style=titulo, x=15, y=55)
+
+# Tabla de items
+headers = ["Concepto", "Cantidad", "Precio", "Total"]
+rows = [
+ ["Consultoria tecnica", "40 h", "75.00 EUR", "3,000.00 EUR"],
+ ["Desarrollo API", "1", "2,500.00 EUR", "2,500.00 EUR"],
+ ["Hosting mensual", "3 meses", "50.00 EUR", "150.00 EUR"],
+]
+doc = pdf_add_table(doc, headers, rows, col_widths=[80, 30, 40, 40])
+
+# Total
+total = PDFStyle(font_size=16, font_family="Helvetica-Bold", alignment="right")
+doc = pdf_add_text(doc, "TOTAL: 5,650.00 EUR", style=total)
+
+pdf_save(doc, "factura_2024_0042.pdf")
+```
+
+### Reporte de datos
+
+```python
+from infra import pdf_create, pdf_add_page, pdf_add_text, pdf_add_table
+from infra import pdf_add_header_footer, pdf_save
+from infra import PDFStyle
+
+# Generar reporte desde resultados de query
+doc = pdf_create(title="Reporte Mensual - Abril 2026")
+doc = pdf_add_header_footer(doc,
+ header_text="Reporte Mensual",
+ footer_text="Pagina {n} de {total}",
+ page_numbers=True
+)
+doc = pdf_add_page(doc)
+
+titulo = PDFStyle(font_size=18, font_family="Helvetica-Bold")
+doc = pdf_add_text(doc, "Resumen de Ejecuciones — Abril 2026", style=titulo)
+
+doc = pdf_add_text(doc, f"Total ejecuciones: {len(executions)}")
+doc = pdf_add_text(doc, f"Tasa de exito: {success_rate:.1f}%")
+doc = pdf_add_text(doc, f"Duracion promedio: {avg_duration:.0f} ms")
+
+headers = ["Pipeline", "Ejecuciones", "Exitos", "Fallos", "Duracion avg"]
+doc = pdf_add_table(doc, headers, execution_summary_rows)
+
+pdf_save(doc, "reporte_abril_2026.pdf")
+```
+
+### Export de Markdown a PDF
+
+```python
+from infra import pdf_from_markdown
+
+# Convertir documentacion a PDF distribuible
+with open("docs/spec_operaciones.md") as f:
+ md_content = f.read()
+
+output = pdf_from_markdown(md_content, "spec_operaciones.pdf",
+ css="body { font-family: sans-serif; } code { background: #f5f5f5; }"
+)
+print(f"PDF generado: {output}")
+```
+
+### Reporte simple en Go
+
+```go
+sections := []infra.ReportSection{
+ {
+ Title: "Estado del Registry",
+ Text: "El registry contiene 142 funciones, 38 tipos y 12 proposals pendientes.",
+ },
+ {
+ Title: "Funciones por Dominio",
+ Table: &infra.ReportTable{
+ Headers: []string{"Dominio", "Funciones", "Tipos"},
+ Rows: [][]string{
+ {"core", "45", "12"},
+ {"infra", "38", "8"},
+ {"finance", "22", "6"},
+ {"datascience", "18", "5"},
+ },
+ },
+ },
+}
+
+path, err := infra.PdfSimpleReport("Registry Status Report", sections, "registry_report.pdf")
+if err != nil {
+ log.Fatal(err)
+}
+fmt.Println("Reporte generado:", path)
+```
+
+## Decisiones de diseño
+
+- **fpdf2 para Python (builder granular):** Libreria ligera, pure Python, sin dependencias binarias. Cubre el 90% de los casos (texto, tablas, imagenes). No requiere instalacion de sistema.
+- **weasyprint para HTML/Markdown → PDF:** Cuando se necesita layout CSS completo (columnas, grids, estilos complejos). Requiere librerias de sistema (pango, cairo) pero produce PDFs de alta calidad tipografica.
+- **PyPDF2 para merge:** Ya es dependencia del registry (usada por `extract_pdf_text_py_core`). No añade dependencias nuevas.
+- **go-pdf/fpdf para Go:** Zero-dependency, API simple, suficiente para reportes tabulares sin necesidad de layout CSS.
+- **Dominio infra (no core):** Generar archivos es I/O, todas las funciones son impuras. Consistente con `notebook_to_pdf_bash_infra` y las funciones de export existentes.
+- **API fluent (doc = fn(doc, ...)):** Cada funcion recibe el documento y lo retorna modificado. Permite encadenar operaciones y mantiene la composabilidad funcional del registry. Internamente muta el objeto fpdf2 — la interfaz inmutable es una convencion, no una garantia.
+- **`pdf_simple_report` en Go como funcion de alto nivel:** No replica el builder granular de Python. Es una funcion unica que genera un reporte completo desde datos estructurados. Para PDFs complejos, usar Python.
+- **Separar builder (pdf_create/add_*/save) de conversiones (pdf_from_html/markdown/merge):** Dos patrones de uso distintos que no comparten estado. El builder construye desde cero; las conversiones transforman contenido existente.
+
+## Riesgos
+
+- **Dependencias de sistema para weasyprint:** pango, cairo, gdk-pixbuf deben estar instalados. En la maquina de desarrollo esto es un `apt install` pero complica portabilidad. Mitigado documentando la instalacion en la fase 3 y haciendo que `pdf_from_html`/`pdf_from_markdown` fallen con mensaje claro si weasyprint no esta disponible. Las funciones del builder (fpdf2) no tienen este problema.
+- **Tamaño de PDFs generados:** Imagenes grandes embebidas sin compresion pueden generar PDFs pesados. Mitigado con compresion JPEG automatica en `pdf_add_image` y documentando limites recomendados.
+- **Diferencias tipograficas entre SO:** Las fuentes disponibles varian entre Linux/Mac/Windows. Mitigado usando Helvetica (built-in en fpdf2) como fuente por defecto, que no requiere archivos .ttf externos.
+- **go-pdf/fpdf es una dependencia Go nueva:** Añade un import al modulo. Mitigado porque es una libreria estable, sin dependencias transitivas, y solo la usa una funcion.
+
+## Criterios de aceptacion
+
+- [ ] `pdf_create` + `pdf_add_page` + `pdf_add_text` + `pdf_save` genera un PDF valido que se puede abrir
+- [ ] `pdf_add_table` renderiza tabla con headers, bordes y datos alineados
+- [ ] `pdf_add_image` embebe PNG y JPEG correctamente
+- [ ] `pdf_from_markdown` convierte markdown con headers, listas y codigo a PDF legible
+- [ ] `pdf_merge` combina 3+ PDFs sin corrupcion
+- [ ] `pdf_simple_report` en Go genera PDF con titulo, texto y tabla
+- [ ] Todos los IDs siguen formato `{name}_py_infra` / `{name}_go_infra`
+- [ ] `fn index` registra las 10 funciones Python, 1 funcion Go y 3 tipos sin errores
+- [ ] Tests pasan en Python y Go
diff --git a/functions/infra/pdf_simple_report.go b/functions/infra/pdf_simple_report.go
new file mode 100644
index 00000000..250165b5
--- /dev/null
+++ b/functions/infra/pdf_simple_report.go
@@ -0,0 +1,36 @@
+package infra
+
+import "fmt"
+
+// ReportSection es una seccion de un reporte PDF simple.
+// Contiene un titulo, texto descriptivo y una tabla opcional.
+type ReportSection struct {
+ Title string
+ Text string
+ Table *ReportTable
+}
+
+// ReportTable es una tabla opcional dentro de una seccion de reporte.
+type ReportTable struct {
+ Headers []string
+ Rows [][]string
+}
+
+// PdfSimpleReport genera un PDF simple con titulo, secciones y tablas opcionales.
+//
+// Stub: requiere go-pdf/fpdf que no esta en el modulo. Para generar PDFs
+// desde Go usar la alternativa Python (pdf_create_py_infra + pdf_add_table_py_infra
+// + pdf_save_py_infra) o añadir go-pdf/fpdf al go.mod.
+//
+// Para usar la implementacion real:
+//
+// go get github.com/go-pdf/fpdf
+//
+// y reemplazar el cuerpo de esta funcion con la implementacion completa.
+func PdfSimpleReport(title string, sections []ReportSection, outputPath string) (string, error) {
+ return "", fmt.Errorf(
+ "not implemented: pdf_simple_report requires go-pdf/fpdf. "+
+ "Add it with: go get github.com/go-pdf/fpdf. "+
+ "Alternatively use pdf_create_py_infra + pdf_add_table_py_infra + pdf_save_py_infra for Python",
+ )
+}
diff --git a/functions/infra/pdf_simple_report.md b/functions/infra/pdf_simple_report.md
new file mode 100644
index 00000000..93acb5ad
--- /dev/null
+++ b/functions/infra/pdf_simple_report.md
@@ -0,0 +1,91 @@
+---
+name: pdf_simple_report
+kind: function
+lang: go
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "func PdfSimpleReport(title string, sections []ReportSection, outputPath string) (string, error)"
+description: "Genera un PDF simple con titulo, secciones de texto y tablas opcionales. Stub activo: requiere go-pdf/fpdf que no esta en el modulo. Para PDFs desde Go añadir la dependencia; para casos complejos usar el builder Python."
+tags: [pdf, report, go, stub, infra]
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: "error_go_core"
+imports: [fmt]
+params:
+ - name: title
+ desc: "titulo principal del reporte PDF, aparece en la cabecera del documento"
+ - name: sections
+ desc: "lista de ReportSection, cada una con Title (string), Text (string) y Table opcional (*ReportTable con Headers y Rows)"
+ - name: outputPath
+ desc: "ruta del archivo PDF a generar en disco"
+output: "outputPath con el PDF generado, o string vacio + error si la dependencia no esta disponible"
+tested: false
+tests: []
+test_file_path: ""
+file_path: "functions/infra/pdf_simple_report.go"
+---
+
+## Tipos auxiliares
+
+```go
+type ReportSection struct {
+ Title string
+ Text string
+ Table *ReportTable // nil = sin tabla en esta seccion
+}
+
+type ReportTable struct {
+ Headers []string
+ Rows [][]string
+}
+```
+
+## Ejemplo de uso
+
+```go
+sections := []infra.ReportSection{
+ {
+ Title: "Estado del Registry",
+ Text: "El registry contiene 142 funciones, 38 tipos y 12 proposals pendientes.",
+ },
+ {
+ Title: "Funciones por Dominio",
+ Table: &infra.ReportTable{
+ Headers: []string{"Dominio", "Funciones", "Tipos"},
+ Rows: [][]string{
+ {"core", "45", "12"},
+ {"infra", "38", "8"},
+ {"finance", "22", "6"},
+ },
+ },
+ },
+}
+
+path, err := infra.PdfSimpleReport("Registry Status Report", sections, "registry_report.pdf")
+if err != nil {
+ log.Fatal(err)
+}
+fmt.Println("PDF generado:", path)
+```
+
+## Activar la implementacion real
+
+```bash
+# Añadir dependencia al modulo Go
+go get github.com/go-pdf/fpdf
+
+# La funcion usa la API de go-pdf/fpdf:
+# pdf := fpdf.New("P", "mm", "A4", "")
+# pdf.AddPage()
+# pdf.SetFont("Arial", "B", 16)
+# pdf.Cell(0, 10, title)
+# ... para cada seccion y tabla
+# pdf.OutputFileAndClose(outputPath)
+```
+
+## Notas
+
+Stub activo: retorna `fmt.Errorf("not implemented: ...")` hasta que `go-pdf/fpdf` se añada al modulo. No añadida como dependencia directa porque solo la usa esta funcion y añade peso al modulo. Para generacion de PDFs desde el registry, la solucion recomendada es el builder Python (`pdf_create_py_infra` + `pdf_add_text_py_infra` + `pdf_add_table_py_infra` + `pdf_save_py_infra`) que es mas completo y no requiere dependencias adicionales en Go.
diff --git a/python/functions/infra/pdf_add_header_footer.md b/python/functions/infra/pdf_add_header_footer.md
new file mode 100644
index 00000000..5d99880d
--- /dev/null
+++ b/python/functions/infra/pdf_add_header_footer.md
@@ -0,0 +1,53 @@
+---
+name: pdf_add_header_footer
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_add_header_footer(doc, header_text, footer_text, page_numbers) -> PDFDoc"
+description: "Configura header y/o footer recurrente para todas las paginas del documento. Debe llamarse antes de pdf_add_page. El footer acepta {n} y {total} como placeholders para numeracion automatica."
+tags: [pdf, header, footer, pagination, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra]
+returns: [pdf_doc_py_infra]
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+params:
+ - name: doc
+ desc: "PDFDoc inicializado con pdf_create, sin paginas aun (llamar antes de pdf_add_page)"
+ - name: header_text
+ desc: "texto centrado en el header de cada pagina; vacio deshabilita el header"
+ - name: footer_text
+ desc: "texto del footer; soporta {n} = numero de pagina y {total} = total de paginas"
+ - name: page_numbers
+ desc: "si True activa numeracion automatica; si footer_text esta vacio usa 'Pagina {n} de {total}'"
+output: "PDFDoc con la configuracion de header/footer guardada; se aplica en cada pdf_add_page posterior"
+tested: false
+tests: []
+test_file_path: ""
+file_path: "python/functions/infra/pdf_add_header_footer.py"
+---
+
+## Ejemplo
+
+```python
+doc = pdf_create(title="Reporte Mensual")
+
+# Header con nombre del reporte, footer con numeracion
+doc = pdf_add_header_footer(
+ doc,
+ header_text="Reporte de Ejecuciones — Abril 2026",
+ footer_text="Pagina {n} de {total}",
+ page_numbers=True,
+)
+
+# Las paginas añadidas despues tendran header y footer
+doc = pdf_add_page(doc)
+doc = pdf_add_text(doc, "Contenido...")
+```
+
+## Notas
+
+Internamente configura los campos `header_text`, `footer_text` y `page_numbers` del PDFDoc. La subclase `_PDFWithHeaderFooter` de `pdf_create` lee estos campos en sus metodos `header()` y `footer()` que fpdf2 llama automaticamente en cada `add_page()`. El alias `{nb}` de fpdf2 se activa via `alias_nb_pages` y fpdf2 lo reemplaza al renderizar el PDF final. Debe llamarse antes de cualquier `pdf_add_page` para que el header/footer se aplique a todas las paginas.
diff --git a/python/functions/infra/pdf_add_header_footer.py b/python/functions/infra/pdf_add_header_footer.py
new file mode 100644
index 00000000..54dfd754
--- /dev/null
+++ b/python/functions/infra/pdf_add_header_footer.py
@@ -0,0 +1,41 @@
+"""pdf_add_header_footer — configura header/footer recurrente con numeracion de pagina."""
+
+import sys
+import os
+
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+
+
+def pdf_add_header_footer(
+ doc: PDFDoc,
+ header_text: str = "",
+ footer_text: str = "",
+ page_numbers: bool = True,
+) -> PDFDoc:
+ """Configura header y/o footer recurrente para todas las paginas del documento.
+
+ El header/footer se aplicara automaticamente en cada pagina nueva.
+ Debe llamarse ANTES de añadir paginas con pdf_add_page para que surta efecto.
+ El footer acepta {n} (numero de pagina actual) y {total} (total de paginas).
+
+ Args:
+ doc: PDFDoc inicializado con pdf_create.
+ header_text: texto a mostrar en el header de cada pagina. Vacio = sin header.
+ footer_text: texto del footer. Acepta {n} y {total} como placeholders.
+ Si page_numbers=True y footer_text esta vacio, usa 'Pagina {n} de {total}'.
+ page_numbers: si True incluye numeracion automatica en el footer.
+
+ Returns:
+ PDFDoc con la configuracion de header/footer establecida.
+ """
+ doc.header_text = header_text
+ doc.footer_text = footer_text
+ doc.page_numbers = page_numbers
+
+ # Activar alias de paginas totales en fpdf2 para {nb}
+ if page_numbers:
+ doc.fpdf.alias_nb_pages("{nb}")
+
+ return doc
diff --git a/python/functions/infra/pdf_add_image.md b/python/functions/infra/pdf_add_image.md
new file mode 100644
index 00000000..9af3eeb1
--- /dev/null
+++ b/python/functions/infra/pdf_add_image.md
@@ -0,0 +1,56 @@
+---
+name: pdf_add_image
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_add_image(doc, image, x, y, width, height, keep_aspect) -> PDFDoc"
+description: "Añade una imagen PNG o JPEG al documento PDF desde un path de archivo o bytes. Calcula automaticamente la dimension faltante si solo se especifica width o height para mantener la relacion de aspecto."
+tags: [pdf, image, embed, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra]
+returns: [pdf_doc_py_infra]
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+params:
+ - name: doc
+ desc: "PDFDoc con pagina activa"
+ - name: image
+ desc: "path absoluto o relativo a la imagen (str) o bytes del contenido de la imagen"
+ - name: x
+ desc: "posicion X en mm desde el borde izquierdo; None usa posicion actual del cursor"
+ - name: y
+ desc: "posicion Y en mm desde el borde superior; None usa posicion actual del cursor"
+ - name: width
+ desc: "ancho de la imagen en mm; None calcula desde height o usa ancho disponible"
+ - name: height
+ desc: "alto de la imagen en mm; None calcula desde width para mantener ratio"
+ - name: keep_aspect
+ desc: "si True fpdf2 calcula automaticamente la dimension faltante"
+output: "PDFDoc con la imagen embebida en la pagina activa"
+tested: false
+tests: []
+test_file_path: ""
+file_path: "python/functions/infra/pdf_add_image.py"
+---
+
+## Ejemplo
+
+```python
+# Imagen desde path con ancho fijo (height automatico)
+doc = pdf_add_image(doc, "assets/logo.png", x=15, y=10, width=40)
+
+# Imagen desde bytes
+with open("chart.png", "rb") as f:
+ img_bytes = f.read()
+doc = pdf_add_image(doc, img_bytes, width=120)
+
+# Imagen en posicion actual del cursor
+doc = pdf_add_image(doc, "diagrama.png", width=fpdf_doc.fpdf.epw)
+```
+
+## Notas
+
+Soporta PNG y JPEG nativamente en fpdf2. Para bytes, usa `io.BytesIO` como wrapper. fpdf2 gestiona automaticamente la relacion de aspecto cuando solo se especifica una dimension (fpdf calcula la otra si `h=0`). El cursor se mueve 5mm debajo de la imagen tras insertarla. Para SVG necesitaria `fpdf2[svg]` como extra opcional.
diff --git a/python/functions/infra/pdf_add_image.py b/python/functions/infra/pdf_add_image.py
new file mode 100644
index 00000000..7ae17a10
--- /dev/null
+++ b/python/functions/infra/pdf_add_image.py
@@ -0,0 +1,57 @@
+"""pdf_add_image — añade una imagen al documento PDF desde path o bytes."""
+
+import io
+import sys
+import os
+
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+
+
+def pdf_add_image(
+ doc: PDFDoc,
+ image: str | bytes,
+ x: float | None = None,
+ y: float | None = None,
+ width: float | None = None,
+ height: float | None = None,
+ keep_aspect: bool = True,
+) -> PDFDoc:
+ """Añade una imagen al documento PDF desde un path de archivo o bytes.
+
+ Si solo se especifica width, la altura se calcula manteniendo el ratio.
+ Si solo se especifica height, el ancho se calcula manteniendo el ratio.
+ Si ninguno se especifica, se usa el ancho disponible entre margenes.
+
+ Args:
+ doc: PDFDoc con pagina activa.
+ image: path absoluto o relativo a la imagen, o bytes del contenido.
+ x: posicion X en mm. None usa la posicion actual del cursor.
+ y: posicion Y en mm. None usa la posicion actual del cursor.
+ width: ancho de la imagen en mm. None calcula desde height o usa ancho disponible.
+ height: alto de la imagen en mm. None calcula desde width.
+ keep_aspect: si True mantiene la relacion de aspecto (siempre aplicable con fpdf2).
+
+ Returns:
+ PDFDoc con la imagen añadida.
+ """
+ fpdf = doc.fpdf
+
+ cur_x = x if x is not None else fpdf.get_x()
+ cur_y = y if y is not None else fpdf.get_y()
+
+ # Ancho por defecto: ancho disponible
+ img_width = width if width is not None else (fpdf.epw if height is None else 0)
+ img_height = height if height is not None else 0
+
+ if isinstance(image, bytes):
+ img_io = io.BytesIO(image)
+ fpdf.image(img_io, x=cur_x, y=cur_y, w=img_width, h=img_height)
+ else:
+ fpdf.image(image, x=cur_x, y=cur_y, w=img_width, h=img_height)
+
+ # Mover cursor debajo de la imagen
+ fpdf.ln(5)
+
+ return doc
diff --git a/python/functions/infra/pdf_add_page.md b/python/functions/infra/pdf_add_page.md
new file mode 100644
index 00000000..20cb6112
--- /dev/null
+++ b/python/functions/infra/pdf_add_page.md
@@ -0,0 +1,44 @@
+---
+name: pdf_add_page
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_add_page(doc, orientation, width, height) -> PDFDoc"
+description: "Añade una pagina nueva al documento PDF con orientacion y tamaño configurables. Sin dimensiones personalizadas usa A4 con la orientacion indicada."
+tags: [pdf, page, layout, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra]
+returns: [pdf_doc_py_infra]
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+params:
+ - name: doc
+ desc: "PDFDoc inicializado con pdf_create"
+ - name: orientation
+ desc: "orientacion de la pagina: 'portrait' (vertical) o 'landscape' (horizontal)"
+ - name: width
+ desc: "ancho personalizado en mm; None usa A4 (210 mm)"
+ - name: height
+ desc: "alto personalizado en mm; None usa A4 (297 mm)"
+output: "PDFDoc con la nueva pagina añadida; metadata de la pagina en doc.pages"
+tested: true
+tests: ["añadir pagina portrait A4", "añadir pagina landscape", "pagina con dimensiones personalizadas"]
+test_file_path: "python/functions/infra/pdf_add_page_test.py"
+file_path: "python/functions/infra/pdf_add_page.py"
+---
+
+## Ejemplo
+
+```python
+doc = pdf_create(title="Demo")
+doc = pdf_add_page(doc) # A4 portrait
+doc = pdf_add_page(doc, orientation="landscape") # A4 landscape
+doc = pdf_add_page(doc, width=148, height=210) # A5 portrait
+```
+
+## Notas
+
+Llama a `fpdf.add_page()` con el formato adecuado. El header/footer configurado en `pdf_add_header_footer` se aplica automaticamente en cada pagina nueva (via subclase FPDF). Las dimensiones reales post-add se registran en `doc.pages` con las medidas internas de fpdf2 (que incluye los margenes).
diff --git a/python/functions/infra/pdf_add_page.py b/python/functions/infra/pdf_add_page.py
new file mode 100644
index 00000000..227386bc
--- /dev/null
+++ b/python/functions/infra/pdf_add_page.py
@@ -0,0 +1,49 @@
+"""pdf_add_page — añade una pagina al documento PDF."""
+
+import sys
+import os
+
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+
+
+def pdf_add_page(
+ doc: PDFDoc,
+ orientation: str = "portrait",
+ width: float | None = None,
+ height: float | None = None,
+) -> PDFDoc:
+ """Añade una pagina nueva al documento PDF.
+
+ Usa orientacion y dimensiones especificadas, o hereda las del documento.
+ Si se especifica width/height se usa formato personalizado;
+ si no, se usa A4 con la orientacion indicada.
+
+ Args:
+ doc: PDFDoc retornado por pdf_create.
+ orientation: orientacion de la pagina: 'portrait' o 'landscape'.
+ width: ancho personalizado en mm. Si None, usa A4 (210 mm).
+ height: alto personalizado en mm. Si None, usa A4 (297 mm).
+
+ Returns:
+ PDFDoc con la nueva pagina añadida (mismo objeto modificado).
+ """
+ fpdf = doc.fpdf
+
+ orient_char = "P" if orientation == "portrait" else "L"
+
+ if width is not None and height is not None:
+ fpdf.add_page(orientation=orient_char, format=(width, height))
+ else:
+ fpdf.add_page(orientation=orient_char, format="A4")
+
+ actual_w = fpdf.w
+ actual_h = fpdf.h
+ doc.pages.append({
+ "orientation": orientation,
+ "width": actual_w,
+ "height": actual_h,
+ })
+
+ return doc
diff --git a/python/functions/infra/pdf_add_page_test.py b/python/functions/infra/pdf_add_page_test.py
new file mode 100644
index 00000000..8d954e17
--- /dev/null
+++ b/python/functions/infra/pdf_add_page_test.py
@@ -0,0 +1,33 @@
+"""Tests para pdf_add_page."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra"))
+
+from pdf_create import pdf_create
+from pdf_add_page import pdf_add_page
+
+
+def test_anadir_pagina_portrait_a4():
+ doc = pdf_create()
+ doc = pdf_add_page(doc, orientation="portrait")
+ assert len(doc.pages) == 1
+ assert doc.pages[0]["orientation"] == "portrait"
+ assert doc.fpdf.page == 1
+
+
+def test_anadir_pagina_landscape():
+ doc = pdf_create()
+ doc = pdf_add_page(doc, orientation="landscape")
+ assert len(doc.pages) == 1
+ assert doc.pages[0]["orientation"] == "landscape"
+
+
+def test_pagina_con_dimensiones_personalizadas():
+ doc = pdf_create()
+ doc = pdf_add_page(doc, width=148.0, height=210.0) # A5
+ assert len(doc.pages) == 1
+ # Verificar que se añadio la pagina
+ assert doc.fpdf.page == 1
diff --git a/python/functions/infra/pdf_add_table.md b/python/functions/infra/pdf_add_table.md
new file mode 100644
index 00000000..fdc0ca32
--- /dev/null
+++ b/python/functions/infra/pdf_add_table.md
@@ -0,0 +1,57 @@
+---
+name: pdf_add_table
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_add_table(doc, headers, rows, col_widths, header_style, row_style, border, row_height, alternate_bg) -> PDFDoc"
+description: "Añade una tabla con fila de headers y filas de datos al documento PDF. Soporta anchos de columna personalizados, estilos diferenciados para headers/datos, bordes y colores alternados en filas."
+tags: [pdf, table, data, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra, pdf_style_py_infra]
+returns: [pdf_doc_py_infra]
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+params:
+ - name: doc
+ desc: "PDFDoc con pagina activa"
+ - name: headers
+ desc: "lista de strings con nombres de columna de la tabla"
+ - name: rows
+ desc: "lista de listas de strings con los datos de cada fila"
+ - name: col_widths
+ desc: "lista de anchos de columna en mm; None distribuye uniformemente el ancho disponible"
+ - name: header_style
+ desc: "PDFStyle para la fila de headers; None usa Helvetica-Bold 10pt"
+ - name: row_style
+ desc: "PDFStyle para filas de datos; None usa Helvetica 10pt"
+ - name: border
+ desc: "tipo de borde: 0=sin borde, 1=borde completo"
+ - name: row_height
+ desc: "altura de cada celda en mm, por defecto 8"
+ - name: alternate_bg
+ desc: "si True alterna fondo gris claro en filas pares para mejorar legibilidad"
+output: "PDFDoc con la tabla renderizada en la pagina activa"
+tested: true
+tests: ["tabla con headers y datos", "tabla con col_widths personalizados", "tabla sin borde"]
+test_file_path: "python/functions/infra/pdf_add_table_test.py"
+file_path: "python/functions/infra/pdf_add_table.py"
+---
+
+## Ejemplo
+
+```python
+headers = ["Dominio", "Funciones", "Purity"]
+rows = [
+ ["core", "45", "pure"],
+ ["infra", "38", "impure"],
+ ["finance", "22", "mixed"],
+]
+doc = pdf_add_table(doc, headers, rows, col_widths=[70, 40, 60])
+```
+
+## Notas
+
+Las columnas se distribuyen uniformemente si `col_widths` es None. Si la lista tiene menos elementos que columnas, se completa con distribucion uniforme. El fondo alternado usa gris muy claro (245, 245, 245) en filas impares para mejorar la lectura de tablas largas. No gestiona overflow de celdas — textos muy largos se truncan en fpdf2 con `cell()`.
diff --git a/python/functions/infra/pdf_add_table.py b/python/functions/infra/pdf_add_table.py
new file mode 100644
index 00000000..8ac2f6cb
--- /dev/null
+++ b/python/functions/infra/pdf_add_table.py
@@ -0,0 +1,128 @@
+"""pdf_add_table — añade una tabla con headers y filas al documento PDF."""
+
+import sys
+import os
+
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+from pdf_style import PDFStyle
+
+
+def _parse_hex_color(hex_color: str) -> tuple[int, int, int]:
+ h = hex_color.lstrip("#")
+ if len(h) == 3:
+ h = "".join(c * 2 for c in h)
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
+
+
+def pdf_add_table(
+ doc: PDFDoc,
+ headers: list[str],
+ rows: list[list[str]],
+ col_widths: list[float] | None = None,
+ header_style: PDFStyle | None = None,
+ row_style: PDFStyle | None = None,
+ border: int = 1,
+ row_height: float = 8.0,
+ alternate_bg: bool = True,
+) -> PDFDoc:
+ """Añade una tabla con headers y filas de datos al documento PDF.
+
+ Las columnas se distribuyen uniformemente si no se especifican anchos.
+ Soporta colores alternados en filas, bordes configurables y estilos
+ diferenciados para headers y filas de datos.
+
+ Args:
+ doc: PDFDoc con pagina activa.
+ headers: lista de strings con los nombres de columna.
+ rows: lista de listas de strings con los datos de cada fila.
+ col_widths: lista de anchos de columna en mm. None = distribucion uniforme.
+ header_style: PDFStyle para la fila de headers. None usa bold 10pt.
+ row_style: PDFStyle para filas de datos. None usa 10pt normal.
+ border: grosor/tipo de borde (0=sin borde, 1=borde completo).
+ row_height: altura de cada celda en mm. Por defecto 8.
+ alternate_bg: si True, alterna fondo gris claro en filas pares.
+
+ Returns:
+ PDFDoc con la tabla añadida.
+ """
+ fpdf = doc.fpdf
+
+ if not headers:
+ return doc
+
+ # Anchos de columna
+ available_width = fpdf.epw
+ n_cols = len(headers)
+ if col_widths is None:
+ col_widths = [available_width / n_cols] * n_cols
+ else:
+ # Rellenar si faltan columnas
+ while len(col_widths) < n_cols:
+ col_widths.append(available_width / n_cols)
+
+ # Estilos
+ if header_style is None:
+ header_style = PDFStyle(
+ font_family="Helvetica-Bold",
+ font_size=10.0,
+ color="#000000",
+ alignment="left",
+ )
+ if row_style is None:
+ row_style = PDFStyle(font_size=10.0, color="#000000", alignment="left")
+
+ def set_font_from_style(s: PDFStyle):
+ family = s.font_family
+ bold = "-Bold" in family or "-BoldOblique" in family
+ italic = "-Oblique" in family or "-BoldOblique" in family
+ base = family.split("-")[0]
+ st = ("B" if bold else "") + ("I" if italic else "")
+ fpdf.set_font(base, style=st, size=s.font_size)
+ r, g, b = _parse_hex_color(s.color)
+ fpdf.set_text_color(r, g, b)
+
+ def align_char(a: str) -> str:
+ return {"left": "L", "center": "C", "right": "R", "justify": "J"}.get(a, "L")
+
+ # Header row
+ set_font_from_style(header_style)
+ fpdf.set_fill_color(230, 230, 230)
+ fpdf.set_draw_color(180, 180, 180)
+
+ for i, (header, width) in enumerate(zip(headers, col_widths)):
+ fpdf.cell(
+ width,
+ row_height,
+ str(header),
+ border=border,
+ align=align_char(header_style.alignment),
+ fill=True,
+ )
+ fpdf.ln()
+
+ # Data rows
+ set_font_from_style(row_style)
+ fpdf.set_text_color(*_parse_hex_color(row_style.color))
+
+ for row_idx, row in enumerate(rows):
+ fill = alternate_bg and (row_idx % 2 == 1)
+ if fill:
+ fpdf.set_fill_color(245, 245, 245)
+ else:
+ fpdf.set_fill_color(255, 255, 255)
+
+ for col_idx, (cell, width) in enumerate(zip(row, col_widths)):
+ fpdf.cell(
+ width,
+ row_height,
+ str(cell) if cell is not None else "",
+ border=border,
+ align=align_char(row_style.alignment),
+ fill=True,
+ )
+ fpdf.ln()
+
+ fpdf.ln(2)
+ return doc
diff --git a/python/functions/infra/pdf_add_table_test.py b/python/functions/infra/pdf_add_table_test.py
new file mode 100644
index 00000000..766b293a
--- /dev/null
+++ b/python/functions/infra/pdf_add_table_test.py
@@ -0,0 +1,47 @@
+"""Tests para pdf_add_table."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra"))
+
+from pdf_create import pdf_create
+from pdf_add_page import pdf_add_page
+from pdf_add_table import pdf_add_table
+from pdf_save import pdf_save
+
+
+def test_tabla_con_headers_y_datos():
+ doc = pdf_create()
+ doc = pdf_add_page(doc)
+ headers = ["Dominio", "Funciones", "Tipos"]
+ rows = [
+ ["core", "45", "12"],
+ ["infra", "38", "8"],
+ ["finance", "22", "6"],
+ ]
+ doc = pdf_add_table(doc, headers, rows)
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
+ assert len(pdf_bytes) > 200
+
+
+def test_tabla_con_col_widths_personalizados():
+ doc = pdf_create()
+ doc = pdf_add_page(doc)
+ headers = ["Nombre", "Valor"]
+ rows = [["alpha", "100"], ["beta", "200"]]
+ doc = pdf_add_table(doc, headers, rows, col_widths=[100.0, 70.0])
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
+
+
+def test_tabla_sin_borde():
+ doc = pdf_create()
+ doc = pdf_add_page(doc)
+ headers = ["A", "B"]
+ rows = [["1", "2"]]
+ doc = pdf_add_table(doc, headers, rows, border=0)
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
diff --git a/python/functions/infra/pdf_add_text.md b/python/functions/infra/pdf_add_text.md
new file mode 100644
index 00000000..f597262f
--- /dev/null
+++ b/python/functions/infra/pdf_add_text.md
@@ -0,0 +1,51 @@
+---
+name: pdf_add_text
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_add_text(doc, text, style, x, y, width) -> PDFDoc"
+description: "Escribe un bloque de texto en el documento PDF con fuente, tamaño, color y alineacion del PDFStyle dado. Soporta saltos de linea y word wrap automatico."
+tags: [pdf, text, typography, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra, pdf_style_py_infra]
+returns: [pdf_doc_py_infra]
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+params:
+ - name: doc
+ desc: "PDFDoc con al menos una pagina activa"
+ - name: text
+ desc: "texto a escribir; soporta \\n para saltos de linea"
+ - name: style
+ desc: "PDFStyle con fuente, tamaño, color, alineacion; None usa defaults (Helvetica 12pt negro)"
+ - name: x
+ desc: "posicion X en mm desde el borde izquierdo; None mantiene posicion actual"
+ - name: y
+ desc: "posicion Y en mm desde el borde superior; None mantiene posicion del cursor"
+ - name: width
+ desc: "ancho del bloque en mm; None usa el ancho disponible entre margenes"
+output: "PDFDoc con el texto añadido en la pagina activa"
+tested: true
+tests: ["texto simple con estilo defecto", "texto con estilo personalizado bold centrado", "texto multilinea con saltos"]
+test_file_path: "python/functions/infra/pdf_add_text_test.py"
+file_path: "python/functions/infra/pdf_add_text.py"
+---
+
+## Ejemplo
+
+```python
+from pdf_style import PDFStyle
+
+titulo = PDFStyle(font_family="Helvetica-Bold", font_size=18.0, alignment="center")
+cuerpo = PDFStyle(font_size=11.0, alignment="justify", margin_bottom=4.0)
+
+doc = pdf_add_text(doc, "Informe Mensual", style=titulo)
+doc = pdf_add_text(doc, "Este reporte resume las ejecuciones de Abril 2026.", style=cuerpo)
+```
+
+## Notas
+
+Usa `multi_cell` internamente para soportar word wrap automatico y saltos de linea `\n`. La altura de linea se calcula en mm a partir de `font_size * line_height`. Las fuentes built-in soportan variantes `-Bold`, `-Oblique`, `-BoldOblique` en el nombre de `font_family` (se separan internamente para fpdf2).
diff --git a/python/functions/infra/pdf_add_text.py b/python/functions/infra/pdf_add_text.py
new file mode 100644
index 00000000..be336f3b
--- /dev/null
+++ b/python/functions/infra/pdf_add_text.py
@@ -0,0 +1,107 @@
+"""pdf_add_text — escribe un bloque de texto en el documento PDF."""
+
+import sys
+import os
+
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+from pdf_style import PDFStyle
+
+
+def _parse_hex_color(hex_color: str) -> tuple[int, int, int]:
+ """Convierte color hex (#RRGGBB) a tupla (r, g, b)."""
+ h = hex_color.lstrip("#")
+ if len(h) == 3:
+ h = "".join(c * 2 for c in h)
+ r = int(h[0:2], 16)
+ g = int(h[2:4], 16)
+ b = int(h[4:6], 16)
+ return r, g, b
+
+
+def _align_char(alignment: str) -> str:
+ """Convierte nombre de alineacion al caracter fpdf2."""
+ mapping = {
+ "left": "L",
+ "center": "C",
+ "right": "R",
+ "justify": "J",
+ }
+ return mapping.get(alignment, "L")
+
+
+def pdf_add_text(
+ doc: PDFDoc,
+ text: str,
+ style: PDFStyle | None = None,
+ x: float | None = None,
+ y: float | None = None,
+ width: float | None = None,
+) -> PDFDoc:
+ """Escribe un bloque de texto en el documento PDF con el estilo dado.
+
+ Posiciona el cursor en (x, y) si se especifican, o continua desde
+ la posicion actual. Aplica fuente, tamaño, color y alineacion del estilo.
+ Respeta saltos de linea en el texto.
+
+ Args:
+ doc: PDFDoc con una pagina activa.
+ text: texto a escribir. Soporta saltos de linea con '\\n'.
+ style: PDFStyle con fuente, tamaño, color, alineacion. None usa defaults.
+ x: posicion X en mm. None mantiene la posicion actual (margen izquierdo).
+ y: posicion Y en mm. None mantiene la posicion actual (posicion del cursor).
+ width: ancho del bloque de texto en mm. None usa el ancho disponible.
+
+ Returns:
+ PDFDoc con el texto añadido (mismo objeto modificado).
+ """
+ if style is None:
+ style = PDFStyle()
+
+ fpdf = doc.fpdf
+
+ # Aplicar margen superior
+ if style.margin_top > 0:
+ fpdf.ln(style.margin_top)
+
+ # Posicion
+ if x is not None or y is not None:
+ cur_x = x if x is not None else fpdf.get_x()
+ cur_y = y if y is not None else fpdf.get_y()
+ fpdf.set_xy(cur_x, cur_y)
+
+ # Fuente
+ family = style.font_family
+ # Detectar bold/italic del nombre
+ bold = "-Bold" in family or "-BoldOblique" in family
+ italic = "-Oblique" in family or "-BoldOblique" in family
+ base_family = family.split("-")[0]
+ style_str = ""
+ if bold:
+ style_str += "B"
+ if italic:
+ style_str += "I"
+ fpdf.set_font(base_family, style=style_str, size=style.font_size)
+
+ # Color
+ r, g, b = _parse_hex_color(style.color)
+ fpdf.set_text_color(r, g, b)
+
+ # Alineacion
+ align = _align_char(style.alignment)
+
+ # Ancho disponible
+ cell_width = width if width is not None else (fpdf.epw)
+
+ # Altura de linea
+ line_h = style.font_size * style.line_height * 0.352778 # pt a mm aprox
+
+ # Escribir texto con multi_cell para soportar saltos de linea y wrap
+ fpdf.multi_cell(w=cell_width, h=line_h, text=text, align=align)
+
+ # Margen inferior
+ if style.margin_bottom > 0:
+ fpdf.ln(style.margin_bottom)
+
+ return doc
diff --git a/python/functions/infra/pdf_add_text_test.py b/python/functions/infra/pdf_add_text_test.py
new file mode 100644
index 00000000..4c7b873b
--- /dev/null
+++ b/python/functions/infra/pdf_add_text_test.py
@@ -0,0 +1,42 @@
+"""Tests para pdf_add_text."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra"))
+
+from pdf_create import pdf_create
+from pdf_add_page import pdf_add_page
+from pdf_add_text import pdf_add_text
+from pdf_save import pdf_save
+from pdf_doc import PDFDoc
+from pdf_style import PDFStyle
+
+
+def test_texto_simple_con_estilo_defecto():
+ doc = pdf_create(title="Test")
+ doc = pdf_add_page(doc)
+ doc = pdf_add_text(doc, "Hola mundo")
+ assert isinstance(doc, PDFDoc)
+ # El PDF debe ser valido
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
+
+
+def test_texto_con_estilo_personalizado_bold_centrado():
+ doc = pdf_create()
+ doc = pdf_add_page(doc)
+ style = PDFStyle(font_family="Helvetica-Bold", font_size=18.0, alignment="center", color="#FF0000")
+ doc = pdf_add_text(doc, "Titulo en Rojo", style=style)
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
+
+
+def test_texto_multilinea_con_saltos():
+ doc = pdf_create()
+ doc = pdf_add_page(doc)
+ doc = pdf_add_text(doc, "Linea 1\nLinea 2\nLinea 3")
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
+ assert len(pdf_bytes) > 100
diff --git a/python/functions/infra/pdf_create.md b/python/functions/infra/pdf_create.md
new file mode 100644
index 00000000..7a3a115c
--- /dev/null
+++ b/python/functions/infra/pdf_create.md
@@ -0,0 +1,59 @@
+---
+name: pdf_create
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_create(title, author, subject, default_font, margin_left, margin_right, margin_top, margin_bottom) -> PDFDoc"
+description: "Inicializa un documento PDF nuevo usando fpdf2. Crea un objeto FPDF configurado con metadatos y margenes. Retorna un PDFDoc listo para añadir paginas con pdf_add_page."
+tags: [pdf, create, fpdf2, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra]
+returns: [pdf_doc_py_infra]
+returns_optional: false
+error_type: "error_go_core"
+imports: [fpdf2]
+params:
+ - name: title
+ desc: "titulo del documento, aparece en metadata del PDF reader"
+ - name: author
+ desc: "autor del documento (metadata)"
+ - name: subject
+ desc: "asunto del documento (metadata)"
+ - name: default_font
+ desc: "fuente por defecto: Helvetica, Times, Courier (built-ins sin .ttf)"
+ - name: margin_left
+ desc: "margen izquierdo en mm, por defecto 20"
+ - name: margin_right
+ desc: "margen derecho en mm, por defecto 20"
+ - name: margin_top
+ desc: "margen superior en mm, por defecto 20"
+ - name: margin_bottom
+ desc: "margen inferior (page break) en mm, por defecto 20"
+output: "PDFDoc inicializado con objeto fpdf2 configurado, listo para pdf_add_page"
+tested: true
+tests: ["crear documento con titulo y autor", "margenes personalizados aplicados"]
+test_file_path: "python/functions/infra/pdf_create_test.py"
+file_path: "python/functions/infra/pdf_create.py"
+---
+
+## Ejemplo
+
+```python
+import sys
+sys.path.insert(0, "python/functions/infra")
+sys.path.insert(0, "python/types/infra")
+
+from pdf_create import pdf_create
+from pdf_add_page import pdf_add_page
+from pdf_save import pdf_save
+
+doc = pdf_create(title="Mi Reporte", author="Agente")
+doc = pdf_add_page(doc)
+pdf_save(doc, "reporte.pdf")
+```
+
+## Notas
+
+Funcion impura: inicializa un objeto FPDF con estado mutable. Usa una subclase interna `_PDFWithHeaderFooter` que sobreescribe `header()` y `footer()` de FPDF para soportar header/footer recurrente configurado via `pdf_add_header_footer`. Los margenes se aplican con `set_margins` y `set_auto_page_break`. Requiere `fpdf2` instalado (`uv add fpdf2`).
diff --git a/python/functions/infra/pdf_create.py b/python/functions/infra/pdf_create.py
new file mode 100644
index 00000000..d5586462
--- /dev/null
+++ b/python/functions/infra/pdf_create.py
@@ -0,0 +1,94 @@
+"""pdf_create — inicializa un documento PDF nuevo con fpdf2."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types"))
+
+from fpdf import FPDF
+
+# Import type from types directory
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+
+
+class _PDFWithHeaderFooter(FPDF):
+ """Subclase de FPDF con soporte para header/footer recurrente."""
+
+ def __init__(self, doc_ref: PDFDoc, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._doc_ref = doc_ref
+
+ def header(self):
+ if not self._doc_ref.header_text:
+ return
+ self.set_font(self._doc_ref.default_font, size=9)
+ self.set_text_color(100, 100, 100)
+ self.cell(0, 8, self._doc_ref.header_text, align="C", new_x="LMARGIN", new_y="NEXT")
+ self.ln(2)
+
+ def footer(self):
+ if not self._doc_ref.footer_text and not self._doc_ref.page_numbers:
+ return
+ self.set_y(-15)
+ self.set_font(self._doc_ref.default_font, size=9)
+ self.set_text_color(100, 100, 100)
+ text = self._doc_ref.footer_text
+ if self._doc_ref.page_numbers:
+ if "{n}" in text or "{total}" in text:
+ text = text.replace("{n}", str(self.page_no())).replace("{total}", "{nb}")
+ else:
+ text = f"Pagina {self.page_no()} de {{nb}}" if not text else text
+ self.cell(0, 10, text, align="C")
+
+
+def pdf_create(
+ title: str = "",
+ author: str = "",
+ subject: str = "",
+ default_font: str = "Helvetica",
+ margin_left: float = 20.0,
+ margin_right: float = 20.0,
+ margin_top: float = 20.0,
+ margin_bottom: float = 20.0,
+) -> PDFDoc:
+ """Inicializa un documento PDF nuevo usando fpdf2.
+
+ Crea un objeto FPDF configurado con los margenes y metadatos especificados.
+ Retorna un PDFDoc listo para añadir paginas con pdf_add_page.
+
+ Args:
+ title: titulo del documento (aparece en metadata del PDF reader).
+ author: autor del documento (metadata).
+ subject: asunto del documento (metadata).
+ default_font: fuente por defecto. Built-ins: Helvetica, Times, Courier.
+ margin_left: margen izquierdo en mm. Por defecto 20.
+ margin_right: margen derecho en mm. Por defecto 20.
+ margin_top: margen superior en mm. Por defecto 20.
+ margin_bottom: margen inferior en mm. Por defecto 20.
+
+ Returns:
+ PDFDoc inicializado con el objeto fpdf2 configurado.
+ """
+ doc = PDFDoc(
+ title=title,
+ author=author,
+ subject=subject,
+ default_font=default_font,
+ margin_left=margin_left,
+ margin_right=margin_right,
+ margin_top=margin_top,
+ margin_bottom=margin_bottom,
+ )
+
+ fpdf = _PDFWithHeaderFooter(doc_ref=doc)
+ fpdf.set_margins(margin_left, margin_top, margin_right)
+ fpdf.set_auto_page_break(auto=True, margin=margin_bottom)
+ fpdf.set_title(title)
+ fpdf.set_author(author)
+ fpdf.set_subject(subject)
+ fpdf.set_creator("fn-registry/pdf_create")
+
+ doc.fpdf = fpdf
+ return doc
diff --git a/python/functions/infra/pdf_create_test.py b/python/functions/infra/pdf_create_test.py
new file mode 100644
index 00000000..446abdaa
--- /dev/null
+++ b/python/functions/infra/pdf_create_test.py
@@ -0,0 +1,29 @@
+"""Tests para pdf_create."""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra"))
+
+from pdf_create import pdf_create
+from pdf_doc import PDFDoc
+
+
+def test_crear_documento_con_titulo_y_autor():
+ doc = pdf_create(title="Test Title", author="Test Author")
+ assert isinstance(doc, PDFDoc)
+ assert doc.title == "Test Title"
+ assert doc.author == "Test Author"
+ assert doc.fpdf is not None
+
+
+def test_margenes_personalizados_aplicados():
+ doc = pdf_create(margin_left=15.0, margin_right=15.0, margin_top=25.0, margin_bottom=25.0)
+ assert doc.margin_left == 15.0
+ assert doc.margin_right == 15.0
+ assert doc.margin_top == 25.0
+ assert doc.margin_bottom == 25.0
+ # Verificar que fpdf tiene los margenes aplicados
+ assert abs(doc.fpdf.l_margin - 15.0) < 0.1
+ assert abs(doc.fpdf.r_margin - 15.0) < 0.1
diff --git a/python/functions/infra/pdf_from_html.md b/python/functions/infra/pdf_from_html.md
new file mode 100644
index 00000000..1b6da555
--- /dev/null
+++ b/python/functions/infra/pdf_from_html.md
@@ -0,0 +1,67 @@
+---
+name: pdf_from_html
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_from_html(html_string, output_path, page_size, css) -> str"
+description: "Convierte un HTML string a PDF usando weasyprint con soporte completo de CSS layout. Retorna error claro si weasyprint no esta instalado (requiere dependencias de sistema: pango, cairo)."
+tags: [pdf, html, weasyprint, conversion, infra]
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: "error_go_core"
+imports: [weasyprint]
+params:
+ - name: html_string
+ desc: "contenido HTML completo como string, incluyendo head y body"
+ - name: output_path
+ desc: "ruta del archivo PDF a generar"
+ - name: page_size
+ desc: "tamaño de pagina CSS: A4, letter, A3. Por defecto A4"
+ - name: css
+ desc: "CSS adicional como string para aplicar sobre el HTML, puede incluir fuentes y layouts"
+output: "output_path con el PDF guardado en disco"
+tested: false
+tests: []
+test_file_path: ""
+file_path: "python/functions/infra/pdf_from_html.py"
+---
+
+## Ejemplo
+
+```python
+html = """
+
+
+
+ Reporte
+
+ | Dominio | Funciones |
+ | core | 45 |
+
+
+
+"""
+css = "body { font-family: sans-serif; } h1 { color: #333; }"
+output = pdf_from_html(html, "reporte.pdf", css=css)
+```
+
+## Instalacion de dependencias
+
+```bash
+# Python
+pip install weasyprint
+
+# Linux (Ubuntu/Debian)
+sudo apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
+
+# macOS
+brew install pango cairo gdk-pixbuf
+```
+
+## Notas
+
+Stub activo: si `weasyprint` no esta instalado, lanza `RuntimeError` con instrucciones de instalacion claras. Para la mayoria de reportes simples, preferir el builder fpdf2 (`pdf_create` + `pdf_add_text` + `pdf_add_table`) que no requiere dependencias de sistema. weasyprint es util para HTML complejo con CSS avanzado (columnas, grid, fuentes web, estilos corporativos).
diff --git a/python/functions/infra/pdf_from_html.py b/python/functions/infra/pdf_from_html.py
new file mode 100644
index 00000000..21171a9b
--- /dev/null
+++ b/python/functions/infra/pdf_from_html.py
@@ -0,0 +1,49 @@
+"""pdf_from_html — convierte HTML string a PDF usando weasyprint (stub si no disponible)."""
+
+
+def pdf_from_html(
+ html_string: str,
+ output_path: str,
+ page_size: str = "A4",
+ css: str = "",
+) -> str:
+ """Convierte un HTML string a PDF usando weasyprint.
+
+ Soporta CSS inline y externo (pasado como string). Produce PDFs de alta
+ calidad tipografica con soporte completo de CSS layout (columnas, grids,
+ fuentes web).
+
+ NOTA: Requiere weasyprint y sus dependencias de sistema (pango, cairo,
+ gdk-pixbuf). Instalar con: pip install weasyprint
+ En Linux: apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
+
+ Args:
+ html_string: contenido HTML completo como string.
+ output_path: ruta donde guardar el PDF generado.
+ page_size: tamaño de pagina CSS: 'A4', 'letter', 'A3'. Por defecto 'A4'.
+ css: CSS adicional como string para aplicar sobre el HTML.
+
+ Returns:
+ output_path con el PDF guardado.
+
+ Raises:
+ RuntimeError: si weasyprint no esta instalado.
+ """
+ try:
+ import weasyprint
+ except ImportError as exc:
+ raise RuntimeError(
+ "weasyprint no esta instalado. "
+ "Instalar con: pip install weasyprint\n"
+ "Dependencias de sistema (Linux): "
+ "apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0"
+ ) from exc
+
+ page_css = f"@page {{ size: {page_size}; }}"
+ full_css = page_css + "\n" + css if css else page_css
+
+ html = weasyprint.HTML(string=html_string)
+ stylesheet = weasyprint.CSS(string=full_css)
+ html.write_pdf(output_path, stylesheets=[stylesheet])
+
+ return output_path
diff --git a/python/functions/infra/pdf_from_markdown.md b/python/functions/infra/pdf_from_markdown.md
new file mode 100644
index 00000000..490a6cc2
--- /dev/null
+++ b/python/functions/infra/pdf_from_markdown.md
@@ -0,0 +1,65 @@
+---
+name: pdf_from_markdown
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_from_markdown(markdown_string, output_path, css, page_size) -> str"
+description: "Convierte un string Markdown a PDF via HTML + weasyprint. Incluye CSS por defecto para tipografia legible. Soporta tablas, codigo y headers. Requiere weasyprint y markdown instalados."
+tags: [pdf, markdown, conversion, weasyprint, infra]
+uses_functions: [pdf_from_html_py_infra]
+uses_types: []
+returns: []
+returns_optional: false
+error_type: "error_go_core"
+imports: [weasyprint, markdown]
+params:
+ - name: markdown_string
+ desc: "contenido Markdown como string; soporta tablas GFM, codigo cercado y listas"
+ - name: output_path
+ desc: "ruta del archivo PDF a generar"
+ - name: css
+ desc: "CSS adicional a añadir sobre los estilos por defecto (tipografia, tablas, codigo)"
+ - name: page_size
+ desc: "tamaño de pagina: A4, letter. Por defecto A4"
+output: "output_path con el PDF generado en disco"
+tested: false
+tests: []
+test_file_path: ""
+file_path: "python/functions/infra/pdf_from_markdown.py"
+---
+
+## Ejemplo
+
+```python
+md_content = """
+# Especificacion de la API
+
+## Endpoints
+
+| Metodo | Path | Descripcion |
+|--------|------|-------------|
+| GET | /fn | Lista funciones |
+| POST | /fn | Crea funcion |
+
+## Notas
+
+Usar `Authorization: Bearer ` en todos los requests.
+"""
+
+output = pdf_from_markdown(md_content, "api_spec.pdf")
+```
+
+## Instalacion
+
+```bash
+pip install weasyprint markdown
+
+# Linux
+sudo apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
+```
+
+## Notas
+
+Usa las extensiones `tables`, `fenced_code` y `nl2br` de la libreria `markdown`. El CSS por defecto incluye estilos para: tipografia base (Helvetica 11pt), headers jerarquicos, bloques de codigo con fondo gris, tablas con bordes y blockquotes. Stub activo: lanza `RuntimeError` con instrucciones si alguna dependencia falta.
diff --git a/python/functions/infra/pdf_from_markdown.py b/python/functions/infra/pdf_from_markdown.py
new file mode 100644
index 00000000..35270366
--- /dev/null
+++ b/python/functions/infra/pdf_from_markdown.py
@@ -0,0 +1,92 @@
+"""pdf_from_markdown — convierte Markdown a HTML y luego a PDF usando weasyprint."""
+
+import sys
+import os
+
+_infra_dir = os.path.dirname(__file__)
+sys.path.insert(0, _infra_dir)
+
+
+def pdf_from_markdown(
+ markdown_string: str,
+ output_path: str,
+ css: str = "",
+ page_size: str = "A4",
+) -> str:
+ """Convierte un string Markdown a PDF via HTML + weasyprint.
+
+ Convierte Markdown a HTML con la libreria `markdown`, luego genera
+ el PDF con weasyprint. Incluye CSS por defecto para tipografia legible.
+
+ NOTA: Requiere `weasyprint` y `markdown` instalados:
+ pip install weasyprint markdown
+ Y dependencias de sistema weasyprint: pango, cairo, gdk-pixbuf.
+
+ Args:
+ markdown_string: contenido Markdown como string.
+ output_path: ruta donde guardar el PDF generado.
+ css: CSS adicional a aplicar sobre los estilos por defecto.
+ page_size: tamaño de pagina: 'A4', 'letter'. Por defecto 'A4'.
+
+ Returns:
+ output_path con el PDF guardado.
+
+ Raises:
+ RuntimeError: si weasyprint o markdown no estan instalados.
+ """
+ try:
+ import markdown as md_lib
+ except ImportError as exc:
+ raise RuntimeError(
+ "markdown no esta instalado. Instalar con: pip install markdown"
+ ) from exc
+
+ try:
+ import weasyprint
+ except ImportError as exc:
+ raise RuntimeError(
+ "weasyprint no esta instalado. "
+ "Instalar con: pip install weasyprint\n"
+ "Dependencias de sistema (Linux): "
+ "apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0"
+ ) from exc
+
+ # Convertir Markdown a HTML
+ html_body = md_lib.markdown(
+ markdown_string,
+ extensions=["tables", "fenced_code", "nl2br"],
+ )
+
+ default_css = """
+ body {
+ font-family: Helvetica, Arial, sans-serif;
+ font-size: 11pt;
+ line-height: 1.5;
+ color: #222;
+ max-width: 170mm;
+ margin: 0 auto;
+ }
+ h1, h2, h3 { color: #333; margin-top: 1.2em; }
+ h1 { font-size: 18pt; border-bottom: 1px solid #ccc; }
+ h2 { font-size: 14pt; }
+ h3 { font-size: 12pt; }
+ code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 9pt; }
+ pre { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow: auto; }
+ table { border-collapse: collapse; width: 100%; }
+ th, td { border: 1px solid #ccc; padding: 6px; text-align: left; }
+ th { background: #eee; }
+ blockquote { border-left: 3px solid #ccc; margin: 0; padding-left: 12px; color: #555; }
+ """
+
+ page_css = f"@page {{ size: {page_size}; margin: 20mm; }}"
+ combined_css = page_css + "\n" + default_css + "\n" + css
+
+ html = f"""
+
+{html_body}"""
+
+ wp_html = weasyprint.HTML(string=html)
+ stylesheet = weasyprint.CSS(string=combined_css)
+ wp_html.write_pdf(output_path, stylesheets=[stylesheet])
+
+ return output_path
diff --git a/python/functions/infra/pdf_merge.md b/python/functions/infra/pdf_merge.md
new file mode 100644
index 00000000..a5999f64
--- /dev/null
+++ b/python/functions/infra/pdf_merge.md
@@ -0,0 +1,40 @@
+---
+name: pdf_merge
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_merge(pdf_paths, output_path) -> str"
+description: "Fusiona una lista de archivos PDF en un unico PDF combinado usando pypdf. Mantiene el orden de la lista. Lanza FileNotFoundError si alguno de los archivos no existe."
+tags: [pdf, merge, combine, pypdf, infra]
+uses_functions: []
+uses_types: []
+returns: []
+returns_optional: false
+error_type: "error_go_core"
+imports: [pypdf]
+params:
+ - name: pdf_paths
+ desc: "lista de rutas a los archivos PDF a fusionar, en el orden deseado"
+ - name: output_path
+ desc: "ruta del archivo PDF combinado a generar"
+output: "output_path con todos los PDFs fusionados en orden"
+tested: true
+tests: ["fusionar dos PDFs", "error si archivo no existe", "error si lista vacia"]
+test_file_path: "python/functions/infra/pdf_merge_test.py"
+file_path: "python/functions/infra/pdf_merge.py"
+---
+
+## Ejemplo
+
+```python
+# Fusionar capitulos en un documento unico
+paths = ["cap1.pdf", "cap2.pdf", "cap3.pdf", "apendice.pdf"]
+output = pdf_merge(paths, "documento_completo.pdf")
+print(f"PDF generado: {output}")
+```
+
+## Notas
+
+Usa `pypdf` (sucesor de PyPDF2). El merge copia pagina a pagina — preserva contenido pero puede perder algunos metadatos avanzados (formularios interactivos, bookmarks anidados). Para PDFs protegidos con contrasena, pypdf necesita que se pase la password en el `PdfReader`. La lista debe tener al menos un elemento o lanza `ValueError`.
diff --git a/python/functions/infra/pdf_merge.py b/python/functions/infra/pdf_merge.py
new file mode 100644
index 00000000..b00aae2f
--- /dev/null
+++ b/python/functions/infra/pdf_merge.py
@@ -0,0 +1,50 @@
+"""pdf_merge — fusiona multiples archivos PDF en uno solo usando pypdf."""
+
+import os
+
+
+def pdf_merge(
+ pdf_paths: list[str],
+ output_path: str,
+) -> str:
+ """Fusiona una lista de archivos PDF en un unico PDF.
+
+ Une los PDFs en el orden de la lista. Requiere pypdf instalado.
+
+ Args:
+ pdf_paths: lista de rutas a los archivos PDF a fusionar (en orden).
+ output_path: ruta del archivo PDF combinado a generar.
+
+ Returns:
+ output_path con el PDF fusionado guardado.
+
+ Raises:
+ FileNotFoundError: si alguno de los archivos no existe.
+ RuntimeError: si pypdf no esta instalado.
+ ValueError: si pdf_paths esta vacio.
+ """
+ if not pdf_paths:
+ raise ValueError("pdf_paths no puede estar vacio")
+
+ for path in pdf_paths:
+ if not os.path.exists(path):
+ raise FileNotFoundError(f"PDF no encontrado: {path}")
+
+ try:
+ from pypdf import PdfWriter, PdfReader
+ except ImportError as exc:
+ raise RuntimeError(
+ "pypdf no esta instalado. Instalar con: pip install pypdf"
+ ) from exc
+
+ writer = PdfWriter()
+
+ for path in pdf_paths:
+ reader = PdfReader(path)
+ for page in reader.pages:
+ writer.add_page(page)
+
+ with open(output_path, "wb") as f:
+ writer.write(f)
+
+ return output_path
diff --git a/python/functions/infra/pdf_merge_test.py b/python/functions/infra/pdf_merge_test.py
new file mode 100644
index 00000000..6926423e
--- /dev/null
+++ b/python/functions/infra/pdf_merge_test.py
@@ -0,0 +1,50 @@
+"""Tests para pdf_merge."""
+
+import sys
+import os
+import tempfile
+import pytest
+
+sys.path.insert(0, os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra"))
+
+from pdf_create import pdf_create
+from pdf_add_page import pdf_add_page
+from pdf_add_text import pdf_add_text
+from pdf_save import pdf_save
+from pdf_merge import pdf_merge
+
+
+def _create_pdf(path: str, text: str) -> str:
+ doc = pdf_create(title=text)
+ doc = pdf_add_page(doc)
+ doc = pdf_add_text(doc, text)
+ return pdf_save(doc, path)
+
+
+def test_fusionar_dos_pdfs():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ p1 = _create_pdf(os.path.join(tmpdir, "a.pdf"), "Documento A")
+ p2 = _create_pdf(os.path.join(tmpdir, "b.pdf"), "Documento B")
+ out = os.path.join(tmpdir, "merged.pdf")
+ result = pdf_merge([p1, p2], out)
+ assert result == out
+ assert os.path.exists(out)
+ # Verificar que el PDF combinado es valido y mas grande que cada parte
+ with open(out, "rb") as f:
+ content = f.read()
+ assert content[:4] == b"%PDF"
+
+
+def test_error_si_archivo_no_existe():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ out = os.path.join(tmpdir, "merged.pdf")
+ with pytest.raises(FileNotFoundError):
+ pdf_merge(["/no/existe.pdf"], out)
+
+
+def test_error_si_lista_vacia():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ out = os.path.join(tmpdir, "merged.pdf")
+ with pytest.raises(ValueError):
+ pdf_merge([], out)
diff --git a/python/functions/infra/pdf_save.md b/python/functions/infra/pdf_save.md
new file mode 100644
index 00000000..7461787d
--- /dev/null
+++ b/python/functions/infra/pdf_save.md
@@ -0,0 +1,46 @@
+---
+name: pdf_save
+kind: function
+lang: py
+domain: infra
+version: "1.0.0"
+purity: impure
+signature: "def pdf_save(doc, output_path) -> str | bytes"
+description: "Serializa el documento PDF a disco o retorna los bytes en memoria. Si output_path es None retorna bytes para transmision directa (HTTP response, almacenamiento en BD). Si se especifica, escribe el archivo y retorna el path."
+tags: [pdf, save, output, builder, infra]
+uses_functions: []
+uses_types: [pdf_doc_py_infra]
+returns: []
+returns_optional: false
+error_type: "error_go_core"
+imports: []
+params:
+ - name: doc
+ desc: "PDFDoc con al menos una pagina y contenido añadido"
+ - name: output_path
+ desc: "ruta del archivo PDF a crear en disco; None retorna bytes del PDF en memoria"
+output: "output_path (str) si se escribio a disco, o bytes del PDF si output_path es None"
+tested: true
+tests: ["guardar PDF a archivo", "retornar bytes del PDF", "PDF valido comienza con %PDF"]
+test_file_path: "python/functions/infra/pdf_save_test.py"
+file_path: "python/functions/infra/pdf_save.py"
+---
+
+## Ejemplo
+
+```python
+# Guardar a disco
+path = pdf_save(doc, "reporte.pdf")
+print(f"PDF guardado en: {path}")
+
+# Obtener bytes (para HTTP response o guardar en BD)
+pdf_bytes = pdf_save(doc)
+assert pdf_bytes[:4] == b"%PDF"
+
+# En un endpoint web (ejemplo conceptual)
+return Response(pdf_save(doc), content_type="application/pdf")
+```
+
+## Notas
+
+Llama a `fpdf2.output()`. Si el documento usa `alias_nb_pages()` (activado por `pdf_add_header_footer` con `page_numbers=True`), fpdf2 reemplaza el alias con el total real de paginas al serializar. Para retornar bytes, `output()` retorna un `bytearray` que se convierte a `bytes` para compatibilidad con APIs que esperan `bytes`.
diff --git a/python/functions/infra/pdf_save.py b/python/functions/infra/pdf_save.py
new file mode 100644
index 00000000..deb7bfea
--- /dev/null
+++ b/python/functions/infra/pdf_save.py
@@ -0,0 +1,31 @@
+"""pdf_save — guarda el documento PDF a un archivo o retorna bytes."""
+
+import sys
+import os
+
+_types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
+sys.path.insert(0, _types_dir)
+from pdf_doc import PDFDoc
+
+
+def pdf_save(
+ doc: PDFDoc,
+ output_path: str | None = None,
+) -> str | bytes:
+ """Guarda el documento PDF a un archivo o retorna los bytes del PDF.
+
+ Si se especifica output_path, escribe el PDF en disco y retorna el path.
+ Si output_path es None, retorna los bytes del PDF sin escribir a disco.
+
+ Args:
+ doc: PDFDoc con al menos una pagina de contenido.
+ output_path: ruta del archivo PDF a crear. None retorna bytes.
+
+ Returns:
+ output_path si se especifico, o bytes del PDF si output_path es None.
+ """
+ if output_path is not None:
+ doc.fpdf.output(output_path)
+ return output_path
+ else:
+ return bytes(doc.fpdf.output())
diff --git a/python/functions/infra/pdf_save_test.py b/python/functions/infra/pdf_save_test.py
new file mode 100644
index 00000000..062dace3
--- /dev/null
+++ b/python/functions/infra/pdf_save_test.py
@@ -0,0 +1,43 @@
+"""Tests para pdf_save."""
+
+import sys
+import os
+import tempfile
+
+sys.path.insert(0, os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra"))
+
+from pdf_create import pdf_create
+from pdf_add_page import pdf_add_page
+from pdf_add_text import pdf_add_text
+from pdf_save import pdf_save
+
+
+def _make_simple_doc():
+ doc = pdf_create(title="Test Save")
+ doc = pdf_add_page(doc)
+ doc = pdf_add_text(doc, "Contenido de prueba")
+ return doc
+
+
+def test_guardar_pdf_a_archivo():
+ doc = _make_simple_doc()
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = os.path.join(tmpdir, "test.pdf")
+ result = pdf_save(doc, path)
+ assert result == path
+ assert os.path.exists(path)
+ assert os.path.getsize(path) > 0
+
+
+def test_retornar_bytes_del_pdf():
+ doc = _make_simple_doc()
+ result = pdf_save(doc)
+ assert isinstance(result, bytes)
+ assert len(result) > 0
+
+
+def test_pdf_valido_comienza_con_pdf():
+ doc = _make_simple_doc()
+ pdf_bytes = pdf_save(doc)
+ assert pdf_bytes[:4] == b"%PDF"
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 1f3b4879..16318a86 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -6,10 +6,12 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"cryptography>=46.0.6",
+ "fpdf2>=2.8.7",
"google-cloud-bigquery>=3.25",
"google-cloud-bigquery-storage>=2.27",
"httpx",
"openpyxl>=3.1.5",
+ "pypdf>=6.10.0",
"python-docx>=1.2.0",
"xlrd>=2.0.2",
]
diff --git a/python/types/infra/pdf_doc.md b/python/types/infra/pdf_doc.md
new file mode 100644
index 00000000..fc81e2fe
--- /dev/null
+++ b/python/types/infra/pdf_doc.md
@@ -0,0 +1,55 @@
+---
+name: pdf_doc
+lang: py
+domain: infra
+version: "1.0.0"
+algebraic: product
+definition: |
+ @dataclass
+ class PDFDoc:
+ fpdf: Any = None
+ pages: list = field(default_factory=list)
+ title: str = ""
+ author: str = ""
+ subject: str = ""
+ default_font: str = "Helvetica"
+ margin_left: float = 20.0
+ margin_right: float = 20.0
+ margin_top: float = 20.0
+ margin_bottom: float = 20.0
+ header_text: str = ""
+ footer_text: str = ""
+ page_numbers: bool = False
+description: "Wrapper de documento PDF generado con fpdf2. Contiene el objeto FPDF interno con el estado mutable del documento y la configuracion de metadatos, margenes, header y footer."
+tags: [pdf, document, builder, fpdf2, infra]
+uses_types: []
+file_path: "python/types/infra/pdf_doc.py"
+---
+
+## Campos
+
+- `fpdf` — objeto `fpdf2.FPDF` con el estado interno del documento. Se inicializa en `pdf_create`. No serializable directamente — usar `pdf_save` para obtener bytes o archivo.
+- `pages` — lista de dicts con metadata de cada pagina añadida: `{orientation, width, height}`.
+- `title` — titulo del documento (metadata PDF, visible en propiedades del PDF reader).
+- `author` — autor del documento (metadata PDF).
+- `subject` — asunto del documento (metadata PDF).
+- `default_font` — fuente por defecto para texto: `Helvetica`, `Times`, `Courier`. Por defecto `Helvetica`.
+- `margin_left`, `margin_right`, `margin_top`, `margin_bottom` — margenes del documento en milimetros. Por defecto `20.0` mm cada uno.
+- `header_text` — texto a mostrar en el header de cada pagina. Vacio = sin header.
+- `footer_text` — texto del footer. Acepta `{n}` y `{total}` como placeholders. Vacio = sin footer.
+- `page_numbers` — si `True`, incluye numero de pagina en el footer.
+
+## Ejemplo
+
+```python
+from pdf_doc import PDFDoc
+
+# El doc se crea via pdf_create, no directamente
+# doc = pdf_create(title="Mi Reporte", author="Agente")
+# doc.title => "Mi Reporte"
+# doc.fpdf =>
+```
+
+## Notas
+
+Este tipo es el carrier del estado del builder. Todas las funciones `pdf_add_*` reciben un `PDFDoc` y retornan uno modificado (API fluent). El campo `fpdf` contiene el estado mutable — no es thread-safe. Creado exclusivamente via `pdf_create_py_infra`.
diff --git a/python/types/infra/pdf_doc.py b/python/types/infra/pdf_doc.py
new file mode 100644
index 00000000..1c14c295
--- /dev/null
+++ b/python/types/infra/pdf_doc.py
@@ -0,0 +1,28 @@
+"""PDFDoc — wrapper de documento PDF sobre fpdf2."""
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass
+class PDFDoc:
+ """Wrapper de documento PDF generado con fpdf2.
+
+ Contiene el objeto FPDF interno con el estado del documento
+ y la configuracion de metadatos y margenes del documento.
+ La lista `pages` registra las paginas añadidas como metadata.
+ """
+
+ fpdf: Any = None # objeto fpdf2.FPDF — estado mutable del documento
+ pages: list = field(default_factory=list) # lista de dicts con metadata de cada pagina
+ title: str = ""
+ author: str = ""
+ subject: str = ""
+ default_font: str = "Helvetica"
+ margin_left: float = 20.0 # mm
+ margin_right: float = 20.0 # mm
+ margin_top: float = 20.0 # mm
+ margin_bottom: float = 20.0 # mm
+ header_text: str = "" # texto del header recurrente (vacio = sin header)
+ footer_text: str = "" # texto del footer recurrente (vacio = sin footer)
+ page_numbers: bool = False # mostrar numeracion de pagina en footer
diff --git a/python/types/infra/pdf_page.md b/python/types/infra/pdf_page.md
new file mode 100644
index 00000000..7dedef92
--- /dev/null
+++ b/python/types/infra/pdf_page.md
@@ -0,0 +1,42 @@
+---
+name: pdf_page
+lang: py
+domain: infra
+version: "1.0.0"
+algebraic: product
+definition: |
+ @dataclass
+ class PDFPage:
+ width: float = 210.0
+ height: float = 297.0
+ orientation: str = "portrait"
+description: "Configuracion de una pagina dentro de un documento PDF. Define dimensiones y orientacion en milimetros. Por defecto A4 vertical (210 x 297 mm)."
+tags: [pdf, page, layout, infra]
+uses_types: []
+file_path: "python/types/infra/pdf_page.py"
+---
+
+## Campos
+
+- `width` — ancho de la pagina en milimetros. A4 = 210.0, Letter = 215.9. Por defecto `210.0`.
+- `height` — alto de la pagina en milimetros. A4 = 297.0, Letter = 279.4. Por defecto `297.0`.
+- `orientation` — orientacion de la pagina: `portrait` (vertical) o `landscape` (horizontal). Por defecto `portrait`.
+
+## Ejemplo
+
+```python
+from pdf_page import PDFPage
+
+# A4 vertical (defecto)
+pagina_a4 = PDFPage()
+
+# A4 horizontal
+pagina_horizontal = PDFPage(width=297.0, height=210.0, orientation="landscape")
+
+# Carta americana
+carta = PDFPage(width=215.9, height=279.4)
+```
+
+## Notas
+
+Tipo producto de solo configuracion. No tiene comportamiento — es metadata de layout. Las dimensiones se pasan a fpdf2 al llamar `add_page()`.
diff --git a/python/types/infra/pdf_page.py b/python/types/infra/pdf_page.py
new file mode 100644
index 00000000..ce8bbc5d
--- /dev/null
+++ b/python/types/infra/pdf_page.py
@@ -0,0 +1,16 @@
+"""PDFPage — configuracion de pagina para PDF."""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class PDFPage:
+ """Configuracion de una pagina dentro de un documento PDF.
+
+ Define dimensiones y orientacion. Las medidas son en milimetros.
+ Por defecto A4 vertical (210 x 297 mm).
+ """
+
+ width: float = 210.0 # mm — A4 ancho
+ height: float = 297.0 # mm — A4 alto
+ orientation: str = "portrait" # portrait, landscape
diff --git a/python/types/infra/pdf_style.md b/python/types/infra/pdf_style.md
new file mode 100644
index 00000000..371812e9
--- /dev/null
+++ b/python/types/infra/pdf_style.md
@@ -0,0 +1,50 @@
+---
+name: pdf_style
+lang: py
+domain: infra
+version: "1.0.0"
+algebraic: product
+definition: |
+ @dataclass
+ class PDFStyle:
+ font_family: str = "Helvetica"
+ font_size: float = 12.0
+ color: str = "#000000"
+ alignment: str = "left"
+ line_height: float = 1.2
+ margin_top: float = 0.0
+ margin_bottom: float = 2.0
+description: "Configuracion de estilo de texto para generacion de PDF. Agrupa fuente, tamaño, color, alineacion y margenes verticales para aplicar a bloques de texto y celdas de tabla."
+tags: [pdf, style, typography, infra]
+uses_types: []
+file_path: "python/types/infra/pdf_style.py"
+---
+
+## Campos
+
+- `font_family` — nombre de fuente fpdf2: `Helvetica`, `Helvetica-Bold`, `Helvetica-Oblique`, `Times`, `Courier`. Por defecto `Helvetica`.
+- `font_size` — tamaño en puntos tipograficos. Por defecto `12.0`.
+- `color` — color hex RGB (ej: `"#000000"` negro, `"#FF0000"` rojo). Por defecto negro.
+- `alignment` — alineacion horizontal del texto: `left`, `center`, `right`, `justify`. Por defecto `left`.
+- `line_height` — multiplicador de interlineado respecto al tamaño de fuente. `1.2` da un interlineado comodo.
+- `margin_top` — espacio vertical en mm antes del bloque de texto. Por defecto `0.0`.
+- `margin_bottom` — espacio vertical en mm despues del bloque de texto. Por defecto `2.0`.
+
+## Ejemplo
+
+```python
+from pdf_style import PDFStyle
+
+# Titulo grande centrado
+titulo = PDFStyle(font_family="Helvetica-Bold", font_size=22.0, alignment="center")
+
+# Texto de pie en gris
+pie = PDFStyle(font_size=9.0, color="#666666")
+
+# Cuerpo justificado con margen
+cuerpo = PDFStyle(font_size=11.0, alignment="justify", margin_bottom=4.0)
+```
+
+## Notas
+
+Tipo producto inmutable por convencion. Las fuentes built-in de fpdf2 (`Helvetica`, `Times`, `Courier` y sus variantes `-Bold`, `-Oblique`, `-BoldOblique`) no requieren archivos .ttf externos. Para fuentes custom, registrarlas en fpdf2 antes de usarlas.
diff --git a/python/types/infra/pdf_style.py b/python/types/infra/pdf_style.py
new file mode 100644
index 00000000..634e7c55
--- /dev/null
+++ b/python/types/infra/pdf_style.py
@@ -0,0 +1,20 @@
+"""PDFStyle — configuracion de estilo de texto para PDF."""
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class PDFStyle:
+ """Configuracion de estilo de texto para generacion de PDF.
+
+ Agrupa fuente, tamaño, color, alineacion y margenes verticales
+ para aplicar a bloques de texto y celdas de tabla.
+ """
+
+ font_family: str = "Helvetica"
+ font_size: float = 12.0
+ color: str = "#000000" # hex color RGB
+ alignment: str = "left" # left, center, right, justify
+ line_height: float = 1.2
+ margin_top: float = 0.0 # mm de margen antes del bloque
+ margin_bottom: float = 2.0 # mm de margen despues del bloque
diff --git a/python/uv.lock b/python/uv.lock
index 55348b0c..71ffb998 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -221,6 +221,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
]
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
+]
+
[[package]]
name = "et-xmlfile"
version = "2.0.0"
@@ -236,10 +245,12 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "cryptography" },
+ { name = "fpdf2" },
{ name = "google-cloud-bigquery" },
{ name = "google-cloud-bigquery-storage" },
{ name = "httpx" },
{ name = "openpyxl" },
+ { name = "pypdf" },
{ name = "python-docx" },
{ name = "xlrd" },
]
@@ -252,10 +263,12 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=46.0.6" },
+ { name = "fpdf2", specifier = ">=2.8.7" },
{ name = "google-cloud-bigquery", specifier = ">=3.25" },
{ name = "google-cloud-bigquery-storage", specifier = ">=2.27" },
{ name = "httpx" },
{ name = "openpyxl", specifier = ">=3.1.5" },
+ { name = "pypdf", specifier = ">=6.10.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "xlrd", specifier = ">=2.0.2" },
]
@@ -263,6 +276,61 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
+[[package]]
+name = "fonttools"
+version = "4.62.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" },
+ { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" },
+ { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" },
+ { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" },
+ { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" },
+ { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" },
+ { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" },
+ { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" },
+ { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" },
+ { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" },
+ { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" },
+ { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" },
+ { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" },
+ { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" },
+ { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" },
+]
+
+[[package]]
+name = "fpdf2"
+version = "2.8.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "defusedxml" },
+ { name = "fonttools" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/27/f2/72feae0b2827ed38013e4307b14f95bf0b3d124adfef4d38a7d57533f7be/fpdf2-2.8.7.tar.gz", hash = "sha256:7060ccee5a9c7ab0a271fb765a36a23639f83ef8996c34e3d46af0a17ede57f9", size = 362351, upload-time = "2026-02-28T05:39:16.456Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" },
+]
+
[[package]]
name = "google-api-core"
version = "2.30.2"
@@ -603,6 +671,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
+[[package]]
+name = "pillow"
+version = "12.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
+ { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
+ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -678,6 +815,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
+[[package]]
+name = "pypdf"
+version = "6.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/9f/ca96abf18683ca12602065e4ed2bec9050b672c87d317f1079abc7b6d993/pypdf-6.10.0.tar.gz", hash = "sha256:4c5a48ba258c37024ec2505f7e8fd858525f5502784a2e1c8d415604af29f6ef", size = 5314833, upload-time = "2026-04-10T09:34:57.102Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/f2/7ebe366f633f30a6ad105f650f44f24f98cb1335c4157d21ae47138b3482/pypdf-6.10.0-py3-none-any.whl", hash = "sha256:90005e959e1596c6e6c84c8b0ad383285b3e17011751cedd17f2ce8fcdfc86de", size = 334459, upload-time = "2026-04-10T09:34:54.966Z" },
+]
+
[[package]]
name = "pytest"
version = "9.0.2"