feat: issue/0020 — generacion de PDFs en Python y Go

Añade 3 tipos Python (PDFDoc, PDFPage, PDFStyle) y 10 funciones Python
para construir PDFs con fpdf2 (builder fluent), fusionar PDFs con pypdf
y convertir HTML/Markdown a PDF via weasyprint (stub si no disponible).
Añade pdf_simple_report en Go como stub hasta que go-pdf/fpdf se integre.

- python/types/infra/: pdf_doc, pdf_page, pdf_style
- python/functions/infra/: pdf_create, pdf_add_page, pdf_add_text,
  pdf_add_table, pdf_add_image, pdf_add_header_footer, pdf_from_html,
  pdf_from_markdown, pdf_merge, pdf_save
- functions/infra/pdf_simple_report.go: stub Go con ReportSection/ReportTable
- 17 tests Python pasando (pytest)
- fpdf2 y pypdf añadidos via uv al venv Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 02:02:51 +02:00
parent eca52b1329
commit 0819c35bbb
34 changed files with 1818 additions and 0 deletions
@@ -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"