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

Classes e Objetos: os fundamentos da Orientação a Objetos
Classes e Objetos: os fundamentos da Orientação a Objetos

Classes, objetos, métodos dunder e encapsulamento em Python. Com duas…

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…

Listas: criação, manipulação e métodos
Listas: criação, manipulação e métodos

A estrutura que aparece em praticamente todo programa Python: criação, índices…