sagadeck 1.3.0 → 1.4.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.
package/src/ai/llm.js CHANGED
@@ -132,6 +132,13 @@ async function chatUnmetered(messages, opts = {}) {
132
132
  const noThinkField = new Set();
133
133
  async function chatOnce(messages, opts = {}) {
134
134
  const cfg = opts.cfg || llmConfig();
135
+ // modelo que entrou em laço pensando (o limite da 1ª palavra estourou sem texto nenhum): uma tentativa a mais, sem
136
+ // raciocínio; a mesma tarefa sem pensar sai em segundos. Sem isso a conferência de cobertura e a correção visual
137
+ // se perdiam depois de 10 min de espera.
138
+ if (opts.think !== false && !opts.noLoopRetry) {
139
+ try { return await chatOnce(messages, { ...opts, noLoopRetry: true }); }
140
+ catch (e) { if (e.code !== "AI_THINKING_LOOP" || opts.signal?.aborted) throw e; opts.onRetry?.({ attempt: 2, maxAttempts: 2, code: e.code }); return chatOnce(messages, { ...opts, think: false, noLoopRetry: true }); }
141
+ }
135
142
  if (opts.think === false && !noThinkField.has(cfg.url)) {
136
143
  try { return await chatOnceRaw(messages, { ...opts, cfg, reasoningOff: true }); }
137
144
  catch (e) { if (e.status !== 400 || !/reasoning/i.test(e.message)) throw e; noThinkField.add(cfg.url); }
@@ -192,12 +199,18 @@ async function chatStream(body, onDelta, cfg, signal) {
192
199
  let timer;
193
200
  const arm = (ms) => { clearTimeout(timer); timer = setTimeout(() => idle.abort(Object.assign(new Error("tempo esgotado"), { name: "TimeoutError" })), ms); };
194
201
  arm(cfg.firstTimeoutMs ?? cfg.timeoutMs);
202
+ // Até a 1ª palavra de TEXTO o limite é o firstTimeoutMs, mesmo com o relay mandando "estou vivo" (e o raciocínio)
203
+ // a cada poucos segundos: o modelo que entra em laço pensando zerava o relógio para sempre e a tarefa ficava
204
+ // parada horas em "Pensando…". Quem chamou trata o erro (a correção visual preserva o deck que já existe).
205
+ const firstMs = cfg.firstTimeoutMs ?? cfg.timeoutMs;
206
+ let firstTimer = setTimeout(() => idle.abort(Object.assign(new Error("sem texto"), { name: "TimeoutError", noText: true })), firstMs);
207
+ const wrote = () => { clearTimeout(firstTimer); firstTimer = null; };
195
208
  const sig = signal ? AbortSignal.any([signal, idle.signal]) : idle.signal;
196
- try { return await readStream(body, onDelta, cfg, signal, sig, () => arm(cfg.timeoutMs)); }
197
- finally { clearTimeout(timer); }
209
+ try { return await readStream(body, onDelta, cfg, signal, sig, () => arm(cfg.timeoutMs), wrote, () => idle.signal.reason?.noText ? firstMs : 0); }
210
+ finally { clearTimeout(timer); if (firstTimer) clearTimeout(firstTimer); }
198
211
  }
199
212
 
200
- async function readStream(body, onDelta, cfg, signal, sig, alive) {
213
+ async function readStream(body, onDelta, cfg, signal, sig, alive, wrote = () => {}, noTextFor = () => 0) {
201
214
  let res;
202
215
  try {
203
216
  res = await fetch(`${cfg.url}/chat/completions`, {
@@ -249,6 +262,7 @@ async function readStream(body, onDelta, cfg, signal, sig, alive) {
249
262
  if (ev.error) throw new LLMError(`LLM falhou no meio da resposta: ${ev.error.message || JSON.stringify(ev.error)}`,{status:Number(ev.error.status||ev.error.code)||undefined,code:/upstream.*terminated.*stream|provider_unavailable/i.test(ev.error.message||'')?'AI_PROVIDER_INTERRUPTED':undefined});
250
263
  const piece = ev.choices?.[0]?.delta?.content;
251
264
  if (piece) {
265
+ wrote();
252
266
  text += piece;
253
267
  onDelta(piece, text);
254
268
  }
@@ -260,8 +274,8 @@ async function readStream(body, onDelta, cfg, signal, sig, alive) {
260
274
  } catch (e) {
261
275
  if (e instanceof LLMError) throw e;
262
276
  if (signal?.aborted) throw new LLMError("Parado a pedido.", { cause: e, aborted: true });
263
- const why = e.name === "TimeoutError" ? `nada chegou em ${cfg.timeoutMs / 1000} s` : e.message;
264
- throw new LLMError(`A resposta do LLM foi interrompida (${why}).`, { cause: e });
277
+ const why = noTextFor() ? `${noTextFor() / 1000} s pensando sem escrever nada` : e.name === "TimeoutError" ? `nada chegou em ${cfg.timeoutMs / 1000} s` : e.message;
278
+ throw new LLMError(`A resposta do LLM foi interrompida (${why}).`, { cause: e, ...(noTextFor() ? { code: "AI_THINKING_LOOP" } : {}) });
265
279
  }
266
280
  return { text, images: [], usage, model, finishReason };
267
281
  }
package/src/ai/quality.js CHANGED
@@ -4,9 +4,41 @@ 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
+ export function auditText(spec, indices) {
15
+ const issues = [];
16
+ const slides = spec.slides || [];
17
+ for (const index of indices) {
18
+ const s = slides[index]; if (!s || !ONE_LINER.has(s.layout)) continue;
19
+ const n = words(oneLinerText(s));
20
+ const limit = s.layout === 'quote' ? 40 : 16;
21
+ 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.` });
22
+ }
23
+ // título encolhido à mão (titleSize pequeno) para "dar espaço à figura": o deck fica com cada slide num tamanho de
24
+ // título; o layout da figura já reserva a área. Vale para os layouts com cabeçalho comum (não capa/seção/manchete).
25
+ for (const index of indices) {
26
+ const s = slides[index];
27
+ if (!s || !Number.isFinite(Number(s.titleSize)) || ONE_LINER.has(s.layout) || ['cover', 'section', 'end', 'kinetic', 'scenography', 'full'].includes(s.layout)) continue;
28
+ 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.` });
29
+ }
30
+ // muitas telas de frase solta: a partir da terceira (ou de 15% do deck), cada uma precisa justificar o lugar
31
+ const singles = slides.map((s, i) => (ONE_LINER.has(s.layout) ? i : -1)).filter(i => i >= 0);
32
+ const allowed = Math.max(2, Math.round(slides.length * 0.15));
33
+ for (const index of singles.slice(allowed)) if (indices.includes(index) && !issues.some(x => x.slide === index + 1))
34
+ 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.` });
35
+ return issues;
36
+ }
37
+
7
38
  export async function reviewExperience(spec, indices, { snapshot, complete = chat, onProgress, signal, briefing = '' } = {}) {
8
39
  const issues = [], unchecked = [], failures = [];
9
40
  const selected = [...new Set(indices)].filter(index => spec.slides[index]);
41
+ issues.push(...auditText(spec, selected));
10
42
  for (const [position, index] of selected.entries()) {
11
43
  signal?.throwIfAborted();
12
44
  const slide = spec.slides[index];
package/src/build.js CHANGED
@@ -10,7 +10,7 @@ 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
14
  import { applyVisualEdits } from "./visual-edits.js";
15
15
  import { el, imageSrc } from "./elements.js";
16
16
  import { iconSVG } from "./figures/icons.js";
@@ -38,6 +38,7 @@ const drawIcon = (name, size = 20) => iconSVG(name, { size, stroke: 2 }).replace
38
38
  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
39
 
40
40
  export function inferLayout(s) {
41
+ if (fullAsFigure(s)) return "image"; // figura do documento em full: sem véu e sem sangrar (src/layouts.js)
41
42
  if (s.layout) return s.layout;
42
43
  if (s.elements) return "canvas";
43
44
  if (s.stats || s.kpis) return "stats";
package/src/elements.js CHANGED
@@ -23,7 +23,13 @@ export const SIZES = { hero: 210, number: 250, title: 128, h2: 92, h3: 56, quote
23
23
  const FACE = { hero: "display", number: "display", title: "display", h2: "display", h3: "heading", quote: "quote", lead: "body", body: "body", small: "body", label: "label", mono: "mono", tiny: "label" };
24
24
 
25
25
  const px = (v) => (v == null ? null : typeof v === "number" ? `${v}px` : String(v));
26
- const colorVal = (c) => (!c ? null : /^#?[0-9a-f]{6}$/i.test(c) ? `#${String(c).replace("#", "")}` : `var(--${c})`);
26
+ const colorVal = (c) => {
27
+ if (!c) return null;
28
+ if (/^#?[0-9a-f]{6}$/i.test(c)) return `#${String(c).replace("#", "")}`;
29
+ // paleta do tema é namespaced (--c-c1…): nome pelado ("c2") viraria var indefinida = preto
30
+ if (/^c[1-9]\d*$/.test(c) || /^(accent|alert|ink|paper)$/.test(c)) return `var(--c-${c})`;
31
+ return `var(--${c})`;
32
+ };
27
33
  // cor de texto: "hi" (cor de destaque) vira a versão legível sobre o fundo (--hi-ink, src/themes.js)
28
34
  const textColorVal = (c) => (c === "hi" ? "var(--hi-ink,var(--hi))" : colorVal(c));
29
35
 
@@ -104,16 +110,26 @@ export function figureHTML(el, ctx, w, h) {
104
110
  if (!src) return `<div${attrs(el, "fig fig-pending fig-missing")} role="img" aria-label="Imagem não encontrada: ${esc(el.image)}"><div class="fp-in"><span class="fp-tag f-label">imagem não encontrada</span><span class="fp-text f-body">${esc(el.image)}</span></div></div>`;
105
111
  const flip = el.flipH || el.flipV ? `transform:scale(${el.flipH ? -1 : 1},${el.flipV ? -1 : 1});` : "";
106
112
  // recorte (crop: l/t/r/b em fração do lado, como o srcRect do PowerPoint): a imagem cresce e a caixa corta
113
+ // A janela é expandida (nunca encolhida) até a proporção da caixa: posicionar por % com
114
+ // proporções diferentes ESTICA a imagem (print de página retrato espremido na caixa paisagem).
107
115
  if (el.crop) {
108
- const c = { l: +el.crop.l || 0, t: +el.crop.t || 0, r: +el.crop.r || 0, b: +el.crop.b || 0 };
116
+ const grown = matchCropAspect(el.crop, imageSize(el.image, ctx), w, h);
117
+ const c = { l: +grown.l || 0, t: +grown.t || 0, r: +grown.r || 0, b: +grown.b || 0 };
109
118
  const fw = Math.max(0.01, 1 - c.l - c.r), fh = Math.max(0.01, 1 - c.t - c.b);
110
- 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>`;
119
+ // 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
120
+ // tamanho aqui, janela que não pôde crescer), encaixa a região inteira sem esticar (src/runtime/fit.js: fitCrops)
121
+ 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>`;
111
122
  }
112
123
  // sem fit escolhido: preenche (cover), mas quando a proporção da imagem é muito diferente da caixa (fórmula, esquema
113
124
  // largo numa coluna alta; gráfico com eixo e legenda na borda) cortar perderia informação: passou de 1,25× (corte de
114
125
  // mais de 20%), cabe inteira (contain). Sem o tamanho da caixa aqui (figura do split, do layout), a mesma conta é feita
115
126
  // na apresentação, com a caixa medida (data-autofit; src/runtime/fit.js: fitImages)
116
127
  let fit = el.fit;
128
+ // figura recortada do documento (mapa, gráfico, tabela do paper) é evidência: nunca deforma nem perde borda.
129
+ // fill/stretch achatavam o mapa na caixa do slide, e o cover automático (até 20% de corte) comia eixo, legenda e
130
+ // escala; vira contain (inteira, na proporção do original)
131
+ const docImage = /contexto\/visuais\//.test(String(el.image || "").replace(/\\/g, "/"));
132
+ if (docImage && (!fit || /^(fill|stretch|none|cover)$/i.test(String(fit)))) fit = "contain";
117
133
  if (!fit) {
118
134
  const sz = w && h ? imageSize(el.image, ctx) : null;
119
135
  const box = w && h ? w / h : 0, img = sz && sz.h ? sz.w / sz.h : 0;
@@ -132,11 +148,37 @@ export function figureHTML(el, ctx, w, h) {
132
148
  if (pct) { box = { ...el }; delete box.w; }
133
149
  }
134
150
  }
135
- 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>`;
151
+ 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>`;
136
152
  }
137
153
  return "";
138
154
  }
139
155
 
156
+ // Expande a janela de recorte {l,t,r,b} (fração) até a proporção boxW/boxH, sem nunca
157
+ // encolher o pedido: cresce para os lados vizinhos (respeitando 0..1). Sem w/h da caixa
158
+ // ou sem tamanho do arquivo, devolve como veio.
159
+ export function matchCropAspect(crop, img, boxW, boxH) {
160
+ const out = { l: +crop?.l || 0, t: +crop?.t || 0, r: +crop?.r || 0, b: +crop?.b || 0 };
161
+ if (!img?.w || !img?.h || !boxW || !boxH) return out;
162
+ const fw = 1 - out.l - out.r, fh = 1 - out.t - out.b;
163
+ if (fw <= 0 || fh <= 0) return out;
164
+ const boxAspect = boxW / boxH, regionAspect = (fw * img.w) / (fh * img.h);
165
+ if (Math.abs(regionAspect - boxAspect) / boxAspect < 0.03) return out;
166
+ if (regionAspect > boxAspect) {
167
+ // região mais larga que a caixa: cresce em cima/embaixo
168
+ let need = (fw * img.w) / (boxAspect * img.h) - fh;
169
+ const takeTop = Math.min(out.t, need);
170
+ out.t -= takeTop; need -= takeTop;
171
+ out.b = Math.max(0, out.b - need);
172
+ } else {
173
+ // região mais alta que a caixa: cresce nas laterais
174
+ let need = ((fh * img.h) * boxAspect) / img.w - fw;
175
+ const takeLeft = Math.min(out.l, need);
176
+ out.l -= takeLeft; need -= takeLeft;
177
+ out.r = Math.max(0, out.r - need);
178
+ }
179
+ return out;
180
+ }
181
+
140
182
  // largura × altura do arquivo de imagem (cabeçalho de PNG, JPEG, GIF, WebP) ou null
141
183
  export function imageSize(p, ctx) {
142
184
  try {
@@ -339,8 +381,31 @@ export function importedTable(e) {
339
381
  return `<div${attrs(e, "tbx-table")}><table style="border-collapse:collapse;table-layout:fixed;width:100%"><colgroup>${cols}</colgroup>${rows}</table></div>`;
340
382
  }
341
383
 
384
+ // O contador reescreve o texto no navegador (textContent): marcação de expoente/índice no prefixo/sufixo ("km^2^",
385
+ // "CO~2~") vira o algarismo sobrescrito/subscrito do Unicode, que sobrevive ao textContent (saía "km^2^" cru).
386
+ const SUP = { 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹", "-": "⁻", "+": "⁺" };
387
+ const SUB = { 0: "₀", 1: "₁", 2: "₂", 3: "₃", 4: "₄", 5: "₅", 6: "₆", 7: "₇", 8: "₈", 9: "₉", "-": "₋", "+": "₊" };
388
+ 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(""));
389
+ // Valor do contador escrito como texto ("37,67%", "1.403", "R$ 2,5 mi", "1521 km^2^"): Number() dava NaN e o slide
390
+ // mostrava "NaN". Separa o que vem antes/depois (prefixo/sufixo) e lê o número no formato brasileiro (milhar com
391
+ // ponto, decimal com vírgula); as casas decimais escritas valem. Sem número nenhum, o texto aparece como está.
392
+ export function parseCounterValue(raw) {
393
+ if (typeof raw === "number") return { value: raw };
394
+ const m = /^(\D*?)(-?\d[\d.,]*)(.*)$/s.exec(String(raw ?? "").trim());
395
+ if (!m) return null;
396
+ let num = m[2].replace(/[.,]$/, ""), dec = 0;
397
+ if (num.includes(",")) { dec = num.split(",").pop().length; num = num.replace(/\./g, "").replace(",", "."); }
398
+ else if (/^-?\d{1,3}(\.\d{3})+$/.test(num)) num = num.replace(/\./g, "");
399
+ else if (num.includes(".")) dec = num.split(".").pop().length;
400
+ const value = Number(num);
401
+ 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;
402
+ }
403
+
342
404
  export function counter(e) {
343
- const v = Number(e.counter);
405
+ const parsed = parseCounterValue(e.counter);
406
+ 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>`;
407
+ e = { ...e, prefix: unicodeScripts(`${e.prefix ?? ""}${parsed.prefix ?? ""}`), suffix: unicodeScripts(`${parsed.suffix ?? ""}${e.suffix ?? ""}`), decimals: e.decimals ?? (typeof e.counter === "number" ? undefined : parsed.dec) };
408
+ const v = parsed.value;
344
409
  const dec = e.decimals ?? (v % 1 ? 1 : 0);
345
410
  const shown = v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec });
346
411
  const size = e.size || SIZES[e.as || "number"];
@@ -412,7 +477,7 @@ export function cards(e, ctx) {
412
477
  ${badgeHtml}
413
478
  </div>
414
479
  ${c.title ? `<div class="cd-title t f-heading">${md(c.title)}</div>` : ""}
415
- ${c.text ? `<div class="cd-text t f-body">${md(c.text)}</div>` : ""}
480
+ ${c.text ? `<div class="cd-text t f-body${/^\s*\$\$?[^$]+\$\$?\s*$/.test(String(c.text)) ? " cd-math" : ""}">${md(c.text)}</div>` : ""}
416
481
  ${extraWidgets}
417
482
  ${c.foot ? `<div class="cd-foot t f-label">${md(c.foot)}</div>` : ""}
418
483
  ${c.goto != null && c.goto !== "" ? `<span class="goto-mark" aria-hidden="true">${iconSVG("arrow-up-right", { size: 30, stroke: 2 })}</span>` : ""}
@@ -441,7 +506,7 @@ export function stats(e, ctx) {
441
506
  ${iconHtml}
442
507
  ${trendHtml}
443
508
  </div>
444
- <div class="st-val t f-display" style="${valColor}">${esc(String(st.value ?? st.stat ?? ""))}</div>
509
+ <div class="st-val t f-display" style="${valColor}">${md(String(st.value ?? st.stat ?? ""))}</div>
445
510
  ${st.label ? `<div class="st-lab t f-heading">${md(st.label)}</div>` : ""}
446
511
  ${st.text ? `<div class="st-sub t f-body">${md(st.text)}</div>` : ""}
447
512
  </div>`;
@@ -15,7 +15,13 @@ import { esc } from "../markup.js";
15
15
  import { iconSVG } from "./icons.js";
16
16
  import { humanBody } from "./pictos.js";
17
17
 
18
- const cvar = (c, d) => (!c ? `var(--${d})` : /^#?[0-9a-f]{6}$/i.test(c) ? `#${String(c).replace("#", "")}` : `var(--${c})`);
18
+ const cvar = (c, d) => {
19
+ if (!c) return `var(--${d})`;
20
+ if (/^#?[0-9a-f]{6}$/i.test(c)) return `#${String(c).replace("#", "")}`;
21
+ // paleta do tema é namespaced (--c-c1…): nome pelado ("c3") viraria var indefinida = preto
22
+ if (/^c[1-9]\d*$/.test(c) || /^(accent|alert|ink|paper)$/.test(c)) return `var(--c-${c})`;
23
+ return `var(--${c})`;
24
+ };
19
25
  const fmt = (v, o) => {
20
26
  if (v == null || isNaN(v)) return "";
21
27
  const d = o.decimals ?? (Math.abs(v) < 10 && v % 1 ? 1 : 0);
@@ -14,8 +14,19 @@ const COLOR_MAP = {
14
14
  branco: "FFFFFF",
15
15
  };
16
16
 
17
- export function normalizeSpec(rawSpec) {
18
- if (!rawSpec || typeof rawSpec !== "object") return rawSpec;
17
+ // Vírgula em valor de fluxo (`text: 3,5 peixes…` dentro de [{...}]) que o YAML partiu em
18
+ // {text: 3, "5 peixes…": null}: junta de volta. Chave nula começando com dígito nunca é intencional.
19
+ function fixCommaSplit(o) {
20
+ if (!o || typeof o !== "object" || Array.isArray(o)) return;
21
+ for (const k of Object.keys(o)) {
22
+ if (o[k] === null && /^\d/.test(k)) {
23
+ const target = ["text", "title", "label", "when"].find((f) => typeof o[f] === "number" || typeof o[f] === "string");
24
+ if (target) { o[target] = `${o[target]},${k}`; delete o[k]; }
25
+ }
26
+ }
27
+ }
28
+
29
+ export function normalizeSpec(rawSpec) { if (!rawSpec || typeof rawSpec !== "object") return rawSpec;
19
30
  const spec = JSON.parse(JSON.stringify(rawSpec));
20
31
 
21
32
  // 1. Normalização no nível da raiz
@@ -100,6 +111,12 @@ export function normalizeSpec(rawSpec) {
100
111
  });
101
112
  }
102
113
 
114
+ // Vírgula em valor de fluxo YAML (`text: 3,5 peixes…` dentro de [{...}]) quebra em
115
+ // {text: 3, "5 peixes…": null}: junta de volta antes que vire dado truncado no slide.
116
+ for (const f of ["events", "steps", "items"]) {
117
+ if (Array.isArray(s[f])) s[f].forEach(fixCommaSplit);
118
+ }
119
+
103
120
  // Cores comuns em português
104
121
  if (s.color && COLOR_MAP[s.color.toLowerCase()]) {
105
122
  s.color = COLOR_MAP[s.color.toLowerCase()];
@@ -45,14 +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
- c.getContext("2d").drawImage(img, x, y, w, h, 0, 0, w, h);
52
- return c.toDataURL("image/png");
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
+ let ink = 0;
54
+ try {
55
+ const d = g.getImageData(0, 0, w, h).data;
56
+ for (let i = 0; i < d.length; i += 16) { if ((d[i] + d[i + 1] + d[i + 2]) / 3 < 235) ink++; }
57
+ ink /= d.length / 16;
58
+ } catch { ink = 1; }
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 } };
53
79
  });
54
- }, [data, list.map(({ x, y, w, h }) => ({ x, y, w, h }))]);
55
- outs.forEach((u, k) => { fs.writeFileSync(list[k].dst, Buffer.from(u.split(",")[1], "base64")); 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); });
56
82
  }
57
83
  } finally { await browser.close(); }
58
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);},
@@ -124,9 +138,11 @@ export const LAYOUTS = {
124
138
  },
125
139
 
126
140
  statement(s, ctx) {
127
- // o tamanho pelo comprimento: frase curta é manchete; parágrafo inteiro em letra de título ocupava a tela toda
128
- const words = String(s.text || (s.lines || []).map((l) => (typeof l === "string" ? l : l?.text || "")).join(" ")).split(/\s+/).filter(Boolean).length;
129
- const role = s.as || (words > 45 ? "lead" : words > 22 ? "h2" : "title");
141
+ // o tamanho pelo comprimento EM CARACTERES: contar palavras falhava com palavra longa
142
+ // e frase de 100+ caracteres em destaque grande ocupava a tela (headline é para frase curta).
143
+ // `as` explícito vence.
144
+ const chars = String(s.text || (s.lines || []).map((l) => (typeof l === "string" ? l : l?.text || "")).join(" ")).length;
145
+ const role = s.as || (chars > 100 ? "lead" : chars > 60 ? "h2" : "title");
130
146
  const lines = s.lines
131
147
  ? s.lines.map((l, i) => {
132
148
  const o = typeof l === "string" ? { text: l } : l;
@@ -437,6 +453,7 @@ export const LAYOUTS = {
437
453
  },
438
454
 
439
455
  image(s, ctx) {
456
+ if (typeof s.figure === "string") s = { ...s, figure: { image: s.figure } }; // full com figure: caminho (fullAsFigure)
440
457
  // figura inteira à vista (fit: contain: gráfico, tabela, esquema): a legenda vai embaixo, sem cobrir; foto (cover)
441
458
  // segue sangrando, com o cartão por cima
442
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/'));
@@ -534,6 +551,7 @@ export const LAYOUTS = {
534
551
  const f = s.figure ?? (s.image || s.image_prompt ? { image: s.image, image_prompt: s.image_prompt } : null);
535
552
  const fig = f == null ? "" : typeof f === "object" ? { fit: s.fit || "cover", ...f } : { image: f, fit: s.fit || "cover" };
536
553
  const hasText = s.title || s.caption || s.kicker;
554
+ if (fullAsFigure(s)) return LAYOUTS.image({ ...s, figure: { ...fig, fit: "contain" }, fit: undefined }, ctx);
537
555
  const pos = s.overlay || (hasText ? "bottom" : "none");
538
556
  // figura desenhada (gráfico, ícone, diagrama) com texto à esquerda: vai para a direita em vez de ficar por baixo do texto
539
557
  const drawn = fig && !(fig.image || fig.image_prompt || fig.video);
@@ -543,7 +561,11 @@ export const LAYOUTS = {
543
561
 
544
562
  // Manchete: uma frase enorme que ocupa o slide (encolhe para caber)
545
563
  headline(s, ctx) {
546
- 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) })}
547
569
  ${s.caption ? text(s.caption, "lead", { class: "hl-cap muted e", style: "--d:2;" }) : ""}</div>${src(s)}${add(s, ctx)}`;
548
570
  },
549
571
 
@@ -247,6 +247,9 @@ code.f-mono{font-size:.86em;background:color-mix(in srgb,var(--fg) 9%,transparen
247
247
  .cd-num{font-size:110px;line-height:.9}
248
248
  .cd-title{font-size:52px}
249
249
  .cd-text{font-size:33px;color:var(--muted)}
250
+ /* cartão cujo texto é só uma fórmula: a fórmula é o conteúdo, não nota de rodapé (no tamanho do texto ela sumia) */
251
+ .cd-text.cd-math{font-size:52px;color:var(--fg);margin:.15em 0}
252
+ .card-hi .cd-text.cd-math{color:var(--on-hi)}
250
253
  .card-hi .cd-text{color:var(--on-hi)}
251
254
  .cd-foot{margin-top:auto;font-size:20px;color:var(--muted)}
252
255
 
@@ -439,16 +442,16 @@ code.f-mono{font-size:.86em;background:color-mix(in srgb,var(--fg) 9%,transparen
439
442
  .av-perigo .av-icon{color:var(--alert)}
440
443
  .av-perigo .av-title{text-transform:uppercase;letter-spacing:.04em}
441
444
 
442
- .tl{position:relative;display:grid;grid-template-columns:repeat(var(--n),1fr);gap:40px;flex:1;align-content:center}
445
+ .tl{position:relative;display:grid;grid-template-columns:repeat(var(--n),1fr);gap:40px;flex:1;align-content:center;min-height:520px}
443
446
  .tl-line{position:absolute;left:0;right:0;top:50%;height:6px;background:var(--line);border-radius:3px;display:none}
444
- .tl-ev{position:relative;display:flex;flex-direction:column;gap:16px;padding-top:62px}
447
+ .tl-ev{position:relative;display:flex;flex-direction:column;gap:20px;padding-top:62px;min-width:0}
445
448
  .tl-dot{position:absolute;top:0;left:0;width:44px;height:44px;border-radius:50%;background:var(--bg);border:8px solid var(--fg)}
446
449
  .tl-ev::before{content:"";position:absolute;left:44px;right:-40px;top:19px;height:6px;background:var(--line)}
447
450
  .tl-ev:last-child::before{display:none}
448
451
  .tl-ev.hl .tl-dot{background:var(--hi)}
449
- .tl-when{font-size:96px;line-height:.9}
452
+ .tl-when{font-size:calc(max(54px, 96px - max(0px, (var(--n, 3) - 3) * 13px)));line-height:.95;overflow-wrap:anywhere}
450
453
  .tl-ev.hl .tl-when{color:var(--em)}
451
- .tl-title{font-size:42px}
454
+ .tl-title{font-size:42px;overflow-wrap:anywhere}
452
455
  .tl-text{font-size:30px;color:var(--muted)}
453
456
  .tl-tag{font-size:20px;color:var(--em)}
454
457
  .tl-after{margin-top:auto}
@@ -225,6 +225,24 @@
225
225
  });
226
226
  }
227
227
 
228
+ // Recorte (crop): a região vira a caixa por porcentagem, o que só é proporcional quando a caixa tem a proporção da
229
+ // região. Medida a caixa de verdade, se não bater (mais de 3%), a região inteira é encaixada no centro (como
230
+ // contain), em vez de esticar a figura na largura ou na altura.
231
+ function fitCrops(root) {
232
+ root.querySelectorAll(".fig-crop img[data-crop]").forEach((img) => {
233
+ if (!img.naturalWidth) { if (!img.dataset.cropWait) { img.dataset.cropWait = "1"; img.addEventListener("load", () => fitCrops(img.parentElement || img), { once: true }); } return; }
234
+ const box = img.parentElement, W = box.clientWidth, H = box.clientHeight;
235
+ if (!W || !H) return;
236
+ const [l, t, r, b] = img.dataset.crop.split(",").map(Number);
237
+ const fw = Math.max(0.01, 1 - l - r), fh = Math.max(0.01, 1 - t - b);
238
+ const region = (fw * img.naturalWidth) / (fh * img.naturalHeight);
239
+ if (Math.abs(region - W / H) / (W / H) < 0.03) return;
240
+ const rw = region > W / H ? W : H * region, rh = rw / region; // a região inteira, sem esticar
241
+ const iw = rw / fw, ih = rh / fh;
242
+ Object.assign(img.style, { width: `${iw}px`, height: `${ih}px`, left: `${(W - rw) / 2 - l * iw}px`, top: `${(H - rh) / 2 - t * ih}px` });
243
+ });
244
+ }
245
+
228
246
  // Passos do exercício resolvido (solution): a coluna é alinhada pelo fim (o passo novo embaixo) e o que sobra vaza por
229
247
  // cima, apagando. Antes de cortar, encolhe (até 60%) para os passos caberem inteiros. Quadro escondido não mede: o
230
248
  // runtime chama de novo a cada clique.
@@ -257,6 +275,7 @@
257
275
  function fitAllIn(root) {
258
276
  fitStages(root);
259
277
  fitImages(root);
278
+ fitCrops(root);
260
279
  fitSteps(root);
261
280
  fitChartText(root);
262
281
  fitCode(root);
@@ -264,5 +283,5 @@
264
283
  fitText(root);
265
284
  }
266
285
 
267
- g.SagadeckFit = { fitText: fitAllIn, fitChartText, fitCode, fitWide, fitImages, fitSteps, fitStages, shrink };
286
+ g.SagadeckFit = { fitText: fitAllIn, fitChartText, fitCode, fitWide, fitImages, fitCrops, fitSteps, fitStages, shrink };
268
287
  })(typeof window !== "undefined" ? window : globalThis);
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import crypto from "node:crypto";
7
7
  import { loadSpec } from "../build.js";
8
8
  import { writeRecording } from "../api-client.js";
9
- import { editDeck } from "../ai/deck-ai.js";
9
+ import { editDeck, coverDocumentVisuals, checkNumbers } from "../ai/deck-ai.js";
10
10
  import { reviewExperience } from "../ai/quality.js";
11
11
  import { maybeResearch } from "../research/chat-research.js";
12
12
  import { chatErrorResult } from "./errors.js";
@@ -96,6 +96,25 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
96
96
  styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
97
97
  });
98
98
  signal.throwIfAborted();
99
+ // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
100
+ // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
101
+ // 5") não dispara: mexe em poucos slides.
102
+ const attachedDocs = (Array.isArray(body.attachments) ? body.attachments : []).some((a) => a && typeof a === "object" && a.type === "doc");
103
+ if (attachedDocs && !result.talk && !result.transform && !result.style && !result.variants && result.spec?.slides) {
104
+ const rewritten = Array.isArray(result.changed) ? result.changed.length : 0;
105
+ if (rewritten >= Math.max(5, result.spec.slides.length / 2)) {
106
+ const covered = await coverDocumentVisuals(result.spec, { materials, briefing: prompt, say: (s) => emit({ phase: "step", text: s }),
107
+ edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
108
+ signal.throwIfAborted();
109
+ if (covered.spec !== result.spec) { result.spec = covered.spec; result.actions = [...(result.actions || []), ...covered.actions]; }
110
+ if (covered.coverage?.missing.length) result.actions = [...(result.actions || []), `Sem slide (figuras/tabelas do material): ${covered.coverage.missing.join("; ")}`];
111
+ const checked = await checkNumbers(result.spec, { materials, say: (s) => emit({ phase: "step", text: s }),
112
+ edit: (o) => editDeck({ signal, images: true, imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)), onProgress: emit, materials, drawCheck: diagramCheck, ...o }) });
113
+ signal.throwIfAborted();
114
+ if (checked.spec !== result.spec) { result.spec = checked.spec; result.actions = [...(result.actions || []), ...checked.actions]; }
115
+ if (checked.facts?.unsupported.length) result.actions = [...(result.actions || []), `Números sem base no material: ${checked.facts.unsupported.map((b) => `${b.number} (slide ${b.slide})`).join(", ")}`];
116
+ }
117
+ }
99
118
  if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
100
119
  // a IA decidiu transformar a apresentação inteira (transform:): o trabalho em etapas, com o andamento aqui
101
120
  if (result.transform) {
@@ -39,6 +39,9 @@ export async function slideSnapshots(spec, index, { mode = "final", maxFrames =
39
39
  await page.emulateMedia({ reducedMotion: 'reduce' });
40
40
  await page.goto(`${pathToFileURL(file).href}#${index + 1}`, { waitUntil: "load" });
41
41
  await page.addStyleTag({ content: '#hud,#laser,.draw-fab,.draw-toolbar,#draw-canvas{display:none!important}' });
42
+ // Fotos para conferência (e exportação estática): animações de gráfico concluídas, senão o instantâneo
43
+ // pega linhas no meio do traço e a revisão acusa "linhas faltando" que o usuário nunca veria.
44
+ await page.addStyleTag({ content: '.chart .draw,.chart .fade-in,.chart .pop,.chart .gx,.chart .gy,.chart .arc{animation:none!important;opacity:1!important;transform:none!important}.chart .draw{stroke-dashoffset:0!important}' });
42
45
  await page.waitForFunction(() => window.sagadeck && typeof window.sagadeck.goto === "function", null, { timeout: 10000 });
43
46
  await page.evaluate(() => window.SagaScienceReady);
44
47
  await page.evaluate(() => window.SagaDiagramsReady);