"""Geração do PDF de "Resumo" do Relatório Contábil — reúne o Resumo do Fechamento (texto rico do contador), os cards de indicador e as observações visíveis ao cliente, o mesmo conteúdo da aba "Resumo" do relatório HTML "Gerar Dashboard" (ver `_contabil_dados_resumo()` em `views.py`). Identidade visual do escritório (logo cursivo, roxo/dourado do próprio relatório HTML), mesmo padrão de `custo_contratacao/pdf.py`/`indicadores/recibo.py` — nunca a marca "P.I.D." do Portal (ver "Logos" no CLAUDE.md raiz).""" from __future__ import annotations import base64 import io from datetime import date from pathlib import Path from typing import Any from xml.sax.saxutils import escape import bs4 from PIL import Image as PILImage from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import ( Image, ListFlowable, ListItem, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) _LOGO_BRANCO_PATH = Path(__file__).resolve().parents[2] / "static" / "img" / "logo-branco.png" # Mesma paleta roxo/dourado do próprio relatório HTML (dashboard-contabil-relatorio.html, # `--roxo-escuro`/`--roxo-mais-escuro`/`--dourado`) — não os tokens de tema do # Portal (que mudam com a cor escolhida pelo usuário logado). _COR_ROXO_ESCURO = colors.HexColor("#281552") _COR_DOURADO = colors.HexColor("#b4872a") _COR_DOURADO_CLARO_BANNER = colors.HexColor("#cbb98c") _COR_TEXTO = colors.HexColor("#241c33") _COR_TEXTO_MUTED = colors.HexColor("#6b6478") _COR_LINHA = colors.HexColor("#e4e0ec") _COR_ZEBRA = colors.HexColor("#f7f5fb") _LARGURA_PAGINA, _ALTURA_PAGINA = A4 _MARGEM = 1.8 * cm _ALTURA_BANNER = 2.6 * cm _LARGURA_UTIL = _LARGURA_PAGINA - 2 * _MARGEM def _dimensoes_imagem(caminho: Path, largura_pt: float) -> tuple[float, float]: with PILImage.open(caminho) as img: largura_px, altura_px = img.size return largura_pt, largura_pt * (altura_px / largura_px) def _ajusta_titulo_banner(canvas: Canvas, texto: str, largura_maxima: float) -> tuple[str, float]: """O nome da empresa é livre (vem do cadastro) e o banner tem largura fixa — reduz o tamanho da fonte até caber numa linha só e, se mesmo no tamanho mínimo ainda estourar, corta com reticências. Evita o corte duro que `drawString` faz por padrão (texto simplesmente sai da página).""" fonte = "Helvetica-Bold" tamanho = 15.0 tamanho_minimo = 10.0 while tamanho > tamanho_minimo and canvas.stringWidth(texto, fonte, tamanho) > largura_maxima: tamanho -= 0.5 if canvas.stringWidth(texto, fonte, tamanho) > largura_maxima: while texto and canvas.stringWidth(texto + "…", fonte, tamanho) > largura_maxima: texto = texto[:-1] texto = f"{texto}…" return texto, tamanho def _desenha_banner(canvas: Canvas, nome_empresa: str, competencia_label: str, gerado_em: str) -> None: topo = _ALTURA_PAGINA canvas.setFillColor(_COR_ROXO_ESCURO) canvas.rect(0, topo - _ALTURA_BANNER, _LARGURA_PAGINA, _ALTURA_BANNER, stroke=0, fill=1) canvas.setFillColor(_COR_DOURADO) canvas.rect(0, topo - _ALTURA_BANNER - 0.09 * cm, _LARGURA_PAGINA, 0.09 * cm, stroke=0, fill=1) logo_largura, logo_altura = _dimensoes_imagem(_LOGO_BRANCO_PATH, 2.9 * cm) logo_y = topo - _ALTURA_BANNER / 2 - logo_altura / 2 canvas.drawImage(str(_LOGO_BRANCO_PATH), _MARGEM, logo_y, width=logo_largura, height=logo_altura, mask="auto") texto_x = _MARGEM + logo_largura + 0.6 * cm centro_y = topo - _ALTURA_BANNER / 2 largura_disponivel = _LARGURA_PAGINA - _MARGEM - texto_x titulo, tamanho_titulo = _ajusta_titulo_banner( canvas, f"Resumo do Fechamento — {nome_empresa}", largura_disponivel ) canvas.setFillColor(colors.white) canvas.setFont("Helvetica-Bold", tamanho_titulo) canvas.drawString(texto_x, centro_y + 0.2 * cm, titulo) canvas.setFont("Helvetica", 9.5) canvas.setFillColor(_COR_DOURADO) canvas.drawString(texto_x, centro_y - 0.28 * cm, f"Competência {competencia_label}") canvas.setFont("Helvetica", 8) canvas.setFillColor(_COR_DOURADO_CLARO_BANNER) canvas.drawString(texto_x, centro_y - 0.62 * cm, f"Gerado em {gerado_em}") def _desenha_rodape(canvas: Canvas) -> None: canvas.setStrokeColor(_COR_DOURADO) canvas.setLineWidth(0.6) canvas.line(_MARGEM, 1.5 * cm, _LARGURA_PAGINA - _MARGEM, 1.5 * cm) canvas.setFont("Helvetica", 7.5) canvas.setFillColor(_COR_TEXTO_MUTED) canvas.drawCentredString(_LARGURA_PAGINA / 2, 1.15 * cm, "De Paula Contadores") def _pagina(canvas: Canvas, doc: SimpleDocTemplate, nome_empresa: str, competencia_label: str, gerado_em: str) -> None: canvas.saveState() _desenha_banner(canvas, nome_empresa, competencia_label, gerado_em) _desenha_rodape(canvas) canvas.setFont("Helvetica", 7.5) canvas.setFillColor(_COR_TEXTO_MUTED) canvas.drawRightString(_LARGURA_PAGINA - _MARGEM, 1.15 * cm, f"Página {doc.page}") canvas.restoreState() def _estilos() -> dict[str, ParagraphStyle]: base = getSampleStyleSheet() return { "h2": ParagraphStyle( "resumo_h2", parent=base["Heading2"], textColor=_COR_ROXO_ESCURO, fontSize=13, spaceBefore=14, spaceAfter=8 ), "normal": ParagraphStyle("resumo_normal", parent=base["Normal"], fontSize=9.5, textColor=_COR_TEXTO, leading=13), "bullet": ParagraphStyle("resumo_bullet", parent=base["Normal"], fontSize=9.5, textColor=_COR_TEXTO, leading=13), "muted": ParagraphStyle("resumo_muted", parent=base["Normal"], fontSize=8, textColor=_COR_TEXTO_MUTED, leading=11), "titulo_item": ParagraphStyle( "resumo_titulo_item", parent=base["Normal"], fontSize=9.5, textColor=_COR_ROXO_ESCURO, fontName="Helvetica-Bold", leading=12, ), "vazio": ParagraphStyle( "resumo_vazio", parent=base["Normal"], fontSize=9, textColor=_COR_TEXTO_MUTED, fontName="Helvetica-Oblique" ), } def _inline_markup(tag: Any) -> str: """Reconstrói o innerHTML de um elemento só com a marcação que o `Paragraph` do reportlab entende nativamente (``/``/``/`
`) — mesmo allowlist fechado de `RICHTEXT_ALLOWED_TAGS` (`serializers.py`: p/br/div/b/strong/i/em/u/ul/ol/li/img), então não precisa lidar com nada fora dele. `` é ignorada aqui de propósito — vira um flowable `Image` próprio, ver `_extrai_imagens()`.""" partes = [] for filho in tag.contents: if isinstance(filho, bs4.NavigableString): partes.append(escape(str(filho))) elif filho.name == "br": partes.append("
") elif filho.name in ("b", "strong"): partes.append(f"{_inline_markup(filho)}") elif filho.name in ("i", "em"): partes.append(f"{_inline_markup(filho)}") elif filho.name == "u": partes.append(f"{_inline_markup(filho)}") elif filho.name == "img": continue else: partes.append(_inline_markup(filho)) return "".join(partes) def _imagem_flowable(tag_img: Any) -> Image | None: """`` — a única forma de imagem que o editor do Resumo do Fechamento produz (colar/arrastar arquivo, ver `insertImageFile()` em `dashboard-contabil.js`). Qualquer outra coisa (src externo, ou decodificação inválida) é ignorada silenciosamente — o texto ao redor continua aparecendo normalmente.""" src = tag_img.get("src", "") if not src.startswith("data:image"): return None try: _cabecalho, b64 = src.split(",", 1) dados = base64.b64decode(b64) with PILImage.open(io.BytesIO(dados)) as img: largura_px, altura_px = img.size largura = min(_LARGURA_UTIL, largura_px * 0.75) altura = largura * (altura_px / largura_px) return Image(io.BytesIO(dados), width=largura, height=altura) except Exception: return None def _resumo_fechamento_flowables(html: str, estilos: dict[str, ParagraphStyle]) -> list[Any]: """Converte o texto rico do Resumo do Fechamento (já sanitizado por `nh3`) em flowables do reportlab — parágrafos, listas e imagens embutidas, na ordem em que aparecem.""" if not html or not html.strip(): return [] soup = bs4.BeautifulSoup(html, "html.parser") flowables: list[Any] = [] for elemento in soup.contents: if isinstance(elemento, bs4.NavigableString): if elemento.strip(): flowables.append(Paragraph(escape(str(elemento)), estilos["normal"])) continue nome = elemento.name if nome in ("p", "div"): for img_tag in elemento.find_all("img"): imagem = _imagem_flowable(img_tag) if imagem: flowables.append(imagem) flowables.append(Spacer(1, 0.2 * cm)) texto = _inline_markup(elemento) if texto.strip(): flowables.append(Paragraph(texto, estilos["normal"])) flowables.append(Spacer(1, 0.15 * cm)) elif nome in ("ul", "ol"): itens = [ListItem(Paragraph(_inline_markup(li), estilos["bullet"])) for li in elemento.find_all("li", recursive=False)] if itens: flowables.append(ListFlowable(itens, bulletType="bullet" if nome == "ul" else "1", leftIndent=14)) flowables.append(Spacer(1, 0.15 * cm)) elif nome == "img": imagem = _imagem_flowable(elemento) if imagem: flowables.append(imagem) flowables.append(Spacer(1, 0.2 * cm)) elif nome == "br": flowables.append(Spacer(1, 0.1 * cm)) else: texto = _inline_markup(elemento) if texto.strip(): flowables.append(Paragraph(texto, estilos["normal"])) flowables.append(Spacer(1, 0.15 * cm)) return flowables def _tabela_indicadores(grupo: dict[str, Any], estilos: dict[str, ParagraphStyle]) -> Table: dados = [[grupo["nome"], ""]] + [[card["nome"], card["valor_formatado"]] for card in grupo["cards"]] tabela = Table(dados, colWidths=[_LARGURA_UTIL - 5 * cm, 5 * cm]) estilo = [ ("SPAN", (0, 0), (-1, 0)), ("BACKGROUND", (0, 0), (-1, 0), _COR_DOURADO), ("TEXTCOLOR", (0, 0), (-1, 0), _COR_ROXO_ESCURO), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, 0), 10.5), ("TOPPADDING", (0, 0), (-1, 0), 7), ("BOTTOMPADDING", (0, 0), (-1, 0), 7), ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10), ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), ("FONTNAME", (1, 1), (1, -1), "Helvetica-Bold"), ("FONTSIZE", (0, 1), (-1, -1), 9.5), ("TEXTCOLOR", (0, 1), (-1, -1), _COR_TEXTO), ("ALIGN", (1, 0), (1, -1), "RIGHT"), ("LINEBELOW", (0, 1), (-1, -2), 0.5, _COR_LINHA), ("TOPPADDING", (0, 1), (-1, -1), 5), ("BOTTOMPADDING", (0, 1), (-1, -1), 5), ("BOX", (0, 0), (-1, -1), 0.75, _COR_DOURADO), ] for i in range(1, len(dados)): if i % 2 == 0: estilo.append(("BACKGROUND", (0, i), (-1, i), _COR_ZEBRA)) tabela.setStyle(TableStyle(estilo)) return tabela _PREFIXO_ALVO = {"conta": "Balancete", "dre": "D.R.E.", "analise_vertical": "Análise Vertical"} def _texto_com_quebras(texto: str) -> str: return escape(texto).replace("\n", "
") def _observacoes_flowables( apuracao: Any, dados_resumo: dict[str, Any], estilos: dict[str, ParagraphStyle] ) -> list[Any]: flowables: list[Any] = [] for obs in dados_resumo["observacoes_visiveis"]: prefixo = _PREFIXO_ALVO.get(obs.alvo_tipo, "") if obs.alvo_tipo == "conta": titulo = f"{prefixo} — {obs.alvo_chave} {obs.alvo_rotulo}" else: titulo = f"{prefixo} — {obs.alvo_rotulo}" assinatura = f"{obs.criado_por.nome if obs.criado_por else 'Equipe contábil'} · {obs.criado_em:%d/%m/%Y}" if obs.competencia_origem != apuracao.competencia: assinatura += f" · competência {obs.competencia_origem:%m/%Y}" flowables.append(Paragraph(escape(titulo), estilos["titulo_item"])) flowables.append(Paragraph(_texto_com_quebras(obs.texto), estilos["normal"])) flowables.append(Paragraph(escape(assinatura), estilos["muted"])) flowables.append(Spacer(1, 0.28 * cm)) for achado in dados_resumo["achados_com_observacao"]: titulo = f"Auditoria — {achado.titulo}" if achado.tratado_por: titulo += f" — {achado.tratado_por.nome}" flowables.append(Paragraph(escape(titulo), estilos["titulo_item"])) flowables.append(Paragraph(_texto_com_quebras(achado.observacao_contador), estilos["normal"])) flowables.append(Spacer(1, 0.28 * cm)) if not flowables: flowables.append(Paragraph("Nenhuma observação registrada nesta análise.", estilos["vazio"])) return flowables def gera_pdf_resumo(apuracao: Any, dados_resumo: dict[str, Any], gerado_em: str) -> bytes: """`apuracao` é a `ContabilApuracao`; `dados_resumo` é o dict devolvido por `_contabil_dados_resumo()` (`views.py`) — mesmo conteúdo da aba "Resumo" do relatório HTML "Gerar Dashboard", sem o resto do relatório (Balancete/D.R.E./Análise Vertical).""" buffer = io.BytesIO() doc = SimpleDocTemplate( buffer, pagesize=A4, topMargin=_ALTURA_BANNER + 0.8 * cm, bottomMargin=2.0 * cm, leftMargin=_MARGEM, rightMargin=_MARGEM, title=f"Resumo do Fechamento - {apuracao.nome_empresa}", ) estilos = _estilos() story: list[Any] = [] if apuracao.resumo_fechamento: story.append(Paragraph("Resumo do Fechamento", estilos["h2"])) story.extend(_resumo_fechamento_flowables(apuracao.resumo_fechamento, estilos)) story.append(Spacer(1, 0.3 * cm)) for grupo in dados_resumo["indicadores_grupos"]: # Sem Paragraph de título separado aqui — `_tabela_indicadores()` já # usa o nome do grupo como a própria barra de cabeçalho da tabela # (mesmo padrão de `_tabela_secao()` em custo_contratacao/pdf.py). story.append(_tabela_indicadores(grupo, estilos)) story.append(Spacer(1, 0.4 * cm)) story.append(Paragraph("Observações da Análise", estilos["h2"])) story.extend(_observacoes_flowables(apuracao, dados_resumo, estilos)) def _desenha_pagina(canvas: Canvas, doc: SimpleDocTemplate) -> None: competencia: date = apuracao.competencia _pagina(canvas, doc, apuracao.nome_empresa, f"{competencia:%m/%Y}", gerado_em) doc.build(story, onFirstPage=_desenha_pagina, onLaterPages=_desenha_pagina) return buffer.getvalue()