@cyber-dash-tech/revela 0.19.4 → 0.19.7
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 +14 -14
- package/README.zh-CN.md +14 -14
- package/designs/lucent/DESIGN.md +2 -1
- package/designs/lucent/design.css +18 -3
- package/designs/lucent-dark/DESIGN.md +2 -1
- package/designs/lucent-dark/design.css +22 -7
- package/designs/monet/DESIGN.md +41 -127
- package/designs/monet/design.css +22 -7
- package/designs/starter/DESIGN.md +17 -24
- package/designs/starter/design.css +22 -7
- package/designs/summit/DESIGN.md +47 -113
- package/designs/summit/design.css +22 -7
- package/lib/design/designs.ts +1 -1
- package/lib/export/html.ts +14 -0
- package/lib/page-templates/built-in-preview.html +3 -2
- package/lib/page-templates/render.ts +57 -7
- package/lib/page-templates/vocabulary.ts +6 -2
- package/lib/pdf/export.ts +1 -10
- package/lib/pptx/export.ts +164 -15
- package/package.json +2 -1
- package/plugins/revela/.codex-plugin/plugin.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "fs"
|
|
2
2
|
import { isAbsolute, normalize, resolve } from "path"
|
|
3
|
+
import katex from "katex"
|
|
3
4
|
import { getPageTemplateVocabulary } from "./vocabulary"
|
|
4
5
|
|
|
5
6
|
export type PageTemplateStatus = "metadata-only" | "renderable"
|
|
@@ -141,6 +142,9 @@ const templates: PageTemplateDefinition[] = [
|
|
|
141
142
|
field("chartTitle", "string", "Chart title."),
|
|
142
143
|
field("takeawaysTitle", "string", "Title for the interpretation text panel."),
|
|
143
144
|
field("items", "items[]", "Takeaways.", true),
|
|
145
|
+
field("quote", "string", "Optional italic quote text member inside the text panel."),
|
|
146
|
+
field("formulaLatex", "string", "Optional LaTeX formula text member inside the text panel."),
|
|
147
|
+
field("formulaCaption", "string", "Optional formula caption."),
|
|
144
148
|
], ["Chart area must be explicit and bounded."], ["Chart panel and takeaways both exist."]),
|
|
145
149
|
define("table", "Table", "Explain a structured table with a left reading card and right table region.", [
|
|
146
150
|
field("title", "string", "Slide title.", true),
|
|
@@ -278,6 +282,8 @@ export function builtInPreviewFixtures(): BuiltInPreviewFixture[] {
|
|
|
278
282
|
fixture("chart-takeaways", {
|
|
279
283
|
title: "chart-takeaways",
|
|
280
284
|
takeawaysTitle: "What to read",
|
|
285
|
+
formulaLatex: "\\mathrm{CAGR}=\\left(\\frac{\\mathrm{FY26\\ Plan}}{\\mathrm{FY25}}\\right)^{1/n}-1",
|
|
286
|
+
formulaCaption: "Formula text member",
|
|
281
287
|
items: [
|
|
282
288
|
{ label: "Trend", description: "Call out the movement or comparison the chart is meant to prove, including the direction and the comparison baseline." },
|
|
283
289
|
{ label: "Driver", description: "Name the likely reason without overclaiming; separate observed movement from the interpretation or hypothesis." },
|
|
@@ -288,6 +294,7 @@ export function builtInPreviewFixtures(): BuiltInPreviewFixture[] {
|
|
|
288
294
|
title: "table",
|
|
289
295
|
textTitle: "Financial readout",
|
|
290
296
|
textBody: "Read top-line growth first, then check margin, cash conversion, and retention to see whether the plan is financially durable.",
|
|
297
|
+
quote: "Durability shows up when growth, margin, and cash all point in the same direction.",
|
|
291
298
|
columns: ["Line item", "FY2025", "FY2026 Plan", "YoY / note"],
|
|
292
299
|
rows: [
|
|
293
300
|
["Revenue", "$84.2M", "$104.8M", "+24% planned growth"],
|
|
@@ -324,6 +331,7 @@ export function builtInPreviewFixtures(): BuiltInPreviewFixture[] {
|
|
|
324
331
|
title: "timeline",
|
|
325
332
|
insightTitle: "Reading the journey",
|
|
326
333
|
insightBody: "The timeline should show sequence and decision rhythm, while the side panel explains why the milestones matter.",
|
|
334
|
+
quote: "Sequence is evidence when each step changes what the audience can believe.",
|
|
327
335
|
milestones: [
|
|
328
336
|
{ date: "Mar 2019", label: "Launch", description: "Baseline mapping." },
|
|
329
337
|
{ date: "Nov 2019", label: "Audit", description: "Evidence sprint." },
|
|
@@ -552,12 +560,12 @@ ${lucentClosingBackgroundCss}
|
|
|
552
560
|
.template-agenda-inner { width: 100%; display: grid; grid-template-columns: 37% minmax(0, 1fr); align-items: stretch; gap: 76px; }
|
|
553
561
|
.template-agenda-header { display: flex; flex-direction: column; min-height: 0; padding: 10px 0 0; }
|
|
554
562
|
.template-agenda-header .template-eyebrow { color: rgba(255,255,255,0.64); }
|
|
555
|
-
.template-agenda-header .template-title { max-width: 440px; font-size: 54px; line-height: 1.16; letter-spacing: 0; text-transform: uppercase; color: white; padding-bottom: 8px; overflow: visible; }
|
|
556
|
-
.template-agenda-footer { margin: auto 0 0; font-size: 13px; line-height: 1.4; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 800; color: rgba(255,255,255,0.84); }
|
|
563
|
+
.template-agenda-header .template-title { max-width: 440px; font-family: var(--font-display); font-size: 54px; line-height: 1.16; letter-spacing: 0; text-transform: uppercase; color: white; padding-bottom: 8px; overflow: visible; }
|
|
564
|
+
.template-agenda-footer { margin: auto 0 0; font-family: var(--font-body); font-size: 13px; line-height: 1.4; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 800; color: rgba(255,255,255,0.84); }
|
|
557
565
|
.template-agenda-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; justify-content: center; gap: 40px; height: 100%; }
|
|
558
566
|
.template-agenda-item { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 44px; align-items: center; min-height: 58px; overflow: visible; }
|
|
559
567
|
.template-agenda-item span { font-family: var(--font-display); font-size: 44px; line-height: 1; letter-spacing: 0.03em; color: var(--accent-cyan, #18a8d8); font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
560
|
-
.template-agenda-item strong { font-size: 18px; line-height: 1.45; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700; color: rgba(255,255,255,0.92); }
|
|
568
|
+
.template-agenda-item strong { font-family: var(--font-body); font-size: 18px; line-height: 1.45; letter-spacing: 0.1em; text-transform: uppercase; font-weight: 700; color: rgba(255,255,255,0.92); }
|
|
561
569
|
.template-metric-layout { height: 100%; min-height: 0; display: grid; gap: 26px; }
|
|
562
570
|
.template-metric-layout--insight-top { grid-template-rows: auto minmax(0, 1fr); }
|
|
563
571
|
.template-metric-layout--insight-bottom { grid-template-rows: minmax(0, 1fr) auto; }
|
|
@@ -573,6 +581,15 @@ ${lucentClosingBackgroundCss}
|
|
|
573
581
|
.template-text-panel--color { background: linear-gradient(135deg, #5f82c8 0%, var(--accent-primary) 58%, #18a8d8 115%); color: white; box-shadow: 0 22px 56px rgba(49,94,234,0.24); }
|
|
574
582
|
.template-text-panel--color .template-text-panel-title { color: white; }
|
|
575
583
|
.template-text-panel--color .template-text-panel-body { color: rgba(255,255,255,0.78); }
|
|
584
|
+
.template-text-panel-quote { margin: 2px 0 0; font-size: 22px; line-height: 1.44; font-style: italic; color: var(--text-secondary); }
|
|
585
|
+
.template-text-panel--color .template-text-panel-quote { color: rgba(255,255,255,0.82); }
|
|
586
|
+
.template-text-panel-formula { margin: 0; width: 100%; display: grid; gap: 8px; color: var(--text-primary); }
|
|
587
|
+
.template-text-panel--color .template-text-panel-formula { color: white; }
|
|
588
|
+
.template-text-panel-formula .katex-display { margin: 0; overflow: visible; }
|
|
589
|
+
.template-text-panel-formula .katex { font-size: 1.08em; color: inherit; }
|
|
590
|
+
.template-text-panel-formula-fallback { display: block; white-space: normal; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.82em; line-height: 1.35; color: inherit; }
|
|
591
|
+
.template-text-panel-formula-caption { margin: 0; font-size: 14px; line-height: 1.35; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
|
592
|
+
.template-text-panel--color .template-text-panel-formula-caption { color: rgba(255,255,255,0.72); }
|
|
576
593
|
.template-chart-takeaway-list { display: grid; gap: 22px; width: 100%; }
|
|
577
594
|
.template-chart-takeaway-item { display: grid; gap: 7px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.24); }
|
|
578
595
|
.template-chart-takeaway-item:first-child { padding-top: 0; border-top: 0; }
|
|
@@ -1045,6 +1062,7 @@ function chartTakeawayPanel(content: Record<string, any>): string {
|
|
|
1045
1062
|
return `<div class="template-text-panel template-text-panel--color template-chart-takeaway-panel" data-template-slot="takeaways">
|
|
1046
1063
|
<h2 class="template-text-panel-title">${escapeHtml(title)}</h2>
|
|
1047
1064
|
<div class="template-chart-takeaway-list">${takeawayItems.map((item) => `<section class="template-chart-takeaway-item"><h3>${escapeHtml(item.label)}</h3><p>${escapeHtml(item.description)}</p></section>`).join("")}</div>
|
|
1065
|
+
${renderTextMembers(content)}
|
|
1048
1066
|
</div>`
|
|
1049
1067
|
}
|
|
1050
1068
|
|
|
@@ -1080,6 +1098,7 @@ function tablePage(content: Record<string, any>): string {
|
|
|
1080
1098
|
const panelContent = {
|
|
1081
1099
|
insightTitle: stringValue(content.textTitle) || "What to read",
|
|
1082
1100
|
insightBody: stringValue(content.textBody) || "Use this card to explain the comparison, caveat, or decision implication before the audience scans the table.",
|
|
1101
|
+
quote: stringValue(content.quote),
|
|
1083
1102
|
}
|
|
1084
1103
|
return `<div class="template-table-layout">${renderTextPanel(panelContent, "text-card", "clear")}<div class="template-table-region" data-template-slot="table">${table({ ...content, insightTitle: "", insightBody: "" })}</div></div>`
|
|
1085
1104
|
}
|
|
@@ -1137,7 +1156,38 @@ function renderTextPanel(content: Record<string, any>, slot = "insight", variant
|
|
|
1137
1156
|
const body = stringValue(content.insightBody)
|
|
1138
1157
|
if (!body) return ""
|
|
1139
1158
|
const title = stringValue(content.insightTitle) || "Insight"
|
|
1140
|
-
return `<div class="template-side-panel template-text-panel template-text-panel--${variant}" data-template-slot="${escapeAttribute(slot)}"><h2 class="template-side-panel-title template-text-panel-title">${escapeHtml(title)}</h2><p class="template-side-panel-body template-text-panel-body">${escapeHtml(body)}</p
|
|
1159
|
+
return `<div class="template-side-panel template-text-panel template-text-panel--${variant}" data-template-slot="${escapeAttribute(slot)}"><h2 class="template-side-panel-title template-text-panel-title">${escapeHtml(title)}</h2><p class="template-side-panel-body template-text-panel-body">${escapeHtml(body)}</p>${renderTextMembers(content)}</div>`
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function renderTextMembers(content: Record<string, any>): string {
|
|
1163
|
+
const quote = stringValue(content.quote)
|
|
1164
|
+
const formulaLatex = stringValue(content.formulaLatex)
|
|
1165
|
+
const parts: string[] = []
|
|
1166
|
+
if (quote) parts.push(`<blockquote class="template-text-panel-quote">${escapeHtml(quote)}</blockquote>`)
|
|
1167
|
+
if (formulaLatex) parts.push(renderFormulaMember(formulaLatex, stringValue(content.formulaCaption)))
|
|
1168
|
+
return parts.join("")
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function renderFormulaMember(latex: string, caption = ""): string {
|
|
1172
|
+
const escapedLatex = escapeAttribute(latex)
|
|
1173
|
+
const rendered = renderLatex(latex)
|
|
1174
|
+
const captionHtml = caption ? `<p class="template-text-panel-formula-caption">${escapeHtml(caption)}</p>` : ""
|
|
1175
|
+
return `<figure class="template-text-panel-formula" data-latex="${escapedLatex}">${rendered}${captionHtml}</figure>`
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function renderLatex(latex: string): string {
|
|
1179
|
+
try {
|
|
1180
|
+
return katex.renderToString(latex, {
|
|
1181
|
+
displayMode: true,
|
|
1182
|
+
output: "mathml",
|
|
1183
|
+
strict: "warn",
|
|
1184
|
+
throwOnError: true,
|
|
1185
|
+
trust: false,
|
|
1186
|
+
})
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1189
|
+
return `<code class="template-text-panel-formula-fallback" data-formula-error="${escapeAttribute(message)}">${escapeHtml(latex)}</code>`
|
|
1190
|
+
}
|
|
1141
1191
|
}
|
|
1142
1192
|
|
|
1143
1193
|
function imageCard(input: any): string {
|
|
@@ -1189,11 +1239,11 @@ function scaffoldSeed(templateId: string, seed: Record<string, any>): Record<str
|
|
|
1189
1239
|
if (templateId === "key-message-evidence") return { body: "Replace with the key message the audience should remember.", items: defaultItems(["Evidence 1", "Evidence 2", "Evidence 3"]), ...base }
|
|
1190
1240
|
if (templateId === "claim-supporting-visual") return { claim: "Replace with one visual claim.", body: "Use this copy to guide how the visual should be read.", items: defaultItems(["Anchor", "Callout"]), ...base }
|
|
1191
1241
|
if (templateId === "metric-highlight") return { metrics: [{ value: "67%", label: "Metric", description: "Replace with interpretation." }, { value: "3x", label: "Comparison", description: "Replace with reading note." }, { value: "14d", label: "Window", description: "Replace with time context." }], insightTitle: "Read the signal", insightBody: "Replace with the decision implication, caveat, or next reading step.", ...base }
|
|
1192
|
-
if (templateId === "chart-takeaways") return { takeawaysTitle: "What to read", items: defaultItems(["Trend", "Driver", "Decision use"]), ...base }
|
|
1193
|
-
if (templateId === "table") return { textTitle: "Financial readout", textBody: "Replace with the table reading note, caveat, or decision implication.", columns: ["Line item", "FY2025", "FY2026 Plan", "YoY / note"], rows: [["Revenue", "$84.2M", "$104.8M", "+24% planned growth"], ["Gross margin", "68.4%", "71.2%", "+280 bps mix shift"], ["Operating expense", "$42.7M", "$49.1M", "Scale hiring below revenue growth"], ["EBITDA", "$14.9M", "$23.6M", "+58% operating leverage"], ["Free cash flow", "$9.8M", "$16.4M", "Cash conversion improves"], ["Net retention", "116%", "121%", "Expansion supports plan quality"]], ...base }
|
|
1242
|
+
if (templateId === "chart-takeaways") return { takeawaysTitle: "What to read", items: defaultItems(["Trend", "Driver", "Decision use"]), formulaLatex: "\\mathrm{CAGR}=\\left(\\frac{\\mathrm{FY26\\ Plan}}{\\mathrm{FY25}}\\right)^{1/n}-1", formulaCaption: "Formula text member", ...base }
|
|
1243
|
+
if (templateId === "table") return { textTitle: "Financial readout", textBody: "Replace with the table reading note, caveat, or decision implication.", quote: "Durability shows up when growth, margin, and cash all point in the same direction.", columns: ["Line item", "FY2025", "FY2026 Plan", "YoY / note"], rows: [["Revenue", "$84.2M", "$104.8M", "+24% planned growth"], ["Gross margin", "68.4%", "71.2%", "+280 bps mix shift"], ["Operating expense", "$42.7M", "$49.1M", "Scale hiring below revenue growth"], ["EBITDA", "$14.9M", "$23.6M", "+58% operating leverage"], ["Free cash flow", "$9.8M", "$16.4M", "Cash conversion improves"], ["Net retention", "116%", "121%", "Expansion supports plan quality"]], ...base }
|
|
1194
1244
|
if (templateId === "table-comparison") return { columns: ["Dimension", "Current", "Target"], rows: [["Replace", "Current state", "Target state"], ["Caveat", "Known limit", "Next proof"]], insightTitle: "Insight", insightBody: "Replace with the table reading note or caveat.", ...base }
|
|
1195
1245
|
if (templateId === "milestone" || templateId === "timeline-roadmap") return { orientation: "horizontal", milestones: [{ date: "2022", label: "Signal", description: "Name the starting condition." }, { date: "2023", label: "Proof", description: "Show the evidence threshold." }, { date: "2024", label: "Inflection", description: "Use the pivotal moment to frame the shift." }, { date: "2025", label: "Scale", description: "Use a taller card for the highlighted milestone.", highlight: true }, { date: "2026", label: "Decision", description: "State what changes next." }], ...base }
|
|
1196
|
-
if (templateId === "timeline") return { orientation: "vertical", insightTitle: "Reading the journey", insightBody: "Replace with the timeline interpretation or caveat.", milestones: [{ date: "Mar 2019", label: "Launch", description: "Name the starting event." }, { date: "Nov 2019", label: "Audit", description: "Show the evidence threshold." }, { date: "May 2020", label: "Scale", description: "Explain the operating cadence." }, { date: "Feb 2021", label: "Review", description: "State what changes next." }], ...base }
|
|
1246
|
+
if (templateId === "timeline") return { orientation: "vertical", insightTitle: "Reading the journey", insightBody: "Replace with the timeline interpretation or caveat.", quote: "Sequence is evidence when each step changes what the audience can believe.", milestones: [{ date: "Mar 2019", label: "Launch", description: "Name the starting event." }, { date: "Nov 2019", label: "Audit", description: "Show the evidence threshold." }, { date: "May 2020", label: "Scale", description: "Explain the operating cadence." }, { date: "Feb 2021", label: "Review", description: "State what changes next." }], ...base }
|
|
1197
1247
|
if (templateId === "process-steps") return { steps: defaultItems(["Step 1", "Step 2", "Step 3"]), ...base }
|
|
1198
1248
|
if (templateId === "recommendation-decision") return { recommendation: "Replace with the recommended decision.", items: defaultItems(["Rationale"]), steps: defaultItems(["Pilot", "Validate", "Ship"]), ...base }
|
|
1199
1249
|
if (templateId === "risks-tradeoffs") return { items: defaultItems(["Risk", "Tradeoff", "Mitigation"]), ...base }
|
|
@@ -55,8 +55,8 @@ export const PAGE_TEMPLATE_VOCABULARY: PageTemplateVocabulary[] = [
|
|
|
55
55
|
vocab("key-message-evidence", ["template-key-message-panel", "template-evidence-grid"], ["key-message", "evidence"], ["key-message", "evidence"], ["Key message and evidence regions must remain distinct."]),
|
|
56
56
|
vocab("claim-supporting-visual", ["template-claim-text-panel", "template-visual-slot-panel"], ["claim", "visual"], ["claim", "visual"], ["Visual slot may be replaced by image, chart, table, or diagram container."]),
|
|
57
57
|
vocab("metric-highlight", ["template-stat-grid"], ["metrics"], ["metrics", "insight"], ["Metric values should remain visible outside prose."]),
|
|
58
|
-
vocab("chart-takeaways", ["template-chart-panel", "template-chart-takeaway-panel", "template-text-panel--color"], ["visual", "takeaways"], ["visual", "takeaways"], ["Chart/image slot and color takeaway text panel must both remain present."]),
|
|
59
|
-
vocab("table", ["template-table-layout", "template-table-wrap", "template-table", "template-side-panel", "template-text-panel", "template-text-panel--clear"], ["text-card", "table"], ["text-card", "table"], ["Left clear text card explains how to read the structured table.", "Table headers and body should remain structured, not prose-only."]),
|
|
58
|
+
vocab("chart-takeaways", ["template-chart-panel", "template-chart-takeaway-panel", "template-text-panel--color"], ["visual", "takeaways"], ["visual", "takeaways"], ["Chart/image slot and color takeaway text panel must both remain present.", "Text panels may include quote and formula text members; do not model them as standalone components."]),
|
|
59
|
+
vocab("table", ["template-table-layout", "template-table-wrap", "template-table", "template-side-panel", "template-text-panel", "template-text-panel--clear"], ["text-card", "table"], ["text-card", "table"], ["Left clear text card explains how to read the structured table.", "Table headers and body should remain structured, not prose-only.", "Text panels may include quote text members; do not model quotes as standalone components."]),
|
|
60
60
|
vocab("table-comparison", ["template-table-wrap", "template-table"], ["table"], ["table", "insight"], ["Table headers and body should remain structured, not prose-only."]),
|
|
61
61
|
vocab("milestone", ["template-timeline", "template-timeline-item", "template-timeline-dot", "template-timeline-copy", "template-insight-icon"], ["timeline"], ["timeline"], ["Each milestone item must keep dot and copy as sibling anchors inside one item.", "Milestone cards reuse .template-card; highlight uses the item modifier."]),
|
|
62
62
|
vocab("timeline", ["template-timeline", "template-timeline-item", "template-timeline-dot", "template-timeline-copy"], ["timeline"], ["timeline", "insight"], ["Each timeline item must keep dot and copy as sibling anchors inside one item.", "The optional color insight slot explains the sequence without replacing event copy."]),
|
|
@@ -107,6 +107,10 @@ const additionalClasses = [
|
|
|
107
107
|
"template-text-panel--color",
|
|
108
108
|
"template-text-panel-title",
|
|
109
109
|
"template-text-panel-body",
|
|
110
|
+
"template-text-panel-quote",
|
|
111
|
+
"template-text-panel-formula",
|
|
112
|
+
"template-text-panel-formula-caption",
|
|
113
|
+
"template-text-panel-formula-fallback",
|
|
110
114
|
"template-insight-panel",
|
|
111
115
|
"template-insight-title",
|
|
112
116
|
"template-insight-icon",
|
package/lib/pdf/export.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { randomBytes } from "crypto"
|
|
|
31
31
|
import { launchChrome } from "../browser/chrome"
|
|
32
32
|
import { detectDeckHtml } from "../html-export/deck-detect"
|
|
33
33
|
import { exportSinglePageHtmlPdf } from "../html-export"
|
|
34
|
+
import { withExportBaseHref } from "../export/html"
|
|
34
35
|
|
|
35
36
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -187,16 +188,6 @@ async function toDataUrlFromRef(ref: string, baseDir: string): Promise<string |
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
function withExportBaseHref(html: string, htmlFilePath: string): string {
|
|
191
|
-
const baseHref = pathToFileURL(`${dirname(resolve(htmlFilePath))}/`).href
|
|
192
|
-
const baseTag = `<base href="${baseHref}">`
|
|
193
|
-
if (/<base\b/i.test(html)) return html
|
|
194
|
-
if (/<head[^>]*>/i.test(html)) {
|
|
195
|
-
return html.replace(/<head([^>]*)>/i, `<head$1>\n${baseTag}`)
|
|
196
|
-
}
|
|
197
|
-
return `${baseTag}\n${html}`
|
|
198
|
-
}
|
|
199
|
-
|
|
200
191
|
async function prepareSlidesForExport(page: any): Promise<void> {
|
|
201
192
|
await page.evaluate((canvasWidth: number, canvasHeight: number) => {
|
|
202
193
|
document.documentElement.style.scrollSnapType = "none"
|
package/lib/pptx/export.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { basename, dirname, extname, join, posix as pathPosix, resolve } from "p
|
|
|
22
22
|
import { randomBytes } from "crypto"
|
|
23
23
|
import { pathToFileURL } from "url"
|
|
24
24
|
import { findChromePath, launchChrome } from "../browser/chrome"
|
|
25
|
+
import { withExportBaseHref } from "../export/html"
|
|
25
26
|
|
|
26
27
|
const CANVAS_W = 1920
|
|
27
28
|
const CANVAS_H = 1080
|
|
@@ -344,6 +345,7 @@ async function preparePage(
|
|
|
344
345
|
document.documentElement.style.scrollSnapType = "none"
|
|
345
346
|
document.documentElement.style.overflow = "visible"
|
|
346
347
|
document.body.style.overflow = "visible"
|
|
348
|
+
document.body.style.margin = "0"
|
|
347
349
|
|
|
348
350
|
const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[]
|
|
349
351
|
if (slides.length === 0) {
|
|
@@ -366,7 +368,24 @@ async function preparePage(
|
|
|
366
368
|
exportStyle = document.createElement("style")
|
|
367
369
|
exportStyle.id = "revela-pptx-export-style"
|
|
368
370
|
exportStyle.textContent = `
|
|
371
|
+
html, body {
|
|
372
|
+
scroll-snap-type: none !important;
|
|
373
|
+
overflow: visible !important;
|
|
374
|
+
}
|
|
375
|
+
.slide {
|
|
376
|
+
width: 1920px !important;
|
|
377
|
+
min-width: 1920px !important;
|
|
378
|
+
height: 1080px !important;
|
|
379
|
+
min-height: 1080px !important;
|
|
380
|
+
display: flex !important;
|
|
381
|
+
align-items: center !important;
|
|
382
|
+
justify-content: center !important;
|
|
383
|
+
overflow: hidden !important;
|
|
384
|
+
scroll-snap-align: none !important;
|
|
385
|
+
}
|
|
369
386
|
.slide-canvas {
|
|
387
|
+
width: 1920px !important;
|
|
388
|
+
height: 1080px !important;
|
|
370
389
|
transform: none !important;
|
|
371
390
|
transform-origin: top left !important;
|
|
372
391
|
transition: none !important;
|
|
@@ -466,6 +485,48 @@ async function readSlideMeta(
|
|
|
466
485
|
})
|
|
467
486
|
}
|
|
468
487
|
|
|
488
|
+
async function rasterizeFormulaNodes(page: Page, diagnostics: string[]): Promise<number> {
|
|
489
|
+
const handles = await page.$$(".template-text-panel-formula, .text-panel-formula")
|
|
490
|
+
let rasterized = 0
|
|
491
|
+
|
|
492
|
+
for (const handle of handles) {
|
|
493
|
+
try {
|
|
494
|
+
const box = await handle.boundingBox()
|
|
495
|
+
if (!box || box.width <= 0 || box.height <= 0) continue
|
|
496
|
+
|
|
497
|
+
const base64 = await (handle as any).screenshot({
|
|
498
|
+
encoding: "base64",
|
|
499
|
+
omitBackground: true,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
await handle.evaluate((node, payload) => {
|
|
503
|
+
const element = node as HTMLElement
|
|
504
|
+
const img = document.createElement("img")
|
|
505
|
+
img.src = `data:image/png;base64,${payload.base64}`
|
|
506
|
+
img.alt = element.getAttribute("data-latex") || "Formula"
|
|
507
|
+
img.setAttribute("data-revela-pptx-formula-raster", "true")
|
|
508
|
+
img.style.width = `${payload.width}px`
|
|
509
|
+
img.style.height = `${payload.height}px`
|
|
510
|
+
img.style.maxWidth = "100%"
|
|
511
|
+
img.style.display = "block"
|
|
512
|
+
img.style.objectFit = "contain"
|
|
513
|
+
element.replaceChildren(img)
|
|
514
|
+
}, {
|
|
515
|
+
base64,
|
|
516
|
+
width: Math.ceil(box.width),
|
|
517
|
+
height: Math.ceil(box.height),
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
rasterized += 1
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
523
|
+
diagnostics.push(`formula rasterize failed: ${message}`)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return rasterized
|
|
528
|
+
}
|
|
529
|
+
|
|
469
530
|
async function exportSlidePptx(
|
|
470
531
|
page: Page,
|
|
471
532
|
diagnostics: string[],
|
|
@@ -499,15 +560,47 @@ async function exportSlidePptx(
|
|
|
499
560
|
})
|
|
500
561
|
}
|
|
501
562
|
|
|
502
|
-
target.scrollIntoView({ block: "center", inline: "center" })
|
|
503
563
|
target.style.transform = "none"
|
|
504
564
|
target.style.transformOrigin = "top left"
|
|
505
565
|
target.style.transition = "none"
|
|
506
566
|
target.style.animation = "none"
|
|
507
567
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
508
568
|
|
|
569
|
+
const host = document.createElement("div")
|
|
570
|
+
host.id = "revela-pptx-export-root"
|
|
571
|
+
host.style.position = "fixed"
|
|
572
|
+
host.style.left = "0"
|
|
573
|
+
host.style.top = "0"
|
|
574
|
+
host.style.width = "1920px"
|
|
575
|
+
host.style.height = "1080px"
|
|
576
|
+
host.style.overflow = "hidden"
|
|
577
|
+
host.style.zIndex = "2147483647"
|
|
578
|
+
host.style.background = "transparent"
|
|
579
|
+
|
|
580
|
+
const exportTarget = target.cloneNode(true) as HTMLElement
|
|
581
|
+
exportTarget.style.width = "1920px"
|
|
582
|
+
exportTarget.style.height = "1080px"
|
|
583
|
+
exportTarget.style.position = "relative"
|
|
584
|
+
exportTarget.style.left = "0"
|
|
585
|
+
exportTarget.style.top = "0"
|
|
586
|
+
exportTarget.style.transform = "none"
|
|
587
|
+
exportTarget.style.transformOrigin = "top left"
|
|
588
|
+
exportTarget.style.transition = "none"
|
|
589
|
+
exportTarget.style.animation = "none"
|
|
590
|
+
host.appendChild(exportTarget)
|
|
591
|
+
document.body.appendChild(host)
|
|
592
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
593
|
+
|
|
594
|
+
const rect = exportTarget.getBoundingClientRect()
|
|
595
|
+
if (Math.abs(rect.width - 1920) > 2 || Math.abs(rect.height - 1080) > 2) {
|
|
596
|
+
host.remove()
|
|
597
|
+
throw new Error(
|
|
598
|
+
`Slide ${index + 1} export canvas is ${Math.round(rect.width)}x${Math.round(rect.height)}, expected 1920x1080.`
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
509
602
|
await Promise.all(
|
|
510
|
-
Array.from(
|
|
603
|
+
Array.from(exportTarget.querySelectorAll("img")).map(async (img) => {
|
|
511
604
|
if (img.complete) return
|
|
512
605
|
await new Promise((resolve) => {
|
|
513
606
|
img.addEventListener("load", resolve, { once: true })
|
|
@@ -516,15 +609,19 @@ async function exportSlidePptx(
|
|
|
516
609
|
})
|
|
517
610
|
)
|
|
518
611
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
612
|
+
try {
|
|
613
|
+
const blob: Blob = await domToPptx.exportToPptx(exportTarget, {
|
|
614
|
+
fileName: `slide-${index + 1}.pptx`,
|
|
615
|
+
skipDownload: true,
|
|
616
|
+
svgAsVector: false,
|
|
617
|
+
autoEmbedFonts,
|
|
618
|
+
width: 10,
|
|
619
|
+
height: 5.625,
|
|
620
|
+
})
|
|
621
|
+
return Array.from(new Uint8Array(await blob.arrayBuffer()))
|
|
622
|
+
} finally {
|
|
623
|
+
host.remove()
|
|
624
|
+
}
|
|
528
625
|
}, { index: slide.index, autoEmbedFonts: options.autoEmbedFonts })
|
|
529
626
|
|
|
530
627
|
return {
|
|
@@ -717,6 +814,55 @@ function setSpeakerNotes(files: ZipFiles, notesPath: string, notes: string | nul
|
|
|
717
814
|
files[notesPath] = xmlToBytes(doc)
|
|
718
815
|
}
|
|
719
816
|
|
|
817
|
+
function stripSvgBlipFallbacks(pptxBytes: Uint8Array): Uint8Array {
|
|
818
|
+
const files = unzipSync(pptxBytes)
|
|
819
|
+
const orphanedSvgParts = new Set<string>()
|
|
820
|
+
|
|
821
|
+
for (const slidePath of Object.keys(files).filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
|
|
822
|
+
const slideXml = getFileText(files, slidePath)
|
|
823
|
+
if (!slideXml.includes("asvg:svgBlip")) continue
|
|
824
|
+
|
|
825
|
+
const slideDoc = parseXml(slideXml)
|
|
826
|
+
const relsPath = relsPathForPart(slidePath)
|
|
827
|
+
const relsDoc = files[relsPath] ? parseXml(getFileText(files, relsPath)) : null
|
|
828
|
+
const svgRelIds = new Set<string>()
|
|
829
|
+
|
|
830
|
+
for (const svgBlip of Array.from(slideDoc.getElementsByTagName("asvg:svgBlip"))) {
|
|
831
|
+
const relId = svgBlip.getAttribute("r:embed")
|
|
832
|
+
if (relId) svgRelIds.add(relId)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
for (const blip of Array.from(slideDoc.getElementsByTagName("a:blip"))) {
|
|
836
|
+
for (const extLst of Array.from(blip.getElementsByTagName("a:extLst"))) {
|
|
837
|
+
if (Array.from(extLst.getElementsByTagName("asvg:svgBlip")).length === 0) continue
|
|
838
|
+
blip.removeChild(extLst)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (relsDoc && svgRelIds.size > 0) {
|
|
843
|
+
const relRoot = relsDoc.getElementsByTagName("Relationships")[0]
|
|
844
|
+
for (const rel of Array.from(relsDoc.getElementsByTagName("Relationship"))) {
|
|
845
|
+
const relId = rel.getAttribute("Id")
|
|
846
|
+
if (!relId || !svgRelIds.has(relId)) continue
|
|
847
|
+
const target = rel.getAttribute("Target")
|
|
848
|
+
if (target) orphanedSvgParts.add(resolveRelationshipTarget(slidePath, target))
|
|
849
|
+
relRoot?.removeChild(rel)
|
|
850
|
+
}
|
|
851
|
+
files[relsPath] = xmlToBytes(relsDoc)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
files[slidePath] = xmlToBytes(slideDoc)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
for (const partPath of orphanedSvgParts) {
|
|
858
|
+
if (/^ppt\/media\/.+\.svg$/i.test(partPath)) {
|
|
859
|
+
delete files[partPath]
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return zipSync(files, { level: 0 })
|
|
864
|
+
}
|
|
865
|
+
|
|
720
866
|
export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Array<string | null | undefined>): Uint8Array {
|
|
721
867
|
const files = unzipSync(pptxBytes)
|
|
722
868
|
|
|
@@ -724,7 +870,7 @@ export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Arr
|
|
|
724
870
|
setSpeakerNotes(files, `ppt/notesSlides/notesSlide${index + 1}.xml`, notes ?? null)
|
|
725
871
|
})
|
|
726
872
|
|
|
727
|
-
return zipSync(files)
|
|
873
|
+
return stripSvgBlipFallbacks(zipSync(files))
|
|
728
874
|
}
|
|
729
875
|
|
|
730
876
|
function updateAppProperties(files: ZipFiles, slideCount: number): void {
|
|
@@ -898,7 +1044,7 @@ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
|
|
|
898
1044
|
mergedFiles["ppt/_rels/presentation.xml.rels"] = xmlToBytes(presentationRelsDoc)
|
|
899
1045
|
updateAppProperties(mergedFiles, slides.length)
|
|
900
1046
|
|
|
901
|
-
return zipSync(mergedFiles, { level: 0 })
|
|
1047
|
+
return stripSvgBlipFallbacks(zipSync(mergedFiles, { level: 0 }))
|
|
902
1048
|
}
|
|
903
1049
|
|
|
904
1050
|
export async function exportToPptx(
|
|
@@ -937,7 +1083,7 @@ export async function exportToPptx(
|
|
|
937
1083
|
const prepareStart = Date.now()
|
|
938
1084
|
const originalHtml = readFileSync(abs, "utf-8")
|
|
939
1085
|
const localized = await localizeExternalImages(originalHtml, tmpDir)
|
|
940
|
-
const patchedHtml = await inlineImageAssets(localized.html, abs)
|
|
1086
|
+
const patchedHtml = withExportBaseHref(await inlineImageAssets(localized.html, abs), abs)
|
|
941
1087
|
tmpHtmlPath = join(tmpDir, "index.html")
|
|
942
1088
|
writeFileSync(tmpHtmlPath, patchedHtml, "utf-8")
|
|
943
1089
|
timingsMs.prepareMs = Date.now() - prepareStart
|
|
@@ -961,6 +1107,7 @@ export async function exportToPptx(
|
|
|
961
1107
|
try {
|
|
962
1108
|
const pageSetupStart = Date.now()
|
|
963
1109
|
const { page, slideCount, diagnostics } = await preparePage(browser, tmpHtmlPath, domToPptxBundlePath)
|
|
1110
|
+
const rasterizedFormulaCount = await rasterizeFormulaNodes(page, diagnostics)
|
|
964
1111
|
timingsMs.pageSetupMs = Date.now() - pageSetupStart
|
|
965
1112
|
const exported: ExportedSlide[] = []
|
|
966
1113
|
const failures: SlideFailure[] = []
|
|
@@ -969,7 +1116,9 @@ export async function exportToPptx(
|
|
|
969
1116
|
const slides = applySpeakerNotesOverride(await readSlideMeta(page, slideCount), options?.speakerNotes)
|
|
970
1117
|
await emitProgress(options, {
|
|
971
1118
|
kind: "stage",
|
|
972
|
-
message:
|
|
1119
|
+
message: rasterizedFormulaCount > 0
|
|
1120
|
+
? `Deck ready. Rasterized ${rasterizedFormulaCount} formula text member(s). Exporting ${slides.length} slide(s) to editable PPTX parts...`
|
|
1121
|
+
: `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
|
|
973
1122
|
})
|
|
974
1123
|
|
|
975
1124
|
const slideExportStart = Date.now()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyber-dash-tech/revela",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.7",
|
|
4
4
|
"description": "Codex-first CLI/MCP workspace for trusted narrative artifacts from local sources, research, and evidence",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"dom-to-pptx": "^1.1.6",
|
|
69
69
|
"fflate": "^0.8.2",
|
|
70
70
|
"jimp": "^1.6.1",
|
|
71
|
+
"katex": "^0.17.0",
|
|
71
72
|
"mammoth": "^1.12.0",
|
|
72
73
|
"pdf-lib": "^1.17.1",
|
|
73
74
|
"puppeteer-core": "^24.40.0",
|