@cyber-dash-tech/revela 0.19.6 → 0.19.8

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.
@@ -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,199 @@ 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 = targetSlide.cloneNode(true) as HTMLElement
581
+ exportTarget.style.width = "1920px"
582
+ exportTarget.style.minWidth = "1920px"
583
+ exportTarget.style.height = "1080px"
584
+ exportTarget.style.minHeight = "1080px"
585
+ exportTarget.style.position = "relative"
586
+ exportTarget.style.left = "0"
587
+ exportTarget.style.top = "0"
588
+ exportTarget.style.margin = "0"
589
+ exportTarget.style.display = "flex"
590
+ exportTarget.style.alignItems = "center"
591
+ exportTarget.style.justifyContent = "center"
592
+ exportTarget.style.overflow = "hidden"
593
+ exportTarget.style.transform = "none"
594
+ exportTarget.style.transformOrigin = "top left"
595
+ exportTarget.style.transition = "none"
596
+ exportTarget.style.animation = "none"
597
+ const exportCanvas = exportTarget.querySelector(".slide-canvas") as HTMLElement | null
598
+ if (!exportCanvas) {
599
+ host.remove()
600
+ throw new Error(`Missing cloned .slide-canvas for slide ${index + 1}`)
601
+ }
602
+ exportCanvas.style.width = "1920px"
603
+ exportCanvas.style.minWidth = "1920px"
604
+ exportCanvas.style.height = "1080px"
605
+ exportCanvas.style.minHeight = "1080px"
606
+ exportCanvas.style.position = "relative"
607
+ exportCanvas.style.left = "0"
608
+ exportCanvas.style.top = "0"
609
+ exportCanvas.style.flexShrink = "0"
610
+ exportCanvas.style.transform = "none"
611
+ exportCanvas.style.transformOrigin = "top left"
612
+ exportCanvas.style.transition = "none"
613
+ exportCanvas.style.animation = "none"
614
+
615
+ const splitCssList = (value: string): string[] => {
616
+ const parts: string[] = []
617
+ let depth = 0
618
+ let quote: string | null = null
619
+ let start = 0
620
+ for (let i = 0; i < value.length; i += 1) {
621
+ const char = value[i]
622
+ if (quote) {
623
+ if (char === quote && value[i - 1] !== "\\") quote = null
624
+ continue
625
+ }
626
+ if (char === "\"" || char === "'") {
627
+ quote = char
628
+ continue
629
+ }
630
+ if (char === "(") depth += 1
631
+ if (char === ")") depth = Math.max(0, depth - 1)
632
+ if (char === "," && depth === 0) {
633
+ parts.push(value.slice(start, i).trim())
634
+ start = i + 1
635
+ }
636
+ }
637
+ parts.push(value.slice(start).trim())
638
+ return parts.filter(Boolean)
639
+ }
640
+
641
+ const extractCssUrl = (value: string): string | null => {
642
+ const match = value.match(/url\((["']?)(.*?)\1\)/i)
643
+ return match?.[2] ?? null
644
+ }
645
+
646
+ const normalizeBackgroundLayersForPptx = (canvas: HTMLElement, sourceStyle: CSSStyleDeclaration) => {
647
+ const computed = sourceStyle
648
+ const imageLayers = splitCssList(computed.backgroundImage)
649
+ const hasUrlLayer = imageLayers.some((layer) => /url\(/i.test(layer))
650
+ const paddingLeft = Number.parseFloat(computed.paddingLeft) || 0
651
+ const paddingRight = Number.parseFloat(computed.paddingRight) || 0
652
+ const paddingTop = Number.parseFloat(computed.paddingTop) || 0
653
+ const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0
654
+ const hasPadding = paddingLeft > 0 || paddingRight > 0 || paddingTop > 0 || paddingBottom > 0
655
+ if (!hasUrlLayer && !hasPadding) return
656
+
657
+ const sizeLayers = splitCssList(computed.backgroundSize)
658
+ const positionLayers = splitCssList(computed.backgroundPosition)
659
+ const repeatLayers = splitCssList(computed.backgroundRepeat)
660
+ const layerCount = imageLayers.length
661
+ const layers = hasUrlLayer ? document.createElement("div") : null
662
+ if (layers) {
663
+ layers.setAttribute("data-revela-pptx-background-layers", "true")
664
+ layers.style.position = "absolute"
665
+ layers.style.inset = "0"
666
+ layers.style.pointerEvents = "none"
667
+ layers.style.zIndex = "0"
668
+ layers.style.overflow = "hidden"
669
+ }
670
+
671
+ if (layers) imageLayers.forEach((layer, layerIndex) => {
672
+ if (layer === "none") return
673
+ const zIndex = String(layerCount - layerIndex)
674
+ const url = extractCssUrl(layer)
675
+ const size = sizeLayers[layerIndex] ?? sizeLayers[0] ?? "auto"
676
+ const position = positionLayers[layerIndex] ?? positionLayers[0] ?? "50% 50%"
677
+ const repeat = repeatLayers[layerIndex] ?? repeatLayers[0] ?? "repeat"
678
+ if (url) {
679
+ const img = document.createElement("img")
680
+ img.src = url
681
+ img.alt = ""
682
+ img.setAttribute("aria-hidden", "true")
683
+ img.style.position = "absolute"
684
+ img.style.inset = "0"
685
+ img.style.width = "100%"
686
+ img.style.height = "100%"
687
+ img.style.display = "block"
688
+ img.style.zIndex = zIndex
689
+ img.style.objectFit = size.includes("contain") ? "contain" : "cover"
690
+ img.style.objectPosition = position
691
+ if (!repeat.includes("no-repeat") && !size.includes("cover") && !size.includes("contain")) {
692
+ img.style.objectFit = "fill"
693
+ }
694
+ layers.appendChild(img)
695
+ return
696
+ }
697
+
698
+ const gradient = document.createElement("div")
699
+ gradient.style.position = "absolute"
700
+ gradient.style.inset = "0"
701
+ gradient.style.zIndex = zIndex
702
+ gradient.style.backgroundImage = layer
703
+ gradient.style.backgroundSize = size
704
+ gradient.style.backgroundPosition = position
705
+ gradient.style.backgroundRepeat = repeat
706
+ layers.appendChild(gradient)
707
+ })
708
+
709
+ if (layers && !layers.childElementCount) return
710
+
711
+ const content = document.createElement("div")
712
+ content.setAttribute("data-revela-pptx-content-layer", "true")
713
+ content.style.position = "absolute"
714
+ content.style.zIndex = String(layerCount + 1)
715
+ content.style.left = `${paddingLeft}px`
716
+ content.style.top = `${paddingTop}px`
717
+ content.style.width = `${Math.max(1, 1920 - paddingLeft - paddingRight)}px`
718
+ content.style.height = `${Math.max(1, 1080 - paddingTop - paddingBottom)}px`
719
+ content.style.minHeight = "0"
720
+ content.style.boxSizing = "border-box"
721
+ while (canvas.firstChild) {
722
+ content.appendChild(canvas.firstChild)
723
+ }
724
+
725
+ canvas.style.padding = "0"
726
+ if (layers) {
727
+ canvas.style.backgroundImage = "none"
728
+ canvas.style.backgroundColor = computed.backgroundColor
729
+ canvas.appendChild(layers)
730
+ }
731
+ canvas.appendChild(content)
732
+ }
733
+
734
+ normalizeBackgroundLayersForPptx(exportCanvas, window.getComputedStyle(target))
735
+ host.appendChild(exportTarget)
736
+ document.body.appendChild(host)
737
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
738
+
739
+ const rect = exportTarget.getBoundingClientRect()
740
+ const canvasRect = exportCanvas.getBoundingClientRect()
741
+ if (Math.abs(rect.width - 1920) > 2 || Math.abs(rect.height - 1080) > 2) {
742
+ host.remove()
743
+ throw new Error(
744
+ `Slide ${index + 1} export slide is ${Math.round(rect.width)}x${Math.round(rect.height)}, expected 1920x1080.`
745
+ )
746
+ }
747
+ if (Math.abs(canvasRect.width - 1920) > 2 || Math.abs(canvasRect.height - 1080) > 2) {
748
+ host.remove()
749
+ throw new Error(
750
+ `Slide ${index + 1} export canvas is ${Math.round(canvasRect.width)}x${Math.round(canvasRect.height)}, expected 1920x1080.`
751
+ )
752
+ }
753
+
509
754
  await Promise.all(
510
- Array.from(target.querySelectorAll("img")).map(async (img) => {
755
+ Array.from(exportTarget.querySelectorAll("img")).map(async (img) => {
511
756
  if (img.complete) return
512
757
  await new Promise((resolve) => {
513
758
  img.addEventListener("load", resolve, { once: true })
@@ -516,15 +761,19 @@ async function exportSlidePptx(
516
761
  })
517
762
  )
518
763
 
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()))
764
+ try {
765
+ const blob: Blob = await domToPptx.exportToPptx(exportTarget, {
766
+ fileName: `slide-${index + 1}.pptx`,
767
+ skipDownload: true,
768
+ svgAsVector: false,
769
+ autoEmbedFonts,
770
+ width: 10,
771
+ height: 5.625,
772
+ })
773
+ return Array.from(new Uint8Array(await blob.arrayBuffer()))
774
+ } finally {
775
+ host.remove()
776
+ }
528
777
  }, { index: slide.index, autoEmbedFonts: options.autoEmbedFonts })
529
778
 
530
779
  return {
@@ -717,6 +966,55 @@ function setSpeakerNotes(files: ZipFiles, notesPath: string, notes: string | nul
717
966
  files[notesPath] = xmlToBytes(doc)
718
967
  }
719
968
 
969
+ function stripSvgBlipFallbacks(pptxBytes: Uint8Array): Uint8Array {
970
+ const files = unzipSync(pptxBytes)
971
+ const orphanedSvgParts = new Set<string>()
972
+
973
+ for (const slidePath of Object.keys(files).filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
974
+ const slideXml = getFileText(files, slidePath)
975
+ if (!slideXml.includes("asvg:svgBlip")) continue
976
+
977
+ const slideDoc = parseXml(slideXml)
978
+ const relsPath = relsPathForPart(slidePath)
979
+ const relsDoc = files[relsPath] ? parseXml(getFileText(files, relsPath)) : null
980
+ const svgRelIds = new Set<string>()
981
+
982
+ for (const svgBlip of Array.from(slideDoc.getElementsByTagName("asvg:svgBlip"))) {
983
+ const relId = svgBlip.getAttribute("r:embed")
984
+ if (relId) svgRelIds.add(relId)
985
+ }
986
+
987
+ for (const blip of Array.from(slideDoc.getElementsByTagName("a:blip"))) {
988
+ for (const extLst of Array.from(blip.getElementsByTagName("a:extLst"))) {
989
+ if (Array.from(extLst.getElementsByTagName("asvg:svgBlip")).length === 0) continue
990
+ blip.removeChild(extLst)
991
+ }
992
+ }
993
+
994
+ if (relsDoc && svgRelIds.size > 0) {
995
+ const relRoot = relsDoc.getElementsByTagName("Relationships")[0]
996
+ for (const rel of Array.from(relsDoc.getElementsByTagName("Relationship"))) {
997
+ const relId = rel.getAttribute("Id")
998
+ if (!relId || !svgRelIds.has(relId)) continue
999
+ const target = rel.getAttribute("Target")
1000
+ if (target) orphanedSvgParts.add(resolveRelationshipTarget(slidePath, target))
1001
+ relRoot?.removeChild(rel)
1002
+ }
1003
+ files[relsPath] = xmlToBytes(relsDoc)
1004
+ }
1005
+
1006
+ files[slidePath] = xmlToBytes(slideDoc)
1007
+ }
1008
+
1009
+ for (const partPath of orphanedSvgParts) {
1010
+ if (/^ppt\/media\/.+\.svg$/i.test(partPath)) {
1011
+ delete files[partPath]
1012
+ }
1013
+ }
1014
+
1015
+ return zipSync(files, { level: 0 })
1016
+ }
1017
+
720
1018
  export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Array<string | null | undefined>): Uint8Array {
721
1019
  const files = unzipSync(pptxBytes)
722
1020
 
@@ -724,7 +1022,7 @@ export function applySpeakerNotesToPptx(pptxBytes: Uint8Array, notesBySlide: Arr
724
1022
  setSpeakerNotes(files, `ppt/notesSlides/notesSlide${index + 1}.xml`, notes ?? null)
725
1023
  })
726
1024
 
727
- return zipSync(files)
1025
+ return stripSvgBlipFallbacks(zipSync(files))
728
1026
  }
729
1027
 
730
1028
  function updateAppProperties(files: ZipFiles, slideCount: number): void {
@@ -898,7 +1196,7 @@ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
898
1196
  mergedFiles["ppt/_rels/presentation.xml.rels"] = xmlToBytes(presentationRelsDoc)
899
1197
  updateAppProperties(mergedFiles, slides.length)
900
1198
 
901
- return zipSync(mergedFiles, { level: 0 })
1199
+ return stripSvgBlipFallbacks(zipSync(mergedFiles, { level: 0 }))
902
1200
  }
903
1201
 
904
1202
  export async function exportToPptx(
@@ -937,7 +1235,7 @@ export async function exportToPptx(
937
1235
  const prepareStart = Date.now()
938
1236
  const originalHtml = readFileSync(abs, "utf-8")
939
1237
  const localized = await localizeExternalImages(originalHtml, tmpDir)
940
- const patchedHtml = await inlineImageAssets(localized.html, abs)
1238
+ const patchedHtml = withExportBaseHref(await inlineImageAssets(localized.html, abs), abs)
941
1239
  tmpHtmlPath = join(tmpDir, "index.html")
942
1240
  writeFileSync(tmpHtmlPath, patchedHtml, "utf-8")
943
1241
  timingsMs.prepareMs = Date.now() - prepareStart
@@ -961,6 +1259,7 @@ export async function exportToPptx(
961
1259
  try {
962
1260
  const pageSetupStart = Date.now()
963
1261
  const { page, slideCount, diagnostics } = await preparePage(browser, tmpHtmlPath, domToPptxBundlePath)
1262
+ const rasterizedFormulaCount = await rasterizeFormulaNodes(page, diagnostics)
964
1263
  timingsMs.pageSetupMs = Date.now() - pageSetupStart
965
1264
  const exported: ExportedSlide[] = []
966
1265
  const failures: SlideFailure[] = []
@@ -969,7 +1268,9 @@ export async function exportToPptx(
969
1268
  const slides = applySpeakerNotesOverride(await readSlideMeta(page, slideCount), options?.speakerNotes)
970
1269
  await emitProgress(options, {
971
1270
  kind: "stage",
972
- message: `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
1271
+ message: rasterizedFormulaCount > 0
1272
+ ? `Deck ready. Rasterized ${rasterizedFormulaCount} formula text member(s). Exporting ${slides.length} slide(s) to editable PPTX parts...`
1273
+ : `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
973
1274
  })
974
1275
 
975
1276
  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.6",
3
+ "version": "0.19.8",
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.6",
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",