| 1 | import { BrowserWindow } from 'electron' |
| 2 | import log from 'electron-log/main.js' |
| 3 | import { createRequire } from 'module' |
| 4 | import fs from 'fs' |
| 5 | import os from 'os' |
| 6 | import path from 'path' |
| 7 | import { pathToFileURL } from 'url' |
| 8 | |
| 9 | export type PptxTextMeasureInput = { |
| 10 | id: string |
| 11 | text: string |
| 12 | width: number |
| 13 | height: number |
| 14 | fontSize: number |
| 15 | lineHeight: number |
| 16 | fontFamily: string |
| 17 | fontWeight?: string |
| 18 | fontStyle?: string |
| 19 | letterSpacing?: number |
| 20 | } |
| 21 | |
| 22 | export type PptxTextMeasureResult = { |
| 23 | id: string |
| 24 | overflow: boolean |
| 25 | measuredHeight: number |
| 26 | lineCount: number |
| 27 | naturalWidth: number |
| 28 | suggestedFontSize: number |
| 29 | suggestedLineHeight: number |
| 30 | suggestedHeight: number |
| 31 | } |
| 32 | |
| 33 | const require = createRequire(import.meta.url) |
| 34 | const pretextModuleUrl = pathToFileURL(require.resolve('@chenglou/pretext')).toString() |
| 35 | |
| 36 | const clamp = (value: number, min: number, max: number): number => |
| 37 | Math.max(min, Math.min(max, value)) |
| 38 | |
| 39 | const escapeFontFamily = (value: string): string => { |
| 40 | const firstFont = String(value || 'Arial') |
| 41 | .split(',') |
| 42 | .map((item) => item.trim().replace(/^["']|["']$/g, '')) |
| 43 | .find(Boolean) |
| 44 | const font = firstFont || 'Arial' |
| 45 | return /^[a-z0-9 -]+$/i.test(font) ? font : `"${font.replace(/"/g, '\\"')}"` |
| 46 | } |
| 47 | |
| 48 | export class PptxTextValidator { |
| 49 | private win: BrowserWindow | null = null |
| 50 | private disabled = false |
| 51 | private tempDir: string | null = null |
| 52 | |
| 53 | async measure(inputs: PptxTextMeasureInput[]): Promise<PptxTextMeasureResult[]> { |
| 54 | const validInputs = inputs.filter( |
| 55 | (input) => |
| 56 | input.text.trim() && |
| 57 | Number.isFinite(input.width) && |
| 58 | Number.isFinite(input.height) && |
| 59 | input.width > 8 && |
| 60 | input.height > 8 |
| 61 | ) |
| 62 | if (this.disabled || validInputs.length === 0) return [] |
| 63 | try { |
| 64 | const win = await this.ensureWindow() |
| 65 | const payload = validInputs.map((input) => ({ |
| 66 | ...input, |
| 67 | width: clamp(input.width, 1, 3200), |
| 68 | height: clamp(input.height, 1, 3200), |
| 69 | fontSize: clamp(input.fontSize, 6, 96), |
| 70 | lineHeight: clamp(input.lineHeight || input.fontSize * 1.2, 8, 140), |
| 71 | letterSpacing: clamp(input.letterSpacing || 0, -10, 80), |
| 72 | fontFamily: escapeFontFamily(input.fontFamily), |
| 73 | fontWeight: String(input.fontWeight || '400'), |
| 74 | fontStyle: String(input.fontStyle || 'normal') |
| 75 | })) |
| 76 | const script = ` |
| 77 | (async () => { |
| 78 | const mod = await (window.__ohmypptPretextModule ||= import(${JSON.stringify(pretextModuleUrl)})); |
| 79 | const inputs = ${JSON.stringify(payload)}; |
| 80 | const measureOne = (input) => { |
| 81 | const minFontSize = Math.max(8, input.fontSize * 0.72); |
| 82 | const measureAt = (fontSize) => { |
| 83 | const lineHeight = Math.max(fontSize * 1.08, input.lineHeight * (fontSize / input.fontSize)); |
| 84 | const font = [input.fontStyle, input.fontWeight, fontSize.toFixed(2) + 'px', input.fontFamily].filter(Boolean).join(' '); |
| 85 | const prepared = mod.prepareWithSegments(input.text, font, { |
| 86 | whiteSpace: 'pre-wrap', |
| 87 | letterSpacing: input.letterSpacing |
| 88 | }); |
| 89 | const layout = mod.layout(prepared, Math.max(1, input.width), lineHeight); |
| 90 | const naturalWidth = mod.measureNaturalWidth(prepared); |
| 91 | return { fontSize, lineHeight, height: layout.height, lineCount: layout.lineCount, naturalWidth }; |
| 92 | }; |
| 93 | |
| 94 | let best = measureAt(input.fontSize); |
| 95 | const hasOverflow = (result) => result.height > input.height + 1; |
| 96 | |
| 97 | if (hasOverflow(best)) { |
| 98 | for (let fontSize = input.fontSize - 1; fontSize >= minFontSize; fontSize -= 1) { |
| 99 | const next = measureAt(fontSize); |
| 100 | best = next; |
| 101 | if (!hasOverflow(next)) break; |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | const overflow = hasOverflow(best); |
| 106 | return { |
| 107 | id: input.id, |
| 108 | overflow, |
| 109 | measuredHeight: Number(best.height.toFixed(2)), |
| 110 | lineCount: best.lineCount, |
| 111 | naturalWidth: Number(best.naturalWidth.toFixed(2)), |
| 112 | suggestedFontSize: Number(best.fontSize.toFixed(2)), |
| 113 | suggestedLineHeight: Number(best.lineHeight.toFixed(2)), |
| 114 | suggestedHeight: Number(Math.max(input.height, best.height + 4).toFixed(2)) |
| 115 | }; |
| 116 | }; |
| 117 | return inputs.map(measureOne); |
| 118 | })() |
| 119 | ` |
| 120 | const result = await win.webContents.executeJavaScript(script, true) |
| 121 | return Array.isArray(result) ? (result as PptxTextMeasureResult[]) : [] |
| 122 | } catch (error) { |
| 123 | this.disabled = true |
| 124 | log.warn('[pptx:import] pretext text validator disabled', { |
| 125 | message: error instanceof Error ? error.message : String(error) |
| 126 | }) |
| 127 | return [] |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | close(): void { |
| 132 | const win = this.win |
| 133 | this.win = null |
| 134 | if (win && !win.isDestroyed()) { |
| 135 | try { |
| 136 | win.webContents.stop() |
| 137 | } catch { |
| 138 | // ignore renderer teardown races |
| 139 | } |
| 140 | win.close() |
| 141 | setTimeout(() => { |
| 142 | if (!win.isDestroyed()) win.destroy() |
| 143 | }, 1000).unref?.() |
| 144 | } |
| 145 | if (this.tempDir) { |
| 146 | fs.promises.rm(this.tempDir, { recursive: true, force: true }).catch(() => {}) |
| 147 | this.tempDir = null |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | private async ensureWindow(): Promise<BrowserWindow> { |
| 152 | if (this.win && !this.win.isDestroyed()) return this.win |
| 153 | const win = new BrowserWindow({ |
| 154 | show: false, |
| 155 | skipTaskbar: true, |
| 156 | paintWhenInitiallyHidden: false, |
| 157 | width: 800, |
| 158 | height: 600, |
| 159 | backgroundColor: '#ffffff', |
| 160 | webPreferences: { |
| 161 | contextIsolation: true, |
| 162 | sandbox: false, |
| 163 | nodeIntegration: false, |
| 164 | backgroundThrottling: false, |
| 165 | offscreen: true |
| 166 | } |
| 167 | }) |
| 168 | this.tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ohmyppt-pretext-')) |
| 169 | const htmlPath = path.join(this.tempDir, 'index.html') |
| 170 | await fs.promises.writeFile(htmlPath, '<!doctype html><html><body></body></html>', 'utf-8') |
| 171 | await win.loadURL(pathToFileURL(htmlPath).toString()) |
| 172 | this.win = win |
| 173 | return win |
| 174 | } |
| 175 | } |
| 176 |