返回 oh-my-ppt
handlers.ts
根目录 / src / main / presentation / fonts / handlers.ts
1 import { BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions } from 'electron'
2 import crypto from 'crypto'
3 import fs from 'fs'
4 import path from 'path'
5 import { nanoid } from 'nanoid'
6 import { localAssetUrl } from '@shared/local-asset'
7 import {
8 AVAILABLE_GOOGLE_FONTS,
9 assertFontFamilyNameAvailableForUpload,
10 cssEscapeString,
11 getBundledFontsRoot,
12 getUserFontFilesRoot,
13 getUserFontsRoot,
14 readUserFontRegistry,
15 writeUserFontRegistry,
16 type FontRole,
17 type FontScript,
18 type FontRegistryEntry
19 } from './font-registry'
20
21 const MAX_FONT_FILE_SIZE_BYTES = 20 * 1024 * 1024
22 const SUPPORTED_FONT_EXTENSIONS = new Set(['.woff2'])
23
24 const nowSeconds = (): number => Math.floor(Date.now() / 1000)
25
26 const sanitizeFileName = (value: string): string => {
27 const base = path.basename(value).trim() || 'font.woff2'
28 return base.replace(/[^\w.-]+/g, '-').replace(/-+/g, '-')
29 }
30
31 const normalizeRoles = (value: unknown): FontRole[] => {
32 const items = Array.isArray(value) ? value : []
33 const roles = items.filter((item): item is FontRole => item === 'title' || item === 'body')
34 return roles.length > 0 ? Array.from(new Set(roles)) : ['title', 'body']
35 }
36
37 const normalizeScripts = (value: unknown): FontScript[] => {
38 const items = Array.isArray(value) ? value : []
39 const scripts = items.filter((item): item is FontScript => item === 'latin' || item === 'cjk')
40 return Array.from(new Set(scripts))
41 }
42
43 const sha256File = async (filePath: string): Promise<string> => {
44 const hash = crypto.createHash('sha256')
45 await new Promise<void>((resolve, reject) => {
46 const stream = fs.createReadStream(filePath)
47 stream.on('data', (chunk) => hash.update(chunk))
48 stream.on('error', reject)
49 stream.on('end', () => resolve())
50 })
51 return hash.digest('hex')
52 }
53
54 const parseUploadPayload = (payload: unknown): {
55 family: string
56 category: string
57 role: FontRole[]
58 scripts: FontScript[]
59 files: Array<{ path: string; weight: number; style: 'normal' | 'italic' }>
60 } => {
61 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
62 const family = String(record.family || '').replace(/\s+/g, ' ').trim()
63 if (!family) throw new Error('字体族名称不能为空')
64 const files = Array.isArray(record.files) ? record.files : []
65 const parsedFiles = files
66 .map((item): { path: string; weight: number; style: 'normal' | 'italic' } | null => {
67 const fileRecord = item && typeof item === 'object' ? (item as Record<string, unknown>) : {}
68 const filePath = typeof fileRecord.path === 'string' ? fileRecord.path.trim() : ''
69 if (!filePath) return null
70 const weight = Number(fileRecord.weight)
71 return {
72 path: filePath,
73 weight: Number.isFinite(weight) ? Math.max(1, Math.floor(weight)) : 400,
74 style: fileRecord.style === 'italic' ? 'italic' : 'normal'
75 }
76 })
77 .filter((item): item is { path: string; weight: number; style: 'normal' | 'italic' } =>
78 Boolean(item)
79 )
80 if (parsedFiles.length === 0) throw new Error('请至少选择一个字体文件')
81 const scripts = normalizeScripts(record.scripts)
82 if (scripts.length === 0) throw new Error('请选择适用文字')
83 return {
84 family,
85 category: String(record.category || 'brand').trim() || 'brand',
86 role: normalizeRoles(record.role),
87 scripts,
88 files: parsedFiles
89 }
90 }
91
92 export function registerFontHandlers(): void {
93 ipcMain.handle('fonts:list', async () => {
94 const registry = await readUserFontRegistry()
95 return {
96 googleFonts: Object.values(AVAILABLE_GOOGLE_FONTS).map((font) => ({
97 id: font.id,
98 family: font.family,
99 source: 'google',
100 category: font.category,
101 role: font.role,
102 scripts: font.scripts
103 })),
104 userFonts: registry.fonts
105 }
106 })
107
108 ipcMain.handle('fonts:upload', async (_event, payload: unknown) => {
109 const parsed = parseUploadPayload(payload)
110 await assertFontFamilyNameAvailableForUpload(parsed.family)
111 const registry = await readUserFontRegistry()
112 const fontId = `font_${nanoid(10)}`
113 const targetDir = path.join(getUserFontFilesRoot(), fontId)
114 await fs.promises.mkdir(targetDir, { recursive: true })
115
116 const copiedFiles: FontRegistryEntry['files'] = []
117 for (const file of parsed.files) {
118 const sourcePath = path.resolve(file.path)
119 const stat = await fs.promises.stat(sourcePath)
120 if (!stat.isFile()) throw new Error(`不是有效字体文件:${path.basename(sourcePath)}`)
121 if (stat.size > MAX_FONT_FILE_SIZE_BYTES) {
122 throw new Error(`字体文件过大:${path.basename(sourcePath)},单文件上限 20MB`)
123 }
124 const ext = path.extname(sourcePath).toLowerCase()
125 if (!SUPPORTED_FONT_EXTENSIONS.has(ext)) {
126 throw new Error(`暂不支持的字体格式:${ext || 'unknown'},第一版仅支持 .woff2`)
127 }
128 const safeName = sanitizeFileName(sourcePath)
129 const targetPath = path.join(targetDir, safeName)
130 await fs.promises.copyFile(sourcePath, targetPath)
131 copiedFiles.push({
132 file: safeName,
133 weight: file.weight,
134 style: file.style,
135 size: stat.size,
136 sha256: await sha256File(targetPath)
137 })
138 }
139
140 const now = nowSeconds()
141 const font: FontRegistryEntry = {
142 id: fontId,
143 family: parsed.family,
144 source: 'uploaded',
145 category: parsed.category,
146 role: parsed.role,
147 scripts: parsed.scripts,
148 createdAt: now,
149 updatedAt: now,
150 files: copiedFiles
151 }
152 registry.fonts.push(font)
153 await writeUserFontRegistry(registry)
154 return { success: true, font }
155 })
156
157 ipcMain.handle('fonts:update', async (_event, payload: unknown) => {
158 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
159 const fontId = typeof record.id === 'string' ? record.id.trim() : ''
160 if (!fontId) throw new Error('fontId 不能为空')
161 const registry = await readUserFontRegistry()
162 const index = registry.fonts.findIndex((font) => font.id === fontId)
163 if (index < 0) throw new Error('字体不存在')
164 const current = registry.fonts[index]
165 const family =
166 typeof record.family === 'string' && record.family.trim()
167 ? record.family.replace(/\s+/g, ' ').trim()
168 : current.family
169 await assertFontFamilyNameAvailableForUpload(family, fontId)
170 const updated: FontRegistryEntry = {
171 ...current,
172 family,
173 category:
174 typeof record.category === 'string' && record.category.trim()
175 ? record.category.trim()
176 : current.category,
177 role: record.role === undefined ? current.role : normalizeRoles(record.role),
178 scripts: record.scripts === undefined ? current.scripts : normalizeScripts(record.scripts),
179 updatedAt: nowSeconds()
180 }
181 registry.fonts[index] = updated
182 await writeUserFontRegistry(registry)
183 return { success: true, font: updated }
184 })
185
186 ipcMain.handle('fonts:delete', async (_event, fontId: unknown) => {
187 const id = typeof fontId === 'string' ? fontId.trim() : ''
188 if (!id) throw new Error('fontId 不能为空')
189 const registry = await readUserFontRegistry()
190 const nextFonts = registry.fonts.filter((font) => font.id !== id)
191 if (nextFonts.length === registry.fonts.length) throw new Error('字体不存在')
192 await writeUserFontRegistry({ version: 1, fonts: nextFonts })
193 await fs.promises.rm(path.join(getUserFontFilesRoot(), id), { recursive: true, force: true })
194 return { success: true }
195 })
196
197 ipcMain.handle('fonts:revealFolder', async () => {
198 const dir = getUserFontsRoot()
199 await fs.promises.mkdir(dir, { recursive: true })
200 await shell.openPath(dir)
201 return { success: true }
202 })
203
204 ipcMain.handle('fonts:chooseFiles', async (event) => {
205 const win = BrowserWindow.fromWebContents(event.sender)
206 const options: OpenDialogOptions = {
207 title: '选择字体文件',
208 properties: ['openFile', 'multiSelections'],
209 filters: [{ name: 'Fonts', extensions: ['woff2'] }]
210 }
211 const result = win ? await dialog.showOpenDialog(win, options) : await dialog.showOpenDialog(options)
212 return { canceled: result.canceled, filePaths: result.filePaths }
213 })
214
215 ipcMain.handle('fonts:previewCss', async () => {
216 const cssBlocks: string[] = []
217 const dirName = (family: string) => family.replace(/ /g, '_')
218
219 // Google fonts: read faces.css, rewrite url("./...") to local-asset://
220 const bundledRoot = getBundledFontsRoot()
221 for (const font of Object.values(AVAILABLE_GOOGLE_FONTS)) {
222 const facesCssPath = path.join(bundledRoot, dirName(font.family), 'faces.css')
223 try {
224 const raw = await fs.promises.readFile(facesCssPath, 'utf-8')
225 const fontDir = path.join(bundledRoot, dirName(font.family))
226 // Rewrite url("./xxx.woff2") to a local-asset URL.
227 const rewritten = raw.replace(
228 /url\(\s*"\.\/([^"]+)"\s*\)/g,
229 (_, fileName) => `url("${localAssetUrl(path.join(fontDir, fileName))}")`
230 )
231 cssBlocks.push(rewritten)
232 } catch {
233 // Skip fonts whose files aren't available yet
234 }
235 }
236
237 // User-uploaded fonts: generate @font-face with local-asset:// URLs
238 const registry = await readUserFontRegistry()
239 for (const entry of registry.fonts) {
240 const fontDir = path.join(getUserFontFilesRoot(), entry.id)
241 for (const file of entry.files) {
242 const fileUrl = localAssetUrl(path.join(fontDir, file.file))
243 cssBlocks.push(
244 `@font-face{font-family:"${cssEscapeString(entry.family)}";src:url("${cssEscapeString(fileUrl)}") format("woff2");font-weight:${file.weight};font-style:${file.style};font-display:swap}`
245 )
246 }
247 }
248
249 return cssBlocks.join('\n')
250 })
251 }
252
252 lines TYPESCRIPT