sagadeck 1.2.0 → 1.3.1

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.
Files changed (46) hide show
  1. package/README.md +11 -2
  2. package/bin/sagadeck.js +36 -3
  3. package/docs/ANDAMENTO.md +37 -0
  4. package/docs/REFERENCIA.md +4 -3
  5. package/package.json +1 -1
  6. package/src/ai/deck-ai.js +54 -12
  7. package/src/ai/document-visuals.js +230 -24
  8. package/src/ai/quality.js +1 -1
  9. package/src/ai/transform.js +51 -2
  10. package/src/diff.js +44 -0
  11. package/src/elements.js +37 -2
  12. package/src/export/pptx.js +8 -6
  13. package/src/figures/charts.js +11 -3
  14. package/src/figures/diagrams.js +37 -9
  15. package/src/fiscal/autofix.js +1 -1
  16. package/src/fiscal/enxugar.js +22 -0
  17. package/src/fiscal/normalize.js +19 -2
  18. package/src/import/crop.js +10 -3
  19. package/src/layouts.js +5 -3
  20. package/src/library.js +74 -2
  21. package/src/motion.js +1 -1
  22. package/src/package.js +16 -1
  23. package/src/research/brave.js +12 -0
  24. package/src/research/chat-research.js +29 -0
  25. package/src/research/links.js +37 -0
  26. package/src/research/research.js +42 -2
  27. package/src/research/wikipedia.js +15 -0
  28. package/src/runtime/base.css +6 -6
  29. package/src/runtime/motion.js +1 -1
  30. package/src/runtime/runtime.js +15 -2
  31. package/src/studio/ai-chat-route.js +158 -0
  32. package/src/studio/exporting.js +2 -2
  33. package/src/studio/library-routes.js +158 -0
  34. package/src/studio/public/app.js +44 -508
  35. package/src/studio/public/chat.js +461 -0
  36. package/src/studio/public/index.html +17 -12
  37. package/src/studio/public/library.js +13 -2
  38. package/src/studio/public/slide-clipboard.js +20 -7
  39. package/src/studio/public/slide-select.js +73 -0
  40. package/src/studio/public/studio-next.css +28 -1
  41. package/src/studio/public/style.css +7 -1
  42. package/src/studio/public-files.js +1 -1
  43. package/src/studio/server.js +14 -285
  44. package/src/studio/slide-copy-routes.js +7 -4
  45. package/src/studio/snapshot.js +3 -0
  46. package/src/themes.js +32 -2
@@ -0,0 +1,73 @@
1
+ // Seleção de slides na lista (multisseleção com Ctrl/Shift) e teclas da lista.
2
+ // Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
3
+ window.SagaSlideSelect = function ({ state, dom, markActiveThumb, renderCurrentSlide, copySelectedSlides, pasteCopiedSlide, deleteCurrentSlide }) {
4
+ // slides selecionados na lista (para excluir/copiar em bloco): ordenados, válidos e nunca vazios
5
+ function selectedSlides() {
6
+ const n = state.deck?.slides.length || 0;
7
+ const sel = (Array.isArray(state.selectedSlides) ? state.selectedSlides : []).filter((i) => Number.isInteger(i) && i >= 0 && i < n);
8
+ if (!sel.length && n) sel.push(Math.min(state.currentSlideIndex, n - 1));
9
+ return [...new Set(sel)].sort((a, b) => a - b);
10
+ }
11
+ // click: só este; Ctrl+click: alterna; Shift+click: do atual até aqui. O atual (foco) vai junto.
12
+ function selectSlide(idx, { add, range } = {}) {
13
+ const n = state.deck.slides.length;
14
+ if (idx < 0 || idx >= n) return;
15
+ let sel = selectedSlides();
16
+ if (add) {
17
+ sel = sel.includes(idx) ? sel.filter((i) => i !== idx) : [...sel, idx];
18
+ if (!sel.length) sel = [idx];
19
+ } else if (range) {
20
+ const a = sel.length ? sel[sel.length - 1] : state.currentSlideIndex;
21
+ const [lo, hi] = a < idx ? [a, idx] : [idx, a];
22
+ sel = [...new Set([...sel, ...Array.from({ length: hi - lo + 1 }, (_, k) => lo + k)])];
23
+ } else sel = [idx];
24
+ if (idx !== state.currentSlideIndex) state.editorStep = "all"; // outro slide: volta a mostrar tudo
25
+ state.currentSlideIndex = idx;
26
+ state.selectedSlides = [...new Set(sel)].sort((a, b) => a - b);
27
+ markActiveThumb();
28
+ renderCurrentSlide();
29
+ }
30
+ // Delete/Backspace com o foco na lista de slides (à esquerda) exclui o slide selecionado. Só ali: digitando
31
+ // num campo ou mexendo num elemento do slide, as teclas continuam fazendo o que já faziam.
32
+ function bindSlideListKeys() {
33
+ document.getElementById("btn-copy-slide").onclick = () => copySelectedSlides();
34
+ document.getElementById("btn-paste-slide").onclick = pasteCopiedSlide;
35
+ if (dom.thumbnailsList._keys) return;
36
+ dom.thumbnailsList._keys = true;
37
+ dom.thumbnailsList.tabIndex = 0;
38
+ dom.thumbnailsList.setAttribute("aria-label", "Slides (Ctrl+click seleciona vários; Delete exclui)");
39
+ const scrollCard = (to) => {
40
+ // rola só a lista (scrollIntoView rolaria também a tela em volta)
41
+ const card = dom.thumbnailsList.querySelector(`.thumb-card[data-idx="${to}"]`), list = dom.thumbnailsList;
42
+ if (card) {
43
+ const c = card.getBoundingClientRect(), l = list.getBoundingClientRect();
44
+ if (c.top < l.top) list.scrollTop -= l.top - c.top + 8;
45
+ else if (c.bottom > l.bottom) list.scrollTop += c.bottom - l.bottom + 8;
46
+ }
47
+ };
48
+ dom.thumbnailsList.addEventListener("keydown", (e) => {
49
+ if (e.altKey || e.target.closest("input, textarea, select, [contenteditable]")) return;
50
+ // Ctrl+C/V copiam e colam os selecionados (funcionam aqui na lista)
51
+ if ((e.ctrlKey || e.metaKey) && ["c","v"].includes(e.key.toLowerCase())) { e.preventDefault(); e.key.toLowerCase()==="c" ? copySelectedSlides() : pasteCopiedSlide(); return; }
52
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { e.preventDefault(); state.selectedSlides = state.deck.slides.map((_, i) => i); markActiveThumb(); return; }
53
+ const go = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];
54
+ // ↑/↓ (e Home/End) trocam de slide, como no PowerPoint; nunca rolam a visualização.
55
+ // Com Ctrl ou Shift segurados, estendem a seleção em vez de trocar.
56
+ if (go || e.key === "Home" || e.key === "End") {
57
+ e.preventDefault();
58
+ const last = state.deck.slides.length - 1;
59
+ const to = e.key === "Home" ? 0 : e.key === "End" ? last : Math.max(0, Math.min(last, state.currentSlideIndex + go));
60
+ if (to !== state.currentSlideIndex) selectSlide(to, { add: e.ctrlKey || e.metaKey || e.shiftKey });
61
+ scrollCard(to);
62
+ return;
63
+ }
64
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
65
+ e.preventDefault();
66
+ deleteCurrentSlide();
67
+ dom.thumbnailsList.focus({ preventScroll: true });
68
+ });
69
+ }
70
+
71
+
72
+ return { selectedSlides, selectSlide, bindSlideListKeys };
73
+ };
@@ -72,6 +72,14 @@
72
72
  .studio-next .rbtn-lg { height: 56px; min-width: 65px; padding: 7px 10px; gap: 5px; font-size: 11px; }
73
73
  .studio-next .rbtn-lg > .ic { width: 21px; height: 21px; }
74
74
  .studio-next .rgroup-fields { gap: 5px; }
75
+ /* aviso de identidade/direção à vista (máquina nova): o grupo continua cabendo na faixa */
76
+ .studio-next .rgroup-body:has(> .rnote:not([hidden])) { gap: 1px; }
77
+ /* Identidade: 2 fileiras + aviso cabem na faixa (controles compactos só neste grupo; */
78
+ /* tamanhos em px fixos para a conta não depender da fonte instalada) */
79
+ #identity-select, #direction-select { height: 24px; font-size: 11px; }
80
+ #btn-identity-setup, #btn-direction-save { height: 24px; width: 26px; }
81
+ /* nada se esmaga em silêncio neste grupo: o que não couber aparece na rolagem e o teste acusa */
82
+ .studio-next .rgroup:has(#identity-select) > .rgroup-body > * { flex-shrink: 0; }
75
83
  .studio-next .rfield > span { font-size: 11px; }
76
84
  .studio-next .workspace { grid-template-columns: 200px minmax(0, 1fr) auto; }
77
85
  .studio-next .slide-rail { background: var(--chrome); }
@@ -135,6 +143,26 @@
135
143
  .studio-next .doc-title { max-width: 280px; }
136
144
  .scene-tools > span { display: none; }
137
145
  }
146
+ @media (max-width: 1440px) {
147
+ /* notebook: 3 temas + 3 paletas à vista mesmo com o aviso de identidade (controles e vãos compactos) */
148
+ .studio-next .ribbon { padding: 6px 8px; }
149
+ .studio-next .ribbon .rgroup { padding: 0 6px; }
150
+ .studio-next .ribbon .rselect { min-width: 104px; }
151
+ .studio-next .ribbon .rfield { gap: 6px; }
152
+ .studio-next .ribbon .rfield > span { min-width: 0; }
153
+ .studio-next .ribbon .rrow { gap: 1px; }
154
+ .studio-next .ribbon .rbtn-icon { width: 26px; }
155
+ }
156
+ @media (max-width: 1024px) {
157
+ /* janela estreita/tablet em pé: a faixa continua cabendo sem rolagem (menos respiro, rótulos e vãos compactos) */
158
+ .studio-next .ribbon .rgroup { padding: 0 4px; }
159
+ .studio-next .ribbon .rselect { min-width: 96px; }
160
+ .studio-next .ribbon .rfield { gap: 4px; }
161
+ .studio-next .ribbon .rfield > span { min-width: 0; }
162
+ .studio-next .ribbon .rrow { gap: 1px; }
163
+ .studio-next .ribbon .rbtn-icon { width: 26px; }
164
+ .studio-next .rgroup-body:has(> .rnote:not([hidden])) { gap: 0; }
165
+ }
138
166
  @media (max-width: 900px) {
139
167
  .studio-next .titlebar { height: 54px; }
140
168
  .studio-next .titlebar .doc-title { max-width: none; min-width: 40px; flex: 1; }
@@ -143,7 +171,6 @@
143
171
  .studio-next .ribbon-tab { padding: 0 10px; }
144
172
  .studio-next .ribbon { padding: 5px; height: 70px; }
145
173
  .studio-next .workspace { grid-template-columns: minmax(0, 1fr); }
146
- .studio-next .rgroup { padding: 0 7px; }
147
174
  .studio-next .rgroup-body { gap: 3px; }
148
175
  .motion-control { padding: 0 6px; gap: 2px; }
149
176
  .motion-control .ic { display: none; }
@@ -224,7 +224,7 @@ kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-3); bord
224
224
  .rbtn .caret { width: 12px; height: 12px; }
225
225
  .rfield { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-2); }
226
226
  .rfield > span { min-width: 44px; }
227
- .rnote { font-size: 11px; color: var(--text-2); max-width: 230px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
227
+ .rnote { font-size: 11px; color: var(--text-2); max-width: 230px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
228
228
  .rnote.warn { color: #b45309; }
229
229
  .rselect, .form-control {
230
230
  height: 28px; padding: 0 8px; border: 1px solid var(--ui-line-strong); border-radius: 4px; background: var(--chrome);
@@ -294,6 +294,9 @@ kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-3); bord
294
294
  .thumb-card:hover .thumb-screen { outline-color: var(--ui-line-strong); }
295
295
  .thumb-card.active .thumb-screen { outline-color: var(--brand); }
296
296
  .thumb-card.active .thumb-num { color: var(--brand); font-weight: 700; }
297
+ /* multisseleção (Ctrl+click, Shift+click, Ctrl+setas): tracejado nos marcados sem foco */
298
+ .thumb-card.selected:not(.active) .thumb-screen { outline-style: dashed; outline-color: var(--brand); }
299
+ .thumb-card.selected:not(.active) .thumb-num { color: var(--brand); }
297
300
  .thumb-render { position: absolute; left: 0; top: 0; width: 1920px; height: var(--sh, 1080px); transform-origin: 0 0; transform: scale(var(--thumb-scale, .09)); pointer-events: none; }
298
301
  .thumb-render .slide { opacity: 1 !important; visibility: visible !important; }
299
302
  .thumb-render mark { background-size: 100% 92% !important; transition: none !important; }
@@ -445,6 +448,9 @@ textarea.form-control { height: auto; min-height: 64px; padding: 6px 8px; resize
445
448
  .modal-dialog-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 8px 20px; }
446
449
  .modal-dialog-header h3 { font-size: 18px; font-weight: 600; }
447
450
  .modal-dialog-body { padding: 8px 20px 16px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
451
+ .modal-dialog-body details summary { cursor: pointer; font-size: 13px; color: var(--text-2); }
452
+ .modal-dialog-body details[open] summary { margin-bottom: 10px; }
453
+ .modal-dialog-body details .form-row { margin-top: 10px; }
448
454
  .modal-dialog-body .form-control { width: 100%; }
449
455
  .modal-dialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; }
450
456
  .form-row { display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap; }
@@ -1 +1 @@
1
- export const PUBLIC_SCRIPTS = new Set(["/workspace.js", "/chat-progress.js", "/slide-clipboard.js", "/app.js", "/ui-icons.js", "/slide-form.js", "/lab-fields.js", "/calc-fields.js", "/art-preview.js", "/library.js", "/screenshot-editor.js", "/visual-editor.js", "/inspector.js", "/explorer.js", "/viewers.js", "/merge-decks.js", "/history.js", "/review-ui.js", "/share-ui.js", "/developer-fields.js", "/api-collections.js", "/studio-awareness.js"]);
1
+ export const PUBLIC_SCRIPTS = new Set(["/workspace.js", "/chat-progress.js", "/slide-clipboard.js", "/slide-select.js", "/app.js", "/ui-icons.js", "/slide-form.js", "/lab-fields.js", "/calc-fields.js", "/art-preview.js", "/library.js", "/screenshot-editor.js", "/visual-editor.js", "/inspector.js", "/explorer.js", "/viewers.js", "/merge-decks.js", "/history.js", "/review-ui.js", "/share-ui.js", "/developer-fields.js", "/api-collections.js", "/studio-awareness.js", "/chat.js"]);
@@ -45,6 +45,8 @@ import { sendExport, sendHtml, lightVariant } from "./exporting.js";
45
45
  import { styleRoutes, styleAction } from "./style-routes.js";
46
46
  import { shareRoutes } from "./share-routes.js";
47
47
  import { apiRoutes } from "./api-routes.js";
48
+ import { aiChatRoutes } from "./ai-chat-route.js";
49
+ import { libraryRoutes } from "./library-routes.js";
48
50
  export { lightVariant };
49
51
  import { VERSION, buildInfo } from "./instance.js";
50
52
  import { extractDocText, fetchUrlText, CONTEXT_STORE_CHARS, CONTEXT_MAX_DOCS, pastedUrls } from "../ai/context.js";
@@ -102,6 +104,8 @@ export function createStudioServer(deckPath = null, opts = {}) {
102
104
  function newWorkspace(user) {
103
105
  const W = { user, library: openLibrary(user ? path.join(libraryRoot, "usuarios", safeName(user, "usuario")) : libraryRoot),
104
106
  file: null, spec: null, lastPreview: { ok: true, error: null, warnings: [] } };
107
+ // gerações que morreram com o processo viram órfãs: vão para a lixeira (visíveis e restauráveis), nunca somem
108
+ try { const n = W.library.recoverStaging().length; if (n) console.warn(`[Studio] ${n} geração(ões) interrompida(s) na biblioteca de ${user || "local"}: movida(s) para a lixeira.`); } catch {}
105
109
  Object.assign(W, sampleSpec());
106
110
  return W;
107
111
  }
@@ -312,12 +316,12 @@ export function createStudioServer(deckPath = null, opts = {}) {
312
316
  while (W.contextDocs.size > CONTEXT_MAX_DOCS) W.contextDocs.delete(W.contextDocs.keys().next().value);
313
317
  return { id, name, chars: text.length, detail };
314
318
  }
315
- // Gerar com IA (editor ou biblioteca): pasta nova na biblioteca ("Gerando…"), o deck gravado nela com as imagens
316
- // em imagens/, e a pasta renomeada para o título. Falhou: a pasta vai para a lixeira. Minutos, estilo, anexos.
319
+ // Gerar com IA (editor ou biblioteca): a IA trabalha numa pasta oculta do tópico (invisível na biblioteca)
320
+ // e a apresentação só aparece quando pronta, publicada com o título final. Falhou ou a IA perguntou algo:
321
+ // a pasta vai para a lixeira (recuperável). Minutos, estilo, anexos.
317
322
  async function generateIntoLibrary(W, topic, b, emit) {
318
323
  const L = W.library;
319
- const id = L.createDeck(topic, { title: "Gerando…", slides: [{ layout: "cover", title: "Gerando…" }] });
320
- const file = L.resolveId(id), dir = path.dirname(file);
324
+ const { dir, file } = L.createStagingDeck(topic);
321
325
  try {
322
326
  const prefs = loadPreferences().ia;
323
327
  const gen = await generateDeck(String(b.briefing || ""), {
@@ -339,15 +343,15 @@ export function createStudioServer(deckPath = null, opts = {}) {
339
343
  research: prefs.pesquisa === false ? false : "auto",
340
344
  researchDir: dir,
341
345
  });
342
- if (gen.question) { L.trashDeck(id); return { question: gen.question }; } // a IA quer saber para que serve o material
346
+ if (gen.question) { L.discardStaging(topic, dir); return { question: gen.question }; } // a IA quer saber para que serve o material
343
347
  // estilo padrão da biblioteca (Brand Kit), se a pessoa não escolheu um tema para esta
344
348
  if (!b.theme) gen.spec = L.withDefaultStyle(gen.spec, dir);
345
349
  fs.writeFileSync(file, toYaml(gen.spec), "utf8");
346
350
  saveGenReport(dir, gen);
347
- const finalId = L.renameDeck(id, gen.spec.title || "Nova apresentação");
351
+ const finalId = L.publishStagedDeck(topic, dir, gen.spec.title || "Nova apresentação");
348
352
  return { id: finalId, file: L.resolveId(finalId), images: gen.images, research: gen.research, quality: gen.quality, variety: gen.variety };
349
353
  } catch (e) {
350
- L.trashDeck(id);
354
+ try { L.discardStaging(topic, dir); } catch {}
351
355
  throw e;
352
356
  }
353
357
  }
@@ -1205,138 +1209,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
1205
1209
  return;
1206
1210
  }
1207
1211
  if (await slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slideClipboard,isBundledTemplate,materializePreview})) return;
1208
- if (pathname === "/api/ai/chat/cancel" && req.method === "POST") {
1209
- const body = await readJSON(req);
1210
- const job = W.aiChats?.get(body.requestId);
1211
- job?.abort();
1212
- res.writeHead(200, { "Content-Type": "application/json" });
1213
- res.end(JSON.stringify({ ok: true, stopped: !!job }));
1214
- return;
1215
- }
1216
- if (pathname === "/api/ai/chat" && req.method === "POST") {
1217
- const body = await readJSON(req);
1218
- if (staleTab(body, W, res)) return;
1219
- const owner = W;
1220
- owner.aiChats ||= new Map();
1221
- const requestId = body.requestId || crypto.randomUUID();
1222
- const controller = new AbortController();
1223
- owner.aiChats.set(requestId, controller);
1224
- const signal = controller.signal;
1225
- const onClose = () => { if (!res.writableEnded && !['melhorar','recriar'].some(m => transforms.has(`${taskWorkspace.file}|${m}`))) controller.abort(); };
1226
- res.on('close', onClose);
1227
- // Capture o arquivo da tarefa: abrir outro deck não redireciona a escrita.
1228
- const taskWorkspace = { ...owner, spec: structuredClone(owner.spec) };
1229
- const prompt = body.message || "";
1230
- const spec = body.spec || taskWorkspace.spec;
1231
- const issues = body.issues || [];
1232
- // a IA vê (e testa) os slides api no ambiente atual, que pode ser o embutido "ensaio"
1233
- if (!apiBlocked(req) && (spec.slides || []).some((sl) => sl && sl.layout === "api")) await ensureEnsaio();
1234
-
1235
- try { await respond(res, body.stream, async (send) => cancellable(async () => {
1236
- const emit = ev => { signal.throwIfAborted(); send(ev); };
1237
- let result;
1238
- const history = Array.isArray(body.history) ? body.history : [];
1239
- // o cache pode ter guardado uma queda de segundos do relay: confere de novo antes de desistir
1240
- if (!(await llmAvailable()) && !(await llmAvailable({ force: true }))) {
1241
- // Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
1242
- // (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
1243
- return {
1244
- reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}), então não mexi em nada. Rode "modelrelay serve" ou defina SAGADECK_LLM_URL e mande de novo.`,
1245
- spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
1246
- };
1247
- }
1248
- try {
1249
- const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
1250
- const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
1251
- // anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
1252
- const materials = await prepareDocumentMaterials(takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
1253
- .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id)), path.dirname(taskWorkspace.file), {signal,onProgress:emit});
1254
- for (const [i, url] of (Array.isArray(body.attachments) ? body.attachments : []).entries()) {
1255
- if (typeof url === "string" && url.startsWith("data:image/")) visuals.push({ label: `imagem colada pelo usuário ${i + 1}`, dataUrl: url });
1256
- }
1257
- // arquivos do projeto (contexto/): material para a IA, junto com os anexos da mensagem
1258
- const P = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? Project.projectOf(taskWorkspace.file) : null;
1259
- if (P) for (const doc of await Project.contextMaterials(P)) if (!materials.some((m) => m.name === doc.name || m.name === doc.name.split("/").pop())) materials.push(doc);
1260
- // links colados na mensagem: o servidor lê sozinho e conta nas ações
1261
- const linkActions = [];
1262
- for (const doc of await readPastedLinks(taskWorkspace, prompt, linkActions)) materials.push(doc);
1263
- result = await editDeck({
1264
- spec: withBase(taskWorkspace, spec), signal,
1265
- instruction: prompt,
1266
- targetSlide: target,
1267
- issues,
1268
- images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
1269
- imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)),
1270
- history,
1271
- onProgress: emit,
1272
- visuals,
1273
- materials,
1274
- renderNotes: Array.isArray(body.renderNotes) ? body.renderNotes.slice(0, 8) : [],
1275
- apiContext: apiContextFor(req, taskWorkspace),
1276
- drawCheck: diagramCheck,
1277
- runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
1278
- reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
1279
- styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
1280
- });
1281
- signal.throwIfAborted();
1282
- if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
1283
- // a IA decidiu transformar a apresentação inteira (transform:): o trabalho em etapas, com o andamento aqui
1284
- if (result.transform) {
1285
- const transformed = await runTransform(taskWorkspace, withBase(taskWorkspace, spec), result, emit, prompt, visuals.filter(v => v.label.startsWith("imagem colada pelo usuário")), signal);
1286
- return owner.file === taskWorkspace.file ? transformed : { ...transformed, spec: owner.spec, talk: true, reply: transformed.reply + '\nO resultado ficou no arquivo original; você está em outra apresentação.' };
1287
- }
1288
- // a IA decidiu mexer no estilo (estilo:): aplicar, salvar, tirar ou padrão das novas
1289
- if (result.style) return styleAction({ W: taskWorkspace, spec: withBase(taskWorkspace, spec), result, persist, isBundledTemplate });
1290
- // Slides api: a IA pediu para testar (test: [n]) → o Studio executa, devolve o relatório e ela
1291
- // corrige, até 3 rodadas. Quem decide testar e o que corrigir é a IA; aqui só executa.
1292
- const convo = [...history, { role: "user", text: prompt }]
1293
- for (let round = 1; result.test?.length && round <= 3; round++) {
1294
- if (apiBlocked(req)) { result.actions.push("Testes de slides api só no Studio local."); break; }
1295
- const reports = [];
1296
- for (const i of result.test) {
1297
- const slide = result.spec.slides[i];
1298
- emit({ phase: "test", text: `Testando o slide ${i + 1} (${apiEnv.currentName() || "sem ambiente"})…` });
1299
- const r = await apiEnv.runSlide(slide, { vars: taskWorkspace.apiVars || {}, deckDir: taskWorkspace.file ? path.dirname(taskWorkspace.file) : null });
1300
- signal.throwIfAborted();
1301
- if (r.saved) taskWorkspace.apiVars = { ...(taskWorkspace.apiVars || {}), ...r.saved };
1302
- if (r.record) {
1303
- const key = globalThis.SagadeckApiCore.key(slide);
1304
- if (taskWorkspace.file && !isBundledTemplate(taskWorkspace.file)) writeRecording(taskWorkspace.file, key, r.record);
1305
- else (taskWorkspace.apiRecordings = taskWorkspace.apiRecordings || {})[key] = { ...r.record, at: new Date().toISOString() };
1306
- }
1307
- reports.push({ slide: i + 1, ...r.report });
1308
- result.actions.push(`Teste do slide ${i + 1}: ${r.report.ok ? "funcionou" : "falhou, " + String(r.report.erro || (r.report.status ? `HTTP ${r.report.status}` : "falhou")).slice(0, 140)}`);
1309
- }
1310
- convo.push({ role: "assistant", text: result.reply });
1311
- const instruction = `Resultado do teste (rodada ${round} de 3), executado no ambiente ${apiEnv.currentName()}:\n\`\`\`json\n${JSON.stringify(reports, null, 2).slice(0, 12000)}\n\`\`\`\nSe algo falhou ou tem "NÃO EXISTE", corrija os slides com base na resposta real e peça test de novo. Se tudo funcionou, confirme em uma frase, sem yaml.`;
1312
- emit({ phase: "test", text: reports.every((x) => x.ok) ? "Os testes passaram; conferindo…" : "Corrigindo com base no resultado…" });
1313
- const next = await editDeck({
1314
- spec: withBase(taskWorkspace, result.spec), signal, instruction, targetSlide: target, images: false,
1315
- imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, result.spec)), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
1316
- drawCheck: diagramCheck, runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
1317
- });
1318
- convo.push({ role: "user", text: instruction });
1319
- result = { ...next, actions: [...result.actions, ...(next.actions || [])], spec: next.spec };
1320
- }
1321
- result.mode = "llm";
1322
- } catch (e) {
1323
- if (signal.aborted) return { reply: "Parado a pedido. Nenhuma resposta pendente foi aplicada.", spec, actions: [], talk: true, mode: "cancelled" };
1324
- // Falhou no meio: não "chuta" com as regras (poderiam fazer outra coisa); deck fica como estava.
1325
- console.error("[Studio] IA falhou:", e.message);
1326
- return chatErrorResult(e,spec,body.targetSlide);
1327
- }
1328
- // o que a pessoa salvou enquanto a IA pensava não some: junção a três (base = o que foi para a IA)
1329
- signal.throwIfAborted();
1330
- const current = taskWorkspace.file && fs.existsSync(taskWorkspace.file) ? loadSpec(taskWorkspace.file) : owner.spec;
1331
- const merged = globalThis.SagadeckMerge.mergeDecks(spec, current || spec, result.spec);
1332
- taskWorkspace.spec = merged.deck;
1333
- carryVisualEdits(spec, taskWorkspace.spec); // a IA mudou o texto de um objeto ajustado: o ajuste acompanha
1334
- persist(taskWorkspace);
1335
- if (owner.file === taskWorkspace.file) owner.spec = taskWorkspace.spec;
1336
- return { ...result, spec: taskWorkspace.spec, conflicts: merged.conflicts, kept: merged.kept };
1337
- }, signal)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
1338
- return;
1339
- }
1212
+ if (await aiChatRoutes({ req, res, pathname, url, W, readJSON, staleTab, llmAvailable, llmConfig, lookAt, withBase, apiBlocked, ensureEnsaio, readPastedLinks, imageOptions, apiContextFor, commandRunner, diagramCheck, slideSnapshots, persist, isBundledTemplate, runTransform, transforms, apiEnv })) return;
1340
1213
 
1341
1214
  // ── Biblioteca (tópicos e apresentações da área de trabalho) ──
1342
1215
  // ---------- slide "api": executa pedidos HTTP (ver src/api-client.js) ----------
@@ -1345,151 +1218,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
1345
1218
  // slide API: ambientes, variáveis, envio, tempo real, gravação (src/studio/api-routes.js)
1346
1219
  if (pathname.startsWith("/api/http/")) return apiRoutes({ req, res, pathname, url, W, apiEnv, rtSessions, apiBlocked, ensureEnsaio, readJSON, isBundledTemplate });
1347
1220
 
1348
- if (pathname.startsWith("/api/library")) {
1349
- const L = W.library;
1350
- const ok = (obj = {}) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...obj })); };
1351
- const fail = (e, code = 400) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message || String(e) })); };
1352
- try {
1353
- if (pathname === "/api/library" && req.method === "GET") {
1354
- const openId = W.file && W.file.startsWith(L.root + path.sep) ? L.idOf(W.file) : null;
1355
- return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, open: openId });
1356
- }
1357
- if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
1358
- const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
1359
- const spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
1360
- const cacheDir = path.join(L.root, ".cache", "vitrine");
1361
- const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${key}|${JSON.stringify(entry.spec)}`).digest("hex")}.jpg`);
1362
- if (!fs.existsSync(cached)) {
1363
- const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
1364
- fs.mkdirSync(cacheDir, { recursive: true });
1365
- fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
1366
- }
1367
- res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=86400" });
1368
- res.end(fs.readFileSync(cached));
1369
- return;
1370
- }
1371
- if (pathname === "/api/library/cover" && req.method === "GET") {
1372
- const file = L.resolveId(url.searchParams.get("id"));
1373
- const st = fs.statSync(file);
1374
- const cacheDir = path.join(L.root, ".cache", "capas");
1375
- const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${L.idOf(file)}|${st.mtimeMs}`).digest("hex")}.jpg`);
1376
- if (!fs.existsSync(cached)) {
1377
- const spec = loadSpec(file);
1378
- const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
1379
- fs.mkdirSync(cacheDir, { recursive: true });
1380
- fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
1381
- }
1382
- res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=31536000" });
1383
- res.end(fs.readFileSync(cached));
1384
- return;
1385
- }
1386
- if (pathname === "/api/library/download" && req.method === "GET") {
1387
- const file = L.resolveId(url.searchParams.get("id"));
1388
- const kind = url.searchParams.get("kind") || "sagadeck";
1389
- if (!["sagadeck", "pptx", "pdf", "roteiro", "tudo", "estudo", "estudo-html"].includes(kind)) throw new Error("formato inválido");
1390
- await sendExport(res, kind, loadSpec(file), path.basename(file).replace(/\.ya?ml$/i, ""), { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0" });
1391
- return;
1392
- }
1393
- if (pathname === "/api/library/import" && req.method === "POST") {
1394
- const name = url.searchParams.get("name") || "Importada";
1395
- // PowerPoint: importação fiel (src/import); .sagadeck/.zip: pacote do sagadeck
1396
- if (/\.pptx$/i.test(name)) { const r = await L.importOffice(await readBody(req), url.searchParams.get("topic") || "", name); return ok({ id: r.id, slides: r.slides, snapshots: r.snapshots, snapBy: r.snapBy }); }
1397
- const id = await L.importPackage(await readBody(req), url.searchParams.get("topic") || "", name);
1398
- return ok({ id });
1399
- }
1400
- if (req.method !== "POST") return fail(new Error("método não suportado"), 405);
1401
- const b = await readJSON(req);
1402
- switch (pathname) {
1403
- case "/api/library/experience": {
1404
- const spec = createExperienceDeck(b.experience, { title: b.title });
1405
- buildHTML(spec); // Valida antes de criar o arquivo na biblioteca.
1406
- return ok({ id: L.createDeck(b.topic || "", spec) });
1407
- }
1408
- case "/api/library/topics": return ok({ id: L.createTopic(b.name, b.color) });
1409
- case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
1410
- case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
1411
- case "/api/library/decks": {
1412
- // nova: em branco (uma capa) — "com IA" usa /api/library/decks/ai
1413
- const title = String(b.title || "Nova apresentação").trim();
1414
- const id = L.createDeck(b.topic || "", { title, theme: b.theme || "bauhaus", duration: 10,
1415
- slides: [{ layout: "cover", title, subtitle: "Subtítulo", author: "" }] });
1416
- // estilo padrão da biblioteca (Brand Kit): sem tema escolhido, a nova já nasce nele
1417
- if (!b.theme && L.defaultStyle()) { const f = L.resolveId(id); writeDeckFile(f, L.withDefaultStyle(YAML.parse(fs.readFileSync(f, "utf8")), path.dirname(f))); }
1418
- return ok({ id });
1419
- }
1420
- case "/api/library/decks/model": {
1421
- // modelos de fábrica (src/studio/demo-decks.js): viram uma apresentação nova na biblioteca
1422
- const id = L.createDeck(b.topic || "Modelos", demoDeck(b.kind));
1423
- copyModelAssets(L, id, b.kind);
1424
- return ok({ id });
1425
- }
1426
- case "/api/library/decks/model-preview": {
1427
- // abre o item da vitrine sem criar arquivo; a cópia só nasce na primeira mudança
1428
- const key = galleryKey(b.key || b.kind), entry = galleryEntry(key);
1429
- W.spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
1430
- W.file = null;
1431
- W.preview = { key, topic: String(b.topic ?? "Modelos") }; // "" = sem tópico
1432
- W.chatHistory = [];
1433
- return ok({ spec: W.spec, preview: { key, title: W.spec.title, topic: W.preview.topic } });
1434
- }
1435
- case "/api/library/decks/model-use": {
1436
- // "Usar como base": cria a cópia agora, mesmo sem mudança
1437
- const made = materializePreview(W);
1438
- if (!made) throw new Error("Não há um modelo em prévia aberto.");
1439
- return ok(made);
1440
- }
1441
- case "/api/library/decks/example-cenario": {
1442
- // demonstração do Texto no cenário: o YAML e as imagens de exemplo (fundo e recorte transparente)
1443
- const dir0 = TEMPLATE_DIRS.map((d) => path.join(d, "cenario")).find((d) => fs.existsSync(d));
1444
- if (!dir0) throw new Error("o exemplo não veio no pacote (templates/cenario)");
1445
- const tpl = fs.readdirSync(dir0).find((f) => f.endsWith(".yaml"));
1446
- const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(path.join(dir0, tpl), "utf8")));
1447
- fs.cpSync(path.join(dir0, "imagens"), path.join(path.dirname(L.resolveId(id)), "imagens"), { recursive: true, force: false });
1448
- return ok({ id });
1449
- }
1450
- case "/api/library/decks/example": {
1451
- // exemplo de slides api (aula de APIs de IA): roda no ambiente embutido "ensaio", sem configurar nada
1452
- const tpl = templateFile("ensaio-api.yaml");
1453
- if (!tpl) throw new Error("o exemplo não veio no pacote (templates/ensaio-api.yaml)");
1454
- const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(tpl, "utf8")));
1455
- const dir = path.dirname(L.resolveId(id));
1456
- for (const [name, text] of Object.entries(DEMO_FILES)) fs.writeFileSync(path.join(dir, name), text);
1457
- return ok({ id });
1458
- }
1459
- case "/api/library/decks/move": return ok({ id: L.moveDeck(b.id, b.topic || "") });
1460
- case "/api/library/decks/rename": {
1461
- const id = L.renameDeck(b.id, String(b.title || "").trim() || "Sem título");
1462
- if (W.file === L.resolveId(b.id)) { W.file = L.resolveId(id); W.spec = loadSpec(W.file); }
1463
- return ok({ id });
1464
- }
1465
- case "/api/library/decks/duplicate": return ok({ id: L.duplicateDeck(b.id) });
1466
- case "/api/library/decks/trash": return ok({ slot: L.trashDeck(b.id) });
1467
- case "/api/library/decks/restore": return ok({ id: L.restoreDeck(b.slot) });
1468
- case "/api/library/decks/purge": L.purgeDeck(b.slot); return ok();
1469
- case "/api/library/trash/empty": return ok({ purged: L.emptyTrash() });
1470
- case "/api/library/open": {
1471
- W.preview = null;
1472
- const file = L.resolveId(b.id);
1473
- W.spec = loadSpec(file);
1474
- W.file = file;
1475
- return ok({ spec: W.spec, file: W.file, id: b.id });
1476
- }
1477
- case "/api/library/decks/ai": {
1478
- // gera com IA direto numa pasta nova da biblioteca (as imagens ficam dentro dela)
1479
- if (!(await llmAvailable({ force: true }))) return fail(new Error(`Nenhum LLM respondendo em ${llmConfig().url}.`), 503);
1480
- await respond(res, b.stream, async (emit) => {
1481
- const r = await generateIntoLibrary(W, b.topic || "", b, emit);
1482
- if (r.question) return { ok: true, question: r.question }; // o Studio mostra a pergunta e gera de novo com a resposta
1483
- return { ok: true, id: r.id, images: r.images, research: r.research, quality: r.quality, variety: r.variety };
1484
- });
1485
- return;
1486
- }
1487
- }
1488
- return fail(new Error("rota da biblioteca desconhecida"), 404);
1489
- } catch (e) {
1490
- return fail(e);
1491
- }
1492
- }
1221
+ if (pathname.startsWith("/api/library")) return libraryRoutes({ req, res, pathname, url, W, opts, readJSON, readBody, slideSnapshots, sendExport, galleryKey, galleryEntry, materializePreview, copyModelAssets, generateIntoLibrary, llmAvailable, llmConfig, respond, templateFile, TEMPLATE_DIRS });
1493
1222
 
1494
1223
  // Baixar o deck aberto: .sagadeck (YAML + imagens, CSS, widgets), PowerPoint, PDF ou roteiro
1495
1224
  const exportKind = (pathname.match(/^\/api\/export\/(sagadeck|pptx|pdf|roteiro|tudo|estudo|estudo-html)$/) || [])[1];
@@ -1497,7 +1226,7 @@ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|num
1497
1226
  const spec = withBase(W, W.spec);
1498
1227
  const name = W.file && !isBundledTemplate(W.file) ? path.basename(W.file).replace(/\.ya?ml$/i, "") : slugify(spec.title);
1499
1228
  // ?ver=1: o material de estudo abre na aba (o que o aluno recebe), em vez de baixar
1500
- await sendExport(res, exportKind, spec, name, { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0", inline: exportKind === "estudo-html" && url.searchParams.get("ver") === "1" });
1229
+ await sendExport(res, exportKind, spec, name, { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0", inline: exportKind === "estudo-html" && url.searchParams.get("ver") === "1", macFonts: url.searchParams.get("macFonts") === "1" });
1501
1230
  return;
1502
1231
  }
1503
1232
 
@@ -8,9 +8,10 @@ export async function slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slid
8
8
  if (pathname === "/api/slides/copy" && req.method === "POST") {
9
9
  const body = await readJSON(req);
10
10
  if (staleTab(body, W, res)) return true;
11
- if (!W.file || !body.slide) throw Error('Abra uma apresentação salva para copiar.');
11
+ const slides = Array.isArray(body.slides) && body.slides.length ? body.slides : [body.slide];
12
+ if (!W.file || !slides[0]) throw Error('Abra uma apresentação salva para copiar.');
12
13
  const token = crypto.randomUUID();
13
- slideClipboard.set(W.user || '', { token, file: W.file, slide: structuredClone(body.slide) });
14
+ slideClipboard.set(W.user || '', { token, file: W.file, slides: structuredClone(slides) });
14
15
  res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ token })); return true;
15
16
  }
16
17
  if (pathname === "/api/slides/paste" && req.method === "POST") {
@@ -19,8 +20,10 @@ export async function slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slid
19
20
  const clip = slideClipboard.get(W.user || '');
20
21
  if (!clip || clip.token !== body.token) throw Error('Copie um slide novamente antes de colar.');
21
22
  if (!W.file || isBundledTemplate(W.file)) materializePreview(W);
22
- const slide = await copySlideAssets(clip.slide, clip.file, W.file);
23
- res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ slide })); return true;
23
+ const from = Array.isArray(clip.slides) && clip.slides.length ? clip.slides : [clip.slide];
24
+ const slides = [];
25
+ for (const one of from) slides.push(await copySlideAssets(one, clip.file, W.file));
26
+ res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ slides, slide: slides[0] })); return true;
24
27
  }
25
28
  return false;
26
29
  }
@@ -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);
package/src/themes.js CHANGED
@@ -599,6 +599,36 @@ export function themeCSS(theme) {
599
599
  }
600
600
 
601
601
  // Mapa usado pelo exportador de PPTX: alias CSS da família -> face do PowerPoint.
602
- export function pptxFontMap(theme) {
603
- return Object.fromEntries(Object.entries(theme.faces).map(([k, f]) => [k, { regular: f.pptx, bold: f.pptxBold || { ...f.pptx, bold: true } }]));
602
+ export function pptxFontMap(theme, { fallback } = {}) {
603
+ const map = Object.fromEntries(Object.entries(theme.faces).map(([k, f]) => [k, { regular: f.pptx, bold: f.pptxBold || { ...f.pptx, bold: true } }]));
604
+ if (fallback === "mac") {
605
+ for (const m of Object.values(map)) {
606
+ m.regular = { ...m.regular, face: macFallbackFace(m.regular.face) };
607
+ m.bold = { ...m.bold, face: macFallbackFace(m.bold.face) };
608
+ }
609
+ }
610
+ return map;
611
+ }
612
+
613
+ // Fontes presentes no Windows e no macOS mesmo sem o Office instalado: o PPTX
614
+ // com --mac-fonts só usa estas. O HTML e o PDF não mudam (continuam idênticos).
615
+ export const PPTX_MAC_SAFE = new Set([
616
+ "Arial", "Arial Narrow", "Arial Black", "Verdana", "Courier New",
617
+ "Georgia", "Comic Sans MS", "Times New Roman",
618
+ ]);
619
+
620
+ // Windows/Office (e as OFL vendorizadas no HTML) -> equivalente dos dois sistemas.
621
+ // A cara é preservada: condensada vira condensada, mono vira mono, mão vira mão.
622
+ export function macFallbackFace(face) {
623
+ if (!face || PPTX_MAC_SAFE.has(face)) return face;
624
+ if (/^Bahnschrift/i.test(face)) return "Arial Narrow";
625
+ if (/^Segoe UI/i.test(face)) return "Arial";
626
+ if (/^Cascadia Mono/i.test(face)) return "Courier New";
627
+ if (/^Century Gothic/i.test(face)) return "Verdana";
628
+ if (/^Franklin Gothic Heavy/i.test(face)) return "Arial Black";
629
+ if (/^Franklin Gothic/i.test(face)) return "Arial";
630
+ if (/^(Ink Free|Segoe Print|Caveat|Patrick Hand|Comic Neue)$/i.test(face)) return "Comic Sans MS";
631
+ if (/^(Press Start 2P|VT323)$/i.test(face)) return "Courier New";
632
+ if (/^(Plus Jakarta Sans|Fredoka|Inter|Futura)$/i.test(face)) return "Arial";
633
+ return "Arial";
604
634
  }