| 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, '&') |
| 189 | .replace(/</g, '<') |
| 190 | .replace(/>/g, '>') |
| 191 | .replace(/"/g, '"') |
| 192 | .replace(/'/g, ''') |
| 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 |