
Ya sabéis que llevo prácticamente toda la vida trasteando con ordenadores. A pesar de haberlo intentado, hace ya años me dí cuenta que la programación no era lo mio, que era más de informática de sistemas (redes, sistemas operativos, ciberseguridad... ese tipo de cosas). Así que he ido cultivando esta afición, que es también profesión, dejando de lado todo lo que tuviera que ver con la programación, adaptándome al software existente que otros hubieran publicado.
Pues bien, recientemente me ha surgido la necesidad de algo muy concreto: tengo en un servidor Baïkal (carddav/caldav) mis contactos y calendarios centralizados, de manera que puedo acceder a ellos desde el móvil, y desde cualquiera de los ordenadores que gestiono (habitualmente con Thunderbird). Es una solución que me funciona estupendamente. A pesar de ello, también sigo usando mi PDA ("Papel De Apuntar) :-D y todos los años me imprimo una agenda en A5 duplex, que taladro, anillo, y uso todo el año (aunque el modelo lo he ido puliendo a mi gusto año tras año, ya en 2012 os hablé de esto aquí mismo: "Mi agenda 2012").
Preparando mi agenda para el próximo año, se me ha ocurrido compactar un poco más la agenda física, e imprimirla en formato A6, que me resulta más manejable. Dado que tengo todos los cumpleaños y aniversarios apuntados como eventos recurrentes, lógicamente quería que me aparecieran en la agenda del nuevo año, así como algún que otro evento a largo plazo apuntado.
Como no sabía como hacerlo, se me ocurrió preguntarle a Google Gemini explicándole mi caso. Me dió dos opciones: imprimir desde el menú de Thunderbird, ajustando el tamaño de papel y usando una impresora virtual que volcara a PDF, o usar Python y dejar que me creara un programa para realizar la tarea. Yo indiqué que no tengo conocimientos de programación, pero la IA me dijo que no era problema, así que decidí intentarlo.
En segundos ya me había explicado como crear un entorno en mi sistema Linux Debian habitual:
Para que esto funcione en tu sistema Linux, vamos a crear un "entorno virtual" (una buena práctica para no mezclar librerías) e instalar las herramientas necesarias.
1 Instalar dependencias del sistema
Abre tu terminal. WeasyPrint (el generador de PDF) necesita unas librerías gráficas de Linux para poder dibujar las fuentes y la página correctamente. En sistemas basados en Debian/Ubuntu, ejecuta:
sudo apt update
sudo apt install python3 python3-venv libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0
Crear el entorno e instalar las librerías de Python
En la misma terminal, vamos a crear una carpeta para el proyecto, preparar el entorno virtual e instalar las 4 librerías clave que mencionamos antes:
mkdir mi_calendario
cd mi_calendario
python3 -m venv entorno
source entorno/bin/activate
pip install caldav icalendar jinja2 weasyprint
Guardar el script
_Crea un archivo llamado generar_pdf.py dentro de esa carpeta. Puedes usar un editor como nano generar_pdf.py o tu editor de texto favorito, y pega el siguiente código._
Importante: Recuerda cambiar las variables de la sección CONFIGURACIÓN con tus datos de Baïkal.
Nota: aquí os voy a abreviar el proceso de "refinado" del script. La primera vez que funcionó, el programa me sacaba una lista de los eventos anuales, uno detrás de otro (error mio, que no le dije que quería una página por día). Agregué este nuevo requerimiento y resultó que sí, ahora me imprimía una hoja por cada día, pero solo se generaban los días que tenían evetos. Por último, le comenté que quería una hoja por día, y que me generara todos los días del año, independientemente de que tuviera o no eventos archivados. Incluso tuvo iniciativa y me planteó una cabeza del tipo "lunes, 1 de mayo de 2027", es decir, que el programa averiguaba qué día de la semana era cada fecha.
Al cabo de unos 15 minutos de interactuar con el prompt, ya tenía algo bastante decente:
import datetime
from caldav import DAVClient
from icalendar import Calendar
from jinja2 import Template
from weasyprint import HTML
# --- 1. CONFIGURACIÓN ---
BAIKAL_URL = "https://miservidorbaikal/dav.php" # O dav.php dependiendo de tu instalación
USERNAME = "usuario"
PASSWORD = "clave"
YEAR = 2027
start_date = datetime.datetime(YEAR, 1, 1)
end_date = datetime.datetime(YEAR, 12, 31, 23, 59, 59)
# Nombres en español para que las fechas queden bonitas sin depender del sistema operativo
DIAS_SEMANA = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"]
def obtener_eventos():
print("Conectando a Baïkal...")
client = DAVClient(url=BAIKAL_URL, username=USERNAME, password=PASSWORD)
principal = client.principal()
calendars = principal.calendars()
if not calendars:
print("No se encontraron calendarios.")
return []
mi_calendario = calendars[0]
print(f"Buscando eventos en el calendario: {mi_calendario.get_display_name()}...")
# MEJORA 1: expand=True obliga a Baïkal a calcular las fechas reales de los eventos repetitivos
eventos_crudos = mi_calendario.search(start=start_date, end=end_date, event=True, expand=True)
# Usamos un diccionario para clasificar cada evento en su día ('YYYY-MM-DD')
eventos_por_dia = {}
for evento in eventos_crudos:
cal = Calendar.from_ical(evento.data)
for component in cal.walk('vevent'):
titulo = component.get('summary')
fecha_inicio = component.get('dtstart').dt
if not titulo or not fecha_inicio:
continue
if type(fecha_inicio) is datetime.date:
fecha_obj_sort = datetime.datetime.combine(fecha_inicio, datetime.time.min)
hora_str = "Diario"
else:
# Si el evento tiene zona horaria, lo adaptamos a la local para no "saltar" de día en eventos nocturnos
if hasattr(fecha_inicio, 'tzinfo') and fecha_inicio.tzinfo is not None:
fecha_inicio = fecha_inicio.astimezone()
fecha_obj_sort = fecha_inicio.replace(tzinfo=None)
hora_str = fecha_inicio.strftime('%H:%M')
clave_fecha = fecha_obj_sort.strftime('%Y-%m-%d')
# Guardamos el evento en la "caja" de su día
if clave_fecha not in eventos_por_dia:
eventos_por_dia[clave_fecha] = []
eventos_por_dia[clave_fecha].append({
'titulo': str(titulo),
'hora': hora_str,
'fecha_obj': fecha_obj_sort
})
# MEJORA 2: Generar todos los días del año (tengan eventos o no)
dias_completos = []
fecha_iter = start_date.date()
fecha_fin = end_date.date()
print("Procesando los 365 días del año...")
while fecha_iter <= fecha_fin:
clave_fecha = fecha_iter.strftime('%Y-%m-%d')
# Formato de texto bonito: "1 de enero, lunes"
nombre_dia = DIAS_SEMANA[fecha_iter.weekday()]
nombre_mes = MESES[fecha_iter.month - 1]
fecha_bonita = f"{fecha_iter.day} de {nombre_mes} de {YEAR}, {nombre_dia}"
# Rescatamos los eventos de este día. Si no hay nada, devuelve una lista vacía []
eventos_del_dia = eventos_por_dia.get(clave_fecha, [])
# Ordenamos los eventos que haya por hora
eventos_del_dia.sort(key=lambda x: x['fecha_obj'])
dias_completos.append({
'fecha': fecha_bonita,
'eventos': eventos_del_dia
})
# Sumamos un día para seguir el bucle
fecha_iter += datetime.timedelta(days=1)
return dias_completos
# --- 2. PLANTILLA DEL PDF (HTML + CSS) ---
html_template = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
size: A6;
/* Orden: arriba | derecha | abajo | izquierda */
margin: 1.5cm 0.5cm 0.5cm 0.5cm;
}
body {
color: #3C3C3C;
}
.pagina-dia {
page-break-after: always;
}
.pagina-dia:last-child {
page-break-after: auto;
}
.cabecera-dia {
font-family: 'NovelDisplay-Black';
background-color: #FBE643;
color: #3C3C3C;
text-align: center;
padding: 5px;
font-size: 12pt;
font-weight: bold;
border-radius: 3px;
margin-bottom: 20px;
}
.evento {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px dotted #ecf0f1;
/* disponemos los divs contenidos uno después de otro, separados por el gap */
display: flex;
gap: 10px;
}
.hora {
font-family: 'Liberation Sans Narrow';
/* font-weight: bold; */
color: #05779D;
font-size: 10pt;
}
.titulo {
font-family: 'Liberation Sans Narrow';
font-size: 10pt;
line-height: 1;
}
/* Clase para cuando no hay eventos (opcional, para dar margen) */
.dia-libre {
text-align: center;
color: #bdc3c7;
font-style: italic;
margin-top: 40px;
}
</style>
</head>
<body>
</body>
</html>
"""
# --- 3. GENERACIÓN ---
def generar_pdf():
dias = obtener_eventos()
if not dias:
print("Error: No se ha podido generar el listado de días.")
return
print("Generando el diseño...")
template = Template(html_template)
html_renderizado = template.render(dias=dias)
print("Convirtiendo a PDF A6 (esto puede tardar unos segundos porque son muchas páginas)...")
HTML(string=html_renderizado).write_pdf("calendario_A6_completo.pdf")
print("¡Completado! El archivo 'calendario_A6_completo.pdf' ya está listo.")
if __name__ == "__main__":
generar_pdf()
Como veréis, la sección 2 del programa es la que se encarga de generar el HTML+CSS que posteriormente se va a volcar a PDF. En esta sección se puede personalizar la salida.
Ya para rematar, al ir a hacer unas pruebas de impresión, me dí cuenta que si imprimía cuatro páginas en un A4, impresión duplex, detrás del uno de enero estaba el día cinco, y no el día dos como correspondería. Se lo comenté a mi nuevo compañero :-) y esto es lo que me planteó:
Te entiendo perfectamente. Lo que estás intentando hacer en artes gráficas se llama imposición (imposition).
El problema con herramientas genéricas como pdfresizer es que colocan las páginas de forma secuencial (Página 1, 2, 3, 4 en el frente y 5, 6, 7, 8 en el reverso). Al imprimir a doble cara y voltear la hoja por el borde largo (lo normal), la posición superior-izquierda del frente coincide con la posición superior-derecha del reverso.
Aprovechando que ya tienes tu entorno de Python listo, la forma más precisa y rápida de resolverlo es escribir un script de post-procesado de unas pocas líneas que reordene las páginas A6 sobre folios A4 respetando el volteo del papel. Este script tomará tu PDF en A6 y generará un nuevo PDF en A4 colocando 4 páginas en el frente y 4 en el reverso de forma que, al guillotinar la hoja en 4 partes, cada minipágina A6 tenga su reverso exacto.
Paso 1: Instalar la librería para manipular PDFs
En tu terminal (con tu entorno virtual activo), instala pypdf:
pip install pypdf
_Crea un archivo llamado imponer_a4.py en tu carpeta de trabajo y pega este código:_
from pypdf import PdfReader, PdfWriter, PageObject, Transformation
# Medidas estándar en puntos PDF (72 puntos = 1 pulgada)
A4_ANCHO = 595.28 # 210 mm
A4_ALTO = 841.89 # 297 mm
A6_ANCHO = 297.64 # 105 mm
A6_ALTO = 420.94 # 148.5 mm
def imponer_pdf_a4(pdf_entrada, pdf_salida):
reader = PdfReader(pdf_entrada)
writer = PdfWriter()
total_paginas = len(reader.pages)
def obtener_pagina(idx):
if idx < total_paginas:
return reader.pages[idx]
else:
# Página en blanco si el número total no es múltiplo de 8
return PageObject.create_blank_page(width=A6_ANCHO, height=A6_ALTO)
print(f"Procesando {total_paginas} páginas A6...")
# Procesamos en bloques de 8 páginas A6 (4 para el frente A4, 4 para el reverso A4)
for i in range(0, total_paginas, 8):
# 1. FRENTE A4
# Posiciones:
# Arriba-Izq: Día 1 | Arriba-Der: Día 3
# Abajo-Izq: Día 5 | Abajo-Der: Día 7
p1 = obtener_pagina(i + 0)
p3 = obtener_pagina(i + 2)
p5 = obtener_pagina(i + 4)
p7 = obtener_pagina(i + 6)
frente = PageObject.create_blank_page(width=A4_ANCHO, height=A4_ALTO)
frente.merge_transformed_page(p1, Transformation().translate(0, A6_ALTO)) # Arriba-Izq
frente.merge_transformed_page(p3, Transformation().translate(A6_ANCHO, A6_ALTO)) # Arriba-Der
frente.merge_transformed_page(p5, Transformation().translate(0, 0)) # Abajo-Izq
frente.merge_transformed_page(p7, Transformation().translate(A6_ANCHO, 0)) # Abajo-Der
writer.add_page(frente)
# 2. REVERSO A4 (Teniendo en cuenta el volteo horizontal por el borde largo)
# La trasera del Arriba-Izq (Día 1) es el Arriba-Der del reverso (Día 2)
# La trasera del Arriba-Der (Día 3) es el Arriba-Izq del reverso (Día 4)
p2 = obtener_pagina(i + 1)
p4 = obtener_pagina(i + 3)
p6 = obtener_pagina(i + 5)
p8 = obtener_pagina(i + 7)
reverso = PageObject.create_blank_page(width=A4_ANCHO, height=A4_ALTO)
reverso.merge_transformed_page(p4, Transformation().translate(0, A6_ALTO)) # Arriba-Izq (Atras de p3)
reverso.merge_transformed_page(p2, Transformation().translate(A6_ANCHO, A6_ALTO)) # Arriba-Der (Atras de p1)
reverso.merge_transformed_page(p8, Transformation().translate(0, 0)) # Abajo-Izq (Atras de p7)
reverso.merge_transformed_page(p6, Transformation().translate(A6_ANCHO, 0)) # Abajo-Der (Atras de p5)
writer.add_page(reverso)
with open(pdf_salida, "wb") as f:
writer.write(f)
print(f"¡Listo! Archivo generado: {pdf_salida}")
if __name__ == "__main__":
# Cambia 'calendario_A6_completo.pdf' por el nombre exacto de tu PDF A6
imponer_pdf_a4("calendario_A6_completo.pdf", "agenda_para_imprimir_A4.pdf")
_Ejecuta el script con python imponer_a4.py_
_Una vez tengas el archivo agenda_para_imprimir_A4.pdf:_
Cuando salgan las hojas impresas, solo tendrás que cortar los folios A4 por la mitad en vertical y por la mitad en horizontal: cada taco de 4 mini-hojas A6 tendrá el 1 de enero por delante, el 2 de enero por detrás, el 3 por delante, el 4 por detrás... ¡listo para encuadernar!
¡Qué puedo decir! Para alguien sin conocimientos de programación, el poder tener acceso a una herramienta que, sabiendo lo que se quiere, te guíe (aunque sea a ciegas) y te solucione la papeleta me ha maravillado.
Ya iré compartiendo otras cosas que vaya haciendo, por si a alguien le puede resultar de interés.
El crédito de la imágen, para ChatGPT ;-)