sagadeck 1.2.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/LICENSE +21 -0
- package/README.md +305 -0
- package/SKILL.md +148 -0
- package/bin/sagadeck.js +511 -0
- package/docs/ANDAMENTO.md +1255 -0
- package/docs/EXPERIENCIAS.md +39 -0
- package/docs/PLANO-EXPERIENCIAS.md +39 -0
- package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
- package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
- package/docs/REFERENCIA.md +1131 -0
- package/package.json +59 -0
- package/src/adaptive-layouts.js +18 -0
- package/src/ai/art-direction.js +8 -0
- package/src/ai/commands.js +130 -0
- package/src/ai/context.js +245 -0
- package/src/ai/deck-ai.js +1135 -0
- package/src/ai/directions.js +74 -0
- package/src/ai/document-materials.js +49 -0
- package/src/ai/document-visuals.js +215 -0
- package/src/ai/evaluation.js +31 -0
- package/src/ai/ground.js +55 -0
- package/src/ai/llm.js +292 -0
- package/src/ai/quality.js +51 -0
- package/src/ai/relay.js +45 -0
- package/src/ai/staged-generation.js +24 -0
- package/src/ai/transform.js +858 -0
- package/src/ai/usage.js +17 -0
- package/src/ai/variety.js +80 -0
- package/src/ai/video-frames.js +24 -0
- package/src/ai/video-generation.js +126 -0
- package/src/ai/web-operation.js +31 -0
- package/src/algo-catalog.js +153 -0
- package/src/algo-trace.js +139 -0
- package/src/api-client.js +514 -0
- package/src/api-collections.js +14 -0
- package/src/api-demo.js +222 -0
- package/src/aspect.js +40 -0
- package/src/build.js +323 -0
- package/src/carousel.js +56 -0
- package/src/chrome.js +65 -0
- package/src/code-highlight.js +68 -0
- package/src/code-lab-browser.js +22 -0
- package/src/code-lab.js +3 -0
- package/src/code-language.js +52 -0
- package/src/csv.js +74 -0
- package/src/decision-lab.js +20 -0
- package/src/deck-file.js +119 -0
- package/src/diagram/napkin-examples.js +13 -0
- package/src/diagram/napkin.js +539 -0
- package/src/docx.js +128 -0
- package/src/dynamics/git-sim.js +200 -0
- package/src/dynamics/layouts.js +112 -0
- package/src/elements.js +554 -0
- package/src/experiences.js +124 -0
- package/src/exploration.js +31 -0
- package/src/export/browser.js +53 -0
- package/src/export/estudo.js +67 -0
- package/src/export/pptx.js +475 -0
- package/src/export/roteiro.js +40 -0
- package/src/export/shots.js +182 -0
- package/src/figures/charts.js +383 -0
- package/src/figures/diagrams.js +117 -0
- package/src/figures/icons.js +166 -0
- package/src/figures/material-pictos.js +20 -0
- package/src/figures/pictos.js +255 -0
- package/src/figures/points.js +106 -0
- package/src/figures/qr.js +29 -0
- package/src/figures/ufmap.js +48 -0
- package/src/fiscal/autofix.js +377 -0
- package/src/fiscal/normalize.js +115 -0
- package/src/graph-lab.js +24 -0
- package/src/hologram.js +30 -0
- package/src/identity.js +86 -0
- package/src/import/crop.js +59 -0
- package/src/import/index.js +132 -0
- package/src/import/legacy-word.js +42 -0
- package/src/import/merge.js +45 -0
- package/src/import/office.js +123 -0
- package/src/import/omml.js +69 -0
- package/src/import/pdf-render.js +56 -0
- package/src/import/pptx.js +701 -0
- package/src/import/xml.js +42 -0
- package/src/infographic.js +401 -0
- package/src/layouts.js +723 -0
- package/src/lessons.js +171 -0
- package/src/library.js +399 -0
- package/src/markup.js +116 -0
- package/src/master.js +214 -0
- package/src/mcp/server.js +371 -0
- package/src/meeting.js +42 -0
- package/src/motion.js +56 -0
- package/src/package.js +116 -0
- package/src/playground.js +19 -0
- package/src/portal-scene.js +20 -0
- package/src/preferences.js +61 -0
- package/src/protect.js +51 -0
- package/src/purpose.js +24 -0
- package/src/pytrace.js +1092 -0
- package/src/research/duckduckgo.js +73 -0
- package/src/research/research.js +202 -0
- package/src/research/svg-image.js +17 -0
- package/src/research/visuals.js +52 -0
- package/src/runtime/api-collection-ui.js +5 -0
- package/src/runtime/api-collections.js +28 -0
- package/src/runtime/api-core.js +530 -0
- package/src/runtime/api-ui.js +966 -0
- package/src/runtime/base.css +1774 -0
- package/src/runtime/calc.js +139 -0
- package/src/runtime/code-lab.js +371 -0
- package/src/runtime/decision-lab.js +49 -0
- package/src/runtime/diagram.js +341 -0
- package/src/runtime/fit.js +268 -0
- package/src/runtime/fonts/caveat.css +2 -0
- package/src/runtime/fonts/comic-neue.css +3 -0
- package/src/runtime/fonts/fredoka.css +2 -0
- package/src/runtime/fonts/index.json +10 -0
- package/src/runtime/fonts/inter.css +2 -0
- package/src/runtime/fonts/patrick-hand.css +2 -0
- package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
- package/src/runtime/fonts/press-start-2p.css +2 -0
- package/src/runtime/fonts/vt323.css +2 -0
- package/src/runtime/formula.js +148 -0
- package/src/runtime/graph-lab.js +72 -0
- package/src/runtime/meeting.css +7 -0
- package/src/runtime/meeting.js +10 -0
- package/src/runtime/motion.js +15 -0
- package/src/runtime/playground.js +11 -0
- package/src/runtime/points.js +19 -0
- package/src/runtime/portal-scene.css +6 -0
- package/src/runtime/portal-scene.js +22 -0
- package/src/runtime/runtime.js +827 -0
- package/src/runtime/science.js +89 -0
- package/src/runtime/skins/aluminio.css +31 -0
- package/src/runtime/skins/arcade.css +25 -0
- package/src/runtime/skins/aurora.css +17 -0
- package/src/runtime/skins/bauhaus.css +19 -0
- package/src/runtime/skins/editorial.css +19 -0
- package/src/runtime/skins/jornal.css +17 -0
- package/src/runtime/skins/manual-noite.css +14 -0
- package/src/runtime/skins/manual.css +14 -0
- package/src/runtime/skins/noite.css +21 -0
- package/src/runtime/skins/oceano.css +17 -0
- package/src/runtime/skins/pop.css +23 -0
- package/src/runtime/skins/prata.css +18 -0
- package/src/runtime/skins/prisma.css +28 -0
- package/src/runtime/skins/rabisco.css +18 -0
- package/src/runtime/skins/relevo.css +33 -0
- package/src/runtime/skins/sinal.css +16 -0
- package/src/runtime/skins/terminal.css +23 -0
- package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
- package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
- package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
- package/src/runtime/vendor/katex.css +1 -0
- package/src/runtime/vendor/mermaid.min.js +3636 -0
- package/src/runtime/vendor/plotly.min.js +3620 -0
- package/src/runtime/video-player.js +35 -0
- package/src/science.js +162 -0
- package/src/studio/ai-response.js +36 -0
- package/src/studio/api-routes.js +152 -0
- package/src/studio/assets/contexto/leia-me.md +12 -0
- package/src/studio/assets/contexto/vendas.csv +7 -0
- package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
- package/src/studio/assets/imagens/colecao-cor.png +0 -0
- package/src/studio/assets/imagens/colecao-equipe.png +0 -0
- package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
- package/src/studio/code-routes.js +32 -0
- package/src/studio/command-approval.js +48 -0
- package/src/studio/demo-decks.js +98 -0
- package/src/studio/direction-routes.js +29 -0
- package/src/studio/errors.js +65 -0
- package/src/studio/exploration-demo.js +17 -0
- package/src/studio/exporting.js +105 -0
- package/src/studio/generation-report.js +10 -0
- package/src/studio/instance.js +48 -0
- package/src/studio/layout-samples.js +152 -0
- package/src/studio/project.js +371 -0
- package/src/studio/public/api-collections.js +28 -0
- package/src/studio/public/app.js +5265 -0
- package/src/studio/public/art-preview.js +10 -0
- package/src/studio/public/calc-fields.js +11 -0
- package/src/studio/public/chat-progress.js +47 -0
- package/src/studio/public/developer-fields.js +22 -0
- package/src/studio/public/explorer.js +443 -0
- package/src/studio/public/history.js +58 -0
- package/src/studio/public/index.html +867 -0
- package/src/studio/public/inspector.js +270 -0
- package/src/studio/public/lab-fields.js +5 -0
- package/src/studio/public/library.css +275 -0
- package/src/studio/public/library.html +55 -0
- package/src/studio/public/library.js +471 -0
- package/src/studio/public/merge-decks.js +155 -0
- package/src/studio/public/review-ui.js +100 -0
- package/src/studio/public/screenshot-editor.js +140 -0
- package/src/studio/public/share-ui.js +72 -0
- package/src/studio/public/slide-clipboard.js +23 -0
- package/src/studio/public/slide-form.js +1263 -0
- package/src/studio/public/studio-awareness.js +154 -0
- package/src/studio/public/studio-next.css +303 -0
- package/src/studio/public/style.css +1262 -0
- package/src/studio/public/ui-icons.js +2 -0
- package/src/studio/public/viewers.js +372 -0
- package/src/studio/public/visual-editor.js +326 -0
- package/src/studio/public/workspace.js +18 -0
- package/src/studio/public-files.js +1 -0
- package/src/studio/request-helpers.js +41 -0
- package/src/studio/server.js +1583 -0
- package/src/studio/share-routes.js +146 -0
- package/src/studio/slide-copy-routes.js +26 -0
- package/src/studio/slide-copy.js +30 -0
- package/src/studio/snapshot.js +160 -0
- package/src/studio/style-routes.js +103 -0
- package/src/studio/template-collections.js +97 -0
- package/src/studio/versions.js +95 -0
- package/src/studio/video-download.js +29 -0
- package/src/studio/visual-keys.js +52 -0
- package/src/studio/workspaces.js +39 -0
- package/src/table.js +93 -0
- package/src/templates/scaffold.js +372 -0
- package/src/themes.js +604 -0
- package/src/trace-view.js +450 -0
- package/src/uid.js +19 -0
- package/src/video-player.js +40 -0
- package/src/visual-edits.js +99 -0
- package/src/ws.js +134 -0
- package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
- package/templates/cenario/imagens/cidade.svg +30 -0
- package/templates/cenario/imagens/pessoa.svg +15 -0
- package/templates/cenario-e-ciencia.yaml +47 -0
- package/templates/ensaio-api.yaml +160 -0
- package/templates/exemplo-alegre.yaml +75 -0
- package/templates/exemplo-keynote.yaml +90 -0
- package/templates/exemplo-napkin.yaml +139 -0
- package/templates/exemplo.yaml +228 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// Napkin AI Engine for SagaDeck
|
|
2
|
+
// Transforma texto bruto, notas de reunião, bullets e processos em diagramas visuais inteligentes e editáveis.
|
|
3
|
+
// Detecta intenção semântica: fluxo sequencial (steps), KPIs (stats/number), contraste (compare), pilares (cards), citação (quote) ou impacto (statement).
|
|
4
|
+
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { resolveIconName } from "../figures/icons.js";
|
|
7
|
+
|
|
8
|
+
// Mapeamento semântico de palavras-chave para ícones adequados
|
|
9
|
+
const KEYWORD_ICONS = [
|
|
10
|
+
{ match: /\b(prospec|pesquis|busc|descobert|mape|explor|encontr|lead)/i, icon: "compass" },
|
|
11
|
+
{ match: /\b(valid|qualif|verific|audit|aprov|check|test)/i, icon: "check-circle" },
|
|
12
|
+
{ match: /\b(demonstr|apresent|pitch|demo|reuni|talk)/i, icon: "presentation" },
|
|
13
|
+
{ match: /\b(fech|contrat|assin|acord|deal|negoci)/i, icon: "file-check" },
|
|
14
|
+
{ match: /\b(onboard|implant|integr|instal|setup|inici)/i, icon: "rocket" },
|
|
15
|
+
{ match: /\b(venda|receit|lucro|fatur|faturamento|crescim|roi|arr|mrr)/i, icon: "trending-up" },
|
|
16
|
+
{ match: /\b(custo|despes|pre[cç]o|dinheir|pagament|investim|economia)/i, icon: "banknote" },
|
|
17
|
+
{ match: /\b(seguran[cç]|privacid|prote[cç]|complianc|blind|cripto|biometr)/i, icon: "shield-check" },
|
|
18
|
+
{ match: /\b(velocid|r[aá]pid|efici[eê]nc|aceler|performan|tempo|agil)/i, icon: "zap" },
|
|
19
|
+
{ match: /\b(equip|time|pessoal|pessoas|usu[aá]ri|colaborad|client)/i, icon: "users" },
|
|
20
|
+
{ match: /\b(dado|analit|m[eé]tric|kpi|relat|dashboard|insights)/i, icon: "bar-chart-3" },
|
|
21
|
+
{ match: /\b(ia|intelig[eê]nc|autom|algoritm|rob[oô]|smart)/i, icon: "sparkles" },
|
|
22
|
+
{ match: /\b(ideia|inova[cç]|pensament|estrat[eé]g|criativ)/i, icon: "lightbulb" },
|
|
23
|
+
{ match: /\b(alvo|meta|objetiv|foco|dire[cç])/i, icon: "target" },
|
|
24
|
+
{ match: /\b(conex|red[e]|integr|api|ecossistem|link)/i, icon: "share-2" },
|
|
25
|
+
{ match: /\b(ferrament|sistema|engrenag|config|infra|motor)/i, icon: "settings" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function deduceIcon(text, fallback = "sparkles") {
|
|
29
|
+
if (!text) return fallback;
|
|
30
|
+
for (const rule of KEYWORD_ICONS) {
|
|
31
|
+
if (rule.match.test(text)) return resolveIconName(rule.icon);
|
|
32
|
+
}
|
|
33
|
+
return resolveIconName(fallback);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Limpa marcadores de listas (-, *, •, 1., a))
|
|
37
|
+
function stripListMarker(line) {
|
|
38
|
+
return line.replace(/^[-*•]\s+/, "").replace(/^\d+[\.\)]\s+/, "").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Converte um bloco de texto bruto em um slide de diagrama visual inteligente (estilo Napkin AI).
|
|
43
|
+
*/
|
|
44
|
+
function classifyText(rawText, options = {}) {
|
|
45
|
+
let text = (rawText || "").trim();
|
|
46
|
+
if (!text) {
|
|
47
|
+
return {
|
|
48
|
+
slide: { layout: "statement", text: "Ideia em branco" },
|
|
49
|
+
detectedType: "statement",
|
|
50
|
+
confidence: 0,
|
|
51
|
+
rationale: "Texto vazio fornecido.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Expande listas numeradas ou em bullets na mesma linha (ex: "1. Setup 2. Treino 3. Go-live")
|
|
56
|
+
text = text.replace(/(\s+)(\d+[\.\)])\s+/g, "\n$2 ");
|
|
57
|
+
// Separa título introdutório se houver múltiplos dois-pontos na primeira linha
|
|
58
|
+
text = text.replace(/^([^:\n]{3,40}):\s*(?=[A-Za-zÀ-ÿ0-9\s]+:)/, "$1\n");
|
|
59
|
+
// Expande separadores em linha como bullets ou ponto mediano (·, |, ;)
|
|
60
|
+
text = text.replace(/(\s*[·|;]\s*)/g, "\n");
|
|
61
|
+
|
|
62
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
63
|
+
|
|
64
|
+
// 1. Extração preliminar de Título e Kicker
|
|
65
|
+
let extractedTitle = "";
|
|
66
|
+
let extractedKicker = options.kicker || "";
|
|
67
|
+
let contentLines = [...lines];
|
|
68
|
+
|
|
69
|
+
const firstLine = lines[0];
|
|
70
|
+
const isHeaderLine =
|
|
71
|
+
firstLine.startsWith("#") ||
|
|
72
|
+
firstLine.endsWith(":") ||
|
|
73
|
+
(lines.length > 2 && firstLine.length < 60 && !/^[-*•\d]/.test(firstLine));
|
|
74
|
+
|
|
75
|
+
if (isHeaderLine) {
|
|
76
|
+
extractedTitle = firstLine.replace(/^#+\s*/, "").replace(/:$/, "").trim();
|
|
77
|
+
contentLines = lines.slice(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Tenta extrair kicker entre colchetes ou antes de barra: [VENDAS] ou PROCESSO //
|
|
81
|
+
const kickerMatch = extractedTitle.match(/^\[(.*?)\]\s*(.*)$/) || extractedTitle.match(/^(.*?)\s*[\/|]\s*(.*)$/);
|
|
82
|
+
if (kickerMatch && kickerMatch[2]) {
|
|
83
|
+
extractedKicker = kickerMatch[1].trim();
|
|
84
|
+
extractedTitle = kickerMatch[2].trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Se não extraiu título do header, gera um trigger title curto
|
|
88
|
+
if (!extractedTitle) {
|
|
89
|
+
extractedTitle = options.title || "Visão Geral";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 2. Análise Semântica de Padrões (Napkin Pattern Classifier)
|
|
93
|
+
|
|
94
|
+
// PADRÃO 1: Citação (Quote)
|
|
95
|
+
const isQuote =
|
|
96
|
+
(text.startsWith('"') || text.startsWith('“') || text.includes('”') || text.includes(' - ')) &&
|
|
97
|
+
(text.includes(' - ') || text.includes(' — ') || lines.some((l) => /^[-—]\s*[A-Z]/.test(l)));
|
|
98
|
+
if (isQuote && lines.length <= 4) {
|
|
99
|
+
const fullText = lines.join(" ");
|
|
100
|
+
const quoteMatch = fullText.match(/^["“]?([^"”]+)["”]?\s*[-—]\s*(.*)$/);
|
|
101
|
+
if (quoteMatch) {
|
|
102
|
+
return {
|
|
103
|
+
slide: {
|
|
104
|
+
layout: "quote",
|
|
105
|
+
quote: quoteMatch[1].trim(),
|
|
106
|
+
by: quoteMatch[2].trim(),
|
|
107
|
+
tone: options.tone || "dark",
|
|
108
|
+
},
|
|
109
|
+
detectedType: "quote",
|
|
110
|
+
confidence: 0.95,
|
|
111
|
+
rationale: "Detectada citação com atribuição de autor.",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// PADRÃO 2: Código (Code)
|
|
117
|
+
const hasCodeBlock = text.includes("```") || (lines.length > 2 && lines.filter((l) => /^(const|let|var|function|import|export|class|def|return)\b/.test(l)).length >= 2);
|
|
118
|
+
if (hasCodeBlock) {
|
|
119
|
+
const cleanCode = text.replace(/```[a-z]*\n?/gi, "").trim();
|
|
120
|
+
return {
|
|
121
|
+
slide: {
|
|
122
|
+
layout: "code",
|
|
123
|
+
title: extractedTitle !== "Visão Geral" ? extractedTitle : "Implementação",
|
|
124
|
+
code: cleanCode,
|
|
125
|
+
lang: options.lang || "javascript",
|
|
126
|
+
kicker: extractedKicker || "CÓDIGO",
|
|
127
|
+
},
|
|
128
|
+
detectedType: "code",
|
|
129
|
+
confidence: 0.98,
|
|
130
|
+
rationale: "Detectado bloco de código ou sintaxe de programação.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// PADRÃO 3: Comparação / Contraste / Prós e Contras / Antes vs Depois (Compare)
|
|
135
|
+
const compareTriggerRegex = /\b(vs|versus|contra|antes\s*(?:x|vs|e|\/)\s*depois|problema\s*(?:vs|x)\s*solu[cç][aã]o|pr[oó]s\s*(?:e|x|vs)\s*contras|vantagens\s*(?:e|x|vs)\s*desvantagens)\b/i;
|
|
136
|
+
const hasCompareHeader = compareTriggerRegex.test(extractedTitle) || compareTriggerRegex.test(firstLine);
|
|
137
|
+
const leftRightSections = splitIntoCompareSections(contentLines);
|
|
138
|
+
|
|
139
|
+
if (hasCompareHeader || leftRightSections) {
|
|
140
|
+
const leftTitle = leftRightSections?.leftTitle || "Antes / Tradicional";
|
|
141
|
+
const rightTitle = leftRightSections?.rightTitle || "Depois / Com IA";
|
|
142
|
+
const leftItems = (leftRightSections?.leftItems && leftRightSections.leftItems.length > 0)
|
|
143
|
+
? leftRightSections.leftItems
|
|
144
|
+
: ["Processo manual lento", "Alto custo de equipe", "Risco de erro humano"];
|
|
145
|
+
const rightItems = (leftRightSections?.rightItems && leftRightSections.rightItems.length > 0)
|
|
146
|
+
? leftRightSections.rightItems
|
|
147
|
+
: ["Automação instantânea", "Zero custo extra", "Precisão determinística"];
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
slide: {
|
|
151
|
+
layout: "compare",
|
|
152
|
+
kicker: extractedKicker || "COMPARAÇÃO",
|
|
153
|
+
title: extractedTitle !== "Visão Geral" ? extractedTitle : "Diferencial Competitivo",
|
|
154
|
+
left: {
|
|
155
|
+
title: leftTitle,
|
|
156
|
+
items: leftItems,
|
|
157
|
+
},
|
|
158
|
+
right: {
|
|
159
|
+
title: rightTitle,
|
|
160
|
+
items: rightItems,
|
|
161
|
+
},
|
|
162
|
+
tone: options.tone || "dark",
|
|
163
|
+
},
|
|
164
|
+
detectedType: "compare",
|
|
165
|
+
confidence: 0.92,
|
|
166
|
+
rationale: "Detectado contraste entre duas ideias, estados (antes/depois) ou prós/contras.",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// PADRÃO 4: Fluxo Sequencial / Processo / Pipeline / Roadmap (Steps) - prioridade antes de métricas se houver marcadores ordinais
|
|
171
|
+
const isSequential = detectSequentialFlow(contentLines, extractedTitle);
|
|
172
|
+
if (isSequential.matches) {
|
|
173
|
+
const stepItems = isSequential.items.map((it, idx) => ({
|
|
174
|
+
step: idx + 1,
|
|
175
|
+
title: it.title,
|
|
176
|
+
desc: it.desc || "",
|
|
177
|
+
icon: it.icon || deduceIcon(it.title + " " + it.desc, idx === 0 ? "compass" : idx === isSequential.items.length - 1 ? "rocket" : "zap"),
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
slide: {
|
|
182
|
+
layout: "steps",
|
|
183
|
+
kicker: extractedKicker || "FLUXO DE TRABALHO",
|
|
184
|
+
title: extractedTitle,
|
|
185
|
+
steps: stepItems.slice(0, 5),
|
|
186
|
+
tone: options.tone || "dark",
|
|
187
|
+
},
|
|
188
|
+
detectedType: "steps",
|
|
189
|
+
confidence: 0.94,
|
|
190
|
+
rationale: `Detectado processo em ${stepItems.length} etapas sequenciais com marcadores cronológicos.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// PADRÃO 5: Métricas & KPIs (Stats / Number)
|
|
195
|
+
const statItems = extractMetrics(contentLines);
|
|
196
|
+
if (statItems.length >= 2) {
|
|
197
|
+
return {
|
|
198
|
+
slide: {
|
|
199
|
+
layout: "stats",
|
|
200
|
+
kicker: extractedKicker || "MÉTRICAS-CHAVE",
|
|
201
|
+
title: extractedTitle,
|
|
202
|
+
stats: statItems.slice(0, 4),
|
|
203
|
+
tone: options.tone || "dark",
|
|
204
|
+
},
|
|
205
|
+
detectedType: "stats",
|
|
206
|
+
confidence: 0.9,
|
|
207
|
+
rationale: `Detectados ${statItems.length} indicadores quantitativos (percentuais, moeda ou multiplicadores).`,
|
|
208
|
+
};
|
|
209
|
+
} else if (statItems.length === 1 && lines.length <= 4) {
|
|
210
|
+
return {
|
|
211
|
+
slide: {
|
|
212
|
+
layout: "number",
|
|
213
|
+
kicker: extractedKicker || "IMPACTO",
|
|
214
|
+
value: statItems[0].value,
|
|
215
|
+
label: statItems[0].label || extractedTitle,
|
|
216
|
+
note: statItems[0].note || "",
|
|
217
|
+
tone: options.tone || "accent",
|
|
218
|
+
},
|
|
219
|
+
detectedType: "number",
|
|
220
|
+
confidence: 0.93,
|
|
221
|
+
rationale: "Detectado KPI único de alto impacto.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// PADRÃO 6: Grade de Pilares / Benefícios / Módulos (Cards)
|
|
226
|
+
const cardItems = extractCardItems(contentLines);
|
|
227
|
+
if (cardItems.length >= 2 && cardItems.length <= 4) {
|
|
228
|
+
return {
|
|
229
|
+
slide: {
|
|
230
|
+
layout: "cards",
|
|
231
|
+
kicker: extractedKicker || "PILARES FUNDAMENTAIS",
|
|
232
|
+
title: extractedTitle,
|
|
233
|
+
cards: cardItems.map((c) => ({
|
|
234
|
+
title: c.title,
|
|
235
|
+
desc: c.desc,
|
|
236
|
+
icon: deduceIcon(c.title + " " + c.desc, "sparkles"),
|
|
237
|
+
})),
|
|
238
|
+
tone: options.tone || "dark",
|
|
239
|
+
},
|
|
240
|
+
detectedType: "cards",
|
|
241
|
+
confidence: 0.85,
|
|
242
|
+
rationale: `Detectada estrutura multi-pilar com ${cardItems.length} blocos conceituais independentes.`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// PADRÃO 7: Frase de Impacto Única (Statement)
|
|
247
|
+
if (lines.length <= 2 && text.length < 140 && !text.includes(":")) {
|
|
248
|
+
return {
|
|
249
|
+
slide: {
|
|
250
|
+
layout: "statement",
|
|
251
|
+
kicker: extractedKicker,
|
|
252
|
+
text: text,
|
|
253
|
+
tone: options.tone || "accent",
|
|
254
|
+
},
|
|
255
|
+
detectedType: "statement",
|
|
256
|
+
confidence: 0.82,
|
|
257
|
+
rationale: "Texto curto e direto de alta densidade sem marcadores secundários.",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// PADRÃO 8: Fallback Inteligente (Split: Narrativa + Destaque)
|
|
262
|
+
const bulletItems = contentLines.map(stripListMarker).filter(Boolean);
|
|
263
|
+
return {
|
|
264
|
+
slide: {
|
|
265
|
+
layout: "split",
|
|
266
|
+
kicker: extractedKicker || "CONCEITO",
|
|
267
|
+
title: extractedTitle,
|
|
268
|
+
left: bulletItems.slice(0, 4),
|
|
269
|
+
right: bulletItems.slice(4).length > 0 ? bulletItems.slice(4, 8) : undefined,
|
|
270
|
+
tone: options.tone || "dark",
|
|
271
|
+
},
|
|
272
|
+
detectedType: "split",
|
|
273
|
+
confidence: 0.75,
|
|
274
|
+
rationale: "Estrutura descritiva formatada com layout dividido para equilíbrio visual.",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Auxiliar: Detecta se o texto é um fluxo sequencial
|
|
279
|
+
function detectSequentialFlow(lines, title = "") {
|
|
280
|
+
const nonFlowKeywords = /\b(pilar|pilares|princ[ií]pio|princ[ií]pios|benef[ií]cio|benef[ií]cios|vantagen|vantagens|recurso|recursos|m[oó]dulo|m[oó]dulos|diferencia|diferenciais)\b/i;
|
|
281
|
+
if (nonFlowKeywords.test(title)) return { matches: false, items: [] };
|
|
282
|
+
|
|
283
|
+
const flowKeywords = /\b(fase|etapa|passo|passos|fluxo|pipeline|roadmap|jornada|ciclo|step|primeiro|segundo|terceiro|em seguida|depois|finalmente)\b/i;
|
|
284
|
+
const isExplicitFlow = flowKeywords.test(title);
|
|
285
|
+
|
|
286
|
+
const sequentialItems = [];
|
|
287
|
+
let numberedMatches = 0;
|
|
288
|
+
|
|
289
|
+
for (const line of lines) {
|
|
290
|
+
const numMatch = line.match(/^(\d+)[\.\)]\s*(.*)$/);
|
|
291
|
+
if (numMatch) {
|
|
292
|
+
numberedMatches++;
|
|
293
|
+
const [t, d] = splitTitleDesc(numMatch[2]);
|
|
294
|
+
sequentialItems.push({ title: t, desc: d });
|
|
295
|
+
} else if (line.startsWith("-") || line.startsWith("*") || line.startsWith("•")) {
|
|
296
|
+
const stripped = stripListMarker(line);
|
|
297
|
+
const [t, d] = splitTitleDesc(stripped);
|
|
298
|
+
sequentialItems.push({ title: t, desc: d });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (numberedMatches >= 2 || (isExplicitFlow && sequentialItems.length >= 2)) {
|
|
303
|
+
return { matches: true, items: sequentialItems.slice(0, 5) };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { matches: false, items: [] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Auxiliar: Extrai métricas e valores quantitativos
|
|
310
|
+
function extractMetrics(lines) {
|
|
311
|
+
const stats = [];
|
|
312
|
+
const metricRegex = /([+~-]?\s*(?:R\$\s*|\$\s*|USD\s*|€\s*)?\d+[.,\d]*\s*(?:%|milhões|bilhões|mil|mi|bi|ms|fps|x|M|k|s|h)?)/i;
|
|
313
|
+
|
|
314
|
+
for (const line of lines) {
|
|
315
|
+
const clean = stripListMarker(line);
|
|
316
|
+
// Ex: "99.9% de uptime garantido no SLA" ou "Crescimento: +140% no ano"
|
|
317
|
+
const colonParts = clean.split(":");
|
|
318
|
+
if (colonParts.length === 2) {
|
|
319
|
+
const p1 = colonParts[0].trim();
|
|
320
|
+
const p2 = colonParts[1].trim();
|
|
321
|
+
const valMatch1 = p1.match(metricRegex);
|
|
322
|
+
const valMatch2 = p2.match(metricRegex);
|
|
323
|
+
|
|
324
|
+
if (valMatch1 && isNumericMetric(valMatch1[1])) {
|
|
325
|
+
stats.push({ value: valMatch1[1].trim(), label: p2, icon: deduceIcon(p2, "trending-up") });
|
|
326
|
+
continue;
|
|
327
|
+
} else if (valMatch2 && (isNumericMetric(valMatch2[1]) || /^\d+$/.test(valMatch2[1].trim()))) {
|
|
328
|
+
stats.push({ value: valMatch2[1].trim(), label: p1, icon: deduceIcon(p1, "trending-up") });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Procura número isolado no início da frase
|
|
334
|
+
const leadMatch = clean.match(/^([+~-]?\s*(?:R\$\s*|\$\s*)?\d+[.,\d]*\s*(?:%|x|k|M|bi|mi)?)\s+[-–—]?\s*(.*)$/i);
|
|
335
|
+
if (leadMatch && isNumericMetric(leadMatch[1])) {
|
|
336
|
+
stats.push({ value: leadMatch[1].trim(), label: leadMatch[2].trim(), icon: deduceIcon(leadMatch[2], "trending-up") });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return stats;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isNumericMetric(str) {
|
|
344
|
+
if (!str) return false;
|
|
345
|
+
const s = str.trim();
|
|
346
|
+
if (/^\d+[\.\)]$/.test(s)) return false;
|
|
347
|
+
const hasUnit = /[%xkmMbi$R€]/i.test(s);
|
|
348
|
+
const isFormattedNumber = /^\d+([.,]\d+)+$/.test(s);
|
|
349
|
+
return hasUnit || isFormattedNumber;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Auxiliar: Separa seções para Compare
|
|
353
|
+
function splitIntoCompareSections(lines) {
|
|
354
|
+
let leftTitle = "Antes";
|
|
355
|
+
let rightTitle = "Depois";
|
|
356
|
+
const leftItems = [];
|
|
357
|
+
const rightItems = [];
|
|
358
|
+
let currentSide = "left";
|
|
359
|
+
|
|
360
|
+
const sideRegex = /^(antes|problema|tradicional|atual|antigo|sem ia|contra|lado a|de um lado)/i;
|
|
361
|
+
const rightRegex = /^(depois|solu[cç][aã]o|sagadeck|novo|com ia|pr[oó]|lado b|do outro lado)/i;
|
|
362
|
+
|
|
363
|
+
for (const line of lines) {
|
|
364
|
+
const colonIdx = line.indexOf(":");
|
|
365
|
+
if (colonIdx > 0 && colonIdx <= 25) {
|
|
366
|
+
const prefix = line.slice(0, colonIdx).trim();
|
|
367
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
368
|
+
if (sideRegex.test(prefix)) {
|
|
369
|
+
leftTitle = prefix;
|
|
370
|
+
currentSide = "left";
|
|
371
|
+
if (rest) leftItems.push(stripListMarker(rest));
|
|
372
|
+
continue;
|
|
373
|
+
} else if (rightRegex.test(prefix)) {
|
|
374
|
+
rightTitle = prefix;
|
|
375
|
+
currentSide = "right";
|
|
376
|
+
if (rest) rightItems.push(stripListMarker(rest));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Detecção "X vs Y" na mesma linha
|
|
382
|
+
if (line.includes(" vs ") || line.includes(" x ")) {
|
|
383
|
+
const [l, r] = line.split(/\s+(?:vs|x)\s+/i);
|
|
384
|
+
if (l && r) {
|
|
385
|
+
leftItems.push(stripListMarker(l));
|
|
386
|
+
rightItems.push(stripListMarker(r));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const clean = stripListMarker(line);
|
|
392
|
+
if (!clean) continue;
|
|
393
|
+
if (currentSide === "left") leftItems.push(clean);
|
|
394
|
+
else rightItems.push(clean);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (leftItems.length > 0 && rightItems.length > 0) {
|
|
398
|
+
return { leftTitle, rightTitle, leftItems: leftItems.slice(0, 5), rightItems: rightItems.slice(0, 5) };
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Auxiliar: Extrai cards conceituais
|
|
404
|
+
function extractCardItems(lines) {
|
|
405
|
+
const cards = [];
|
|
406
|
+
for (const line of lines) {
|
|
407
|
+
const clean = stripListMarker(line);
|
|
408
|
+
if (!clean) continue;
|
|
409
|
+
const [title, desc] = splitTitleDesc(clean);
|
|
410
|
+
cards.push({ title, desc });
|
|
411
|
+
}
|
|
412
|
+
return cards;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Separa "Título: Descrição" ou "Título - Descrição"
|
|
416
|
+
function splitTitleDesc(text) {
|
|
417
|
+
const match = text.match(/^(.*?)\s*[:–—]\s*(.*)$/);
|
|
418
|
+
if (match && match[1].length < 40) {
|
|
419
|
+
return [match[1].trim(), match[2].trim()];
|
|
420
|
+
}
|
|
421
|
+
// sem separador (":" ou travessão), a frase inteira é o título — cortar no meio estraga o sentido
|
|
422
|
+
return [text, ""];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Converte notas completas ou texto longo em um deck inteiro de slides visuais.
|
|
427
|
+
*/
|
|
428
|
+
export function textToVisualDeck(rawText, options = {}) {
|
|
429
|
+
const text = (rawText || "").trim();
|
|
430
|
+
// Divide por delimitador triplo --- ou por quebras de linha duplas com headers #
|
|
431
|
+
const chunks = text
|
|
432
|
+
.split(/(?:\n\s*---\s*\n|\n(?=#\s+))/)
|
|
433
|
+
.map((c) => c.trim())
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
|
|
436
|
+
const slides = [];
|
|
437
|
+
for (const chunk of chunks) {
|
|
438
|
+
const res = textToVisualSlide(chunk, options);
|
|
439
|
+
slides.push(res.slide);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
title: options.title || slides[0]?.title || "Apresentação Visual",
|
|
444
|
+
theme: options.theme || "sinal",
|
|
445
|
+
slides: slides.length > 0 ? slides : [textToVisualSlide(rawText, options).slide],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Retorna YAML pronto para copiar ou salvar em arquivo.
|
|
451
|
+
*/
|
|
452
|
+
export function napkinToYaml(rawText, options = {}) {
|
|
453
|
+
const deck = textToVisualDeck(rawText, options);
|
|
454
|
+
return YAML.stringify(deck, { indent: 2 });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------------------------
|
|
458
|
+
// Camada pública: estruturas específicas (linha do tempo, funil, pirâmide, agenda, matriz) antes do
|
|
459
|
+
// classificador genérico, e saída sempre no formato que os layouts de verdade leem.
|
|
460
|
+
// ---------------------------------------------------------------------------------------------
|
|
461
|
+
const GENERIC_KICKERS = new Set(["PILARES FUNDAMENTAIS", "FLUXO DE TRABALHO", "MÉTRICAS-CHAVE", "IMPACTO", "CONCEITO", "COMPARAÇÃO", "CÓDIGO"]);
|
|
462
|
+
const INVENTED = ["Processo manual lento", "Alto custo de equipe", "Risco de erro humano", "Automação instantânea", "Zero custo extra", "Precisão determinística"];
|
|
463
|
+
|
|
464
|
+
export function textToVisualSlide(rawText, options = {}) {
|
|
465
|
+
const text = String(rawText || "").trim();
|
|
466
|
+
const special = text ? detectStructure(text, options) : null;
|
|
467
|
+
const r = special || classifyText(text, options);
|
|
468
|
+
r.slide = tidySlide(r.slide, options);
|
|
469
|
+
return r;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function headAndBody(text) {
|
|
473
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
474
|
+
const first = lines[0] || "";
|
|
475
|
+
const isHead = lines.length > 2 && (first.endsWith(":") || !/^([-*•]|\d+[.)]|\d{4}\b)/.test(first));
|
|
476
|
+
return { title: isHead ? first.replace(/^#+\s*/, "").replace(/:$/, "").trim() : "", body: isHead ? lines.slice(1) : lines };
|
|
477
|
+
}
|
|
478
|
+
const strip = (l) => l.replace(/^([-*•]|\d+[.)])\s*/, "").trim();
|
|
479
|
+
const splitKV = (l) => {
|
|
480
|
+
const m = strip(l).match(/^(.+?)\s*[:—–]\s+(.+)$/) || strip(l).match(/^(.+?)\s+-\s+(.+)$/);
|
|
481
|
+
return m ? [m[1].trim(), m[2].trim()] : [strip(l), ""];
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
function detectStructure(text, options) {
|
|
485
|
+
const { title, body } = headAndBody(text);
|
|
486
|
+
const t = `${title} ${options.layout || ""}`.toLowerCase();
|
|
487
|
+
const base = (layout, extra, why) => ({ slide: { layout, ...(title ? { title } : {}), ...extra }, detectedType: layout, confidence: 0.9, rationale: why });
|
|
488
|
+
if (body.length < 2) return null;
|
|
489
|
+
|
|
490
|
+
// linha do tempo: linhas que começam com ano (2019 — …, 2021: …, Q3 2024 - …)
|
|
491
|
+
const dated = body.map((l) => strip(l).match(/^((?:Q[1-4]\s*)?(?:19|20)\d{2}|(?:jan|fev|mar|abr|mai|jun|jul|ago|set|out|nov|dez)\w*\.?\s*(?:de\s*)?(?:19|20)?\d{2,4})\s*[:—–-]\s*(.+)$/i));
|
|
492
|
+
if (dated.filter(Boolean).length >= 2 && dated.filter(Boolean).length >= body.length - 1) {
|
|
493
|
+
return base("timeline", { events: dated.filter(Boolean).map((m) => { const [ti, tx] = splitKV(m[2]); return { when: m[1], title: ti, ...(tx ? { text: tx } : {}) }; }) },
|
|
494
|
+
"Linhas começam com datas: linha do tempo.");
|
|
495
|
+
}
|
|
496
|
+
if (/\bfunil\b|funnel/.test(t)) {
|
|
497
|
+
return base("funnel", { stages: body.map((l) => { const [a, b] = splitKV(l); return b ? { title: a, value: b } : { title: a }; }) }, "O texto fala de um funil: etapas que afunilam.");
|
|
498
|
+
}
|
|
499
|
+
if (/pir[aâ]mide|pyramid/.test(t)) {
|
|
500
|
+
return base("pyramid", { levels: body.map((l) => { const [a, b] = splitKV(l); return /^(topo|meio|base)$/i.test(a) && b ? { title: b } : b ? { title: a, text: b } : { title: a }; }) }, "O texto descreve níveis de uma pirâmide.");
|
|
501
|
+
}
|
|
502
|
+
if (/\bagenda\b|programa[cç][aã]o|cronograma do (evento|dia)/.test(t)) {
|
|
503
|
+
return base("agenda", { items: body.map((l) => { const m = strip(l).match(/^(.*?)\s*\(([^)]*\d[^)]*)\)\s*$/); return m ? { title: m[1], time: m[2] } : { title: strip(l) }; }) }, "O texto é uma agenda.");
|
|
504
|
+
}
|
|
505
|
+
if ((/matriz|2\s*[x×]\s*2|impacto e esfor[cç]o/.test(t)) && body.length === 4) {
|
|
506
|
+
const cells = body.map((l) => { const [a, b] = splitKV(l); return b ? { title: b, text: a } : { title: a }; });
|
|
507
|
+
return base("matrix", { cells }, "Quatro quadrantes: matriz 2×2.");
|
|
508
|
+
}
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Saída no formato que os layouts leem (nada de campos que o motor ignora nem conteúdo inventado).
|
|
513
|
+
function tidySlide(slide, options) {
|
|
514
|
+
const s = { ...slide };
|
|
515
|
+
if (s.kicker && GENERIC_KICKERS.has(s.kicker) && !options.kicker) delete s.kicker;
|
|
516
|
+
const fixItem = (x) => {
|
|
517
|
+
if (!x || typeof x !== "object") return x;
|
|
518
|
+
const o = { ...x };
|
|
519
|
+
if (o.desc != null && o.text == null) o.text = o.desc;
|
|
520
|
+
delete o.desc;
|
|
521
|
+
delete o.step; // "step" no item = aparece no clique N; o napkin não deve decidir animação
|
|
522
|
+
if (o.text === "") delete o.text;
|
|
523
|
+
if (typeof o.title === "string") o.title = o.title.charAt(0).toUpperCase() + o.title.slice(1);
|
|
524
|
+
return o;
|
|
525
|
+
};
|
|
526
|
+
if (s.layout === "cards" && Array.isArray(s.cards) && !s.items) { s.items = s.cards; delete s.cards; }
|
|
527
|
+
for (const k of ["items", "steps", "stats", "stages", "levels", "events", "cells"]) if (Array.isArray(s[k])) s[k] = s[k].map(fixItem);
|
|
528
|
+
if (s.layout === "split" && (s.left || s.right)) {
|
|
529
|
+
s.bullets = [...(Array.isArray(s.left) ? s.left : []), ...(Array.isArray(s.right) ? s.right : [])];
|
|
530
|
+
delete s.left; delete s.right;
|
|
531
|
+
}
|
|
532
|
+
if (s.layout === "compare") {
|
|
533
|
+
for (const side of ["left", "right"]) {
|
|
534
|
+
const it = s[side]?.items;
|
|
535
|
+
if (Array.isArray(it) && it.some((x) => INVENTED.includes(x))) s[side] = { ...s[side], items: [] };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return s;
|
|
539
|
+
}
|
package/src/docx.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Leitura de .docx (só para ver: o Word é o dono do formato). O docx é um zip de XML; aqui vira HTML simples:
|
|
2
|
+
// títulos, parágrafos com negrito/itálico/sublinhado/cor, listas, links, tabelas e imagens embutidas. Sem biblioteca
|
|
3
|
+
// extra além do jszip, e sem nada que rode do documento.
|
|
4
|
+
import JSZip from "jszip";
|
|
5
|
+
|
|
6
|
+
const unxml = (s) => String(s).replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
7
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8
|
+
const attr = (xml, name) => xml.match(new RegExp(`${name}="([^"]*)"`))?.[1];
|
|
9
|
+
const MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", bmp: "image/bmp", svg: "image/svg+xml", webp: "image/webp" };
|
|
10
|
+
|
|
11
|
+
// elementos de primeiro nível do corpo, na ordem (parágrafo ou tabela), sem confundir tabela dentro de tabela
|
|
12
|
+
function topLevel(xml, tags) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const re = new RegExp(`<(/?)(${tags.join("|")})\\b[^>]*?(/?)>`, "g");
|
|
15
|
+
let depth = 0, start = -1, tag = null, m;
|
|
16
|
+
while ((m = re.exec(xml))) {
|
|
17
|
+
const [full, close, name, self] = m;
|
|
18
|
+
if (!close && !self) { if (depth === 0) { start = m.index; tag = name; } depth++; }
|
|
19
|
+
else if (!close && self) { if (depth === 0) out.push({ tag: name, xml: full }); }
|
|
20
|
+
else { depth--; if (depth === 0 && start >= 0) { out.push({ tag, xml: xml.slice(start, m.index + full.length) }); start = -1; } }
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function docxToHtml(buf, { maxImage = 4 * 1024 * 1024 } = {}) {
|
|
26
|
+
const zip = await JSZip.loadAsync(buf);
|
|
27
|
+
const doc = await zip.file("word/document.xml")?.async("string");
|
|
28
|
+
if (!doc) throw new Error("Não parece um .docx (falta word/document.xml).");
|
|
29
|
+
const rels = new Map([...((await zip.file("word/_rels/document.xml.rels")?.async("string")) || "").matchAll(/<Relationship\b([^>]*)\/?>/g)].map((m) => [attr(m[1], "Id"), { target: attr(m[1], "Target"), mode: attr(m[1], "TargetMode") }]));
|
|
30
|
+
const stylesXml = (await zip.file("word/styles.xml")?.async("string")) || "";
|
|
31
|
+
const styleName = new Map([...stylesXml.matchAll(/<w:style\b[^>]*w:styleId="([^"]+)"[^>]*>([\s\S]*?)<\/w:style>/g)].map((m) => [m[1], (m[2].match(/<w:name w:val="([^"]+)"/)?.[1] || m[1]).toLowerCase()]));
|
|
32
|
+
const numXml = (await zip.file("word/numbering.xml")?.async("string")) || "";
|
|
33
|
+
const absFmt = new Map([...numXml.matchAll(/<w:abstractNum\b[^>]*w:abstractNumId="(\d+)"[^>]*>([\s\S]*?)<\/w:abstractNum>/g)].map((m) => [m[1], m[2].match(/<w:lvl w:ilvl="0"[\s\S]*?<w:numFmt w:val="([^"]+)"/)?.[1] || "bullet"]));
|
|
34
|
+
const numFmt = new Map([...numXml.matchAll(/<w:num w:numId="(\d+)"[^>]*>[\s\S]*?<w:abstractNumId w:val="(\d+)"/g)].map((m) => [m[1], absFmt.get(m[2]) || "bullet"]));
|
|
35
|
+
const images = new Map();
|
|
36
|
+
const image = async (rid) => {
|
|
37
|
+
if (images.has(rid)) return images.get(rid);
|
|
38
|
+
const t = rels.get(rid)?.target; let src = "";
|
|
39
|
+
if (t) {
|
|
40
|
+
const f = zip.file("word/" + t.replace(/^\.?\//, "")) || zip.file(t.replace(/^\//, ""));
|
|
41
|
+
const ext = (t.split(".").pop() || "").toLowerCase();
|
|
42
|
+
if (f && MIME[ext]) { const b = await f.async("uint8array"); if (b.length <= maxImage) src = `data:${MIME[ext]};base64,${Buffer.from(b).toString("base64")}`; }
|
|
43
|
+
}
|
|
44
|
+
images.set(rid, src);
|
|
45
|
+
return src;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function runs(pxml) {
|
|
49
|
+
let html = "";
|
|
50
|
+
for (const part of topLevel(pxml, ["w:r", "w:hyperlink"])) {
|
|
51
|
+
if (part.tag === "w:hyperlink") {
|
|
52
|
+
const rid = attr(part.xml, "r:id"), href = rid && rels.get(rid)?.mode === "External" ? rels.get(rid).target : "";
|
|
53
|
+
const inner = await runs(part.xml.replace(/^<w:hyperlink[^>]*>/, "").replace(/<\/w:hyperlink>$/, ""));
|
|
54
|
+
html += /^https?:|^mailto:/i.test(href) ? `<a href="${esc(href)}" target="_blank" rel="noopener">${inner}</a>` : inner;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const r = part.xml;
|
|
58
|
+
const pr = r.match(/<w:rPr>([\s\S]*?)<\/w:rPr>/)?.[1] || "";
|
|
59
|
+
let text = "";
|
|
60
|
+
for (const t of r.matchAll(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>|<w:drawing>[\s\S]*?<\/w:drawing>|<w:t\/>/g)) {
|
|
61
|
+
if (t[0] === "<w:tab/>") text += " ";
|
|
62
|
+
else if (t[0] === "<w:br/>") text += "<br>";
|
|
63
|
+
else if (t[0].startsWith("<w:drawing")) { const rid = t[0].match(/r:embed="([^"]+)"/)?.[1]; const src = rid ? await image(rid) : ""; if (src) text += `<img src="${src}" alt="">`; }
|
|
64
|
+
else if (t[1] != null) text += esc(unxml(t[1]));
|
|
65
|
+
}
|
|
66
|
+
if (!text) continue;
|
|
67
|
+
const on = (tag) => new RegExp(`<w:${tag}(?: w:val="(?!0|false|none)[^"]*")?\\s*/>`).test(pr);
|
|
68
|
+
const color = pr.match(/<w:color w:val="([0-9A-Fa-f]{6})"/)?.[1];
|
|
69
|
+
const hl = pr.match(/<w:highlight w:val="([a-z]+)"/)?.[1];
|
|
70
|
+
if (on("b")) text = `<b>${text}</b>`;
|
|
71
|
+
if (on("i")) text = `<i>${text}</i>`;
|
|
72
|
+
if (/<w:u w:val="(?!none)/.test(pr)) text = `<u>${text}</u>`;
|
|
73
|
+
if (on("strike")) text = `<s>${text}</s>`;
|
|
74
|
+
const va = pr.match(/<w:vertAlign w:val="(superscript|subscript)"/)?.[1];
|
|
75
|
+
if (va) text = va === "superscript" ? `<sup>${text}</sup>` : `<sub>${text}</sub>`;
|
|
76
|
+
if ((color && color.toLowerCase() !== "000000") || hl) text = `<span style="${color ? `color:#${color};` : ""}${hl ? `background:${hl === "yellow" ? "#fff59d" : hl};` : ""}">${text}</span>`;
|
|
77
|
+
html += text;
|
|
78
|
+
}
|
|
79
|
+
return html;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function paragraph(pxml) {
|
|
83
|
+
const ppr = pxml.match(/<w:pPr>([\s\S]*?)<\/w:pPr>/)?.[1] || "";
|
|
84
|
+
const style = styleName.get(ppr.match(/<w:pStyle w:val="([^"]+)"/)?.[1] || "") || "";
|
|
85
|
+
const inner = await runs(pxml);
|
|
86
|
+
const align = ppr.match(/<w:jc w:val="(center|right|both)"/)?.[1];
|
|
87
|
+
const st = align ? ` style="text-align:${align === "both" ? "justify" : align}"` : "";
|
|
88
|
+
const h = style.match(/^(?:heading|título|titulo|cabeçalho)\s*(\d)/)?.[1] || (style === "title" || style === "título" ? "1" : null);
|
|
89
|
+
const numId = ppr.match(/<w:numId w:val="(\d+)"/)?.[1], lvl = Number(ppr.match(/<w:ilvl w:val="(\d+)"/)?.[1] || 0);
|
|
90
|
+
if (numId && numId !== "0") return { list: numFmt.get(numId) === "bullet" ? "ul" : "ol", lvl, html: `<li>${inner}</li>` };
|
|
91
|
+
if (!inner.replace(/<br>| /g, "").trim()) return { html: "" };
|
|
92
|
+
if (h) return { html: `<h${Math.min(6, Number(h))}${st}>${inner}</h${Math.min(6, Number(h))}>` };
|
|
93
|
+
return { html: `<p${st}>${inner}</p>` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function table(txml) {
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (const tr of topLevel(txml.replace(/^<w:tbl[^>]*>/, "").replace(/<\/w:tbl>$/, ""), ["w:tr"])) {
|
|
99
|
+
const cells = [];
|
|
100
|
+
for (const tc of topLevel(tr.xml.replace(/^<w:tr[^>]*>/, "").replace(/<\/w:tr>$/, ""), ["w:tc"])) {
|
|
101
|
+
if (/<w:vMerge\s*\/>/.test(tc.xml) || /<w:vMerge w:val="continue"/.test(tc.xml)) continue;
|
|
102
|
+
const span = Number(tc.xml.match(/<w:gridSpan w:val="(\d+)"/)?.[1] || 1);
|
|
103
|
+
const body = await blocks(tc.xml.replace(/^<w:tc[^>]*>/, "").replace(/<\/w:tc>$/, ""));
|
|
104
|
+
cells.push(`<td${span > 1 ? ` colspan="${span}"` : ""}>${body}</td>`);
|
|
105
|
+
}
|
|
106
|
+
rows.push(`<tr>${cells.join("")}</tr>`);
|
|
107
|
+
}
|
|
108
|
+
return `<table>${rows.join("")}</table>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function blocks(xml) {
|
|
112
|
+
let out = "", list = null;
|
|
113
|
+
const flush = () => { if (list) { out += `<${list.tag}>${list.items.join("")}</${list.tag}>`; list = null; } };
|
|
114
|
+
for (const b of topLevel(xml, ["w:p", "w:tbl"])) {
|
|
115
|
+
if (b.tag === "w:tbl") { flush(); out += await table(b.xml); continue; }
|
|
116
|
+
const p = await paragraph(b.xml);
|
|
117
|
+
if (p.list) {
|
|
118
|
+
if (!list || list.tag !== p.list) { flush(); list = { tag: p.list, items: [] }; }
|
|
119
|
+
list.items.push(p.lvl ? p.html.replace("<li>", `<li style="margin-left:${p.lvl * 1.5}em">`) : p.html);
|
|
120
|
+
} else { flush(); out += p.html; }
|
|
121
|
+
}
|
|
122
|
+
flush();
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const body = doc.match(/<w:body>([\s\S]*)<\/w:body>/)?.[1] || "";
|
|
127
|
+
return await blocks(body.replace(/<w:sectPr[\s\S]*?<\/w:sectPr>\s*$/, ""));
|
|
128
|
+
}
|