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/docs/ANDAMENTO.md +91 -0
- package/docs/REFERENCIA.md +3 -3
- package/package.json +1 -1
- package/src/ai/context.js +8 -1
- package/src/ai/deck-ai.js +106 -3
- package/src/ai/document-materials.js +2 -2
- package/src/ai/document-visuals.js +551 -44
- package/src/ai/llm.js +19 -5
- package/src/ai/quality.js +32 -0
- package/src/build.js +2 -1
- package/src/elements.js +72 -7
- package/src/figures/charts.js +7 -1
- package/src/fiscal/normalize.js +19 -2
- package/src/import/crop.js +33 -7
- package/src/layouts.js +26 -4
- package/src/runtime/base.css +7 -4
- package/src/runtime/fit.js +20 -1
- package/src/studio/ai-chat-route.js +20 -1
- package/src/studio/snapshot.js +3 -0
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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}">${
|
|
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>`;
|
package/src/figures/charts.js
CHANGED
|
@@ -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) =>
|
|
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);
|
package/src/fiscal/normalize.js
CHANGED
|
@@ -14,8 +14,19 @@ const COLOR_MAP = {
|
|
|
14
14
|
branco: "FFFFFF",
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
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()];
|
package/src/import/crop.js
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
c.getContext("2d").drawImage(img, x, y, w, h, 0, 0, w, h);
|
|
52
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
package/src/runtime/base.css
CHANGED
|
@@ -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:
|
|
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:.
|
|
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}
|
package/src/runtime/fit.js
CHANGED
|
@@ -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) {
|
package/src/studio/snapshot.js
CHANGED
|
@@ -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);
|