返回 oh-my-ppt
renderer.ts
根目录 / src / main / io / html-pptx / renderer.ts
1 import { BrowserWindow, type NativeImage } from 'electron'
2 import log from 'electron-log/main.js'
3 import fs from 'fs'
4 import path from 'path'
5 import { pathToFileURL } from 'url'
6 import {
7 buildHtmlToPptxExtractScript,
8 normalizeExtractedHtmlToPptxSlide,
9 type HtmlToPptxSlide,
10 type HtmlToPptxTextBox
11 } from '@arcsin1/html2pptx'
12 import type { SlideSizePreset } from '@shared/slide-size'
13 import {
14 FREEZE_PAGE_FOR_PPTX_SCRIPT,
15 HIDE_FOR_PPTX_BACKGROUND_SCRIPT,
16 RESTORE_PPTX_PAGE_AFTER_BACKGROUND_CAPTURE_SCRIPT,
17 RESET_SCALE_FOR_PPTX_CAPTURE_SCRIPT,
18 WAIT_FOR_PPTX_CAPTURE_FRAME_SCRIPT,
19 MARK_KATEX_BLOCKS_SCRIPT,
20 COLLECT_KATEX_BLOCK_RECTS_SCRIPT,
21 HAS_DECLARED_PPTX_ANIMATION_SCRIPT,
22 buildMarkPptxExtractedTextForBackgroundScript
23 } from './browser-scripts'
24 import {
25 isPptxStaticBackgroundShape,
26 resolvePptxCaptureRect,
27 resolvePptxExportLayout,
28 type PptxExportLayout
29 } from './static-background'
30 import { buildExtractionReportWarning } from './extraction-report'
31
32 export interface HtmlPageForPptx {
33 htmlPath: string
34 pageId: string
35 title?: string
36 }
37
38 export interface HtmlPageToPptxSlideOptions {
39 page: HtmlPageForPptx
40 slideSize: SlideSizePreset
41 timeoutMs: number
42 settleMs: number
43 animationMode?: 'static' | 'slide-transition'
44 waitForPrintReadySignal: (args: {
45 win: BrowserWindow
46 pageId: string
47 timeoutMs: number
48 }) => Promise<{ timedOut: boolean }>
49 }
50
51 export interface HtmlPageToPptxSlideResult {
52 slide: HtmlToPptxSlide
53 warning?: string
54 }
55
56 const PPTX_BACKGROUND_CAPTURE_ATTEMPTS = 3
57 const TEXT_RESIDUE_MAX_BOXES = 24
58 const TEXT_RESIDUE_GRID_COLUMNS = 18
59 const TEXT_RESIDUE_GRID_ROWS = 10
60 const TEXT_RESIDUE_COLOR_DISTANCE = 62
61 const TEXT_RESIDUE_RATIO_THRESHOLD = 0.075
62
63 const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
64
65 const parseHexColor = (value?: string): { r: number; g: number; b: number } | null => {
66 const normalized = String(value || '')
67 .trim()
68 .replace(/^#/, '')
69 if (/^[0-9a-f]{3}$/i.test(normalized)) {
70 const [r, g, b] = normalized.split('').map((char) => Number.parseInt(`${char}${char}`, 16))
71 return { r, g, b }
72 }
73 if (!/^[0-9a-f]{6}$/i.test(normalized)) return null
74 return {
75 r: Number.parseInt(normalized.slice(0, 2), 16),
76 g: Number.parseInt(normalized.slice(2, 4), 16),
77 b: Number.parseInt(normalized.slice(4, 6), 16)
78 }
79 }
80
81 const colorDistance = (
82 left: { r: number; g: number; b: number },
83 right: { r: number; g: number; b: number }
84 ): number => {
85 const dr = left.r - right.r
86 const dg = left.g - right.g
87 const db = left.b - right.b
88 return Math.sqrt(dr * dr + dg * dg + db * db)
89 }
90
91 const pixelMatchesTextColor = (
92 bitmap: Buffer,
93 index: number,
94 target: { r: number; g: number; b: number }
95 ): boolean => {
96 const b = bitmap[index] ?? 0
97 const g = bitmap[index + 1] ?? 0
98 const r = bitmap[index + 2] ?? 0
99 return colorDistance({ r, g, b }, target) <= TEXT_RESIDUE_COLOR_DISTANCE
100 }
101
102 const hasTextResidueInCapture = (
103 image: NativeImage,
104 texts: HtmlToPptxTextBox[],
105 layout: PptxExportLayout
106 ): { suspicious: boolean; checkedBoxes: number; maxRatio: number } => {
107 if (texts.length === 0) return { suspicious: false, checkedBoxes: 0, maxRatio: 0 }
108 const size = image.getSize()
109 const bitmap = image.toBitmap()
110 if (!size.width || !size.height || bitmap.length < size.width * size.height * 4) {
111 return { suspicious: false, checkedBoxes: 0, maxRatio: 0 }
112 }
113 const pxPerInX = size.width / layout.slideWidthIn
114 const pxPerInY = size.height / layout.slideHeightIn
115 const candidates = texts
116 .filter((text) => {
117 const color = parseHexColor(text.color)
118 return Boolean(
119 color &&
120 text.text.trim().length >= 2 &&
121 text.w > 0.05 &&
122 text.h > 0.03 &&
123 (text.opacity ?? 1) > 0.05
124 )
125 })
126 .sort((a, b) => b.fontSize * b.w * b.h - a.fontSize * a.w * a.h)
127 .slice(0, TEXT_RESIDUE_MAX_BOXES)
128
129 let checkedBoxes = 0
130 let maxRatio = 0
131 for (const text of candidates) {
132 const target = parseHexColor(text.color)
133 if (!target) continue
134 const left = Math.max(0, Math.floor(text.x * pxPerInX))
135 const top = Math.max(0, Math.floor(text.y * pxPerInY))
136 const right = Math.min(size.width - 1, Math.ceil((text.x + text.w) * pxPerInX))
137 const bottom = Math.min(size.height - 1, Math.ceil((text.y + text.h) * pxPerInY))
138 const width = right - left
139 const height = bottom - top
140 if (width < 4 || height < 4) continue
141
142 checkedBoxes += 1
143 let samples = 0
144 let textLikePixels = 0
145 const columns = Math.min(TEXT_RESIDUE_GRID_COLUMNS, Math.max(3, Math.floor(width / 3)))
146 const rows = Math.min(TEXT_RESIDUE_GRID_ROWS, Math.max(3, Math.floor(height / 3)))
147 for (let row = 0; row < rows; row += 1) {
148 const y = Math.min(bottom, top + Math.floor(((row + 0.5) * height) / rows))
149 for (let column = 0; column < columns; column += 1) {
150 const x = Math.min(right, left + Math.floor(((column + 0.5) * width) / columns))
151 const index = (y * size.width + x) * 4
152 samples += 1
153 if (pixelMatchesTextColor(bitmap, index, target)) {
154 textLikePixels += 1
155 }
156 }
157 }
158 if (samples === 0) continue
159 const ratio = textLikePixels / samples
160 maxRatio = Math.max(maxRatio, ratio)
161 if (textLikePixels >= 8 && ratio >= TEXT_RESIDUE_RATIO_THRESHOLD) {
162 return { suspicious: true, checkedBoxes, maxRatio }
163 }
164 }
165
166 return { suspicious: false, checkedBoxes, maxRatio }
167 }
168
169 const capturePptxBackgroundWithRetry = async (
170 win: BrowserWindow,
171 pageId: string,
172 texts: HtmlToPptxTextBox[],
173 layout: PptxExportLayout,
174 hideScript?: string,
175 textMaskScript?: string
176 ): Promise<{ image: NativeImage; warning?: string; hasTextResidue: boolean }> => {
177 let lastImage: NativeImage | null = null
178 let lastCheck: ReturnType<typeof hasTextResidueInCapture> | null = null
179 const script = hideScript || HIDE_FOR_PPTX_BACKGROUND_SCRIPT
180
181 for (let attempt = 1; attempt <= PPTX_BACKGROUND_CAPTURE_ATTEMPTS; attempt += 1) {
182 if (textMaskScript) {
183 await win.webContents.executeJavaScript(textMaskScript, true)
184 }
185 await win.webContents.executeJavaScript(script, true)
186 await win.webContents.executeJavaScript(WAIT_FOR_PPTX_CAPTURE_FRAME_SCRIPT, true)
187 await sleep(process.platform === 'win32' ? 180 : 80)
188 await win.webContents.executeJavaScript(WAIT_FOR_PPTX_CAPTURE_FRAME_SCRIPT, true)
189
190 const image = await win.webContents.capturePage({
191 x: 0,
192 y: 0,
193 width: layout.captureWidthPx,
194 height: layout.captureHeightPx
195 })
196 const check = hasTextResidueInCapture(image, texts, layout)
197 lastImage = image
198 lastCheck = check
199 if (!check.suspicious) {
200 if (attempt > 1) {
201 log.info('[export:pptx] background capture recovered after retry', {
202 pageId,
203 attempt,
204 checkedBoxes: check.checkedBoxes,
205 maxRatio: Number(check.maxRatio.toFixed(3))
206 })
207 }
208 return { image, hasTextResidue: false }
209 }
210
211 log.warn('[export:pptx] background capture text residue detected', {
212 pageId,
213 attempt,
214 checkedBoxes: check.checkedBoxes,
215 maxRatio: Number(check.maxRatio.toFixed(3))
216 })
217 }
218
219 if (!lastImage) {
220 throw new Error(`PPTX background capture failed for ${pageId}`)
221 }
222 return {
223 image: lastImage,
224 hasTextResidue: true,
225 warning: `页面 ${pageId} 背景截图可能仍有文字残影,已使用最后一次截图。${
226 lastCheck ? `检测比率 ${Number(lastCheck.maxRatio.toFixed(3))}` : ''
227 }`
228 }
229 }
230
231 const createPptxBrowserWindow = (layout: PptxExportLayout): BrowserWindow => {
232 const win = new BrowserWindow({
233 show: false,
234 width: layout.captureWidthPx,
235 height: layout.captureHeightPx,
236 backgroundColor: '#ffffff',
237 webPreferences: {
238 contextIsolation: true,
239 sandbox: false,
240 nodeIntegration: false,
241 backgroundThrottling: false,
242 offscreen: false
243 }
244 })
245 win.webContents.setZoomFactor(1)
246 win.setContentSize(layout.captureWidthPx, layout.captureHeightPx)
247 return win
248 }
249
250 const loadAndFreezePptxPage = async (
251 win: BrowserWindow,
252 page: HtmlPageForPptx,
253 timeoutMs: number,
254 settleMs: number,
255 waitForPrintReadySignal: HtmlPageToPptxSlideOptions['waitForPrintReadySignal']
256 ): Promise<{ timedOut: boolean }> => {
257 const pageUrl = new URL(pathToFileURL(page.htmlPath).toString())
258 pageUrl.searchParams.set('fit', 'off')
259 pageUrl.searchParams.set('print', '1')
260 pageUrl.searchParams.set('export', '1')
261 pageUrl.searchParams.set('pageId', page.pageId)
262 pageUrl.searchParams.set('printTimeoutMs', String(timeoutMs))
263 pageUrl.searchParams.set(
264 '_pptMasterExpected',
265 fs.existsSync(path.join(path.dirname(page.htmlPath), 'master', 'master.css')) ? '1' : '0'
266 )
267 pageUrl.searchParams.set(
268 '_pptMasterElementsExpected',
269 fs.existsSync(path.join(path.dirname(page.htmlPath), 'master', 'master.html')) ? '1' : '0'
270 )
271 pageUrl.searchParams.set('_ts', String(Date.now()))
272
273 const readyWaitPromise = waitForPrintReadySignal({
274 win,
275 pageId: page.pageId,
276 timeoutMs
277 })
278
279 await win.loadURL(pageUrl.toString())
280 await win.webContents.executeJavaScript(FREEZE_PAGE_FOR_PPTX_SCRIPT, true)
281 const readyResult = await readyWaitPromise
282 if (readyResult.timedOut) {
283 log.warn('[export:pptx] print ready timeout', {
284 pageId: page.pageId,
285 htmlPath: page.htmlPath,
286 timeoutMs
287 })
288 }
289
290 await sleep(settleMs)
291 await win.webContents.executeJavaScript(FREEZE_PAGE_FOR_PPTX_SCRIPT, true)
292 await sleep(450)
293 await win.webContents.executeJavaScript(FREEZE_PAGE_FOR_PPTX_SCRIPT, true)
294 await sleep(80)
295
296 return readyResult
297 }
298
299 const captureFullPage = async (
300 win: BrowserWindow,
301 layout: PptxExportLayout
302 ): Promise<NativeImage> => {
303 await win.webContents.executeJavaScript(WAIT_FOR_PPTX_CAPTURE_FRAME_SCRIPT, true)
304 await sleep(process.platform === 'win32' ? 180 : 80)
305 await win.webContents.executeJavaScript(WAIT_FOR_PPTX_CAPTURE_FRAME_SCRIPT, true)
306 return win.webContents.capturePage({
307 x: 0,
308 y: 0,
309 width: layout.captureWidthPx,
310 height: layout.captureHeightPx
311 })
312 }
313
314 const buildRasterPptxSlide = (
315 title: string | undefined,
316 image: NativeImage,
317 layout: PptxExportLayout
318 ): HtmlToPptxSlide => ({
319 title,
320 texts: [],
321 shapes: [],
322 images: [],
323 tables: [],
324 backgroundImage: {
325 dataUri: `data:image/png;base64,${image.toPNG().toString('base64')}`,
326 mimeType: 'image/png',
327 x: 0,
328 y: 0,
329 w: layout.slideWidthIn,
330 h: layout.slideHeightIn,
331 alt: title
332 }
333 })
334
335 export const captureHtmlPageToPptxImageSlide = async ({
336 page,
337 slideSize,
338 timeoutMs,
339 settleMs,
340 waitForPrintReadySignal
341 }: HtmlPageToPptxSlideOptions): Promise<HtmlPageToPptxSlideResult> => {
342 const layout = resolvePptxExportLayout(slideSize)
343 const win = createPptxBrowserWindow(layout)
344
345 try {
346 const readyResult = await loadAndFreezePptxPage(
347 win,
348 page,
349 timeoutMs,
350 settleMs,
351 waitForPrintReadySignal
352 )
353
354 // Reset page fit scale for full-resolution capture
355 await win.webContents.executeJavaScript(RESET_SCALE_FOR_PPTX_CAPTURE_SCRIPT, true)
356
357 const slide = buildRasterPptxSlide(page.title, await captureFullPage(win, layout), layout)
358
359 return {
360 slide,
361 warning: readyResult.timedOut
362 ? `页面 ${page.pageId} 未收到打印就绪信号,已按当前状态导出`
363 : undefined
364 }
365 } finally {
366 if (!win.isDestroyed()) {
367 win.destroy()
368 }
369 }
370 }
371
372 export const extractHtmlPageToPptxSlide = async ({
373 page,
374 slideSize,
375 timeoutMs,
376 settleMs,
377 animationMode = 'slide-transition',
378 waitForPrintReadySignal
379 }: HtmlPageToPptxSlideOptions): Promise<HtmlPageToPptxSlideResult> => {
380 const layout = resolvePptxExportLayout(slideSize)
381 const win = createPptxBrowserWindow(layout)
382
383 try {
384 const readyResult = await loadAndFreezePptxPage(
385 win,
386 page,
387 timeoutMs,
388 settleMs,
389 waitForPrintReadySignal
390 )
391
392 // Mark formula blocks before extraction so text extraction can skip them
393 await win.webContents.executeJavaScript(MARK_KATEX_BLOCKS_SCRIPT, true)
394
395 const extracted = await win.webContents.executeJavaScript(
396 buildHtmlToPptxExtractScript({
397 pageWidthPx: layout.captureWidthPx,
398 pageHeightPx: layout.captureHeightPx,
399 slideWidthIn: layout.slideWidthIn,
400 slideHeightIn: layout.slideHeightIn,
401 maxTextBoxes: 360,
402 maxShapes: 400,
403 maxImages: 80,
404 unsupportedTransformStrategy: 'raster-fallback'
405 }),
406 true
407 )
408
409 const slide = normalizeExtractedHtmlToPptxSlide(extracted, page.title, {
410 widthIn: layout.slideWidthIn,
411 heightIn: layout.slideHeightIn
412 })
413
414 // Keep large edge-anchored fills in the screenshot base. They are visual
415 // structure, not useful editable objects, and their size causes overlap-
416 // based animation matching to capture unrelated animated text.
417 if (slide.shapes?.length) {
418 slide.shapes = slide.shapes.filter((shape) => !isPptxStaticBackgroundShape(shape, layout))
419 }
420
421 if (animationMode === 'slide-transition') {
422 const hasDeclaredAnimation = await win.webContents.executeJavaScript(
423 HAS_DECLARED_PPTX_ANIMATION_SCRIPT,
424 true
425 )
426 if (hasDeclaredAnimation) {
427 slide.transitionType = 'fade'
428 slide.transitionDurationMs = 350
429 }
430 }
431
432 // Reset page fit scale BEFORE background capture for full resolution,
433 // but AFTER extraction (which used the scaled coordinates for correct positions).
434 await win.webContents.executeJavaScript(RESET_SCALE_FOR_PPTX_CAPTURE_SCRIPT, true)
435
436 // Capture formula blocks as overlay images (whole blocks containing katex)
437 const blockRects: Array<{ x: number; y: number; w: number; h: number }> =
438 await win.webContents.executeJavaScript(COLLECT_KATEX_BLOCK_RECTS_SCRIPT, true)
439 for (const rect of blockRects) {
440 const captureRect = resolvePptxCaptureRect(rect, layout, 2)
441 if (!captureRect) continue
442 const img = await win.webContents.capturePage(captureRect)
443 const png = img.toPNG()
444 const dataUri = `data:image/png;base64,${png.toString('base64')}`
445 if (!slide.overlayImages) slide.overlayImages = []
446 slide.overlayImages.push({
447 dataUri,
448 mimeType: 'image/png',
449 x: (captureRect.x / layout.captureWidthPx) * layout.slideWidthIn,
450 y: (captureRect.y / layout.captureHeightPx) * layout.slideHeightIn,
451 w: (captureRect.width / layout.captureWidthPx) * layout.slideWidthIn,
452 h: (captureRect.height / layout.captureHeightPx) * layout.slideHeightIn,
453 alt: 'formula'
454 })
455 }
456
457 // Background capture: keep decorative elements (blur blobs, glass-morphism) visible,
458 // hide text and non-decorative shapes/images (which are extracted separately).
459 const backgroundCapture = await capturePptxBackgroundWithRetry(
460 win,
461 page.pageId,
462 slide.texts,
463 layout,
464 HIDE_FOR_PPTX_BACKGROUND_SCRIPT,
465 buildMarkPptxExtractedTextForBackgroundScript(slide.texts, {
466 widthIn: layout.slideWidthIn,
467 heightIn: layout.slideHeightIn
468 })
469 )
470 if (backgroundCapture.hasTextResidue) {
471 await win.webContents.executeJavaScript(RESTORE_PPTX_PAGE_AFTER_BACKGROUND_CAPTURE_SCRIPT, true)
472 const rasterSlide = buildRasterPptxSlide(page.title, await captureFullPage(win, layout), layout)
473 return {
474 slide: rasterSlide,
475 warning: `页面 ${page.pageId} 的背景截图仍有文字残影,已降级为整页图片以避免文字重影`
476 }
477 }
478 const backgroundPng = backgroundCapture.image.toPNG()
479 slide.backgroundImage = {
480 dataUri: `data:image/png;base64,${backgroundPng.toString('base64')}`,
481 mimeType: 'image/png',
482 x: 0,
483 y: 0,
484 w: layout.slideWidthIn,
485 h: layout.slideHeightIn,
486 alt: page.title
487 }
488
489 return {
490 slide,
491 warning: [
492 readyResult.timedOut
493 ? `页面 ${page.pageId} 未收到打印就绪信号,已按当前状态导出`
494 : '',
495 backgroundCapture.warning || '',
496 buildExtractionReportWarning(page.pageId, slide.extractionReport) || ''
497 ]
498 .filter(Boolean)
499 .join(';')
500 }
501 } finally {
502 if (!win.isDestroyed()) {
503 win.destroy()
504 }
505 }
506 }
507
507 lines TYPESCRIPT