Saltar al contenido

Escribiendo ficheros docx de Word con Python. Capítulo VIII – Ejemplo práctico

Para terminar el tutorial de creación de ficheros docx con Python vamos a ver un caso práctico de creación de un informe a partir de información de internet.

Lo que vamos a hacer es crear un informe de la predicción generada por la AEMet (Agencia Estatal de Meteorología de España) para una localidad en concreto, digamos Palma de Mallorca, que es un lugar ideal para vivir y para crear franquicias de empresas guays.

La información la podéis encontrar en este enlace. Lo que haremos será descargar el fichero XML que hay en al anterior enlace y, a partir del mismo, crear el fichero docx.

Empezamos importando unas pocas cosas que necesitaremos:

from urllib.request import urlopen
import xml.etree.ElementTree as ET
from xml.etree.ElementTree import Element
from typing import Dict
from collections import defaultdict
from pprint import pprint
import datetime as dt

import docx
from docx.table import Table
from docx.shared import Pt

Obtención de la información

La información que vamos a descargar está en formato XML y es información, como he comentado antes, de predicción meteorológica proporcionada por la AEMet (Agencia Estatal de Meteorología de España).

La que voy a descargar corresponde a la ciudad de Palma pero puedes elegir otras localidades (cambiando la url a usar).

url = "http://www.aemet.es/xml/municipios_h/localidad_h_07040.xml"
aemetdoc = ET.fromstring(urlopen(url).read())

De la información que acabamos de descargar podemos extraer algunos metadatos que luego usaremos en nuestro informe automatizado:

# Algunos metadatos

# Fecha de elaboración de la predicción
print(aemetdoc.find("elaborado").text)
# Ciudad para la predicción
print(aemetdoc.find("nombre").text)
# Provincia de la predicción
print(aemetdoc.find("provincia").text)
# Metadata del productor de los datos
print(aemetdoc.find("origen/copyright").text)
print(aemetdoc.find("origen/enlace").text)
print(aemetdoc.find("origen/nota_legal").text)
print(aemetdoc.find("origen/productor").text)
print(aemetdoc.find("origen/web").text)

El resultado de lo anterior debería mostrar algo parecido a:

2020-07-26T12:47:46
Palma
Illes Balears
© AEMET. Autorizado el uso de la información y su reproducción citando a AEMET como autora de la misma.
http://www.aemet.es/es/eltiempo/prediccion/municipios/horas/palma-id07040
http://www.aemet.es/es/nota_legal
Agencia Estatal de Meteorología - AEMET. Gobierno de España
http://www.aemet.es

Los datos de predicción los podemos extraer de cada día para el que hay predicción disponible:

dias = aemetdoc.findall("prediccion/dia")

Para ello nos ayudaremos de la siguiente función adaptada de una respuesta en SO para convertir el parte de predicción de cada día del formato original en XML a un diccionario que sea más digerible.

# Función adaptada de https://stackoverflow.com/a/10077069
def extract_info_day(daytag: Element) -> Dict[str, str]:
    d = {daytag.tag: {} if daytag.attrib else None}
    children = list(daytag)
    if children:
        dd = defaultdict(list)
        for dc in map(extract_info_day, children):
            for k, v in dc.items():
                dd[k].append(v)
        d = {daytag.tag: {k:v[0] if len(v) == 1 else v for k, v in dd.items()}}
    if daytag.attrib:
        d[daytag.tag].update(('@' + k, v) for k, v in daytag.attrib.items())
    if daytag.text:
        text = daytag.text.strip()
        if children or daytag.attrib:
            if text:
              d[daytag.tag]['#text'] = text
        else:
            d[daytag.tag] = text
    return d

Meto la información en un diccionario junto con algunos metadatos.

Empiezo por los metadatos:

fcst_data = {}
fcst_data["elaborado"] = aemetdoc.find("elaborado").text
fcst_data["nombre"] = aemetdoc.find("nombre").text
fcst_data["provincia"] = aemetdoc.find("provincia").text
fcst_data["origen_copyright"] = aemetdoc.find("origen/copyright").text
fcst_data["origen_enlace"] = aemetdoc.find("origen/enlace").text
fcst_data["origen_notalegal"] = aemetdoc.find("origen/nota_legal").text
fcst_data["origen_productor"] = aemetdoc.find("origen/productor").text
fcst_data["origen_web"] = aemetdoc.find("origen/web").text
fcst_data["prediccion_comienzo"] = f"{dias[0].get('fecha')}"
fcst_data["prediccion_fin"] = f"{dias[-1].get('fecha')}"

Y ahora extraigo los datos para cada hora (viento, temperatura,…), periodo (probabilidad de tormenta, probabilidad de lluvia,…) y día (orto y ocaso).

fcst_data["datos"] = {
    "hourly": {"datetime": []},
    "periods": {"datetime": []},
    "daily": {"datetime": []},
}
for dia in dias:
    d = extract_info_day(dia)
    keys = d['dia'].keys()
    datetime = dt.datetime.strptime( # NAIVE DATETIMES!!!
        d['dia']["@fecha"],
        "%Y-%m-%d",
    )
    # Prepare the datetimes
    # hourly data
    for dd in d['dia']['temperatura']:
        timedelta = int(dd["@periodo"])
        fcst_data["datos"]["hourly"]["datetime"].append(
            datetime + dt.timedelta(hours=timedelta)
        )
    # 6-hourly period data
    for dd in d['dia']['prob_nieve']:
        timedelta = int(dd["@periodo"][:2])
        fcst_data["datos"]["periods"]["datetime"].append(
            datetime + dt.timedelta(hours=timedelta)
        )
    # daily data
    fcst_data["datos"]["daily"]["datetime"].append(
            datetime
        )
    # Prepare the data
    for k in keys:
        if k.startswith("prob_"):
            if fcst_data["datos"]["periods"].get(k, None) is None:
                fcst_data["datos"]["periods"][k] = []
            for p in d["dia"][k]:
                fcst_data["datos"]["periods"][k].append(
                    p.get("#text", None) # Aquí el campo me vale como str
                )
        elif k.startswith("@"):
            if k == "@fecha":
                continue
            if fcst_data["datos"]["daily"].get(k[1:], None) is None:
                fcst_data["datos"]["daily"][k[1:]] = []
            p = d["dia"][k]
            fcst_data["datos"]["daily"][k[1:]].append(p)
        else:
            if k != "viento":
                if fcst_data["datos"]["hourly"].get(k, None) is None:
                    fcst_data["datos"]["hourly"][k] = []
            else:
                if fcst_data["datos"]["hourly"].get(f"{k}_vel", None) is None:
                    fcst_data["datos"]["hourly"][f"{k}_vel"] = []
                if fcst_data["datos"]["hourly"].get(f"{k}_dir", None) is None:
                    fcst_data["datos"]["hourly"][f"{k}_dir"] = []
            for p in d["dia"][k]:
                if k == "estado_cielo":
                    fcst_data["datos"]["hourly"][k].append(
                        p.get("@descripcion", None)
                    )
                elif k == "viento":
                    try:
                        fcst_data["datos"]["hourly"][f"{k}_vel"].append(
                          int(p["velocidad"])
                        )
                    except:
                        fcst_data["datos"]["hourly"][f"{k}_vel"].append(
                          None
                        )
                    fcst_data["datos"]["hourly"][f"{k}_dir"].append(
                        p.get("direccion", None)
                    )
                else:
                    try:
                        fcst_data["datos"]["hourly"][k].append(
                            int(p["#text"])
                        )
                    except:
                        fcst_data["datos"]["hourly"][k].append(
                            None
                        )

Muestro el resultado de mi diccionario:

pprint(fcst_data,
       indent=1,
       compact=True)

La salida será:

{'datos': {'daily': {'datetime': [datetime.datetime(2020, 7, 26, 0, 0),
                                  datetime.datetime(2020, 7, 27, 0, 0),
                                  datetime.datetime(2020, 7, 28, 0, 0)],
                     'ocaso': ['21:07', '21:06', '21:06'],
                     'orto': ['06:43', '06:44', '06:45']},
           'hourly': {'datetime': [datetime.datetime(2020, 7, 26, 8, 0),
                                   datetime.datetime(2020, 7, 26, 9, 0),
                                   datetime.datetime(2020, 7, 26, 10, 0),
                                   datetime.datetime(2020, 7, 26, 11, 0),
                                   datetime.datetime(2020, 7, 26, 12, 0),
                                   datetime.datetime(2020, 7, 26, 13, 0),
                                   datetime.datetime(2020, 7, 26, 14, 0),
                                   datetime.datetime(2020, 7, 26, 15, 0),
                                   datetime.datetime(2020, 7, 26, 16, 0),
                                   datetime.datetime(2020, 7, 26, 17, 0),
                                   datetime.datetime(2020, 7, 26, 18, 0),
                                   datetime.datetime(2020, 7, 26, 19, 0),
                                   datetime.datetime(2020, 7, 26, 20, 0),
                                   datetime.datetime(2020, 7, 26, 21, 0),
                                   datetime.datetime(2020, 7, 26, 22, 0),
                                   datetime.datetime(2020, 7, 26, 23, 0),
                                   datetime.datetime(2020, 7, 27, 0, 0),
                                   datetime.datetime(2020, 7, 27, 1, 0),
                                   datetime.datetime(2020, 7, 27, 2, 0),
                                   datetime.datetime(2020, 7, 27, 3, 0),
                                   datetime.datetime(2020, 7, 27, 4, 0),
                                   datetime.datetime(2020, 7, 27, 5, 0),
                                   datetime.datetime(2020, 7, 27, 6, 0),
                                   datetime.datetime(2020, 7, 27, 7, 0),
                                   datetime.datetime(2020, 7, 27, 8, 0),
                                   datetime.datetime(2020, 7, 27, 9, 0),
                                   datetime.datetime(2020, 7, 27, 10, 0),
                                   datetime.datetime(2020, 7, 27, 11, 0),
                                   datetime.datetime(2020, 7, 27, 12, 0),
                                   datetime.datetime(2020, 7, 27, 13, 0),
                                   datetime.datetime(2020, 7, 27, 14, 0),
                                   datetime.datetime(2020, 7, 27, 15, 0),
                                   datetime.datetime(2020, 7, 27, 16, 0),
                                   datetime.datetime(2020, 7, 27, 17, 0),
                                   datetime.datetime(2020, 7, 27, 18, 0),
                                   datetime.datetime(2020, 7, 27, 19, 0),
                                   datetime.datetime(2020, 7, 27, 20, 0),
                                   datetime.datetime(2020, 7, 27, 21, 0),
                                   datetime.datetime(2020, 7, 27, 22, 0),
                                   datetime.datetime(2020, 7, 27, 23, 0),
                                   datetime.datetime(2020, 7, 28, 0, 0),
                                   datetime.datetime(2020, 7, 28, 1, 0),
                                   datetime.datetime(2020, 7, 28, 2, 0),
                                   datetime.datetime(2020, 7, 28, 3, 0),
                                   datetime.datetime(2020, 7, 28, 4, 0),
                                   datetime.datetime(2020, 7, 28, 5, 0),
                                   datetime.datetime(2020, 7, 28, 6, 0),
                                   datetime.datetime(2020, 7, 28, 7, 0)],
                      'estado_cielo': ['Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado',
                                       'Despejado', 'Despejado', 'Despejado'],
                      'humedad_relativa': [49, 40, 33, 30, 28, 32, 32, 32, 34,
                                           36, 39, 41, 48, 55, 54, 57, 61, 58,
                                           51, 49, 48, 52, 54, 51, 42, 33, 28,
                                           27, 28, 31, 30, 29, 30, 32, 35, 37,
                                           44, 50, 56, 56, 55, 56, 59, 62, 65,
                                           60, 51, 49],
                      'nieve': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                0],
                      'precipitacion': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
                                        0, 0, 0, 0, 0, 0, 0, 0, 0],
                      'racha_max': [11, 12, 16, 17, 20, 38, 40, 40, 39, 33, 29,
                                    24, 19, 11, 9, 12, 12, 11, 9, 8, 8, 9, 9, 9,
                                    16, 18, 18, 17, 22, 35, 33, 32, 32, 31, 28,
                                    23, 18, 11, 5, 7, 8, 10, 11, 11, 10, 7, 5,
                                    7],
                      'sens_termica': [27, 29, 31, 31, 33, 32, 32, 31, 31, 31,
                                       30, 29, 28, 28, 26, 26, 25, 24, 24, 24,
                                       24, 23, 24, 25, 28, 30, 32, 33, 33, 33,
                                       33, 33, 33, 32, 31, 31, 29, 28, 28, 26,
                                       26, 25, 25, 24, 24, 25, 26, 26],
                      'temperatura': [27, 29, 31, 31, 33, 32, 32, 31, 31, 31,
                                      30, 29, 28, 27, 26, 26, 25, 24, 24, 24,
                                      24, 23, 24, 25, 28, 30, 32, 33, 33, 33,
                                      33, 33, 33, 32, 31, 31, 29, 28, 27, 26,
                                      26, 25, 25, 24, 24, 25, 26, 26],
                      'viento_dir': ['E', 'E', 'E', 'E', 'S', 'SO', 'SO', 'SO',
                                     'SO', 'SO', 'SO', 'SO', 'O', 'SO', 'E',
                                     'NE', 'NE', 'NE', 'N', 'N', 'NE', 'NE',
                                     'NE', 'NE', 'NE', 'E', 'E', 'E', 'S', 'SO',
                                     'SO', 'SO', 'SO', 'SO', 'SO', 'SO', 'SO',
                                     'O', 'NO', 'N', 'NE', 'NE', 'NE', 'NE',
                                     'NE', 'N', 'E', 'E'],
                      'viento_vel': [7, 6, 7, 8, 9, 22, 25, 25, 21, 18, 15, 12,
                                     8, 3, 7, 8, 9, 7, 5, 6, 6, 7, 6, 6, 10, 11,
                                     9, 7, 10, 20, 20, 20, 20, 18, 15, 12, 8, 4,
                                     4, 5, 6, 8, 8, 8, 6, 4, 3, 5]},
           'periods': {'datetime': [datetime.datetime(2020, 7, 26, 2, 0),
                                    datetime.datetime(2020, 7, 26, 8, 0),
                                    datetime.datetime(2020, 7, 26, 14, 0),
                                    datetime.datetime(2020, 7, 26, 20, 0),
                                    datetime.datetime(2020, 7, 27, 2, 0),
                                    datetime.datetime(2020, 7, 27, 8, 0),
                                    datetime.datetime(2020, 7, 27, 14, 0),
                                    datetime.datetime(2020, 7, 27, 20, 0),
                                    datetime.datetime(2020, 7, 28, 2, 0),
                                    datetime.datetime(2020, 7, 28, 8, 0),
                                    datetime.datetime(2020, 7, 28, 14, 0),
                                    datetime.datetime(2020, 7, 28, 20, 0)],
                       'prob_nieve': [None, '0', '0', '0', '0', '0', '0', '0',
                                      '0', None, None, None],
                       'prob_precipitacion': [None, '0', '0', '0', '0', '0',
                                              '0', '0', '0', None, None, None],
                       'prob_tormenta': [None, '0', '0', '0', '0', '0', '0',
                                         '0', '0', None, None, None]}},
 'elaborado': '2020-07-26T12:47:46',
 'nombre': 'Palma',
 'origen_copyright': '© AEMET. Autorizado el uso de la información y su '
                     'reproducción citando a AEMET como autora de la misma.',
 'origen_enlace': 'http://www.aemet.es/es/eltiempo/prediccion/municipios/horas/palma-id07040',
 'origen_notalegal': 'http://www.aemet.es/es/nota_legal',
 'origen_productor': 'Agencia Estatal de Meteorología - AEMET. Gobierno de '
                     'España',
 'origen_web': 'http://www.aemet.es',
 'prediccion_comienzo': '2020-07-26',
 'prediccion_fin': '2020-07-28',
 'provincia': 'Illes Balears'}

Además de los datos de la predicción vamos a descargar también datos climáticos para poder comparar si lo que se nos viene encima es normal para estas fechas, si está por encima o por debajo de lo que podríamos esperar,… Para hacer esto voy a descargar los datos climatológicos de la estación del puerto de Palma para el periodo 1980-2010. La información la podéis encontrar aquí donde veréis un botón que pone csv. Esa es la información que vamos a descargar:

url = (
    "http://www.aemet.es/es/serviciosclimaticos/"
    "datosclimatologicos/valoresclimatologicos_palma-puerto.csv"
    "?l=B228&k=bal"
)

Creo una función para convertir lo que descargamos en algo útil para procesar. En este punto, lo sencillo sería usar pandas pero como lo quiero mantener simple (de dependencias) voy a usar estructuras de datos básicas.

def get_clima(url: str) -> Dict[str, Dict]:
    d = {
        "T": {
            "descripcion": "Temperatura media mensual/anual (°C)",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "TM": {
            "descripcion": "Media mensual/anual de las temperaturas máximas diarias (°C)",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "Tm": {
            "descripcion": "Media mensual/anual de las temperaturas mínimas diarias (°C)",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "R": {
            "descripcion": "Precipitación mensual/anual media (mm)",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "H": {
            "descripcion": "Humedad relativa media (%)",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "DR": {
            "descripcion": "Número medio mensual/anual de días de precipitación superior o igual a 1 mm",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "DN": {
            "descripcion": "Número medio mensual/anual de días de nieve",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "DT": {
            "descripcion": "Número medio mensual/anual de días de tormenta",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "DF": {
            "descripcion": "Número medio mensual/anual de días de niebla",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "DH": {
            "descripcion": "Número medio mensual/anual de días de helada",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "DD": {
            "descripcion": "Número medio mensual/anual de días despejados",
            "valor_mensual": [],
            "valor_anual": None,
        },
        "I": {
            "descripcion": "Número medio mensual/anual de horas de sol",
            "valor_mensual": [],
            "valor_anual": None,
        },
    }
    raw_data = urlopen(url).read()
    raw_data = raw_data.split()[3:]
    for line in raw_data[:-1]:
        values = line.decode().replace('"', '').split(",")
        for key, value in zip(d.keys(), values[1:]): # (*)
            d[key]["valor_mensual"].append(float(value))
    values = raw_data[-1].decode("ISO-8859-1").replace('"', '').split(",")
    for key, value in zip(d.keys(), values[1:]): # (*)
        d[key]["valor_anual"] = float(value)
    return d

En la función anterior he dejado un (*) para indicar que eso funcionaría a partir de CPython 3.6. A partir de esa versión de CPython se preserva el orden de inserción en un diccionario. Si usas una versión más antigua de CPython podrías usar un OrderedDict. Si usas PyPy no deberías tener problema puesto que este detalle de implementación en CPython proviene de cómo funcionan los diccionarios en PyPy.

Descargamos y formateamos los datos climatológicos:

clima_data = get_clima(url)

Si muestro el resultado en pantalla:

pprint(clima_data,
       indent=1,
       compact=True)

Mostrará algo como:

{'DD': {'descripcion': 'Número medio mensual/anual de días despejados',
        'valor_anual': 71.6,
        'valor_mensual': [4.1, 3.4, 4.1, 4.1, 5.1, 8.9, 16.2, 11.0, 4.9, 3.3,
                          3.0, 4.0]},
 'DF': {'descripcion': 'Número medio mensual/anual de días de niebla',
        'valor_anual': 4.2,
        'valor_mensual': [0.6, 0.7, 1.0, 0.4, 0.5, 0.1, 0.0, 0.0, 0.0, 0.2, 0.2,
                          0.4]},
 'DH': {'descripcion': 'Número medio mensual/anual de días de helada',
        'valor_anual': 0.0,
        'valor_mensual': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                          0.0]},
 'DN': {'descripcion': 'Número medio mensual/anual de días de nieve',
        'valor_anual': 0.4,
        'valor_mensual': [0.3, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                          0.0]},
 'DR': {'descripcion': 'Número medio mensual/anual de días de precipitación '
                       'superior o igual a 1 mm',
        'valor_anual': 53.1,
        'valor_mensual': [5.8, 5.6, 4.5, 5.1, 3.6, 1.7, 0.7, 1.9, 4.7, 6.7, 6.4,
                          6.5]},
 'DT': {'descripcion': 'Número medio mensual/anual de días de tormenta',
        'valor_anual': 15.9,
        'valor_mensual': [0.7, 0.7, 0.9, 1.3, 1.2, 0.8, 0.6, 1.5, 3.0, 2.1, 1.8,
                          1.3]},
 'H': {'descripcion': 'Humedad relativa media (%)',
       'valor_anual': 71.0,
       'valor_mensual': [73.0, 72.0, 70.0, 68.0, 69.0, 69.0, 68.0, 70.0, 72.0,
                         74.0, 74.0, 74.0]},
 'I': {'descripcion': 'Número medio mensual/anual de horas de sol',
       'valor_anual': 2779.0,
       'valor_mensual': [167.0, 170.0, 205.0, 237.0, 284.0, 315.0, 346.0, 316.0,
                         226.0, 205.0, 161.0, 151.0]},
 'R': {'descripcion': 'Precipitación mensual/anual media (mm)',
       'valor_anual': 449.0,
       'valor_mensual': [42.0, 37.0, 28.0, 39.0, 36.0, 11.0, 6.0, 22.0, 52.0,
                         69.0, 59.0, 48.0]},
 'T': {'descripcion': 'Temperatura media mensual/anual (°C)',
       'valor_anual': 18.2,
       'valor_mensual': [11.9, 11.9, 13.4, 15.5, 18.8, 22.7, 25.7, 26.2, 23.5,
                         20.2, 15.8, 13.1]},
 'TM': {'descripcion': 'Media mensual/anual de las temperaturas máximas '
                       'diarias (°C)',
        'valor_anual': 21.8,
        'valor_mensual': [15.4, 15.5, 17.2, 19.2, 22.5, 26.5, 29.4, 29.8, 27.1,
                          23.7, 19.3, 16.5]},
 'Tm': {'descripcion': 'Media mensual/anual de las temperaturas mínimas '
                       'diarias (°C)',
        'valor_anual': 14.6,
        'valor_mensual': [8.3, 8.4, 9.6, 11.7, 15.1, 18.9, 21.9, 22.5, 19.9,
                          16.6, 12.3, 9.7]}}

Más o menos, tenemos todo lo que necesitamos. ¿Pasamos ya a la parte de Docx?

Formateado del documento

Voy a crear una clase con cierta funcionalidad que me ayudará a formatear el texto y a crear el informe final en formato docx:

weekdays = {
    0: "Lunes",
    1: "Martes",
    2: "Miércoles",
    3: "Jueves",
    4: "Viernes",
    5: "Sábado",
    6: "Domingo",
}

class Report:
    def __init__(self, fcst: Dict, climate: Dict) -> None:
        self._fcst_data = fcst
        self._clima_data = climate
        self._doc = docx.Document()
        self._title_page()
        self._nota_legal()
        self._footer()
        self._fcst_page()
    
    def save(self, filename: str) -> None:
        self._doc.save(filename)

    def _title_page(self) -> None:
        # no header nor footer on cover
        self._doc.sections[0].different_first_page_header_footer = True
        # some space at the beginning
        for i in range(5):
            self._doc.add_paragraph()
        # Title
        p = self._doc.add_paragraph()
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.CENTER
        p.add_run(
            "Informe de predicción meteorológica para "
        )
        r = p.add_run(
            f"{self._fcst_data['nombre']} "
            f"({self._fcst_data['provincia']})"
        )
        r.bold = True
        r.font.color.rgb = docx.shared.RGBColor(200, 200, 200)
        # Subtitle
        p = self._doc.add_paragraph()
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.CENTER
        p.add_run("Válido para los días comprendidos entre ")
        r = p.add_run(    
            f"{self._fcst_data['prediccion_comienzo']}"
            " y "
            f"{self._fcst_data['prediccion_fin']}"
        )
        r.bold = True
        r.font.color.rgb = docx.shared.RGBColor(200, 0, 0)
        # some more space
        for i in range(2):
            self._doc.add_paragraph()
        # Elaborado por
        p = self._doc.add_paragraph()
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.CENTER
        p.add_run("Documento elaborado por ")
        r = p.add_run("Pybonacci industries")
        r.bold = True
        r.font.color.rgb = docx.shared.RGBColor(0, 200, 0)
    
    def _nota_legal(self) -> None:
        # Page break
        self._doc.add_page_break()
        p = self._doc.add_paragraph("NOTA LEGAL:")
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.JUSTIFY
        p = self._doc.add_paragraph(
            "Toda la información presente en este informe "
            f"ha sido elaborada por {self._fcst_data['origen_productor']} "
            f"({self._fcst_data['origen_copyright']})."
            "Puedes obtener más información en los siguientes enlaces: "
            f"{self._fcst_data['origen_notalegal']} y "
            f"{self._fcst_data['origen_web']}."
        )
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.JUSTIFY
        p = self._doc.add_paragraph(
            "Puedes consultar estos datos en la web de AEMet en el enlace "
            f"{self._fcst_data['origen_enlace']}."
        )
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.JUSTIFY
        p = self._doc.add_paragraph(
            "Toda la información presente en este informe "
            f"ha sido elaborada por {self._fcst_data['origen_copyright']}"
        )
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.JUSTIFY
        self._doc.add_paragraph()
        p = self._doc.add_paragraph()
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.JUSTIFY
        r = p.add_run(
            "La información aquí contenida es meramente informativa y "
            "Pybonacci Industries no se hace responsable del uso que se "
            "haga de la misma."
        )
        r.bold = True
        r.font.color.rgb = docx.shared.RGBColor(255, 0, 0)
    
    def _footer(self):
        # Add footer
        section = self._doc.sections[-1]
        footer = section.footer
        p = footer.add_paragraph("Elaborado por Pybonacci Industries.")
        p.alignment = docx.enum.text.WD_ALIGN_PARAGRAPH.CENTER
    
    def _fcst_page(self) -> None:
        # Page break
        self._doc.add_page_break()
        # Add title
        self._doc.add_heading(
            (
                f'Predicción elaborada el {fcst_data["elaborado"]} '
                f'para {fcst_data["nombre"]} ({fcst_data["provincia"]})'
            ),
            level=0
        )
        
        # Información sobre el orto y el ocaso
        self._doc.add_heading(
            "Orto y Ocaso para los próximos días",
            level=1
        )
        self._doc.add_paragraph() # añado un párrafo vacio para dejar 'aire'
        for d, orto, ocaso in zip(
            fcst_data["datos"]["daily"]["datetime"],
            fcst_data["datos"]["daily"]["orto"],
            fcst_data["datos"]["daily"]["ocaso"]
        ):
            dia = weekdays[d.weekday()]
            self._doc.add_paragraph(
                (
                    f'El {dia.lower()}, {d.strftime("%d-%m-%Y")}, '
                    f'el sol saldrá a las {orto} y '
                    f'se pondrá a las {ocaso}.'
                )
            )
        self._doc.add_paragraph() # añado un párrafo vacio para dejar 'aire'
        
        # Información sobre probabilidades de tormenta y precipitación
        self._doc.add_heading(
            "Probabilidad de caída de meteoros y de tormenta",
            level=1
        )
        self._doc.add_paragraph() # añado un párrafo vacio para dejar 'aire'
        datos = fcst_data["datos"]["periods"]
        rows = 2
        for d in datos["prob_precipitacion"]:
            if d is not None:
                rows += 1
        cols = 5
        t = self._doc.add_table(rows, cols, style="Table Grid")
        row = t.rows[0]
        a, b = row.cells[:2]
        a.merge(b)
        a.text = "Válido (hora local)"
        a = row.cells[2]
        for cell in row.cells[3:]:
            a.merge(cell)
        a.text = "Probabilidad (%)"
        row = t.rows[1]
        row.cells[0].text = "Desde"
        row.cells[1].text = "Hasta"
        col_names = [k for k in datos.keys() if "prob_" in k]
        for cell, n in zip(row.cells[2:cols], col_names):
            cell.text = n
        for i, row in enumerate(t.rows[2:]):
            if datos[col_names[0]][i] is not None:
                ini = datos["datetime"][i].strftime("%y-%m-%d %H:%M")
                row.cells[0].text = ini
                fin = (
                    datos["datetime"][i] + dt.timedelta(hours=6)
                ).strftime("%y-%m-%d %H:%M")
                row.cells[1].text = fin
                for cell, name in zip(row.cells[2:], col_names):
                    cell.text = datos[name][i]
        self._doc.add_paragraph() # añado un párrafo vacio para dejar 'aire'
        
        # Información horaria
        # Page break
        self._doc.add_page_break()
        self._doc.add_heading(
            "Predicción horaria",
            level=1
        )
        self._doc.add_paragraph() # añado un párrafo vacio para dejar 'aire'
        datos = fcst_data["datos"]["hourly"]
        rows = 1 + len(datos["precipitacion"])
        cols = len(datos.keys())
        t = self._doc.add_table(rows, cols, style="Table Grid")
        row = t.rows[0]
        for cell, k in zip(row.cells, datos.keys()):
            if k == "datetime":
                cell.text = "fecha (hora local)"
            if k in ("precipitacion", "nieve"):
                cell.text = f"{k} (mm)"
            if k in ("precipitacion", "nieve"):
                cell.text = f"{k} (mm)"
            if k in ("temperatura", "sens_termica"):
                cell.text = f'{k.replace("_", " ")} (ºC)'
            if k == "humedad_relativa":
                cell.text = f'{k.replace("_", " ")} (%)'
            if k in ("viento_vel", "racha_max"):
                cell.text = f'{k.replace("_", " ")} (km/h)'
            if k == "viento_dir":
                cell.text = k.replace("_", " ")
        col_names = [k for k in datos.keys() if k != "datetime"]
        for i, row in enumerate(t.rows[1:]):
            ini = datos["datetime"][i].strftime("El %d del %m a las %H")
            row.cells[0].text = ini
            for cell, name in zip(row.cells[1:], col_names):
                cell.text = str(datos[name][i])
        for row in t.rows:
            for cell in row.cells:
                paragraphs = cell.paragraphs
                for paragraph in paragraphs:
                    for run in paragraph.runs:
                        font = run.font
                        font.size= Pt(8)
        
        # Climatología
        # Page break
        self._doc.add_page_break()
        self._doc.add_heading(
            "Valores esperados",
            level=0
        )
        self._doc.add_paragraph(
            "Los valores esperados para el mes en que se ha iniciado "
            "este pronóstico figuran a continuación."
        )
        mes = fcst_data["datos"]["daily"]["datetime"][0].month
        for key in clima_data.keys():
            self._doc.add_paragraph(
                f'{clima_data[key]["descripcion"]}: '
                f'{clima_data[key]["valor_mensual"][mes - 1]}'
            )

La clase anterior solo tiene un método útil, que es save, el resto son métodos que se usan internamente para crear la portada del documento, _title_page, una página con una nota legal informando sobre el origen de los datos y sobre la responsabilidad de los usos de los mismos, _nota_legal, un método _footer que añade algo de información en los pies de página, y el método _fcst_page que en realidad añade varias páginas con la información meteorológica y climatológica que hemos procesado. Todos estos métodos internos se invocan al instanciar la clase. Lo podemos hacer de la siguiente forma:

r = Report(fcst_data, clima_data)

Ese objeto ya tendrá la información procesada y lo único que nos quedaría es guardarlo en disco con el formato docx con el nombre que le indiquemos:


r.save("ejemplo.docx")

El resultado final lo podéis ver en este enlace.

Espero que os hayan gustado los distintos capítulos del tutorial. Si tenéis dudas en alguno de los capítulos podéis preguntar en los comentarios del capítulo que corresponda.

6 comentarios en «Escribiendo ficheros docx de Word con Python. Capítulo VIII – Ejemplo práctico»

  1. Hola,
    Muchas gracias por el tutorial.
    No tengo ni idea de programación y estoy empezando con python porque me interesa automatizar un proceso de extracción de autor, fecha y artículo de páginas webs de noticias para guardarlo en un documento de Word docx.
    Consigo todos los datos con la librería newspaper3k https://newspaper.readthedocs.io/en/latest/
    Puedo crear documentos nuevos de Word con la librería https://python-docx.readthedocs.io/en/latest/
    ¿Alguna idea de cómo podría “pegar” los datos obtenidos (autor, fecha y artículo) en un documento Word docx?
    Gracias anticipadas,

    1. Hola, Joan.

      Muchos ánimos con lo que quieres hacer. Durante todo el tutorial se explica cómo crear un documento docx. Lo ideal es que lo intentes siguiendo los distrintos capítulos.

      Saludos.

  2. Hola, gracias por el tutorial. Disculpa, en el caso de tener un CRUD y se quisiera hacer un reporte del mismo con Python-docx. ¿Cómo se haría?

    Gracias de antemano.

    1. Hola.

      Tu pregunta es muy amplia e involucra varios temas. Lo mejor es que los vayas aprendiendo por separado, aquí ya tienes un tutorial de una de las partes, y verás que es fácil unirlo.

      Saludos.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

twenty three − = eighteen

Pybonacci