返回 oh-my-ppt
page-export.ts
根目录 / src / main / ipc / runtime / page-export.ts
1 import { BrowserWindow } from 'electron'
2 import fs from 'fs'
3 import path from 'path'
4 import { pathToFileURL } from 'url'
5 import log from 'electron-log/main.js'
6 import type { PPTDatabase } from '../../db/database'
7 import { FREEZE_PAGE_FOR_EXPORT_SCRIPT } from '../../io/html-pptx/browser-scripts'
8 import { sleep } from '../utils'
9 import type { RuntimeLocalFiles } from './local-files'
10 import type { SessionProjectResolver } from './session-project'
11
12 export type SessionPageFile = {
13 id: string
14 pageNumber: number
15 pageId: string
16 title: string
17 htmlPath: string
18 }
19
20 export type PageExport = {
21 PRINT_READY_PREFIX: string
22 EXPORT_PAGE_READY_TIMEOUT_MS: number
23 EXPORT_CAPTURE_SETTLE_MS: number
24 resolveSessionPageFiles(sessionId: string): Promise<{
25 session: Record<string, unknown>
26 pages: SessionPageFile[]
27 projectDir: string
28 }>
29 waitForPrintReadySignal(args: {
30 win: BrowserWindow
31 pageId: string
32 timeoutMs: number
33 }): Promise<{ timedOut: boolean; reportedPageId?: string }>
34 renderPageToPdfBuffer(args: {
35 page: SessionPageFile
36 timeoutMs: number
37 slideSize: import('@shared/slide-size').SlideSizePreset
38 }): Promise<{ pngBuffer: Buffer; warning?: string }>
39 }
40
41 const PRINT_READY_PREFIX = '__PPT_PRINT_READY__'
42 const EXPORT_PAGE_READY_TIMEOUT_MS = 4000
43 const EXPORT_CAPTURE_SETTLE_MS = 120
44
45 export function createPageExport(args: {
46 db: PPTDatabase
47 localFiles: RuntimeLocalFiles
48 sessionProject: SessionProjectResolver
49 }): PageExport {
50 const { db, localFiles, sessionProject } = args
51
52 const resolveSessionPageFiles = async (
53 sessionId: string
54 ): Promise<{
55 session: Record<string, unknown>
56 pages: SessionPageFile[]
57 projectDir: string
58 }> => {
59 const session = await db.getSession(sessionId)
60 if (!session) throw new Error('Session not found')
61 const sessionRecord = session as unknown as Record<string, unknown>
62 const projectDir = await sessionProject.resolveSessionProjectDir(sessionId)
63 const sessionPages = await db.listSessionPages(sessionId)
64 if (sessionPages.length === 0) {
65 throw new Error(
66 'session_pages is empty after migration; export path requires session_pages as source of truth'
67 )
68 }
69 const pages = sessionPages.map((page) => ({
70 id: page.id,
71 pageNumber: page.page_number,
72 pageId: page.file_slug,
73 title: page.title,
74 htmlPath: sessionProject.resolveProjectPageHtmlPath(
75 projectDir,
76 page.file_slug,
77 page.html_path
78 )
79 }))
80
81 const missingPages: string[] = []
82 const safePages: SessionPageFile[] = []
83 for (const page of pages) {
84 try {
85 const safePath = await localFiles.assertPathInAllowedRoots({
86 filePath: page.htmlPath,
87 mode: 'read',
88 sessionId,
89 htmlOnly: true
90 })
91 safePages.push({ ...page, htmlPath: safePath })
92 } catch {
93 missingPages.push(page.pageId)
94 }
95 }
96 if (missingPages.length > 0) {
97 throw new Error(`页面文件缺失:${missingPages.join(', ')}`)
98 }
99 return { session: sessionRecord, pages: safePages, projectDir }
100 }
101
102 const waitForPrintReadySignal = async (args: {
103 win: BrowserWindow
104 pageId: string
105 timeoutMs: number
106 }): Promise<{ timedOut: boolean; reportedPageId?: string }> => {
107 const { win, pageId, timeoutMs } = args
108 return new Promise((resolve) => {
109 let done = false
110 let timeoutRef: NodeJS.Timeout | null = null
111 let closedListenerBound = false
112
113 const finalize = (timedOut: boolean, reportedPageId?: string): void => {
114 if (done) return
115 done = true
116 if (timeoutRef) clearTimeout(timeoutRef)
117 win.webContents.removeListener('console-message', onConsoleMessage)
118 if (closedListenerBound) win.removeListener('closed', onClosed)
119 resolve({ timedOut, reportedPageId })
120 }
121
122 const resolveConsoleMessageText = (...rawArgs: unknown[]): string => {
123 if (rawArgs.length >= 3 && typeof rawArgs[2] === 'string') return rawArgs[2]
124 const firstArg = rawArgs[0] as
125 | { message?: unknown; params?: { message?: unknown } }
126 | undefined
127 if (firstArg && typeof firstArg === 'object') {
128 if (typeof firstArg.message === 'string') return firstArg.message
129 if (firstArg.params && typeof firstArg.params.message === 'string') {
130 return firstArg.params.message
131 }
132 }
133 return ''
134 }
135
136 const extractReportedPageId = (message: string): string | null => {
137 if (typeof message !== 'string') return null
138 const prefixIndex = message.indexOf(PRINT_READY_PREFIX)
139 if (prefixIndex < 0) return null
140 const suffix = message.slice(prefixIndex + PRINT_READY_PREFIX.length)
141 const colonIndex = suffix.indexOf(':')
142 if (colonIndex < 0) return null
143 return suffix.slice(colonIndex + 1).trim() || null
144 }
145
146 const onConsoleMessage = (...rawArgs: unknown[]): void => {
147 const reported = extractReportedPageId(resolveConsoleMessageText(...rawArgs))
148 if (reported === pageId || reported === 'page-unknown') finalize(false, reported)
149 }
150 const onClosed = (): void => finalize(true)
151
152 timeoutRef = setTimeout(() => finalize(true), Math.max(500, timeoutMs))
153 win.webContents.on('console-message', onConsoleMessage as (...args: unknown[]) => void)
154 win.on('closed', onClosed)
155 closedListenerBound = true
156 })
157 }
158
159 const renderPageToPdfBuffer = async (args: {
160 page: SessionPageFile
161 timeoutMs: number
162 slideSize: import('@shared/slide-size').SlideSizePreset
163 }): Promise<{ pngBuffer: Buffer; warning?: string }> => {
164 const { page, timeoutMs, slideSize } = args
165 const captureWidth = slideSize.width
166 const captureHeight = slideSize.height
167 const win = new BrowserWindow({
168 show: false,
169 width: captureWidth,
170 height: captureHeight,
171 backgroundColor: '#ffffff',
172 webPreferences: {
173 contextIsolation: true,
174 sandbox: false,
175 nodeIntegration: false,
176 backgroundThrottling: false,
177 offscreen: false
178 }
179 })
180
181 try {
182 win.webContents.setZoomFactor(1)
183 win.setContentSize(captureWidth, captureHeight)
184 const pageUrl = new URL(pathToFileURL(page.htmlPath).toString())
185 pageUrl.searchParams.set('fit', 'off')
186 pageUrl.searchParams.set('print', '1')
187 pageUrl.searchParams.set('export', '1')
188 pageUrl.searchParams.set('pageId', page.pageId)
189 pageUrl.searchParams.set('printTimeoutMs', String(timeoutMs))
190 pageUrl.searchParams.set(
191 '_pptMasterExpected',
192 fs.existsSync(path.join(path.dirname(page.htmlPath), 'master', 'master.css')) ? '1' : '0'
193 )
194 pageUrl.searchParams.set(
195 '_pptMasterElementsExpected',
196 fs.existsSync(path.join(path.dirname(page.htmlPath), 'master', 'master.html')) ? '1' : '0'
197 )
198 pageUrl.searchParams.set('_ts', String(Date.now()))
199
200 const readyWaitPromise = waitForPrintReadySignal({ win, pageId: page.pageId, timeoutMs })
201 await win.loadURL(pageUrl.toString())
202 await win.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
203 const readyResult = await readyWaitPromise
204 if (readyResult.timedOut) {
205 log.warn('[export:pdf] print ready timeout', {
206 pageId: page.pageId,
207 htmlPath: page.htmlPath,
208 timeoutMs
209 })
210 }
211 await sleep(EXPORT_CAPTURE_SETTLE_MS)
212 await win.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
213 await sleep(450)
214 await win.webContents.executeJavaScript(FREEZE_PAGE_FOR_EXPORT_SCRIPT, true)
215 await sleep(80)
216 const pngBuffer = (await win.webContents.capturePage({
217 x: 0,
218 y: 0,
219 width: captureWidth,
220 height: captureHeight
221 })).toPNG()
222
223 return {
224 pngBuffer,
225 warning: readyResult.timedOut
226 ? `页面 ${page.pageId} 未收到打印就绪信号,已按当前状态导出`
227 : undefined
228 }
229 } finally {
230 if (!win.isDestroyed()) win.destroy()
231 }
232 }
233
234 return {
235 PRINT_READY_PREFIX,
236 EXPORT_PAGE_READY_TIMEOUT_MS,
237 EXPORT_CAPTURE_SETTLE_MS,
238 resolveSessionPageFiles,
239 waitForPrintReadySignal,
240 renderPageToPdfBuffer
241 }
242 }
243
243 lines TYPESCRIPT