auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4

Open
dataforge wants to merge 615 commits from auto/0129 into master
37 changed files with 2278 additions and 0 deletions
Showing only changes of commit 93ae1bd497 - Show all commits
+312
View File
@@ -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
+36
View File
@@ -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",
)
}
+91
View File
@@ -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.
@@ -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.
@@ -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
+56
View File
@@ -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.
+57
View File
@@ -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
+44
View File
@@ -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).
+49
View File
@@ -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
@@ -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
+57
View File
@@ -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()`.
+128
View File
@@ -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
@@ -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"
+51
View File
@@ -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).
+107
View File
@@ -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
@@ -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
+59
View File
@@ -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`).
+94
View File
@@ -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
+29
View File
@@ -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
+67
View File
@@ -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 = """
<!DOCTYPE html>
<html>
<body>
<h1>Reporte</h1>
<table>
<tr><th>Dominio</th><th>Funciones</th></tr>
<tr><td>core</td><td>45</td></tr>
</table>
</body>
</html>
"""
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).
+49
View File
@@ -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
@@ -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 <token>` 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.
@@ -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"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body>{html_body}</body></html>"""
wp_html = weasyprint.HTML(string=html)
stylesheet = weasyprint.CSS(string=combined_css)
wp_html.write_pdf(output_path, stylesheets=[stylesheet])
return output_path
+40
View File
@@ -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`.
+50
View File
@@ -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
+50
View File
@@ -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)
+46
View File
@@ -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`.
+31
View File
@@ -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())
+43
View File
@@ -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"
+2
View File
@@ -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",
]
+55
View File
@@ -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 => <fpdf2.FPDF object>
```
## 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`.
+28
View File
@@ -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
+42
View File
@@ -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()`.
+16
View File
@@ -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
+50
View File
@@ -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.
+20
View File
@@ -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
+146
View File
@@ -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"