Automação de Planilhas e PDFs

Automação de Planilhas e PDFs

Planilhas e PDFs com Python: boletim estilizado com gráfico no openpyxl, análise no pandas, PDF montado no ReportLab e marca d’água no PyMuPDF — e as armadilhas dos relatórios gerados, como a linha de totais lida como dado, a fórmula que volta None e a rotação que só aceita múltiplos de 90°.
Python

• • 19 min de leitura

Planilhas e PDFs são dois dos formatos mais onipresentes no ambiente corporativo — relatórios financeiros, boletins escolares, notas fiscais, contratos, folhas de pagamento. Manipulá-los manualmente é lento e sujeito a erros. Python tem bibliotecas maduras para automatizar completamente a criação, leitura e modificação desses arquivos, transformando horas de trabalho repetitivo em segundos de execução.

openpyxl: Planilhas Excel

pip install openpyxl

Criando Planilhas

from openpyxl import Workbook
from openpyxl.styles import (
    Font, Fill, PatternFill, Alignment,
    Border, Side, numbers
)
from openpyxl.utils import get_column_letter
from openpyxl.chart import BarChart, Reference
from datetime import datetime


def criar_boletim(alunos: list, output_path: str):
    wb = Workbook()
    ws = wb.active
    ws.title = "Boletim"

    # Estilos
    fonte_titulo  = Font(name="Calibri", bold=True, size=14, color="FFFFFF")
    fonte_header  = Font(name="Calibri", bold=True, size=11, color="FFFFFF")
    fonte_normal  = Font(name="Calibri", size=11)
    fill_titulo   = PatternFill("solid", fgColor="1F4E79")
    fill_header   = PatternFill("solid", fgColor="2E75B6")
    fill_aprovado = PatternFill("solid", fgColor="E2EFDA")
    fill_reprovado= PatternFill("solid", fgColor="FCE4D6")

    borda = Border(
        left=Side(style="thin"),
        right=Side(style="thin"),
        top=Side(style="thin"),
        bottom=Side(style="thin")
    )

    centralizado = Alignment(horizontal="center", vertical="center")

    # Título
    ws.merge_cells("A1:F1")
    ws["A1"] = "BOLETIM ESCOLAR — 2024"
    ws["A1"].font       = fonte_titulo
    ws["A1"].fill       = fill_titulo
    ws["A1"].alignment  = centralizado
    ws.row_dimensions[1].height = 30

    # Subtítulo
    ws.merge_cells("A2:F2")
    ws["A2"] = f"Gerado em {datetime.now().strftime('%d/%m/%Y às %H:%M')}"
    ws["A2"].font      = Font(name="Calibri", italic=True, size=10, color="666666")
    ws["A2"].alignment = centralizado

    # Cabeçalhos
    headers = ["#", "Nome", "Email", "Nota", "Status", "Conceito"]
    for col, header in enumerate(headers, 1):
        cell           = ws.cell(row=3, column=col, value=header)
        cell.font      = fonte_header
        cell.fill      = fill_header
        cell.alignment = centralizado
        cell.border    = borda

    ws.row_dimensions[3].height = 20

    # Dados
    for idx, aluno in enumerate(alunos, 1):
        linha = idx + 3
        nota  = aluno["nota"]

        if nota >= 9:    conceito = "A"
        elif nota >= 7:  conceito = "B"
        elif nota >= 6:  conceito = "C"
        else:            conceito = "F"

        status   = "Aprovado" if nota >= 6 else "Reprovado"
        fill_row = fill_aprovado if nota >= 6 else fill_reprovado
        # sem zebra por cima: ela pintava de cinza o aprovado de linha par,
        # e a cor deixava de dizer a situação do aluno

        valores = [idx, aluno["nome"], aluno["email"], nota, status, conceito]
        for col, valor in enumerate(valores, 1):
            cell           = ws.cell(row=linha, column=col, value=valor)
            cell.font      = fonte_normal
            cell.fill      = fill_row
            cell.border    = borda
            cell.alignment = centralizado

        # Formata a nota com 1 casa decimal
        ws.cell(row=linha, column=4).number_format = "0.0"

    # Linha de totais
    total_linha = len(alunos) + 4
    ws.merge_cells(f"A{total_linha}:C{total_linha}")
    ws[f"A{total_linha}"]      = "ESTATÍSTICAS"
    ws[f"A{total_linha}"].font = Font(bold=True)

    notas = [a["nota"] for a in alunos]
    ws[f"D{total_linha}"]      = sum(notas) / len(notas)
    ws[f"D{total_linha}"].number_format = "0.00"
    ws[f"D{total_linha}"].font = Font(bold=True)

    aprovados = sum(1 for n in notas if n >= 6)
    ws[f"E{total_linha}"]      = f"{aprovados}/{len(alunos)} aprovados"
    ws[f"E{total_linha}"].font = Font(bold=True)

    # Largura das colunas
    larguras = {"A": 5, "B": 25, "C": 30, "D": 10, "E": 12, "F": 10}
    for col, largura in larguras.items():
        ws.column_dimensions[col].width = largura

    # Congela o cabeçalho
    ws.freeze_panes = "A4"

    # Gráfico de barras
    chart      = BarChart()
    chart.type = "col"
    chart.title     = "Distribuição de Notas"
    chart.y_axis.title = "Nota"
    chart.x_axis.title = "Aluno"

    dados_chart = Reference(
        ws,
        min_col=4, max_col=4,
        min_row=3, max_row=len(alunos) + 3
    )
    nomes_chart = Reference(
        ws,
        min_col=2, max_col=2,
        min_row=4, max_row=len(alunos) + 3
    )
    chart.add_data(dados_chart, titles_from_data=True)
    chart.set_categories(nomes_chart)
    chart.shape    = 4
    chart.width    = 20
    chart.height   = 12

    ws.add_chart(chart, f"A{total_linha + 2}")

    wb.save(output_path)
    print(f"Boletim salvo: {output_path}")


alunos = [
    {"nome": "Ana Silva",    "email": "ana@email.com",    "nota": 9.5},
    {"nome": "Bruno Costa",  "email": "bruno@email.com",  "nota": 7.0},
    {"nome": "Carla Souza",  "email": "carla@email.com",  "nota": 8.5},
    {"nome": "Diego Lima",   "email": "diego@email.com",  "nota": 5.5},
    {"nome": "Elena Pires",  "email": "elena@email.com",  "nota": 6.0},
]

criar_boletim(alunos, "boletim_2024.xlsx")

Lendo Planilhas Existentes

from openpyxl import load_workbook


def ler_planilha(caminho: str) -> list:
    """Lê planilha e retorna lista de dicionários."""
    wb = load_workbook(caminho, read_only=True, data_only=True)
    # data_only=True lê o valor calculado que o Excel gravou. Em arquivo salvo
    # pelo openpyxl, que não calcula fórmulas, a célula com fórmula vem None
    ws = wb.active

    dados = []
    headers = [cell.value for cell in ws[1]]   # primeira linha = cabeçalho

    for linha in ws.iter_rows(min_row=2, values_only=True):
        if any(v is not None for v in linha):   # ignora linhas vazias
            registro = dict(zip(headers, linha))
            dados.append(registro)

    wb.close()
    return dados


def processar_multiplas_abas(caminho: str):
    """Processa todas as abas de uma planilha."""
    wb = load_workbook(caminho, data_only=True)

    for nome_aba in wb.sheetnames:
        ws     = wb[nome_aba]
        linhas = list(ws.iter_rows(values_only=True))
        print(f"\nAba: {nome_aba} — {len(linhas)} linhas")
        for linha in linhas[:3]:
            print(f"  {linha}")

    wb.close()

pandas: Análise de Planilhas em Escala

Para planilhas grandes, pandas é muito mais eficiente:

pip install pandas openpyxl   # xlrd só para o .xls antigo; desde a 2.0 ele não lê .xlsx
import pandas as pd


def analisar_boletim(caminho: str):
    """Análise completa de boletim com pandas."""

    # Lendo Excel
    df = pd.read_excel(caminho, sheet_name="Boletim", header=2)
    # A linha de ESTATÍSTICAS entraria como um 6.º aluno (count 6 e 5
    # "aprovados", com a média no lugar da nota): ela não tem e-mail
    df = df[df["Email"].notna()]

    print("=== Visão Geral ===")
    print(df.describe())

    # Filtrando
    aprovados  = df[df["Nota"] >= 6.0]
    reprovados = df[df["Nota"] < 6.0]

    print(f"\nAprovados:  {len(aprovados)}")
    print(f"Reprovados: {len(reprovados)}")
    print(f"Média:      {df['Nota'].mean():.2f}")

    # Agrupando
    por_conceito = df.groupby("Conceito")["Nota"].agg(["count", "mean"])
    print("\nPor conceito:")
    print(por_conceito)

    # Exportando resultado filtrado
    aprovados.to_excel("aprovados.xlsx", index=False)

    # Múltiplas abas
    with pd.ExcelWriter("relatorio_completo.xlsx", engine="openpyxl") as writer:
        df.to_excel(writer,         sheet_name="Todos",      index=False)
        aprovados.to_excel(writer,  sheet_name="Aprovados",  index=False)
        reprovados.to_excel(writer, sheet_name="Reprovados", index=False)

    print("\nRelatório completo salvo.")

ReportLab: Criando PDFs do Zero

pip install reportlab
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.platypus import (
    SimpleDocTemplate, Paragraph, Spacer,
    Table, TableStyle, HRFlowable
)
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from datetime import datetime


def gerar_boletim_pdf(alunos: list, output_path: str):
    """Gera boletim escolar em PDF com ReportLab."""

    doc    = SimpleDocTemplate(
        output_path,
        pagesize=A4,
        rightMargin=2*cm, leftMargin=2*cm,
        topMargin=2*cm,   bottomMargin=2*cm
    )
    estilos = getSampleStyleSheet()
    elementos = []

    # Estilos customizados
    estilo_titulo = ParagraphStyle(
        "Titulo",
        parent=estilos["Heading1"],
        fontSize=18,
        textColor=colors.HexColor("#1F4E79"),
        alignment=TA_CENTER,
        spaceAfter=6
    )
    estilo_subtitulo = ParagraphStyle(
        "Subtitulo",
        parent=estilos["Normal"],
        fontSize=10,
        textColor=colors.grey,
        alignment=TA_CENTER,
        spaceAfter=20
    )
    estilo_normal = ParagraphStyle(
        "Normal2",
        parent=estilos["Normal"],
        fontSize=10,
        spaceAfter=6
    )

    # Cabeçalho
    elementos.append(Paragraph("SISTEMA ESCOLAR", estilo_titulo))
    elementos.append(Paragraph("Boletim de Desempenho — 2024", estilo_subtitulo))
    elementos.append(HRFlowable(width="100%", thickness=2, color=colors.HexColor("#1F4E79")))
    elementos.append(Spacer(1, 0.5*cm))

    # Informações gerais
    notas      = [a["nota"] for a in alunos]
    media      = sum(notas) / len(notas)
    aprovados  = sum(1 for n in notas if n >= 6)

    info = [
        [Paragraph("<b>Total de Alunos:</b>", estilo_normal),
         Paragraph(str(len(alunos)), estilo_normal)],
        [Paragraph("<b>Média Geral:</b>", estilo_normal),
         Paragraph(f"{media:.2f}", estilo_normal)],
        [Paragraph("<b>Aprovados:</b>", estilo_normal),
         Paragraph(f"{aprovados} ({aprovados/len(alunos)*100:.1f}%)", estilo_normal)],
        [Paragraph("<b>Data de Emissão:</b>", estilo_normal),
         Paragraph(datetime.now().strftime("%d/%m/%Y às %H:%M"), estilo_normal)],
    ]

    tabela_info = Table(info, colWidths=[5*cm, 10*cm])
    tabela_info.setStyle(TableStyle([
        ("VALIGN",       (0, 0), (-1, -1), "MIDDLE"),
        ("BOTTOMPADDING",(0, 0), (-1, -1), 6),
    ]))
    elementos.append(tabela_info)
    elementos.append(Spacer(1, 0.8*cm))

    # Tabela de alunos
    elementos.append(Paragraph("<b>Resultado por Aluno</b>", estilo_normal))
    elementos.append(Spacer(1, 0.3*cm))

    dados_tabela = [["#", "Nome", "E-mail", "Nota", "Status"]]

    for idx, aluno in enumerate(alunos, 1):
        status = "Aprovado" if aluno["nota"] >= 6 else "Reprovado"
        dados_tabela.append([
            str(idx),
            aluno["nome"],
            aluno["email"],
            f"{aluno['nota']:.1f}",
            status
        ])

    tabela = Table(
        dados_tabela,
        colWidths=[1*cm, 5*cm, 6*cm, 2*cm, 3*cm]
    )

    # Estilo da tabela
    estilo_tabela = TableStyle([
        # Cabeçalho
        ("BACKGROUND",    (0, 0), (-1, 0),  colors.HexColor("#2E75B6")),
        ("TEXTCOLOR",     (0, 0), (-1, 0),  colors.white),
        ("FONTNAME",      (0, 0), (-1, 0),  "Helvetica-Bold"),
        ("FONTSIZE",      (0, 0), (-1, 0),  10),
        ("ALIGN",         (0, 0), (-1, 0),  "CENTER"),
        ("BOTTOMPADDING", (0, 0), (-1, 0),  8),
        ("TOPPADDING",    (0, 0), (-1, 0),  8),

        # Corpo
        ("FONTNAME",      (0, 1), (-1, -1), "Helvetica"),
        ("FONTSIZE",      (0, 1), (-1, -1), 9),
        ("ALIGN",         (0, 1), (-1, -1), "CENTER"),
        ("VALIGN",        (0, 0), (-1, -1), "MIDDLE"),
        ("TOPPADDING",    (0, 1), (-1, -1), 6),
        ("BOTTOMPADDING", (0, 1), (-1, -1), 6),

        # Linhas alternadas
        *[("BACKGROUND", (0, i), (-1, i), colors.HexColor("#F2F2F2"))
          for i in range(2, len(dados_tabela), 2)],

        # Aprovados/Reprovados na última coluna
        *[("TEXTCOLOR", (4, i), (4, i),
           colors.HexColor("#375623") if dados_tabela[i][4] == "Aprovado"
           else colors.HexColor("#C00000"))
          for i in range(1, len(dados_tabela))],

        # Bordas
        ("GRID",          (0, 0), (-1, -1), 0.5, colors.HexColor("#CCCCCC")),
        ("BOX",           (0, 0), (-1, -1), 1,   colors.HexColor("#2E75B6")),
    ])
    tabela.setStyle(estilo_tabela)
    elementos.append(tabela)

    # Rodapé com estatísticas
    elementos.append(Spacer(1, 1*cm))
    elementos.append(HRFlowable(width="100%", thickness=1, color=colors.grey))
    elementos.append(Spacer(1, 0.3*cm))

    rodape = ParagraphStyle(
        "Rodape",
        parent=estilos["Normal"],
        fontSize=8,
        textColor=colors.grey,
        alignment=TA_CENTER
    )
    elementos.append(Paragraph(
        "Documento gerado automaticamente pelo Sistema Escolar. "
        "Não requer assinatura.",
        rodape
    ))

    # Gera o PDF
    doc.build(elementos)
    print(f"PDF gerado: {output_path}")


gerar_boletim_pdf(alunos, "boletim_2024.pdf")

PyMuPDF: Lendo e Manipulando PDFs

pip install pymupdf
import pymupdf   # "import fitz" ainda funciona, mas avisa que será removido


def extrair_texto_pdf(caminho: str) -> str:
    """Extrai todo o texto de um PDF."""
    doc   = pymupdf.open(caminho)
    texto = ""

    for pagina in doc:
        texto += pagina.get_text()

    doc.close()
    return texto


def extrair_por_pagina(caminho: str) -> list:
    """Extrai texto por página."""
    doc    = pymupdf.open(caminho)
    paginas = []

    for num, pagina in enumerate(doc, 1):
        paginas.append({
            "pagina":  num,
            "texto":   pagina.get_text(),
            "tamanho": pagina.rect
        })

    doc.close()
    return paginas


def dividir_pdf(caminho: str, paginas_por_arquivo: int = 10):
    """Divide PDF em arquivos menores."""
    doc     = pymupdf.open(caminho)
    total   = len(doc)
    base    = caminho.replace(".pdf", "")
    partes  = []

    for inicio in range(0, total, paginas_por_arquivo):
        fim    = min(inicio + paginas_por_arquivo, total)
        novo   = pymupdf.open()

        novo.insert_pdf(doc, from_page=inicio, to_page=fim - 1)
        saida = f"{base}_parte_{inicio//paginas_por_arquivo + 1}.pdf"
        novo.save(saida)
        novo.close()
        partes.append(saida)
        print(f"Salvo: {saida} ({fim - inicio} páginas)")

    doc.close()
    return partes


def mesclar_pdfs(lista_caminhos: list, saida: str):
    """Mescla múltiplos PDFs em um único arquivo."""
    resultado = pymupdf.open()

    for caminho in lista_caminhos:
        doc = pymupdf.open(caminho)
        resultado.insert_pdf(doc)
        doc.close()
        print(f"Adicionado: {caminho}")

    resultado.save(saida)
    resultado.close()
    print(f"\nMesclado: {saida}")


def adicionar_marca_dagua(caminho: str, texto: str, saida: str):
    """Adiciona marca d'água diagonal em todas as páginas."""
    doc = pymupdf.open(caminho)

    for pagina in doc:
        rect    = pagina.rect
        centro  = pymupdf.Point(rect.width / 2, rect.height / 2)
        largura = pymupdf.get_text_length(texto, fontsize=50)
        inicio  = pymupdf.Point(centro.x - largura / 2, centro.y)   # centralizado
        pagina.insert_text(
            inicio,
            texto,
            fontsize=50,
            color=(0.8, 0.8, 0.8),   # cinza claro
            # rotate só aceita 0, 90, 180 e 270 (45 dá "bad rotate value");
            # ângulo livre é com morph: gira 45° em torno do centro
            morph=(centro, pymupdf.Matrix(45)),
        )

    doc.save(saida)
    doc.close()
    print(f"Marca d'água adicionada: {saida}")

Exemplo Completo: Pipeline de Relatórios

from pathlib import Path
from datetime import datetime
import json


def pipeline_relatorios(dados_json: str, pasta_saida: str):
    """
    Pipeline completo:
    1. Lê dados JSON
    2. Gera planilha Excel
    3. Gera PDF
    4. Adiciona marca d'água no PDF
    """
    pasta = Path(pasta_saida)
    pasta.mkdir(exist_ok=True)

    # 1. Carrega dados
    with open(dados_json, encoding="utf-8") as f:
        dados = json.load(f)

    alunos  = dados["alunos"]
    periodo = dados.get("periodo", datetime.now().strftime("%Y-%m"))
    print(f"Processando {len(alunos)} alunos — Período: {periodo}")

    # 2. Gera Excel
    excel_path = str(pasta / f"boletim_{periodo}.xlsx")
    criar_boletim(alunos, excel_path)

    # 3. Gera PDF
    pdf_path    = str(pasta / f"boletim_{periodo}.pdf")
    pdf_final   = str(pasta / f"boletim_{periodo}_oficial.pdf")
    gerar_boletim_pdf(alunos, pdf_path)

    # 4. Adiciona marca d'água
    adicionar_marca_dagua(pdf_path, "DOCUMENTO OFICIAL", pdf_final)

    # 5. Remove PDF intermediário
    Path(pdf_path).unlink()

    print(f"\n✓ Pipeline concluído:")
    print(f"  Excel: {excel_path}")
    print(f"  PDF:   {pdf_final}")

    return {"excel": excel_path, "pdf": pdf_final}


# Dados de exemplo
dados_exemplo = {
    "periodo": "2024-03",
    "alunos": [
        {"nome": "Ana Silva",    "email": "ana@email.com",    "nota": 9.5},
        {"nome": "Bruno Costa",  "email": "bruno@email.com",  "nota": 7.0},
        {"nome": "Carla Souza",  "email": "carla@email.com",  "nota": 8.5},
        {"nome": "Diego Lima",   "email": "diego@email.com",  "nota": 5.5},
        {"nome": "Elena Pires",  "email": "elena@email.com",  "nota": 6.0},
    ]
}

with open("dados.json", "w", encoding="utf-8") as f:
    json.dump(dados_exemplo, f, ensure_ascii=False, indent=2)

resultado = pipeline_relatorios("dados.json", "relatorios")

Cada formato tem sua ferramenta. O openpyxl escreve e lê o .xlsx célula a célula, com estilos, células mescladas, painéis congelados e gráficos; o pandas trata a planilha como tabela, para filtrar, agrupar e exportar em várias abas; o ReportLab monta um PDF do zero a partir de blocos — parágrafos, tabelas, espaçadores — que ele próprio distribui pelas páginas; e o PyMuPDF abre PDFs prontos para extrair texto, dividir, mesclar e carimbar. O pipeline do fim junta tudo, e é o mesmo tipo de script de Automação com Python: arquivos, pastas e sistema operacional, só que produzindo documentos em vez de mover arquivos.

O ponto fraco desses relatórios é que o arquivo gerado parece certo mesmo quando não está. Uma linha de totais lida de volta vira mais um aluno, uma fórmula salva pelo openpyxl volta vazia porque ninguém a calculou, e uma cor usada para enfeite apaga a cor que carregava informação. Vale reler o que foi gerado com o mesmo código que vai consumi-lo, separar os dados da apresentação dentro da planilha, e escapar todo texto que vem de fora antes de entregá-lo a um Paragraph.

Fontes e leituras recomendadas

Exercícios

Exercício 1

A coordenação recebe dois números do mesmo boletim: a planilha, aberta no Excel, mostra 4 aprovados em 5 alunos; o relatório gerado pelo analisar_boletim diz "Aprovados: 5", e o describe() mostra count 6. A média, curiosamente, bate nos dois: 7,30. Ninguém mexeu nas notas. De onde vem o sexto aluno, e por que a média não denunciou o erro?

Ver resposta

✓ Resposta: O sexto "aluno" é a linha de ESTATÍSTICAS que o criar_boletim escreve abaixo dos dados. O pd.read_excel(..., header=2) pula o título e usa a linha 3 como cabeçalho, mas lê tudo o que vem depois, e a linha de totais tem um número na coluna Nota — a média da turma. Medido com o artigo original: count 6.0, "Aprovados: 5" e "Média: 7.30". A média não mudou por uma coincidência matemática: acrescentar a um conjunto um valor igual à média dele mantém a média. Por isso a média, o número que todo mundo confere, era justamente o único que não denunciava o erro. A correção filtra as linhas que não são de aluno — a de totais não tem e-mail, e df[df["Email"].notna()] deu count 5 e 4 aprovados. A solução estrutural é separar as coisas: dados numa aba, totais e gráficos em outra, ou pelo menos ler com nrows igual ao número de alunos. Uma planilha feita para gente ler raramente é uma boa fonte para programa ler.

Exercício 2

Para que os totais "fiquem vivos", um desenvolvedor troca o valor fixo da média por ws["D9"] = "=AVERAGE(D4:D8)". No Excel, a célula mostra 7,3. Mas o script noturno, que relê o arquivo com load_workbook(caminho, data_only=True) para enviar o resumo por e-mail, passa a enviar "Média: None". Explique e diga como resolver.

Ver resposta

✓ Resposta: Um .xlsx guarda, para cada célula com fórmula, o texto da fórmula e o último valor calculado. Quem calcula é o Excel (ou o LibreOffice) ao abrir e salvar; o openpyxl só escreve a fórmula, sem valor. Com data_only=True, o openpyxl devolve o valor guardado — e não há nenhum. Medido: uma célula com =SUM(A1:A2) salva pelo openpyxl voltou None com data_only=True e '=SUM(A1:A2)' sem ele. No Excel aparece 7,3 porque o Excel recalcula ao abrir. Se alguém abrir e salvar o arquivo no Excel, o valor passa a existir, o que torna o defeito intermitente e ainda mais confuso. A solução é o script calcular os números que ele mesmo vai usar — a média vem das notas em Python — e usar a fórmula só para quem vai abrir a planilha. Se o valor precisar sair do arquivo, o recálculo tem que acontecer antes, por exemplo com o LibreOffice em modo headless convertendo o arquivo. Isso também desmente uma regra comum: data_only=True não "calcula as fórmulas", só lê o que já foi calculado.

Exercício 3

O pipeline de relatórios roda à noite. Pela manhã, a pasta relatorios tem o .xlsx e um boletim_2024-03.pdf sem marca d'água, e nenhum _oficial.pdf. O log termina com ValueError: bad rotate value. A secretaria, sem ver o log, enviou aos pais o PDF que estava lá. O que falhou, e o que o pipeline deveria ter feito para que isso não acontecesse?

Ver resposta

✓ Resposta: O insert_text do PyMuPDF aceita rotate só em múltiplos de 90 — 0, 90, 180 e 270 —, e a marca d'água usava 45. Medido: ValueError: bad rotate value na primeira página. A exceção interrompeu o pipeline no passo 4, depois de o PDF intermediário já estar gravado e antes do passo 5, que o apagaria. Sobrou na pasta, com o nome de um documento de verdade, um arquivo que nunca deveria ser visto. Para girar num ângulo qualquer, o parâmetro é morph=(centro, pymupdf.Matrix(45)), que aplica a rotação em torno de um ponto; medido, o texto saiu na diagonal, centralizado na página. O defeito maior, porém, é de desenho do pipeline: produtos intermediários devem ser gerados em uma pasta temporária (tempfile.TemporaryDirectory), que some sozinha em caso de erro, e só o arquivo final, completo, é movido para a pasta de saída. Assim, o que está em relatorios é sempre algo que terminou; um erro no meio não deixa nada pela metade à vista de quem vai usar.

Exercício 4

No boletim gerado pelo criar_boletim original, a professora reclama: "o Bruno está em cinza, como se não tivesse nota; a Carla está em verde". Os dois foram aprovados. O código tinha a intenção de fazer linhas alternadas, "para facilitar a leitura". O que aconteceu, e qual é a regra de projeto por trás?

Ver resposta

✓ Resposta: A zebra era aplicada por cima da cor de situação, e só para aprovados em linha par: if nota >= 6 and idx % 2 == 0: fill_row = fill_linha_par. Medido na planilha gerada: a célula do Bruno (linha 2) ficou F2F2F2, cinza, e a da Carla (linha 3), E2EFDA, verde. O leitor aprende que verde é aprovado e vermelho é reprovado — e aí encontra um aprovado sem cor, o que parece um terceiro estado. A regra é que uma propriedade visual carrega uma informação só: se o fundo diz a situação, não pode também dizer a paridade da linha. Para as duas coisas, use canais diferentes: o fundo para a situação e uma borda inferior mais forte a cada linha para a leitura; ou a zebra no fundo e a situação na cor do texto da coluna Status, como faz o PDF do artigo. E a cor nunca deve ser o único sinal: a coluna Status já traz "Aprovado" escrito, o que atende também quem não distingue verde de vermelho.

Exercício 5

O PDF do boletim passa a mostrar o nome da turma no cabeçalho, com Paragraph(f"<b>Turma:</b> {turma}", estilo_normal). Com "3º Ano & Técnico" e "Nota < 6 em recuperação", tudo funciona. Com a turma "Turma <A>", a geração inteira aborta com ValueError e nenhum boletim sai. Por que só esse nome quebra, e como tratar texto externo em ReportLab?

Ver resposta

✓ Resposta: O Paragraph do ReportLab interpreta o texto como uma marcação parecida com HTML — é o que faz o <b> virar negrito. Um <A> no nome é lido como uma tag desconhecida, e o parser desiste. Medido: "Colégio Santos & Filhos" e "Nota < 6" saíram corretos, porque o parser tolera um & solto e um < seguido de espaço; "Turma <A>" levantou ValueError: ... Parse error. Essa tolerância parcial é o que torna o defeito traiçoeiro: os testes passam com dados comuns, e o primeiro nome com sinal de menor e maior derruba a geração em produção. A regra é a mesma do HTML: todo texto que vem de fora é escapado antes de entrar na marcação, com xml.sax.saxutils.escape — medido, escape("Turma <A>") saiu exatamente como digitado. Só a marcação que o próprio programa escreve, como o <b> do rótulo, fica sem escape. Nas células de Table com texto simples, como os nomes da tabela de alunos do artigo, não há interpretação e o problema não existe.

Comentários

Mais em Python

Projeto 3: Bot de Automação com Scraping e Notificações
Projeto 3: Bot de Automação com Scraping e Notificações

Um bot de vagas com coleta pela API do RemoteOK, filtro por pontuação, SQLite…

Processamento de Linguagem Natural (NLP)
Processamento de Linguagem Natural (NLP)

NLP em Python com NLTK, spaCy, TF-IDF, Word2Vec e Hugging Face Transformers no…

Interfaces, Protocolos e Composição
Interfaces, Protocolos e Composição

Por que preferir composição à herança, e como declarar contratos em Python sem…