| 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 |