返回 oh-my-ppt
export-handlers.ts
根目录 / src / main / io / export-handlers.ts
1 import { BrowserWindow, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
2 import log from 'electron-log/main.js'
3 import fs from 'fs'
4 import os from 'os'
5 import path from 'path'
6 import { execFileSync } from 'child_process'
7 import { is } from '@electron-toolkit/utils'
8 import { nanoid } from 'nanoid'
9 import pLimit from 'p-limit'
10 import { zipSync } from 'fflate'
11 import { PDFDocument } from 'pdf-lib'
12 import type { IpcContext } from '../ipc/context'
13 import { resolveOutlinesForPages } from '../session/page-outline-utils'
14 import {
15 type HtmlToPptxEmbeddedFont,
16 type HtmlToPptxSlide
17 } from '@arcsin1/html2pptx'
18 import { writeHtmlToPptx } from '@arcsin1/html2pptx/node'
19 import { collectEmbeddedFonts } from './html-pptx/font-collect'
20 import {
21 captureHtmlPageToPptxImageSlide,
22 extractHtmlPageToPptxSlide
23 } from './html-pptx/renderer'
24 import { resolvePptxExportLayout } from './html-pptx/static-background'
25 import {
26 exportHtmlPagesToVideo,
27 normalizeVideoExportFps,
28 normalizeVideoExportSecondsPerPage
29 } from './html-video/exporter'
30 import type {
31 ExportKind,
32 ExportProgressPayload,
33 ExportProgressStage
34 } from '@shared/export-progress'
35 import { assertPptxExportSupported, requireSessionSlideSize } from '@shared/slide-size'
36 import { stitchPngBuffersVertical } from './thumbnails/png-stitch'
37
38 type PptxExportPayload = {
39 sessionId?: unknown
40 imageOnly?: unknown
41 embedFonts?: unknown
42 pageId?: unknown
43 fps?: unknown
44 captureFps?: unknown
45 secondsPerPage?: unknown
46 }
47
48 const EXPORT_PAGE_RENDER_CONCURRENCY = Math.max(1, Math.min(2, os.cpus().length || 1))
49
50 const clampExportProgress = (progress: number): number =>
51 Math.max(0, Math.min(100, Math.round(progress)))
52
53 const scaleExportProgress = (
54 current: number,
55 total: number,
56 startProgress: number,
57 endProgress: number
58 ): number => {
59 if (total <= 0) return clampExportProgress(startProgress)
60 const ratio = Math.max(0, Math.min(1, current / total))
61 return clampExportProgress(startProgress + (endProgress - startProgress) * ratio)
62 }
63
64 const createExportProgressSender =
65 (event: IpcMainInvokeEvent, sessionId: string, kind: ExportKind) =>
66 (payload: {
67 stage: ExportProgressStage
68 progress: number
69 current?: number
70 total?: number
71 }): void => {
72 const progressPayload: ExportProgressPayload = {
73 sessionId,
74 kind,
75 stage: payload.stage,
76 progress: clampExportProgress(payload.progress),
77 current: payload.current,
78 total: payload.total
79 }
80 event.sender.send('export:progress', progressPayload)
81 }
82
83 const mapPageBatch = async <T, R>(
84 items: T[],
85 worker: (item: T, index: number) => Promise<R>
86 ): Promise<R[]> => {
87 const limit = pLimit(EXPORT_PAGE_RENDER_CONCURRENCY)
88 return Promise.all(items.map((item, index) => limit(() => worker(item, index))))
89 }
90
91 const isString = (value: unknown): value is string => typeof value === 'string'
92
93 const parseSessionId = (payload: unknown): string => {
94 if (
95 payload &&
96 typeof payload === 'object' &&
97 typeof (payload as PptxExportPayload).sessionId === 'string'
98 ) {
99 return String((payload as { sessionId?: string }).sessionId).trim()
100 }
101 return typeof payload === 'string' ? payload.trim() : ''
102 }
103
104 const parseImageOnly = (payload: unknown): boolean =>
105 Boolean(
106 payload && typeof payload === 'object' && (payload as PptxExportPayload).imageOnly === true
107 )
108
109 const parseFontEmbedMode = (payload: unknown): 'auto' | 'always' | 'never' => {
110 if (!payload || typeof payload !== 'object') return 'always'
111 const value = (payload as PptxExportPayload).embedFonts
112 if (value === true || value === 'always') return 'always'
113 if (value === false || value === 'never') return 'never'
114 if (value === 'auto') return 'auto'
115 return 'always'
116 }
117
118 const parseExportPageId = (payload: unknown): string => {
119 if (!payload || typeof payload !== 'object') return ''
120 const value = (payload as PptxExportPayload).pageId
121 return typeof value === 'string' ? value.trim() : ''
122 }
123
124 const sanitizeExportBaseName = (value: string, fallback: string): string =>
125 value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 120) || fallback
126
127 const buildOutlinesMarkdown = (args: {
128 title: string
129 pages: Array<{ id: string; page_number: number; title: string }>
130 outlines: Map<string, string | null>
131 }): string => {
132 const sections = args.pages.map((page) => {
133 const pageTitle = String(page.title || `P${page.page_number}`).trim()
134 const outline = String(args.outlines.get(page.id) || '').trim()
135 return [`## P${page.page_number}. ${pageTitle}`, outline].filter(Boolean).join('\n\n')
136 })
137 return [`# ${args.title}`, ...sections].filter(Boolean).join('\n\n').trim() + '\n'
138 }
139
140 const isSameOrChildPath = async (candidatePath: string, parentPath: string): Promise<boolean> => {
141 const resolveRealPath = async (value: string): Promise<string> =>
142 fs.promises.realpath(value).catch(() => path.resolve(value))
143
144 const candidate = path.resolve(await resolveRealPath(candidatePath))
145 const parent = path.resolve(await resolveRealPath(parentPath))
146 const relative = path.relative(parent, candidate)
147
148 return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
149 }
150
151 const buildPngFileName = (pageNumber: number, title: string | undefined): string => {
152 const paddedNumber = String(pageNumber).padStart(2, '0')
153 const sanitizedTitle = sanitizeExportBaseName(String(title || '').trim(), `page-${paddedNumber}`)
154 return `${paddedNumber}-${sanitizedTitle}.png`
155 }
156
157 const collectDirectoryZipFiles = (
158 dir: string,
159 prefix: string,
160 zipFiles: Record<string, Uint8Array>
161 ): void => {
162 for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163 const fullPath = path.join(dir, entry.name)
164 const zipPath = prefix ? `${prefix}/${entry.name}` : entry.name
165 if (entry.isDirectory()) {
166 collectDirectoryZipFiles(fullPath, zipPath, zipFiles)
167 } else if (entry.isFile()) {
168 zipFiles[zipPath] = fs.readFileSync(fullPath)
169 }
170 }
171 }
172
173 const sanitizeMacBundleExecutableName = (value: string): string => {
174 const sanitized = value.replace(/[/:]/g, '').trim()
175 return sanitized || 'slides'
176 }
177
178 const sanitizeMacBundleIdentifierPart = (value: string): string => {
179 const sanitized = value
180 .toLowerCase()
181 .replace(/[^a-z0-9]+/g, '-')
182 .replace(/^-+|-+$/g, '')
183 return sanitized || 'slides'
184 }
185
186 const escapeXmlText = (value: string): string =>
187 value
188 .replace(/&/g, '&amp;')
189 .replace(/</g, '&lt;')
190 .replace(/>/g, '&gt;')
191 .replace(/"/g, '&quot;')
192 .replace(/'/g, '&apos;')
193
194 const buildMacInfoPlist = (
195 appName: string,
196 executableName: string
197 ): string => `<?xml version="1.0" encoding="UTF-8"?>
198 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
199 <plist version="1.0">
200 <dict>
201 <key>CFBundleExecutable</key>
202 <string>${escapeXmlText(executableName)}</string>
203 <key>CFBundleIdentifier</key>
204 <string>com.ohmyppt.slidepack.${sanitizeMacBundleIdentifierPart(appName)}</string>
205 <key>CFBundleName</key>
206 <string>${escapeXmlText(appName)}</string>
207 <key>CFBundlePackageType</key>
208 <string>APPL</string>
209 <key>CFBundleVersion</key>
210 <string>1</string>
211 <key>CFBundleShortVersionString</key>
212 <string>1.0</string>
213 </dict>
214 </plist>
215 `
216
217 const collectMacAppZipFiles = (
218 appRoot: string,
219 appName: string,
220 zipFiles: Record<string, Uint8Array | [Uint8Array, unknown]>,
221 currentDir = appRoot
222 ): void => {
223 for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
224 const fullPath = path.join(currentDir, entry.name)
225 const relativePath = path.relative(appRoot, fullPath).split(path.sep).join('/')
226 const zipPath = `${appName}.app/${relativePath}`
227 if (entry.isDirectory()) {
228 collectMacAppZipFiles(appRoot, appName, zipFiles, fullPath)
229 } else if (entry.isFile()) {
230 const mode = fs.statSync(fullPath).mode & 0o777
231 zipFiles[zipPath] = [fs.readFileSync(fullPath), { os: 3, attrs: (mode || 0o644) << 16 }]
232 }
233 }
234 }
235
236 const writeMacAppZip = (
237 outputPath: string,
238 appName: string,
239 viewerPath: string,
240 slidesZipData: Uint8Array
241 ): void => {
242 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ohmyppt-slide-pack-app-'))
243 try {
244 const appRoot = path.join(tempDir, `${appName}.app`)
245 const macosDir = path.join(appRoot, 'Contents', 'MacOS')
246 const resourcesDir = path.join(appRoot, 'Contents', 'Resources')
247 fs.mkdirSync(macosDir, { recursive: true })
248 fs.mkdirSync(resourcesDir, { recursive: true })
249
250 const executableName = sanitizeMacBundleExecutableName(appName)
251 const executablePath = path.join(macosDir, executableName)
252 fs.copyFileSync(viewerPath, executablePath)
253 fs.chmodSync(executablePath, 0o755)
254 fs.writeFileSync(path.join(resourcesDir, 'slides.zip'), Buffer.from(slidesZipData))
255 fs.writeFileSync(
256 path.join(appRoot, 'Contents', 'Info.plist'),
257 buildMacInfoPlist(appName, executableName),
258 'utf-8'
259 )
260
261 if (process.platform === 'darwin') {
262 try {
263 execFileSync(
264 'codesign',
265 ['--force', '--deep', '--sign', '-', '--timestamp=none', appRoot],
266 {
267 stdio: 'pipe'
268 }
269 )
270 } catch (error) {
271 log.warn('[export:slidePack] codesign failed, continuing with unsigned app bundle', {
272 appName,
273 message: error instanceof Error ? error.message : String(error)
274 })
275 }
276 }
277
278 if (process.platform === 'darwin') {
279 execFileSync('ditto', ['-c', '-k', '--keepParent', `${appName}.app`, outputPath], {
280 cwd: tempDir,
281 stdio: 'pipe'
282 })
283 return
284 }
285
286 const zipFiles: Record<string, Uint8Array | [Uint8Array, unknown]> = {}
287 collectMacAppZipFiles(appRoot, appName, zipFiles)
288 fs.writeFileSync(outputPath, Buffer.from(zipSync(zipFiles as Record<string, Uint8Array>)))
289 } finally {
290 fs.rmSync(tempDir, { recursive: true, force: true })
291 }
292 }
293
294 export function registerExportHandlers(ctx: IpcContext): void {
295 const {
296 mainWindow,
297 db,
298 resolveSessionPageFiles,
299 renderPageToPdfBuffer,
300 waitForPrintReadySignal,
301 EXPORT_PAGE_READY_TIMEOUT_MS,
302 EXPORT_CAPTURE_SETTLE_MS
303 } = ctx
304
305 ipcMain.handle('export:pdf', async (event, payload: unknown) => {
306 const sessionId = parseSessionId(payload)
307 if (!sessionId) {
308 throw new Error('sessionId 不能为空')
309 }
310
311 const { session, pages, projectDir } = await resolveSessionPageFiles(sessionId)
312 const slideSize = requireSessionSlideSize(session)
313 const sessionTitle =
314 typeof session.title === 'string' && session.title.trim().length > 0
315 ? session.title.trim()
316 : `ohmyppt-${sessionId}`
317 const sanitizedBaseName = sanitizeExportBaseName(sessionTitle, `ohmyppt-${sessionId}`)
318
319 const ownerWindow =
320 BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
321 const saveResult = await dialog.showSaveDialog(ownerWindow, {
322 title: '导出 PDF',
323 defaultPath: path.join(path.dirname(projectDir), `${sanitizedBaseName}.pdf`),
324 filters: [{ name: 'PDF', extensions: ['pdf'] }],
325 properties: ['createDirectory', 'showOverwriteConfirmation']
326 })
327
328 if (saveResult.canceled || !saveResult.filePath) {
329 return { success: false, cancelled: true }
330 }
331
332 const sendProgress = createExportProgressSender(event, sessionId, 'pdf')
333 const warnings: string[] = []
334 try {
335 let renderedCount = 0
336 sendProgress({
337 stage: 'preparing',
338 progress: 3,
339 current: 0,
340 total: pages.length
341 })
342 const mergedPdf = await PDFDocument.create()
343 const longEdgePoints = 16 * 72
344 const pdfPageWidth =
345 slideSize.width >= slideSize.height
346 ? longEdgePoints
347 : longEdgePoints * (slideSize.width / slideSize.height)
348 const pdfPageHeight =
349 slideSize.height >= slideSize.width
350 ? longEdgePoints
351 : longEdgePoints * (slideSize.height / slideSize.width)
352
353 for (let start = 0; start < pages.length; start += EXPORT_PAGE_RENDER_CONCURRENCY) {
354 const pageBatch = pages.slice(start, start + EXPORT_PAGE_RENDER_CONCURRENCY)
355 const renderedPages = await mapPageBatch(pageBatch, async (page) => {
356 log.info('[export:pdf] render page', {
357 sessionId,
358 pageId: page.pageId,
359 htmlPath: page.htmlPath
360 })
361 return renderPageToPdfBuffer({
362 page,
363 timeoutMs: EXPORT_PAGE_READY_TIMEOUT_MS,
364 slideSize
365 })
366 })
367
368 for (const rendered of renderedPages) {
369 if (rendered.warning) warnings.push(rendered.warning)
370 const embeddedImage = await mergedPdf.embedPng(rendered.pngBuffer)
371 const pageDoc = mergedPdf.addPage([pdfPageWidth, pdfPageHeight])
372 pageDoc.drawImage(embeddedImage, {
373 x: 0,
374 y: 0,
375 width: pdfPageWidth,
376 height: pdfPageHeight
377 })
378 renderedCount += 1
379 sendProgress({
380 stage: 'rendering',
381 progress: scaleExportProgress(renderedCount, pages.length, 8, 88),
382 current: renderedCount,
383 total: pages.length
384 })
385 }
386 }
387
388 sendProgress({
389 stage: 'writing',
390 progress: 94,
391 current: pages.length,
392 total: pages.length
393 })
394 const outputBytes = await mergedPdf.save()
395 await fs.promises.writeFile(saveResult.filePath, outputBytes)
396 const project = await db.getProject(sessionId)
397 if (project?.id) {
398 await db.updateProjectStatus(project.id, 'exported')
399 }
400
401 log.info('[export:pdf] completed', {
402 sessionId,
403 pageCount: pages.length,
404 filePath: saveResult.filePath,
405 warningCount: warnings.length
406 })
407 shell.showItemInFolder(saveResult.filePath)
408 return {
409 success: true,
410 cancelled: false,
411 path: saveResult.filePath,
412 pageCount: pages.length,
413 warnings
414 }
415 } catch (error) {
416 const message = error instanceof Error ? error.message : String(error)
417 log.error('[export:pdf] failed', {
418 sessionId,
419 message
420 })
421 throw error
422 }
423 })
424
425 ipcMain.handle('export:longImage', async (event, payload: unknown) => {
426 const sessionId = parseSessionId(payload)
427 if (!sessionId) {
428 throw new Error('sessionId 不能为空')
429 }
430
431 const { session, pages, projectDir } = await resolveSessionPageFiles(sessionId)
432 const slideSize = requireSessionSlideSize(session)
433 const sessionTitle =
434 typeof session.title === 'string' && session.title.trim().length > 0
435 ? session.title.trim()
436 : `ohmyppt-${sessionId}`
437 const sanitizedBaseName = sanitizeExportBaseName(sessionTitle, `ohmyppt-${sessionId}`)
438
439 const ownerWindow =
440 BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
441 const saveResult = await dialog.showSaveDialog(ownerWindow, {
442 title: '导出长图',
443 defaultPath: path.join(path.dirname(projectDir), `${sanitizedBaseName}-long.png`),
444 filters: [{ name: 'PNG', extensions: ['png'] }],
445 properties: ['createDirectory', 'showOverwriteConfirmation']
446 })
447
448 if (saveResult.canceled || !saveResult.filePath) {
449 return { success: false, cancelled: true }
450 }
451
452 const sendProgress = createExportProgressSender(event, sessionId, 'longImage')
453 const warnings: string[] = []
454 try {
455 sendProgress({
456 stage: 'preparing',
457 progress: 3,
458 current: 0,
459 total: pages.length
460 })
461
462 const pagePngBuffers: Buffer[] = []
463 let renderedCount = 0
464 for (let start = 0; start < pages.length; start += EXPORT_PAGE_RENDER_CONCURRENCY) {
465 const pageBatch = pages.slice(start, start + EXPORT_PAGE_RENDER_CONCURRENCY)
466 const renderedPages = await mapPageBatch(pageBatch, async (page) => {
467 log.info('[export:longImage] render page', {
468 sessionId,
469 pageId: page.pageId,
470 htmlPath: page.htmlPath
471 })
472 return renderPageToPdfBuffer({
473 page,
474 timeoutMs: EXPORT_PAGE_READY_TIMEOUT_MS,
475 slideSize
476 })
477 })
478
479 for (const rendered of renderedPages) {
480 if (rendered.warning) warnings.push(rendered.warning)
481 pagePngBuffers.push(rendered.pngBuffer)
482 renderedCount += 1
483 sendProgress({
484 stage: 'rendering',
485 progress: scaleExportProgress(renderedCount, pages.length, 8, 80),
486 current: renderedCount,
487 total: pages.length
488 })
489 }
490 }
491
492 sendProgress({
493 stage: 'packaging',
494 progress: 88,
495 current: pages.length,
496 total: pages.length
497 })
498 const mergedPng = stitchPngBuffersVertical(pagePngBuffers)
499
500 sendProgress({
501 stage: 'writing',
502 progress: 94,
503 current: pages.length,
504 total: pages.length
505 })
506 await fs.promises.writeFile(saveResult.filePath, mergedPng)
507 const project = await db.getProject(sessionId)
508 if (project?.id) {
509 await db.updateProjectStatus(project.id, 'exported')
510 }
511
512 log.info('[export:longImage] completed', {
513 sessionId,
514 pageCount: pages.length,
515 filePath: saveResult.filePath,
516 warningCount: warnings.length
517 })
518 shell.showItemInFolder(saveResult.filePath)
519 return {
520 success: true,
521 cancelled: false,
522 path: saveResult.filePath,
523 pageCount: pages.length,
524 warnings
525 }
526 } catch (error) {
527 const message = error instanceof Error ? error.message : String(error)
528 log.error('[export:longImage] failed', {
529 sessionId,
530 message
531 })
532 throw error
533 }
534 })
535
536 ipcMain.handle('export:png', async (event, payload: unknown) => {
537 const sessionId = parseSessionId(payload)
538 if (!sessionId) {
539 throw new Error('sessionId 不能为空')
540 }
541
542 const { session, pages, projectDir } = await resolveSessionPageFiles(sessionId)
543 const slideSize = requireSessionSlideSize(session)
544
545 const ownerWindow =
546 BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
547 const directoryResult = await dialog.showOpenDialog(ownerWindow, {
548 title: '选择 PNG 导出目录',
549 defaultPath: path.dirname(projectDir),
550 buttonLabel: '导出到此目录',
551 properties: ['openDirectory', 'createDirectory']
552 })
553
554 if (directoryResult.canceled || directoryResult.filePaths.length === 0) {
555 return { success: false, cancelled: true }
556 }
557
558 const outputParentDir = directoryResult.filePaths[0]
559 const outputDir = path.join(outputParentDir, `ohmyppt-export-image_${nanoid(8)}`)
560 const sendProgress = createExportProgressSender(event, sessionId, 'png')
561 const warnings: string[] = []
562
563 try {
564 let renderedCount = 0
565 sendProgress({
566 stage: 'preparing',
567 progress: 3,
568 current: 0,
569 total: pages.length
570 })
571 await fs.promises.mkdir(outputDir, { recursive: true })
572 for (let start = 0; start < pages.length; start += EXPORT_PAGE_RENDER_CONCURRENCY) {
573 const pageBatch = pages.slice(start, start + EXPORT_PAGE_RENDER_CONCURRENCY)
574 const batchWarnings = await mapPageBatch(pageBatch, async (page) => {
575 log.info('[export:png] render page', {
576 sessionId,
577 pageId: page.pageId,
578 htmlPath: page.htmlPath
579 })
580 const rendered = await renderPageToPdfBuffer({
581 page,
582 timeoutMs: EXPORT_PAGE_READY_TIMEOUT_MS,
583 slideSize
584 })
585 await fs.promises.writeFile(
586 path.join(outputDir, buildPngFileName(page.pageNumber, page.title)),
587 rendered.pngBuffer
588 )
589 return rendered.warning
590 })
591 warnings.push(...batchWarnings.filter(isString))
592 renderedCount += pageBatch.length
593 sendProgress({
594 stage: 'rendering',
595 progress: scaleExportProgress(renderedCount, pages.length, 8, 92),
596 current: renderedCount,
597 total: pages.length
598 })
599 }
600
601 const project = await db.getProject(sessionId)
602 if (project?.id) {
603 await db.updateProjectStatus(project.id, 'exported')
604 }
605
606 log.info('[export:png] completed', {
607 sessionId,
608 pageCount: pages.length,
609 directoryPath: outputDir,
610 warningCount: warnings.length
611 })
612 shell.openPath(outputDir).catch(() => {
613 shell.showItemInFolder(outputDir)
614 })
615 return {
616 success: true,
617 cancelled: false,
618 path: outputDir,
619 pageCount: pages.length,
620 warnings
621 }
622 } catch (error) {
623 const message = error instanceof Error ? error.message : String(error)
624 log.error('[export:png] failed', {
625 sessionId,
626 message
627 })
628 throw error
629 }
630 })
631
632 ipcMain.handle('export:pptx', async (event, payload: unknown) => {
633 const sessionId = parseSessionId(payload)
634 if (!sessionId) {
635 throw new Error('sessionId 不能为空')
636 }
637 const imageOnly = parseImageOnly(payload)
638 const fontEmbedMode = imageOnly ? 'never' : parseFontEmbedMode(payload)
639 const requestedPageId = parseExportPageId(payload)
640
641 const { session, pages: allPages, projectDir } = await resolveSessionPageFiles(sessionId)
642 const slideSize = requireSessionSlideSize(session)
643 assertPptxExportSupported(slideSize)
644 const pptxLayout = resolvePptxExportLayout(slideSize)
645 const pages = requestedPageId
646 ? allPages.filter((page) => page.id === requestedPageId)
647 : allPages
648 if (requestedPageId && pages.length === 0) {
649 throw new Error(`页面不存在:${requestedPageId}`)
650 }
651 const sessionTitle =
652 typeof session.title === 'string' && session.title.trim().length > 0
653 ? session.title.trim()
654 : `ohmyppt-${sessionId}`
655 const prefix = imageOnly ? '【Image】' : '【Edit】'
656 const singlePage = requestedPageId && pages.length === 1 ? pages[0] : null
657 const singlePageTitle = singlePage
658 ? singlePage.title.trim() || `P${String(singlePage.pageNumber).padStart(2, '0')}`
659 : ''
660 const sanitizedBaseName = sanitizeExportBaseName(
661 singlePage ? `${prefix}${singlePageTitle}` : `${prefix}${sessionTitle}`,
662 `ohmyppt-${sessionId}`
663 )
664
665 const ownerWindow =
666 BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
667 const saveResult = await dialog.showSaveDialog(ownerWindow, {
668 title: '导出 PPTX',
669 defaultPath: path.join(path.dirname(projectDir), `${sanitizedBaseName}.pptx`),
670 filters: [{ name: 'PowerPoint', extensions: ['pptx'] }],
671 properties: ['createDirectory', 'showOverwriteConfirmation']
672 })
673
674 if (saveResult.canceled || !saveResult.filePath) {
675 return { success: false, cancelled: true }
676 }
677
678 const sendProgress = createExportProgressSender(event, sessionId, 'pptx')
679 const warnings: string[] = []
680
681 try {
682 let extractedCount = 0
683 sendProgress({
684 stage: 'preparing',
685 progress: 3,
686 current: 0,
687 total: pages.length
688 })
689 const slides: HtmlToPptxSlide[] = []
690 for (let start = 0; start < pages.length; start += EXPORT_PAGE_RENDER_CONCURRENCY) {
691 const pageBatch = pages.slice(start, start + EXPORT_PAGE_RENDER_CONCURRENCY)
692 const extractedPages = await mapPageBatch(pageBatch, async (page) => {
693 const mode = imageOnly ? 'image' : 'editable'
694 log.info('[export:pptx] extract page', {
695 sessionId,
696 sessionPageId: page.id,
697 pageId: page.pageId,
698 htmlPath: page.htmlPath,
699 mode,
700 singlePage: Boolean(requestedPageId)
701 })
702 return imageOnly
703 ? captureHtmlPageToPptxImageSlide({
704 page,
705 slideSize,
706 timeoutMs: EXPORT_PAGE_READY_TIMEOUT_MS,
707 settleMs: EXPORT_CAPTURE_SETTLE_MS,
708 waitForPrintReadySignal
709 })
710 : extractHtmlPageToPptxSlide({
711 page,
712 slideSize,
713 timeoutMs: EXPORT_PAGE_READY_TIMEOUT_MS,
714 settleMs: EXPORT_CAPTURE_SETTLE_MS,
715 animationMode: 'slide-transition',
716 waitForPrintReadySignal
717 })
718 })
719 for (const extracted of extractedPages) {
720 slides.push(extracted.slide)
721 if (extracted.warning) warnings.push(extracted.warning)
722 extractedCount += 1
723 sendProgress({
724 stage: 'rendering',
725 progress: scaleExportProgress(extractedCount, pages.length, 8, 82),
726 current: extractedCount,
727 total: pages.length
728 })
729 }
730 }
731
732 if (!imageOnly) {
733 const pagesWithoutText = slides.filter((s) => s.texts.length === 0).length
734 if (pagesWithoutText > 0) {
735 warnings.push(`${pages.length} 页中有 ${pagesWithoutText} 页未提取到可编辑文本。`)
736 }
737 }
738
739 // Collect embedded fonts (editable mode only). The user-facing behavior is
740 // always "try to include fonts"; fallback is internal compatibility handling.
741 let embeddedFonts: HtmlToPptxEmbeddedFont[] = []
742 if (!imageOnly) {
743 try {
744 sendProgress({
745 stage: 'packaging',
746 progress: 88,
747 current: pages.length,
748 total: pages.length
749 })
750 embeddedFonts = await collectEmbeddedFonts(projectDir, slides, {
751 mode: fontEmbedMode,
752 maxTotalBytes: 20 * 1024 * 1024,
753 pageHtmlPaths: pages.map((page) => page.htmlPath)
754 })
755 } catch (error) {
756 log.warn('[export:pptx] font embedding collection failed, fallback to system fonts', {
757 sessionId,
758 message: error instanceof Error ? error.message : String(error)
759 })
760 warnings.push('字体嵌入失败,已自动改用 PowerPoint 本机字体导出。')
761 }
762 }
763
764 sendProgress({
765 stage: 'writing',
766 progress: 94,
767 current: pages.length,
768 total: pages.length
769 })
770 try {
771 await writeHtmlToPptx(saveResult.filePath, {
772 title: sessionTitle,
773 author: 'OhMyPPT',
774 slides,
775 slideSize: {
776 widthIn: pptxLayout.slideWidthIn,
777 heightIn: pptxLayout.slideHeightIn
778 },
779 embeddedFonts: embeddedFonts.length > 0 ? embeddedFonts : undefined
780 })
781 } catch (error) {
782 if (embeddedFonts.length === 0) throw error
783 log.warn('[export:pptx] write with embedded fonts failed, retry without fonts', {
784 sessionId,
785 message: error instanceof Error ? error.message : String(error)
786 })
787 warnings.push('字体嵌入写入失败,已自动降级为 PowerPoint 本机字体导出。')
788 embeddedFonts = []
789 await writeHtmlToPptx(saveResult.filePath, {
790 title: sessionTitle,
791 author: 'OhMyPPT',
792 slides,
793 slideSize: {
794 widthIn: pptxLayout.slideWidthIn,
795 heightIn: pptxLayout.slideHeightIn
796 }
797 })
798 }
799 const project = await db.getProject(sessionId)
800 if (project?.id) {
801 await db.updateProjectStatus(project.id, 'exported')
802 }
803
804 log.info('[export:pptx] completed', {
805 sessionId,
806 pageCount: slides.length,
807 filePath: saveResult.filePath,
808 warningCount: warnings.length,
809 imageOnly,
810 sessionPageId: requestedPageId || undefined,
811 fontEmbedMode,
812 embeddedFontCount: embeddedFonts.length
813 })
814 shell.showItemInFolder(saveResult.filePath)
815 return {
816 success: true,
817 cancelled: false,
818 path: saveResult.filePath,
819 pageCount: slides.length,
820 warnings
821 }
822 } catch (error) {
823 const message = error instanceof Error ? error.message : String(error)
824 log.error('[export:pptx] failed', {
825 sessionId,
826 message
827 })
828 throw error
829 }
830 })
831
832 ipcMain.handle('export:video', async (event, payload: unknown) => {
833 const sessionId = parseSessionId(payload)
834 if (!sessionId) {
835 throw new Error('sessionId 不能为空')
836 }
837 const requestedPageId = parseExportPageId(payload)
838 const fps = normalizeVideoExportFps(
839 payload && typeof payload === 'object' ? (payload as PptxExportPayload).fps : undefined
840 )
841 const secondsPerPage = normalizeVideoExportSecondsPerPage(
842 payload && typeof payload === 'object'
843 ? (payload as PptxExportPayload).secondsPerPage
844 : undefined
845 )
846
847 const { session, pages: allPages, projectDir } = await resolveSessionPageFiles(sessionId)
848 const slideSize = requireSessionSlideSize(session)
849 const pages = requestedPageId
850 ? allPages.filter((page) => page.id === requestedPageId)
851 : allPages
852 if (requestedPageId && pages.length === 0) {
853 throw new Error(`页面不存在:${requestedPageId}`)
854 }
855 const sessionTitle =
856 typeof session.title === 'string' && session.title.trim().length > 0
857 ? session.title.trim()
858 : `ohmyppt-${sessionId}`
859 const singlePage = requestedPageId && pages.length === 1 ? pages[0] : null
860 const singlePageTitle = singlePage
861 ? singlePage.title.trim() || `P${String(singlePage.pageNumber).padStart(2, '0')}`
862 : ''
863 const sanitizedBaseName = sanitizeExportBaseName(
864 singlePage ? `【Video】${singlePageTitle}` : `【Video】${sessionTitle}`,
865 `ohmyppt-${sessionId}`
866 )
867
868 const ownerWindow =
869 BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
870 const saveResult = await dialog.showSaveDialog(ownerWindow, {
871 title: '导出视频',
872 defaultPath: path.join(path.dirname(projectDir), `${sanitizedBaseName}.mp4`),
873 filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
874 properties: ['createDirectory', 'showOverwriteConfirmation']
875 })
876
877 if (saveResult.canceled || !saveResult.filePath) {
878 return { success: false, cancelled: true }
879 }
880
881 const sendProgress = createExportProgressSender(event, sessionId, 'video')
882 try {
883 sendProgress({
884 stage: 'preparing',
885 progress: 3,
886 current: 0,
887 total: pages.length
888 })
889 log.info('[export:video] starting', {
890 sessionId,
891 pageCount: pages.length,
892 filePath: saveResult.filePath,
893 fps,
894 secondsPerPage,
895 slideWidth: slideSize.width,
896 slideHeight: slideSize.height,
897 sessionPageId: requestedPageId || undefined
898 })
899 const exported = await exportHtmlPagesToVideo({
900 pages,
901 outputPath: saveResult.filePath,
902 tempRootDir: path.dirname(projectDir),
903 slideSize,
904 fps,
905 captureFps:
906 payload && typeof payload === 'object'
907 ? normalizeVideoExportFps((payload as PptxExportPayload).captureFps)
908 : undefined,
909 secondsPerPage,
910 timeoutMs: EXPORT_PAGE_READY_TIMEOUT_MS,
911 settleMs: EXPORT_CAPTURE_SETTLE_MS,
912 waitForPrintReadySignal,
913 onProgress: (progress) => {
914 sendProgress({
915 stage: progress.stage,
916 progress:
917 progress.stage === 'writing'
918 ? 94
919 : scaleExportProgress(progress.current || 0, progress.total || pages.length, 8, 86),
920 current: progress.current,
921 total: progress.total
922 })
923 }
924 })
925 const project = await db.getProject(sessionId)
926 if (project?.id) {
927 await db.updateProjectStatus(project.id, 'exported')
928 }
929
930 log.info('[export:video] completed', {
931 sessionId,
932 pageCount: exported.pageCount,
933 frameCount: exported.frameCount,
934 durationMs: exported.durationMs,
935 filePath: saveResult.filePath,
936 warningCount: exported.warnings.length
937 })
938 shell.showItemInFolder(saveResult.filePath)
939 return {
940 success: true,
941 cancelled: false,
942 path: saveResult.filePath,
943 pageCount: exported.pageCount,
944 durationMs: exported.durationMs,
945 frameCount: exported.frameCount,
946 warnings: exported.warnings
947 }
948 } catch (error) {
949 const message = error instanceof Error ? error.message : String(error)
950 log.error('[export:video] failed', {
951 sessionId,
952 message
953 })
954 throw error
955 }
956 })
957
958 ipcMain.handle('export:outlinesMarkdown', async (event, payload: unknown) => {
959 const sessionId = parseSessionId(payload)
960 if (!sessionId) {
961 throw new Error('sessionId 不能为空')
962 }
963
964 const { session, projectDir } = await resolveSessionPageFiles(sessionId)
965 const pages = await db.listSessionPages(sessionId)
966 if (pages.length === 0) {
967 throw new Error('没有可导出的大纲页面')
968 }
969 const outlines = await resolveOutlinesForPages(db, sessionId, pages)
970 const rawTitle =
971 typeof session.title === 'string' && session.title.trim().length > 0
972 ? session.title.trim()
973 : `ohmyppt-${sessionId}`
974 const baseName = sanitizeExportBaseName(`${rawTitle}-大纲`, `ohmyppt-${sessionId}-outline`)
975 const content = buildOutlinesMarkdown({
976 title: rawTitle,
977 pages,
978 outlines
979 })
980
981 const ownerWindow =
982 BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() ?? mainWindow
983 const saveResult = await dialog.showSaveDialog(ownerWindow, {
984 title: '导出大纲',
985 defaultPath: path.join(path.dirname(projectDir), `${baseName}.md`),
986 filters: [
987 { name: 'Markdown', extensions: ['md'] },
988 { name: 'Text', extensions: ['txt'] }
989 ],
990 properties: ['createDirectory', 'showOverwriteConfirmation']
991 })
992
993 if (saveResult.canceled || !saveResult.filePath) {
994 return { success: false, cancelled: true }
995 }
996
997 try {
998 await fs.promises.writeFile(saveResult.filePath, content, 'utf-8')
999 log.info('[export:outlinesMarkdown] completed', {
1000 sessionId,
1001 filePath: saveResult.filePath,
1002 byteLength: Buffer.byteLength(content, 'utf-8')
1003 })
1004 shell.showItemInFolder(saveResult.filePath)
1005 return {
1006 success: true,
1007 cancelled: false,
1008 path: saveResult.filePath,
1009 warnings: []
1010 }
1011 } catch (error) {
1012 const message = error instanceof Error ? error.message : String(error)
1013 log.error('[export:outlinesMarkdown] failed', {
1014 sessionId,
1015 message
1016 })
1017 throw error
1018 }
1019 })
1020
1021 // Export: slide-pack (standalone executable with embedded slides)
1022 ipcMain.handle('export:slidePack', async (event, payload: unknown) => {
1023 const sessionId = parseSessionId(payload)
1024 if (!sessionId) throw new Error('Missing sessionId')
1025
1026 try {
1027 const { session, projectDir } = await resolveSessionPageFiles(sessionId)
1028
1029 // Find pre-compiled viewer binary in resources
1030 const resourcesDir = is.dev
1031 ? path.join(process.cwd(), 'resources')
1032 : path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
1033
1034 const targets = [
1035 {
1036 platform: 'macos-arm64',
1037 bin: 'slide-pack-darwin-arm64',
1038 ext: '',
1039 os: 'darwin',
1040 arch: 'arm64'
1041 },
1042 {
1043 platform: 'macos-amd64',
1044 bin: 'slide-pack-darwin-amd64',
1045 ext: '',
1046 os: 'darwin',
1047 arch: 'x64'
1048 },
1049 {
1050 platform: 'windows-amd64',
1051 bin: 'slide-pack-windows-amd64.exe',
1052 ext: '.exe',
1053 os: 'win32',
1054 arch: 'x64'
1055 }
1056 ]
1057
1058 const rawTitle =
1059 typeof session.title === 'string' && session.title.trim() ? session.title.trim() : 'slides'
1060 const sessionName = sanitizeExportBaseName(rawTitle, 'slides')
1061
1062 // Let user choose save directory
1063 const ownerWindow =
1064 BrowserWindow.fromWebContents(event.sender) ??
1065 BrowserWindow.getFocusedWindow() ??
1066 mainWindow
1067 const saveResult = await dialog.showOpenDialog(ownerWindow, {
1068 title: '选择打包导出目录',
1069 defaultPath: path.dirname(projectDir),
1070 properties: ['openDirectory', 'createDirectory'],
1071 buttonLabel: '导出到此目录'
1072 })
1073 if (saveResult.canceled || !saveResult.filePaths[0]) {
1074 return { success: false, cancelled: true }
1075 }
1076
1077 const outputParentDir = saveResult.filePaths[0]
1078 if (await isSameOrChildPath(outputParentDir, projectDir)) {
1079 throw new Error('打包导出目录不能选择当前会话目录或其子目录,请选择会话目录外的位置。')
1080 }
1081
1082 const sendProgress = createExportProgressSender(event, sessionId, 'slidePack')
1083 // Create output folder
1084 const outputFolder = path.join(outputParentDir, `ohmyppt-${nanoid(8)}`)
1085 fs.mkdirSync(outputFolder, { recursive: true })
1086
1087 log.info('[export:slidePack] starting', { sessionId, projectDir, outputFolder })
1088 sendProgress({
1089 stage: 'preparing',
1090 progress: 5
1091 })
1092
1093 // ZIP all slides
1094 const zipFiles: Record<string, Uint8Array> = {}
1095 const collectFiles = (dir: string, prefix: string) => {
1096 for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1097 if (entry.name.startsWith('.')) continue
1098 const fullPath = path.join(dir, entry.name)
1099 const zipPath = prefix ? `${prefix}/${entry.name}` : entry.name
1100 if (entry.isDirectory()) {
1101 collectFiles(fullPath, zipPath)
1102 } else {
1103 zipFiles[zipPath] = fs.readFileSync(fullPath)
1104 }
1105 }
1106 }
1107 collectFiles(projectDir, '')
1108 sendProgress({
1109 stage: 'packaging',
1110 progress: 45
1111 })
1112 const zipData = zipSync(zipFiles)
1113
1114 log.info('[export:slidePack] zip created', {
1115 fileCount: Object.keys(zipFiles).length,
1116 zipSize: zipData.byteLength
1117 })
1118
1119 const generatedFiles: string[] = []
1120
1121 // macOS uses an app bundle with slides.zip in Resources; Windows keeps the trailer format.
1122 let generatedTargetCount = 0
1123 for (const t of targets) {
1124 const viewerPath = path.join(resourcesDir, t.bin)
1125 if (!fs.existsSync(viewerPath)) {
1126 log.warn('[export:slidePack] skip platform, viewer not found', { bin: t.bin })
1127 continue
1128 }
1129
1130 if (t.os === 'darwin') {
1131 const appName = `${sessionName}-${t.platform}`
1132 const zipOutputName = `${appName}.app.zip`
1133 writeMacAppZip(path.join(outputFolder, zipOutputName), appName, viewerPath, zipData)
1134 generatedFiles.push(zipOutputName)
1135 } else {
1136 const viewerData = fs.readFileSync(viewerPath)
1137 const outputName = `${sessionName}-${t.platform}${t.ext}`
1138
1139 // Trailer: uint64 LE = ZIP data length
1140 const trailer = Buffer.alloc(8)
1141 trailer.writeBigUInt64LE(BigInt(zipData.byteLength))
1142
1143 const output = Buffer.concat([viewerData, Buffer.from(zipData), trailer])
1144 const outputPath = path.join(outputFolder, outputName)
1145 fs.writeFileSync(outputPath, output)
1146 fs.chmodSync(outputPath, 0o755)
1147 generatedFiles.push(outputName)
1148 }
1149 generatedTargetCount += 1
1150 sendProgress({
1151 stage: 'packaging',
1152 progress: scaleExportProgress(generatedTargetCount, targets.length, 55, 90),
1153 current: generatedTargetCount,
1154 total: targets.length
1155 })
1156 }
1157
1158 sendProgress({
1159 stage: 'writing',
1160 progress: 95
1161 })
1162 // Write README.txt
1163 const readmeContent = `演示文稿预览包
1164 ================
1165
1166 双击对应平台的文件即可在浏览器中打开演示。
1167
1168 文件说明:
1169 *-macos-arm64.app.zip → Apple Silicon Mac (M1/M2/M3/M4)
1170 *-macos-amd64.app.zip → Intel Mac
1171 *-windows-amd64.exe → Windows 电脑
1172
1173 使用方法:
1174 macOS:先解压 .app.zip,再双击 .app 打开
1175 Windows:双击 .exe 文件打开
1176 如果提示"无法打开",请右键 → 打开 → 确认打开
1177
1178 打开后会自动启动浏览器显示演示。
1179 关闭终端窗口或按 Ctrl+C 即可停止。
1180 `
1181 fs.writeFileSync(path.join(outputFolder, 'README.txt'), readmeContent, 'utf-8')
1182
1183 if (generatedFiles.length === 0) {
1184 throw new Error('No viewer binaries found in resources/')
1185 }
1186
1187 await shell.openPath(outputFolder)
1188
1189 log.info('[export:slidePack] completed', { sessionId, outputFolder, files: generatedFiles })
1190
1191 return {
1192 success: true,
1193 path: path.join(outputFolder, generatedFiles[0]),
1194 cancelled: false,
1195 pageCount: generatedFiles.length,
1196 warnings: []
1197 }
1198 } catch (error) {
1199 const message = error instanceof Error ? error.message : String(error)
1200 log.error('[export:slidePack] failed', { sessionId, message })
1201 throw error
1202 }
1203 })
1204
1205 ipcMain.handle('export:sessionZip', async (event, payload: unknown) => {
1206 const sessionId = parseSessionId(payload)
1207 if (!sessionId) throw new Error('Missing sessionId')
1208
1209 try {
1210 const { session, projectDir } = await resolveSessionPageFiles(sessionId)
1211 const rawTitle =
1212 typeof session.title === 'string' && session.title.trim()
1213 ? session.title.trim()
1214 : `ohmyppt-${sessionId}`
1215 const sessionName = sanitizeExportBaseName(rawTitle, `ohmyppt-${sessionId}`)
1216
1217 const ownerWindow =
1218 BrowserWindow.fromWebContents(event.sender) ??
1219 BrowserWindow.getFocusedWindow() ??
1220 mainWindow
1221 const saveResult = await dialog.showSaveDialog(ownerWindow, {
1222 title: '导出 ZIP 会话文件包',
1223 defaultPath: path.join(path.dirname(projectDir), `${sessionName}-session.zip`),
1224 filters: [{ name: 'ZIP', extensions: ['zip'] }],
1225 properties: ['createDirectory', 'showOverwriteConfirmation']
1226 })
1227 if (saveResult.canceled || !saveResult.filePath) {
1228 return { success: false, cancelled: true }
1229 }
1230
1231 if (await isSameOrChildPath(saveResult.filePath, projectDir)) {
1232 throw new Error('ZIP 会话文件包不能导出到当前会话目录或其子目录,请选择会话目录外的位置。')
1233 }
1234
1235 const sendProgress = createExportProgressSender(event, sessionId, 'sessionZip')
1236 log.info('[export:sessionZip] starting', {
1237 sessionId,
1238 projectDir,
1239 filePath: saveResult.filePath
1240 })
1241 sendProgress({
1242 stage: 'preparing',
1243 progress: 5
1244 })
1245
1246 const zipRootName = `ohmyppt-session-${sessionName}`
1247 const zipFiles: Record<string, Uint8Array> = {}
1248 collectDirectoryZipFiles(projectDir, zipRootName, zipFiles)
1249 sendProgress({
1250 stage: 'packaging',
1251 progress: 55
1252 })
1253 const zipData = zipSync(zipFiles)
1254 sendProgress({
1255 stage: 'writing',
1256 progress: 94
1257 })
1258 await fs.promises.writeFile(saveResult.filePath, Buffer.from(zipData))
1259
1260 log.info('[export:sessionZip] completed', {
1261 sessionId,
1262 filePath: saveResult.filePath,
1263 fileCount: Object.keys(zipFiles).length,
1264 zipSize: zipData.byteLength
1265 })
1266 shell.showItemInFolder(saveResult.filePath)
1267
1268 return {
1269 success: true,
1270 cancelled: false,
1271 path: saveResult.filePath,
1272 pageCount: Object.keys(zipFiles).length,
1273 warnings: []
1274 }
1275 } catch (error) {
1276 const message = error instanceof Error ? error.message : String(error)
1277 log.error('[export:sessionZip] failed', { sessionId, message })
1278 throw error
1279 }
1280 })
1281 }
1282
1282 lines TYPESCRIPT