Henrique Braga commited on
Commit
ccb50f7
·
verified ·
1 Parent(s): c905317

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1443 -0
app.py ADDED
@@ -0,0 +1,1443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ # ══════════════════════════════════════════════════════════════
4
+ # ☢︎ RADIOTERAPIA.AI — POP de elite
5
+ # Célula 2: Aplicação completa
6
+ # por: Braga, HF.
7
+ # ══════════════════════════════════════════════════════════════
8
+ #
9
+ # • Landing page com navegação para Gemini Gem + App
10
+ # • Paleta de 4 cores clicáveis (primária cascateia, demais customizáveis)
11
+ # • Upload de logotipo institucional no cabeçalho
12
+ # • 10 tipos de SmartArt: checklist, escala, tabela, processo,
13
+ # fluxograma, ciclo, hierarquia, pirâmide, misto, texto
14
+ # • Preview de páginas com navegação ◄ ►
15
+ # • Download funcional via gr.DownloadButton
16
+ # • Cor padrão: #283264
17
+ """
18
+
19
+ import json, os, tempfile, io
20
+ from datetime import datetime
21
+ try:
22
+ import graphviz as gv
23
+ HAS_GRAPHVIZ = True
24
+ except ImportError:
25
+ HAS_GRAPHVIZ = False
26
+ from docx import Document
27
+ from docx.shared import Pt, Cm, Emu, RGBColor, Inches
28
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
29
+ from docx.enum.table import WD_TABLE_ALIGNMENT
30
+ from docx.oxml.ns import qn, nsdecls
31
+ from docx.oxml import parse_xml
32
+
33
+ # ============================================================
34
+ # SISTEMA DE CORES DINÂMICO
35
+ # ============================================================
36
+
37
+ def hex_to_rgb(h):
38
+ h = h.lstrip("#")
39
+ return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
40
+
41
+ def rgb_to_hex(r, g, b):
42
+ return f"{min(255,max(0,int(r))):02X}{min(255,max(0,int(g))):02X}{min(255,max(0,int(b))):02X}"
43
+
44
+ def lighten(hex_color, pct):
45
+ """Clareia uma cor por pct% misturando com branco."""
46
+ r, g, b = hex_to_rgb(hex_color)
47
+ return rgb_to_hex(r + (255-r)*pct, g + (255-g)*pct, b + (255-b)*pct)
48
+
49
+ def darken(hex_color, pct):
50
+ r, g, b = hex_to_rgb(hex_color)
51
+ return rgb_to_hex(r*(1-pct), g*(1-pct), b*(1-pct))
52
+
53
+ def build_palette(primary_hex):
54
+ """Gera paleta completa a partir de uma cor primária."""
55
+ p = primary_hex.lstrip("#").upper()
56
+ return {
57
+ "primary": p, # H1, bullets, table headers
58
+ "primary_dark": darken(p, 0.15), # Texto sobre fundo claro
59
+ "secondary": lighten(p, 0.45), # H2, SmartArt — +10% mais claro que v4.0
60
+ "tertiary": lighten(p, 0.70), # Footer header, zebra — +10% mais claro
61
+ "quaternary": lighten(p, 0.85), # Zebra alternada mais clara
62
+ "text_on_primary": "FFFFFF", # Texto sobre cor primária
63
+ "text_on_secondary": darken(p, 0.30), # Texto sobre cor secundária
64
+ "text_body": "1A1A1A",
65
+ "header_text": "000000", # PRETO automático para cabeçalho
66
+ "border": darken(p, 0.0),
67
+ "border_light": lighten(p, 0.20),
68
+ "risk_red": "8B1A1A",
69
+ "barrier_green": "1B5E20",
70
+ "gray_border": "7F8C9A",
71
+ "annex_border": "000000",
72
+ }
73
+
74
+ def parse_color_input(val):
75
+ """Parseia qualquer formato de cor do Gradio ColorPicker → hex 6 chars."""
76
+ if not val:
77
+ return DEFAULT_PRIMARY
78
+ val = str(val).strip()
79
+
80
+ # Formato rgb(R, G, B) ou rgba(R, G, B, A) — COM FLOATS
81
+ if val.startswith("rgb"):
82
+ import re
83
+ # Capturar números com decimais: 248.402, 79.543, etc.
84
+ nums = re.findall(r'[\d]+\.?[\d]*', val)
85
+ if len(nums) >= 3:
86
+ r = min(255, max(0, int(float(nums[0]))))
87
+ g = min(255, max(0, int(float(nums[1]))))
88
+ b = min(255, max(0, int(float(nums[2]))))
89
+ return rgb_to_hex(r, g, b)
90
+ return DEFAULT_PRIMARY
91
+
92
+ # Formato hex
93
+ c = val.lstrip("#")
94
+ c = ''.join(ch for ch in c if ch in '0123456789abcdefABCDEF')
95
+ if len(c) >= 6:
96
+ return c[:6].upper()
97
+ if len(c) == 3:
98
+ return (c[0]*2 + c[1]*2 + c[2]*2).upper()
99
+ return DEFAULT_PRIMARY
100
+
101
+ DEFAULT_PRIMARY = "283264"
102
+
103
+ FONTE = "Calibri"
104
+ TAM_CORPO = Pt(11)
105
+ TAM_H1 = Pt(12)
106
+ TAM_H2 = Pt(11)
107
+ TAM_SMALL = Pt(9)
108
+ TAM_HEADER = Pt(9)
109
+ TAM_TINY = Pt(8)
110
+
111
+ # Página A4
112
+ MARGEM_SUP = Cm(5.0) # v4.1: aumentado para 5.0cm — mais respiro header↔corpo
113
+ MARGEM_INF = Cm(3.0)
114
+ MARGEM_ESQ = Cm(2.5)
115
+ MARGEM_DIR = Cm(2.0)
116
+ LARGURA_DXA = int((21 - 2.5 - 2.0) * 567)
117
+
118
+ # Indentação hierárquica
119
+ IND_H2 = 0.5
120
+ IND_BODY = 0.5
121
+ IND_ITEM = 1.0
122
+ IND_SUBITEM = 1.5
123
+
124
+
125
+ # ============================================================
126
+ # UTILITÁRIOS DOCX
127
+ # ============================================================
128
+
129
+ def set_shading(cell, color):
130
+ cell._tc.get_or_add_tcPr().append(
131
+ parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color}" w:val="clear"/>'))
132
+
133
+ def set_borders(cell, top=None, bottom=None, left=None, right=None):
134
+ tcPr = cell._tc.get_or_add_tcPr()
135
+ borders = parse_xml(f'<w:tcBorders {nsdecls("w")}></w:tcBorders>')
136
+ for side, val in [("top",top),("bottom",bottom),("left",left),("right",right)]:
137
+ if val:
138
+ c, s = val if isinstance(val, tuple) else (val, "4")
139
+ borders.append(parse_xml(
140
+ f'<w:{side} {nsdecls("w")} w:val="single" w:sz="{s}" w:space="0" w:color="{c}"/>'))
141
+ tcPr.append(borders)
142
+
143
+ def set_valign(cell, v="center"):
144
+ cell._tc.get_or_add_tcPr().append(parse_xml(f'<w:vAlign {nsdecls("w")} w:val="{v}"/>'))
145
+
146
+ def set_width(cell, w):
147
+ cell._tc.get_or_add_tcPr().append(
148
+ parse_xml(f'<w:tcW {nsdecls("w")} w:w="{w}" w:type="dxa"/>'))
149
+
150
+ def set_margins(cell, t=0, b=0, l=80, r=80):
151
+ cell._tc.get_or_add_tcPr().append(parse_xml(
152
+ f'<w:tcMar {nsdecls("w")}><w:top w:w="{t}" w:type="dxa"/>'
153
+ f'<w:left w:w="{l}" w:type="dxa"/><w:bottom w:w="{b}" w:type="dxa"/>'
154
+ f'<w:right w:w="{r}" w:type="dxa"/></w:tcMar>'))
155
+
156
+ def fmt(p, text, bold=False, italic=False, size=None, color=None, font=FONTE, caps=False):
157
+ run = p.add_run(text)
158
+ run.bold = bold; run.italic = italic
159
+ if size: run.font.size = size
160
+ if color: run.font.color.rgb = RGBColor.from_string(color)
161
+ run.font.name = font
162
+ if caps: run.font.all_caps = True
163
+ return run
164
+
165
+ def spacing(p, before=0, after=0, line=1.15):
166
+ pf = p.paragraph_format
167
+ pf.space_before = Pt(before); pf.space_after = Pt(after); pf.line_spacing = line
168
+
169
+ def p_shading(p, color):
170
+ p._p.get_or_add_pPr().append(
171
+ parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color}" w:val="clear"/>'))
172
+
173
+ def repeat_header(row):
174
+ row._tr.get_or_add_trPr().append(parse_xml(f'<w:tblHeader {nsdecls("w")}/>'))
175
+
176
+ def page_break(doc):
177
+ p = doc.add_paragraph()
178
+ p.add_run()._r.append(parse_xml(f'<w:br {nsdecls("w")} w:type="page"/>'))
179
+
180
+
181
+ # ============================================================
182
+ # HEADER + FOOTER
183
+ # ============================================================
184
+
185
+ def build_header(section, meta, pal, logo_bytes=None):
186
+ header = section.header
187
+ header.is_linked_to_previous = False
188
+ for p in header.paragraphs: p.clear()
189
+
190
+ tbl = header.add_table(rows=4, cols=3, width=Cm(16.5))
191
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
192
+ cw = [1700, 5800, 2900]
193
+ brd = (pal["gray_border"], "4")
194
+
195
+ for row in tbl.rows:
196
+ for i, cell in enumerate(row.cells):
197
+ set_width(cell, cw[i]); set_valign(cell)
198
+ set_margins(cell, 30, 30, 80, 80)
199
+ set_borders(cell, brd, brd, brd, brd)
200
+
201
+ # Logo
202
+ logo_cell = tbl.cell(0,0).merge(tbl.cell(3,0))
203
+ logo_cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
204
+ if logo_bytes:
205
+ from docx.shared import Inches as In
206
+ run = logo_cell.paragraphs[0].add_run()
207
+ run.add_picture(io.BytesIO(logo_bytes), height=Cm(1.8))
208
+ else:
209
+ fmt(logo_cell.paragraphs[0], "[LOGO]", bold=True, size=Pt(10), color="888888")
210
+
211
+ # Título — PRETO
212
+ tc = tbl.cell(0,1).merge(tbl.cell(1,1))
213
+ tc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
214
+ fmt(tc.paragraphs[0], "PROCEDIMENTO OPERACIONAL PADRÃO",
215
+ bold=True, size=Pt(13), color="000000")
216
+
217
+ # Nome do processo — PRETO
218
+ pc = tbl.cell(2,1).merge(tbl.cell(3,1))
219
+ pc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
220
+ fmt(pc.paragraphs[0], meta.get("titulo_processo","").upper(),
221
+ bold=True, size=Pt(12), color="000000")
222
+
223
+ # Metadados — PRETO
224
+ for i, (lbl, val) in enumerate([
225
+ ("Código: ", meta.get("codigo","POP-XXX-001")),
226
+ ("Elaborado em: ", meta.get("data_elaboracao","")),
227
+ ("Revisado em: ", meta.get("data_revisao","—")),
228
+ ("Válido até: ", meta.get("validade","")),
229
+ ]):
230
+ c = tbl.cell(i, 2)
231
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
232
+ fmt(c.paragraphs[0], lbl, bold=True, size=TAM_HEADER, color="000000")
233
+ fmt(c.paragraphs[0], val or "—", size=TAM_HEADER, color="000000")
234
+
235
+
236
+ def build_footer(section, meta, pal):
237
+ footer = section.footer
238
+ footer.is_linked_to_previous = False
239
+ for p in footer.paragraphs: p.clear()
240
+
241
+ footer.add_paragraph() # separator
242
+ tbl = footer.add_table(rows=2, cols=3, width=Cm(16.5))
243
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
244
+ cw = [3500, 3500, 3400]
245
+ brd = (pal["gray_border"], "4")
246
+
247
+ for row in tbl.rows:
248
+ for i, cell in enumerate(row.cells):
249
+ set_width(cell, cw[i]); set_valign(cell)
250
+ set_margins(cell, 30, 30, 60, 60)
251
+ set_borders(cell, brd, brd, brd, brd)
252
+
253
+ for i, h in enumerate(["Elaborado por:", "Validado por:", "Aprovado por:"]):
254
+ c = tbl.cell(0, i)
255
+ set_shading(c, pal["tertiary"]) # Cor terciária no header do rodapé
256
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
257
+ fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color=pal["primary_dark"])
258
+
259
+ for i, key in enumerate(["elaborado_por", "validado_por", "aprovado_por"]):
260
+ c = tbl.cell(1, i)
261
+ person = meta.get(key, {})
262
+ nome = person.get("nome","") if isinstance(person, dict) else ""
263
+ cargo = person.get("cargo","") if isinstance(person, dict) else ""
264
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
265
+ if nome:
266
+ fmt(c.paragraphs[0], nome, bold=True, size=TAM_SMALL, color=pal["text_body"])
267
+ p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
268
+ fmt(p2, cargo, size=TAM_TINY, color="666666")
269
+ else:
270
+ fmt(c.paragraphs[0], "________________", size=TAM_SMALL, color="AAAAAA")
271
+
272
+ # Página
273
+ pp = footer.add_paragraph()
274
+ pp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
275
+ spacing(pp, 2, 0)
276
+ fmt(pp, "Página ", size=TAM_TINY, color="888888")
277
+ for x in [f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>',
278
+ f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>',
279
+ f'<w:fldChar {nsdecls("w")} w:fldCharType="separate"/>']:
280
+ pp.add_run()._r.append(parse_xml(x))
281
+ r = pp.add_run("1"); r.font.size = TAM_TINY; r.font.color.rgb = RGBColor.from_string("888888")
282
+ pp.add_run()._r.append(parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>'))
283
+ fmt(pp, f" | {meta.get('codigo','POP-XXX-001')} | v{meta.get('versao','01')}",
284
+ size=TAM_TINY, color="888888")
285
+
286
+
287
+ # ============================================================
288
+ # ELEMENTOS DE CONTEÚDO
289
+ # ============================================================
290
+
291
+ def add_h1(doc, num, titulo, pal):
292
+ p = doc.add_paragraph(); spacing(p, 16, 8)
293
+ p_shading(p, pal["primary"])
294
+ pf = p.paragraph_format; pf.left_indent = Cm(0.3); pf.right_indent = Cm(0.3)
295
+ fmt(p, f" {num}. {titulo.upper()}", bold=True, size=TAM_H1, color=pal["text_on_primary"])
296
+
297
+ def add_h2(doc, num, titulo, pal):
298
+ p = doc.add_paragraph(); spacing(p, 12, 6)
299
+ p_shading(p, pal["secondary"])
300
+ pf = p.paragraph_format; pf.left_indent = Cm(IND_H2 + 0.3); pf.right_indent = Cm(0.3)
301
+ fmt(p, f" {num}. {titulo.upper()}", bold=True, size=TAM_H2, color=pal["text_on_secondary"])
302
+
303
+ def add_body(doc, text, indent=IND_BODY):
304
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
305
+ spacing(p, 2, 5, 1.15); p.paragraph_format.left_indent = Cm(indent)
306
+ fmt(p, text, size=TAM_CORPO, color="1A1A1A")
307
+
308
+ def add_bullet(doc, text, bold_prefix=None, indent=IND_ITEM, pal=None):
309
+ c = pal["primary"] if pal else DEFAULT_PRIMARY
310
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
311
+ spacing(p, 1, 3, 1.15)
312
+ p.paragraph_format.left_indent = Cm(indent); p.paragraph_format.first_line_indent = Cm(-0.4)
313
+ fmt(p, "● ", size=Pt(9), color=c, bold=True)
314
+ if bold_prefix:
315
+ fmt(p, bold_prefix, bold=True, size=TAM_CORPO, color=c)
316
+ fmt(p, text, size=TAM_CORPO, color="1A1A1A")
317
+ else:
318
+ fmt(p, text, size=TAM_CORPO, color="1A1A1A")
319
+
320
+ def add_num(doc, n, text, indent=IND_SUBITEM, pal=None):
321
+ c = pal["primary"] if pal else DEFAULT_PRIMARY
322
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
323
+ spacing(p, 1, 4, 1.15)
324
+ p.paragraph_format.left_indent = Cm(indent); p.paragraph_format.first_line_indent = Cm(-0.5)
325
+ fmt(p, f"{n}. ", bold=True, size=TAM_CORPO, color=c)
326
+ fmt(p, text, size=TAM_CORPO, color="1A1A1A")
327
+
328
+ def add_def_item(doc, termo, defn, pal):
329
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
330
+ spacing(p, 2, 5, 1.15)
331
+ p.paragraph_format.left_indent = Cm(IND_ITEM); p.paragraph_format.first_line_indent = Cm(-0.4)
332
+ fmt(p, "● ", size=Pt(9), color=pal["primary"], bold=True)
333
+ fmt(p, f"{termo}: ", bold=True, size=TAM_CORPO, color=pal["primary"])
334
+ fmt(p, defn, size=TAM_CORPO, color="1A1A1A")
335
+
336
+ def add_risk(doc, risco, barreira, pal):
337
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
338
+ spacing(p, 4, 3, 1.15)
339
+ p.paragraph_format.left_indent = Cm(IND_ITEM); p.paragraph_format.first_line_indent = Cm(-0.5)
340
+ fmt(p, "⚠ ", size=TAM_CORPO, color=pal["risk_red"], bold=True)
341
+ fmt(p, "Risco: ", bold=True, size=TAM_CORPO, color=pal["risk_red"])
342
+ fmt(p, risco, size=TAM_CORPO, color="1A1A1A")
343
+ p2 = doc.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
344
+ spacing(p2, 0, 8, 1.15); p2.paragraph_format.left_indent = Cm(IND_SUBITEM)
345
+ fmt(p2, "↳ Barreira de Segurança: ", bold=True, size=TAM_CORPO, color=pal["barrier_green"])
346
+ fmt(p2, barreira, size=TAM_CORPO, color="1A1A1A")
347
+
348
+
349
+ # ============================================================
350
+ # TABELAS
351
+ # ============================================================
352
+
353
+ def add_table(doc, headers, rows, col_widths=None, pal=None):
354
+ nc = len(headers)
355
+ if not col_widths: col_widths = [LARGURA_DXA // nc] * nc
356
+ tbl = doc.add_table(rows=1+len(rows), cols=nc)
357
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
358
+ brd = (pal["border_light"], "4") if pal else ("3B6AA0", "4")
359
+ brd2 = (pal["gray_border"], "2") if pal else ("7F8C9A", "2")
360
+
361
+ for i, h in enumerate(headers):
362
+ c = tbl.cell(0, i); set_shading(c, pal["primary"] if pal else DEFAULT_PRIMARY)
363
+ set_width(c, col_widths[i]); set_valign(c)
364
+ set_margins(c, 50, 50, 80, 80); set_borders(c, brd, brd, brd, brd)
365
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
366
+ fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color="FFFFFF")
367
+ repeat_header(tbl.rows[0])
368
+
369
+ for ri, rd in enumerate(rows):
370
+ for ci, val in enumerate(rd):
371
+ c = tbl.cell(ri+1, ci); set_width(c, col_widths[ci]); set_valign(c)
372
+ set_margins(c, 40, 40, 80, 80); set_borders(c, brd2, brd2, brd2, brd2)
373
+ a = WD_ALIGN_PARAGRAPH.LEFT if ci == 0 and nc > 2 else WD_ALIGN_PARAGRAPH.CENTER
374
+ c.paragraphs[0].alignment = a
375
+ fmt(c.paragraphs[0], str(val), size=TAM_SMALL, color="1A1A1A")
376
+ if ri % 2 == 0:
377
+ for ci in range(nc):
378
+ set_shading(tbl.cell(ri+1, ci), pal["tertiary"] if pal else "E8EFF7")
379
+ doc.add_paragraph()
380
+
381
+
382
+ # ============================================================
383
+ # SMARTART
384
+ # ============================================================
385
+
386
+ def add_process(doc, titulo, etapas, pal):
387
+ if not etapas: return
388
+ p = doc.add_paragraph(); spacing(p, 8, 6)
389
+ p.paragraph_format.left_indent = Cm(IND_BODY)
390
+ fmt(p, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
391
+
392
+ n = len(etapas); total = n*2-1; aw = 400
393
+ bw = (LARGURA_DXA - aw*(n-1)) // n
394
+ tbl = doc.add_table(rows=1, cols=total)
395
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
396
+ colors = [pal["primary"], pal["primary"], pal["primary"], pal["primary"]]
397
+
398
+ for ci in range(total):
399
+ c = tbl.cell(0, ci)
400
+ if ci % 2 == 0:
401
+ ei = ci // 2
402
+ set_width(c, bw); set_shading(c, pal["primary"] if ei % 2 == 0 else lighten(pal["primary"], 0.15))
403
+ bg = pal["primary"] if ei % 2 == 0 else lighten(pal["primary"], 0.15)
404
+ set_borders(c, (bg,"6"), (bg,"6"), (bg,"6"), (bg,"6"))
405
+ set_valign(c); set_margins(c, 60, 60, 80, 80)
406
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
407
+ fmt(c.paragraphs[0], f"Etapa {ei+1}", bold=True, size=TAM_TINY, color="FFFFFF")
408
+ p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
409
+ fmt(p2, etapas[ei], size=TAM_SMALL, color="FFFFFF")
410
+ else:
411
+ set_width(c, aw)
412
+ set_borders(c,("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"))
413
+ set_valign(c); c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
414
+ fmt(c.paragraphs[0], "→", bold=True, size=Pt(16), color=pal["primary"])
415
+ doc.add_paragraph()
416
+
417
+
418
+ def add_checklist(doc, titulo, itens, pal):
419
+ if not itens: return
420
+ p = doc.add_paragraph(); spacing(p, 8, 6)
421
+ p.paragraph_format.left_indent = Cm(IND_BODY)
422
+ fmt(p, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
423
+
424
+ tbl = doc.add_table(rows=len(itens), cols=2)
425
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
426
+ chw = 600; tw = LARGURA_DXA - chw
427
+
428
+ for ri, item in enumerate(itens):
429
+ cc = tbl.cell(ri, 0); set_width(cc, chw); set_valign(cc)
430
+ set_margins(cc, 40, 40, 40, 40); set_shading(cc, pal["primary"])
431
+ set_borders(cc,("FFFFFF","2"),("FFFFFF","2"),(pal["primary"],"4"),("FFFFFF","2"))
432
+ cc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
433
+ fmt(cc.paragraphs[0], "✓", bold=True, size=Pt(14), color="FFFFFF")
434
+
435
+ ct = tbl.cell(ri, 1); set_width(ct, tw); set_valign(ct)
436
+ set_margins(ct, 50, 50, 120, 80)
437
+ bg = pal["quaternary"] if ri % 2 == 0 else "FFFFFF"
438
+ set_shading(ct, bg)
439
+ set_borders(ct,(pal["gray_border"],"2"),(pal["gray_border"],"2"),("FFFFFF","0"),(pal["gray_border"],"2"))
440
+ ct.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.LEFT
441
+ fmt(ct.paragraphs[0], item, size=TAM_CORPO, color="1A1A1A")
442
+ doc.add_paragraph()
443
+
444
+
445
+ def add_cycle(doc, titulo, etapas, pal):
446
+ if not etapas: return
447
+ p = doc.add_paragraph(); spacing(p, 8, 6)
448
+ p.paragraph_format.left_indent = Cm(IND_BODY)
449
+ fmt(p, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
450
+
451
+ n = len(etapas); cpr = min(n, 4); rows_n = (n+cpr-1)//cpr
452
+ bw = LARGURA_DXA // cpr
453
+ tbl = doc.add_table(rows=rows_n, cols=cpr)
454
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
455
+
456
+ for idx, et in enumerate(etapas):
457
+ r, c_idx = idx//cpr, idx%cpr
458
+ c = tbl.cell(r, c_idx); set_width(c, bw)
459
+ bg = pal["primary"] if idx % 2 == 0 else lighten(pal["primary"], 0.15)
460
+ set_shading(c, bg)
461
+ set_borders(c,("FFFFFF","4"),("FFFFFF","4"),("FFFFFF","4"),("FFFFFF","4"))
462
+ set_valign(c); set_margins(c, 60, 60, 80, 80)
463
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
464
+ arrow = " →" if idx < n-1 else " ⟳"
465
+ fmt(c.paragraphs[0], f"{idx+1}{arrow}", bold=True, size=TAM_TINY, color="FFFFFF")
466
+ p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
467
+ fmt(p2, et, size=TAM_SMALL, color="FFFFFF")
468
+
469
+ for idx in range(n, rows_n*cpr):
470
+ c = tbl.cell(idx//cpr, idx%cpr)
471
+ set_borders(c,("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"))
472
+ doc.add_paragraph()
473
+
474
+
475
+ # ============================================================
476
+ # ANEXOS COM BORDA — 1 PÁGINA POR ANEXO, BORDA FULL-HEIGHT
477
+ # ============================================================
478
+
479
+ # Altura da caixa bordada em DXA (~14.5cm para caber com H1+descrição na mesma página)
480
+ ANNEX_BOX_HEIGHT_DXA = 8200
481
+
482
+ def _set_row_height(row, height_dxa):
483
+ """Define altura mínima de uma linha de tabela."""
484
+ trPr = row._tr.get_or_add_trPr()
485
+ trPr.append(parse_xml(
486
+ f'<w:trHeight {nsdecls("w")} w:val="{height_dxa}" w:hRule="atLeast"/>'))
487
+
488
+ def _fill_cell_blank_lines(cell, n=8):
489
+ """Adiciona linhas em branco ao final de uma célula para preenchimento vertical."""
490
+ for _ in range(n):
491
+ p = cell.add_paragraph()
492
+ p.paragraph_format.space_before = Pt(0)
493
+ p.paragraph_format.space_after = Pt(0)
494
+
495
+ def start_annex_border(doc, pal):
496
+ """Cria container bordado com altura mínima full-page."""
497
+ tbl = doc.add_table(rows=1, cols=1)
498
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
499
+ c = tbl.cell(0, 0)
500
+ brd = (pal["annex_border"], "6")
501
+ set_borders(c, brd, brd, brd, brd)
502
+ set_width(c, LARGURA_DXA)
503
+ set_margins(c, 150, 150, 250, 250)
504
+ _set_row_height(tbl.rows[0], ANNEX_BOX_HEIGHT_DXA)
505
+ return c, tbl
506
+
507
+
508
+ # ============================================================
509
+ # GERADOR DE DIAGRAMAS VIA GRAPHVIZ (imagens PNG)
510
+ # ============================================================
511
+
512
+ def gerar_diagrama_png(tipo, conteudo, titulo, pal):
513
+ """Gera PNG de diagrama profissional via Graphviz. Retorna path ou None."""
514
+ if not HAS_GRAPHVIZ:
515
+ return None
516
+ try:
517
+ dot = gv.Digraph(format='png')
518
+ dot.attr(dpi='120', bgcolor='transparent', margin='0.15', nodesep='0.4', ranksep='0.5')
519
+ node_style = {
520
+ 'style': 'filled,rounded', 'shape': 'box', 'fontname': 'Arial',
521
+ 'fontsize': '11', 'fillcolor': f'#{pal["primary"]}',
522
+ 'fontcolor': '#FFFFFF', 'color': f'#{pal["primary_dark"]}', 'penwidth': '1.2',
523
+ 'height': '0.5', 'margin': '0.12,0.06'
524
+ }
525
+ node_light = {**node_style, 'fillcolor': f'#{pal["secondary"]}',
526
+ 'fontcolor': f'#{pal["text_on_secondary"]}'}
527
+ node_ter = {**node_style, 'fillcolor': f'#{pal["tertiary"]}',
528
+ 'fontcolor': f'#{pal["primary_dark"]}'}
529
+ edge_style = {'color': f'#{pal["primary"]}', 'penwidth': '1.0', 'arrowsize': '0.6'}
530
+
531
+ if tipo in ("processo", "fluxograma"):
532
+ dot.attr(rankdir='LR', size='8,2!')
533
+ etapas = conteudo if isinstance(conteudo, list) else [conteudo]
534
+ for i, et in enumerate(etapas[:6]):
535
+ st = node_style if i % 2 == 0 else node_light
536
+ dot.node(f'n{i}', et[:30], **st)
537
+ if i > 0:
538
+ dot.edge(f'n{i-1}', f'n{i}', **edge_style)
539
+
540
+ elif tipo == "hierarquia":
541
+ dot.attr(rankdir='TB', size='8,4.5!')
542
+ items = conteudo if isinstance(conteudo, list) else [conteudo]
543
+ for i, item in enumerate(items):
544
+ if isinstance(item, dict):
545
+ lbl = item.get('titulo', '')[:30]
546
+ dot.node(f'g{i}', lbl, **node_style)
547
+ for j, sub in enumerate(item.get('subitens', [])):
548
+ dot.node(f'g{i}s{j}', sub[:25], **node_light)
549
+ dot.edge(f'g{i}', f'g{i}s{j}', **edge_style)
550
+ else:
551
+ dot.node(f'g{i}', str(item)[:30], **node_style)
552
+ if i > 0:
553
+ dot.edge(f'g{i-1}', f'g{i}', **edge_style)
554
+
555
+ elif tipo == "piramide":
556
+ dot.attr(rankdir='TB', size='7,5!')
557
+ items = conteudo if isinstance(conteudo, list) else [conteudo]
558
+ for i, item in enumerate(items):
559
+ w = str(max(1.5, 3.5 - i * 0.4))
560
+ st = {**node_style, 'width': w, 'fixedsize': 'true', 'height': '0.45'}
561
+ if i > len(items) // 2:
562
+ st = {**node_ter, 'width': w, 'fixedsize': 'true', 'height': '0.45'}
563
+ dot.node(f'p{i}', str(item)[:30], **st)
564
+ if i > 0:
565
+ dot.edge(f'p{i-1}', f'p{i}', **edge_style, style='invis')
566
+ for i in range(len(items)):
567
+ dot.body.append(f' {{ rank=same; p{i} }}')
568
+
569
+ elif tipo == "ciclo":
570
+ dot.attr(rankdir='LR', size='8,3!') # Horizontal e compacto
571
+ etapas = conteudo if isinstance(conteudo, list) else [conteudo]
572
+ n = len(etapas)
573
+ for i, et in enumerate(etapas):
574
+ st = node_style if i % 2 == 0 else node_light
575
+ dot.node(f'c{i}', et[:30], **st)
576
+ for i in range(n):
577
+ dot.edge(f'c{i}', f'c{(i+1)%n}', **edge_style)
578
+
579
+ else:
580
+ return None
581
+
582
+ out = os.path.join(tempfile.gettempdir(), f"diag_{tipo}_{id(conteudo)}")
583
+ return dot.render(out, cleanup=True)
584
+ except Exception:
585
+ return None
586
+
587
+
588
+ def _inserir_diagrama_na_celula(cell, img_path):
589
+ """Insere imagem PNG do diagrama dentro de uma célula do docx."""
590
+ if not img_path or not os.path.exists(img_path):
591
+ return False
592
+ try:
593
+ p = cell.add_paragraph()
594
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
595
+ run = p.add_run()
596
+ run.add_picture(img_path, width=Cm(15))
597
+ return True
598
+ except Exception:
599
+ return False
600
+
601
+
602
+ def _inserir_diagrama_no_corpo(doc, img_path, legenda="", pal=None):
603
+ """Insere imagem PNG de diagrama no corpo do documento (fora de tabela/célula).
604
+ Centralizado, com legenda em itálico abaixo."""
605
+ if not img_path or not os.path.exists(img_path):
606
+ return
607
+ try:
608
+ p = doc.add_paragraph()
609
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
610
+ p.paragraph_format.space_before = Pt(8)
611
+ p.paragraph_format.space_after = Pt(4)
612
+ run = p.add_run()
613
+ run.add_picture(img_path, width=Cm(14))
614
+ if legenda:
615
+ pl = doc.add_paragraph()
616
+ pl.alignment = WD_ALIGN_PARAGRAPH.CENTER
617
+ pl.paragraph_format.space_before = Pt(2)
618
+ pl.paragraph_format.space_after = Pt(8)
619
+ fmt(pl, legenda, italic=True, size=Pt(9),
620
+ color=pal["primary"] if pal else "333333")
621
+ except Exception:
622
+ pass
623
+
624
+
625
+ def _render_passo(doc, num, item, pal):
626
+ """Renderiza um item de procedimento: string (texto) ou dict (diagrama inline).
627
+ Aceita: {"diagrama": {"tipo":..., "titulo":..., "conteudo":...}}
628
+ Ou: {"tipo":..., "titulo":..., "conteudo":...}
629
+ """
630
+ if isinstance(item, str):
631
+ add_num(doc, num, item, pal=pal)
632
+ elif isinstance(item, dict):
633
+ # Extrair dados do diagrama (com ou sem wrapper "diagrama")
634
+ diag = item.get("diagrama", None)
635
+ if diag is None and "tipo" in item:
636
+ diag = item # Formato direto sem wrapper
637
+ if diag is None:
638
+ # Dict sem formato reconhecido — renderizar como texto
639
+ add_num(doc, num, str(item), pal=pal)
640
+ return
641
+ tipo = diag.get("tipo", "processo")
642
+ titulo = diag.get("titulo", "Diagrama")
643
+ conteudo = diag.get("conteudo", [])
644
+ img_path = gerar_diagrama_png(tipo, conteudo, titulo, pal)
645
+ if img_path:
646
+ _inserir_diagrama_no_corpo(doc, img_path, titulo, pal)
647
+ else:
648
+ # Fallback: texto descritivo
649
+ items_txt = ', '.join(str(c) for c in conteudo) if isinstance(conteudo, list) else str(conteudo)
650
+ add_num(doc, num, f"[{titulo}]: {items_txt}", pal=pal)
651
+
652
+
653
+ def render_annex(doc, anexo, num, pal):
654
+ page_break(doc)
655
+ titulo = anexo.get("titulo", anexo) if isinstance(anexo, dict) else str(anexo)
656
+ add_h1(doc, f"ANEXO {num}", titulo.upper(), pal)
657
+
658
+ if isinstance(anexo, str):
659
+ add_body(doc, f"Conteúdo do {titulo} — a ser inserido pela instituição.")
660
+ return
661
+
662
+ if anexo.get("descricao"):
663
+ add_body(doc, anexo["descricao"])
664
+
665
+ tipo = anexo.get("tipo", "texto")
666
+ conteudo = anexo.get("conteudo", "")
667
+
668
+ # Criar container bordado com altura full-page
669
+ bc, border_tbl = start_annex_border(doc, pal)
670
+
671
+ # Para tipos de diagrama: tentar Graphviz primeiro
672
+ graphviz_ok = False
673
+ if tipo in ("processo", "fluxograma", "hierarquia", "piramide", "ciclo"):
674
+ img_path = gerar_diagrama_png(tipo, conteudo, titulo, pal)
675
+ if img_path:
676
+ _inserir_diagrama_na_celula(bc, img_path)
677
+ graphviz_ok = True
678
+ _fill_cell_blank_lines(bc, 6)
679
+
680
+ # === CHECKLIST ===
681
+ if graphviz_ok:
682
+ pass # Graphviz renderizou — pular SmartArt de tabela
683
+ elif tipo == "checklist":
684
+ itens = conteudo if isinstance(conteudo, list) else [conteudo]
685
+ pt = bc.paragraphs[0]; pt.alignment = WD_ALIGN_PARAGRAPH.LEFT
686
+ fmt(pt, f"▸ {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
687
+ inner = bc.add_table(rows=len(itens), cols=2)
688
+ chw = 500; tw = LARGURA_DXA - 900
689
+ for ri, item in enumerate(itens):
690
+ cc = inner.cell(ri, 0); set_width(cc, chw); set_valign(cc)
691
+ set_margins(cc, 30, 30, 30, 30); set_shading(cc, pal["primary"])
692
+ set_borders(cc,("FFFFFF","2"),("FFFFFF","2"),(pal["primary"],"4"),("FFFFFF","2"))
693
+ cc.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
694
+ fmt(cc.paragraphs[0], "✓", bold=True, size=Pt(12), color="FFFFFF")
695
+ ct = inner.cell(ri, 1); set_width(ct, tw); set_valign(ct)
696
+ set_margins(ct, 40, 40, 100, 60)
697
+ set_shading(ct, pal["quaternary"] if ri % 2 == 0 else "FFFFFF")
698
+ set_borders(ct,(pal["gray_border"],"1"),(pal["gray_border"],"1"),("FFFFFF","0"),(pal["gray_border"],"1"))
699
+ fmt(ct.paragraphs[0], item, size=TAM_SMALL, color="1A1A1A")
700
+ _fill_cell_blank_lines(bc, max(1, 12 - len(itens)))
701
+
702
+ # === ESCALA / TABELA ===
703
+ elif tipo in ("escala", "tabela"):
704
+ headers = anexo.get("colunas", [])
705
+ rows_data = conteudo if isinstance(conteudo, list) else anexo.get("linhas", [])
706
+ if headers and rows_data:
707
+ bc.paragraphs[0].text = ""
708
+ nc = len(headers); cw_each = (LARGURA_DXA - 900) // nc
709
+ inner = bc.add_table(rows=1+len(rows_data), cols=nc)
710
+ for i, h in enumerate(headers):
711
+ c = inner.cell(0, i); set_shading(c, pal["primary"])
712
+ set_width(c, cw_each); set_valign(c); set_margins(c, 40, 40, 60, 60)
713
+ set_borders(c,(pal["primary"],"4"),(pal["primary"],"4"),(pal["primary"],"4"),(pal["primary"],"4"))
714
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
715
+ fmt(c.paragraphs[0], h, bold=True, size=TAM_SMALL, color="FFFFFF")
716
+ for ri, rd in enumerate(rows_data):
717
+ for ci, val in enumerate(rd):
718
+ c = inner.cell(ri+1, ci); set_width(c, cw_each); set_valign(c)
719
+ set_margins(c, 30, 30, 60, 60)
720
+ set_borders(c,(pal["gray_border"],"1"),(pal["gray_border"],"1"),
721
+ (pal["gray_border"],"1"),(pal["gray_border"],"1"))
722
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
723
+ fmt(c.paragraphs[0], str(val), size=TAM_SMALL, color="1A1A1A")
724
+ if ri % 2 == 0:
725
+ for ci in range(nc): set_shading(inner.cell(ri+1, ci), pal["tertiary"])
726
+ _fill_cell_blank_lines(bc, max(1, 10 - len(rows_data)))
727
+
728
+ # === PROCESSO / FLUXOGRAMA ===
729
+ elif tipo in ("processo", "fluxograma"):
730
+ etapas = conteudo if isinstance(conteudo, list) else [conteudo]
731
+ etapas = etapas[:6]
732
+ fmt(bc.paragraphs[0], f"▸ Fluxo do Processo", bold=True, size=TAM_CORPO, color=pal["primary"])
733
+ n = len(etapas); total = n*2-1; aw = 350
734
+ bw = (LARGURA_DXA - 900 - aw*(n-1)) // n
735
+ inner = bc.add_table(rows=1, cols=total)
736
+ for ci in range(total):
737
+ c = inner.cell(0, ci)
738
+ if ci % 2 == 0:
739
+ ei = ci // 2; set_width(c, bw)
740
+ bg = pal["primary"] if ei % 2 == 0 else lighten(pal["primary"], 0.15)
741
+ set_shading(c, bg); set_borders(c,(bg,"4"),(bg,"4"),(bg,"4"),(bg,"4"))
742
+ set_valign(c); set_margins(c, 50, 50, 60, 60)
743
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
744
+ fmt(c.paragraphs[0], f"Etapa {ei+1}", bold=True, size=TAM_TINY, color="FFFFFF")
745
+ p2 = c.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
746
+ fmt(p2, etapas[ei], size=Pt(8), color="FFFFFF")
747
+ else:
748
+ set_width(c, aw)
749
+ set_borders(c,("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"),("FFFFFF","0"))
750
+ set_valign(c); c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
751
+ fmt(c.paragraphs[0], "\u2192", bold=True, size=Pt(14), color=pal["primary"])
752
+ _fill_cell_blank_lines(bc, 15)
753
+
754
+ # === HIERARQUIA (organograma / classificação em níveis) ===
755
+ elif tipo == "hierarquia":
756
+ items = conteudo if isinstance(conteudo, list) else [conteudo]
757
+ fmt(bc.paragraphs[0], f"\u25b8 {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
758
+ for item in items:
759
+ if isinstance(item, dict):
760
+ # Nível 1: box colorido
761
+ p1 = bc.add_paragraph()
762
+ p1.paragraph_format.space_before = Pt(6)
763
+ p1_shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{pal["primary"]}" w:val="clear"/>')
764
+ p1._p.get_or_add_pPr().append(p1_shading)
765
+ fmt(p1, f" {item.get('titulo', '')}", bold=True, size=TAM_CORPO, color="FFFFFF")
766
+ # Nível 2: subitens indentados com barra lateral
767
+ for sub in item.get("subitens", []):
768
+ p2 = bc.add_paragraph()
769
+ p2.paragraph_format.left_indent = Cm(1.0)
770
+ p2.paragraph_format.space_before = Pt(2)
771
+ p2_shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{pal["tertiary"]}" w:val="clear"/>')
772
+ p2._p.get_or_add_pPr().append(p2_shading)
773
+ fmt(p2, f" \u25b9 {sub}", size=TAM_SMALL, color=pal["primary_dark"])
774
+ elif isinstance(item, str):
775
+ p1 = bc.add_paragraph()
776
+ p1_shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{pal["secondary"]}" w:val="clear"/>')
777
+ p1._p.get_or_add_pPr().append(p1_shading)
778
+ fmt(p1, f" {item}", bold=True, size=TAM_SMALL, color=pal["primary_dark"])
779
+ _fill_cell_blank_lines(bc, max(1, 8 - len(items)))
780
+
781
+ # === PIRÂMIDE (de cima para baixo, estreito→largo) ===
782
+ elif tipo == "piramide":
783
+ items = conteudo if isinstance(conteudo, list) else [conteudo]
784
+ fmt(bc.paragraphs[0], f"\u25b8 {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
785
+ n = len(items)
786
+ max_w = LARGURA_DXA - 900
787
+ for i, item in enumerate(items):
788
+ # Cada nível fica mais largo (proporção crescente)
789
+ level_w = int(max_w * (0.4 + 0.6 * i / max(n-1, 1)))
790
+ margin_l = (max_w - level_w) // 2
791
+ inner = bc.add_table(rows=1, cols=1)
792
+ inner.alignment = WD_TABLE_ALIGNMENT.CENTER
793
+ c = inner.cell(0, 0)
794
+ set_width(c, level_w)
795
+ # Gradiente de cores: topo=primário, base=terciário
796
+ frac = i / max(n-1, 1)
797
+ r1, g1, b1 = hex_to_rgb(pal["primary"])
798
+ r2, g2, b2 = hex_to_rgb(pal["tertiary"])
799
+ bg = rgb_to_hex(r1+(r2-r1)*frac, g1+(g2-g1)*frac, b1+(b2-b1)*frac)
800
+ set_shading(c, bg)
801
+ brd = (bg, "4")
802
+ set_borders(c, brd, brd, brd, brd)
803
+ set_valign(c); set_margins(c, 40, 40, 80, 80)
804
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
805
+ txt_color = "FFFFFF" if frac < 0.5 else pal["primary_dark"]
806
+ fmt(c.paragraphs[0], str(item), bold=True, size=TAM_SMALL, color=txt_color)
807
+ _fill_cell_blank_lines(bc, max(1, 10 - n))
808
+
809
+ # === CICLO (PDCA, melhoria contínua) ===
810
+ elif tipo == "ciclo":
811
+ etapas = conteudo if isinstance(conteudo, list) else [conteudo]
812
+ fmt(bc.paragraphs[0], f"\u25b8 {titulo}", bold=True, size=TAM_CORPO, color=pal["primary"])
813
+ n = len(etapas)
814
+ # Layout em grid 2x2 ou linear
815
+ if n == 4:
816
+ inner = bc.add_table(rows=2, cols=2)
817
+ inner.alignment = WD_TABLE_ALIGNMENT.CENTER
818
+ positions = [(0,0),(0,1),(1,1),(1,0)] # Sentido horário
819
+ colors = [pal["primary"], lighten(pal["primary"],0.15),
820
+ lighten(pal["primary"],0.30), lighten(pal["primary"],0.45)]
821
+ bw = (LARGURA_DXA - 900) // 2
822
+ for idx, (r,c_idx) in enumerate(positions):
823
+ c = inner.cell(r, c_idx); set_width(c, bw); set_valign(c)
824
+ set_shading(c, colors[idx])
825
+ brd = (colors[idx], "4")
826
+ set_borders(c, brd, brd, brd, brd)
827
+ set_margins(c, 50, 50, 80, 80)
828
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
829
+ fmt(c.paragraphs[0], etapas[idx], bold=True, size=TAM_SMALL, color="FFFFFF")
830
+ # Setas no centro
831
+ p_arrow = bc.add_paragraph()
832
+ p_arrow.alignment = WD_ALIGN_PARAGRAPH.CENTER
833
+ fmt(p_arrow, "\u21bb Ciclo Cont\u00ednuo", bold=True, size=TAM_SMALL, color=pal["primary"])
834
+ else:
835
+ cpr = min(n, 4); rows_n = (n+cpr-1)//cpr
836
+ bw = (LARGURA_DXA - 900) // cpr
837
+ inner = bc.add_table(rows=rows_n, cols=cpr)
838
+ inner.alignment = WD_TABLE_ALIGNMENT.CENTER
839
+ for idx, et in enumerate(etapas):
840
+ r, ci2 = divmod(idx, cpr)
841
+ c = inner.cell(r, ci2); set_width(c, bw); set_valign(c)
842
+ bg = pal["primary"] if idx % 2 == 0 else lighten(pal["primary"], 0.20)
843
+ set_shading(c, bg)
844
+ brd = (bg, "4")
845
+ set_borders(c, brd, brd, brd, brd)
846
+ set_margins(c, 40, 40, 60, 60)
847
+ c.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
848
+ fmt(c.paragraphs[0], et, bold=True, size=TAM_TINY, color="FFFFFF")
849
+ _fill_cell_blank_lines(bc, 10)
850
+
851
+ # === MISTO ===
852
+ elif tipo == "misto":
853
+ bc.paragraphs[0].text = ""
854
+ elementos = conteudo if isinstance(conteudo, list) else [conteudo]
855
+ for elem in elementos:
856
+ if isinstance(elem, str):
857
+ pe = bc.add_paragraph(); fmt(pe, elem, size=TAM_CORPO, color="1A1A1A")
858
+ elif isinstance(elem, dict):
859
+ st = elem.get("tipo", "texto")
860
+ if st == "texto":
861
+ pe = bc.add_paragraph(); fmt(pe, elem.get("conteudo",""), size=TAM_CORPO, color="1A1A1A")
862
+ elif st == "checklist":
863
+ pe = bc.add_paragraph()
864
+ fmt(pe, f"\n\u25b8 {elem.get('titulo','')}", bold=True, size=TAM_CORPO, color=pal["primary"])
865
+ for it in elem.get("itens", []):
866
+ pi = bc.add_paragraph(); pi.paragraph_format.left_indent = Cm(0.5)
867
+ fmt(pi, "\u2713 ", bold=True, size=TAM_SMALL, color=pal["primary"])
868
+ fmt(pi, it, size=TAM_SMALL, color="1A1A1A")
869
+ elif st == "processo":
870
+ pe = bc.add_paragraph()
871
+ fmt(pe, f"\n\u25b8 {elem.get('titulo','')}", bold=True, size=TAM_CORPO, color=pal["primary"])
872
+ for i, et in enumerate(elem.get("etapas",[]), 1):
873
+ pi = bc.add_paragraph(); pi.paragraph_format.left_indent = Cm(0.5)
874
+ fmt(pi, f"{i}. ", bold=True, size=TAM_SMALL, color=pal["primary"])
875
+ fmt(pi, et, size=TAM_SMALL, color="1A1A1A")
876
+ elif st == "tabela":
877
+ pe = bc.add_paragraph()
878
+ fmt(pe, f"\n\u25b8 {elem.get('titulo','')}", bold=True, size=TAM_CORPO, color=pal["primary"])
879
+ hdrs = elem.get("colunas",[]); rws = elem.get("linhas",[])
880
+ if hdrs and rws:
881
+ nc2 = len(hdrs); cw2 = (LARGURA_DXA - 900) // nc2
882
+ it2 = bc.add_table(rows=1+len(rws), cols=nc2)
883
+ for i, h in enumerate(hdrs):
884
+ cc2 = it2.cell(0,i); set_shading(cc2, pal["primary"])
885
+ set_width(cc2,cw2); set_valign(cc2); set_margins(cc2,30,30,50,50)
886
+ set_borders(cc2,(pal["primary"],"3"),(pal["primary"],"3"),
887
+ (pal["primary"],"3"),(pal["primary"],"3"))
888
+ cc2.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
889
+ fmt(cc2.paragraphs[0], h, bold=True, size=TAM_TINY, color="FFFFFF")
890
+ for ri2, rd2 in enumerate(rws):
891
+ for ci2, v2 in enumerate(rd2):
892
+ cc2 = it2.cell(ri2+1,ci2); set_width(cc2,cw2); set_valign(cc2)
893
+ set_margins(cc2,25,25,50,50)
894
+ set_borders(cc2,(pal["gray_border"],"1"),(pal["gray_border"],"1"),
895
+ (pal["gray_border"],"1"),(pal["gray_border"],"1"))
896
+ cc2.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
897
+ fmt(cc2.paragraphs[0], str(v2), size=TAM_TINY, color="1A1A1A")
898
+ if ri2 % 2 == 0:
899
+ for ci2 in range(nc2): set_shading(it2.cell(ri2+1,ci2), pal["tertiary"])
900
+ _fill_cell_blank_lines(bc, 4)
901
+
902
+ # === TEXTO / FALLBACK ===
903
+ else:
904
+ if isinstance(conteudo, str):
905
+ fmt(bc.paragraphs[0], conteudo, size=TAM_CORPO, color="1A1A1A")
906
+ elif isinstance(conteudo, list):
907
+ bc.paragraphs[0].text = ""
908
+ for item in conteudo:
909
+ pi = bc.add_paragraph(); fmt(pi, f"\u25cf {item}", size=TAM_CORPO, color="1A1A1A")
910
+ _fill_cell_blank_lines(bc, 12)
911
+
912
+
913
+ # ============================================================
914
+ # GERADOR PRINCIPAL
915
+ # ============================================================
916
+
917
+ def gerar_pop_docx(json_str, primary_color=None, logo_bytes=None, palette_overrides=None):
918
+ data = json.loads(json_str)
919
+ meta = data.get("metadata", {})
920
+ secoes = data.get("secoes", {})
921
+ pal = build_palette(parse_color_input(primary_color))
922
+ # Aplicar overrides individuais de cor
923
+ if palette_overrides:
924
+ for k, v in palette_overrides.items():
925
+ if v and v.strip():
926
+ pal[k] = parse_color_input(v)
927
+
928
+ doc = Document()
929
+ style = doc.styles["Normal"]
930
+ style.font.name = FONTE; style.font.size = TAM_CORPO
931
+ style.font.color.rgb = RGBColor.from_string("1A1A1A")
932
+ style.paragraph_format.space_after = Pt(4); style.paragraph_format.line_spacing = 1.15
933
+
934
+ sec = doc.sections[0]
935
+ sec.page_width = Cm(21); sec.page_height = Cm(29.7)
936
+ sec.top_margin = MARGEM_SUP; sec.bottom_margin = MARGEM_INF
937
+ sec.left_margin = MARGEM_ESQ; sec.right_margin = MARGEM_DIR
938
+
939
+ build_header(sec, meta, pal, logo_bytes)
940
+ build_footer(sec, meta, pal)
941
+
942
+ if doc.paragraphs and doc.paragraphs[0].text == "":
943
+ doc.paragraphs[0]._element.getparent().remove(doc.paragraphs[0]._element)
944
+
945
+ # Seções
946
+ sn = 1
947
+ add_h1(doc, sn, "OBJETIVO / FINALIDADE", pal)
948
+ add_body(doc, secoes.get("objetivo", "[Objetivo não fornecido]"))
949
+
950
+ sn += 1; add_h1(doc, sn, "CAMPO DE APLICAÇÃO / ÁREA", pal)
951
+ add_body(doc, secoes.get("campo_aplicacao", "[Campo não fornecido]"))
952
+
953
+ sn += 1; add_h1(doc, sn, "CONCEITOS E DEFINIÇÕES", pal)
954
+ for it in secoes.get("conceitos", []):
955
+ if isinstance(it, dict): add_def_item(doc, it.get("termo",""), it.get("definicao",""), pal)
956
+ else: add_bullet(doc, str(it), pal=pal)
957
+
958
+ sn += 1; add_h1(doc, sn, "RESPONSABILIDADES E COMPETÊNCIAS", pal)
959
+ for it in secoes.get("responsabilidades", []):
960
+ if isinstance(it, dict):
961
+ add_bullet(doc, it.get("acoes",""), bold_prefix=f"{it.get('papel','')}: ", pal=pal)
962
+ else: add_bullet(doc, str(it), pal=pal)
963
+
964
+ sn += 1; add_h1(doc, sn, "RECURSOS NECESSÁRIOS (MATERIAIS E EQUIPAMENTOS)", pal)
965
+ for it in secoes.get("recursos", []):
966
+ add_bullet(doc, str(it), pal=pal)
967
+
968
+ sn += 1; add_h1(doc, sn, "DESCRIÇÃO DO PROCEDIMENTO (PASSO A PASSO)", pal)
969
+ proc = secoes.get("procedimento", {})
970
+ sub = 1
971
+ add_h2(doc, f"{sn}.{sub}", "Ações Iniciais e Preparo", pal)
972
+ for i, p in enumerate(proc.get("acoes_iniciais",[]), 1): _render_passo(doc, i, p, pal)
973
+ sub += 1; add_h2(doc, f"{sn}.{sub}", "Execução Técnica", pal)
974
+ for i, p in enumerate(proc.get("execucao_tecnica",[]), 1): _render_passo(doc, i, p, pal)
975
+ sub += 1; add_h2(doc, f"{sn}.{sub}", "Ações Finais e Organização", pal)
976
+ for i, p in enumerate(proc.get("acoes_finais",[]), 1): _render_passo(doc, i, p, pal)
977
+
978
+ sn += 1; add_h1(doc, sn, "GERENCIAMENTO DE RISCO E PONTOS CRÍTICOS", pal)
979
+ riscos = secoes.get("riscos", {})
980
+ add_h2(doc, f"{sn}.1", "Riscos Assistenciais", pal)
981
+ for it in riscos.get("assistenciais", []):
982
+ if isinstance(it, dict): add_risk(doc, it.get("risco",""), it.get("barreira",""), pal)
983
+ else: add_bullet(doc, str(it), pal=pal)
984
+ add_h2(doc, f"{sn}.2", "Plano de Contingência", pal)
985
+ add_body(doc, riscos.get("contingencia","[Contingência não fornecida]"), indent=IND_SUBITEM)
986
+
987
+ sn += 1; add_h1(doc, sn, "REGISTROS DA QUALIDADE (EVIDÊNCIAS)", pal)
988
+ add_body(doc, "A execução deste procedimento e eventuais intercorrências devem ser "
989
+ "obrigatoriamente documentadas nos seguintes registros para rastreabilidade e auditoria:")
990
+ for it in secoes.get("registros",[]): add_bullet(doc, str(it), pal=pal)
991
+
992
+ sn += 1; add_h1(doc, sn, "INDICADORES DE MONITORAMENTO", pal)
993
+ add_body(doc, "A eficácia da adesão a este POP será medida através dos seguintes indicadores institucionais:")
994
+ inds = secoes.get("indicadores", [])
995
+ if inds and isinstance(inds[0], dict) and "meta" in inds[0]:
996
+ add_table(doc, ["Indicador","Meta","Periodicidade"],
997
+ [[i.get("nome",""),i.get("meta",""),i.get("periodicidade","")] for i in inds],
998
+ [5157,2400,1800], pal)
999
+ else:
1000
+ for it in inds: add_bullet(doc, str(it), pal=pal)
1001
+
1002
+ sn += 1; add_h1(doc, sn, "REFERÊNCIAS BIBLIOGRÁFICAS / NORMATIVAS", pal)
1003
+ for i, ref in enumerate(secoes.get("referencias",[]), 1):
1004
+ add_num(doc, i, str(ref), indent=IND_ITEM, pal=pal)
1005
+
1006
+ sn += 1; add_h1(doc, sn, "ANEXOS / FLUXOGRAMAS", pal)
1007
+ anexos = secoes.get("anexos", [])
1008
+ if anexos:
1009
+ for idx, it in enumerate(anexos, 1):
1010
+ nome = it.get("titulo", it) if isinstance(it, dict) else str(it)
1011
+ add_bullet(doc, f"Anexo {idx}: {nome}", pal=pal)
1012
+ else:
1013
+ add_body(doc, "Não há anexos vinculados a este POP nesta versão.")
1014
+
1015
+ sn += 1; add_h1(doc, sn, "HISTÓRICO DE REVISÕES", pal)
1016
+ revs = secoes.get("historico_revisoes", [])
1017
+ if revs:
1018
+ add_table(doc, ["Versão","Data","Descrição da Alteração","Responsável"],
1019
+ [[r.get("versao",""),r.get("data",""),r.get("descricao",""),r.get("responsavel","")] for r in revs],
1020
+ [1000,1200,5557,1600], pal)
1021
+
1022
+ # Anexos completos
1023
+ if anexos:
1024
+ for idx, anx in enumerate(anexos, 1):
1025
+ render_annex(doc, anx, idx, pal)
1026
+
1027
+ titulo_safe = meta.get("titulo_processo","POP").replace(" ","_").replace("/","-")
1028
+ codigo = meta.get("codigo","POP-XXX-001")
1029
+ fp = os.path.join(tempfile.gettempdir(), f"{codigo}_{titulo_safe}.docx")
1030
+ doc.save(fp)
1031
+ return fp
1032
+
1033
+
1034
+ # ============================================================
1035
+ # VALIDADOR
1036
+ # ============================================================
1037
+
1038
+ def validar(json_str):
1039
+ try: data = json.loads(json_str)
1040
+ except json.JSONDecodeError as e: return False, f"❌ JSON inválido: {e}", None
1041
+ erros, avisos = [], []
1042
+ if "metadata" not in data: erros.append("'metadata' ausente")
1043
+ else:
1044
+ for c in ["titulo_processo","codigo","versao","setor"]:
1045
+ if not data["metadata"].get(c): erros.append(f"metadata.{c} vazio")
1046
+ if "secoes" not in data: erros.append("'secoes' ausente")
1047
+ else:
1048
+ for s in ["objetivo","campo_aplicacao","conceitos","responsabilidades","recursos",
1049
+ "procedimento","riscos","registros","indicadores","referencias","historico_revisoes"]:
1050
+ if s not in data["secoes"]: erros.append(f"Seção '{s}' ausente")
1051
+ if erros:
1052
+ return False, "❌ ERROS:\n"+"\n".join(f" • {e}" for e in erros), None
1053
+ msg = "✅ JSON válido!"
1054
+ if avisos: msg += "\n⚠️ "+"\n".join(avisos)
1055
+ return True, msg, data
1056
+
1057
+
1058
+
1059
+ # ============================================================
1060
+ # GRADIO FRONTEND
1061
+ # ============================================================
1062
+
1063
+ DEFAULT_UI_COLOR = "283264"
1064
+
1065
+ def processar(json_text, json_file, c_pri, c_sec, c_ter, c_zeb, logo_file):
1066
+ json_str = ""
1067
+ # Prioridade 1: arquivo anexo
1068
+ if json_file is not None:
1069
+ try:
1070
+ fpath = json_file.name if hasattr(json_file, 'name') else json_file
1071
+ with open(fpath, "r", encoding="utf-8") as f: json_str = f.read()
1072
+ except Exception as e:
1073
+ return None, f"\u274c {e}", None
1074
+ # Prioridade 2: texto colado
1075
+ if not json_str.strip():
1076
+ json_str = json_text or ""
1077
+ if not json_str.strip():
1078
+ return None, "\u26a0\ufe0f Insira o c\u00f3digo.", None
1079
+ jc = json_str.strip()
1080
+ if jc.startswith("```"): jc = jc.split("\n",1)[1] if "\n" in jc else jc[3:]
1081
+ if jc.endswith("```"): jc = jc[:-3]
1082
+ jc = jc.strip()
1083
+ ok, msg, data = validar(jc)
1084
+ if not ok: return None, msg, None
1085
+ pri = parse_color_input(c_pri) if c_pri else DEFAULT_UI_COLOR
1086
+ pal = build_palette(pri)
1087
+ if c_sec and c_sec.strip(): pal["secondary"] = parse_color_input(c_sec)
1088
+ if c_ter and c_ter.strip(): pal["tertiary"] = parse_color_input(c_ter)
1089
+ if c_zeb and c_zeb.strip(): pal["quaternary"] = parse_color_input(c_zeb)
1090
+ logo_bytes = None
1091
+ if logo_file is not None:
1092
+ try:
1093
+ lp = logo_file if isinstance(logo_file, str) else (logo_file.name if hasattr(logo_file,'name') else None)
1094
+ if lp and os.path.exists(lp):
1095
+ with open(lp, "rb") as f: logo_bytes = f.read()
1096
+ except: pass
1097
+ try:
1098
+ overrides = {}
1099
+ if c_sec and c_sec.strip(): overrides["secondary"] = c_sec
1100
+ if c_ter and c_ter.strip(): overrides["tertiary"] = c_ter
1101
+ if c_zeb and c_zeb.strip(): overrides["quaternary"] = c_zeb
1102
+ fp = gerar_pop_docx(jc, pri, logo_bytes, palette_overrides=overrides)
1103
+ meta = data.get("metadata",{})
1104
+ info = "\u2705 POP gerado com sucesso!"
1105
+ return fp, info, meta
1106
+ except Exception as e:
1107
+ return None, f"\u274c {str(e)}", None
1108
+
1109
+
1110
+ def criar_interface():
1111
+ try: import gradio as gr
1112
+ except: os.system("pip install gradio -q"); import gradio as gr
1113
+
1114
+ TITLE_COLOR = "#9ab4d2"
1115
+ init_pal = build_palette(DEFAULT_UI_COLOR)
1116
+
1117
+ with gr.Blocks(
1118
+ title="\u2622\ufe0e RADIOTERAPIA.AI - POP de elite",
1119
+ theme=gr.themes.Soft(primary_hue="blue"),
1120
+ css=f"""
1121
+ .gradio-container {{ max-width: 1200px !important; }}
1122
+ #pop-hex-pri, #pop-hex-sec, #pop-hex-ter, #pop-hex-zeb {{
1123
+ height: 0 !important; overflow: hidden !important;
1124
+ margin: 0 !important; padding: 0 !important;
1125
+ }}
1126
+ #color-state-row {{
1127
+ height: 0 !important; overflow: hidden !important;
1128
+ margin: 0 !important; padding: 0 !important; gap: 0 !important;
1129
+ }}
1130
+ #json-paste textarea {{
1131
+ min-height: 120px !important; height: 120px !important; overflow-y: auto !important;
1132
+ }}
1133
+ .upload-container span {{ font-size: 10px !important; }}
1134
+ .upload-container {{ min-height: 130px !important; }}
1135
+ h1, h3 {{ color: {TITLE_COLOR} !important; }}
1136
+ .left-col {{ flex-shrink: 0 !important; }}
1137
+ .step3-row {{ flex-wrap: nowrap !important; }}
1138
+ .step3-row > div {{ min-width: 0 !important; }}
1139
+ .landing-title {{ text-align: center; margin-top: 60px; margin-bottom: 0; }}
1140
+ .landing-title h1 {{ font-size: 2.2em !important; margin-bottom: 0 !important; padding-bottom: 0 !important; }}
1141
+ .landing-sub {{ text-align: center; color: #9ab4d2 !important; font-size: 0.95em;
1142
+ margin-top: 8px; position: relative; z-index: 10; }}
1143
+ .landing-steps {{ margin-top: 40px; }}
1144
+ .step-card {{ text-align: center; padding: 10px; }}
1145
+ .step-card button {{ min-height: 80px !important; white-space: normal !important;
1146
+ line-height: 1.4 !important; padding: 16px 20px !important;
1147
+ background: #283264 !important; border-color: #283264 !important; color: #fff !important; }}
1148
+ .step-card button:hover {{ background: #3a4a8a !important; border-color: #3a4a8a !important; }}
1149
+ .btn-voltar {{ position: absolute; right: 16px; top: 8px; z-index: 100; }}
1150
+ """
1151
+ ) as demo:
1152
+
1153
+ # ══════════════════════════════════════
1154
+ # LANDING PAGE
1155
+ # ══════════════════════════════════════
1156
+ with gr.Column(visible=True) as landing_page:
1157
+ gr.Markdown("# \u2622\ufe0e RADIOTERAPIA.AI \u2014 POP de elite", elem_classes="landing-title")
1158
+ gr.Markdown("**por: Braga, HF.**", elem_classes="landing-sub")
1159
+
1160
+ gr.HTML("<div style='height:50px;'></div>")
1161
+
1162
+ with gr.Row(elem_classes="landing-steps"):
1163
+ with gr.Column(scale=1, elem_classes="step-card"):
1164
+ btn_passo1 = gr.Button(
1165
+ "\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.",
1166
+ variant="primary", size="lg")
1167
+ with gr.Column(scale=1, elem_classes="step-card"):
1168
+ btn_passo2 = gr.Button(
1169
+ "\U0001f4c4 Passo 2: Depois de receber o c\u00f3digo, clique aqui para montar o POP e receber o documento final",
1170
+ variant="primary", size="lg")
1171
+
1172
+ # ══════════════════════════════════════
1173
+ # APP PRINCIPAL (começa oculto)
1174
+ # ══════════════════════════════════════
1175
+ with gr.Column(visible=False) as main_app:
1176
+ with gr.Row():
1177
+ gr.Markdown("# \u2622\ufe0e RADIOTERAPIA.AI \u2014 POP de elite")
1178
+ btn_voltar = gr.Button("\u2190 Voltar", size="sm", variant="secondary", elem_classes="btn-voltar")
1179
+ gr.Markdown("*por: Braga, HF.*\n\n---")
1180
+
1181
+ with gr.Row():
1182
+
1183
+ # ══════════════ COLUNA ESQUERDA ══════════════
1184
+ with gr.Column(scale=3, min_width=580, elem_classes="left-col"):
1185
+
1186
+ # ── ETAPA 1: LOGO (compacto) ──
1187
+ gr.Markdown("### \U0001f5bc\ufe0e Etapa 2.1 \u2014 Logotipo institucional *(opcional)*")
1188
+ logo_input = gr.Image(
1189
+ type="filepath", sources=["upload"],
1190
+ height=130, show_label=False, show_download_button=False
1191
+ )
1192
+
1193
+ gr.Markdown("---")
1194
+
1195
+ # ── ETAPA 2: CORES (4 boxes clicáveis) ──
1196
+ gr.Markdown("### \U0001f3a8\ufe0e Etapa 2.2 \u2014 Paleta de cores")
1197
+ with gr.Row(elem_id="color-state-row"):
1198
+ color_pri = gr.Textbox(value=f"#{init_pal['primary']}", elem_id="pop-hex-pri", show_label=False, container=False)
1199
+ color_sec = gr.Textbox(value="", elem_id="pop-hex-sec", show_label=False, container=False)
1200
+ color_ter = gr.Textbox(value="", elem_id="pop-hex-ter", show_label=False, container=False)
1201
+ color_zeb = gr.Textbox(value="", elem_id="pop-hex-zeb", show_label=False, container=False)
1202
+
1203
+ gr.HTML(f"""
1204
+ <div style="display:flex; gap:6px; height:50px; position:relative;" id="color-boxes">
1205
+ <input type="color" id="cpick-pri" value="#{init_pal['primary']}"
1206
+ style="position:absolute;opacity:0;width:0;height:0;"
1207
+ oninput="
1208
+ var v=this.value, hex=v.replace('#','');
1209
+ document.getElementById('cbox-pri').style.background=v;
1210
+ document.getElementById('cbox-pri').querySelector('span').innerHTML='Prim\\u00e1ria<br>'+v.toUpperCase();
1211
+ var t=document.querySelector('#pop-hex-pri textarea')||document.querySelector('#pop-hex-pri input');
1212
+ if(t){{t.value=v;t.dispatchEvent(new Event('input',{{bubbles:true}}));}}
1213
+ var r=parseInt(hex.substr(0,2),16),g=parseInt(hex.substr(2,2),16),b=parseInt(hex.substr(4,2),16);
1214
+ function mx(c,p){{return Math.min(255,Math.max(0,Math.round(c+(255-c)*p)));}}
1215
+ function th(r,g,b){{return '#'+[r,g,b].map(x=>x.toString(16).padStart(2,'0')).join('').toUpperCase();}}
1216
+ var cs=th(mx(r,.45),mx(g,.45),mx(b,.45));
1217
+ var ct=th(mx(r,.70),mx(g,.70),mx(b,.70));
1218
+ var cz=th(mx(r,.85),mx(g,.85),mx(b,.85));
1219
+ [['sec',cs,'Secund\\u00e1ria'],['ter',ct,'Terci\\u00e1ria'],['zeb',cz,'Zebra']].forEach(function(x){{
1220
+ document.getElementById('cbox-'+x[0]).style.background=x[1];
1221
+ document.getElementById('cbox-'+x[0]).querySelector('span').innerHTML=x[2]+'<br>'+x[1];
1222
+ document.getElementById('cpick-'+x[0]).value=x[1];
1223
+ var t2=document.querySelector('#pop-hex-'+x[0]+' textarea')||document.querySelector('#pop-hex-'+x[0]+' input');
1224
+ if(t2){{t2.value=x[1];t2.dispatchEvent(new Event('input',{{bubbles:true}}));}}
1225
+ }});
1226
+ ">
1227
+ <div id="cbox-pri" onclick="document.getElementById('cpick-pri').click();"
1228
+ style="flex:1;background:#{init_pal['primary']};border-radius:6px;cursor:pointer;
1229
+ display:flex;align-items:center;justify-content:center;border:2px solid #444;">
1230
+ <span style="color:#fff;font-size:9px;font-weight:bold;text-align:center;pointer-events:none;">
1231
+ Prim\u00e1ria<br>#{init_pal['primary']}</span></div>
1232
+
1233
+ <input type="color" id="cpick-sec" value="#{init_pal['secondary']}"
1234
+ style="position:absolute;opacity:0;width:0;height:0;"
1235
+ oninput="var v=this.value;document.getElementById('cbox-sec').style.background=v;
1236
+ document.getElementById('cbox-sec').querySelector('span').innerHTML='Secund\\u00e1ria<br>'+v.toUpperCase();
1237
+ var t=document.querySelector('#pop-hex-sec textarea')||document.querySelector('#pop-hex-sec input');
1238
+ if(t){{t.value=v;t.dispatchEvent(new Event('input',{{bubbles:true}}));}}">
1239
+ <div id="cbox-sec" onclick="document.getElementById('cpick-sec').click();"
1240
+ style="flex:1;background:#{init_pal['secondary']};border-radius:6px;cursor:pointer;
1241
+ display:flex;align-items:center;justify-content:center;">
1242
+ <span style="color:#{init_pal['text_on_secondary']};font-size:9px;font-weight:bold;text-align:center;pointer-events:none;">
1243
+ Secund\u00e1ria<br>#{init_pal['secondary']}</span></div>
1244
+
1245
+ <input type="color" id="cpick-ter" value="#{init_pal['tertiary']}"
1246
+ style="position:absolute;opacity:0;width:0;height:0;"
1247
+ oninput="var v=this.value;document.getElementById('cbox-ter').style.background=v;
1248
+ document.getElementById('cbox-ter').querySelector('span').innerHTML='Terci\\u00e1ria<br>'+v.toUpperCase();
1249
+ var t=document.querySelector('#pop-hex-ter textarea')||document.querySelector('#pop-hex-ter input');
1250
+ if(t){{t.value=v;t.dispatchEvent(new Event('input',{{bubbles:true}}));}}">
1251
+ <div id="cbox-ter" onclick="document.getElementById('cpick-ter').click();"
1252
+ style="flex:1;background:#{init_pal['tertiary']};border-radius:6px;cursor:pointer;
1253
+ display:flex;align-items:center;justify-content:center;">
1254
+ <span style="color:#{init_pal['primary_dark']};font-size:9px;font-weight:bold;text-align:center;pointer-events:none;">
1255
+ Terci\u00e1ria<br>#{init_pal['tertiary']}</span></div>
1256
+
1257
+ <input type="color" id="cpick-zeb" value="#{init_pal['quaternary']}"
1258
+ style="position:absolute;opacity:0;width:0;height:0;"
1259
+ oninput="var v=this.value;document.getElementById('cbox-zeb').style.background=v;
1260
+ document.getElementById('cbox-zeb').querySelector('span').innerHTML='Zebra<br>'+v.toUpperCase();
1261
+ var t=document.querySelector('#pop-hex-zeb textarea')||document.querySelector('#pop-hex-zeb input');
1262
+ if(t){{t.value=v;t.dispatchEvent(new Event('input',{{bubbles:true}}));}}">
1263
+ <div id="cbox-zeb" onclick="document.getElementById('cpick-zeb').click();"
1264
+ style="flex:1;background:#{init_pal['quaternary']};border-radius:6px;cursor:pointer;
1265
+ display:flex;align-items:center;justify-content:center;">
1266
+ <span style="color:#{init_pal['primary_dark']};font-size:9px;text-align:center;pointer-events:none;">
1267
+ Zebra<br>#{init_pal['quaternary']}</span></div>
1268
+ </div>
1269
+ """)
1270
+
1271
+ btn_reset_colors = gr.Button("\u21ba restaurar cores padr\u00e3o", size="sm", variant="secondary")
1272
+
1273
+ gr.Markdown("---")
1274
+
1275
+ # ── ETAPA 3: JSON (lado a lado) ──
1276
+ gr.Markdown("### \U0001f4dd\ufe0e Etapa 2.3 \u2014 Insira o c\u00f3digo recebido *(upload do arquivo OU cole o c\u00f3digo)*")
1277
+ with gr.Row(equal_height=True, elem_classes="step3-row"):
1278
+ with gr.Column(scale=3):
1279
+ json_file_input = gr.File(
1280
+ file_types=[".json", ".txt"], type="filepath",
1281
+ height=130, show_label=False
1282
+ )
1283
+ with gr.Column(scale=0, min_width=30):
1284
+ gr.HTML('<div style="display:flex;align-items:center;justify-content:center;height:100%;font-weight:700;color:#666;font-size:11px;">OU</div>')
1285
+ with gr.Column(scale=3):
1286
+ json_text_input = gr.Textbox(
1287
+ placeholder='Cole o c\u00f3digo aqui',
1288
+ lines=6, max_lines=6, show_label=False, elem_id="json-paste"
1289
+ )
1290
+
1291
+ # Quando arquivo é carregado, desabilita textbox (mas preserva o valor existente)
1292
+ def toggle_textbox(file, current_text):
1293
+ if file is not None:
1294
+ return gr.Textbox(value=current_text or "", interactive=False,
1295
+ placeholder='Arquivo carregado \u2014 usando arquivo anexo')
1296
+ return gr.Textbox(value=current_text or "", interactive=True,
1297
+ placeholder='Cole o c\u00f3digo aqui')
1298
+
1299
+ json_file_input.change(
1300
+ fn=toggle_textbox,
1301
+ inputs=[json_file_input, json_text_input],
1302
+ outputs=[json_text_input]
1303
+ )
1304
+
1305
+ btn_gerar = gr.Button("\U0001f680 GERAR POP (.docx)", variant="primary", size="lg")
1306
+ btn_novo = gr.Button("\U0001f504 Novo POP \u2014 limpar c\u00f3digo", variant="secondary", size="sm")
1307
+
1308
+ # ══════════════ COLUNA DIREITA ══════════════
1309
+ with gr.Column(scale=2, min_width=280):
1310
+ result_info = gr.Markdown("")
1311
+ btn_download = gr.DownloadButton(
1312
+ "\U0001f4e5 Baixar POP (.docx)", visible=False, variant="primary", size="lg"
1313
+ )
1314
+ preview_gallery = gr.Gallery(
1315
+ label="\u25c4 \u25ba Preview do documento",
1316
+ columns=1, rows=1, height=320,
1317
+ object_fit="contain", visible=False,
1318
+ show_download_button=False
1319
+ )
1320
+
1321
+ # ═══════════ EVENTOS ═══════════
1322
+
1323
+ def generate_preview_images(docx_path):
1324
+ """Converte .docx → PDF → imagens para preview."""
1325
+ import subprocess, glob
1326
+ try:
1327
+ # Converter docx → pdf via LibreOffice
1328
+ tmp_dir = tempfile.mkdtemp()
1329
+ subprocess.run(
1330
+ ["libreoffice", "--headless", "--convert-to", "pdf",
1331
+ "--outdir", tmp_dir, docx_path],
1332
+ capture_output=True, timeout=30
1333
+ )
1334
+ pdf_files = glob.glob(os.path.join(tmp_dir, "*.pdf"))
1335
+ if not pdf_files:
1336
+ return []
1337
+ pdf_path = pdf_files[0]
1338
+ # Converter pdf → imagens via pdftoppm
1339
+ subprocess.run(
1340
+ ["pdftoppm", "-jpeg", "-r", "150", pdf_path,
1341
+ os.path.join(tmp_dir, "page")],
1342
+ capture_output=True, timeout=30
1343
+ )
1344
+ imgs = sorted(glob.glob(os.path.join(tmp_dir, "page-*.jpg")))
1345
+ return imgs if imgs else []
1346
+ except Exception:
1347
+ return []
1348
+
1349
+ def on_generate(json_text, json_file, cpri, csec, cter, czeb, logo):
1350
+ fp, info, meta = processar(json_text, json_file, cpri, csec, cter, czeb, logo)
1351
+ if fp:
1352
+ fname = os.path.basename(fp)
1353
+ msg = f"\u2705 POP gerado com sucesso!\n\n`{fname}`"
1354
+ imgs = generate_preview_images(fp)
1355
+ if imgs:
1356
+ return (msg, gr.DownloadButton(value=fp, visible=True),
1357
+ gr.Gallery(value=imgs, visible=True),
1358
+ gr.Button("\U0001f680 GERAR POP (.docx)", interactive=True))
1359
+ return (msg, gr.DownloadButton(value=fp, visible=True),
1360
+ gr.Gallery(visible=False),
1361
+ gr.Button("\U0001f680 GERAR POP (.docx)", interactive=True))
1362
+ return (info or "\u26a0\ufe0f Erro", gr.DownloadButton(visible=False),
1363
+ gr.Gallery(visible=False),
1364
+ gr.Button("\U0001f680 GERAR POP (.docx)", interactive=True))
1365
+
1366
+ def on_start_generate():
1367
+ return ("\u23f3 *Redigindo o POP, aguarde...*",
1368
+ gr.DownloadButton(visible=False),
1369
+ gr.Gallery(visible=False),
1370
+ gr.Button("\u23f3 Redigindo...", variant="secondary", interactive=False))
1371
+
1372
+ btn_gerar.click(
1373
+ fn=on_start_generate,
1374
+ outputs=[result_info, btn_download, preview_gallery, btn_gerar]
1375
+ ).then(
1376
+ fn=on_generate,
1377
+ inputs=[json_text_input, json_file_input, color_pri, color_sec, color_ter, color_zeb, logo_input],
1378
+ outputs=[result_info, btn_download, preview_gallery, btn_gerar]
1379
+ )
1380
+
1381
+ def on_novo():
1382
+ return "", None, "", gr.DownloadButton(visible=False), gr.Gallery(visible=False)
1383
+
1384
+ btn_novo.click(
1385
+ fn=on_novo,
1386
+ outputs=[json_text_input, json_file_input, result_info, btn_download, preview_gallery]
1387
+ )
1388
+
1389
+ # Reset cores para padrão
1390
+ def on_reset_colors():
1391
+ p = build_palette(DEFAULT_UI_COLOR)
1392
+ return f"#{p['primary']}", "", "", ""
1393
+
1394
+ btn_reset_colors.click(
1395
+ fn=on_reset_colors,
1396
+ outputs=[color_pri, color_sec, color_ter, color_zeb],
1397
+ js=f"""() => {{
1398
+ var p = '{init_pal["primary"]}', s = '{init_pal["secondary"]}',
1399
+ t = '{init_pal["tertiary"]}', z = '{init_pal["quaternary"]}';
1400
+ var d = '{init_pal["primary_dark"]}', ts = '{init_pal["text_on_secondary"]}';
1401
+ document.getElementById('cbox-pri').style.background='#'+p;
1402
+ document.getElementById('cbox-pri').querySelector('span').innerHTML='Prim\\u00e1ria<br>#'+p;
1403
+ document.getElementById('cpick-pri').value='#'+p;
1404
+ document.getElementById('cbox-sec').style.background='#'+s;
1405
+ document.getElementById('cbox-sec').querySelector('span').innerHTML='Secund\\u00e1ria<br>#'+s;
1406
+ document.getElementById('cpick-sec').value='#'+s;
1407
+ document.getElementById('cbox-ter').style.background='#'+t;
1408
+ document.getElementById('cbox-ter').querySelector('span').innerHTML='Terci\\u00e1ria<br>#'+t;
1409
+ document.getElementById('cpick-ter').value='#'+t;
1410
+ document.getElementById('cbox-zeb').style.background='#'+z;
1411
+ document.getElementById('cbox-zeb').querySelector('span').innerHTML='Zebra<br>#'+z;
1412
+ document.getElementById('cpick-zeb').value='#'+z;
1413
+ }}"""
1414
+ )
1415
+
1416
+ # ═══════════ NAVEGA\u00c7\u00c3O LANDING \u2194 APP ═══════════
1417
+
1418
+ # Passo 1: abre URL do Gemini Gem em nova aba
1419
+ btn_passo1.click(
1420
+ fn=None, inputs=None, outputs=None,
1421
+ js="() => { window.open('https://gemini.google.com/gem/c86826a9110d', '_blank'); }"
1422
+ )
1423
+
1424
+ # Passo 2: oculta landing, mostra app
1425
+ def show_app():
1426
+ return gr.Column(visible=False), gr.Column(visible=True)
1427
+
1428
+ btn_passo2.click(fn=show_app, outputs=[landing_page, main_app])
1429
+
1430
+ # Voltar: oculta app, mostra landing
1431
+ def show_landing():
1432
+ return gr.Column(visible=True), gr.Column(visible=False)
1433
+
1434
+ btn_voltar.click(fn=show_landing, outputs=[landing_page, main_app])
1435
+
1436
+ return demo
1437
+
1438
+ # ══════════════════════════════════════════════════════════════
1439
+ # INICIAR APLICAÇÃO
1440
+ # ══════════════════════════════════════════════════════════════
1441
+
1442
+ demo = criar_interface()
1443
+ demo.launch(share=True, debug=False, show_error=True)