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
- openpyxl — documentação oficial — https://openpyxl.readthedocs.io/en/stable/
- ReportLab — guia do usuário — https://www.reportlab.com/docs/reportlab-userguide.pdf
- PyMuPDF — documentação oficial — https://pymupdf.readthedocs.io/en/latest/
- pandas — leitura de Excel — https://pandas.pydata.org/docs/reference/api/pandas.read_excel.html
- SWEIGART, Al. Automate the Boring Stuff with Python. 2. ed. No Starch Press, 2019. Cap. 12–13 — automação de planilhas Excel e PDFs com Python.
- MCKINNEY, Wes. Python for Data Analysis. 3. ed. O'Reilly Media, 2022. Cap. 6 — leitura e escrita de dados tabulares com pandas.
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.