返回 oh-my-ppt
text-validator.ts
根目录 / src / main / io / pptx-import / text-validator.ts
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
176 lines TYPESCRIPT