#!/usr/bin/env python3
"""
# ══════════════════════════════════════════════════════════════
# ☢︎ RADIOTERAPIA.AI — POP de elite
# app.py — Hugging Face Spaces
# por: Braga, HF.
# ══════════════════════════════════════════════════════════════
"""
import json, os, tempfile, io
from datetime import datetime
try:
import graphviz as gv
HAS_GRAPHVIZ = True
except ImportError:
HAS_GRAPHVIZ = False
from docx import Document
from docx.shared import Pt, Cm, Emu, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn, nsdecls
from docx.oxml import parse_xml
# ============================================================
# SISTEMA DE CORES DINÂMICO
# ============================================================
def hex_to_rgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
def rgb_to_hex(r, g, b):
return f"{min(255,max(0,int(r))):02X}{min(255,max(0,int(g))):02X}{min(255,max(0,int(b))):02X}"
def lighten(hex_color, pct):
"""Clareia uma cor por pct% misturando com branco."""
r, g, b = hex_to_rgb(hex_color)
return rgb_to_hex(r + (255-r)*pct, g + (255-g)*pct, b + (255-b)*pct)
def darken(hex_color, pct):
r, g, b = hex_to_rgb(hex_color)
return rgb_to_hex(r*(1-pct), g*(1-pct), b*(1-pct))
def build_palette(primary_hex):
"""Gera paleta completa a partir de uma cor primária."""
p = primary_hex.lstrip("#").upper()
return {
"primary": p, # H1, bullets, table headers
"primary_dark": darken(p, 0.15), # Texto sobre fundo claro
"secondary": lighten(p, 0.45), # H2, SmartArt — +10% mais claro que v4.0
"tertiary": lighten(p, 0.70), # Footer header, zebra — +10% mais claro
"quaternary": lighten(p, 0.85), # Zebra alternada mais clara
"text_on_primary": "FFFFFF", # Texto sobre cor primária
"text_on_secondary": darken(p, 0.30), # Texto sobre cor secundária
"text_body": "1A1A1A",
"header_text": "000000", # PRETO automático para cabeçalho
"border": darken(p, 0.0),
"border_light": lighten(p, 0.20),
"risk_red": "8B1A1A",
"barrier_green": "1B5E20",
"gray_border": "7F8C9A",
"annex_border": "000000",
}
def parse_color_input(val):
"""Parseia qualquer formato de cor do Gradio ColorPicker → hex 6 chars."""
if not val:
return DEFAULT_PRIMARY
val = str(val).strip()
# Formato rgb(R, G, B) ou rgba(R, G, B, A) — COM FLOATS
if val.startswith("rgb"):
import re
# Capturar números com decimais: 248.402, 79.543, etc.
nums = re.findall(r'[\d]+\.?[\d]*', val)
if len(nums) >= 3:
r = min(255, max(0, int(float(nums[0]))))
g = min(255, max(0, int(float(nums[1]))))
b = min(255, max(0, int(float(nums[2]))))
return rgb_to_hex(r, g, b)
return DEFAULT_PRIMARY
# Formato hex
c = val.lstrip("#")
c = ''.join(ch for ch in c if ch in '0123456789abcdefABCDEF')
if len(c) >= 6:
return c[:6].upper()
if len(c) == 3:
return (c[0]*2 + c[1]*2 + c[2]*2).upper()
return DEFAULT_PRIMARY
DEFAULT_PRIMARY = "283264"
FONTE = "Calibri"
TAM_CORPO = Pt(11)
TAM_H1 = Pt(12)
TAM_H2 = Pt(11)
TAM_SMALL = Pt(9)
TAM_HEADER = Pt(9)
TAM_TINY = Pt(8)
# Página A4
MARGEM_SUP = Cm(6.0) # 6cm — espaço uniforme header↔corpo em todas as páginas
MARGEM_INF = Cm(3.0)
MARGEM_ESQ = Cm(2.5)
MARGEM_DIR = Cm(2.0)
LARGURA_DXA = int((21 - 2.5 - 2.0) * 567)
# Indentação hierárquica
IND_H2 = 0.5
IND_BODY = 0.5
IND_ITEM = 1.0
IND_SUBITEM = 1.5
# ============================================================
# UTILITÁRIOS DOCX
# ============================================================
def set_shading(cell, color):
cell._tc.get_or_add_tcPr().append(
parse_xml(f''))
def set_borders(cell, top=None, bottom=None, left=None, right=None):
tcPr = cell._tc.get_or_add_tcPr()
borders = parse_xml(f'')
for side, val in [("top",top),("bottom",bottom),("left",left),("right",right)]:
if val:
c, s = val if isinstance(val, tuple) else (val, "4")
borders.append(parse_xml(
f''))
tcPr.append(borders)
def set_valign(cell, v="center"):
cell._tc.get_or_add_tcPr().append(parse_xml(f''))
def set_width(cell, w):
cell._tc.get_or_add_tcPr().append(
parse_xml(f''))
def set_margins(cell, t=0, b=0, l=80, r=80):
cell._tc.get_or_add_tcPr().append(parse_xml(
f''
f''
f''))
def fmt(p, text, bold=False, italic=False, size=None, color=None, font=FONTE, caps=False):
run = p.add_run(text)
run.bold = bold; run.italic = italic
if size: run.font.size = size
if color: run.font.color.rgb = RGBColor.from_string(color)
run.font.name = font
if caps: run.font.all_caps = True
return run
def _add_highlight_to_run(run, highlight_color="cyan"):
"""Aplica highlight (fundo colorido) a um run via XML.
Aceita nomes Word (cyan, yellow...) ou hex 6-char para shading."""
rPr = run._r.get_or_add_rPr()
if len(highlight_color) == 6 and all(c in '0123456789ABCDEFabcdef' for c in highlight_color):
# Hex color → usar shading (aceita qualquer cor)
rPr.append(parse_xml(f''))
else:
# Nome Word predefinido (cyan, yellow, etc)
rPr.append(parse_xml(f''))
def fmt_with_meta_badges(p, text, size=None, color="1A1A1A", font=FONTE, pal=None):
"""Renderiza texto com badges visuais para Metas OMS (Meta 1..Meta 6).
Metas ficam em bold + highlight com cor terciária da paleta."""
import re
pattern = re.compile(r'(Meta\s+[1-6])', re.IGNORECASE)
parts = pattern.split(text)
badge_color = pal["tertiary"] if pal else "cyan"
badge_text_color = pal["primary_dark"] if pal else "1A3A6E"
for part in parts:
if pattern.match(part):
run = p.add_run(f" {part} ")
run.bold = True
run.font.name = font
if size: run.font.size = size
run.font.color.rgb = RGBColor.from_string(badge_text_color)
_add_highlight_to_run(run, badge_color)
else:
if part:
run = p.add_run(part)
run.font.name = font
if size: run.font.size = size
if color: run.font.color.rgb = RGBColor.from_string(color)
def spacing(p, before=0, after=0, line=1.15):
pf = p.paragraph_format
pf.space_before = Pt(before); pf.space_after = Pt(after); pf.line_spacing = line
def p_shading(p, color):
p._p.get_or_add_pPr().append(
parse_xml(f''))
def repeat_header(row):
row._tr.get_or_add_trPr().append(parse_xml(f''))
def page_break(doc):
p = doc.add_paragraph()
p.add_run()._r.append(parse_xml(f''))
# ============================================================
# HEADER + FOOTER
# ============================================================
def build_header(section, meta, pal, logo_bytes=None):
header = section.header
header.is_linked_to_previous = False
for p in header.paragraphs: p.clear()
tbl = header.add_table(rows=4, cols=3, width=Cm(16.5))
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
cw = [1700, 5800, 2900]
brd = (pal["gray_border"], "4")
for row in tbl.rows:
for i, cell in enumerate(row.cells):
set_width(cell, cw[i]); set_valign(cell)
set_margins(cell, 30, 30, 80, 80)
set_borders(cell, brd, brd, brd, brd)
# Logo
logo_cell = tbl.cell(0,0).merge(tbl.cell(3,0))
logo_cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
if logo_bytes:
from docx.shared import Inches as In
run = logo_cell.paragraphs[0].add_run()
run.add_picture(io.BytesIO(logo_bytes), height=Cm(1.8))
else:
fmt(logo_cell.paragraphs[0], "[LOGO]", bold=True, size=Pt(10), color="888888")
# Título — PRETO
tc = tbl.cell(0,1).merge(tbl.cell(1,1))
tc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(tc.paragraphs[0], "PROCEDIMENTO OPERACIONAL PADRÃO",
bold=True, size=Pt(13), color="000000")
# Nome do processo — PRETO
pc = tbl.cell(2,1).merge(tbl.cell(3,1))
pc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(pc.paragraphs[0], meta.get("titulo_processo","").upper(),
bold=True, size=Pt(12), color="000000")
# Metadados — PRETO
for i, (lbl, val) in enumerate([
("Código: ", meta.get("codigo","POP-XXX-001")),
("Elaborado em: ", meta.get("data_elaboracao","")),
("Revisado em: ", meta.get("data_revisao","—")),
("Válido até: ", meta.get("validade","")),
]):
c = tbl.cell(i, 2)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], lbl, bold=True, size=TAM_HEADER, color="000000")
fmt(c.paragraphs[0], val or "—", size=TAM_HEADER, color="000000")
def build_footer(section, meta, pal):
footer = section.footer
footer.is_linked_to_previous = False
for p in footer.paragraphs: p.clear()
footer.add_paragraph() # separator
tbl = footer.add_table(rows=2, cols=3, width=Cm(16.5))
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
cw = [3500, 3500, 3400]
brd = (pal["gray_border"], "4")
for row in tbl.rows:
for i, cell in enumerate(row.cells):
set_width(cell, cw[i]); set_valign(cell)
set_margins(cell, 30, 30, 60, 60)
set_borders(cell, brd, brd, brd, brd)
for i, h in enumerate(["Elaborado por:", "Validado por:", "Aprovado por:"]):
c = tbl.cell(0, i)
set_shading(c, pal["tertiary"]) # Cor terciária no header do rodapé
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color=pal["primary_dark"])
for i, key in enumerate(["elaborado_por", "validado_por", "aprovado_por"]):
c = tbl.cell(1, i)
person = meta.get(key, {})
nome = person.get("nome","") if isinstance(person, dict) else ""
cargo = person.get("cargo","") if isinstance(person, dict) else ""
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
if nome:
fmt(c.paragraphs[0], nome, bold=True, size=TAM_SMALL, color=pal["text_body"])
p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(p2, cargo, size=TAM_TINY, color="666666")
else:
fmt(c.paragraphs[0], "________________", size=TAM_SMALL, color="AAAAAA")
# Página X de Y
pp = footer.add_paragraph()
pp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
spacing(pp, 2, 0)
fmt(pp, "Página ", size=TAM_TINY, color="888888")
# Campo PAGE (número atual)
for x in [f'',
f' PAGE ',
f'']:
pp.add_run()._r.append(parse_xml(x))
r = pp.add_run("1"); r.font.size = TAM_TINY; r.font.color.rgb = RGBColor.from_string("888888")
pp.add_run()._r.append(parse_xml(f''))
# " de " + NUMPAGES (total)
fmt(pp, " de ", size=TAM_TINY, color="888888")
for x in [f'',
f' NUMPAGES ',
f'']:
pp.add_run()._r.append(parse_xml(x))
r2 = pp.add_run("1"); r2.font.size = TAM_TINY; r2.font.color.rgb = RGBColor.from_string("888888")
pp.add_run()._r.append(parse_xml(f''))
fmt(pp, f" | {meta.get('codigo','POP-XXX-001')} | v{meta.get('versao','01')}",
size=TAM_TINY, color="888888")
# ============================================================
# ELEMENTOS DE CONTEÚDO
# ============================================================
def add_h1(doc, num, titulo, pal):
p = doc.add_paragraph(); spacing(p, 16, 8)
p_shading(p, pal["primary"])
pf = p.paragraph_format; pf.left_indent = Cm(0.3); pf.right_indent = Cm(0.3)
fmt(p, f" {num}. {titulo.upper()}", bold=True, size=TAM_H1, color=pal["text_on_primary"])
def add_h2(doc, num, titulo, pal):
p = doc.add_paragraph(); spacing(p, 12, 6)
p_shading(p, pal["secondary"])
pf = p.paragraph_format; pf.left_indent = Cm(IND_H2 + 0.3); pf.right_indent = Cm(0.3)
fmt(p, f" {num}. {titulo.upper()}", bold=True, size=TAM_H2, color=pal["text_on_secondary"])
def add_body(doc, text, indent=IND_BODY):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
spacing(p, 2, 5, 1.15); p.paragraph_format.left_indent = Cm(indent)
fmt(p, text, size=TAM_CORPO, color="1A1A1A")
def add_bullet(doc, text, bold_prefix=None, indent=IND_ITEM, pal=None):
c = pal["primary"] if pal else DEFAULT_PRIMARY
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
spacing(p, 1, 3, 1.15)
p.paragraph_format.left_indent = Cm(indent); p.paragraph_format.first_line_indent = Cm(-0.4)
fmt(p, "● ", size=Pt(9), color=c, bold=True)
if bold_prefix:
fmt(p, bold_prefix, bold=True, size=TAM_CORPO, color=c)
fmt(p, text, size=TAM_CORPO, color="1A1A1A")
else:
fmt(p, text, size=TAM_CORPO, color="1A1A1A")
def add_num(doc, n, text, indent=IND_SUBITEM, pal=None, critico=False):
c = pal["primary"] if pal else DEFAULT_PRIMARY
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
spacing(p, 1, 4, 1.15)
p.paragraph_format.left_indent = Cm(indent); p.paragraph_format.first_line_indent = Cm(-0.5)
if critico:
fmt(p, f"\u26a0 ", bold=True, size=TAM_CORPO, color="C05000")
fmt(p, f"{n}. [PASSO CR\u00cdTICO] ", bold=True, size=TAM_CORPO, color="C05000")
else:
fmt(p, f"{n}. ", bold=True, size=TAM_CORPO, color=c)
# Texto com badges de Meta OMS
fmt_with_meta_badges(p, text, size=TAM_CORPO, pal=pal)
def add_def_item(doc, termo, defn, pal):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
spacing(p, 2, 5, 1.15)
p.paragraph_format.left_indent = Cm(IND_ITEM); p.paragraph_format.first_line_indent = Cm(-0.4)
fmt(p, "● ", size=Pt(9), color=pal["primary"], bold=True)
fmt(p, f"{termo}: ", bold=True, size=TAM_CORPO, color=pal["primary"])
fmt(p, defn, size=TAM_CORPO, color="1A1A1A")
def add_risk(doc, risco, barreira, pal):
p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
spacing(p, 4, 3, 1.15)
p.paragraph_format.left_indent = Cm(IND_ITEM); p.paragraph_format.first_line_indent = Cm(-0.5)
fmt(p, "⚠ ", size=TAM_CORPO, color=pal["risk_red"], bold=True)
fmt(p, "Risco: ", bold=True, size=TAM_CORPO, color=pal["risk_red"])
fmt(p, risco, size=TAM_CORPO, color="1A1A1A")
p2 = doc.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
spacing(p2, 0, 8, 1.15); p2.paragraph_format.left_indent = Cm(IND_SUBITEM)
fmt(p2, "↳ Barreira de Segurança: ", bold=True, size=TAM_CORPO, color=pal["barrier_green"])
fmt(p2, barreira, size=TAM_CORPO, color="1A1A1A")
# ============================================================
# TABELAS
# ============================================================
def add_table(doc, headers, rows, col_widths=None, pal=None):
nc = len(headers)
if not col_widths: col_widths = [LARGURA_DXA // nc] * nc
tbl = doc.add_table(rows=1+len(rows), cols=nc)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
brd = (pal["border_light"], "4") if pal else ("3B6AA0", "4")
brd2 = (pal["gray_border"], "2") if pal else ("7F8C9A", "2")
for i, h in enumerate(headers):
c = tbl.cell(0, i); set_shading(c, pal["primary"] if pal else DEFAULT_PRIMARY)
set_width(c, col_widths[i]); set_valign(c)
set_margins(c, 50, 50, 80, 80); set_borders(c, brd, brd, brd, brd)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color="FFFFFF")
repeat_header(tbl.rows[0])
for ri, rd in enumerate(rows):
for ci, val in enumerate(rd):
c = tbl.cell(ri+1, ci); set_width(c, col_widths[ci]); set_valign(c)
set_margins(c, 40, 40, 80, 80); set_borders(c, brd2, brd2, brd2, brd2)
a = WD_ALIGN_PARAGRAPH.LEFT if ci == 0 and nc > 2 else WD_ALIGN_PARAGRAPH.CENTER
c.paragraphs[0].alignment = a
fmt(c.paragraphs[0], str(val), size=TAM_SMALL, color="1A1A1A")
if ri % 2 == 0:
for ci in range(nc):
set_shading(tbl.cell(ri+1, ci), pal["tertiary"] if pal else "E8EFF7")
doc.add_paragraph()
# ============================================================
# SMARTART
# ============================================================
def add_process(doc, titulo, etapas, pal):
if not etapas: return
p = doc.add_paragraph(); spacing(p, 8, 6)
p.paragraph_format.left_indent = Cm(IND_BODY)
fmt(p, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
n = len(etapas); total = n*2-1; aw = 400
bw = (LARGURA_DXA - aw*(n-1)) // n
tbl = doc.add_table(rows=1, cols=total)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
colors = [pal["primary"], pal["primary"], pal["primary"], pal["primary"]]
for ci in range(total):
c = tbl.cell(0, ci)
if ci % 2 == 0:
ei = ci // 2
set_width(c, bw); set_shading(c, pal["primary"] if ei % 2 == 0 else lighten(pal["primary"], 0.15))
bg = pal["primary"] if ei % 2 == 0 else lighten(pal["primary"], 0.15)
set_borders(c, (bg,"6"), (bg,"6"), (bg,"6"), (bg,"6"))
set_valign(c); set_margins(c, 60, 60, 80, 80)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], f"Etapa {ei+1}", bold=True, size=TAM_TINY, color="FFFFFF")
p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(p2, etapas[ei], size=TAM_SMALL, color="FFFFFF")
else:
set_width(c, aw)
set_borders(c,("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"))
set_valign(c); c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], "→", bold=True, size=Pt(16), color=pal["primary"])
doc.add_paragraph()
def add_checklist(doc, titulo, itens, pal):
if not itens: return
p = doc.add_paragraph(); spacing(p, 8, 6)
p.paragraph_format.left_indent = Cm(IND_BODY)
fmt(p, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
tbl = doc.add_table(rows=len(itens), cols=2)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
chw = 600; tw = LARGURA_DXA - chw
for ri, item in enumerate(itens):
cc = tbl.cell(ri, 0); set_width(cc, chw); set_valign(cc)
set_margins(cc, 40, 40, 40, 40); set_shading(cc, pal["primary"])
set_borders(cc,("FFFFFF","2"),("FFFFFF","2"),(pal["primary"],"4"),("FFFFFF","2"))
cc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(cc.paragraphs[0], "✓", bold=True, size=Pt(14), color="FFFFFF")
ct = tbl.cell(ri, 1); set_width(ct, tw); set_valign(ct)
set_margins(ct, 50, 50, 120, 80)
bg = pal["quaternary"] if ri % 2 == 0 else "FFFFFF"
set_shading(ct, bg)
set_borders(ct,(pal["gray_border"],"2"),(pal["gray_border"],"2"),("FFFFFF","0"),(pal["gray_border"],"2"))
ct.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.LEFT
fmt(ct.paragraphs[0], item, size=TAM_CORPO, color="1A1A1A")
doc.add_paragraph()
def add_cycle(doc, titulo, etapas, pal):
if not etapas: return
p = doc.add_paragraph(); spacing(p, 8, 6)
p.paragraph_format.left_indent = Cm(IND_BODY)
fmt(p, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
n = len(etapas); cpr = min(n, 4); rows_n = (n+cpr-1)//cpr
bw = LARGURA_DXA // cpr
tbl = doc.add_table(rows=rows_n, cols=cpr)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
for idx, et in enumerate(etapas):
r, c_idx = idx//cpr, idx%cpr
c = tbl.cell(r, c_idx); set_width(c, bw)
bg = pal["primary"] if idx % 2 == 0 else lighten(pal["primary"], 0.15)
set_shading(c, bg)
set_borders(c,("FFFFFF","4"),("FFFFFF","4"),("FFFFFF","4"),("FFFFFF","4"))
set_valign(c); set_margins(c, 60, 60, 80, 80)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
arrow = " →" if idx < n-1 else " ⟳"
fmt(c.paragraphs[0], f"{idx+1}{arrow}", bold=True, size=TAM_TINY, color="FFFFFF")
p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(p2, et, size=TAM_SMALL, color="FFFFFF")
for idx in range(n, rows_n*cpr):
c = tbl.cell(idx//cpr, idx%cpr)
set_borders(c,("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"))
doc.add_paragraph()
# ============================================================
# ANEXOS COM BORDA — 1 PÁGINA POR ANEXO, BORDA FULL-HEIGHT
# ============================================================
# Altura da caixa bordada em DXA (~14.5cm para caber com H1+descrição na mesma página)
ANNEX_BOX_HEIGHT_DXA = 8200
def _set_row_height(row, height_dxa):
"""Define altura mínima de uma linha de tabela."""
trPr = row._tr.get_or_add_trPr()
trPr.append(parse_xml(
f''))
def _fill_cell_blank_lines(cell, n=8):
"""Adiciona linhas em branco ao final de uma célula para preenchimento vertical."""
for _ in range(n):
p = cell.add_paragraph()
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
def start_annex_border(doc, pal):
"""Cria container bordado com altura mínima full-page."""
tbl = doc.add_table(rows=1, cols=1)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
c = tbl.cell(0, 0)
brd = (pal["annex_border"], "6")
set_borders(c, brd, brd, brd, brd)
set_width(c, LARGURA_DXA)
set_margins(c, 150, 150, 250, 250)
_set_row_height(tbl.rows[0], ANNEX_BOX_HEIGHT_DXA)
return c, tbl
# ============================================================
# GERADOR DE DIAGRAMAS VIA GRAPHVIZ (imagens PNG)
# ============================================================
def gerar_diagrama_png(tipo, conteudo, titulo, pal, context="annex"):
"""Gera PNG de diagrama profissional via Graphviz. Retorna path ou None.
context: 'inline' (no corpo do procedimento) ou 'annex' (página de anexo)."""
if not HAS_GRAPHVIZ:
return None
try:
dot = gv.Digraph(format='png')
dot.attr(dpi='120', bgcolor='transparent', margin='0.15', nodesep='0.4', ranksep='0.5')
node_style = {
'style': 'filled,rounded', 'shape': 'box', 'fontname': 'Arial',
'fontsize': '11', 'fillcolor': f'#{pal["primary"]}',
'fontcolor': '#FFFFFF', 'color': f'#{pal["primary_dark"]}', 'penwidth': '1.2',
'height': '0.5', 'margin': '0.12,0.06'
}
node_light = {**node_style, 'fillcolor': f'#{pal["secondary"]}',
'fontcolor': f'#{pal["text_on_secondary"]}'}
node_ter = {**node_style, 'fillcolor': f'#{pal["tertiary"]}',
'fontcolor': f'#{pal["primary_dark"]}'}
edge_style = {'color': f'#{pal["primary"]}', 'penwidth': '1.0', 'arrowsize': '0.6'}
node_decision = {**node_style, 'shape': 'diamond', 'height': '0.7', 'width': '1.2'}
# Mapear formas
forma_map = {'elipse': 'ellipse', 'losango': 'diamond', 'retangulo': 'box',
'ellipse': 'ellipse', 'diamond': 'diamond', 'box': 'box'}
def _parse_etapa(et, idx=0, total=1):
if isinstance(et, dict):
txt = et.get('texto', et.get('titulo', str(et)))[:30]
frm = forma_map.get(et.get('forma', 'retangulo'), 'box')
return txt, frm
txt = str(et)[:30]
if idx == 0 or idx == total - 1:
return txt, 'ellipse'
if '?' in str(et):
return txt, 'diamond'
return txt, 'box'
if tipo in ("processo", "fluxograma"):
etapas_raw = conteudo if isinstance(conteudo, list) else [conteudo]
has_decision = any(
(isinstance(e, dict) and e.get('forma') in ('losango','diamond'))
or (isinstance(e, str) and '?' in e)
for e in etapas_raw
)
if context == "inline" and not has_decision:
# ── INLINE TIPO 1: Linear simples, max 5 etapas ──
etapas = etapas_raw[:5]
dot.attr(rankdir='LR', size='8,2!')
for i, et in enumerate(etapas):
txt, frm = _parse_etapa(et, i, len(etapas))
st = {**(node_style if i % 2 == 0 else node_light), 'shape': frm}
dot.node(f'n{i}', txt, **st)
if i > 0:
dot.edge(f'n{i-1}', f'n{i}', **edge_style)
elif context == "inline" and has_decision:
# ── INLINE TIPO 2: 2 sequenciais + decisão + split ──
# Layout: n0 → n1 (mesma linha)
# ↓
# n2 (losango)
# ↙ ↘
# n3 n4
etapas = etapas_raw[:5]
dot.attr(rankdir='TB', size='7,4!')
for i, et in enumerate(etapas):
txt, frm = _parse_etapa(et, i, len(etapas))
if i == 2 or frm == 'diamond':
st = {**node_decision}
elif i < 2:
st = {**node_style, 'shape': frm}
else:
st = {**node_light, 'shape': frm}
dot.node(f'n{i}', txt, **st)
# Edges: 0→1, 1→2, 2→3(left), 2→4(right)
if len(etapas) >= 2: dot.edge('n0', 'n1', **edge_style)
if len(etapas) >= 3: dot.edge('n1', 'n2', **edge_style)
if len(etapas) >= 4: dot.edge('n2', 'n3', **edge_style, label=' N\u00e3o', fontsize='9', fontcolor=f'#{pal["primary"]}')
if len(etapas) >= 5: dot.edge('n2', 'n4', **edge_style, label=' Sim', fontsize='9', fontcolor=f'#{pal["primary"]}')
# Ranks
dot.body.append(' { rank=same; n0 n1 }')
if len(etapas) >= 4:
rank_bottom = 'n3'
if len(etapas) >= 5: rank_bottom += ' n4'
dot.body.append(f' {{ rank=same; {rank_bottom} }}')
else:
# ── ANEXO: Layout 3 colunas com splits em decisões ──
etapas = etapas_raw[:10]
n = len(etapas)
dot.attr(rankdir='TB', size='7,4!')
# Criar todos os nós
for i, et in enumerate(etapas):
txt, frm = _parse_etapa(et, i, n)
if frm == 'diamond':
st = {**node_decision}
elif i % 2 == 0:
st = {**node_style, 'shape': frm}
else:
st = {**node_light, 'shape': frm}
dot.node(f'n{i}', txt, **st)
# Edges inteligentes: detectar diamonds e criar splits
diamonds = [i for i in range(n) if _parse_etapa(etapas[i], i, n)[1] == 'diamond']
if diamonds:
# Edges sequenciais até o primeiro diamond
d1 = diamonds[0]
for i in range(d1):
dot.edge(f'n{i}', f'n{i+1}', **edge_style)
# Primeiro split
left_idx = d1 + 1 if d1 + 1 < n else None
right_idx = d1 + 2 if d1 + 2 < n else None
if left_idx is not None:
dot.edge(f'n{d1}', f'n{left_idx}', **edge_style,
label=' N\u00e3o', fontsize='9', fontcolor=f'#{pal["primary"]}')
if right_idx is not None:
dot.edge(f'n{d1}', f'n{right_idx}', **edge_style,
label=' Sim', fontsize='9', fontcolor=f'#{pal["primary"]}')
# Ranks: centro, split esquerdo/direito
if left_idx and right_idx:
dot.body.append(f' {{ rank=same; n{left_idx} n{right_idx} }}')
# Continuação após split
used = set(range(d1 + 1)) | {d1, left_idx, right_idx}
remaining = [i for i in range(n) if i not in used and i is not None]
# Left path
if left_idx is not None and remaining:
left_next = remaining[0]
dot.edge(f'n{left_idx}', f'n{left_next}', **edge_style)
remaining = remaining[1:]
# Right path: sequencial
prev_right = right_idx
for ri in remaining:
if prev_right is not None:
frm_ri = _parse_etapa(etapas[ri], ri, n)[1]
if frm_ri == 'diamond' and ri + 1 < n and ri + 2 < n:
# Segundo split
dot.edge(f'n{prev_right}', f'n{ri}', **edge_style)
dot.edge(f'n{ri}', f'n{ri+1}', **edge_style,
label=' N\u00e3o', fontsize='9', fontcolor=f'#{pal["primary"]}')
dot.edge(f'n{ri}', f'n{ri+2}', **edge_style,
label=' Sim', fontsize='9', fontcolor=f'#{pal["primary"]}')
dot.body.append(f' {{ rank=same; n{ri+1} n{ri+2} }}')
break
else:
dot.edge(f'n{prev_right}', f'n{ri}', **edge_style)
prev_right = ri
else:
# Sem diamonds: zigzag simples
per_row = 3
for i in range(n):
if i > 0:
dot.edge(f'n{i-1}', f'n{i}', **edge_style)
for row_start in range(0, n, per_row):
row_nodes = ' '.join(f'n{j}' for j in range(row_start, min(row_start + per_row, n)))
dot.body.append(f' {{ rank=same; {row_nodes} }}')
elif tipo == "hierarquia":
dot.attr(rankdir='TB', size='8,4.5!')
items = conteudo if isinstance(conteudo, list) else [conteudo]
for i, item in enumerate(items):
if isinstance(item, dict):
lbl = item.get('titulo', '')[:30]
dot.node(f'g{i}', lbl, **node_style)
for j, sub in enumerate(item.get('subitens', [])):
dot.node(f'g{i}s{j}', sub[:25], **node_light)
dot.edge(f'g{i}', f'g{i}s{j}', **edge_style)
else:
dot.node(f'g{i}', str(item)[:30], **node_style)
if i > 0:
dot.edge(f'g{i-1}', f'g{i}', **edge_style)
elif tipo == "piramide":
dot.attr(rankdir='TB', size='7,5!')
items = conteudo if isinstance(conteudo, list) else [conteudo]
for i, item in enumerate(items):
w = str(max(1.5, 3.5 - i * 0.4))
st = {**node_style, 'width': w, 'fixedsize': 'true', 'height': '0.45'}
if i > len(items) // 2:
st = {**node_ter, 'width': w, 'fixedsize': 'true', 'height': '0.45'}
dot.node(f'p{i}', str(item)[:30], **st)
if i > 0:
dot.edge(f'p{i-1}', f'p{i}', **edge_style, style='invis')
for i in range(len(items)):
dot.body.append(f' {{ rank=same; p{i} }}')
elif tipo == "ciclo":
dot.attr(rankdir='LR', size='8,3!') # Horizontal e compacto
etapas = conteudo if isinstance(conteudo, list) else [conteudo]
n = len(etapas)
for i, et in enumerate(etapas):
st = node_style if i % 2 == 0 else node_light
dot.node(f'c{i}', et[:30], **st)
for i in range(n):
dot.edge(f'c{i}', f'c{(i+1)%n}', **edge_style)
else:
return None
out = os.path.join(tempfile.gettempdir(), f"diag_{tipo}_{id(conteudo)}")
return dot.render(out, cleanup=True)
except Exception:
return None
def _inserir_diagrama_na_celula(cell, img_path):
"""Insere imagem PNG do diagrama dentro de uma célula do docx.
Limita largura a 15cm E altura a 10cm para caber na caixa do anexo."""
if not img_path or not os.path.exists(img_path):
return False
try:
from PIL import Image as PILImage
img = PILImage.open(img_path)
w_px, h_px = img.size
# Calcular dimensões respeitando limites
max_w = Cm(15)
max_h = Cm(10)
ratio = w_px / h_px
width = max_w
height = int(width / ratio) if ratio > 0 else max_h
if height > max_h:
height = max_h
width = int(height * ratio)
p = cell.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
run.add_picture(img_path, width=width)
return True
except Exception:
try:
p = cell.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
run.add_picture(img_path, width=Cm(12))
return True
except:
return False
def _inserir_diagrama_no_corpo(doc, img_path, legenda="", pal=None):
"""Insere imagem PNG de diagrama no corpo do documento (fora de tabela/célula).
Centralizado, com legenda em itálico abaixo."""
if not img_path or not os.path.exists(img_path):
return
try:
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(8)
p.paragraph_format.space_after = Pt(4)
run = p.add_run()
run.add_picture(img_path, width=Cm(14))
if legenda:
pl = doc.add_paragraph()
pl.alignment = WD_ALIGN_PARAGRAPH.CENTER
pl.paragraph_format.space_before = Pt(2)
pl.paragraph_format.space_after = Pt(8)
fmt(pl, legenda, italic=True, size=Pt(9),
color=pal["primary"] if pal else "333333")
except Exception:
pass
def _render_passo(doc, num, item, pal):
"""Renderiza um item de procedimento: string, dict com texto/prioridade, ou dict com diagrama.
Formatos aceitos:
- "texto simples"
- {"texto": "...", "prioridade": "critica"}
- {"diagrama": {"tipo":..., "titulo":..., "conteudo":...}}
- {"tipo":..., "titulo":..., "conteudo":...}
"""
if isinstance(item, str):
add_num(doc, num, item, pal=pal)
elif isinstance(item, dict):
# Formato 1: {"texto": "...", "prioridade": "critica"}
if "texto" in item and "diagrama" not in item and "tipo" not in item:
texto = item.get("texto", "")
critico = item.get("prioridade", "").lower() == "critica"
add_num(doc, num, texto, pal=pal, critico=critico)
return
# Formato 2: diagrama (com ou sem wrapper "diagrama")
diag = item.get("diagrama", None)
if diag is None and "tipo" in item:
diag = item
if diag is None:
# Dict não reconhecido — texto fallback
add_num(doc, num, str(item), pal=pal)
return
tipo = diag.get("tipo", "processo")
titulo = diag.get("titulo", "Diagrama")
conteudo = diag.get("conteudo", [])
img_path = gerar_diagrama_png(tipo, conteudo, titulo, pal, context="inline")
if img_path:
_inserir_diagrama_no_corpo(doc, img_path, titulo, pal)
else:
items_txt = ', '.join(str(c) for c in conteudo) if isinstance(conteudo, list) else str(conteudo)
add_num(doc, num, f"[{titulo}]: {items_txt}", pal=pal)
def render_annex(doc, anexo, num, pal):
page_break(doc)
titulo = anexo.get("titulo", anexo) if isinstance(anexo, dict) else str(anexo)
add_h1(doc, f"ANEXO {num}", titulo.upper(), pal)
if isinstance(anexo, str):
add_body(doc, f"Conteúdo do {titulo} — a ser inserido pela instituição.")
return
if anexo.get("descricao"):
add_body(doc, anexo["descricao"])
tipo = anexo.get("tipo", "texto")
conteudo = anexo.get("conteudo", "")
# Criar container bordado com altura full-page
bc, border_tbl = start_annex_border(doc, pal)
# Para tipos de diagrama: tentar Graphviz primeiro
graphviz_ok = False
if tipo in ("processo", "fluxograma", "hierarquia", "piramide", "ciclo"):
img_path = gerar_diagrama_png(tipo, conteudo, titulo, pal)
if img_path:
_inserir_diagrama_na_celula(bc, img_path)
graphviz_ok = True
_fill_cell_blank_lines(bc, 6)
# === CHECKLIST ===
if graphviz_ok:
pass # Graphviz renderizou — pular SmartArt de tabela
elif tipo == "checklist":
itens = conteudo if isinstance(conteudo, list) else [conteudo]
pt = bc.paragraphs[0]; pt.alignment = WD_ALIGN_PARAGRAPH.LEFT
fmt(pt, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
inner = bc.add_table(rows=len(itens), cols=2)
chw = 500; tw = LARGURA_DXA - 900
for ri, item in enumerate(itens):
cc = inner.cell(ri, 0); set_width(cc, chw); set_valign(cc)
set_margins(cc, 30, 30, 30, 30); set_shading(cc, pal["primary"])
set_borders(cc,("FFFFFF","2"),("FFFFFF","2"),(pal["primary"],"4"),("FFFFFF","2"))
cc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(cc.paragraphs[0], "✓", bold=True, size=Pt(12), color="FFFFFF")
ct = inner.cell(ri, 1); set_width(ct, tw); set_valign(ct)
set_margins(ct, 40, 40, 100, 60)
set_shading(ct, pal["quaternary"] if ri % 2 == 0 else "FFFFFF")
set_borders(ct,(pal["gray_border"],"1"),(pal["gray_border"],"1"),("FFFFFF","0"),(pal["gray_border"],"1"))
fmt(ct.paragraphs[0], item, size=TAM_SMALL, color="1A1A1A")
_fill_cell_blank_lines(bc, max(1, 12 - len(itens)))
# === ESCALA / TABELA ===
elif tipo in ("escala", "tabela", "referencia", "referência"):
headers = anexo.get("colunas", [])
rows_data = anexo.get("linhas", [])
# Handle Gemini format: conteudo = [{colunas: [...], linhas: [...]}]
if not headers and isinstance(conteudo, list) and len(conteudo) > 0 and isinstance(conteudo[0], dict):
tbl_obj = conteudo[0]
headers = tbl_obj.get("colunas", [])
rows_data = tbl_obj.get("linhas", [])
# Fallback: if conteudo is list of lists (actual row data) and no linhas at annexo level
if not rows_data and isinstance(conteudo, list) and len(conteudo) > 0 and isinstance(conteudo[0], list):
rows_data = conteudo
if headers and rows_data:
bc.paragraphs[0].text = ""
nc = len(headers); cw_each = (LARGURA_DXA - 900) // nc
inner = bc.add_table(rows=1+len(rows_data), cols=nc)
for i, h in enumerate(headers):
c = inner.cell(0, i); set_shading(c, pal["primary"])
set_width(c, cw_each); set_valign(c); set_margins(c, 40, 40, 60, 60)
set_borders(c,(pal["primary"],"4"),(pal["primary"],"4"),(pal["primary"],"4"),(pal["primary"],"4"))
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color="FFFFFF")
for ri, rd in enumerate(rows_data):
for ci, val in enumerate(rd):
c = inner.cell(ri+1, ci); set_width(c, cw_each); set_valign(c)
set_margins(c, 30, 30, 60, 60)
set_borders(c,(pal["gray_border"],"1"),(pal["gray_border"],"1"),
(pal["gray_border"],"1"),(pal["gray_border"],"1"))
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], str(val), size=TAM_SMALL, color="1A1A1A")
if ri % 2 == 0:
for ci in range(nc): set_shading(inner.cell(ri+1, ci), pal["tertiary"])
_fill_cell_blank_lines(bc, max(1, 10 - len(rows_data)))
# === PROCESSO / FLUXOGRAMA ===
elif tipo in ("processo", "fluxograma"):
etapas = conteudo if isinstance(conteudo, list) else [conteudo]
etapas = etapas[:6]
fmt(bc.paragraphs[0], f"▸ Fluxo do Processo", bold=True, size=TAM_CORPO, color=pal["primary"])
n = len(etapas); total = n*2-1; aw = 350
bw = (LARGURA_DXA - 900 - aw*(n-1)) // n
inner = bc.add_table(rows=1, cols=total)
for ci in range(total):
c = inner.cell(0, ci)
if ci % 2 == 0:
ei = ci // 2; set_width(c, bw)
bg = pal["primary"] if ei % 2 == 0 else lighten(pal["primary"], 0.15)
set_shading(c, bg); set_borders(c,(bg,"4"),(bg,"4"),(bg,"4"),(bg,"4"))
set_valign(c); set_margins(c, 50, 50, 60, 60)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], f"Etapa {ei+1}", bold=True, size=TAM_TINY, color="FFFFFF")
p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(p2, etapas[ei], size=Pt(8), color="FFFFFF")
else:
set_width(c, aw)
set_borders(c,("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"))
set_valign(c); c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], "\u2192", bold=True, size=Pt(14), color=pal["primary"])
_fill_cell_blank_lines(bc, 15)
# === HIERARQUIA (organograma / classificação em níveis) ===
elif tipo == "hierarquia":
items = conteudo if isinstance(conteudo, list) else [conteudo]
fmt(bc.paragraphs[0], f"\u25b8 {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
for item in items:
if isinstance(item, dict):
# Nível 1: box colorido
p1 = bc.add_paragraph()
p1.paragraph_format.space_before = Pt(6)
p1_shading = parse_xml(f'')
p1._p.get_or_add_pPr().append(p1_shading)
fmt(p1, f" {item.get('titulo', '')}", bold=True, size=TAM_CORPO, color="FFFFFF")
# Nível 2: subitens indentados com barra lateral
for sub in item.get("subitens", []):
p2 = bc.add_paragraph()
p2.paragraph_format.left_indent = Cm(1.0)
p2.paragraph_format.space_before = Pt(2)
p2_shading = parse_xml(f'')
p2._p.get_or_add_pPr().append(p2_shading)
fmt(p2, f" \u25b9 {sub}", size=TAM_SMALL, color=pal["primary_dark"])
elif isinstance(item, str):
p1 = bc.add_paragraph()
p1_shading = parse_xml(f'')
p1._p.get_or_add_pPr().append(p1_shading)
fmt(p1, f" {item}", bold=True, size=TAM_SMALL, color=pal["primary_dark"])
_fill_cell_blank_lines(bc, max(1, 8 - len(items)))
# === PIRÂMIDE (de cima para baixo, estreito→largo) ===
elif tipo == "piramide":
items = conteudo if isinstance(conteudo, list) else [conteudo]
fmt(bc.paragraphs[0], f"\u25b8 {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
n = len(items)
max_w = LARGURA_DXA - 900
for i, item in enumerate(items):
# Cada nível fica mais largo (proporção crescente)
level_w = int(max_w * (0.4 + 0.6 * i / max(n-1, 1)))
margin_l = (max_w - level_w) // 2
inner = bc.add_table(rows=1, cols=1)
inner.alignment = WD_TABLE_ALIGNMENT.CENTER
c = inner.cell(0, 0)
set_width(c, level_w)
# Gradiente de cores: topo=primário, base=terciário
frac = i / max(n-1, 1)
r1, g1, b1 = hex_to_rgb(pal["primary"])
r2, g2, b2 = hex_to_rgb(pal["tertiary"])
bg = rgb_to_hex(r1+(r2-r1)*frac, g1+(g2-g1)*frac, b1+(b2-b1)*frac)
set_shading(c, bg)
brd = (bg, "4")
set_borders(c, brd, brd, brd, brd)
set_valign(c); set_margins(c, 40, 40, 80, 80)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
txt_color = "FFFFFF" if frac < 0.5 else pal["primary_dark"]
fmt(c.paragraphs[0], str(item), bold=True, size=TAM_SMALL, color=txt_color)
_fill_cell_blank_lines(bc, max(1, 10 - n))
# === CICLO (PDCA, melhoria contínua) ===
elif tipo == "ciclo":
etapas = conteudo if isinstance(conteudo, list) else [conteudo]
fmt(bc.paragraphs[0], f"\u25b8 {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
n = len(etapas)
# Layout em grid 2x2 ou linear
if n == 4:
inner = bc.add_table(rows=2, cols=2)
inner.alignment = WD_TABLE_ALIGNMENT.CENTER
positions = [(0,0),(0,1),(1,1),(1,0)] # Sentido horário
colors = [pal["primary"], lighten(pal["primary"],0.15),
lighten(pal["primary"],0.30), lighten(pal["primary"],0.45)]
bw = (LARGURA_DXA - 900) // 2
for idx, (r,c_idx) in enumerate(positions):
c = inner.cell(r, c_idx); set_width(c, bw); set_valign(c)
set_shading(c, colors[idx])
brd = (colors[idx], "4")
set_borders(c, brd, brd, brd, brd)
set_margins(c, 50, 50, 80, 80)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], etapas[idx], bold=True, size=TAM_SMALL, color="FFFFFF")
# Setas no centro
p_arrow = bc.add_paragraph()
p_arrow.alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(p_arrow, "\u21bb Ciclo Cont\u00ednuo", bold=True, size=TAM_SMALL, color=pal["primary"])
else:
cpr = min(n, 4); rows_n = (n+cpr-1)//cpr
bw = (LARGURA_DXA - 900) // cpr
inner = bc.add_table(rows=rows_n, cols=cpr)
inner.alignment = WD_TABLE_ALIGNMENT.CENTER
for idx, et in enumerate(etapas):
r, ci2 = divmod(idx, cpr)
c = inner.cell(r, ci2); set_width(c, bw); set_valign(c)
bg = pal["primary"] if idx % 2 == 0 else lighten(pal["primary"], 0.20)
set_shading(c, bg)
brd = (bg, "4")
set_borders(c, brd, brd, brd, brd)
set_margins(c, 40, 40, 60, 60)
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], et, bold=True, size=TAM_TINY, color="FFFFFF")
_fill_cell_blank_lines(bc, 10)
# === MISTO ===
elif tipo == "misto":
bc.paragraphs[0].text = ""
elementos = conteudo if isinstance(conteudo, list) else [conteudo]
for elem in elementos:
if isinstance(elem, str):
pe = bc.add_paragraph(); fmt(pe, elem, size=TAM_CORPO, color="1A1A1A")
elif isinstance(elem, dict):
st = elem.get("tipo", "texto")
if st == "texto":
pe = bc.add_paragraph(); fmt(pe, elem.get("conteudo",""), size=TAM_CORPO, color="1A1A1A")
elif st == "checklist":
pe = bc.add_paragraph()
fmt(pe, f"\n\u25b8 {elem.get('titulo','')}", bold=True, size=TAM_CORPO, color=pal["primary"])
for it in elem.get("itens", []):
pi = bc.add_paragraph(); pi.paragraph_format.left_indent = Cm(0.5)
fmt(pi, "\u2713 ", bold=True, size=TAM_SMALL, color=pal["primary"])
fmt(pi, it, size=TAM_SMALL, color="1A1A1A")
elif st == "processo":
pe = bc.add_paragraph()
fmt(pe, f"\n\u25b8 {elem.get('titulo','')}", bold=True, size=TAM_CORPO, color=pal["primary"])
for i, et in enumerate(elem.get("etapas",[]), 1):
pi = bc.add_paragraph(); pi.paragraph_format.left_indent = Cm(0.5)
fmt(pi, f"{i}. ", bold=True, size=TAM_SMALL, color=pal["primary"])
fmt(pi, et, size=TAM_SMALL, color="1A1A1A")
elif st == "tabela":
pe = bc.add_paragraph()
fmt(pe, f"\n\u25b8 {elem.get('titulo','')}", bold=True, size=TAM_CORPO, color=pal["primary"])
hdrs = elem.get("colunas",[]); rws = elem.get("linhas",[])
if hdrs and rws:
nc2 = len(hdrs); cw2 = (LARGURA_DXA - 900) // nc2
it2 = bc.add_table(rows=1+len(rws), cols=nc2)
for i, h in enumerate(hdrs):
cc2 = it2.cell(0,i); set_shading(cc2, pal["primary"])
set_width(cc2,cw2); set_valign(cc2); set_margins(cc2,30,30,50,50)
set_borders(cc2,(pal["primary"],"3"),(pal["primary"],"3"),
(pal["primary"],"3"),(pal["primary"],"3"))
cc2.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(cc2.paragraphs[0], h, bold=True, size=TAM_TINY, color="FFFFFF")
for ri2, rd2 in enumerate(rws):
for ci2, v2 in enumerate(rd2):
cc2 = it2.cell(ri2+1,ci2); set_width(cc2,cw2); set_valign(cc2)
set_margins(cc2,25,25,50,50)
set_borders(cc2,(pal["gray_border"],"1"),(pal["gray_border"],"1"),
(pal["gray_border"],"1"),(pal["gray_border"],"1"))
cc2.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(cc2.paragraphs[0], str(v2), size=TAM_TINY, color="1A1A1A")
if ri2 % 2 == 0:
for ci2 in range(nc2): set_shading(it2.cell(ri2+1,ci2), pal["tertiary"])
_fill_cell_blank_lines(bc, 4)
# === GLOSSÁRIO / SÍMBOLOS ===
elif tipo in ("glossario", "glossário", "simbolos", "símbolos", "glossario_visual"):
bc.paragraphs[0].text = ""
if isinstance(conteudo, list):
if len(conteudo) > 0 and isinstance(conteudo[0], dict):
# Formato tabela: [{colunas:..., linhas:...}] ou [{simbolo:..., nome:...}]
tbl_obj = conteudo[0]
if "colunas" in tbl_obj:
headers = tbl_obj.get("colunas", [])
rows_data = tbl_obj.get("linhas", [])
if headers and rows_data:
nc = len(headers); cw_each = (LARGURA_DXA - 900) // nc
inner = bc.add_table(rows=1+len(rows_data), cols=nc)
for i, h in enumerate(headers):
c = inner.cell(0, i); set_shading(c, pal["primary"])
set_width(c, cw_each); set_valign(c); set_margins(c, 40, 40, 60, 60)
set_borders(c,(pal["primary"],"4"),(pal["primary"],"4"),(pal["primary"],"4"),(pal["primary"],"4"))
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color="FFFFFF")
for ri, rd in enumerate(rows_data):
for ci, val in enumerate(rd):
c = inner.cell(ri+1, ci); set_width(c, cw_each); set_valign(c)
set_margins(c, 30, 30, 60, 60)
set_borders(c,(pal["gray_border"],"1"),(pal["gray_border"],"1"),
(pal["gray_border"],"1"),(pal["gray_border"],"1"))
c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
fmt(c.paragraphs[0], str(val), size=TAM_SMALL, color="1A1A1A")
if ri % 2 == 0:
for ci in range(nc): set_shading(inner.cell(ri+1, ci), pal["tertiary"])
else:
# Formato dict simples: [{simbolo: "⚠", nome: "Atenção"}, ...]
for item in conteudo:
pi = bc.add_paragraph()
pi.paragraph_format.left_indent = Cm(0.5)
simb = item.get("simbolo", item.get("icone", "●"))
nome = item.get("nome", item.get("descricao", str(item)))
fmt(pi, f"{simb} {nome}", size=TAM_CORPO, color="1A1A1A")
else:
# Formato lista simples de strings: "⚠ Atenção" (símbolo + 2 espaços + nome)
for item in conteudo:
pi = bc.add_paragraph()
pi.paragraph_format.left_indent = Cm(0.5)
fmt(pi, str(item), size=TAM_CORPO, color="1A1A1A")
_fill_cell_blank_lines(bc, max(1, 10 - len(conteudo) if isinstance(conteudo, list) else 10))
# === TEXTO / FALLBACK ===
else:
if isinstance(conteudo, str):
fmt(bc.paragraphs[0], conteudo, size=TAM_CORPO, color="1A1A1A")
elif isinstance(conteudo, list):
bc.paragraphs[0].text = ""
for item in conteudo:
pi = bc.add_paragraph(); fmt(pi, f"\u25cf {item}", size=TAM_CORPO, color="1A1A1A")
_fill_cell_blank_lines(bc, 12)
# ============================================================
# GERADOR PRINCIPAL
# ============================================================
def gerar_pop_docx(json_str, primary_color=None, logo_bytes=None, palette_overrides=None):
data = json.loads(json_str)
meta = data.get("metadata", {})
secoes = data.get("secoes", {})
pal = build_palette(parse_color_input(primary_color))
# Aplicar overrides individuais de cor
if palette_overrides:
for k, v in palette_overrides.items():
if v and v.strip():
pal[k] = parse_color_input(v)
doc = Document()
style = doc.styles["Normal"]
style.font.name = FONTE; style.font.size = TAM_CORPO
style.font.color.rgb = RGBColor.from_string("1A1A1A")
style.paragraph_format.space_after = Pt(4); style.paragraph_format.line_spacing = 1.15
sec = doc.sections[0]
sec.page_width = Cm(21); sec.page_height = Cm(29.7)
sec.top_margin = MARGEM_SUP; sec.bottom_margin = MARGEM_INF
sec.left_margin = MARGEM_ESQ; sec.right_margin = MARGEM_DIR
build_header(sec, meta, pal, logo_bytes)
build_footer(sec, meta, pal)
if doc.paragraphs and doc.paragraphs[0].text == "":
doc.paragraphs[0]._element.getparent().remove(doc.paragraphs[0]._element)
# Seções
sn = 1
add_h1(doc, sn, "OBJETIVO / FINALIDADE", pal)
# Remover space_before do primeiro H1 (margem superior já cuida do espaço)
doc.paragraphs[0].paragraph_format.space_before = Pt(0)
add_body(doc, secoes.get("objetivo", "[Objetivo não fornecido]"))
sn += 1; add_h1(doc, sn, "CAMPO DE APLICAÇÃO / ÁREA", pal)
add_body(doc, secoes.get("campo_aplicacao", "[Campo não fornecido]"))
sn += 1; add_h1(doc, sn, "CONCEITOS E DEFINIÇÕES", pal)
for it in secoes.get("conceitos", []):
if isinstance(it, dict): add_def_item(doc, it.get("termo",""), it.get("definicao",""), pal)
else: add_bullet(doc, str(it), pal=pal)
sn += 1; add_h1(doc, sn, "RESPONSABILIDADES E COMPETÊNCIAS", pal)
for it in secoes.get("responsabilidades", []):
if isinstance(it, dict):
add_bullet(doc, it.get("acoes",""), bold_prefix=f"{it.get('papel','')}: ", pal=pal)
else: add_bullet(doc, str(it), pal=pal)
sn += 1; add_h1(doc, sn, "RECURSOS NECESSÁRIOS (MATERIAIS E EQUIPAMENTOS)", pal)
for it in secoes.get("recursos", []):
add_bullet(doc, str(it), pal=pal)
sn += 1; add_h1(doc, sn, "DESCRIÇÃO DO PROCEDIMENTO (PASSO A PASSO)", pal)
proc = secoes.get("procedimento", {})
sub = 1
add_h2(doc, f"{sn}.{sub}", "Ações Iniciais e Preparo", pal)
for i, p in enumerate(proc.get("acoes_iniciais",[]), 1): _render_passo(doc, i, p, pal)
sub += 1; add_h2(doc, f"{sn}.{sub}", "Execução Técnica", pal)
for i, p in enumerate(proc.get("execucao_tecnica",[]), 1): _render_passo(doc, i, p, pal)
sub += 1; add_h2(doc, f"{sn}.{sub}", "Ações Finais e Organização", pal)
for i, p in enumerate(proc.get("acoes_finais",[]), 1): _render_passo(doc, i, p, pal)
sn += 1; add_h1(doc, sn, "GERENCIAMENTO DE RISCO E PONTOS CRÍTICOS", pal)
riscos = secoes.get("riscos", {})
add_h2(doc, f"{sn}.1", "Riscos Assistenciais", pal)
# Ordenar riscos: Crítico primeiro, Moderado depois, demais por último
riscos_list = riscos.get("assistenciais", [])
def _risk_sort_key(it):
r = it.get("risco","").lower() if isinstance(it, dict) else str(it).lower()
if "crít" in r or "crit" in r: return 0
if "moder" in r: return 1
return 2
riscos_list = sorted(riscos_list, key=_risk_sort_key)
for it in riscos_list:
if isinstance(it, dict): add_risk(doc, it.get("risco",""), it.get("barreira",""), pal)
else: add_bullet(doc, str(it), pal=pal)
add_h2(doc, f"{sn}.2", "Plano de Contingência", pal)
# Caixa de destaque com borda lateral vermelha
cont_raw = riscos.get("contingencia","[Contingência não fornecida]")
# Normalizar: str → lista de 1 item
if isinstance(cont_raw, str):
cont_items = [cont_raw]
elif isinstance(cont_raw, list):
cont_items = cont_raw
else:
cont_items = [str(cont_raw)]
cont_tbl = doc.add_table(rows=1, cols=1)
cont_tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
cc = cont_tbl.cell(0, 0)
set_width(cc, LARGURA_DXA - 200)
set_margins(cc, 80, 80, 200, 200)
set_borders(cc, (pal["risk_red"],"12"), (pal["gray_border"],"1"), (pal["gray_border"],"1"), (pal["gray_border"],"1"))
set_shading(cc, "FFF3E0")
# Título
pt = cc.paragraphs[0]
pt.paragraph_format.space_after = Pt(8)
fmt(pt, "\u26a0 ATEN\u00c7\u00c3O \u2014 Plano de Conting\u00eancia", bold=True, size=TAM_CORPO, color=pal["risk_red"])
# Itens numerados: conceito bold + ":" + ações com seta
for idx, item_c in enumerate(cont_items, 1):
pi = cc.add_paragraph()
pi.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
pi.paragraph_format.line_spacing = 1.15
pi.paragraph_format.left_indent = Cm(0.5)
pi.paragraph_format.space_before = Pt(3)
pi.paragraph_format.space_after = Pt(3)
# Número bold + cor risk_red
fmt(pi, f"{idx}. ", bold=True, size=TAM_SMALL, color=pal["risk_red"])
# Limpar texto: remover prefixos "→ " ou "1. " que o Gemini pode ter adicionado
txt = str(item_c).strip()
import re
txt = re.sub(r'^\d+\.\s*', '', txt) # Remove "1. " prefix
txt = re.sub(r'^[\u2192→]\s*', '', txt) # Remove "→ " prefix
# Separar conceito (antes do primeiro ":") do resto
if ':' in txt:
conceito, resto = txt.split(':', 1)
# Strip markdown bold markers **
conceito = conceito.replace('**', '').strip()
fmt(pi, conceito + ":", bold=True, size=TAM_SMALL, color="1A1A1A")
# Formatar setas com espaçamento
resto = resto.strip()
# Normalizar setas: garantir " → " com espaços
resto = re.sub(r'\s*[\u2192→]\s*', ' \u2192 ', resto)
fmt_with_meta_badges(pi, " " + resto, size=TAM_SMALL, pal=pal)
else:
fmt_with_meta_badges(pi, txt, size=TAM_SMALL, pal=pal)
sn += 1; add_h1(doc, sn, "REGISTROS DA QUALIDADE (EVIDÊNCIAS)", pal)
add_body(doc, "A execução deste procedimento e eventuais intercorrências devem ser "
"obrigatoriamente documentadas nos seguintes registros para rastreabilidade e auditoria:")
for it in secoes.get("registros",[]): add_bullet(doc, str(it), pal=pal)
sn += 1; add_h1(doc, sn, "INDICADORES DE MONITORAMENTO", pal)
add_body(doc, "A eficácia da adesão a este POP será medida através dos seguintes indicadores institucionais:")
inds = secoes.get("indicadores", [])
if inds and isinstance(inds[0], dict) and "meta" in inds[0]:
add_table(doc, ["Indicador","Meta","Periodicidade"],
[[i.get("nome",""),i.get("meta",""),i.get("periodicidade","")] for i in inds],
[5157,2400,1800], pal)
else:
for it in inds: add_bullet(doc, str(it), pal=pal)
sn += 1; add_h1(doc, sn, "REFERÊNCIAS BIBLIOGRÁFICAS / NORMATIVAS", pal)
for i, ref in enumerate(secoes.get("referencias",[]), 1):
add_num(doc, i, str(ref), indent=IND_ITEM, pal=pal)
sn += 1; add_h1(doc, sn, "ANEXOS / FLUXOGRAMAS", pal)
anexos = secoes.get("anexos", [])
if anexos:
for idx, it in enumerate(anexos, 1):
nome = it.get("titulo", it) if isinstance(it, dict) else str(it)
add_bullet(doc, f"Anexo {idx}: {nome}", pal=pal)
else:
add_body(doc, "Não há anexos vinculados a este POP nesta versão.")
sn += 1; add_h1(doc, sn, "HISTÓRICO DE REVISÕES", pal)
revs = secoes.get("historico_revisoes", [])
if revs:
add_table(doc, ["Versão","Data","Descrição da Alteração","Responsável"],
[[r.get("versao",""),r.get("data",""),r.get("descricao",""),r.get("responsavel","")] for r in revs],
[1000,1200,5557,1600], pal)
# Anexos completos
if anexos:
for idx, anx in enumerate(anexos, 1):
render_annex(doc, anx, idx, pal)
titulo_safe = meta.get("titulo_processo","POP").replace(" ","_").replace("/","-")
codigo = meta.get("codigo","POP-XXX-001")
fp = os.path.join(tempfile.gettempdir(), f"{codigo}_{titulo_safe}.docx")
doc.save(fp)
return fp
# ============================================================
# VALIDADOR
# ============================================================
def validar(json_str):
try: data = json.loads(json_str)
except json.JSONDecodeError as e: return False, f"❌ JSON inválido: {e}", None
erros, avisos = [], []
if "metadata" not in data: erros.append("'metadata' ausente")
else:
for c in ["titulo_processo","codigo","versao","setor"]:
if not data["metadata"].get(c): erros.append(f"metadata.{c} vazio")
if "secoes" not in data: erros.append("'secoes' ausente")
else:
for s in ["objetivo","campo_aplicacao","conceitos","responsabilidades","recursos",
"procedimento","riscos","registros","indicadores","referencias","historico_revisoes"]:
if s not in data["secoes"]: erros.append(f"Seção '{s}' ausente")
if erros:
return False, "❌ ERROS:\n"+"\n".join(f" • {e}" for e in erros), None
msg = "✅ JSON válido!"
if avisos: msg += "\n⚠️ "+"\n".join(avisos)
return True, msg, data
# ============================================================
# GRADIO FRONTEND
# ============================================================
DEFAULT_UI_COLOR = "283264"
def processar(json_text, json_file, c_pri, c_sec, c_ter, c_zeb, logo_file):
json_str = ""
# Prioridade 1: arquivo anexo
if json_file is not None:
try:
fpath = json_file.name if hasattr(json_file, 'name') else json_file
with open(fpath, "r", encoding="utf-8") as f: json_str = f.read()
except Exception as e:
return None, f"\u274c {e}", None
# Prioridade 2: texto colado
if not json_str.strip():
json_str = json_text or ""
if not json_str.strip():
return None, "\u26a0\ufe0f Insira o c\u00f3digo.", None
jc = json_str.strip()
# Remover backticks de codeblock markdown (```json ... ```)
if jc.startswith("```"):
jc = jc.split("\n",1)[1] if "\n" in jc else jc[3:]
jc = jc.strip()
if jc.startswith("json"):
jc = jc[4:].strip()
if jc.endswith("```"):
jc = jc[:-3].strip()
ok, msg, data = validar(jc)
if not ok: return None, msg, None
pri = parse_color_input(c_pri) if c_pri else DEFAULT_UI_COLOR
pal = build_palette(pri)
if c_sec and c_sec.strip(): pal["secondary"] = parse_color_input(c_sec)
if c_ter and c_ter.strip(): pal["tertiary"] = parse_color_input(c_ter)
if c_zeb and c_zeb.strip(): pal["quaternary"] = parse_color_input(c_zeb)
logo_bytes = None
if logo_file is not None:
try:
lp = logo_file if isinstance(logo_file, str) else (logo_file.name if hasattr(logo_file,'name') else None)
if lp and os.path.exists(lp):
with open(lp, "rb") as f: logo_bytes = f.read()
except: pass
try:
overrides = {}
if c_sec and c_sec.strip(): overrides["secondary"] = c_sec
if c_ter and c_ter.strip(): overrides["tertiary"] = c_ter
if c_zeb and c_zeb.strip(): overrides["quaternary"] = c_zeb
fp = gerar_pop_docx(jc, pri, logo_bytes, palette_overrides=overrides)
meta = data.get("metadata",{})
info = "\u2705 POP gerado com sucesso!"
return fp, info, meta
except Exception as e:
return None, f"\u274c {str(e)}", None
def criar_interface():
import gradio as gr
TITLE_COLOR = "#9ab4d2"
init_pal = build_palette(DEFAULT_UI_COLOR)
APP_CSS = f"""
.gradio-container {{ max-width: 1200px !important; }}
#pop-hex-pri, #pop-hex-sec, #pop-hex-ter, #pop-hex-zeb {{
height: 0 !important; overflow: hidden !important;
margin: 0 !important; padding: 0 !important;
}}
#color-state-row {{
height: 0 !important; overflow: hidden !important;
margin: 0 !important; padding: 0 !important; gap: 0 !important;
}}
#json-paste textarea {{
min-height: 120px !important; height: 120px !important; overflow-y: auto !important;
}}
.upload-container span {{ font-size: 10px !important; }}
.upload-container {{ min-height: 130px !important; }}
h1, h3 {{ color: {TITLE_COLOR} !important; }}
.left-col {{ flex-shrink: 0 !important; }}
.step3-row {{ flex-wrap: nowrap !important; }}
.step3-row > div {{ min-width: 0 !important; }}
.landing-title {{ text-align: center; margin-top: 60px; margin-bottom: 0; }}
.landing-title h1 {{ font-size: 2.2em !important; margin-bottom: 0 !important; padding-bottom: 0 !important; }}
.landing-sub {{ text-align: center; color: #9ab4d2 !important; font-size: 0.95em;
margin-top: 8px; position: relative; z-index: 10; }}
.landing-steps {{ margin-top: 40px; }}
.step-card {{ text-align: center; padding: 10px; }}
.step-card button {{ min-height: 80px !important; white-space: normal !important;
line-height: 1.4 !important; padding: 16px 20px !important;
background: #283264 !important; border-color: #283264 !important; color: #fff !important; }}
.step-card button:hover {{ background: #3a4a8a !important; border-color: #3a4a8a !important; }}
.btn-voltar {{ position: absolute; right: 16px; top: 8px; z-index: 100; }}
"""
with gr.Blocks(title="\u2622\ufe0e RADIOTERAPIA.AI - POP de elite") as demo:
# ══════════════════════════════════════
# LANDING PAGE
# ══════════════════════════════════════
with gr.Column(visible=True) as landing_page:
gr.HTML("""
\u2014 POP de Elite \u2014
por: Braga, HF.
""")
gr.HTML("")
with gr.Row(elem_classes="landing-steps"):
with gr.Column(scale=1, elem_classes="step-card"):
btn_passo1 = gr.Button(
"\U0001f4ac Passo 1: Clique aqui primeiro, passe sua demanda e receba o conte\u00fado do POP (em c\u00f3digo) com o nosso Agente Conselheiro da Qualidade.",
variant="primary", size="lg")
with gr.Column(scale=1, elem_classes="step-card"):
btn_passo2 = gr.Button(
"\U0001f4c4 Passo 2: Depois de receber o c\u00f3digo, clique aqui para montar o POP e receber o documento final",
variant="primary", size="lg")
# ══════════════════════════════════════
# APP PRINCIPAL (começa oculto)
# ══════════════════════════════════════
with gr.Column(visible=False) as main_app:
with gr.Row():
gr.HTML("""
\u2014 POP de Elite \u2014
por: Braga, HF.
""")
btn_voltar = gr.Button("\u2190 Voltar", size="sm", variant="secondary", elem_classes="btn-voltar")
gr.Markdown("---")
with gr.Row():
# ══════════════ COLUNA ESQUERDA ══════════════
with gr.Column(scale=3, min_width=580, elem_classes="left-col"):
# ── ETAPA 1: LOGO (compacto) ──
gr.Markdown("### \U0001f5bc\ufe0e Etapa 2.1 \u2014 Logotipo institucional *(opcional)*")
logo_input = gr.Image(
type="filepath", sources=["upload"],
height=130, show_label=False
)
gr.Markdown("---")
# ── ETAPA 2: CORES (4 boxes clicáveis) ──
gr.Markdown("### \U0001f3a8\ufe0e Etapa 2.2 \u2014 Paleta de cores")
with gr.Row(elem_id="color-state-row"):
color_pri = gr.Textbox(value=f"#{init_pal['primary']}", elem_id="pop-hex-pri", show_label=False, container=False)
color_sec = gr.Textbox(value="", elem_id="pop-hex-sec", show_label=False, container=False)
color_ter = gr.Textbox(value="", elem_id="pop-hex-ter", show_label=False, container=False)
color_zeb = gr.Textbox(value="", elem_id="pop-hex-zeb", show_label=False, container=False)
gr.HTML(f"""
""")
btn_reset_colors = gr.Button("\u21ba restaurar cores padr\u00e3o", size="sm", variant="secondary")
gr.Markdown("---")
# ── ETAPA 3: JSON (lado a lado) ──
gr.Markdown("### \U0001f4dd\ufe0e Etapa 2.3 \u2014 Insira o c\u00f3digo recebido *(upload do arquivo OU cole o c\u00f3digo)*")
with gr.Row(equal_height=True, elem_classes="step3-row"):
with gr.Column(scale=3):
json_file_input = gr.File(
file_types=[".json", ".txt"], type="filepath",
height=130, show_label=False
)
with gr.Column(scale=0, min_width=30):
gr.HTML('OU
')
with gr.Column(scale=3):
json_text_input = gr.Textbox(
placeholder='Cole o c\u00f3digo aqui',
lines=6, max_lines=6, show_label=False, elem_id="json-paste"
)
# Quando arquivo é carregado, desabilita textbox (mas preserva o valor existente)
def toggle_textbox(file, current_text):
if file is not None:
return gr.Textbox(value=current_text or "", interactive=False,
placeholder='Arquivo carregado \u2014 usando arquivo anexo')
return gr.Textbox(value=current_text or "", interactive=True,
placeholder='Cole o c\u00f3digo aqui')
json_file_input.change(
fn=toggle_textbox,
inputs=[json_file_input, json_text_input],
outputs=[json_text_input]
)
btn_gerar = gr.Button("\U0001f680 GERAR POP (.docx)", variant="primary", size="lg")
btn_novo = gr.Button("\U0001f504 Novo POP \u2014 limpar c\u00f3digo", variant="secondary", size="sm")
# ══════════════ COLUNA DIREITA ══════════════
with gr.Column(scale=2, min_width=280):
result_info = gr.Markdown("")
btn_download = gr.DownloadButton(
"\U0001f4e5 Baixar POP (.docx)", visible=False, variant="primary", size="lg"
)
preview_gallery = gr.Gallery(
label="\u25c4 \u25ba Preview do documento",
columns=1, rows=1, height=320,
object_fit="contain", visible=False
)
# ═══════════ EVENTOS ═══════════
def generate_preview_images(docx_path):
"""Converte .docx → PDF → imagens para preview."""
import subprocess, glob
try:
# Converter docx → pdf via LibreOffice
tmp_dir = tempfile.mkdtemp()
subprocess.run(
["libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", tmp_dir, docx_path],
capture_output=True, timeout=30
)
pdf_files = glob.glob(os.path.join(tmp_dir, "*.pdf"))
if not pdf_files:
return []
pdf_path = pdf_files[0]
# Converter pdf → imagens via pdftoppm
subprocess.run(
["pdftoppm", "-jpeg", "-r", "150", pdf_path,
os.path.join(tmp_dir, "page")],
capture_output=True, timeout=30
)
imgs = sorted(glob.glob(os.path.join(tmp_dir, "page-*.jpg")))
return imgs if imgs else []
except Exception:
return []
def on_generate(json_text, json_file, cpri, csec, cter, czeb, logo):
fp, info, meta = processar(json_text, json_file, cpri, csec, cter, czeb, logo)
if fp:
fname = os.path.basename(fp)
msg = f"\u2705 POP gerado com sucesso!\n\n`{fname}`"
imgs = generate_preview_images(fp)
if imgs:
return (msg, gr.DownloadButton(value=fp, visible=True),
gr.Gallery(value=imgs, visible=True),
gr.Button("\U0001f680 GERAR POP (.docx)", interactive=True))
return (msg, gr.DownloadButton(value=fp, visible=True),
gr.Gallery(visible=False),
gr.Button("\U0001f680 GERAR POP (.docx)", interactive=True))
return (info or "\u26a0\ufe0f Erro", gr.DownloadButton(visible=False),
gr.Gallery(visible=False),
gr.Button("\U0001f680 GERAR POP (.docx)", interactive=True))
def on_start_generate():
return ("\u23f3 *Redigindo o POP, aguarde...*",
gr.DownloadButton(visible=False),
gr.Gallery(visible=False),
gr.Button("\u23f3 Redigindo...", variant="secondary", interactive=False))
btn_gerar.click(
fn=on_start_generate,
outputs=[result_info, btn_download, preview_gallery, btn_gerar]
).then(
fn=on_generate,
inputs=[json_text_input, json_file_input, color_pri, color_sec, color_ter, color_zeb, logo_input],
outputs=[result_info, btn_download, preview_gallery, btn_gerar]
)
def on_novo():
return "", None, "", gr.DownloadButton(visible=False), gr.Gallery(visible=False)
btn_novo.click(
fn=on_novo,
outputs=[json_text_input, json_file_input, result_info, btn_download, preview_gallery]
)
# Reset cores para padrão
def on_reset_colors():
p = build_palette(DEFAULT_UI_COLOR)
return f"#{p['primary']}", "", "", ""
btn_reset_colors.click(
fn=on_reset_colors,
outputs=[color_pri, color_sec, color_ter, color_zeb],
js=f"""() => {{
var p = '{init_pal["primary"]}', s = '{init_pal["secondary"]}',
t = '{init_pal["tertiary"]}', z = '{init_pal["quaternary"]}';
var d = '{init_pal["primary_dark"]}', ts = '{init_pal["text_on_secondary"]}';
document.getElementById('cbox-pri').style.background='#'+p;
document.getElementById('cbox-pri').querySelector('span').innerHTML='Prim\\u00e1ria
#'+p;
document.getElementById('cpick-pri').value='#'+p;
document.getElementById('cbox-sec').style.background='#'+s;
document.getElementById('cbox-sec').querySelector('span').innerHTML='Secund\\u00e1ria
#'+s;
document.getElementById('cpick-sec').value='#'+s;
document.getElementById('cbox-ter').style.background='#'+t;
document.getElementById('cbox-ter').querySelector('span').innerHTML='Terci\\u00e1ria
#'+t;
document.getElementById('cpick-ter').value='#'+t;
document.getElementById('cbox-zeb').style.background='#'+z;
document.getElementById('cbox-zeb').querySelector('span').innerHTML='Zebra
#'+z;
document.getElementById('cpick-zeb').value='#'+z;
}}"""
)
# ═══════════ NAVEGA\u00c7\u00c3O LANDING \u2194 APP ═══════════
# Passo 1: abre URL do Gemini Gem em nova aba
btn_passo1.click(
fn=None, inputs=None, outputs=None,
js="() => { window.open('https://gemini.google.com/gem/c86826a9110d', '_blank'); }"
)
# Passo 2: oculta landing, mostra app
def show_app():
return gr.Column(visible=False), gr.Column(visible=True)
btn_passo2.click(fn=show_app, outputs=[landing_page, main_app])
# Voltar: oculta app, mostra landing
def show_landing():
return gr.Column(visible=True), gr.Column(visible=False)
btn_voltar.click(fn=show_landing, outputs=[landing_page, main_app])
return demo, APP_CSS
# ══════════════════════════════════════════════════════════════
# INICIAR APLICAÇÃO
# ══════════════════════════════════════════════════════════════
demo, app_css = criar_interface()
demo.launch(debug=False, show_error=True, css=app_css,
theme=__import__('gradio').themes.Soft(primary_hue="blue"))