merge: issue/0020-pdf-generation — PDF generation Python+Go
# Conflicts: # registry.db
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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()`.
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user