@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.
@@ -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></div>`
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"
@@ -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(target.querySelectorAll("img")).map(async (img) => {
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
- const blob: Blob = await domToPptx.exportToPptx(target, {
520
- fileName: `slide-${index + 1}.pptx`,
521
- skipDownload: true,
522
- svgAsVector: false,
523
- autoEmbedFonts,
524
- width: 10,
525
- height: 5.625,
526
- })
527
- return Array.from(new Uint8Array(await blob.arrayBuffer()))
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: `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
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.4",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revela",
3
- "version": "0.19.4",
3
+ "version": "0.19.7",
4
4
  "description": "Use Revela in Codex to specify, research, plan, make, and export trusted narrative decision artifacts.",
5
5
  "author": {
6
6
  "name": "cyber-dash-tech",