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