Plantillas y placeholders
El autoinforme se basa en una plantilla Word preformateada (.docx) con marcadores de posición —placeholders— que Python reemplaza en tiempo de ejecución. Este enfoque separa el diseño visual (responsabilidad del Word) del contenido numérico (responsabilidad del script).
Convención de placeholders
Los placeholders se escriben en la plantilla entre corchetes, en mayúsculas y con guiones bajos:
[PROYECTO] → nombre del proyecto
[VEL_MAX_7M] → velocidad máxima en la capa de 7 m
[DIR_PRED_7M] → dirección predominante en 7 m
[FIGURA_ROSA_7M] → imagen de rosa de corrientes en 7 m
[TABLA_INCIDENCIA] → tabla de incidencia
Nomenclatura consistente
Usar siempre el mismo formato en la plantilla y en el diccionario de reemplazos del script. Un error tipográfico en el placeholder deja el marcador sin reemplazar en el informe final, lo cual es fácil de detectar visualmente.
Validar que todos los placeholders se reemplazaron
Antes de guardar el informe, verificar que no quedó ningún placeholder sin llenar:
import re
def placeholders_pendientes(doc):
"""Retorna una lista de placeholders que no fueron reemplazados."""
patron = re.compile(r'\[[A-Z_0-9]+\]')
encontrados = set()
for parrafo in todos_los_parrafos(doc): # función del capítulo 14
for match in patron.finditer(parrafo.text):
encontrados.add(match.group())
return sorted(encontrados)
pendientes = placeholders_pendientes(doc)
if pendientes:
print(f" ! Placeholders sin reemplazar: {pendientes}")
else:
print(" ✓ Todos los placeholders reemplazados")
Placeholders en tablas
Los placeholders pueden estar dentro de celdas de tablas. La función todos_los_parrafos del capítulo anterior ya los cubre, pero a veces se necesita reemplazar una celda completa:
def reemplazar_en_tablas(doc, reemplazos: dict):
"""Reemplaza placeholders dentro de todas las celdas de todas las tablas."""
for tabla in doc.tables:
for fila in tabla.rows:
for celda in fila.cells:
for parrafo in celda.paragraphs:
for viejo, nuevo in reemplazos.items():
if viejo in parrafo.text:
reemplazar_en_parrafo(parrafo, viejo, str(nuevo))
Placeholders que se repiten
Un mismo placeholder puede aparecer múltiples veces en el documento (p. ej. [PROYECTO] en el encabezado, en el cuerpo y en el pie de página). La función reemplazar_en_doc ya los reemplaza todos porque itera sobre todos los párrafos.
Para placeholders en encabezados y pies de página, hay que acceder explícitamente a las secciones:
def reemplazar_en_encabezados_pies(doc, reemplazos: dict):
for seccion in doc.sections:
for parrafo in seccion.header.paragraphs:
for viejo, nuevo in reemplazos.items():
if viejo in parrafo.text:
reemplazar_en_parrafo(parrafo, viejo, str(nuevo))
for parrafo in seccion.footer.paragraphs:
for viejo, nuevo in reemplazos.items():
if viejo in parrafo.text:
reemplazar_en_parrafo(parrafo, viejo, str(nuevo))
Placeholder partido entre runs
Word a veces divide un placeholder en runs separados cuando el usuario activa la corrección automática o cuando lo escribe carácter a carácter. Por ejemplo [VEL_MAX] puede quedar como:
En ese caso viejo in parrafo.text detecta el placeholder, pero viejo in run.text no encuentra nada. La solución es fusionar los runs antes de reemplazar:
def fusionar_runs_parrafo(parrafo):
"""Fusiona todos los runs del párrafo en el primero, preservando el formato del primero."""
if not parrafo.runs:
return
texto_total = parrafo.text
for i, run in enumerate(parrafo.runs):
run.text = texto_total if i == 0 else ''
def reemplazar_robusto(doc, reemplazos: dict):
"""Fusiona runs y luego reemplaza. Usar solo cuando hay placeholders partidos."""
patron = re.compile(r'\[[A-Z_0-9]+\]')
for parrafo in todos_los_parrafos(doc):
if patron.search(parrafo.text):
fusionar_runs_parrafo(parrafo)
for viejo, nuevo in reemplazos.items():
if viejo in parrafo.text:
reemplazar_en_parrafo(parrafo, viejo, str(nuevo))
Fusionar runs borra el formato interno
Al fusionar todos los runs en uno solo, el texto queda con el formato del primer run (fuente, tamaño, negrita). Esto es aceptable si el placeholder ocupa su propio párrafo. Si el placeholder está en medio de un párrafo con texto mixto (parte en negrita, parte normal), la fusión puede romper el formato del párrafo completo.
Diccionario de reemplazos por campaña
En el pipeline se construye el diccionario de reemplazos a partir de los datos calculados:
def construir_reemplazos(df_stats, meta):
"""
df_stats : DataFrame con estadísticas por profundidad
meta : dict con metadatos del proyecto (fechas, nombre, etc.)
"""
reemplazos = {
'[PROYECTO]': meta['nombre'],
'[EMPRESA]': meta['empresa'],
'[FECHA_INI]': meta['fecha_inicio'].strftime('%d/%m/%Y'),
'[FECHA_FIN]': meta['fecha_fin'].strftime('%d/%m/%Y'),
'[N_DATOS]': str(meta['n_datos']),
}
# Estadísticas por profundidad
for prof in df_stats.index:
fila = df_stats.loc[prof]
clave = str(prof).replace('.', '_') # 7.5 → 7_5
reemplazos[f'[VEL_MEDIA_{clave}M]'] = f"{fila['vel_media']:.2f}"
reemplazos[f'[VEL_MAX_{clave}M]'] = f"{fila['vel_max']:.2f}"
reemplazos[f'[DIR_PRED_{clave}M]'] = f"{fila['dir_pred']:.0f}°"
return reemplazos
Flujo recomendado
from pathlib import Path
from docx import Document
def generar_informe_completo(ruta_plantilla, ruta_salida, meta, df_stats, figuras):
doc = Document(ruta_plantilla)
# 1. Reemplazar texto en cuerpo
reemplazos = construir_reemplazos(df_stats, meta)
reemplazar_robusto(doc, reemplazos)
# 2. Reemplazar en encabezados y pies
reemplazar_en_encabezados_pies(doc, reemplazos)
# 3. Insertar figuras
for placeholder, ruta_img in figuras.items():
insertar_figura_en_placeholder(doc, placeholder, ruta_img)
# 4. Validar
pendientes = placeholders_pendientes(doc)
if pendientes:
print(f" ! Sin reemplazar: {pendientes}")
doc.save(ruta_salida)
print(f"Informe generado: {ruta_salida.name}")