sagadeck 1.8.0 → 1.10.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 +25 -8
- package/SKILL.md +10 -3
- package/bin/sagadeck.js +23 -0
- package/docs/ANDAMENTO.md +482 -7
- package/docs/PLANO-MELHORIAS.md +180 -0
- package/docs/REFERENCIA.md +178 -111
- package/package.json +35 -4
- package/src/ai/commands.js +2 -2
- package/src/ai/context.js +7 -2
- package/src/ai/deck-ai.js +4 -2
- package/src/ai/ia-check.js +127 -0
- package/src/ai/ia-config.js +2 -2
- package/src/ai/llm.js +5 -1
- package/src/ai/map-analysis-run.js +1 -1
- package/src/ai/quality.js +1 -1
- package/src/ai/video-generation.js +70 -25
- package/src/api-client.js +42 -10
- package/src/build.js +46 -14
- package/src/code-highlight.js +19 -2
- package/src/code-language.js +13 -0
- package/src/elements.js +39 -1
- package/src/export/pptx.js +7 -2
- package/src/export/shots.js +7 -2
- package/src/fiscal/normalize.js +19 -0
- package/src/fs-retry.js +37 -2
- package/src/import/office.js +5 -4
- package/src/layouts.js +104 -99
- package/src/lessons.js +1 -1
- package/src/library.js +7 -7
- package/src/map-analysis.js +4 -1
- package/src/map-config.js +10 -4
- package/src/map.js +21 -8
- package/src/mcp/server.js +17 -0
- package/src/motion.js +20 -6
- package/src/purpose.js +1 -1
- package/src/runtime/api-core.js +122 -11
- package/src/runtime/api-ui.js +2 -1
- package/src/runtime/base.css +88 -332
- package/src/runtime/fit.js +3 -2
- package/src/runtime/fonts/amatic-sc.css +3 -0
- package/src/runtime/fonts/bebas-neue.css +2 -0
- package/src/runtime/fonts/cabin-sketch.css +2 -0
- package/src/runtime/fonts/fraunces.css +3 -0
- package/src/runtime/fonts/gelasio.css +3 -0
- package/src/runtime/fonts/ibm-plex-mono.css +4 -0
- package/src/runtime/fonts/index.json +10 -1
- package/src/runtime/fonts/itim.css +2 -0
- package/src/runtime/fonts/montserrat.css +2 -0
- package/src/runtime/fonts/space-grotesk.css +2 -0
- package/src/runtime/map-background.js +91 -0
- package/src/runtime/map.js +41 -9
- package/src/runtime/runtime.js +81 -100
- package/src/runtime/skins/aurora-dev.css +39 -0
- package/src/runtime/skins/caderno.css +33 -0
- package/src/runtime/skins/cartaz.css +27 -0
- package/src/runtime/skins/infografico.css +29 -0
- package/src/runtime/skins/lousa.css +22 -0
- package/src/runtime/skins/musgo.css +42 -0
- package/src/runtime/skins/terminal-pro.css +33 -0
- package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
- package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
- package/src/runtime/vendor/geo-contornos.json +1 -1
- package/src/runtime/vendor/maplibre-gl.css +1 -0
- package/src/runtime/vendor/maplibre-gl.js +825 -0
- package/src/runtime/vendor/maplibre-leaflet.js +234 -0
- package/src/runtime/vendor/maplibre-worker.js +10 -0
- package/src/studio/ai-chat-route.js +26 -7
- package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
- package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
- package/src/studio/command-approval.js +2 -3
- package/src/studio/demo-decks.js +4 -2
- package/src/studio/developer-starters.js +46 -0
- package/src/studio/export-jobs.js +86 -0
- package/src/studio/exporting.js +36 -18
- package/src/studio/feature-demos.js +144 -0
- package/src/studio/ia-routes.js +9 -0
- package/src/studio/layout-samples.js +9 -18
- package/src/studio/library-routes.js +7 -9
- package/src/studio/project.js +1 -0
- package/src/studio/public/about.js +68 -0
- package/src/studio/public/ai-settings.js +2 -2
- package/src/studio/public/app.js +43 -52
- package/src/studio/public/chat.js +5 -1
- package/src/studio/public/developer-authoring.css +12 -0
- package/src/studio/public/developer-authoring.js +306 -0
- package/src/studio/public/export-status.js +154 -0
- package/src/studio/public/generation-guide.js +42 -0
- package/src/studio/public/index.html +31 -10
- package/src/studio/public/library.css +48 -3
- package/src/studio/public/library.html +5 -1
- package/src/studio/public/library.js +150 -41
- package/src/studio/public/map-data.js +7 -3
- package/src/studio/public/map-editor.js +625 -236
- package/src/studio/public/map-settings.js +3 -1
- package/src/studio/public/slide-form.js +27 -29
- package/src/studio/public/style.css +56 -9
- package/src/studio/public/ui-icons.js +1 -1
- package/src/studio/server.js +29 -14
- package/src/studio/snapshot.js +8 -4
- package/src/studio/template-collections.js +1 -1
- package/src/studio/tour.js +89 -7
- package/src/themes.js +202 -0
- package/templates/cenario-e-ciencia.yaml +1 -21
- package/templates/ensaio-api.yaml +3 -3
- package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
- package/templates/cenario/imagens/cidade.svg +0 -30
- package/templates/cenario/imagens/pessoa.svg +0 -15
package/src/api-client.js
CHANGED
|
@@ -362,12 +362,43 @@ export class ApiEnvironments {
|
|
|
362
362
|
return out.filter((s) => typeof s === "string" && s.length >= 6);
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
maskText(env, text) {
|
|
365
|
+
maskText(env, text, contextual = false) {
|
|
366
366
|
let s = String(text);
|
|
367
|
-
for (const secret of this.secrets(env)) s = s.split(secret).join(C.mask(secret));
|
|
367
|
+
if (!contextual) { for (const secret of this.secrets(env)) s = s.split(secret).join(C.mask(secret)); return s; }
|
|
368
|
+
// Pedidos importados codificam segredos em JSON/query/form. A resposta e o relatório podem
|
|
369
|
+
// devolver essas representações; nenhuma delas deve revelar o valor, mesmo quando curto.
|
|
370
|
+
const replacements = new Map();
|
|
371
|
+
for (const secret of [...this.secrets(env), ...Object.values(this.secretVars(env))].filter(Boolean)) {
|
|
372
|
+
const masked = C.mask(secret);
|
|
373
|
+
for (const [raw, safe] of [[secret, masked], [encodeURIComponent(secret), encodeURIComponent(masked)], [JSON.stringify(secret).slice(1, -1), JSON.stringify(masked).slice(1, -1)]]) if (!replacements.has(raw)) replacements.set(raw, safe);
|
|
374
|
+
}
|
|
375
|
+
for (const [raw, safe] of [...replacements].sort((a, b) => b[0].length - a[0].length)) s = s.split(raw).join(safe);
|
|
368
376
|
return s;
|
|
369
377
|
}
|
|
370
378
|
|
|
379
|
+
maskValue(env, value, depth = 0) {
|
|
380
|
+
if (typeof value === "string") {
|
|
381
|
+
const masked = this.maskText(env, value, true);
|
|
382
|
+
if (depth > 64 || !/^\s*[\[{\"]/.test(value) || Object.values(this.secretVars(env)).includes(value)) return masked;
|
|
383
|
+
try { JSON.parse(value); } catch { return masked; }
|
|
384
|
+
// APIs podem devolver JSON dentro de strings de logs. Decodifica cada string e mascara
|
|
385
|
+
// recursivamente; troca apenas esses spans para preservar números grandes e formatação.
|
|
386
|
+
let result = "", last = 0;
|
|
387
|
+
for (let at = 0; at < value.length; at++) {
|
|
388
|
+
if (value[at] !== '"') continue;
|
|
389
|
+
const from = at++;
|
|
390
|
+
while (at < value.length) { if (value[at] === "\\") at += 2; else if (value[at++] === '"') break; }
|
|
391
|
+
const decoded = JSON.parse(value.slice(from, at)), safe = this.maskValue(env, decoded, depth + 1);
|
|
392
|
+
if (safe !== decoded) { result += value.slice(last, from) + JSON.stringify(safe); last = at; }
|
|
393
|
+
at--;
|
|
394
|
+
}
|
|
395
|
+
return result + value.slice(last);
|
|
396
|
+
}
|
|
397
|
+
if (Array.isArray(value)) return value.map(item => this.maskValue(env, item, depth + 1));
|
|
398
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.maskValue(env, item, depth + 1)]));
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
|
|
371
402
|
async token(env, { renew = false } = {}) {
|
|
372
403
|
const t = env.token;
|
|
373
404
|
if (!t || !t.url) return null;
|
|
@@ -420,13 +451,13 @@ export class ApiEnvironments {
|
|
|
420
451
|
});
|
|
421
452
|
throw new ApiError(`Ambiente "${env.name}" não tem ${names.join(", ")} configurado em secrets:`, "config");
|
|
422
453
|
}
|
|
423
|
-
req = { ...req, url: C.
|
|
454
|
+
req = { ...req, url: C.renderURL(req.url, sv, req.urlEncoding), headers: C.render(req.headers || {}, sv), body: C.renderBody(req.body, sv, req.bodyEncoding) };
|
|
424
455
|
const headers = { Accept: "application/json, text/event-stream, */*", ...C.render(env.headers || {}, sv), ...(req.headers || {}) };
|
|
425
456
|
let body = req.body;
|
|
426
457
|
const file = req.file || null; // { name, type, data: Buffer } — o arquivo do slide (upload ou base64)
|
|
427
458
|
const needsFile = (req.form && Object.values(req.form).includes("@file")) || /\{\{\s*file\./.test(JSON.stringify(body ?? ""));
|
|
428
459
|
if (needsFile && !file) throw new ApiError("Este slide precisa de um arquivo: arraste um para o slide ou defina file: no YAML.", "config");
|
|
429
|
-
if (file && body != null) body = C.
|
|
460
|
+
if (file && body != null) body = C.renderBody(body, { "file.base64": file.data.toString("base64"), "file.name": file.name, "file.type": file.type || "application/octet-stream" }, req.bodyEncoding);
|
|
430
461
|
if (req.form) {
|
|
431
462
|
// multipart/form-data, montado à mão (sem dependências)
|
|
432
463
|
const boundary = "----sagadeck" + Math.random().toString(16).slice(2);
|
|
@@ -453,8 +484,9 @@ export class ApiEnvironments {
|
|
|
453
484
|
const tok = await this.token(env, { renew });
|
|
454
485
|
return tok ? { ...headers, [authHeader]: (t.prefix ?? "Bearer ") + tok } : headers;
|
|
455
486
|
};
|
|
456
|
-
const
|
|
457
|
-
|
|
487
|
+
const contextual = !!(req.bodyEncoding || req.urlEncoding);
|
|
488
|
+
const sent = (h) => ({ method, url: this.maskText(env, req.url, contextual), headers: contextual ? this.maskValue(env, h) : JSON.parse(this.maskText(env, JSON.stringify(h))), ...(file ? { file: { name: file.name, type: file.type, size: file.data.length } } : {}) });
|
|
489
|
+
return { env, method, url: req.url, body, withToken, sent, contextual, timeout: (Number(env.timeout) || 60) * 1000 };
|
|
458
490
|
}
|
|
459
491
|
|
|
460
492
|
// Pedido completo: a resposta volta inteira (JSON ou texto), com os segredos mascarados.
|
|
@@ -463,7 +495,7 @@ export class ApiEnvironments {
|
|
|
463
495
|
const run = async (renew) => {
|
|
464
496
|
const headers = await p.withToken(renew);
|
|
465
497
|
const t0 = Date.now();
|
|
466
|
-
const res = await open({ method: p.method, url:
|
|
498
|
+
const res = await open({ method: p.method, url: p.url, headers, body: p.body, ca: this.ca(p.env), insecure: !!p.env.insecure, timeout: p.timeout });
|
|
467
499
|
const buf = await readAll(res);
|
|
468
500
|
return { res, buf, headers, ms: Date.now() - t0 };
|
|
469
501
|
};
|
|
@@ -483,7 +515,7 @@ export class ApiEnvironments {
|
|
|
483
515
|
}
|
|
484
516
|
// áudio (TTS): vai inteiro, em base64, para o slide tocar
|
|
485
517
|
const body = /^audio\//i.test(type) ? { _audio: true, type: type.split(";")[0], base64: r.buf.toString("base64") }
|
|
486
|
-
: parseBody(Buffer.from(this.maskText(p.env, r.buf.toString("utf8"))), type);
|
|
518
|
+
: p.contextual ? this.maskValue(p.env, parseBody(r.buf, type)) : parseBody(Buffer.from(this.maskText(p.env, r.buf.toString("utf8"))), type);
|
|
487
519
|
return {
|
|
488
520
|
ok: r.res.statusCode < 400, status: r.res.statusCode, statusText: r.res.statusMessage || "", ms: r.ms, size: r.buf.length,
|
|
489
521
|
type, body, sent: p.sent(r.headers), env: p.env.name, token: this.tokenInfo(p.env.name), ...(jwt ? { jwt } : {}),
|
|
@@ -610,7 +642,7 @@ export class ApiEnvironments {
|
|
|
610
642
|
async open(req) {
|
|
611
643
|
const p = await this.prepare(req);
|
|
612
644
|
const headers = await p.withToken(false);
|
|
613
|
-
const res = await open({ method: p.method, url:
|
|
614
|
-
return { res, env: p.env, sent: p.sent(headers), mask: (s) => this.maskText(p.env, s) };
|
|
645
|
+
const res = await open({ method: p.method, url: p.url, headers, body: p.body, ca: this.ca(p.env), insecure: !!p.env.insecure, timeout: p.timeout });
|
|
646
|
+
return { res, env: p.env, sent: p.sent(headers), mask: (s) => this.maskText(p.env, s, p.contextual) };
|
|
615
647
|
}
|
|
616
648
|
}
|
package/src/build.js
CHANGED
|
@@ -8,9 +8,9 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import YAML from "yaml";
|
|
11
|
-
import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
|
|
11
|
+
import { resolveTheme, themeCSS, scopedThemeCSS, THEMES } from "./themes.js";
|
|
12
12
|
import { identityFor, identitiesFile } from "./identity.js";
|
|
13
|
-
import { LAYOUTS,
|
|
13
|
+
import { LAYOUTS, fullAsFigure } from "./layouts.js";
|
|
14
14
|
import { setDeckLocale } from "./locale.js";
|
|
15
15
|
import { applyVisualEdits } from "./visual-edits.js";
|
|
16
16
|
import { el, imageSrc } from "./elements.js";
|
|
@@ -24,6 +24,12 @@ import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
|
|
|
24
24
|
|
|
25
25
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
const read = (p) => fs.readFileSync(path.join(HERE, p), "utf8");
|
|
27
|
+
let mapBackgroundAssets;
|
|
28
|
+
const embeddedMapBackground = (spec, tiles) => {
|
|
29
|
+
if (!spec.slides.some((s) => s.layout === "map" && tiles?.[s.basemap || spec.defaults?.basemap]?.tipo === "vetorial")) return "";
|
|
30
|
+
mapBackgroundAssets ||= JSON.stringify(Object.fromEntries(Object.entries({ js: "maplibre-gl.js", css: "maplibre-gl.css", worker: "maplibre-worker.js", adapter: "maplibre-leaflet.js" }).map(([key, file]) => [key, fs.readFileSync(path.join(HERE, "runtime/vendor", file)).toString("base64")])));
|
|
31
|
+
return `<script type="application/json" id="map-background-assets">${mapBackgroundAssets}</script>`;
|
|
32
|
+
};
|
|
27
33
|
|
|
28
34
|
export function loadSpec(file) {
|
|
29
35
|
const src = fs.readFileSync(file, "utf8");
|
|
@@ -59,7 +65,7 @@ export function inferLayout(s) {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
const DEFAULT_TONE = { section: "accent", cover: "light", end: "dark" };
|
|
62
|
-
const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline"
|
|
68
|
+
const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline"]);
|
|
63
69
|
|
|
64
70
|
export function wordCount(s) {
|
|
65
71
|
const txt = [];
|
|
@@ -81,7 +87,11 @@ export function wordCount(s) {
|
|
|
81
87
|
// identidade do deck (fontes da empresa, do arquivo local; ver src/identity.js)
|
|
82
88
|
const identityOf = (spec) => (spec.identity ? identityFor(spec.identity) : null);
|
|
83
89
|
const slideTheme = (s, deckTheme, spec) => (s.theme || s.palette ? resolveTheme(s.theme || spec.theme, s.palette ?? spec.palette, identityOf(spec)) : deckTheme);
|
|
84
|
-
|
|
90
|
+
// tema irmão (skin: "musgo" no wine): usa a pele do outro, com o seletor trocado para o nome dele
|
|
91
|
+
const skinCSS = (name) => {
|
|
92
|
+
const base = THEMES[name]?.skin;
|
|
93
|
+
try { return base ? read(`runtime/skins/${base}.css`).replaceAll(`.th-${base}`, `.th-${name}`) : read(`runtime/skins/${name}.css`); } catch { return ""; }
|
|
94
|
+
};
|
|
85
95
|
export function deckThemeCSS(spec, deckTheme) {
|
|
86
96
|
const looks = new Map(), names = new Set([deckTheme.name]);
|
|
87
97
|
for (const raw of spec.slides || []) {
|
|
@@ -145,7 +155,7 @@ export function buildHTML(rawSpec, opts = {}) {
|
|
|
145
155
|
const layout = inferLayout(s);
|
|
146
156
|
const fn = LAYOUTS[layout];
|
|
147
157
|
if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe. Use: ${Object.keys(LAYOUTS).join(", ")}`);
|
|
148
|
-
const tone = s.tone ||
|
|
158
|
+
const tone = s.tone || DEFAULT_TONE[layout] || spec.tone || "light";
|
|
149
159
|
let inner;
|
|
150
160
|
const th = slideTheme(s, theme, spec), sctx = th === theme ? ctx : { ...ctx, theme: th };
|
|
151
161
|
try { inner = fn(s, sctx); } catch (e) { throw new Error(`Slide ${i + 1} (${layout}${s.title ? `: ${plain(s.title).slice(0, 40)}` : ""}): ${e.message}`); }
|
|
@@ -234,7 +244,7 @@ ${spec.slides.some((s) => s.layout === "diagram") ? `<script>${read("runtime/ven
|
|
|
234
244
|
${spec.slides.some(s => s.layout === "science") ? `<script>${read("runtime/vendor/plotly.min.js").replace(/<\/script/gi,"<\\/script")}</script><script>${read("runtime/formula.js")}</script><script>${read("runtime/science.js")}</script>` : ""}
|
|
235
245
|
${spec.slides.some(s => s.layout === "calc") ? `${spec.slides.some(s => s.layout === "science") ? "" : `<script>${read("runtime/formula.js")}</script>`}<script>${read("runtime/calc.js")}</script>` : ""}
|
|
236
246
|
${spec.slides.some(s=>s.layout==="portal") ? `<script>${read("runtime/portal-scene.js")}</script>` : ""}
|
|
237
|
-
${spec.slides.some((s) => s.layout === "map") ?
|
|
247
|
+
${spec.slides.some((s) => s.layout === "map") ? `${embeddedMapBackground(spec, mapTiles)}<style>${read("runtime/vendor/leaflet.css")}</style><script>${read("runtime/vendor/leaflet.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/map-background.js")}</script><script>${read("runtime/map.js")}</script>` : ""}
|
|
238
248
|
${JSON.stringify(spec.slides || []).includes('"points"') ? `<script>${read("runtime/points.js")}</script>` : ""}
|
|
239
249
|
${spec.slides.some(s=>s.layout==="playground") ? `<script>${read("runtime/playground.js")}</script>` : ""}
|
|
240
250
|
${hasApi ? `${apiScripts}\n<script>${read("runtime/api-ui.js")}</script><script>${read("runtime/api-collection-ui.js")}</script>` : ""}
|
|
@@ -258,21 +268,41 @@ export function setFitDefaults(d = {}) {
|
|
|
258
268
|
export const fitDefaults = () => ({ ...FIT_DEFAULTS });
|
|
259
269
|
|
|
260
270
|
// eco ambiente: camada viva discreta atrás do conteúdo (só no HTML; PPTX/PDF usam o quadro parado)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
// (rede: nós à deriva ligados por proximidade, num canvas animado pelo runtime; o tema pode pô-la sozinha na capa,
|
|
272
|
+
// coverAmbient, e ambient: false tira)
|
|
273
|
+
const AMBIENT = ["pontos", "grade", "rede"];
|
|
274
|
+
function ambientOf(s, i, ctx, theme, layout) {
|
|
275
|
+
if (s.ambient === false) return "";
|
|
276
|
+
if (s.ambient == null || s.ambient === "") return layout === "cover" && theme?.coverAmbient ? theme.coverAmbient : "";
|
|
264
277
|
if (!AMBIENT.includes(s.ambient)) { ctx.warnings?.push(`slide ${i + 1}: ambiente "${s.ambient}" desconhecido (vale: ${AMBIENT.join(", ")})`); return ""; }
|
|
265
278
|
return s.ambient;
|
|
266
279
|
}
|
|
280
|
+
// faixa que corre na capa: `ticker: [..]` no slide, ou automática nos temas com ticker (musgo), com os títulos das
|
|
281
|
+
// seções (sem seções suficientes, os dos slides). `ticker: false` desliga. Só no HTML; parada sem movimento/exportação.
|
|
282
|
+
function tickerOf(s, spec, theme, layout) {
|
|
283
|
+
if (s.ticker === false || layout !== "cover") return null;
|
|
284
|
+
const clean = (list) => [...new Set(list.map((x) => plain(String(x ?? "")).trim()).filter(Boolean))].slice(0, 16);
|
|
285
|
+
if (Array.isArray(s.ticker)) return clean(s.ticker);
|
|
286
|
+
if (!theme.ticker && s.ticker !== true) return null;
|
|
287
|
+
const others = (spec.slides || []).filter((x) => x && typeof x === "object");
|
|
288
|
+
let items = clean(others.filter((x) => inferLayout(x) === "section").map((x) => x.title || x.kicker));
|
|
289
|
+
if (items.length < 3) items = clean(others.filter((x) => !isCoverLayout(inferLayout(x))).map((x) => x.title || x.kicker));
|
|
290
|
+
return items.length ? items : null;
|
|
291
|
+
}
|
|
292
|
+
function tickerHTML(items) {
|
|
293
|
+
const run = items.map((t) => `<span class="tk-it t f-mono">${esc(t)}</span><span class="tk-sep"></span>`).join("");
|
|
294
|
+
const dur = Math.max(24, Math.round(items.join("").length * 0.22));
|
|
295
|
+
return `<div class="ticker" aria-hidden="true"><div class="tk-track" style="--tk-dur:${dur}s">${run}${run}</div></div>`;
|
|
296
|
+
}
|
|
267
297
|
// <section> de um slide: tom, textura, estilo do destaque, fundo, área, cabeçalho e rodapé.
|
|
268
298
|
// Única montagem para o Studio (renderSlide) e para a apresentação/exportação (buildHTML).
|
|
269
299
|
function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, current = false }) {
|
|
270
300
|
let inner = innerIn;
|
|
271
|
-
const ambient = ambientOf(s, i, ctx);
|
|
301
|
+
const ambient = ambientOf(s, i, ctx, theme, layout);
|
|
272
302
|
const deco = s.deco ?? theme.deco;
|
|
273
303
|
const style = (s.bg ? `--bg:#${String(s.bg).replace("#", "")};` : "") + (s.fg ? `--fg:#${String(s.fg).replace("#", "")};` : "");
|
|
274
304
|
const bars = s.footer !== false && (s.footer === true || !NO_FOOTER.has(layout));
|
|
275
|
-
const area = layout === "canvas" || layout === "full"
|
|
305
|
+
const area = layout === "canvas" || layout === "full" ? "free" : "safe";
|
|
276
306
|
const motionAccent = area === 'safe' && s.motionAccent && typeof s.motionAccent === 'object';
|
|
277
307
|
// estilo do ==destaque== (marca-texto | sublinhado | cor | negrito | nenhum), no deck ou por slide
|
|
278
308
|
const markStyle = s.markStyle || spec.markStyle;
|
|
@@ -293,7 +323,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
|
|
|
293
323
|
if (hd) { bandTitle = `<div class="master-title">${hd[0].replace(/<[a-z0-9]+ [^>]*class="[^"]*\bttl\b[^"]*"[^>]*/, (tag) => `${tag}${/\sdata-fit[\s=>]|\sdata-fit$/.test(tag) ? "" : " data-fit"} data-fit-self`)}</div>`; inner = inner.replace(hd[0], ""); }
|
|
294
324
|
}
|
|
295
325
|
let html = `<section class="slide${current ? " current" : ""}${withMaster ? ` has-master${isCoverLayout(layout) ? " master-cover" : ""}${band ? " master-band" : ""}` : ""} th-${theme.name} lk-${theme.key} tone-${tone} ${deco && deco !== "none" ? "deco-" + deco : ""} ${markStyle && markStyle !== "marca-texto" ? "ms-" + markStyle : ""} L-${layout}-slide${["compact", "dense"].includes(s.density) ? " density-" + s.density : ""}" data-idx="${i}"${s.uid ? ` data-uid="${esc(String(s.uid))}"` : ""} data-layout="${layout}" data-tr="${s.transition || "fade"}"${ambient ? ` data-ambient="${ambient}"` : ""}${nav}${fitAttrs}${!Array.isArray(s.steps) && Number.isFinite(Number(s.steps)) && Number(s.steps) > 0 ? ` data-steps="${Number(s.steps)}"` : ""}${style ? ` style="${style}"` : ""}>`;
|
|
296
|
-
if (ambient) html += `<div class="ambient" aria-hidden="true"></div>`;
|
|
326
|
+
if (ambient) html += `<div class="ambient" aria-hidden="true">${ambient === "rede" ? "<canvas></canvas>" : ""}</div>`;
|
|
297
327
|
if (s.background) html += `<div class="bgfig" style="${s.backgroundStyle || ""}">${el(s.background, ctx, 1920, slideSize(spec).h)}</div>`;
|
|
298
328
|
// mestre do deck (logos, faixas, número da página: src/master.js), atrás do conteúdo
|
|
299
329
|
if (withMaster) html += `<div class="master" aria-hidden="true">${masterElements(spec, layout, i).map((e) => el(e, ctx)).join("")}</div>`;
|
|
@@ -302,7 +332,9 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
|
|
|
302
332
|
if (bars && s.header !== false) html += barHTML("header", spec, i, total);
|
|
303
333
|
if (bandTitle) html += applyVisualEdits(bandTitle, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p });
|
|
304
334
|
html += `<div class="${area}${motionAccent ? ' with-motion-accent' : ''}">${applyVisualEdits(inner, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p })}</div>`;
|
|
305
|
-
|
|
335
|
+
const ticker = tickerOf(s, spec, theme, layout);
|
|
336
|
+
if (ticker) html += tickerHTML(ticker);
|
|
337
|
+
if (motionAccent) {const fg=s.fg||theme.tones[tone]?.fg||'ink';html +=`<div class="motion-accent" aria-hidden="true">${el({motion:{background:'transparent',foreground:theme.colors[fg]||fg,...s.motionAccent}},ctx,320,90)}</div>`;}
|
|
306
338
|
if (bars) html += barHTML("footer", spec, i, total);
|
|
307
339
|
if (s.back != null && s.back !== "") {
|
|
308
340
|
const alvo = (spec.slides || []).find((x) => x && String(x.id) === String(s.back));
|
|
@@ -321,7 +353,7 @@ export function renderSlide(raw, i = 0, spec = {}) {
|
|
|
321
353
|
const layout = inferLayout(s);
|
|
322
354
|
const fn = LAYOUTS[layout];
|
|
323
355
|
if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
|
|
324
|
-
const tone = s.tone ||
|
|
356
|
+
const tone = s.tone || DEFAULT_TONE[layout] || spec.tone || "light";
|
|
325
357
|
const inner = fn(s, ctx);
|
|
326
358
|
const deco = s.deco ?? theme.deco;
|
|
327
359
|
const html = slideShell({ s, i, spec, theme, ctx, layout, tone, inner, current: true });
|
package/src/code-highlight.js
CHANGED
|
@@ -6,6 +6,9 @@ const KEYWORDS = {
|
|
|
6
6
|
JavaScript: new Set("async await break case catch class const continue debugger default delete do else export extends finally for from function if import in instanceof let new of return static super switch this throw try typeof var void while with yield".split(" ")),
|
|
7
7
|
TypeScript: new Set("abstract any as asserts async await bigint boolean break case catch class const constructor continue declare default delete do else enum export extends finally for from function get if implements import in infer instanceof interface is keyof let namespace never new number object of package private protected public readonly return set static string super switch symbol this throw try type typeof undefined unique unknown var void while with yield".split(" ")),
|
|
8
8
|
"C#": new Set("abstract as async await base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw try typeof uint ulong unchecked unsafe ushort using virtual void volatile while".split(" ")),
|
|
9
|
+
Bash: new Set("if then else elif fi for in do done while until case esac function select time export local readonly return exit source".split(" ")),
|
|
10
|
+
JSON: new Set(),
|
|
11
|
+
SQL: new Set("SELECT FROM WHERE JOIN INNER LEFT RIGHT FULL OUTER CROSS ON AS AND OR NOT IN IS NULL DISTINCT GROUP BY ORDER HAVING LIMIT OFFSET ASC DESC INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE INDEX VIEW DROP ALTER WITH UNION ALL EXISTS CASE WHEN THEN ELSE END BETWEEN LIKE RETURNING BEGIN COMMIT ROLLBACK PRIMARY KEY REFERENCES DEFAULT".split(" ")),
|
|
9
12
|
};
|
|
10
13
|
|
|
11
14
|
const TYPES = {
|
|
@@ -14,11 +17,19 @@ const TYPES = {
|
|
|
14
17
|
JavaScript: new Set("Array Boolean Date Map Number Object Promise Set String".split(" ")),
|
|
15
18
|
TypeScript: new Set("Array any bigint boolean never number object string symbol unknown void".split(" ")),
|
|
16
19
|
"C#": new Set("bool byte char DateTime decimal double float int List long object short string Task uint ulong var void".split(" ")),
|
|
20
|
+
Bash: new Set(),
|
|
21
|
+
JSON: new Set(),
|
|
22
|
+
SQL: new Set("INT INTEGER BIGINT TEXT VARCHAR BOOLEAN FLOAT DOUBLE DECIMAL NUMERIC DATE TIMESTAMP JSON JSONB".split(" ")),
|
|
17
23
|
};
|
|
18
24
|
|
|
25
|
+
const SHELL_COMMANDS = new Set("curl echo printf git npm node python python3 pip pytest jq cat grep sed awk ls cd pwd mkdir rm cp mv sleep read test find sort head tail".split(" "));
|
|
26
|
+
|
|
19
27
|
const CONSTANTS = new Set(["False", "None", "True", "false", "null", "true", "undefined", "NaN", "Infinity"]);
|
|
20
28
|
|
|
21
29
|
function tokenPattern(language) {
|
|
30
|
+
if (language === "Bash") return /(?<comment>(?<!\S)#[^\n]*)|(?<string>'[^']*'|"(?:\\[\s\S]|[^"\\])*")|(?<variable>\$(?:\{[^}\n]+\}|[A-Za-z_][\w]*|[\d?@#*!$-]))|(?<flag>(?<!\S)--?[A-Za-z][\w-]*(?:=[^\s]+)?)|(?<number>\b\d+(?:\.\d+)?\b)|(?<identifier>[A-Za-z_][\w]*)|(?<operator>[|&;<>]+)|(?<punctuation>[{}()\[\]])/g;
|
|
31
|
+
if (language === "JSON") return /(?<comment>\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(?<string>"(?:\\.|[^"\\])*")|(?<number>\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<identifier>true|false|null)|(?<punctuation>[{}\[\],:])/g;
|
|
32
|
+
if (language === "SQL") return /(?<comment>--[^\n]*|\/\*[\s\S]*?\*\/)|(?<string>'(?:''|[^'])*'|"(?:""|[^"])*"|`[^`]*`)|(?<number>\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<identifier>[A-Za-z_][\w$]*)|(?<operator>[-+*/%=&|^!<>~]+)|(?<punctuation>[{}()\[\].,;])/g;
|
|
22
33
|
const comments = language === "Python"
|
|
23
34
|
? "#[^\\n]*"
|
|
24
35
|
: "\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/";
|
|
@@ -32,13 +43,18 @@ function tokenPattern(language) {
|
|
|
32
43
|
function classify(match, language, source, offset) {
|
|
33
44
|
const groups = match.groups;
|
|
34
45
|
if (groups.comment) return "comment";
|
|
46
|
+
if (groups.variable) return "constant";
|
|
47
|
+
if (groups.flag) return "keyword";
|
|
48
|
+
if (groups.string && language === "JSON" && /^\s*:/.test(source.slice(offset + match[0].length))) return "type";
|
|
35
49
|
if (groups.string) return "string";
|
|
36
50
|
if (groups.number) return "number";
|
|
37
51
|
if (groups.identifier) {
|
|
38
52
|
const word = groups.identifier;
|
|
39
|
-
|
|
53
|
+
const normalized = language === "SQL" ? word.toUpperCase() : word;
|
|
54
|
+
if (KEYWORDS[language].has(normalized)) return "keyword";
|
|
40
55
|
if (CONSTANTS.has(word)) return "constant";
|
|
41
|
-
if (TYPES[language].has(
|
|
56
|
+
if (TYPES[language].has(normalized)) return "type";
|
|
57
|
+
if (language === "Bash" && SHELL_COMMANDS.has(word)) return "function";
|
|
42
58
|
if (/^\s*\(/.test(source.slice(offset + match[0].length))) return "function";
|
|
43
59
|
}
|
|
44
60
|
if (groups.operator) return "operator";
|
|
@@ -47,6 +63,7 @@ function classify(match, language, source, offset) {
|
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
export function highlightCode(source, language) {
|
|
66
|
+
if (!KEYWORDS[language]) return source.split("\n").map(esc);
|
|
50
67
|
const pattern = tokenPattern(language);
|
|
51
68
|
const lines = [""];
|
|
52
69
|
const append = (value, kind = "") => {
|
package/src/code-language.js
CHANGED
|
@@ -11,6 +11,11 @@ const EXTENSIONS = new Map([
|
|
|
11
11
|
[".mts", "TypeScript"],
|
|
12
12
|
[".cts", "TypeScript"],
|
|
13
13
|
[".cs", "C#"],
|
|
14
|
+
[".sh", "Bash"],
|
|
15
|
+
[".bash", "Bash"],
|
|
16
|
+
[".json", "JSON"],
|
|
17
|
+
[".jsonc", "JSON"],
|
|
18
|
+
[".sql", "SQL"],
|
|
14
19
|
]);
|
|
15
20
|
|
|
16
21
|
const ALIASES = new Map([
|
|
@@ -26,6 +31,13 @@ const ALIASES = new Map([
|
|
|
26
31
|
["c#", "C#"],
|
|
27
32
|
["csharp", "C#"],
|
|
28
33
|
["cs", "C#"],
|
|
34
|
+
["bash", "Bash"],
|
|
35
|
+
["shell", "Bash"],
|
|
36
|
+
["sh", "Bash"],
|
|
37
|
+
["curl", "Bash"],
|
|
38
|
+
["json", "JSON"],
|
|
39
|
+
["jsonc", "JSON"],
|
|
40
|
+
["sql", "SQL"],
|
|
29
41
|
]);
|
|
30
42
|
|
|
31
43
|
export function inferCodeLanguage(filename) {
|
|
@@ -48,5 +60,6 @@ export function resolveCodeLanguage(language, filename) {
|
|
|
48
60
|
|
|
49
61
|
export function displayCodeLanguage(language, filename) {
|
|
50
62
|
const requested = String(language || "").trim();
|
|
63
|
+
if (requested.toLowerCase() === "curl") return "cURL";
|
|
51
64
|
return normalizeCodeLanguage(requested) || requested || inferCodeLanguage(filename);
|
|
52
65
|
}
|
package/src/elements.js
CHANGED
|
@@ -9,7 +9,7 @@ import katex from "katex";
|
|
|
9
9
|
import { formatNumber, decimalSeparator } from "./locale.js";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import { md, esc } from "./markup.js";
|
|
12
|
+
import { md, esc, plain } from "./markup.js";
|
|
13
13
|
import { iconSVG } from "./figures/icons.js";
|
|
14
14
|
import { picto } from "./figures/pictos.js";
|
|
15
15
|
import { diagram } from "./figures/diagrams.js";
|
|
@@ -258,6 +258,7 @@ export function el(e, ctx, w, h) {
|
|
|
258
258
|
if (e.poll) return poll(e, ctx);
|
|
259
259
|
if (e.list) return list(e);
|
|
260
260
|
if (e.cards) return cards(e, ctx);
|
|
261
|
+
if (e.pop) return popEl(e);
|
|
261
262
|
if (e.stats || e.kpis) return stats(e, ctx);
|
|
262
263
|
if (e.steps || e.process || e.flow) return steps(e, ctx);
|
|
263
264
|
if (e.progress != null) return progress(e);
|
|
@@ -478,6 +479,7 @@ export function cards(e, ctx) {
|
|
|
478
479
|
if (c.check === true || c.status === "pro") extraWidgets += `<div class="check-pill pro">${iconSVG("check", { size: 26, stroke: 2.4 })} ${c.checkText ? md(c.checkText) : "Recomendado"}</div>`;
|
|
479
480
|
if (c.check === false || c.status === "con") extraWidgets += `<div class="check-pill con">${iconSVG("x", { size: 26, stroke: 2.4 })} ${c.checkText ? md(c.checkText) : "Evitar"}</div>`;
|
|
480
481
|
|
|
482
|
+
if (c.flip != null && c.flip !== "") return flipCard(c, step, top, badgeHtml, extraWidgets);
|
|
481
483
|
return `<div${attrs({ ...c, step, card: c.hl ? "hi" : true }, "cd")}>
|
|
482
484
|
<div style="display:flex;justify-content:space-between;align-items:center;width:100%;">
|
|
483
485
|
${top}
|
|
@@ -493,6 +495,42 @@ export function cards(e, ctx) {
|
|
|
493
495
|
return `<div${attrs(e, "cards", `grid-template-columns:repeat(${cols},1fr);`)}>${body}</div>`;
|
|
494
496
|
}
|
|
495
497
|
|
|
498
|
+
// Cartão que vira (flip: no cartão): clicar gira e mostra o verso (flipTitle + flip; "back" já é o Voltar da navegação). Frente e verso ficam no
|
|
499
|
+
// mesmo lugar da grade; o mais alto dita a altura. Na exportação e no PPTX vale a frente.
|
|
500
|
+
function flipCard(c, step, top, badgeHtml, extraWidgets) {
|
|
501
|
+
const face = `card${c.hl ? " card-hi" : ""} flip-face`;
|
|
502
|
+
const back = typeof c.flip === "object" ? c.flip : { text: c.flip };
|
|
503
|
+
return `<div${attrs({ ...c, step, card: false }, "cd flip")} data-flip tabindex="0" role="button" aria-pressed="false" aria-label="${esc(plain(String(c.title || "Cartão")))}: virar">
|
|
504
|
+
<div class="flip-in">
|
|
505
|
+
<div class="${face} flip-front">
|
|
506
|
+
<div style="display:flex;justify-content:space-between;align-items:center;width:100%;">${top}${badgeHtml}</div>
|
|
507
|
+
${c.title ? `<div class="cd-title t f-heading">${md(c.title)}</div>` : ""}
|
|
508
|
+
${c.text ? `<div class="cd-text t f-body">${md(c.text)}</div>` : ""}
|
|
509
|
+
${extraWidgets}
|
|
510
|
+
${c.foot ? `<div class="cd-foot t f-label">${md(c.foot)}</div>` : ""}
|
|
511
|
+
<span class="flip-hint" aria-hidden="true">${iconSVG("rotate-ccw", { size: 28, stroke: 2.2 })}</span>
|
|
512
|
+
</div>
|
|
513
|
+
<div class="${face} flip-back" aria-hidden="true">
|
|
514
|
+
${back.title || c.flipTitle ? `<div class="cd-title t f-heading">${md(back.title || c.flipTitle)}</div>` : ""}
|
|
515
|
+
<div class="cd-text t f-body">${md(String(back.text ?? ""))}</div>
|
|
516
|
+
<span class="flip-hint" aria-hidden="true">${iconSVG("rotate-ccw", { size: 28, stroke: 2.2 })}</span>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
</div>`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Botão + com balão (pop): clicar abre um balãozinho com título e texto e escurece o resto do slide; clicar de novo,
|
|
523
|
+
// fora dele ou trocar de slide fecha. side: above (padrão) | below | left | right. Na exportação, fechado.
|
|
524
|
+
export function popHTML(more, side = "above") {
|
|
525
|
+
const m = typeof more === "object" && more ? more : { text: more };
|
|
526
|
+
const sd = ["above", "below", "left", "right"].includes(m.side || side) ? m.side || side : "above";
|
|
527
|
+
return `<span class="pop pop-${sd}" data-pop><button type="button" class="pop-btn" aria-expanded="false" aria-label="${esc(plain(String(m.title || "Ver mais")))}">${iconSVG("plus", { size: 30, stroke: 2.6 })}</button><span class="pop-card" role="note">${m.title ? `<span class="pop-title t f-heading">${md(String(m.title))}</span>` : ""}<span class="pop-text t f-body">${md(String(m.text ?? ""))}</span></span></span>`;
|
|
528
|
+
}
|
|
529
|
+
function popEl(e) {
|
|
530
|
+
const m = typeof e.pop === "object" ? e.pop : { text: e.pop, title: e.title, side: e.side };
|
|
531
|
+
return `<span${attrs(e, "pop-el")}>${popHTML(m)}</span>`;
|
|
532
|
+
}
|
|
533
|
+
|
|
496
534
|
export function stats(e, ctx) {
|
|
497
535
|
const items = e.stats || e.kpis || [];
|
|
498
536
|
const cols = e.cols || Math.min(items.length, 4) || 3;
|
package/src/export/pptx.js
CHANGED
|
@@ -48,6 +48,8 @@ function collectInPage([idx, nativeCharts]) {
|
|
|
48
48
|
const isLeaf = (e) => {
|
|
49
49
|
const d = getComputedStyle(e).display;
|
|
50
50
|
if ((d.includes("flex") || d.includes("grid")) && e.children.length) return false;
|
|
51
|
+
// Na régua da calculadora, cada faixa e cada rótulo têm caixas próprias: agrupar perderia posições e fundos.
|
|
52
|
+
if (e.classList.contains("calc-track") || e.classList.contains("calc-band")) return false;
|
|
51
53
|
let has = false;
|
|
52
54
|
for (const c of e.childNodes) {
|
|
53
55
|
if (c.nodeType === 3) { if (c.textContent.trim()) has = true; }
|
|
@@ -168,7 +170,7 @@ function collectInPage([idx, nativeCharts]) {
|
|
|
168
170
|
return;
|
|
169
171
|
}
|
|
170
172
|
// coisas que viram imagem
|
|
171
|
-
if (tag.toLowerCase() === "svg" || tag === "IMG" || tag === "CANVAS" || tag === "VIDEO" ||
|
|
173
|
+
if (tag.toLowerCase() === "svg" || tag === "IMG" || tag === "CANVAS" || tag === "VIDEO" || (tag === "INPUT" && e.type === "range") ||
|
|
172
174
|
e.classList.contains("katex") || (isLeaf(e) && e.querySelector(".katex")) || e.classList.contains("widget") || e.classList.contains("timer") || e.classList.contains("code") || e.classList.contains("raster") || transformed(cs)) {
|
|
173
175
|
const vr = visualRect(e);
|
|
174
176
|
e.setAttribute("data-pid", String(++pid));
|
|
@@ -327,7 +329,7 @@ function materializePseudos() {
|
|
|
327
329
|
}
|
|
328
330
|
|
|
329
331
|
// notes: false tira as notas do apresentador (mandar a apresentação para alguém sem a "cola")
|
|
330
|
-
export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {} } = {}) {
|
|
332
|
+
export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {}, onProgress = () => {} } = {}) {
|
|
331
333
|
const notesOpt = notes !== false;
|
|
332
334
|
const { browser, page, errors } = await openDeck(htmlFile, { scale: 2 });
|
|
333
335
|
try {
|
|
@@ -351,6 +353,7 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
|
|
|
351
353
|
const animBySlide = [];
|
|
352
354
|
|
|
353
355
|
for (let i = 0; i < n; i++) {
|
|
356
|
+
onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
|
|
354
357
|
const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
|
|
355
358
|
await page.evaluate(([j, k]) => window.sagadeck.goto(j, k, true), [i, S]);
|
|
356
359
|
await settleSlide(page);
|
|
@@ -432,8 +435,10 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
|
|
|
432
435
|
if (notes && withNotes) slide.addNotes(notesPlain(notes));
|
|
433
436
|
animBySlide.push({ anims, tr: data.tr });
|
|
434
437
|
log(` slide ${i + 1}/${n}: ${data.items.length} objetos`);
|
|
438
|
+
onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
|
|
435
439
|
}
|
|
436
440
|
// pós-processamento: animações + transições
|
|
441
|
+
onProgress({ phase: 'packing', completed: n, total: n });
|
|
437
442
|
const buf = await pres.write({ outputType: "nodebuffer" });
|
|
438
443
|
const zip = await JSZip.loadAsync(buf);
|
|
439
444
|
for (let i = 0; i < animBySlide.length; i++) {
|
package/src/export/shots.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { openDeck, waitForResources, settleSlide } from "./browser.js";
|
|
5
5
|
|
|
6
6
|
// Estado final de cada slide (todos os cliques revelados)
|
|
7
|
-
export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false } = {}) {
|
|
7
|
+
export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false, onProgress = () => {} } = {}) {
|
|
8
8
|
fs.mkdirSync(outDir, { recursive: true });
|
|
9
9
|
for (const f of fs.readdirSync(outDir)) if (/^slide-\d+.*\.(png|jpg)$|^folha-\d+\.png$/.test(f)) fs.unlinkSync(path.join(outDir, f));
|
|
10
10
|
const { browser, page, errors } = await openDeck(htmlFile, { scale });
|
|
@@ -13,6 +13,7 @@ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only,
|
|
|
13
13
|
const files = [];
|
|
14
14
|
for (let i = 0; i < n; i++) {
|
|
15
15
|
if (only && !only.includes(i + 1)) continue;
|
|
16
|
+
onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
|
|
16
17
|
const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
|
|
17
18
|
const list = steps ? Array.from({ length: S + 1 }, (_, k) => k) : [S];
|
|
18
19
|
for (const k of list) {
|
|
@@ -23,6 +24,7 @@ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only,
|
|
|
23
24
|
await page.screenshot(jpeg ? { path: f, type: "jpeg", quality: 82 } : { path: f });
|
|
24
25
|
files.push(f);
|
|
25
26
|
}
|
|
27
|
+
onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
|
|
26
28
|
}
|
|
27
29
|
return { files, errors };
|
|
28
30
|
} finally { await browser.close(); }
|
|
@@ -49,19 +51,22 @@ export async function contactSheet(files, outDir, { perSheet = 12, cols = 3 } =
|
|
|
49
51
|
} finally { await browser.close(); }
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
export async function pdf(htmlFile, outFile) {
|
|
54
|
+
export async function pdf(htmlFile, outFile, { onProgress = () => {} } = {}) {
|
|
53
55
|
const { browser, page } = await openDeck(htmlFile, { scale: 1 });
|
|
54
56
|
try {
|
|
55
57
|
const n = await page.evaluate(() => window.sagadeck.n);
|
|
56
58
|
const imgs = [], links = [];
|
|
57
59
|
for (let i = 0; i < n; i++) {
|
|
60
|
+
onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
|
|
58
61
|
const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
|
|
59
62
|
await page.evaluate(([j, k]) => window.sagadeck.goto(j, k), [i, S]);
|
|
60
63
|
await settleSlide(page);
|
|
61
64
|
await page.waitForTimeout(50);
|
|
62
65
|
imgs.push((await page.screenshot({ type: "jpeg", quality: 92 })).toString("base64"));
|
|
63
66
|
links.push(await page.evaluate(gotoRects, i));
|
|
67
|
+
onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
|
|
64
68
|
}
|
|
69
|
+
onProgress({ phase: 'packing', completed: n, total: n });
|
|
65
70
|
// cada página é a foto do slide; por cima, áreas clicáveis nos itens com goto (o PDF pula para a página do destino)
|
|
66
71
|
const hit = (l) => `<a href="#p${l.to}" style="position:absolute;left:${l.x}px;top:${l.y}px;width:${l.w}px;height:${l.h}px"></a>`;
|
|
67
72
|
const { w: W, h: H } = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 }); // deck.aspect
|
package/src/fiscal/normalize.js
CHANGED
|
@@ -52,6 +52,25 @@ export function normalizeSpec(rawSpec) { if (!rawSpec || typeof rawSpec !== "ob
|
|
|
52
52
|
spec.slides = spec.slides.map((s) => {
|
|
53
53
|
if (!s || typeof s !== "object") return { layout: "statement", text: String(s) };
|
|
54
54
|
|
|
55
|
+
// Tipografia cinética saiu (Narumi: "se ele não consegue ser melhor, remova completamente"): o deck que a usava
|
|
56
|
+
// vira uma frase em destaque com as mesmas frases, uma por clique; a cena de fundo não tinha onde ficar
|
|
57
|
+
if (s.layout === "kinetic") {
|
|
58
|
+
const beats = (Array.isArray(s.beats) ? s.beats : []).map((b) => (typeof b === "string" ? b : b?.text)).filter((t) => typeof t === "string" && t.trim());
|
|
59
|
+
const phrases = beats.length ? beats : [s.title || s.text, s.subtitle].filter((t) => typeof t === "string" && t.trim());
|
|
60
|
+
const { beats: _b, autoplay: _a, interval: _i, figure: _f, layout: _l, title: _t, subtitle: _s, text: _x, ...rest } = s;
|
|
61
|
+
s = phrases.length > 1 ? { ...rest, layout: "statement", lines: phrases, build: true } : { ...rest, layout: "statement", text: phrases[0] || "" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Texto no cenário saiu (Narumi: "ele é muito limitado e dá para fazer coisas melhores pedindo pra IA desenhar"):
|
|
65
|
+
// com imagem, vira imagem de página inteira com o título por cima; sem imagem, frase em destaque
|
|
66
|
+
if (s.layout === "scenography") {
|
|
67
|
+
const { scene: _sc, foreground: _fg, imageOpacity: _op, titleSize: _ts, subtitle, caption, title, image, kicker, ...rest } = s;
|
|
68
|
+
const detail = [subtitle, caption].filter((v) => v != null && String(v).trim()).map(String).join("\n\n");
|
|
69
|
+
s = image
|
|
70
|
+
? { ...rest, layout: "full", image, ...(kicker ? { kicker } : {}), ...(title ? { title } : {}), ...(detail ? { caption: detail } : {}), overlay: "left" }
|
|
71
|
+
: { ...rest, layout: "statement", ...(kicker ? { kicker } : {}), text: String(title || "").replace(/\n/g, " ").trim(), ...(detail ? { by: detail } : {}) };
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
// Termos em português para campos básicos
|
|
56
75
|
if (s.titulo && !s.title) { s.title = s.titulo; delete s.titulo; }
|
|
57
76
|
if (s.subtitulo && !s.subtitle) { s.subtitle = s.subtitulo; delete s.subtitulo; }
|
package/src/fs-retry.js
CHANGED
|
@@ -2,18 +2,53 @@
|
|
|
2
2
|
// enquanto sincroniza (e também o antivírus ou o indexador), o arquivo fica preso por um instante e o rename falha
|
|
3
3
|
// com EPERM/EBUSY/EACCES. Em vez de perder o salvamento da pessoa, tenta de novo com espera curta e crescente
|
|
4
4
|
// (~1 s no total). Qualquer outro erro (destino inexistente, disco cheio…) sobe na hora.
|
|
5
|
+
//
|
|
6
|
+
// Copiar e remover pasta NÃO usam fs.cpSync/fs.rmSync recursivos: o código nativo deles (std::filesystem) no
|
|
7
|
+
// Windows quebra o processo inteiro (0xC0000409, sem exceção para pegar) ou "faz nada" quando o caminho tem
|
|
8
|
+
// acento ou caractere fora do ASCII (nodejs/node#54476, #61878) — "Relatório (cópia)" da biblioteca é o caso da
|
|
9
|
+
// vida real. A cópia/remoção caminha na mão por readdir/copyFile/unlink/rmdir (libuv), que lida com acento, e
|
|
10
|
+
// cada arquivo ainda tolera o travamento de um instante (EPERM/EBUSY/EACCES), como o rename.
|
|
5
11
|
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
6
13
|
|
|
7
14
|
const LOCKED = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
8
15
|
export const RENAME_DELAYS = [20, 40, 80, 150, 250, 450];
|
|
9
16
|
|
|
10
17
|
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
11
18
|
|
|
12
|
-
|
|
19
|
+
// tenta de novo enquanto o erro for "travado por um instante"; o resto sobe na hora
|
|
20
|
+
function retry(delays, fn) {
|
|
13
21
|
for (let i = 0; ; i++) {
|
|
14
|
-
try { return
|
|
22
|
+
try { return fn(); } catch (e) {
|
|
15
23
|
if (!LOCKED.has(e?.code) || i >= delays.length) throw e;
|
|
16
24
|
pause(delays[i]);
|
|
17
25
|
}
|
|
18
26
|
}
|
|
19
27
|
}
|
|
28
|
+
|
|
29
|
+
export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
|
|
30
|
+
return retry(delays, () => fs.renameSync(from, to));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// arquivo ou pasta inteira (com imagens): duplicar uma apresentação é o caso — no Windows o antivírus segura o
|
|
34
|
+
// arquivo recém-criado por um instante e a cópia falhava com EPERM (era o flake do CI no "mover (pasta inteira…)").
|
|
35
|
+
export function copyRetry(from, to, { delays = RENAME_DELAYS } = {}) {
|
|
36
|
+
const st = fs.statSync(from);
|
|
37
|
+
if (!st.isDirectory()) return retry(delays, () => fs.copyFileSync(from, to));
|
|
38
|
+
fs.mkdirSync(to, { recursive: true });
|
|
39
|
+
for (const e of fs.readdirSync(from, { withFileTypes: true })) {
|
|
40
|
+
copyRetry(path.join(from, e.name), path.join(to, e.name), { delays });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// pasta inteira ou arquivo: a lixeira apaga de vez e o import limpa a área de trabalho; no Windows um arquivo
|
|
45
|
+
// travado derrubava a operação e o rmSync recursivo ainda errava com caminho acentuado (apagava nada, em silêncio).
|
|
46
|
+
export function removeRetry(target, { delays = RENAME_DELAYS } = {}) {
|
|
47
|
+
let st;
|
|
48
|
+
try { st = fs.lstatSync(target); } catch (e) { if (e?.code === "ENOENT") return; throw e; }
|
|
49
|
+
if (st.isDirectory()) {
|
|
50
|
+
for (const name of fs.readdirSync(target)) removeRetry(path.join(target, name), { delays });
|
|
51
|
+
return retry(delays, () => fs.rmdirSync(target));
|
|
52
|
+
}
|
|
53
|
+
return retry(delays, () => fs.unlinkSync(target));
|
|
54
|
+
}
|