sagadeck 1.3.1 → 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/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
@@ -116,13 +116,20 @@ export function figureHTML(el, ctx, w, h) {
116
116
  const grown = matchCropAspect(el.crop, imageSize(el.image, ctx), w, h);
117
117
  const c = { l: +grown.l || 0, t: +grown.t || 0, r: +grown.r || 0, b: +grown.b || 0 };
118
118
  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>`;
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>`;
120
122
  }
121
123
  // sem fit escolhido: preenche (cover), mas quando a proporção da imagem é muito diferente da caixa (fórmula, esquema
122
124
  // largo numa coluna alta; gráfico com eixo e legenda na borda) cortar perderia informação: passou de 1,25× (corte de
123
125
  // mais de 20%), cabe inteira (contain). Sem o tamanho da caixa aqui (figura do split, do layout), a mesma conta é feita
124
126
  // na apresentação, com a caixa medida (data-autofit; src/runtime/fit.js: fitImages)
125
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";
126
133
  if (!fit) {
127
134
  const sz = w && h ? imageSize(el.image, ctx) : null;
128
135
  const box = w && h ? w / h : 0, img = sz && sz.h ? sz.w / sz.h : 0;
@@ -141,7 +148,7 @@ export function figureHTML(el, ctx, w, h) {
141
148
  if (pct) { box = { ...el }; delete box.w; }
142
149
  }
143
150
  }
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>`;
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>`;
145
152
  }
146
153
  return "";
147
154
  }
@@ -374,8 +381,31 @@ export function importedTable(e) {
374
381
  return `<div${attrs(e, "tbx-table")}><table style="border-collapse:collapse;table-layout:fixed;width:100%"><colgroup>${cols}</colgroup>${rows}</table></div>`;
375
382
  }
376
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
+
377
404
  export function counter(e) {
378
- 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;
379
409
  const dec = e.decimals ?? (v % 1 ? 1 : 0);
380
410
  const shown = v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec });
381
411
  const size = e.size || SIZES[e.as || "number"];
@@ -447,7 +477,7 @@ export function cards(e, ctx) {
447
477
  ${badgeHtml}
448
478
  </div>
449
479
  ${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>` : ""}
480
+ ${c.text ? `<div class="cd-text t f-body${/^\s*\$\$?[^$]+\$\$?\s*$/.test(String(c.text)) ? " cd-math" : ""}">${md(c.text)}</div>` : ""}
451
481
  ${extraWidgets}
452
482
  ${c.foot ? `<div class="cd-foot t f-label">${md(c.foot)}</div>` : ""}
453
483
  ${c.goto != null && c.goto !== "" ? `<span class="goto-mark" aria-hidden="true">${iconSVG("arrow-up-right", { size: 30, stroke: 2 })}</span>` : ""}
@@ -476,7 +506,7 @@ export function stats(e, ctx) {
476
506
  ${iconHtml}
477
507
  ${trendHtml}
478
508
  </div>
479
- <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>
480
510
  ${st.label ? `<div class="st-lab t f-heading">${md(st.label)}</div>` : ""}
481
511
  ${st.text ? `<div class="st-sub t f-body">${md(st.text)}</div>` : ""}
482
512
  </div>`;
@@ -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);},
@@ -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
 
@@ -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
 
@@ -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) {