@cyber-dash-tech/revela 0.1.16 → 0.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.
@@ -0,0 +1,974 @@
1
+ /**
2
+ * lib/pptx/export.ts
3
+ *
4
+ * HTML -> PPTX export using Puppeteer + dom-to-pptx.
5
+ *
6
+ * Export strategy:
7
+ * 1. Open the HTML in Chrome at a fixed 1920x1080 viewport
8
+ * 2. Normalize reveal state and rasterize ECharts into <img>
9
+ * 3. Export each .slide-canvas as an isolated single-slide PPTX
10
+ * 4. Merge the single-slide PPTX packages into one editable deck
11
+ *
12
+ * This avoids the unstable whole-deck batch export path while keeping slides
13
+ * editable whenever dom-to-pptx can represent them.
14
+ */
15
+
16
+ import puppeteer, { type Browser, type Page } from "puppeteer-core"
17
+ import { DOMParser, XMLSerializer } from "@xmldom/xmldom"
18
+ import { unzipSync, zipSync, strFromU8, strToU8 } from "fflate"
19
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"
20
+ import { basename, dirname, extname, join, posix as pathPosix, resolve } from "path"
21
+ import { randomBytes } from "crypto"
22
+ import { fileURLToPath, pathToFileURL } from "url"
23
+
24
+ const CANVAS_W = 1920
25
+ const CANVAS_H = 1080
26
+ const PPT_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
27
+
28
+ const CHROME_PATHS = [
29
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
30
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
31
+ "/usr/bin/google-chrome-stable",
32
+ "/usr/bin/google-chrome",
33
+ "/usr/bin/chromium-browser",
34
+ "/usr/bin/chromium",
35
+ ]
36
+
37
+ const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp"])
38
+ const MIME_TO_EXT: Record<string, string> = {
39
+ "image/jpeg": ".jpg",
40
+ "image/jpg": ".jpg",
41
+ "image/png": ".png",
42
+ "image/gif": ".gif",
43
+ "image/webp": ".webp",
44
+ "image/svg+xml": ".svg",
45
+ "image/avif": ".avif",
46
+ }
47
+ const EXT_TO_MIME: Record<string, string> = {
48
+ ".jpg": "image/jpeg",
49
+ ".jpeg": "image/jpeg",
50
+ ".png": "image/png",
51
+ ".gif": "image/gif",
52
+ ".webp": "image/webp",
53
+ ".svg": "image/svg+xml",
54
+ ".avif": "image/avif",
55
+ ".bmp": "image/bmp",
56
+ }
57
+
58
+ type ZipFiles = Record<string, Uint8Array>
59
+ type XmlDoc = ReturnType<typeof parseXml>
60
+
61
+ interface SlideMeta {
62
+ index: number
63
+ pageNo: string | null
64
+ title: string | null
65
+ }
66
+
67
+ interface ExportedSlide extends SlideMeta {
68
+ bytes: Uint8Array
69
+ }
70
+
71
+ interface SlideFailure extends SlideMeta {
72
+ error: string
73
+ diagnostics: string[]
74
+ }
75
+
76
+ interface ExportAttemptOptions {
77
+ autoEmbedFonts: boolean
78
+ }
79
+
80
+ interface PreparedPage {
81
+ page: Page
82
+ slideCount: number
83
+ diagnostics: string[]
84
+ }
85
+
86
+ interface ContentTypesIndex {
87
+ defaults: Map<string, string>
88
+ overrides: Map<string, string>
89
+ }
90
+
91
+ export interface ExportPptxProgress {
92
+ kind: "stage" | "slide"
93
+ message: string
94
+ current?: number
95
+ total?: number
96
+ }
97
+
98
+ export interface ExportPptxOptions {
99
+ onProgress?: (progress: ExportPptxProgress) => void | Promise<void>
100
+ }
101
+
102
+ export interface ExportPptxTimings {
103
+ prepareMs: number
104
+ pageSetupMs: number
105
+ slideExportMs: number
106
+ mergeMs: number
107
+ writeMs: number
108
+ }
109
+
110
+ export interface ExportPptxResult {
111
+ outputPath: string
112
+ slideCount: number
113
+ durationMs: number
114
+ timingsMs: ExportPptxTimings
115
+ }
116
+
117
+ interface LocalizeExternalImagesResult {
118
+ html: string
119
+ foundCount: number
120
+ localizedCount: number
121
+ }
122
+
123
+ function findChromePath(): string {
124
+ for (const p of CHROME_PATHS) {
125
+ if (existsSync(p)) return p
126
+ }
127
+ throw new Error(
128
+ "Could not find a Chrome/Chromium installation.\n" +
129
+ "Tried:\n" + CHROME_PATHS.map((p) => ` ${p}`).join("\n")
130
+ )
131
+ }
132
+
133
+ async function launchBrowser(executablePath: string): Promise<Browser> {
134
+ return await puppeteer.launch({
135
+ executablePath,
136
+ headless: true,
137
+ args: [
138
+ "--no-sandbox",
139
+ "--disable-setuid-sandbox",
140
+ "--disable-dev-shm-usage",
141
+ "--allow-file-access-from-files",
142
+ `--window-size=${CANVAS_W},${CANVAS_H}`,
143
+ ],
144
+ })
145
+ }
146
+
147
+ export function derivePptxPath(htmlFilePath: string): string {
148
+ const abs = resolve(htmlFilePath)
149
+ const dir = dirname(abs)
150
+ const name = basename(abs).replace(/\.html?$/i, "")
151
+ return join(dir, `${name}.pptx`)
152
+ }
153
+
154
+ function isLocalImageRef(ref: string): boolean {
155
+ const pathPart = ref.split(/[?#]/)[0]
156
+ return IMAGE_EXTS.has(extname(pathPart).toLowerCase())
157
+ }
158
+
159
+ async function toDataUrlFromRef(ref: string, baseDir: string): Promise<string | null> {
160
+ if (!ref || ref.startsWith("data:") || ref.startsWith("blob:") || ref.startsWith("#")) {
161
+ return null
162
+ }
163
+
164
+ try {
165
+ if (ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("//") || ref.startsWith("file://")) {
166
+ return null
167
+ }
168
+
169
+ let filePath: string | null = null
170
+ if (isLocalImageRef(ref)) {
171
+ filePath = resolve(baseDir, decodeURI(ref.split(/[?#]/)[0]))
172
+ }
173
+
174
+ if (!filePath || !existsSync(filePath)) return null
175
+ const ext = extname(filePath).toLowerCase()
176
+ const mime = EXT_TO_MIME[ext]
177
+ if (!mime) return null
178
+ const buf = readFileSync(filePath)
179
+ return `data:${mime};base64,${buf.toString("base64")}`
180
+ } catch {
181
+ return null
182
+ }
183
+ }
184
+
185
+ async function inlineImageAssets(htmlContent: string, htmlFilePath: string): Promise<string> {
186
+ const baseDir = dirname(resolve(htmlFilePath))
187
+ const urlPattern = /(?:src=["']|url\(["']?)([^"')>\s]+)/g
188
+ const refs = new Set<string>()
189
+ let match: RegExpExecArray | null
190
+
191
+ while ((match = urlPattern.exec(htmlContent)) !== null) {
192
+ refs.add(match[1])
193
+ }
194
+
195
+ if (refs.size === 0) return htmlContent
196
+
197
+ const replacements = new Map<string, string>()
198
+ await Promise.allSettled(
199
+ Array.from(refs).map(async (ref) => {
200
+ const dataUrl = await toDataUrlFromRef(ref, baseDir)
201
+ if (dataUrl) replacements.set(ref, dataUrl)
202
+ })
203
+ )
204
+
205
+ let patched = htmlContent
206
+ for (const [original, replacement] of replacements) {
207
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
208
+ patched = patched.replace(new RegExp(escaped, "g"), replacement)
209
+ }
210
+ return patched
211
+ }
212
+
213
+ async function localizeExternalImages(htmlContent: string, tmpDir: string): Promise<LocalizeExternalImagesResult> {
214
+ const urlPattern = /(?:src=["']|url\(["']?)(https?:\/\/[^"')>\s]+)/g
215
+ const uniqueUrls = new Set<string>()
216
+ let match: RegExpExecArray | null
217
+
218
+ while ((match = urlPattern.exec(htmlContent)) !== null) {
219
+ uniqueUrls.add(match[1])
220
+ }
221
+
222
+ if (uniqueUrls.size === 0) {
223
+ return {
224
+ html: htmlContent,
225
+ foundCount: 0,
226
+ localizedCount: 0,
227
+ }
228
+ }
229
+
230
+ const urlToLocal = new Map<string, string>()
231
+
232
+ await Promise.allSettled(
233
+ Array.from(uniqueUrls).map(async (url, i) => {
234
+ try {
235
+ const controller = new AbortController()
236
+ const timer = setTimeout(() => controller.abort(), 10000)
237
+ const res = await fetch(url, {
238
+ signal: controller.signal,
239
+ headers: {
240
+ "User-Agent":
241
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
242
+ "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
243
+ },
244
+ })
245
+ clearTimeout(timer)
246
+
247
+ if (!res.ok) return
248
+
249
+ const contentType = res.headers.get("content-type") ?? ""
250
+ const mimeBase = contentType.split(";")[0].trim().toLowerCase()
251
+
252
+ let ext = extname(new URL(url).pathname).toLowerCase()
253
+ if (!ext || ext.length > 6) {
254
+ ext = MIME_TO_EXT[mimeBase] ?? ".bin"
255
+ }
256
+
257
+ const localPath = join(tmpDir, `img-${i}${ext}`)
258
+ const buf = new Uint8Array(await res.arrayBuffer())
259
+ writeFileSync(localPath, buf)
260
+ urlToLocal.set(url, pathToFileURL(localPath).href)
261
+ } catch {
262
+ // Preserve original URL on per-image failure.
263
+ }
264
+ })
265
+ )
266
+
267
+ let patched = htmlContent
268
+ for (const [original, local] of urlToLocal) {
269
+ const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
270
+ patched = patched.replace(new RegExp(escaped, "g"), local)
271
+ }
272
+
273
+ return {
274
+ html: patched,
275
+ foundCount: uniqueUrls.size,
276
+ localizedCount: urlToLocal.size,
277
+ }
278
+ }
279
+
280
+ async function emitProgress(
281
+ options: ExportPptxOptions | undefined,
282
+ progress: ExportPptxProgress,
283
+ ): Promise<void> {
284
+ await options?.onProgress?.(progress)
285
+ }
286
+
287
+ function attachDiagnostics(page: Page, diagnostics: string[]): void {
288
+ page.on("pageerror", (error) => {
289
+ const message = error instanceof Error ? error.message : String(error)
290
+ diagnostics.push(`pageerror: ${message}`)
291
+ })
292
+ page.on("error", (error) => {
293
+ const message = error instanceof Error ? error.message : String(error)
294
+ diagnostics.push(`error: ${message}`)
295
+ })
296
+ page.on("console", (msg) => {
297
+ if (msg.type() === "error" || msg.type() === "warn") {
298
+ diagnostics.push(`console.${msg.type()}: ${msg.text()}`)
299
+ }
300
+ })
301
+ }
302
+
303
+ async function preparePage(
304
+ browser: Browser,
305
+ htmlPath: string,
306
+ domToPptxBundlePath: string,
307
+ ): Promise<PreparedPage> {
308
+ const page = await browser.newPage()
309
+ const diagnostics: string[] = []
310
+ attachDiagnostics(page, diagnostics)
311
+
312
+ await page.setViewport({ width: CANVAS_W, height: CANVAS_H })
313
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "domcontentloaded", timeout: 60000 })
314
+ await page.waitForSelector(".slide", { timeout: 30000 })
315
+ await page.addScriptTag({ path: domToPptxBundlePath })
316
+
317
+ const slideCount = await page.evaluate(async () => {
318
+ await document.fonts.ready
319
+
320
+ document.documentElement.style.scrollSnapType = "none"
321
+ document.documentElement.style.overflow = "visible"
322
+ document.body.style.overflow = "visible"
323
+
324
+ const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[]
325
+ if (slides.length === 0) {
326
+ throw new Error(
327
+ "No .slide elements found in the HTML file.\nMake sure this is a revela-generated slide deck."
328
+ )
329
+ }
330
+
331
+ slides.forEach((slide, index) => {
332
+ slide.setAttribute("data-export-slide-index", String(index))
333
+ slide.querySelectorAll(".reveal").forEach((el) => {
334
+ const htmlEl = el as HTMLElement
335
+ htmlEl.dataset.exportOriginalVisible = htmlEl.classList.contains("visible") ? "1" : "0"
336
+ })
337
+ })
338
+
339
+ const canvases = Array.from(document.querySelectorAll(".slide-canvas")) as HTMLElement[]
340
+ let exportStyle = document.getElementById("revela-pptx-export-style") as HTMLStyleElement | null
341
+ if (!exportStyle) {
342
+ exportStyle = document.createElement("style")
343
+ exportStyle.id = "revela-pptx-export-style"
344
+ exportStyle.textContent = `
345
+ .slide-canvas {
346
+ transform: none !important;
347
+ transform-origin: top left !important;
348
+ transition: none !important;
349
+ animation: none !important;
350
+ }
351
+ .slide-canvas * {
352
+ transition: none !important;
353
+ animation: none !important;
354
+ }
355
+ `
356
+ document.head.appendChild(exportStyle)
357
+ }
358
+
359
+ canvases.forEach((canvas, index) => {
360
+ canvas.setAttribute("data-export-canvas-index", String(index))
361
+ })
362
+
363
+ const rasterizeNodeAsImage = (node: HTMLElement, dataUrl: string) => {
364
+ const img = document.createElement("img")
365
+ img.src = dataUrl
366
+ img.alt = "Rasterized chart"
367
+ img.width = node.clientWidth || node.scrollWidth || 1
368
+ img.height = node.clientHeight || node.scrollHeight || 1
369
+ img.style.width = "100%"
370
+ img.style.height = "100%"
371
+ img.style.display = "block"
372
+ img.style.objectFit = "contain"
373
+ node.replaceChildren(img)
374
+ }
375
+
376
+ const echartsApi = (window as any).echarts
377
+ const chartNodes = Array.from(document.querySelectorAll(".echart-container")) as HTMLElement[]
378
+ for (const node of chartNodes) {
379
+ let dataUrl: string | null = null
380
+ if (echartsApi?.getInstanceByDom) {
381
+ const chart = echartsApi.getInstanceByDom(node)
382
+ if (chart?.getDataURL) {
383
+ try {
384
+ dataUrl = chart.getDataURL({
385
+ type: "png",
386
+ pixelRatio: 2,
387
+ backgroundColor: "transparent",
388
+ excludeComponents: ["toolbox"],
389
+ })
390
+ } catch {
391
+ dataUrl = null
392
+ }
393
+ }
394
+ }
395
+
396
+ if (!dataUrl) {
397
+ const fallbackCanvas = node.querySelector("canvas") as HTMLCanvasElement | null
398
+ if (fallbackCanvas) {
399
+ try {
400
+ dataUrl = fallbackCanvas.toDataURL("image/png")
401
+ } catch {
402
+ dataUrl = null
403
+ }
404
+ }
405
+ }
406
+
407
+ if (dataUrl) rasterizeNodeAsImage(node, dataUrl)
408
+ }
409
+
410
+ return canvases.length
411
+ })
412
+
413
+ return { page, slideCount, diagnostics }
414
+ }
415
+
416
+ async function readSlideMeta(
417
+ page: Page,
418
+ slideCount: number,
419
+ ): Promise<SlideMeta[]> {
420
+ const meta = await page.evaluate(() => {
421
+ return Array.from(document.querySelectorAll(".slide")).map((slide, index) => {
422
+ const pageNo =
423
+ Array.from(slide.querySelectorAll("div"))
424
+ .map((el) => el.textContent?.trim() ?? "")
425
+ .find((text) => /^\d{2}$/.test(text)) ?? null
426
+ const title = slide.querySelector("h1,h2,h3")?.textContent?.trim()?.slice(0, 120) ?? null
427
+ return { index, pageNo, title }
428
+ })
429
+ })
430
+
431
+ return Array.from({ length: slideCount }, (_, index) => {
432
+ return meta[index] ?? { index, pageNo: null, title: null }
433
+ })
434
+ }
435
+
436
+ async function exportSlidePptx(
437
+ page: Page,
438
+ diagnostics: string[],
439
+ slide: SlideMeta,
440
+ options: ExportAttemptOptions,
441
+ ): Promise<ExportedSlide> {
442
+ const diagStart = diagnostics.length
443
+ try {
444
+ const pptxBytes = await page.evaluate(async ({ index, autoEmbedFonts }) => {
445
+ const domToPptx = (window as any).domToPptx
446
+ const slides = Array.from(document.querySelectorAll(".slide")) as HTMLElement[]
447
+ const targetSlide = slides[index]
448
+ const target = document.querySelectorAll(".slide-canvas")[index] as HTMLElement | undefined
449
+ if (!domToPptx?.exportToPptx) {
450
+ throw new Error("dom-to-pptx bundle did not initialize correctly.")
451
+ }
452
+ if (!targetSlide) {
453
+ throw new Error(`Missing .slide for slide ${index + 1}`)
454
+ }
455
+ if (!target) {
456
+ throw new Error(`Missing .slide-canvas for slide ${index + 1}`)
457
+ }
458
+
459
+ for (const slideEl of slides) {
460
+ const isTargetSlide = slideEl === targetSlide
461
+ slideEl.dataset.exportActive = isTargetSlide ? "1" : "0"
462
+ slideEl.querySelectorAll(".reveal").forEach((el) => {
463
+ const htmlEl = el as HTMLElement
464
+ const wasVisible = htmlEl.dataset.exportOriginalVisible === "1"
465
+ htmlEl.classList.toggle("visible", wasVisible || isTargetSlide)
466
+ })
467
+ }
468
+
469
+ target.scrollIntoView({ block: "center", inline: "center" })
470
+ target.style.transform = "none"
471
+ target.style.transformOrigin = "top left"
472
+ target.style.transition = "none"
473
+ target.style.animation = "none"
474
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
475
+
476
+ await Promise.all(
477
+ Array.from(target.querySelectorAll("img")).map(async (img) => {
478
+ if (img.complete) return
479
+ await new Promise((resolve) => {
480
+ img.addEventListener("load", resolve, { once: true })
481
+ img.addEventListener("error", resolve, { once: true })
482
+ })
483
+ })
484
+ )
485
+
486
+ const blob: Blob = await domToPptx.exportToPptx(target, {
487
+ fileName: `slide-${index + 1}.pptx`,
488
+ skipDownload: true,
489
+ svgAsVector: false,
490
+ autoEmbedFonts,
491
+ width: 10,
492
+ height: 5.625,
493
+ })
494
+ return Array.from(new Uint8Array(await blob.arrayBuffer()))
495
+ }, { index: slide.index, autoEmbedFonts: options.autoEmbedFonts })
496
+
497
+ return {
498
+ ...slide,
499
+ bytes: Uint8Array.from(pptxBytes),
500
+ }
501
+ } catch (error) {
502
+ throw formatSlideFailure(error, diagnostics.slice(diagStart ?? 0), slide)
503
+ }
504
+ }
505
+
506
+ function parseXml(xml: string) {
507
+ return new DOMParser().parseFromString(xml, "text/xml")
508
+ }
509
+
510
+ function xmlToBytes(doc: XmlDoc): Uint8Array {
511
+ return strToU8(new XMLSerializer().serializeToString(doc))
512
+ }
513
+
514
+ function getFileText(files: ZipFiles, path: string): string {
515
+ const file = files[path]
516
+ if (!file) throw new Error(`Missing PPTX part: ${path}`)
517
+ return strFromU8(file)
518
+ }
519
+
520
+ function readContentTypes(files: ZipFiles): ContentTypesIndex {
521
+ const doc = parseXml(getFileText(files, "[Content_Types].xml"))
522
+ const defaults = new Map<string, string>()
523
+ const overrides = new Map<string, string>()
524
+
525
+ for (const node of Array.from(doc.getElementsByTagName("Default"))) {
526
+ const ext = node.getAttribute("Extension")
527
+ const contentType = node.getAttribute("ContentType")
528
+ if (ext && contentType) defaults.set(ext, contentType)
529
+ }
530
+
531
+ for (const node of Array.from(doc.getElementsByTagName("Override"))) {
532
+ const partName = node.getAttribute("PartName")
533
+ const contentType = node.getAttribute("ContentType")
534
+ if (partName && contentType) overrides.set(partName, contentType)
535
+ }
536
+
537
+ return { defaults, overrides }
538
+ }
539
+
540
+ function upsertDefault(doc: XmlDoc, ext: string, contentType: string): void {
541
+ const types = doc.getElementsByTagName("Types")[0]
542
+ const existing = Array.from(doc.getElementsByTagName("Default")).find(
543
+ (node) => node.getAttribute("Extension") === ext
544
+ )
545
+ if (existing) {
546
+ existing.setAttribute("ContentType", contentType)
547
+ return
548
+ }
549
+
550
+ const node = doc.createElement("Default")
551
+ node.setAttribute("Extension", ext)
552
+ node.setAttribute("ContentType", contentType)
553
+ types.appendChild(node)
554
+ }
555
+
556
+ function upsertOverride(doc: XmlDoc, partName: string, contentType: string): void {
557
+ const types = doc.getElementsByTagName("Types")[0]
558
+ const existing = Array.from(doc.getElementsByTagName("Override")).find(
559
+ (node) => node.getAttribute("PartName") === partName
560
+ )
561
+ if (existing) {
562
+ existing.setAttribute("ContentType", contentType)
563
+ return
564
+ }
565
+
566
+ const node = doc.createElement("Override")
567
+ node.setAttribute("PartName", partName)
568
+ node.setAttribute("ContentType", contentType)
569
+ types.appendChild(node)
570
+ }
571
+
572
+ function relsPathForPart(partPath: string): string {
573
+ const dir = pathPosix.dirname(partPath)
574
+ const base = pathPosix.basename(partPath)
575
+ return pathPosix.join(dir, "_rels", `${base}.rels`)
576
+ }
577
+
578
+ function ownerPartForRels(relsPath: string): string {
579
+ return relsPath.replace(/\/_[Rr]els\//, "/").replace(/\.rels$/, "")
580
+ }
581
+
582
+ function resolveRelationshipTarget(ownerPartPath: string, target: string): string {
583
+ const baseDir = pathPosix.dirname(ownerPartPath)
584
+ return pathPosix.normalize(pathPosix.join(baseDir, target))
585
+ }
586
+
587
+ function relativeTarget(ownerPartPath: string, targetPartPath: string): string {
588
+ const baseDir = pathPosix.dirname(ownerPartPath)
589
+ return pathPosix.relative(baseDir, targetPartPath)
590
+ }
591
+
592
+ function isSharedPart(partPath: string): boolean {
593
+ return (
594
+ /^ppt\/(slideLayouts|slideMasters|theme|notesMasters)\//.test(partPath) ||
595
+ partPath === "ppt/presProps.xml" ||
596
+ partPath === "ppt/viewProps.xml" ||
597
+ partPath === "ppt/tableStyles.xml"
598
+ )
599
+ }
600
+
601
+ function maxIndexedFile(files: ZipFiles, pattern: RegExp): number {
602
+ let max = 0
603
+ for (const path of Object.keys(files)) {
604
+ const match = path.match(pattern)
605
+ if (!match) continue
606
+ max = Math.max(max, Number(match[1]))
607
+ }
608
+ return max
609
+ }
610
+
611
+ function nextAvailablePath(files: ZipFiles, originalPath: string): string {
612
+ const dir = pathPosix.dirname(originalPath)
613
+ const ext = pathPosix.extname(originalPath)
614
+ const base = pathPosix.basename(originalPath, ext)
615
+ let counter = 1
616
+ let candidate = originalPath
617
+
618
+ while (files[candidate]) {
619
+ candidate = pathPosix.join(dir, `${base}-import${counter}${ext}`)
620
+ counter += 1
621
+ }
622
+
623
+ return candidate
624
+ }
625
+
626
+ function getMaxPresentationRelId(doc: XmlDoc): number {
627
+ let max = 0
628
+ for (const rel of Array.from(doc.getElementsByTagName("Relationship"))) {
629
+ const id = rel.getAttribute("Id")
630
+ const match = id?.match(/^rId(\d+)$/)
631
+ if (match) max = Math.max(max, Number(match[1]))
632
+ }
633
+ return max
634
+ }
635
+
636
+ function setSlideName(files: ZipFiles, slidePath: string, label: string): void {
637
+ const doc = parseXml(getFileText(files, slidePath))
638
+ const cSld = doc.getElementsByTagName("p:cSld")[0]
639
+ if (cSld) cSld.setAttribute("name", label)
640
+ files[slidePath] = xmlToBytes(doc)
641
+ }
642
+
643
+ function setNotesSlideNumber(files: ZipFiles, notesPath: string, number: number): void {
644
+ const doc = parseXml(getFileText(files, notesPath))
645
+ const fields = Array.from(doc.getElementsByTagName("a:fld"))
646
+ for (const field of fields) {
647
+ if (field.getAttribute("type") !== "slidenum") continue
648
+ const textNode = Array.from(field.childNodes).find((node) => node.nodeName === "a:t")
649
+ if (textNode) {
650
+ textNode.textContent = String(number)
651
+ }
652
+ }
653
+ files[notesPath] = xmlToBytes(doc)
654
+ }
655
+
656
+ function updateAppProperties(files: ZipFiles, slideCount: number): void {
657
+ const doc = parseXml(getFileText(files, "docProps/app.xml"))
658
+ const setText = (tag: string, value: string) => {
659
+ const node = doc.getElementsByTagName(tag)[0]
660
+ if (node) node.textContent = value
661
+ }
662
+
663
+ setText("Slides", String(slideCount))
664
+ setText("Notes", String(slideCount))
665
+
666
+ const titlesNode = doc.getElementsByTagName("TitlesOfParts")[0]
667
+ const titlesVector = titlesNode?.getElementsByTagName("vt:vector")[0]
668
+ if (titlesVector) {
669
+ const preserved = Array.from(titlesVector.getElementsByTagName("vt:lpstr"))
670
+ .map((node) => node.textContent ?? "")
671
+ .filter((text) => !/^Slide \d+$/.test(text))
672
+
673
+ while (titlesVector.firstChild) titlesVector.removeChild(titlesVector.firstChild)
674
+ for (const text of [...preserved, ...Array.from({ length: slideCount }, (_, i) => `Slide ${i + 1}`)]) {
675
+ const node = doc.createElement("vt:lpstr")
676
+ node.appendChild(doc.createTextNode(text))
677
+ titlesVector.appendChild(node)
678
+ }
679
+ titlesVector.setAttribute("size", String(preserved.length + slideCount))
680
+ }
681
+
682
+ const variants = Array.from(doc.getElementsByTagName("vt:variant"))
683
+ for (let i = 0; i < variants.length - 1; i += 1) {
684
+ const label = variants[i].textContent?.trim()
685
+ if (label !== "Slide Titles") continue
686
+ const countNode = variants[i + 1].getElementsByTagName("vt:i4")[0]
687
+ if (countNode) countNode.textContent = String(slideCount)
688
+ break
689
+ }
690
+
691
+ files["docProps/app.xml"] = xmlToBytes(doc)
692
+ }
693
+
694
+ function formatBrowserCrash(error: unknown, diagnostics: string[]): string {
695
+ const message = error instanceof Error ? error.message : String(error)
696
+ const detail = diagnostics.length > 0 ? ` [${diagnostics.join(" | ")}]` : ""
697
+
698
+ if (/Target closed|Session closed|Target page, context or browser has been closed/i.test(message)) {
699
+ return "Chrome page crashed during PPTX export." + detail
700
+ }
701
+
702
+ return `${message}${detail}`
703
+ }
704
+
705
+ function formatSlideFailure(error: unknown, diagnostics: string[], slide: SlideMeta): Error {
706
+ const label = slide.pageNo ? `slide ${slide.pageNo}` : `slide #${slide.index + 1}`
707
+ const title = slide.title ? ` (${slide.title})` : ""
708
+ return new Error(`${label}${title}: ${formatBrowserCrash(error, diagnostics)}`)
709
+ }
710
+
711
+ function mergeSingleSlidePptx(slides: ExportedSlide[]): Uint8Array {
712
+ if (slides.length === 0) {
713
+ throw new Error("No slides were exported.")
714
+ }
715
+
716
+ const mergedFiles: ZipFiles = { ...unzipSync(slides[0].bytes) }
717
+ const contentTypesDoc = parseXml(getFileText(mergedFiles, "[Content_Types].xml"))
718
+ const presentationDoc = parseXml(getFileText(mergedFiles, "ppt/presentation.xml"))
719
+ const presentationRelsDoc = parseXml(getFileText(mergedFiles, "ppt/_rels/presentation.xml.rels"))
720
+ const contentTypes = readContentTypes(mergedFiles)
721
+
722
+ const sldIdLst = presentationDoc.getElementsByTagName("p:sldIdLst")[0]
723
+ const relRoot = presentationRelsDoc.getElementsByTagName("Relationships")[0]
724
+ if (!sldIdLst || !relRoot) {
725
+ throw new Error("PPTX merge failed: missing presentation slide list.")
726
+ }
727
+
728
+ let nextSlideNumber = maxIndexedFile(mergedFiles, /^ppt\/slides\/slide(\d+)\.xml$/) + 1
729
+ let nextNotesNumber = maxIndexedFile(mergedFiles, /^ppt\/notesSlides\/notesSlide(\d+)\.xml$/) + 1
730
+ let nextSlideId = Math.max(maxIndexedFile(mergedFiles, /^ppt\/slides\/slide(\d+)\.xml$/) + 255, 256)
731
+ for (const node of Array.from(sldIdLst.getElementsByTagName("p:sldId"))) {
732
+ const id = Number(node.getAttribute("id") ?? "0")
733
+ nextSlideId = Math.max(nextSlideId, id + 1)
734
+ }
735
+ let nextPresentationRelId = getMaxPresentationRelId(presentationRelsDoc) + 1
736
+
737
+ setSlideName(mergedFiles, "ppt/slides/slide1.xml", "Slide 1")
738
+ setNotesSlideNumber(mergedFiles, "ppt/notesSlides/notesSlide1.xml", 1)
739
+
740
+ for (let slideIdx = 1; slideIdx < slides.length; slideIdx += 1) {
741
+ const sourceFiles: ZipFiles = { ...unzipSync(slides[slideIdx].bytes) }
742
+ const sourceTypes = readContentTypes(sourceFiles)
743
+ const pathMap = new Map<string, string>()
744
+
745
+ const importPart = (sourcePartPath: string): string => {
746
+ if (pathMap.has(sourcePartPath)) return pathMap.get(sourcePartPath)!
747
+ if (isSharedPart(sourcePartPath)) return sourcePartPath
748
+
749
+ let destPath: string
750
+ if (sourcePartPath === "ppt/slides/slide1.xml") {
751
+ destPath = `ppt/slides/slide${nextSlideNumber}.xml`
752
+ } else if (sourcePartPath === "ppt/notesSlides/notesSlide1.xml") {
753
+ destPath = `ppt/notesSlides/notesSlide${nextNotesNumber}.xml`
754
+ } else {
755
+ destPath = nextAvailablePath(mergedFiles, sourcePartPath)
756
+ }
757
+
758
+ pathMap.set(sourcePartPath, destPath)
759
+ const file = sourceFiles[sourcePartPath]
760
+ if (!file) {
761
+ throw new Error(`PPTX merge failed: missing source part ${sourcePartPath}`)
762
+ }
763
+ mergedFiles[destPath] = file
764
+
765
+ const overrideType = sourceTypes.overrides.get(`/${sourcePartPath}`)
766
+ if (overrideType) {
767
+ upsertOverride(contentTypesDoc, `/${destPath}`, overrideType)
768
+ } else {
769
+ const ext = pathPosix.extname(destPath).slice(1)
770
+ if (ext) {
771
+ const contentType = sourceTypes.defaults.get(ext) ?? contentTypes.defaults.get(ext)
772
+ if (contentType) upsertDefault(contentTypesDoc, ext, contentType)
773
+ }
774
+ }
775
+
776
+ const sourceRelsPath = relsPathForPart(sourcePartPath)
777
+ if (sourceFiles[sourceRelsPath]) {
778
+ const ownerDestPath = destPath
779
+ const relsDoc = parseXml(getFileText(sourceFiles, sourceRelsPath))
780
+ for (const rel of Array.from(relsDoc.getElementsByTagName("Relationship"))) {
781
+ if (rel.getAttribute("TargetMode") === "External") continue
782
+ const target = rel.getAttribute("Target")
783
+ if (!target) continue
784
+ const targetSourcePath = resolveRelationshipTarget(sourcePartPath, target)
785
+ const targetDestPath = isSharedPart(targetSourcePath) ? targetSourcePath : importPart(targetSourcePath)
786
+ rel.setAttribute("Target", relativeTarget(ownerDestPath, targetDestPath))
787
+ }
788
+ mergedFiles[relsPathForPart(destPath)] = xmlToBytes(relsDoc)
789
+ }
790
+
791
+ return destPath
792
+ }
793
+
794
+ const slidePath = importPart("ppt/slides/slide1.xml")
795
+ const notesPath = pathMap.get("ppt/notesSlides/notesSlide1.xml") ?? null
796
+
797
+ setSlideName(mergedFiles, slidePath, `Slide ${slideIdx + 1}`)
798
+ if (notesPath && mergedFiles[notesPath]) {
799
+ setNotesSlideNumber(mergedFiles, notesPath, slideIdx + 1)
800
+ }
801
+
802
+ const relId = `rId${nextPresentationRelId}`
803
+ const relNode = presentationRelsDoc.createElement("Relationship")
804
+ relNode.setAttribute("Id", relId)
805
+ relNode.setAttribute("Type", `${PPT_REL_NS}/slide`)
806
+ relNode.setAttribute("Target", pathPosix.relative("ppt", slidePath))
807
+ relRoot.appendChild(relNode)
808
+
809
+ const slideNode = presentationDoc.createElement("p:sldId")
810
+ slideNode.setAttribute("id", String(nextSlideId))
811
+ slideNode.setAttribute("r:id", relId)
812
+ sldIdLst.appendChild(slideNode)
813
+
814
+ nextSlideNumber += 1
815
+ nextNotesNumber += notesPath ? 1 : 0
816
+ nextSlideId += 1
817
+ nextPresentationRelId += 1
818
+ }
819
+
820
+ mergedFiles["[Content_Types].xml"] = xmlToBytes(contentTypesDoc)
821
+ mergedFiles["ppt/presentation.xml"] = xmlToBytes(presentationDoc)
822
+ mergedFiles["ppt/_rels/presentation.xml.rels"] = xmlToBytes(presentationRelsDoc)
823
+ updateAppProperties(mergedFiles, slides.length)
824
+
825
+ return zipSync(mergedFiles, { level: 0 })
826
+ }
827
+
828
+ export async function exportToPptx(
829
+ htmlFilePath: string,
830
+ options?: ExportPptxOptions,
831
+ ): Promise<ExportPptxResult> {
832
+ const startMs = Date.now()
833
+ const abs = resolve(htmlFilePath)
834
+ const domToPptxBundlePath = resolve(
835
+ dirname(fileURLToPath(import.meta.url)),
836
+ "../../node_modules/dom-to-pptx/dist/dom-to-pptx.bundle.js"
837
+ )
838
+
839
+ if (!existsSync(abs)) {
840
+ throw new Error(`File not found: ${abs}`)
841
+ }
842
+ if (!/\.html?$/i.test(abs)) {
843
+ throw new Error(`Not an HTML file: ${abs}`)
844
+ }
845
+
846
+ const outputPath = derivePptxPath(abs)
847
+ const executablePath = findChromePath()
848
+ const tmpDir = join("/tmp", `revela-pptx-${randomBytes(6).toString("hex")}`)
849
+ mkdirSync(tmpDir, { recursive: true })
850
+ const timingsMs: ExportPptxTimings = {
851
+ prepareMs: 0,
852
+ pageSetupMs: 0,
853
+ slideExportMs: 0,
854
+ mergeMs: 0,
855
+ writeMs: 0,
856
+ }
857
+
858
+ let tmpHtmlPath = abs
859
+ try {
860
+ await emitProgress(options, {
861
+ kind: "stage",
862
+ message: "Preparing HTML and localizing remote assets...",
863
+ })
864
+ const prepareStart = Date.now()
865
+ const originalHtml = readFileSync(abs, "utf-8")
866
+ const localized = await localizeExternalImages(originalHtml, tmpDir)
867
+ const patchedHtml = await inlineImageAssets(localized.html, abs)
868
+ tmpHtmlPath = join(tmpDir, "index.html")
869
+ writeFileSync(tmpHtmlPath, patchedHtml, "utf-8")
870
+ timingsMs.prepareMs = Date.now() - prepareStart
871
+ await emitProgress(options, {
872
+ kind: "stage",
873
+ message: localized.foundCount > 0
874
+ ? `Prepared export HTML. Localized ${localized.localizedCount}/${localized.foundCount} remote image assets.`
875
+ : "Prepared export HTML.",
876
+ })
877
+ } catch {
878
+ tmpHtmlPath = abs
879
+ timingsMs.prepareMs = 0
880
+ }
881
+
882
+ await emitProgress(options, {
883
+ kind: "stage",
884
+ message: "Launching Chrome and preparing slide DOM...",
885
+ })
886
+ const browser = await launchBrowser(executablePath)
887
+
888
+ try {
889
+ const pageSetupStart = Date.now()
890
+ const { page, slideCount, diagnostics } = await preparePage(browser, tmpHtmlPath, domToPptxBundlePath)
891
+ timingsMs.pageSetupMs = Date.now() - pageSetupStart
892
+ const exported: ExportedSlide[] = []
893
+ const failures: SlideFailure[] = []
894
+
895
+ try {
896
+ const slides = await readSlideMeta(page, slideCount)
897
+ await emitProgress(options, {
898
+ kind: "stage",
899
+ message: `Deck ready. Exporting ${slides.length} slide(s) to editable PPTX parts...`,
900
+ })
901
+
902
+ const slideExportStart = Date.now()
903
+ for (const slide of slides) {
904
+ await emitProgress(options, {
905
+ kind: "slide",
906
+ message: `Exporting slide ${slide.index + 1}/${slides.length}${slide.title ? `: ${slide.title}` : ""}`,
907
+ current: slide.index + 1,
908
+ total: slides.length,
909
+ })
910
+ try {
911
+ exported.push(
912
+ await exportSlidePptx(page, diagnostics, slide, {
913
+ autoEmbedFonts: false,
914
+ })
915
+ )
916
+ } catch (error) {
917
+ failures.push({
918
+ ...slide,
919
+ error: error instanceof Error ? error.message : String(error),
920
+ diagnostics: [],
921
+ })
922
+ }
923
+ }
924
+ timingsMs.slideExportMs = Date.now() - slideExportStart
925
+
926
+ if (failures.length > 0) {
927
+ const summary = failures
928
+ .map((slide) => `- ${slide.error}`)
929
+ .join("\n")
930
+ throw new Error(
931
+ `Editable PPTX export failed on ${failures.length} slide(s):\n${summary}\n\n` +
932
+ "No screenshot fallback was used. Fix the failing slide styles/resources and retry."
933
+ )
934
+ }
935
+
936
+ await emitProgress(options, {
937
+ kind: "stage",
938
+ message: "Merging slide parts into final PPTX package...",
939
+ })
940
+ const mergeStart = Date.now()
941
+ const pptxBytes = mergeSingleSlidePptx(exported)
942
+ timingsMs.mergeMs = Date.now() - mergeStart
943
+
944
+ await emitProgress(options, {
945
+ kind: "stage",
946
+ message: "Writing PPTX file to disk...",
947
+ })
948
+ const writeStart = Date.now()
949
+ writeFileSync(outputPath, pptxBytes)
950
+ timingsMs.writeMs = Date.now() - writeStart
951
+
952
+ await emitProgress(options, {
953
+ kind: "stage",
954
+ message: `PPTX export complete: ${exported.length} slide(s) written.`,
955
+ })
956
+
957
+ return {
958
+ outputPath,
959
+ slideCount: exported.length,
960
+ durationMs: Date.now() - startMs,
961
+ timingsMs,
962
+ }
963
+ } finally {
964
+ await page.close().catch(() => undefined)
965
+ }
966
+ } finally {
967
+ await browser.close().catch(() => undefined)
968
+ try {
969
+ rmSync(tmpDir, { recursive: true, force: true })
970
+ } catch {
971
+ // Non-fatal.
972
+ }
973
+ }
974
+ }