@cyber-dash-tech/revela 0.1.16 → 0.2.1
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 +87 -90
- package/README.zh-CN.md +90 -93
- package/lib/agents/research-prompt.ts +12 -2
- package/lib/commands/help.ts +2 -1
- package/lib/commands/pptx.ts +75 -0
- package/lib/document-materials/extract.ts +373 -0
- package/lib/pptx/export.ts +974 -0
- package/package.json +2 -1
- package/plugin.ts +8 -1
- package/tools/extract-document-materials.ts +18 -0
|
@@ -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
|
+
}
|