primocode 7.18.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,792 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PrimoCode Studio — suíte de criação operada por IA.
4
+
5
+ python3 server.py # http://localhost:7777
6
+ python3 server.py 8080
7
+
8
+ Só biblioteca padrão do Python. Nada para instalar.
9
+ """
10
+
11
+ import base64
12
+ import json
13
+ import mimetypes
14
+ import os
15
+ import re
16
+ import sys
17
+ import unicodedata
18
+ import uuid
19
+ from datetime import datetime, timezone
20
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
21
+ from urllib.parse import urlparse, unquote
22
+
23
+ RAIZ = os.path.dirname(os.path.abspath(__file__))
24
+ WEB = os.path.join(RAIZ, "web")
25
+ ARQUIVOS = os.path.join(RAIZ, "arquivos")
26
+ FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
27
+ MIDIA = os.path.join(RAIZ, "midia")
28
+ PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777
29
+
30
+ # 60 MB por arquivo: cobre faixa longa em mp3 e foto de câmera sem susto
31
+ LIMITE_MIDIA = 60 * 1024 * 1024
32
+
33
+ os.makedirs(ARQUIVOS, exist_ok=True)
34
+ os.makedirs(MIDIA, exist_ok=True)
35
+
36
+
37
+ # ------------------------------------------------------------- ferramentas --
38
+
39
+ FERRAMENTAS_INFO = [
40
+ {
41
+ "slug": "prisma",
42
+ "nome": "Prisma",
43
+ "papel": "Apresentações",
44
+ "equivale": "PowerPoint, Keynote, Canva Apresentações",
45
+ "modelo": "cena",
46
+ "resumo": "Decks com layouts automáticos, temas, animação de entrada, notas do apresentador e export em PDF.",
47
+ "cor": "#FF5A36",
48
+ },
49
+ {
50
+ "slug": "tela",
51
+ "nome": "Tela",
52
+ "papel": "Design gráfico",
53
+ "equivale": "Photoshop, Figma, Canva",
54
+ "modelo": "cena",
55
+ "resumo": "Artes em canvas livre com camadas, formas, texto, imagens, gradientes e sombras. Post, capa, banner, thumbnail.",
56
+ "cor": "#8B5CF6",
57
+ },
58
+ {
59
+ "slug": "prosa",
60
+ "nome": "Prosa",
61
+ "papel": "Documentos",
62
+ "equivale": "Word, Google Docs, Notion",
63
+ "modelo": "documento",
64
+ "resumo": "Textos longos com hierarquia, citações, listas, tabelas, capa e sumário. Export em HTML e Markdown.",
65
+ "cor": "#2E9E6B",
66
+ },
67
+ {
68
+ "slug": "grade",
69
+ "nome": "Grade",
70
+ "papel": "Planilhas",
71
+ "equivale": "Excel, Google Sheets",
72
+ "modelo": "planilha",
73
+ "resumo": "Tabelas com fórmulas, formatação condicional, totais e gráficos. Export em CSV.",
74
+ "cor": "#1F8FE0",
75
+ },
76
+ {
77
+ "slug": "corte",
78
+ "nome": "Corte",
79
+ "papel": "Vídeo",
80
+ "equivale": "CapCut, Premiere, After Effects",
81
+ "modelo": "cena",
82
+ "resumo": "Montagem por cenas com duração, transições e animação. Reproduz no navegador e grava .webm.",
83
+ "cor": "#E0397A",
84
+ },
85
+ {
86
+ "slug": "traco",
87
+ "nome": "Traço",
88
+ "papel": "Diagramas",
89
+ "equivale": "Miro, Whimsical, Lucidchart",
90
+ "modelo": "cena",
91
+ "resumo": "Fluxos, mapas mentais, arquiteturas e organogramas com nós e conectores que se ligam sozinhos.",
92
+ "cor": "#D9A21B",
93
+ },
94
+ ]
95
+
96
+ POR_SLUG = {f["slug"]: f for f in FERRAMENTAS_INFO}
97
+
98
+
99
+ # -------------------------------------------------------------- utilidades --
100
+
101
+ def slugify(texto):
102
+ texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
103
+ texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
104
+ return (texto or "arquivo")[:40]
105
+
106
+
107
+ def caminho(arq_id):
108
+ if not re.fullmatch(r"[a-z0-9\-]{1,72}", arq_id or ""):
109
+ return None
110
+ return os.path.join(ARQUIVOS, arq_id + ".json")
111
+
112
+
113
+ def ler(arq_id):
114
+ c = caminho(arq_id)
115
+ if not c or not os.path.exists(c):
116
+ return None
117
+ with open(c, encoding="utf-8") as fh:
118
+ return json.load(fh)
119
+
120
+
121
+ def gravar(arq):
122
+ arq["atualizado"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
123
+ with open(caminho(arq["id"]), "w", encoding="utf-8") as fh:
124
+ json.dump(arq, fh, ensure_ascii=False, indent=2)
125
+ return arq
126
+
127
+
128
+ def listar():
129
+ saida = []
130
+ for nome in os.listdir(ARQUIVOS):
131
+ if not nome.endswith(".json"):
132
+ continue
133
+ try:
134
+ with open(os.path.join(ARQUIVOS, nome), encoding="utf-8") as fh:
135
+ a = json.load(fh)
136
+ doc = a.get("doc") or {}
137
+ saida.append({
138
+ "id": a["id"],
139
+ "titulo": a.get("titulo", "Sem título"),
140
+ "ferramenta": a.get("ferramenta", "prisma"),
141
+ "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
142
+ "atualizado": a.get("atualizado", ""),
143
+ })
144
+ except Exception:
145
+ pass
146
+ return sorted(saida, key=lambda a: a["atualizado"], reverse=True)
147
+
148
+
149
+ # ------------------------------------------------------------------ mídia --
150
+
151
+ TIPOS_OK = {
152
+ ".png": "imagem", ".jpg": "imagem", ".jpeg": "imagem", ".gif": "imagem",
153
+ ".webp": "imagem", ".svg": "imagem", ".avif": "imagem",
154
+ ".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
155
+ ".aac": "audio", ".flac": "audio", ".opus": "audio", ".weba": "audio",
156
+ ".mp4": "video", ".webm": "video", ".mov": "video",
157
+ }
158
+
159
+ # .weba é WebM só com áudio — é o que sai da gravação de voz pelo microfone
160
+ mimetypes.add_type("audio/webm", ".weba")
161
+ mimetypes.add_type("audio/mp4", ".m4a")
162
+
163
+
164
+ def nome_seguro(nome):
165
+ base, ext = os.path.splitext(nome or "arquivo")
166
+ ext = ext.lower()
167
+ base = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode()
168
+ base = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower()[:48] or "arquivo"
169
+ return base, ext
170
+
171
+
172
+ def listar_midia():
173
+ fora = []
174
+ for nome in sorted(os.listdir(MIDIA)):
175
+ ext = os.path.splitext(nome)[1].lower()
176
+ if ext not in TIPOS_OK:
177
+ continue
178
+ caminho_arq = os.path.join(MIDIA, nome)
179
+ fora.append({
180
+ "nome": nome,
181
+ "url": "/midia/" + nome,
182
+ "tipo": TIPOS_OK[ext],
183
+ "bytes": os.path.getsize(caminho_arq),
184
+ })
185
+ return sorted(fora, key=lambda m: m["nome"])
186
+
187
+
188
+ def guardar_midia(dados):
189
+ """Recebe {nome, dados:"data:...;base64,..."} e grava em midia/."""
190
+ bruto = dados.get("dados") or ""
191
+ m = re.match(r"^data:([^;]*);base64,(.*)$", bruto, re.S)
192
+ if not m:
193
+ return {"erro": "envie 'dados' como data URI base64"}, 400
194
+
195
+ base, ext = nome_seguro(dados.get("nome"))
196
+ if ext not in TIPOS_OK:
197
+ adivinhado = mimetypes.guess_extension(m.group(1) or "") or ""
198
+ ext = adivinhado.lower()
199
+ if ext not in TIPOS_OK:
200
+ return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400
201
+
202
+ try:
203
+ conteudo = base64.b64decode(m.group(2), validate=True)
204
+ except Exception:
205
+ return {"erro": "base64 inválido"}, 400
206
+
207
+ if len(conteudo) > LIMITE_MIDIA:
208
+ return {"erro": "arquivo acima de %d MB" % (LIMITE_MIDIA // 1048576)}, 413
209
+
210
+ nome = base + ext
211
+ if os.path.exists(os.path.join(MIDIA, nome)):
212
+ nome = base + "-" + uuid.uuid4().hex[:4] + ext
213
+ with open(os.path.join(MIDIA, nome), "wb") as fh:
214
+ fh.write(conteudo)
215
+
216
+ return {"nome": nome, "url": "/midia/" + nome,
217
+ "tipo": TIPOS_OK[ext], "bytes": len(conteudo)}, 201
218
+
219
+
220
+ def criar(dados):
221
+ ferramenta = (dados.get("ferramenta") or "prisma").lower()
222
+ if ferramenta not in POR_SLUG:
223
+ return None
224
+ titulo = (dados.get("titulo") or "Sem título").strip()
225
+ arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
226
+ return gravar({
227
+ "id": arq_id,
228
+ "ferramenta": ferramenta,
229
+ "titulo": titulo,
230
+ "doc": dados.get("doc") or exemplo(ferramenta),
231
+ })
232
+
233
+
234
+ def exemplo(ferramenta):
235
+ """Documento inicial de cada ferramenta — o mínimo que já fica bonito."""
236
+ if ferramenta == "prosa":
237
+ return {
238
+ "tema": "papel",
239
+ "blocos": [
240
+ {"tipo": "titulo", "texto": "Título do documento"},
241
+ {"tipo": "paragrafo", "texto": "Comece a escrever por aqui."},
242
+ ],
243
+ }
244
+ if ferramenta == "grade":
245
+ return {
246
+ "tema": "claro",
247
+ "abas": [{
248
+ "nome": "Planilha 1",
249
+ "colunas": ["Item", "Valor"],
250
+ "linhas": [["Exemplo", 100]],
251
+ }],
252
+ }
253
+ if ferramenta == "tela":
254
+ return {
255
+ "tema": "meia-noite", "largura": 1080, "altura": 1080,
256
+ "cenas": [{"layout": "livre", "fundo": "malha", "elementos": [
257
+ {"tipo": "texto", "x": 100, "y": 440, "l": 880, "a": 200,
258
+ "texto": "Sua arte", "tamanho": 120, "peso": 700, "alinhar": "centro"},
259
+ ]}],
260
+ }
261
+ if ferramenta == "corte":
262
+ return {
263
+ "tema": "meia-noite", "largura": 1920, "altura": 1080,
264
+ "cenas": [
265
+ {"layout": "capa", "duracao": 3, "fundo": "malha",
266
+ "elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Primeira cena"}]},
267
+ {"layout": "capa", "duracao": 3, "fundo": "brilho",
268
+ "elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Segunda cena"}]},
269
+ ],
270
+ }
271
+ if ferramenta == "traco":
272
+ return {
273
+ "tema": "meia-noite", "largura": 1920, "altura": 1080,
274
+ "cenas": [{"layout": "livre", "elementos": [
275
+ {"id": "a", "tipo": "no", "x": 300, "y": 460, "l": 340, "a": 160, "texto": "Início"},
276
+ {"id": "b", "tipo": "no", "x": 1100, "y": 460, "l": 340, "a": 160, "texto": "Fim"},
277
+ {"tipo": "conector", "de": "a", "para": "b"},
278
+ ]}],
279
+ }
280
+ return {
281
+ "tema": "meia-noite", "largura": 1920, "altura": 1080,
282
+ "cenas": [
283
+ {"layout": "capa", "fundo": "malha", "elementos": [
284
+ {"tipo": "texto", "papel": "chapeu", "texto": "PrimoCode Studio"},
285
+ {"tipo": "texto", "papel": "titulo", "texto": "Título da apresentação"},
286
+ {"tipo": "texto", "papel": "apoio", "texto": "Uma frase que explica do que se trata."},
287
+ ]},
288
+ {"layout": "titulo", "elementos": [
289
+ {"tipo": "texto", "papel": "titulo", "texto": "Uma ideia por cena"},
290
+ {"tipo": "lista", "itens": ["Primeiro ponto", "Segundo ponto", "Terceiro ponto"]},
291
+ ]},
292
+ ],
293
+ }
294
+
295
+
296
+ # ------------------------------------------------------------------ manual --
297
+
298
+ def manual():
299
+ """Junta ferramentas/*.md — a pasta é a fonte da verdade para a IA."""
300
+ ordem = ["00-protocolo", "01-modelos"] + [f["slug"] for f in FERRAMENTAS_INFO]
301
+ partes = ["PRIMOCODE STUDIO — MANUAL PARA AGENTES",
302
+ "Servidor: http://localhost:%d" % PORTA, ""]
303
+ for nome in ordem:
304
+ p = os.path.join(FERRAMENTAS, nome + ".md")
305
+ if os.path.exists(p):
306
+ with open(p, encoding="utf-8") as fh:
307
+ partes.append(fh.read().rstrip())
308
+ partes.append("\n" + "=" * 70 + "\n")
309
+ return "\n".join(partes)
310
+
311
+
312
+ # ----------------------------------------------------------------- handler --
313
+
314
+ class Handler(SimpleHTTPRequestHandler):
315
+ server_version = "PrimoCode/1.0"
316
+
317
+ def __init__(self, *a, **kw):
318
+ super().__init__(*a, directory=WEB, **kw)
319
+
320
+ def log_message(self, fmt, *args):
321
+ if "/api/" in (self.path or ""):
322
+ sys.stderr.write(" %s %s\n" % (self.command, self.path))
323
+
324
+ def end_headers(self):
325
+ self.send_header("Cache-Control", "no-store, max-age=0")
326
+ super().end_headers()
327
+
328
+ # -------------------------------------------------------------- saídas --
329
+
330
+ def js(self, obj, status=200):
331
+ corpo = json.dumps(obj, ensure_ascii=False).encode("utf-8")
332
+ self.send_response(status)
333
+ self.send_header("Content-Type", "application/json; charset=utf-8")
334
+ self.send_header("Content-Length", str(len(corpo)))
335
+ self.end_headers()
336
+ self.wfile.write(corpo)
337
+
338
+ def txt(self, texto, ctype="text/plain; charset=utf-8", status=200, baixar=None):
339
+ corpo = texto.encode("utf-8")
340
+ self.send_response(status)
341
+ self.send_header("Content-Type", ctype)
342
+ self.send_header("Content-Length", str(len(corpo)))
343
+ if baixar:
344
+ self.send_header("Content-Disposition", 'attachment; filename="%s"' % baixar)
345
+ self.end_headers()
346
+ self.wfile.write(corpo)
347
+
348
+ def pagina(self, nome):
349
+ with open(os.path.join(WEB, nome), encoding="utf-8") as fh:
350
+ self.txt(fh.read(), "text/html; charset=utf-8")
351
+
352
+ def corpo(self):
353
+ n = int(self.headers.get("Content-Length") or 0)
354
+ if not n:
355
+ return {}
356
+ try:
357
+ return json.loads(self.rfile.read(n).decode("utf-8"))
358
+ except Exception:
359
+ return {}
360
+
361
+ # -------------------------------------------------------------- rotas --
362
+
363
+ def do_GET(self):
364
+ p = urlparse(self.path).path
365
+
366
+ if p == "/":
367
+ return self.pagina("home.html")
368
+ if re.fullmatch(r"/t/[^/]+/[^/]+", p):
369
+ return self.pagina("tool.html")
370
+ if re.fullmatch(r"/v/[^/]+", p):
371
+ return self.pagina("view.html")
372
+
373
+ if p == "/api/manual":
374
+ return self.txt(manual())
375
+ if p == "/api/ferramentas":
376
+ return self.js(FERRAMENTAS_INFO)
377
+
378
+ m = re.fullmatch(r"/api/ferramentas/([a-z]+)", p)
379
+ if m:
380
+ info = POR_SLUG.get(m.group(1))
381
+ if not info:
382
+ return self.js({"erro": "ferramenta desconhecida"}, 404)
383
+ doc = os.path.join(FERRAMENTAS, m.group(1) + ".md")
384
+ texto = ""
385
+ if os.path.exists(doc):
386
+ with open(doc, encoding="utf-8") as fh:
387
+ texto = fh.read()
388
+ return self.txt(texto or json.dumps(info, ensure_ascii=False, indent=2))
389
+
390
+ if p == "/api/midia":
391
+ return self.js(listar_midia())
392
+ if p.startswith("/midia/"):
393
+ return self.servir_midia(unquote(p[len("/midia/"):]))
394
+
395
+ if p == "/api/arquivos":
396
+ return self.js(listar())
397
+
398
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", p)
399
+ if m:
400
+ a = ler(m.group(1))
401
+ return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
402
+
403
+ m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
404
+ if m:
405
+ a = ler(m.group(1))
406
+ if not a:
407
+ return self.js({"erro": "não encontrado"}, 404)
408
+ return self.exportar(a)
409
+
410
+ return super().do_GET()
411
+
412
+ def servir_midia(self, nome):
413
+ """Serve com suporte a Range — sem isso o áudio não busca no <audio>."""
414
+ if "/" in nome or ".." in nome:
415
+ return self.js({"erro": "caminho inválido"}, 400)
416
+ caminho_arq = os.path.join(MIDIA, nome)
417
+ if not os.path.isfile(caminho_arq):
418
+ return self.js({"erro": "não encontrado"}, 404)
419
+
420
+ tamanho = os.path.getsize(caminho_arq)
421
+ ctype = mimetypes.guess_type(caminho_arq)[0] or "application/octet-stream"
422
+ faixa = self.headers.get("Range")
423
+ inicio, fim = 0, tamanho - 1
424
+ status = 200
425
+
426
+ if faixa:
427
+ m = re.match(r"bytes=(\d*)-(\d*)", faixa)
428
+ if m:
429
+ if m.group(1):
430
+ inicio = int(m.group(1))
431
+ if m.group(2):
432
+ fim = min(int(m.group(2)), tamanho - 1)
433
+ if inicio > fim or inicio >= tamanho:
434
+ self.send_response(416)
435
+ self.send_header("Content-Range", "bytes */%d" % tamanho)
436
+ self.end_headers()
437
+ return
438
+ status = 206
439
+
440
+ self.send_response(status)
441
+ self.send_header("Content-Type", ctype)
442
+ self.send_header("Accept-Ranges", "bytes")
443
+ self.send_header("Content-Length", str(fim - inicio + 1))
444
+ if status == 206:
445
+ self.send_header("Content-Range", "bytes %d-%d/%d" % (inicio, fim, tamanho))
446
+ self.end_headers()
447
+
448
+ with open(caminho_arq, "rb") as fh:
449
+ fh.seek(inicio)
450
+ restante = fim - inicio + 1
451
+ while restante > 0:
452
+ pedaco = fh.read(min(262144, restante))
453
+ if not pedaco:
454
+ break
455
+ self.wfile.write(pedaco)
456
+ restante -= len(pedaco)
457
+
458
+ def do_POST(self):
459
+ if urlparse(self.path).path == "/api/midia":
460
+ corpo, status = guardar_midia(self.corpo())
461
+ return self.js(corpo, status)
462
+
463
+ if urlparse(self.path).path == "/api/arquivos":
464
+ a = criar(self.corpo())
465
+ if not a:
466
+ return self.js({"erro": "ferramenta inválida",
467
+ "validas": list(POR_SLUG)}, 400)
468
+ base = "http://localhost:%d" % PORTA
469
+ return self.js({
470
+ "id": a["id"],
471
+ "ferramenta": a["ferramenta"],
472
+ "titulo": a["titulo"],
473
+ "editar": "%s/t/%s/%s" % (base, a["ferramenta"], a["id"]),
474
+ "ver": "%s/v/%s" % (base, a["id"]),
475
+ }, 201)
476
+ return self.js({"erro": "não encontrado"}, 404)
477
+
478
+ def do_PUT(self):
479
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
480
+ if not m:
481
+ return self.js({"erro": "não encontrado"}, 404)
482
+ a = ler(m.group(1))
483
+ if not a:
484
+ return self.js({"erro": "não encontrado"}, 404)
485
+ dados = self.corpo()
486
+ for chave in ("titulo", "doc"):
487
+ if chave in dados:
488
+ a[chave] = dados[chave]
489
+ return self.js(gravar(a))
490
+
491
+ def do_DELETE(self):
492
+ m = re.fullmatch(r"/api/midia/([^/]+)", urlparse(self.path).path)
493
+ if m:
494
+ nome = unquote(m.group(1))
495
+ alvo = os.path.join(MIDIA, nome)
496
+ if "/" not in nome and ".." not in nome and os.path.isfile(alvo):
497
+ os.remove(alvo)
498
+ return self.js({"ok": True})
499
+ return self.js({"erro": "não encontrado"}, 404)
500
+
501
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
502
+ c = caminho(m.group(1)) if m else None
503
+ if c and os.path.exists(c):
504
+ os.remove(c)
505
+ return self.js({"ok": True})
506
+ return self.js({"erro": "não encontrado"}, 404)
507
+
508
+ # ------------------------------------------------------------- export --
509
+
510
+ def exportar(self, a):
511
+ ferramenta = a.get("ferramenta")
512
+ doc = a.get("doc") or {}
513
+
514
+ if ferramenta == "grade":
515
+ linhas = []
516
+ for aba in doc.get("abas", []):
517
+ linhas.append(",".join(csv_celula(c) for c in aba.get("colunas", [])))
518
+ for lin in calcular_aba(aba):
519
+ linhas.append(",".join(csv_celula(c) for c in lin))
520
+ linhas.append("")
521
+ return self.txt("\n".join(linhas), "text/csv; charset=utf-8",
522
+ baixar=a["id"] + ".csv")
523
+
524
+ if ferramenta == "prosa":
525
+ return self.txt(prosa_markdown(doc), "text/markdown; charset=utf-8",
526
+ baixar=a["id"] + ".md")
527
+
528
+ return self.txt(html_autonomo(a), "text/html; charset=utf-8",
529
+ baixar=a["id"] + ".html")
530
+
531
+
532
+ def csv_celula(valor):
533
+ if isinstance(valor, float):
534
+ valor = round(valor, 2)
535
+ if valor == int(valor):
536
+ valor = int(valor)
537
+ s = "" if valor is None else str(valor)
538
+ return '"%s"' % s.replace('"', '""') if re.search(r'[",\n]', s) else s
539
+
540
+
541
+ # ------------------------------------------------- fórmulas (espelha o JS) --
542
+
543
+ def _mediana(v):
544
+ if not v:
545
+ return 0
546
+ o = sorted(v)
547
+ m = len(o) // 2
548
+ return o[m] if len(o) % 2 else (o[m - 1] + o[m]) / 2
549
+
550
+
551
+ _FUNCOES = {
552
+ "SOMA": sum,
553
+ "MEDIA": lambda v: sum(v) / len(v) if v else 0,
554
+ "MIN": lambda v: min(v) if v else 0,
555
+ "MAX": lambda v: max(v) if v else 0,
556
+ "CONT": len,
557
+ "ARRED": lambda v: round(v[0], int(v[1])) if len(v) > 1 else round(v[0] if v else 0),
558
+ "ABS": lambda v: abs(v[0] if v else 0),
559
+ "RAIZ": lambda v: abs(v[0] if v else 0) ** 0.5,
560
+ "POT": lambda v: (v[0] if v else 0) ** (v[1] if len(v) > 1 else 2),
561
+ "TETO": lambda v: -(-(v[0] if v else 0) // 1),
562
+ "PISO": lambda v: (v[0] if v else 0) // 1,
563
+ "MEDIANA": _mediana,
564
+ "MAIOR": lambda v: sorted(v[:-1], reverse=True)[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
565
+ "MENOR": lambda v: sorted(v[:-1])[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
566
+ "SE": lambda v: (v[1] if len(v) > 1 else 1) if v and v[0] else (v[2] if len(v) > 2 else 0),
567
+ }
568
+
569
+
570
+ def _criterio(texto):
571
+ m = re.match(r'^(<=|>=|<>|!=|<|>|=)?\s*(-?[\d.,]+)$', str(texto).strip().strip('"\''))
572
+ if not m:
573
+ return lambda x: False
574
+ op, alvo = m.group(1) or "=", float(m.group(2).replace(",", "."))
575
+ return {
576
+ ">": lambda x: x > alvo, "<": lambda x: x < alvo,
577
+ ">=": lambda x: x >= alvo, "<=": lambda x: x <= alvo,
578
+ "<>": lambda x: x != alvo, "!=": lambda x: x != alvo,
579
+ }.get(op, lambda x: x == alvo)
580
+
581
+
582
+ def _col(letras):
583
+ n = 0
584
+ for c in letras:
585
+ n = n * 26 + (ord(c) - 64)
586
+ return n - 1
587
+
588
+
589
+ def _num(v):
590
+ if isinstance(v, (int, float)):
591
+ return v
592
+ if v in (None, ""):
593
+ return 0
594
+ try:
595
+ return float(re.sub(r"[R$\s%.]", "", str(v)).replace(",", "."))
596
+ except ValueError:
597
+ return 0
598
+
599
+
600
+ def calcular_aba(aba):
601
+ """Resolve as fórmulas da aba e devolve a matriz de valores."""
602
+ linhas = [list(l) for l in aba.get("linhas", [])]
603
+
604
+ def celula(c, l, prof):
605
+ if prof > 24 or l >= len(linhas) or c >= len(linhas[l]):
606
+ return 0
607
+ bruto = linhas[l][c]
608
+ if isinstance(bruto, str) and bruto.startswith("="):
609
+ return avaliar(bruto[1:], prof + 1)
610
+ return bruto
611
+
612
+ def faixa(ref, prof):
613
+ m = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", ref)
614
+ if not m:
615
+ return []
616
+ c1, l1, c2, l2 = _col(m.group(1)), int(m.group(2)) - 1, _col(m.group(3)), int(m.group(4)) - 1
617
+ return [_num(celula(c, l, prof))
618
+ for l in range(min(l1, l2), max(l1, l2) + 1)
619
+ for c in range(min(c1, c2), max(c1, c2) + 1)]
620
+
621
+ def ref_simples(ref, prof):
622
+ m = re.fullmatch(r"([A-Z]+)(\d+)", ref)
623
+ return celula(_col(m.group(1)), int(m.group(2)) - 1, prof) if m else 0
624
+
625
+ def avaliar(expr, prof):
626
+ def fn(m):
627
+ nome = m.group(1)
628
+ partes = [p.strip() for p in m.group(2).split(";")]
629
+
630
+ if nome in ("SOMASE", "CONTSE"):
631
+ base = faixa(partes[0], prof) if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", partes[0]) else []
632
+ passou = [x for x in base if _criterio(partes[1] if len(partes) > 1 else "")(x)]
633
+ return repr(sum(passou) if nome == "SOMASE" else len(passou))
634
+
635
+ f = _FUNCOES.get(nome)
636
+ if not f:
637
+ return "0"
638
+ vals = []
639
+ for parte in partes:
640
+ if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", parte):
641
+ vals += faixa(parte, prof)
642
+ elif parte:
643
+ # qualquer outro argumento é uma expressão: resolve antes
644
+ vals.append(_num(avaliar(parte, prof)))
645
+ return repr(f(vals))
646
+
647
+ # repete até esgotar: resolve de dentro para fora em fórmula aninhada
648
+ e, voltas = expr, 0
649
+ while re.search(r"[A-Z]+\(", e) and voltas < 12:
650
+ e = re.sub(r"([A-Z]+)\(([^()]*)\)", fn, e)
651
+ voltas += 1
652
+ e = re.sub(r"\b[A-Z]+\d+\b", lambda m: repr(_num(ref_simples(m.group(0), prof))), e)
653
+ # comparações são permitidas para o SE funcionar
654
+ if not re.fullmatch(r"[-+*/().\d\s,<>=!]*", e):
655
+ return "#ERRO"
656
+ try:
657
+ pronto = e.replace(",", ".").replace("<>", "!=")
658
+ pronto = re.sub(r"([^<>!=])=(?!=)", r"\1==", pronto) or "0"
659
+ r = eval(pronto, {"__builtins__": {}}, {})
660
+ if isinstance(r, bool):
661
+ return 1 if r else 0
662
+ return round(r, 6) if isinstance(r, float) else r
663
+ except Exception:
664
+ return "#ERRO"
665
+
666
+ return [[celula(c, l, 0) for c in range(len(linhas[l]))] for l in range(len(linhas))]
667
+
668
+
669
+ def prosa_markdown(doc):
670
+ fora = []
671
+ capa = doc.get("capa")
672
+ if capa:
673
+ if capa.get("chapeu"):
674
+ fora += ["*%s*" % capa["chapeu"], ""]
675
+ if capa.get("titulo"):
676
+ fora += ["# " + capa["titulo"], ""]
677
+ if capa.get("apoio"):
678
+ fora += [capa["apoio"], ""]
679
+ assinatura = " · ".join(x for x in (capa.get("autor"), capa.get("data")) if x)
680
+ if assinatura:
681
+ fora += ["_%s_" % assinatura, ""]
682
+ fora += ["---", ""]
683
+
684
+ for b in doc.get("blocos", []):
685
+ t, texto = b.get("tipo"), b.get("texto", "")
686
+ if t == "titulo":
687
+ fora.append("# " + texto)
688
+ elif t == "secao":
689
+ fora.append("## " + texto)
690
+ elif t == "subsecao":
691
+ fora.append("### " + texto)
692
+ elif t == "chapeu":
693
+ fora.append("*" + texto + "*")
694
+ elif t == "apoio":
695
+ fora.append("_" + texto + "_")
696
+ elif t == "destaque":
697
+ fora.append("> **%s** " % b.get("titulo", "Nota"))
698
+ fora.append("> " + texto)
699
+ elif t == "sumario":
700
+ fora.append("## " + (texto or "Sumário"))
701
+ fora += ["%d. %s" % (i + 1, s.get("texto", ""))
702
+ for i, s in enumerate([x for x in doc.get("blocos", []) if x.get("tipo") == "secao"])]
703
+ elif t == "citacao":
704
+ fora.append("> " + texto)
705
+ if b.get("autor"):
706
+ fora += [">", "> — " + b["autor"]]
707
+ elif t == "lista":
708
+ fora += ["- " + i for i in b.get("itens", [])]
709
+ elif t == "numerada":
710
+ fora += ["%d. %s" % (i + 1, v) for i, v in enumerate(b.get("itens", []))]
711
+ elif t == "codigo":
712
+ fora.append("```\n" + texto + "\n```")
713
+ elif t == "divisor":
714
+ fora.append("---")
715
+ elif t == "tabela":
716
+ linhas = b.get("linhas", [])
717
+ if linhas:
718
+ fora.append("| " + " | ".join(map(str, linhas[0])) + " |")
719
+ fora.append("|" + "---|" * len(linhas[0]))
720
+ for l in linhas[1:]:
721
+ fora.append("| " + " | ".join(map(str, l)) + " |")
722
+ else:
723
+ fora.append(texto)
724
+ fora.append("")
725
+ return "\n".join(fora)
726
+
727
+
728
+ def embutir_midia(texto, limite=24 * 1024 * 1024):
729
+ """Troca /midia/x por data URI para o export abrir sem servidor nenhum."""
730
+ def troca(m):
731
+ nome = unquote(m.group(1))
732
+ alvo = os.path.join(MIDIA, nome)
733
+ if "/" in nome or ".." in nome or not os.path.isfile(alvo):
734
+ return m.group(0)
735
+ if os.path.getsize(alvo) > limite:
736
+ return m.group(0) # grande demais: mantém o link
737
+ tipo = mimetypes.guess_type(alvo)[0] or "application/octet-stream"
738
+ with open(alvo, "rb") as fh:
739
+ return "data:%s;base64,%s" % (tipo, base64.b64encode(fh.read()).decode())
740
+
741
+ return re.sub(r"/midia/([A-Za-z0-9._%\-]+)", troca, texto)
742
+
743
+
744
+ def html_autonomo(a):
745
+ """Arquivo único: CSS, renderizadores, áudio, mídia e documento embutidos."""
746
+ def leia(*partes):
747
+ with open(os.path.join(WEB, *partes), encoding="utf-8") as fh:
748
+ return fh.read()
749
+
750
+ js = "\n".join(leia("js", n) for n in
751
+ ("icones.js", "audio.js", "render.js", "render-prosa.js", "render-grade.js"))
752
+
753
+ dados = json.dumps(a, ensure_ascii=False).replace("</script", "<\\/script")
754
+
755
+ return """<!doctype html>
756
+ <html lang="pt-BR"><head><meta charset="utf-8">
757
+ <meta name="viewport" content="width=device-width,initial-scale=1">
758
+ <title>%(titulo)s</title>
759
+ <style>%(css)s</style></head>
760
+ <body class="modo-ver">
761
+ <div id="palco" class="palco"></div>
762
+ <script id="dados" type="application/json">%(dados)s</script>
763
+ <script>%(js)s</script>
764
+ <script>%(player)s</script>
765
+ </body></html>
766
+ """ % {
767
+ "titulo": (a.get("titulo") or "PrimoCode").replace("<", "&lt;"),
768
+ "css": leia("core.css") + leia("render.css"),
769
+ "dados": embutir_midia(dados),
770
+ "js": js,
771
+ "player": leia("js", "player.js"),
772
+ }
773
+
774
+
775
+ # -------------------------------------------------------------------- main --
776
+
777
+ if __name__ == "__main__":
778
+ url = "http://localhost:%d" % PORTA
779
+ print("""
780
+ ██ PrimoCode Studio
781
+ ────────────────────────────────────────────────────────
782
+ Estúdio %s
783
+ Manual da IA %s/api/manual
784
+ Ferramentas %s
785
+ ────────────────────────────────────────────────────────
786
+ Ctrl+C para parar
787
+ """ % (url, url, ", ".join(f["nome"] for f in FERRAMENTAS_INFO)))
788
+
789
+ try:
790
+ ThreadingHTTPServer(("127.0.0.1", PORTA), Handler).serve_forever()
791
+ except KeyboardInterrupt:
792
+ print("\n Encerrado.\n")