Categoría: Automatización del trabajo

Automatización de oficina con Python#14¡Genera informes de PowerPoint automáticamente! Cambia los números del Excel y la presentación queda lista (python-pptx) 📊

Esta entrada es una traducción del original en coreano.

Índice13 secciones

¡Hola! Soy Mojangcam 😎

En la entrega anterior vimos cómo subir datos de Excel a Google Sheets automáticamente (gspread). Recopilar, guardar y compartir los datos ya es cosa de Python.

Pero el trabajo de oficina no termina ahí. Al final, alguien tiene que pasar esos números a un PowerPoint. El mismo formato cada mes, y solo cambian los números. Yo antes perdía dos horas cada fin de mes pegando tablas y volviendo a dibujar gráficos.

Hoy vamos a hacer que Python arme esa presentación por ti. Cambias un solo archivo de Excel y sale automáticamente un informe de 4 diapositivas: portada → resumen → gráfico → tabla detallada. ¡Solo necesitas una librería: python-pptx!

📌 Lo que necesitas

  • Python 3.8 o superior (python-pptx 1.0.2 tiene como requisito Requires-Python >=3.8)
  • python-pptx 1.0.2 — librería para crear y editar archivos PPTX
  • openpyxl 3.1.x — para leer el Excel con los datos de origen
  • ¡No hace falta tener PowerPoint instalado! python-pptx trabaja directamente con el formato del archivo, así que puede crear un .pptx sin Office. (También se abre en Hancom Office, Google Slides y LibreOffice)
Terminal
pip install python-pptx openpyxl

Así compruebas la instalación.

Terminal
python -c "import pptx; print(pptx.__version__)"
# 1.0.2

💡 El paquete se llama python-pptx, pero al importarlo se usa pptx. Si escribes pip install pptx, se instala un paquete totalmente distinto, ¡ojo!

📌 Paso 1 - Preparar el Excel de origen y leerlo

Primero crea el Excel que servirá de materia prima para el informe. Basta con que la primera hoja de sales.xlsx tenga esto.

Equipo Objetivo Resultado
Ventas 1 5000 5400
Ventas 2 4500 3900
Equipo online 3000 3600

Vamos a leerlo con Python.

Python
import openpyxl

def load_rows(xlsx_path):
    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
    ws = wb.active
    rows = []
    for name, target, actual in ws.iter_rows(min_row=2, values_only=True):
        if name is None:
            continue
        rows.append({"name": str(name), "target": int(target), "actual": int(actual)})
    return rows

Vamos parte por parte 💡

  • data_only=True : aunque la celda tenga una fórmula, obtienes el valor ya calculado. Si lo quitas, te llega el texto de la fórmula tal cual, como =B2*1.1.
  • ws.iter_rows(min_row=2) : la fila 1 es el encabezado, así que leemos desde la fila 2.
  • values_only=True : en lugar de objetos de celda, devuelve solo los valores como tuplas, así que puedes desempaquetarlos directamente.
  • if name is None: continue : filtra las filas vacías que se cuelan desde la parte de abajo del Excel. ¡Si no lo haces, te salta un error de int(None)!

📌 Paso 2 - Entender los layouts de diapositiva y crear la portada

Lo más confuso de python-pptx son los números de layout. La plantilla por defecto trae 11 layouts, pero en la práctica solo se usan tres.

  • slide_layouts[0] : diapositiva de título (título + subtítulo)
  • slide_layouts[1] : título + contenido (texto con viñetas)
  • slide_layouts[5] : solo título (el más cómodo para poner tú mismo gráficos y tablas)
Python
from pptx import Presentation
from pptx.util import Inches

prs = Presentation()
prs.slide_width = Inches(10)      # relación 4:3
prs.slide_height = Inches(7.5)

slide = prs.slides.add_slide(prs.slide_layouts[0])
slide.shapes.title.text = "Informe de resultados de septiembre de 2026"
slide.placeholders[1].text = "Generado automáticamente · Mojangcam"

Vamos parte por parte 💡

  • Presentation() : si lo llamas sin argumentos, empieza con la plantilla por defecto. Si tu empresa tiene una plantilla, pásala como Presentation("회사양식.pptx") y se usará ese diseño tal cual. ¡Este es un truco de verdad útil en el trabajo!
  • slide_width / slide_height : por defecto es 4:3 (10×7.5 pulgadas). Si quieres 16:9, cámbialo a Inches(13.333) × Inches(7.5).
  • shapes.title : obtiene el espacio del título (placeholder) que viene en el layout.
  • placeholders[1] : el 0 es el título y el 1 es el subtítulo (o el cuerpo). Los números cambian según el layout; si te lías, imprímelos con for ph in slide.placeholders: print(ph.placeholder_format.idx, ph.name).

📌 Paso 3 - Crear las diapositivas de resumen, gráfico y tabla

Diapositiva de resumen (viñetas)

Python
from pptx.dml.color import RGBColor

def add_summary_slide(prs, rows):
    slide = prs.slides.add_slide(prs.slide_layouts[1])
    slide.shapes.title.text = "Resumen"
    body = slide.placeholders[1].text_frame

    total_t = sum(r["target"] for r in rows)
    total_a = sum(r["actual"] for r in rows)
    body.text = f"Objetivo total {total_t:,} / Resultado {total_a:,} (en unidades de 10.000 KRW)"

    for r in rows:
        rate = r["actual"] / r["target"] * 100
        p = body.add_paragraph()
        p.text = f'{r["name"]} : cumplimiento {rate:.1f}%'
        p.level = 1
        if rate < 100:
            p.runs[0].font.color.rgb = RGBColor(0xC0, 0x00, 0x00)
  • text_frame.text = ... : este código rellena el primer párrafo. Desde la segunda línea tienes que usar add_paragraph().
  • p.level = 1 : es el nivel de sangría. 0 es el nivel superior y, cuanto mayor el número, más hacia dentro.
  • p.runs[0].font.color.rgb : resalté en rojo solo los equipos que no llegaron al objetivo. Cuando pones el valor con p.text se crea exactamente un run, así que basta con tomar runs[0].

Diapositiva de gráfico (¡un gráfico de PPT editable de verdad!)

Python
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION

def add_chart_slide(prs, rows):
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    slide.shapes.title.text = "Objetivo vs. resultado por equipo"

    data = CategoryChartData()
    data.categories = [r["name"] for r in rows]
    data.add_series("Objetivo", [r["target"] for r in rows])
    data.add_series("Resultado", [r["actual"] for r in rows])

    graphic_frame = slide.shapes.add_chart(
        XL_CHART_TYPE.COLUMN_CLUSTERED,
        Inches(0.8), Inches(1.8), Inches(8.4), Inches(4.5),
        data,
    )
    chart = graphic_frame.chart
    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.BOTTOM
    chart.legend.include_in_layout = False

Esta es la mayor ventaja de python-pptx. No se inserta una imagen, sino un gráfico nativo de PowerPoint. Si justo antes de la presentación tu jefe te dice "¿me corriges este número?", lo editas directamente en el PPT. 🙌

  • XL_CHART_TYPE.COLUMN_CLUSTERED : columnas agrupadas. Para líneas es LINE, para circular PIE y para barras horizontales BAR_CLUSTERED.
  • add_chart(tipo, left, top, width, height, data) : la posición y el tamaño se indican siempre con Inches() o Cm(). El punto de referencia (0, 0) es la esquina superior izquierda de la diapositiva.
  • legend.include_in_layout = False : evita que la leyenda invada el área del gráfico y lo estreche. Si no lo pones, el gráfico queda extrañamente aplastado.

Diapositiva de tabla

Python
from pptx.util import Pt

def add_table_slide(prs, rows):
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    slide.shapes.title.text = "Tabla detallada"

    table = slide.shapes.add_table(
        len(rows) + 1, 4, Inches(0.8), Inches(1.8), Inches(8.4), Inches(0.8)
    ).table

    for col, head in enumerate(["Equipo", "Objetivo", "Resultado", "Cumplimiento"]):
        table.cell(0, col).text = head

    for i, r in enumerate(rows, start=1):
        rate = r["actual"] / r["target"] * 100
        table.cell(i, 0).text = r["name"]
        table.cell(i, 1).text = f'{r["target"]:,}'
        table.cell(i, 2).text = f'{r["actual"]:,}'
        table.cell(i, 3).text = f"{rate:.1f}%"
  • add_table(filas, columnas, left, top, width, height) : hay que sumar una fila para el encabezado, por eso es len(rows) + 1.
  • ¡No olvides añadir .table al final! Lo que devuelve add_table() es una forma (GraphicFrame), y para tocar las celdas necesitas el .table que lleva dentro. Muchísima gente se lo salta y acaba viendo un AttributeError 😅
  • height es la altura mínima, así que si hay más filas, PowerPoint la amplía solo.

📌 Código completo (listo para copiar y pegar)

Python
# python-pptx 1.0.2 / openpyxl 3.1.x / Python 3.8+
from pathlib import Path

import openpyxl
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.util import Inches, Pt

XLSX_PATH = Path("sales.xlsx")
OUT_PATH = Path("output/2026_09_report.pptx")
POINT_COLOR = RGBColor(0x2E, 0x5B, 0xFF)

def load_rows(xlsx_path):
    wb = openpyxl.load_workbook(xlsx_path, data_only=True)
    ws = wb.active
    rows = []
    for name, target, actual in ws.iter_rows(min_row=2, values_only=True):
        if name is None:
            continue
        rows.append({"name": str(name), "target": int(target), "actual": int(actual)})
    return rows

def add_title_slide(prs, title, subtitle):
    slide = prs.slides.add_slide(prs.slide_layouts[0])
    slide.shapes.title.text = title
    slide.placeholders[1].text = subtitle
    return slide

def add_summary_slide(prs, rows):
    slide = prs.slides.add_slide(prs.slide_layouts[1])
    slide.shapes.title.text = "Resumen"
    body = slide.placeholders[1].text_frame
    total_t = sum(r["target"] for r in rows)
    total_a = sum(r["actual"] for r in rows)
    body.text = f"Objetivo total {total_t:,} / Resultado {total_a:,} (en unidades de 10.000 KRW)"
    for r in rows:
        rate = r["actual"] / r["target"] * 100
        p = body.add_paragraph()
        p.text = f'{r["name"]} : cumplimiento {rate:.1f}%'
        p.level = 1
        if rate < 100:
            p.runs[0].font.color.rgb = RGBColor(0xC0, 0x00, 0x00)
    return slide

def add_chart_slide(prs, rows):
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    slide.shapes.title.text = "Objetivo vs. resultado por equipo"

    data = CategoryChartData()
    data.categories = [r["name"] for r in rows]
    data.add_series("Objetivo", [r["target"] for r in rows])
    data.add_series("Resultado", [r["actual"] for r in rows])

    graphic_frame = slide.shapes.add_chart(
        XL_CHART_TYPE.COLUMN_CLUSTERED,
        Inches(0.8), Inches(1.8), Inches(8.4), Inches(4.5),
        data,
    )
    chart = graphic_frame.chart
    chart.has_legend = True
    chart.legend.position = XL_LEGEND_POSITION.BOTTOM
    chart.legend.include_in_layout = False
    chart.plots[0].series[1].format.fill.solid()
    chart.plots[0].series[1].format.fill.fore_color.rgb = POINT_COLOR
    return slide

def add_table_slide(prs, rows):
    slide = prs.slides.add_slide(prs.slide_layouts[5])
    slide.shapes.title.text = "Tabla detallada"

    table = slide.shapes.add_table(
        len(rows) + 1, 4, Inches(0.8), Inches(1.8), Inches(8.4), Inches(0.8)
    ).table
    for col, head in enumerate(["Equipo", "Objetivo", "Resultado", "Cumplimiento"]):
        table.cell(0, col).text = head
    for i, r in enumerate(rows, start=1):
        rate = r["actual"] / r["target"] * 100
        table.cell(i, 0).text = r["name"]
        table.cell(i, 1).text = f'{r["target"]:,}'
        table.cell(i, 2).text = f'{r["actual"]:,}'
        table.cell(i, 3).text = f"{rate:.1f}%"
    for row in table.rows:
        for cell in row.cells:
            for p in cell.text_frame.paragraphs:
                for run in p.runs:
                    run.font.size = Pt(14)
    return slide

def main():
    rows = load_rows(XLSX_PATH)
    prs = Presentation()
    prs.slide_width = Inches(10)      # para 16:9, Inches(13.333)
    prs.slide_height = Inches(7.5)

    add_title_slide(prs, "Informe de resultados de septiembre de 2026", "Generado automáticamente · Mojangcam")
    add_summary_slide(prs, rows)
    add_chart_slide(prs, rows)
    add_table_slide(prs, rows)

    OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
    prs.save(OUT_PATH)
    print(f"완료: {OUT_PATH} (슬라이드 {len(prs.slides)}장)")

if __name__ == "__main__":
    main()

📌 Resultado de la ejecución

Terminal
python make_report.py
Terminal
완료: output/2026_09_report.pptx (슬라이드 4장)

Si abres el archivo que aparece en la carpeta output, verás las 4 diapositivas en orden: portada → resumen → gráfico → tabla. Haz clic en el gráfico y pulsa "Editar datos" (데이터 편집): se abre una ventana de Excel. Es la prueba de que es un gráfico de PPT de verdad 😎

¿Y el mes que viene? Solo cambias los números de sales.xlsx y lo vuelves a ejecutar. Un trabajo de dos horas se convierte en 2 segundos.

📌 Extra - Usar la plantilla de tu empresa tal cual 💡

Si también quieres respetar el diseño, basta con abrir la plantilla de tu empresa y usarla directamente.

Python
prs = Presentation("회사양식.pptx")

# ¡Primero revisa qué layouts tiene esta plantilla!
for i, layout in enumerate(prs.slide_layouts):
    print(i, layout.name)

Como cada plantilla tiene los layouts en un orden distinto, lo más seguro es imprimir los números y elegir el índice del layout que quieras. También puedes conservar las diapositivas existentes y añadir las nuevas al final, o preparar un archivo de plantilla vacío (un pptx que solo tenga el patrón) y trabajar sobre él.

📌 Errores frecuentes

1. ModuleNotFoundError: No module named 'pptx'

  • Causa: lo instalaste por error con pip install pptx, o está instalado en otro entorno virtual
  • Solución: vuelve a instalarlo con pip install python-pptx. Si dudas de dónde se instaló, usa python -m pip install python-pptx para instalarlo con el mismo intérprete de Python con el que lo vas a ejecutar, y listo.

2. AttributeError: 'GraphicFrame' object has no attribute 'cell'

  • Causa: no añadiste .table después de add_table()
  • Solución: escríbelo completo, shapes.add_table(...).table.

3. KeyError: 'no placeholder on this slide with idx == 1'

  • Causa: ese layout no tiene el placeholder número 1. En concreto, slide_layouts[5] (solo título) no tiene espacio para el cuerpo
  • Solución: comprueba los números reales con for ph in slide.placeholders: print(ph.placeholder_format.idx, ph.name), o usa slide_layouts[1] si necesitas cuerpo.

4. PermissionError: [Errno 13] Permission denied: '...pptx'

  • Causa: ejecutaste el script con el archivo PPT que quieres generar abierto en PowerPoint
  • Solución: cierra el archivo y vuelve a ejecutarlo. En el trabajo conviene añadir la fecha al nombre del archivo, como 2026_09_report_20260920.pptx, para no sobrescribirlo.

5. El texto coreano aparece como cuadrados (□□□)

  • Causa: no es un problema del código, sino que el PC de la presentación no tiene esa fuente
  • Solución: indica una fuente estándar que tenga cualquier PC, como run.font.name = "맑은 고딕" (Malgun Gothic). Las fuentes poco comunes no viajan con el archivo.

📌 Para terminar

Hoy generamos automáticamente con python-pptx un informe de PPT de 4 diapositivas a partir de datos de Excel. En resumen 💬

  • Empiezas con Presentation() y, conociendo solo slide_layouts[0/1/5], resuelves casi todo
  • Gráficos con CategoryChartData + add_chart() → un gráfico de PPT de verdad, no una imagen
  • Tablas con add_table(...).table, sin olvidar el .table del final
  • La plantilla de la empresa se reutiliza tal cual con Presentation("양식.pptx")

Si le sumas las entregas anteriores, tienes el sistema completo. Recopilas los datos con scraping y hojas de cálculo → creas el PPT con lo de hoy → y lo envías a tu jefe con el envío automático de correos: ¡un pipeline en el que nadie tiene que mover un dedo!

En la próxima entrega veremos cómo convertir el informe a PDF y distribuirlo, y después cerraremos la serie con 🏆 Proyecto final: un sistema de informes de trabajo que llega solo cada mañana, juntando todo lo que hemos aprendido. ¡No te lo pierdas! 🙌

Si tienes dudas, déjalas en los comentarios 💬 ¡Nos vemos en la próxima entrega!

📚 Serie Automatización de oficina con Python

  • #10 - Generar documentos de Word automáticamente (python-docx) → Ver
  • #11 - Envío masivo de correos personalizados (smtplib) → Ver
  • #12 - Conectar con Google Sheets (gspread) → Ver
  • #13 - Sistema de logs, reintentos y alertas de fallos (logging·tenacity)
  • #14 - Generar informes de PowerPoint automáticamente (python-pptx) ← ¡este artículo!
  • #15 - Convertir a PDF y distribuir (próximamente)

Serie · Parte 14 de 14

Automatización de oficina con Python

  1. #1엑셀 자동화 입문 (openpyxl)! (en coreano)
  2. #2웹 크롤링 입문 (requests + BeautifulSoup)! (en coreano)
  3. #3파일 정리 자동화 (이름 일괄 변경)! (en coreano)
  4. #4디스코드 웹훅(Webhook)으로 알림 보내기 (requests)! (en coreano)
  5. #5로컬 LLM(LM Studio)으로 고객 문의 자동 분류·요약하기! (무료 AI) (en coreano)
  6. #6예약 실행 자동화! 매일 정해진 시간에 스크립트 돌리기 (schedule·APScheduler) (en coreano)
  7. #7동적 페이지·로그인 자동화! requests로 안 되는 사이트 긁기 (Selenium) (en coreano)
  8. #8PDF 자동 처리! 여러 파일 병합·분할부터 비밀번호까지 (pypdf) 📄 (en coreano)
  9. #9마우스·키보드 GUI 자동화! 클릭·타이핑을 파이썬이 대신하게 하기 (PyAutoGUI) (en coreano)
  10. #10워드(Word) 문서 자동 생성! 엑셀 명단으로 계약서·안내문 100장 뽑기 (python-docx) 📄 (en coreano)
  11. #11개인화 대량 메일 자동 발송! 명단별 첨부파일·발송 로그·재시도까지 (smtplib 실전편) 📬 (en coreano)
  12. #12구글 시트 연동하기! 팀원과 같이 보는 자동 시트 (gspread) 📗 (en coreano)
  13. #13파이썬 자동화 로그·재시도·디스코드 실패 알림 만들기 - 조용히 멈추는 스크립트 잡기 (en coreano)
  14. #14¡Genera informes de PowerPoint automáticamente! Cambia los números del Excel y la presentación queda lista (python-pptx) 📊Estás aquí
Ver las 14Contraer

Autor

Mojangcam

Un registro práctico de desarrollo e IT: código que escribo, servidores que mantengo y los errores que resuelvo por el camino.