sagadeck 1.3.1 → 1.5.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.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/bin/sagadeck.js +9 -1
  3. package/docs/ANDAMENTO.md +376 -0
  4. package/docs/REFERENCIA.md +4 -4
  5. package/package.json +1 -1
  6. package/src/ai/bench.js +132 -0
  7. package/src/ai/context.js +12 -3
  8. package/src/ai/critique.js +114 -0
  9. package/src/ai/deck-ai.js +234 -30
  10. package/src/ai/document-materials.js +2 -2
  11. package/src/ai/document-visuals.js +400 -20
  12. package/src/ai/llm.js +19 -5
  13. package/src/ai/progress.js +23 -0
  14. package/src/ai/quality.js +60 -0
  15. package/src/api-client.js +2 -1
  16. package/src/build.js +5 -1
  17. package/src/decision-lab.js +2 -1
  18. package/src/deck-file.js +2 -1
  19. package/src/elements.js +44 -7
  20. package/src/figures/charts.js +7 -4
  21. package/src/figures/diagrams.js +15 -1
  22. package/src/figures/ufmap.js +2 -1
  23. package/src/fs-retry.js +19 -0
  24. package/src/import/crop.js +27 -8
  25. package/src/layouts.js +22 -2
  26. package/src/library.js +68 -16
  27. package/src/locale.js +18 -0
  28. package/src/mcp/server.js +2 -2
  29. package/src/motion.js +4 -2
  30. package/src/preferences.js +2 -1
  31. package/src/runtime/base.css +3 -0
  32. package/src/runtime/calc.js +3 -3
  33. package/src/runtime/decision-lab.js +1 -1
  34. package/src/runtime/fit.js +20 -1
  35. package/src/runtime/runtime.js +2 -1
  36. package/src/studio/ai-chat-route.js +119 -4
  37. package/src/studio/code-version.js +35 -0
  38. package/src/studio/generate.js +53 -0
  39. package/src/studio/project.js +8 -7
  40. package/src/studio/public/app.js +7 -216
  41. package/src/studio/public/chat.js +71 -1
  42. package/src/studio/public/header-footer.js +140 -0
  43. package/src/studio/public/index.html +5 -0
  44. package/src/studio/public/library.html +1 -0
  45. package/src/studio/public/library.js +4 -3
  46. package/src/studio/public/preferences-ui.js +83 -0
  47. package/src/studio/public/provenance.js +13 -0
  48. package/src/studio/public/style.css +17 -0
  49. package/src/studio/public/ui-icons.js +1 -1
  50. package/src/studio/public/update-notice.js +25 -0
  51. package/src/studio/public-files.js +11 -1
  52. package/src/studio/server.js +20 -29
  53. package/src/studio/share-routes.js +2 -1
package/src/ai/quality.js CHANGED
@@ -4,9 +4,69 @@ import { chat } from './llm.js';
4
4
  import { auditExploration } from '../exploration.js';
5
5
  import { varietyReport } from './variety.js';
6
6
 
7
+ // Slide de uma frase só: statement, headline e quote (o "slide com uma frase gigante" das reclamações).
8
+ const ONE_LINER = new Set(['statement', 'headline', 'quote']);
9
+ const words = s => String(s || '').replace(/\$[^$]*\$/g, 'x').replace(/[=*_`^~#>\[\]()]/g, ' ').split(/\s+/).filter(Boolean).length;
10
+ const oneLinerText = s => s.text || s.quote || (Array.isArray(s.lines) ? s.lines.map(l => (typeof l === 'string' ? l : l?.text || '')).join(' ') : '') || s.title || '';
11
+
12
+ // Conferência de texto sem visão (determinística, roda sempre): frase longa em layout de impacto vira tela de uma
13
+ // frase gigante; uma apresentação feita de frases soltas não explica nada. Os achados voltam para a correção da IA.
14
+ // Interação de plateia (enquete, votação, quiz, cronômetro): em apresentação acadêmica não entra sem pedido.
15
+ export const votingSlide = (s) => s.layout === 'poll' || (s.layout === 'question' && Array.isArray(s.options) && s.options.length) || s.poll != null || s.timer != null;
16
+ const ASKED_INTERACTION = /vota|votação|enquete|quiz|interativ|dinâmica|gamific|perguntas? (?:para|à) plateia/i;
17
+ // marcação que sobrou crua no texto visível (campo que não passa pela marcação): "^^≠^^", "==x==", "**x**"
18
+ const RAW_MARKUP = /\^\^[^^\n]{1,40}\^\^|==[^=\n]{1,60}==|\*\*[^*\n]{1,60}\*\*/;
19
+ const CODE_LAYOUTS = new Set(['code', 'codewalk', 'codelab', 'api', 'algo', 'terminals', 'duel', 'turns', 'playground']);
20
+
21
+ export function auditText(spec, indices, { briefing = '', render = null } = {}) {
22
+ const issues = [];
23
+ const slides = spec.slides || [];
24
+ // deck feito de um artigo (autoria decidida) é acadêmico: votação só se o pedido quis interação
25
+ const academic = !!spec.context?.autoria || /congresso|simp[óo]sio|confer[êe]ncia|defesa|banca|journal club|semin[áa]rio/i.test(`${briefing} ${JSON.stringify(spec.context || {})}`);
26
+ if (academic && !ASKED_INTERACTION.test(briefing)) for (const index of indices) {
27
+ const s = slides[index];
28
+ if (s && votingSlide(s)) issues.push({ slide: index + 1, text: 'Votação/enquete numa apresentação acadêmica que não pediu interação. Tire o mecanismo de voto (options, timer, poll): a pergunta para discussão vira slide de texto (question sem options, statement ou split) ou vai nas notes.' });
29
+ }
30
+ // slide repetido (mesmo layout e mesmo título de outro): duas capas, o mesmo resultado em dois slides seguidos
31
+ const sig = (s) => `${s?.layout}|${String(s?.title || s?.text || s?.question || '').replace(/[=*^_~`]/g, '').trim().toLowerCase()}`;
32
+ for (const index of indices) {
33
+ const s = slides[index]; if (!s || !(s.title || s.text || s.question)) continue;
34
+ const first = slides.findIndex((o) => sig(o) === sig(s));
35
+ if (first >= 0 && first < index) issues.push({ slide: index + 1, text: `Slide repetido: mesmo layout e mesmo título do slide ${first + 1}. Tire a cópia ou faça este slide dizer outra coisa.` });
36
+ }
37
+ if (render) for (const index of indices) {
38
+ const s = slides[index]; if (!s || CODE_LAYOUTS.has(s.layout)) continue;
39
+ let visible = '';
40
+ try { visible = render(s, index).replace(/<(code|pre|script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' '); } catch { continue; }
41
+ const m = RAW_MARKUP.exec(visible);
42
+ if (m) issues.push({ slide: index + 1, text: `Marcação aparecendo crua na tela ("${m[0]}"): esse campo não aceita marcação. Escreva o texto sem ^^, == ou ** nesse campo.` });
43
+ }
44
+ for (const index of indices) {
45
+ const s = slides[index]; if (!s || !ONE_LINER.has(s.layout)) continue;
46
+ const n = words(oneLinerText(s));
47
+ const limit = s.layout === 'quote' ? 40 : 16;
48
+ if (n > limit) issues.push({ slide: index + 1, text: `Slide de frase única (${s.layout}) com ${n} palavras: vira uma frase gigante na tela. Encurte para a ideia central (até ~12 palavras) ou troque por um layout de conteúdo (split, list, cards, compare) que explique com evidência.` });
49
+ }
50
+ // título encolhido à mão (titleSize pequeno) para "dar espaço à figura": o deck fica com cada slide num tamanho de
51
+ // título; o layout da figura já reserva a área. Vale para os layouts com cabeçalho comum (não capa/seção/manchete).
52
+ for (const index of indices) {
53
+ const s = slides[index];
54
+ if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', 'kinetic', 'scenography', 'full'].includes(s.layout)) continue;
55
+ if (Number(s.titleSize) < 60) issues.push({ slide: index + 1, text: `Título com titleSize ${s.titleSize}, bem menor que o dos outros slides: a hierarquia do deck fica desigual. Tire o titleSize (o layout já dá a área da figura); se o título não couber, encurte o texto.` });
56
+ }
57
+ // muitas telas de frase solta: a partir da terceira (ou de 15% do deck), cada uma precisa justificar o lugar
58
+ const singles = slides.map((s, i) => (ONE_LINER.has(s.layout) ? i : -1)).filter(i => i >= 0);
59
+ const allowed = Math.max(2, Math.round(slides.length * 0.15));
60
+ for (const index of singles.slice(allowed)) if (indices.includes(index) && !issues.some(x => x.slide === index + 1))
61
+ issues.push({ slide: index + 1, text: `Há ${singles.length} slides de frase única (statement/headline/quote) no deck; o máximo é ${allowed}. Transforme este em conteúdo (o que, por que, evidência do material) ou junte a frase ao slide vizinho.` });
62
+ return issues;
63
+ }
64
+
7
65
  export async function reviewExperience(spec, indices, { snapshot, complete = chat, onProgress, signal, briefing = '' } = {}) {
8
66
  const issues = [], unchecked = [], failures = [];
9
67
  const selected = [...new Set(indices)].filter(index => spec.slides[index]);
68
+ const { renderSlide } = await import('../build.js');
69
+ issues.push(...auditText(spec, selected, { briefing, render: (s, i) => renderSlide(s, i, spec).html }));
10
70
  for (const [position, index] of selected.entries()) {
11
71
  signal?.throwIfAborted();
12
72
  const slide = spec.slides[index];
package/src/api-client.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // ttl_minutes: 30
15
15
  // ca: "C:/certs/empresa.pem" # certificado da empresa (proxy/inspeção TLS)
16
16
  import fs from "node:fs";
17
+ import { renameRetry } from "./fs-retry.js";
17
18
  import os from "node:os";
18
19
  import path from "node:path";
19
20
  import http from "node:http";
@@ -187,7 +188,7 @@ export class ApiEnvironments {
187
188
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
188
189
  const tmp = `${this.file}.tmp-${process.pid}`;
189
190
  fs.writeFileSync(tmp, String(doc), { encoding: "utf8", mode: 0o600 });
190
- fs.renameSync(tmp, this.file);
191
+ renameRetry(tmp, this.file);
191
192
  }
192
193
 
193
194
  // Ambiente de exemplo (embutido) editado pela pessoa: vira um ambiente dela, cópia completa no arquivo (o do arquivo
package/src/build.js CHANGED
@@ -10,7 +10,8 @@ import { fileURLToPath } from "node:url";
10
10
  import YAML from "yaml";
11
11
  import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
12
12
  import { identityFor, identitiesFile } from "./identity.js";
13
- import { LAYOUTS, SCENES } from "./layouts.js";
13
+ import { LAYOUTS, SCENES, fullAsFigure } from "./layouts.js";
14
+ import { setDeckLocale } from "./locale.js";
14
15
  import { applyVisualEdits } from "./visual-edits.js";
15
16
  import { el, imageSrc } from "./elements.js";
16
17
  import { iconSVG } from "./figures/icons.js";
@@ -38,6 +39,7 @@ const drawIcon = (name, size = 20) => iconSVG(name, { size, stroke: 2 }).replace
38
39
  const slug = (s) => String(s || "deck").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "deck";
39
40
 
40
41
  export function inferLayout(s) {
42
+ if (fullAsFigure(s)) return "image"; // figura do documento em full: sem véu e sem sangrar (src/layouts.js)
41
43
  if (s.layout) return s.layout;
42
44
  if (s.elements) return "canvas";
43
45
  if (s.stats || s.kpis) return "stats";
@@ -126,6 +128,7 @@ export function navWarnings(slides = []) {
126
128
 
127
129
  export function buildHTML(rawSpec, opts = {}) {
128
130
  const spec = normalizeSpec(meetingSpec(rawSpec,opts.audience));
131
+ setDeckLocale(spec.lang); // números no idioma do deck (src/locale.js)
129
132
  const theme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
130
133
  const warnings = [];
131
134
  if (spec.identity && !theme.identity) warnings.push(`identidade "${spec.identity}" não está em ${identitiesFile()} nesta máquina: a apresentação saiu com as fontes do tema`);
@@ -306,6 +309,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
306
309
  }
307
310
 
308
311
  export function renderSlide(raw, i = 0, spec = {}) {
312
+ setDeckLocale(spec.lang);
309
313
  const deckTheme = resolveTheme(spec.theme, spec.palette, identityOf(spec));
310
314
  const s = { ...(spec.defaults || {}), ...raw };
311
315
  const theme = slideTheme(s, deckTheme, spec);
@@ -1,9 +1,10 @@
1
1
  import './runtime/decision-lab.js';
2
2
  import { esc } from './markup.js';
3
+ import { formatNumber } from './locale.js';
3
4
  export const calculateDecisionLab = globalThis.SagaDecisionLab.calculate;
4
5
  export function decisionLabHTML(input = {}) {
5
6
  const v = globalThis.SagaDecisionLab.normalize(input), r = calculateDecisionLab(v);
6
- const fmt = n => n.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
7
+ const fmt = n => formatNumber(n, { maximumFractionDigits: 1 });
7
8
  const controls = [
8
9
  ['errorRate', 'Erros da automação (%)', 100, .1],
9
10
  ['reviewRate', 'Casos revisados (%)', 100, 1],
package/src/deck-file.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // 3. o texto final é relido e comparado com o deck: se não bater, grava o deck inteiro (correção primeiro);
9
9
  // 4. a troca é atômica: escreve num temporário ao lado e renomeia (nunca fica um arquivo pela metade).
10
10
  import fs from "node:fs";
11
+ import { renameRetry } from "./fs-retry.js";
11
12
  import path from "node:path";
12
13
  import { isDeepStrictEqual } from "node:util";
13
14
  import YAML from "yaml";
@@ -114,6 +115,6 @@ export function writeDeckFile(file, spec) {
114
115
  if (text === current) return false;
115
116
  const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
116
117
  fs.writeFileSync(tmp, text, "utf8");
117
- try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
118
+ try { renameRetry(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
118
119
  return true;
119
120
  }
package/src/elements.js CHANGED
@@ -6,6 +6,7 @@ import { videoPlayer } from './video-player.js';
6
6
  import { tableHTML } from "./table.js";
7
7
  import { qrSVG } from "./figures/qr.js";
8
8
  import katex from "katex";
9
+ import { formatNumber, decimalSeparator } from "./locale.js";
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
11
12
  import { md, esc } from "./markup.js";
@@ -116,13 +117,20 @@ export function figureHTML(el, ctx, w, h) {
116
117
  const grown = matchCropAspect(el.crop, imageSize(el.image, ctx), w, h);
117
118
  const c = { l: +grown.l || 0, t: +grown.t || 0, r: +grown.r || 0, b: +grown.b || 0 };
118
119
  const fw = Math.max(0.01, 1 - c.l - c.r), fh = Math.max(0.01, 1 - c.t - c.b);
119
- return `<div${attrs(el, "fig fig-img fig-crop")}><img src="${src}" alt="${esc(el.alt || "")}" style="left:${(-c.l / fw) * 100}%;top:${(-c.t / fh) * 100}%;width:${100 / fw}%;height:${100 / fh}%;${flip}"></div>`;
120
+ // data-crop: a apresentação mede a caixa de verdade e, se a proporção não bater com a da região (caixa sem
121
+ // tamanho aqui, janela que não pôde crescer), encaixa a região inteira sem esticar (src/runtime/fit.js: fitCrops)
122
+ return `<div${attrs(el, "fig fig-img fig-crop")}><img src="${src}" alt="${esc(el.alt || "")}" data-crop="${[c.l, c.t, c.r, c.b].map((v) => +v.toFixed(5)).join(",")}" style="left:${(-c.l / fw) * 100}%;top:${(-c.t / fh) * 100}%;width:${100 / fw}%;height:${100 / fh}%;${flip}"></div>`;
120
123
  }
121
124
  // sem fit escolhido: preenche (cover), mas quando a proporção da imagem é muito diferente da caixa (fórmula, esquema
122
125
  // largo numa coluna alta; gráfico com eixo e legenda na borda) cortar perderia informação: passou de 1,25× (corte de
123
126
  // mais de 20%), cabe inteira (contain). Sem o tamanho da caixa aqui (figura do split, do layout), a mesma conta é feita
124
127
  // na apresentação, com a caixa medida (data-autofit; src/runtime/fit.js: fitImages)
125
128
  let fit = el.fit;
129
+ // figura recortada do documento (mapa, gráfico, tabela do paper) é evidência: nunca deforma nem perde borda.
130
+ // fill/stretch achatavam o mapa na caixa do slide, e o cover automático (até 20% de corte) comia eixo, legenda e
131
+ // escala; vira contain (inteira, na proporção do original)
132
+ const docImage = /contexto\/visuais\//.test(String(el.image || "").replace(/\\/g, "/"));
133
+ if (docImage && (!fit || /^(fill|stretch|none|cover)$/i.test(String(fit)))) fit = "contain";
126
134
  if (!fit) {
127
135
  const sz = w && h ? imageSize(el.image, ctx) : null;
128
136
  const box = w && h ? w / h : 0, img = sz && sz.h ? sz.w / sz.h : 0;
@@ -141,7 +149,7 @@ export function figureHTML(el, ctx, w, h) {
141
149
  if (pct) { box = { ...el }; delete box.w; }
142
150
  }
143
151
  }
144
- return `<div${attrs(box, "fig fig-img", flow)}><img src="${src}" alt="${esc(el.alt || "")}"${el.fit ? "" : " data-autofit"} style="object-fit:${fit};${el.radius ? `border-radius:${el.radius}px;` : ""}${flip}"></div>`;
152
+ return `<div${attrs(box, "fig fig-img", flow)}><img src="${src}" alt="${esc(el.alt || "")}"${el.fit || docImage ? "" : " data-autofit"} style="object-fit:${fit};${el.radius ? `border-radius:${el.radius}px;` : ""}${flip}"></div>`;
145
153
  }
146
154
  return "";
147
155
  }
@@ -260,7 +268,7 @@ export function el(e, ctx, w, h) {
260
268
  if (e.badge) return `<div${attrs(e, "badge f-label")}>${md(e.badge)}</div>`;
261
269
  if (e.aviso) return aviso(e);
262
270
  if (e.video) return video(e, ctx);
263
- if (e.motion) return `<div${attrs(e, "motion")}>${motionHTML(e.motion,ctx?.theme?.colors)}</div>`;
271
+ if (e.motion) return `<div${attrs(e, "motion")}>${motionHTML(e.motion,ctx?.theme?.colors,{still:ctx?.spec?.motion==='none'})}</div>`;
264
272
  if (e.widget) return `<div${attrs(e, "widget")} data-widget="${esc(e.widget)}" data-opts="${esc(JSON.stringify(e))}"></div>`;
265
273
  if (e.html) return `<div${attrs(e, "raw")}>${e.html}</div>`;
266
274
  if (e.spacer != null) return `<div class="spacer" style="flex:${e.spacer === true ? 1 : 0} 0 ${px(e.spacer === true ? 0 : e.spacer)}"></div>`;
@@ -374,10 +382,39 @@ export function importedTable(e) {
374
382
  return `<div${attrs(e, "tbx-table")}><table style="border-collapse:collapse;table-layout:fixed;width:100%"><colgroup>${cols}</colgroup>${rows}</table></div>`;
375
383
  }
376
384
 
385
+ // O contador reescreve o texto no navegador (textContent): marcação de expoente/índice no prefixo/sufixo ("km^2^",
386
+ // "CO~2~") vira o algarismo sobrescrito/subscrito do Unicode, que sobrevive ao textContent (saía "km^2^" cru).
387
+ const SUP = { 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹", "-": "⁻", "+": "⁺" };
388
+ const SUB = { 0: "₀", 1: "₁", 2: "₂", 3: "₃", 4: "₄", 5: "₅", 6: "₆", 7: "₇", 8: "₈", 9: "₉", "-": "₋", "+": "₊" };
389
+ export const unicodeScripts = (t) => String(t ?? "").replace(/\^([-+]?\d+)\^/g, (_, d) => [...d].map((c) => SUP[c]).join("")).replace(/~([-+]?\d+)~/g, (_, d) => [...d].map((c) => SUB[c]).join(""));
390
+ // Valor do contador escrito como texto ("37,67%", "1.403", "R$ 2,5 mi", "1521 km^2^"): Number() dava NaN e o slide
391
+ // mostrava "NaN". Separa o que vem antes/depois (prefixo/sufixo) e lê o número no formato brasileiro (milhar com
392
+ // ponto, decimal com vírgula); as casas decimais escritas valem. Sem número nenhum, o texto aparece como está.
393
+ export function parseCounterValue(raw) {
394
+ if (typeof raw === "number") return { value: raw };
395
+ const m = /^(\D*?)(-?\d[\d.,]*)(.*)$/s.exec(String(raw ?? "").trim());
396
+ if (!m) return null;
397
+ let num = m[2].replace(/[.,]$/, ""), dec = 0;
398
+ if (decimalSeparator() === ".") {
399
+ // deck em inglês: vírgula é milhar ("1,403"), ponto é decimal ("37.67")
400
+ num = num.replace(/,(?=\d{3}(?:\D|$))/g, "");
401
+ if (num.includes(".")) dec = num.split(".").pop().length;
402
+ else if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(",", "."); }
403
+ }
404
+ else if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(/\./g, "").replace(",", "."); }
405
+ else if (/^-?\d{1,3}(\.\d{3})+$/.test(num)) num = num.replace(/\./g, "");
406
+ else if (num.includes(".")) dec = num.split(".").pop().length;
407
+ const value = Number(num);
408
+ return Number.isFinite(value) ? { value, prefix: m[1], suffix: m[3] + (m[2].length > num.length && /[.,]$/.test(m[2]) ? m[2].slice(-1) : ""), dec } : null;
409
+ }
410
+
377
411
  export function counter(e) {
378
- const v = Number(e.counter);
412
+ const parsed = parseCounterValue(e.counter);
413
+ if (!parsed) return `<div${attrs(e, `t f-display r-number pl`, `font-size:${e.size || SIZES[e.as || "number"]}px;`)}${e.fit ? " data-fit" : ""}>${md(String(e.counter ?? ""))}</div>`;
414
+ e = { ...e, prefix: unicodeScripts(`${e.prefix ?? ""}${parsed.prefix ?? ""}`), suffix: unicodeScripts(`${parsed.suffix ?? ""}${e.suffix ?? ""}`), decimals: e.decimals ?? (typeof e.counter === "number" ? undefined : parsed.dec) };
415
+ const v = parsed.value;
379
416
  const dec = e.decimals ?? (v % 1 ? 1 : 0);
380
- const shown = v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec });
417
+ const shown = formatNumber(v, { minimumFractionDigits: dec, maximumFractionDigits: dec }); // no idioma do deck
381
418
  const size = e.size || SIZES[e.as || "number"];
382
419
  return `<div${attrs(e, `t f-display r-number counter pl`, `font-size:${size}px;`)}${e.fit ? " data-fit" : ""} data-to="${v}" data-from="${e.from ?? 0}" data-dec="${dec}" data-prefix="${esc(e.prefix || "")}" data-suffix="${esc(e.suffix || "")}"><span class="cv">${esc(e.prefix || "")}${shown}${esc(e.suffix || "")}</span></div>`;
383
420
  }
@@ -447,7 +484,7 @@ export function cards(e, ctx) {
447
484
  ${badgeHtml}
448
485
  </div>
449
486
  ${c.title ? `<div class="cd-title t f-heading">${md(c.title)}</div>` : ""}
450
- ${c.text ? `<div class="cd-text t f-body">${md(c.text)}</div>` : ""}
487
+ ${c.text ? `<div class="cd-text t f-body${/^\s*\$\$?[^$]+\$\$?\s*$/.test(String(c.text)) ? " cd-math" : ""}">${md(c.text)}</div>` : ""}
451
488
  ${extraWidgets}
452
489
  ${c.foot ? `<div class="cd-foot t f-label">${md(c.foot)}</div>` : ""}
453
490
  ${c.goto != null && c.goto !== "" ? `<span class="goto-mark" aria-hidden="true">${iconSVG("arrow-up-right", { size: 30, stroke: 2 })}</span>` : ""}
@@ -476,7 +513,7 @@ export function stats(e, ctx) {
476
513
  ${iconHtml}
477
514
  ${trendHtml}
478
515
  </div>
479
- <div class="st-val t f-display" style="${valColor}">${esc(String(st.value ?? st.stat ?? ""))}</div>
516
+ <div class="st-val t f-display" style="${valColor}">${md(String(st.value ?? st.stat ?? ""))}</div>
480
517
  ${st.label ? `<div class="st-lab t f-heading">${md(st.label)}</div>` : ""}
481
518
  ${st.text ? `<div class="st-sub t f-body">${md(st.text)}</div>` : ""}
482
519
  </div>`;
@@ -12,6 +12,7 @@
12
12
  // Cores: por padrão usam o tom do slide (--cb base, --ca destaque, --s1..--s5 séries).
13
13
  // Qualquer item aceita color: hi | em | fg | muted | "FF0000".
14
14
  import { esc } from "../markup.js";
15
+ import { formatNumber } from "../locale.js";
15
16
  import { iconSVG } from "./icons.js";
16
17
  import { humanBody } from "./pictos.js";
17
18
 
@@ -24,8 +25,10 @@ const cvar = (c, d) => {
24
25
  };
25
26
  const fmt = (v, o) => {
26
27
  if (v == null || isNaN(v)) return "";
27
- const d = o.decimals ?? (Math.abs(v) < 10 && v % 1 ? 1 : 0);
28
- const s = Number(v).toLocaleString("pt-BR", { minimumFractionDigits: d, maximumFractionDigits: d });
28
+ // sem decimals pedido: as casas do próprio valor, até 2 (0.93 aparecia "0.9" com a regra antiga de 1 casa
29
+ // abaixo de 10, e 37.67 aparecia "38": num gráfico de paper, a precisão relatada é dado)
30
+ const d = o.decimals ?? Math.min(2, (String(Number(v)).split(".")[1] || "").length);
31
+ const s = formatNumber(v, { minimumFractionDigits: d, maximumFractionDigits: d }); // no idioma do deck (lang)
29
32
  return `${o.prefix || ""}${s}${o.suffix || ""}`;
30
33
  };
31
34
  const norm = (o) => {
@@ -198,8 +201,8 @@ export function niceTicks(min, max, want = 5) {
198
201
  }
199
202
  function line(o, W, H) {
200
203
  const series = o.series || [{ values: o.values }];
201
- // rótulo que veio como número (0.5): com vírgula, como o resto do gráfico
202
- const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? l.toLocaleString("pt-BR", { useGrouping: false }) : l));
204
+ // rótulo que veio como número (0.5): no formato do resto do gráfico (vírgula em pt-BR, ponto em inglês)
205
+ const labels = (o.labels || series[0].values.map((_, i) => String(i + 1))).map((l) => (typeof l === "number" ? formatNumber(l, { useGrouping: false }) : l));
203
206
  const all = series.flatMap((s) => s.values).filter((v) => v != null);
204
207
  const min = o.min ?? Math.min(0, ...all), max = o.max ?? Math.max(...all) * 1.1;
205
208
  // nomes no fim das linhas, a não ser que se encostem (as linhas terminam juntas) ou o slide peça legenda: aí, legenda em cima
@@ -82,6 +82,20 @@ export function spectrum(o = {}) {
82
82
  return wrap([0, 0, W, 190], g, "spectrum");
83
83
  }
84
84
 
85
+ // Tamanho da letra que cabe na caixa (largura × altura): simula a quebra por palavras e desce de 36 até 18 px.
86
+ // Com 36 px fixos, "Low-lying areas near the drainage network" pedia 4 linhas e vazava por baixo da caixa.
87
+ export function boxFontSize(text, w, h, { max = 36, min = 18, lh = 1.15, em = 0.56 } = {}) {
88
+ const words = String(text || "").split(/\s+/).filter(Boolean);
89
+ for (let fs = max; fs > min; fs -= 2) {
90
+ const cpl = Math.max(1, Math.floor(w / (fs * em)));
91
+ if (words.some((wd) => wd.length > cpl)) continue;
92
+ let lines = 1, len = 0;
93
+ for (const wd of words) { if (len && len + 1 + wd.length > cpl) { lines++; len = wd.length; } else len += (len ? 1 : 0) + wd.length; }
94
+ if (lines * fs * lh <= h) return fs;
95
+ }
96
+ return min;
97
+ }
98
+
85
99
  export function flow(o = {}) {
86
100
  const steps = o.steps || [];
87
101
  const n = steps.length, bw = 300, gap = 90, bh = 150, vgap = 130;
@@ -90,7 +104,7 @@ export function flow(o = {}) {
90
104
  const id = `ar${++uid}`;
91
105
  const box = (x, y, s, hl) =>
92
106
  `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" rx="18" style="fill:var(${hl ? "--hi" : "--surface"});stroke:var(--fg)" stroke-width="${hl ? 0 : 4}"/>` +
93
- `<foreignObject x="${x + 16}" y="${y + 10}" width="${bw - 32}" height="${bh - 20}"><div xmlns="http://www.w3.org/1999/xhtml" class="f-heading" style="height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-size:36px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
107
+ `<foreignObject x="${x + 16}" y="${y + 10}" width="${bw - 32}" height="${bh - 20}"><div xmlns="http://www.w3.org/1999/xhtml" class="f-heading" style="height:100%;display:flex;align-items:center;justify-content:center;text-align:center;line-height:1.15;font-size:${boxFontSize(s, bw - 32, bh - 20)}px;color:var(${hl ? "--on-hi" : "--fg"})">${esc(s)}</div></foreignObject>`;
94
108
  const arrow = (x1, y1, x2, y2) =>
95
109
  `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:var(--fg)" stroke-width="8" marker-end="url(#${id})"/>`;
96
110
  let g = arrowDefs(id);
@@ -1,6 +1,7 @@
1
1
  // Mapa do Brasil por UF em grade (cartograma de ladrilhos): cada estado é um quadrado na posição aproximada dele.
2
2
  // Sem geodados de fora (nada pede coisa à internet) e legível em slide: a cor diz o valor, o rótulo diz a UF.
3
3
  import { esc } from "../markup.js";
4
+ import { formatNumber } from "../locale.js";
4
5
 
5
6
  // [coluna, linha] de cada UF, do norte ao sul, do oeste ao leste
6
7
  export const UF_GRID = {
@@ -14,7 +15,7 @@ export const UF_GRID = {
14
15
  RS: [1, 7],
15
16
  };
16
17
 
17
- const fmt = (v) => (Number.isFinite(v) ? v.toLocaleString("pt-BR", { maximumFractionDigits: 1 }) : "");
18
+ const fmt = (v) => (Number.isFinite(v) ? formatNumber(v, { maximumFractionDigits: 1 }) : "");
18
19
 
19
20
  // o: { ufmap: { SP: 120, RJ: 80, … }, suffix, prefix, highlight: [SP], legend: "texto", showValues }
20
21
  export function ufmap(o, warnings) {
@@ -0,0 +1,19 @@
1
+ // Renomear tolerante a arquivo travado. A biblioteca mora em Documentos, que no Windows costuma estar no OneDrive:
2
+ // enquanto sincroniza (e também o antivírus ou o indexador), o arquivo fica preso por um instante e o rename falha
3
+ // com EPERM/EBUSY/EACCES. Em vez de perder o salvamento da pessoa, tenta de novo com espera curta e crescente
4
+ // (~1 s no total). Qualquer outro erro (destino inexistente, disco cheio…) sobe na hora.
5
+ import fs from "node:fs";
6
+
7
+ const LOCKED = new Set(["EPERM", "EBUSY", "EACCES"]);
8
+ export const RENAME_DELAYS = [20, 40, 80, 150, 250, 450];
9
+
10
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
+
12
+ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
13
+ for (let i = 0; ; i++) {
14
+ try { return fs.renameSync(from, to); } catch (e) {
15
+ if (!LOCKED.has(e?.code) || i >= delays.length) throw e;
16
+ pause(delays[i]);
17
+ }
18
+ }
19
+ }
@@ -45,21 +45,40 @@ export async function cropRegions(jobs) {
45
45
  const outs = await page.evaluate(async ([data, list]) => {
46
46
  const img = new Image(); img.src = data; await img.decode();
47
47
  return list.map((j) => {
48
- const x = Math.max(0, Math.floor(j.x)), y = Math.max(0, Math.floor(j.y));
49
- const w = Math.max(1, Math.min(img.width - x, Math.ceil(j.w))), h = Math.max(1, Math.min(img.height - y, Math.ceil(j.h)));
50
- const c = document.createElement("canvas"); c.width = w; c.height = h;
51
- const g = c.getContext("2d", { willReadFrequently: true }); g.drawImage(img, x, y, w, h, 0, 0, w, h);
52
- // densidade de tinta (fração de pixels não-papel): recorte quase em branco = caixa errada
48
+ let x = Math.max(0, Math.floor(j.x)), y = Math.max(0, Math.floor(j.y));
49
+ let w = Math.max(1, Math.min(img.width - x, Math.ceil(j.w))), h = Math.max(1, Math.min(img.height - y, Math.ceil(j.h)));
50
+ let c = document.createElement("canvas"); c.width = w; c.height = h;
51
+ let g = c.getContext("2d", { willReadFrequently: true }); g.drawImage(img, x, y, w, h, 0, 0, w, h);
52
+ // densidade de tinta (fração de pixels não-papel): recorte quase em branco = caixa errada (medida antes de aparar)
53
53
  let ink = 0;
54
54
  try {
55
55
  const d = g.getImageData(0, 0, w, h).data;
56
56
  for (let i = 0; i < d.length; i += 16) { if ((d[i] + d[i + 1] + d[i + 2]) / 3 < 235) ink++; }
57
57
  ink /= d.length / 16;
58
58
  } catch { ink = 1; }
59
- return { png: c.toDataURL("image/png"), ink };
59
+ // trim: apara a margem de papel em volta (linha/coluna inteira branca), com um respiro de 6 px.
60
+ // Só papel puro sai: céu claro numa foto ou fundo cinza de gráfico não é branco puro em toda a linha.
61
+ if (j.trim && w > 24 && h > 24) {
62
+ const d = g.getImageData(0, 0, w, h).data;
63
+ const paper = (i) => d[i] >= 246 && d[i + 1] >= 246 && d[i + 2] >= 246;
64
+ const rowBlank = (r) => { for (let q = 0; q < w; q++) if (!paper((r * w + q) * 4)) return false; return true; };
65
+ const colBlank = (q, t0, t1) => { for (let r = t0; r <= t1; r++) if (!paper((r * w + q) * 4)) return false; return true; };
66
+ let t = 0, b = h - 1, l = 0, r = w - 1;
67
+ while (t < b && rowBlank(t)) t++;
68
+ while (b > t && rowBlank(b)) b--;
69
+ while (l < r && colBlank(l, t, b)) l++;
70
+ while (r > l && colBlank(r, t, b)) r--;
71
+ const pad = 6, nl = Math.max(0, l - pad), nt = Math.max(0, t - pad), nw = Math.min(w, r + pad + 1) - nl, nh = Math.min(h, b + pad + 1) - nt;
72
+ if (r > l && b > t && (nw < w || nh < h)) {
73
+ const c2 = document.createElement("canvas"); c2.width = nw; c2.height = nh;
74
+ const g2 = c2.getContext("2d", { willReadFrequently: true }); g2.drawImage(c, nl, nt, nw, nh, 0, 0, nw, nh);
75
+ c = c2; g = g2; w = nw; h = nh;
76
+ }
77
+ }
78
+ return { png: c.toDataURL("image/png"), ink, size: { w, h } };
60
79
  });
61
- }, [data, list.map(({ x, y, w, h }) => ({ x, y, w, h }))]);
62
- outs.forEach((u, k) => { fs.writeFileSync(list[k].dst, Buffer.from(u.png.split(",")[1], "base64")); list[k].ink = u.ink; done.push(list[k].dst); });
80
+ }, [data, list.map(({ x, y, w, h, trim }) => ({ x, y, w, h, trim }))]);
81
+ outs.forEach((u, k) => { fs.writeFileSync(list[k].dst, Buffer.from(u.png.split(",")[1], "base64")); list[k].ink = u.ink; list[k].size = u.size; done.push(list[k].dst); });
63
82
  }
64
83
  } finally { await browser.close(); }
65
84
  return done;
package/src/layouts.js CHANGED
@@ -97,6 +97,20 @@ function chartsSideBySide(content) {
97
97
  return out;
98
98
  }
99
99
 
100
+ // `full` com figura do documento (mapa, gráfico, tabela recortada do paper) ou figura inteira (fit: contain) e texto:
101
+ // o véu por cima cobria a legenda do mapa e os eixos, e a figura sangrava cortando a borda. Evidência não leva texto
102
+ // por cima nem sangra: vira a composição do layout image (título no alto, figura inteira, legenda embaixo). Para
103
+ // figura do documento até overlay: none com título vale assim (senão título e legenda sumiam do slide).
104
+ export function fullAsFigure(s) {
105
+ if (s?.layout !== "full") return false;
106
+ const f = s.figure ?? (s.image ? { image: s.image } : null);
107
+ const fig = typeof f === "string" ? { image: f } : f;
108
+ if (!fig || typeof fig !== "object" || !fig.image || fig.image_prompt || fig.video) return false;
109
+ if (!(s.title || s.caption || s.kicker)) return false;
110
+ const doc = /contexto\/visuais\//.test(String(fig.image).replace(/\\/g, "/"));
111
+ return doc || ((fig.fit || s.fit) === "contain" && s.overlay !== "none");
112
+ }
113
+
100
114
  export const LAYOUTS = {
101
115
  portal(s,ctx){return portalHTML(s,ctx,head)+src(s)+add(s,ctx);},
102
116
  playground(s,ctx){return playgroundHTML(s,ctx,head)+src(s)+add(s,ctx);},
@@ -401,7 +415,7 @@ export const LAYOUTS = {
401
415
  ${c.title ? text(c.title, "h3", { class: "cp-title" }) : ""}
402
416
  ${c.text ? text(c.text, "body", { class: "cp-text" }) : ""}
403
417
  ${c.items ? list({ list: c.items, size: 32 }) : ""}</div>`;
404
- return `<div class="L-compare">${head(s)}<div class="cp-row">${col(s.left, "l", 0)}<div${attrs({ step: s.right?.step ?? build(s, 1) }, "cp-vs t f-display")}>${esc(s.vs ?? "×")}</div>${col(s.right, "r", 1)}</div>
418
+ return `<div class="L-compare">${head(s)}<div class="cp-row">${col(s.left, "l", 0)}<div${attrs({ step: s.right?.step ?? build(s, 1) }, "cp-vs t f-display")}>${md(String(s.vs ?? "×"))}</div>${col(s.right, "r", 1)}</div>
405
419
  ${s.after ? text(s.after, "h3", { class: "cp-after", step: s.afterStep ?? (s.build ? 3 : 1), face: "quote", size: 50 }) : ""}</div>${src(s)}${add(s, ctx)}`;
406
420
  },
407
421
 
@@ -439,6 +453,7 @@ export const LAYOUTS = {
439
453
  },
440
454
 
441
455
  image(s, ctx) {
456
+ if (typeof s.figure === "string") s = { ...s, figure: { image: s.figure } }; // full com figure: caminho (fullAsFigure)
442
457
  // figura inteira à vista (fit: contain: gráfico, tabela, esquema): a legenda vai embaixo, sem cobrir; foto (cover)
443
458
  // segue sangrando, com o cartão por cima
444
459
  const contain = (s.figure?.fit || s.fit) === "contain" || (!s.fit&&!s.figure?.fit&&String(s.figure?.image||s.image||'').replace(/\\/g,'/').includes('contexto/visuais/'));
@@ -536,6 +551,7 @@ export const LAYOUTS = {
536
551
  const f = s.figure ?? (s.image || s.image_prompt ? { image: s.image, image_prompt: s.image_prompt } : null);
537
552
  const fig = f == null ? "" : typeof f === "object" ? { fit: s.fit || "cover", ...f } : { image: f, fit: s.fit || "cover" };
538
553
  const hasText = s.title || s.caption || s.kicker;
554
+ if (fullAsFigure(s)) return LAYOUTS.image({ ...s, figure: { ...fig, fit: "contain" }, fit: undefined }, ctx);
539
555
  const pos = s.overlay || (hasText ? "bottom" : "none");
540
556
  // figura desenhada (gráfico, ícone, diagrama) com texto à esquerda: vai para a direita em vez de ficar por baixo do texto
541
557
  const drawn = fig && !(fig.image || fig.image_prompt || fig.video);
@@ -545,7 +561,11 @@ export const LAYOUTS = {
545
561
 
546
562
  // Manchete: uma frase enorme que ocupa o slide (encolhe para caber)
547
563
  headline(s, ctx) {
548
- return `<div class="L-headline">${kicker(s)}${text(s.text || s.title, s.as || "hero", { class: "hl-text e", style: "--d:1;", fit: true, size: s.size || 300 })}
564
+ // manchete é para frase curta; frase longa em "hero" de 300 px virava o slide de uma frase gigante (objetivo
565
+ // inteiro do paper na tela). Como no statement, o tamanho segue o comprimento; `as`/`size` explícitos vencem.
566
+ const chars = String(s.text || s.title || "").replace(/[=*_`^~]/g, "").length;
567
+ const role = s.as || (chars > 100 ? "h2" : chars > 60 ? "title" : "hero");
568
+ return `<div class="L-headline">${kicker(s)}${text(s.text || s.title, role, { class: "hl-text e", style: "--d:1;", fit: true, size: s.size || (role === "hero" ? 300 : undefined) })}
549
569
  ${s.caption ? text(s.caption, "lead", { class: "hl-cap muted e", style: "--d:2;" }) : ""}</div>${src(s)}${add(s, ctx)}`;
550
570
  },
551
571