返回 oh-my-ppt
font-registry.ts
根目录 / src / main / presentation / fonts / font-registry.ts
1 /**
2 * Font registry: bundled Google Fonts (local-first) + user-uploaded fonts infrastructure.
3 * Used by buildScaffoldDocument to auto-inject font loading + CSS variables.
4 * Presentation-domain capability; legacy tools/font-registry remains a forwarding shim.
5 */
6
7 import { is } from '@electron-toolkit/utils'
8 import { app } from 'electron'
9 import fs from 'fs'
10 import path from 'path'
11
12 export type FontSource = 'google' | 'uploaded'
13 export type FontRole = 'title' | 'body'
14 export type FontScript = 'latin' | 'cjk'
15
16 export interface FontFileEntry {
17 file: string
18 weight: number
19 style: 'normal' | 'italic'
20 size?: number
21 sha256?: string
22 }
23
24 export interface FontRegistryEntry {
25 id: string
26 family: string
27 source: 'uploaded'
28 category: string
29 role: FontRole[]
30 scripts: FontScript[]
31 createdAt: number
32 updatedAt: number
33 files: FontFileEntry[]
34 }
35
36 export interface AvailableFont {
37 id: string
38 family: string
39 source: FontSource
40 category: string
41 role: FontRole[]
42 scripts: FontScript[]
43 files?: FontFileEntry[]
44 }
45
46 export type ProjectFontResource = {
47 sourcePath: string
48 targetPath: string
49 }
50
51 export type ProjectFontResources = {
52 css: string
53 assets: ProjectFontResource[]
54 }
55
56 export interface FontRegistryFile {
57 version: 1
58 fonts: FontRegistryEntry[]
59 }
60
61 export interface GoogleFontEntry {
62 id: string
63 family: string
64 /** Category for AI selection guidance */
65 category: string
66 role: FontRole[]
67 scripts: FontScript[]
68 }
69
70 /**
71 * Built-in Google Fonts catalog (local-first).
72 * Key = font family name (must match titleFont/bodyFont in design contract).
73 * Woff2 files live in resources/google-fonts/{FamilyName}/.
74 */
75 const GOOGLE_FONTS: Record<string, GoogleFontEntry> = {
76 Poppins: {
77 id: 'google:poppins',
78 family: 'Poppins',
79 category: 'sans-body',
80 role: ['body', 'title'],
81 scripts: ['latin']
82 },
83 Inter: {
84 id: 'google:inter',
85 family: 'Inter',
86 category: 'sans-body',
87 role: ['body', 'title'],
88 scripts: ['latin']
89 },
90 Montserrat: {
91 id: 'google:montserrat',
92 family: 'Montserrat',
93 category: 'sans-title',
94 role: ['title'],
95 scripts: ['latin']
96 },
97 'Space Grotesk': {
98 id: 'google:space-grotesk',
99 family: 'Space Grotesk',
100 category: 'sans-title',
101 role: ['title', 'body'],
102 scripts: ['latin']
103 },
104 'Bebas Neue': {
105 id: 'google:bebas-neue',
106 family: 'Bebas Neue',
107 category: 'display',
108 role: ['title'],
109 scripts: ['latin']
110 },
111 'Playfair Display': {
112 id: 'google:playfair-display',
113 family: 'Playfair Display',
114 category: 'serif',
115 role: ['title'],
116 scripts: ['latin']
117 },
118 Merriweather: {
119 id: 'google:merriweather',
120 family: 'Merriweather',
121 category: 'serif',
122 role: ['body', 'title'],
123 scripts: ['latin']
124 },
125 Caveat: {
126 id: 'google:caveat',
127 family: 'Caveat',
128 category: 'handwriting',
129 role: ['title'],
130 scripts: ['latin']
131 },
132 'Dancing Script': {
133 id: 'google:dancing-script',
134 family: 'Dancing Script',
135 category: 'handwriting',
136 role: ['title'],
137 scripts: ['latin']
138 },
139 'Fira Code': {
140 id: 'google:fira-code',
141 family: 'Fira Code',
142 category: 'mono',
143 role: ['body'],
144 scripts: ['latin']
145 },
146 'Noto Sans SC': {
147 id: 'google:noto-sans-sc',
148 family: 'Noto Sans SC',
149 category: 'cjk-sans',
150 role: ['body', 'title'],
151 scripts: ['cjk', 'latin']
152 },
153 'Noto Serif SC': {
154 id: 'google:noto-serif-sc',
155 family: 'Noto Serif SC',
156 category: 'cjk-serif',
157 role: ['body', 'title'],
158 scripts: ['cjk', 'latin']
159 },
160 'ZCOOL XiaoWei': {
161 id: 'google:zcool-xiaowei',
162 family: 'ZCOOL XiaoWei',
163 category: 'cjk-display',
164 role: ['title'],
165 scripts: ['cjk']
166 },
167 'Ma Shan Zheng': {
168 id: 'google:ma-shan-zheng',
169 family: 'Ma Shan Zheng',
170 category: 'cjk-display',
171 role: ['title'],
172 scripts: ['cjk']
173 }
174 }
175
176 export const AVAILABLE_GOOGLE_FONTS = GOOGLE_FONTS
177
178 const DEFAULT_REGISTRY: FontRegistryFile = { version: 1, fonts: [] }
179
180 const normalizeFamily = (value: string): string => value.replace(/\s+/g, ' ').trim()
181 export const cssEscapeString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
182
183 /** Resolve resources/ root (dev vs production). */
184 function getResourcesRoot(): string {
185 return is.dev
186 ? path.join(process.cwd(), 'resources')
187 : path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
188 }
189
190 /** Bundled Google Fonts directory: resources/google-fonts/ */
191 export function getBundledFontsRoot(): string {
192 return path.join(getResourcesRoot(), 'google-fonts')
193 }
194
195 /** Directory name for a font family: "Noto Sans SC" → "Noto_Sans_SC" */
196 const familyDirName = (family: string): string => family.replace(/ /g, '_')
197
198 export function getUserFontsRoot(): string {
199 return path.join(app.getPath('userData'), 'userFonts')
200 }
201
202 export function getUserFontRegistryPath(): string {
203 return path.join(getUserFontsRoot(), 'registry.json')
204 }
205
206 export function getUserFontFilesRoot(): string {
207 return path.join(getUserFontsRoot(), 'files')
208 }
209
210 const normalizeRoles = (value: unknown): FontRole[] => {
211 const roles = Array.isArray(value) ? value : []
212 const normalized = roles.filter((item): item is FontRole => item === 'title' || item === 'body')
213 return normalized.length > 0 ? Array.from(new Set(normalized)) : ['title', 'body']
214 }
215
216 const normalizeScripts = (value: unknown): FontScript[] => {
217 const scripts = Array.isArray(value) ? value : []
218 const normalized = scripts.filter((item): item is FontScript => item === 'latin' || item === 'cjk')
219 return normalized.length > 0 ? Array.from(new Set(normalized)) : []
220 }
221
222 const normalizeFontFile = (value: unknown): FontFileEntry | null => {
223 const record = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
224 const file = String(record.file || '').trim()
225 if (!file) return null
226 const weight = Number(record.weight)
227 return {
228 file,
229 weight: Number.isFinite(weight) ? Math.max(1, Math.floor(weight)) : 400,
230 style: record.style === 'italic' ? 'italic' : 'normal',
231 size: Number.isFinite(Number(record.size)) ? Math.max(0, Math.floor(Number(record.size))) : undefined,
232 sha256: typeof record.sha256 === 'string' ? record.sha256 : undefined
233 }
234 }
235
236 const normalizeUserFontEntry = (value: unknown): FontRegistryEntry | null => {
237 const record = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
238 const id = String(record.id || '').trim()
239 const family = normalizeFamily(String(record.family || ''))
240 const files = Array.isArray(record.files)
241 ? record.files.map(normalizeFontFile).filter((item): item is FontFileEntry => Boolean(item))
242 : []
243 if (!id || !family || files.length === 0) return null
244 const now = Math.floor(Date.now() / 1000)
245 return {
246 id,
247 family,
248 source: 'uploaded',
249 category: String(record.category || 'brand').trim() || 'brand',
250 role: normalizeRoles(record.role),
251 scripts: normalizeScripts(record.scripts),
252 createdAt: Number.isFinite(Number(record.createdAt)) ? Math.floor(Number(record.createdAt)) : now,
253 updatedAt: Number.isFinite(Number(record.updatedAt)) ? Math.floor(Number(record.updatedAt)) : now,
254 files
255 }
256 }
257
258 export async function readUserFontRegistry(): Promise<FontRegistryFile> {
259 const registryPath = getUserFontRegistryPath()
260 try {
261 const raw = await fs.promises.readFile(registryPath, 'utf-8')
262 const parsed = JSON.parse(raw) as Partial<FontRegistryFile>
263 const fonts = Array.isArray(parsed.fonts)
264 ? parsed.fonts.map(normalizeUserFontEntry).filter((item): item is FontRegistryEntry => Boolean(item))
265 : []
266 return { version: 1, fonts }
267 } catch (error) {
268 if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return DEFAULT_REGISTRY
269 throw error
270 }
271 }
272
273 export async function writeUserFontRegistry(registry: FontRegistryFile): Promise<void> {
274 const root = getUserFontsRoot()
275 await fs.promises.mkdir(root, { recursive: true })
276 const registryPath = getUserFontRegistryPath()
277 const tmpPath = `${registryPath}.tmp`
278 const payload: FontRegistryFile = {
279 version: 1,
280 fonts: registry.fonts.map((entry) => ({
281 ...entry,
282 family: normalizeFamily(entry.family),
283 role: normalizeRoles(entry.role),
284 scripts: normalizeScripts(entry.scripts),
285 files: entry.files.map((file) => ({
286 ...file,
287 style: file.style === 'italic' ? 'italic' : 'normal'
288 }))
289 }))
290 }
291 await fs.promises.writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8')
292 await fs.promises.rename(tmpPath, registryPath)
293 }
294
295 export function getGoogleFont(family: string): GoogleFontEntry | undefined {
296 return GOOGLE_FONTS[normalizeFamily(family)]
297 }
298
299 export async function getUserFont(family: string): Promise<FontRegistryEntry | undefined> {
300 const normalized = normalizeFamily(family)
301 const registry = await readUserFontRegistry()
302 return registry.fonts.find((entry) => entry.family === normalized)
303 }
304
305 export async function getAvailableFonts(): Promise<AvailableFont[]> {
306 const registry = await readUserFontRegistry()
307 return [
308 ...Object.values(GOOGLE_FONTS).map((entry): AvailableFont => ({
309 id: entry.id,
310 family: entry.family,
311 source: 'google',
312 category: entry.category,
313 role: entry.role,
314 scripts: entry.scripts
315 })),
316 ...registry.fonts.map((entry): AvailableFont => ({
317 id: entry.id,
318 family: entry.family,
319 source: 'uploaded',
320 category: entry.category,
321 role: entry.role,
322 scripts: entry.scripts,
323 files: entry.files
324 }))
325 ]
326 }
327
328 export async function assertFontFamilyAvailable(family: string, fieldName: string): Promise<void> {
329 const normalized = normalizeFamily(family)
330 if (!normalized) throw new Error(`${fieldName} 不能为空`)
331 if (GOOGLE_FONTS[normalized]) return
332 const uploaded = await getUserFont(normalized)
333 if (uploaded) return
334 throw new Error(`${fieldName} 不在可用字体列表中:${normalized}`)
335 }
336
337 export async function assertFontFamilyNameAvailableForUpload(family: string, currentFontId?: string): Promise<void> {
338 const normalized = normalizeFamily(family)
339 if (!normalized) throw new Error('字体族名称不能为空')
340 if (GOOGLE_FONTS[normalized]) throw new Error(`字体族名称与内置 Google Fonts 重名:${normalized}`)
341 const registry = await readUserFontRegistry()
342 const duplicate = registry.fonts.find(
343 (entry) => entry.family === normalized && entry.id !== currentFontId
344 )
345 if (duplicate) throw new Error(`字体族名称已存在:${normalized}`)
346 }
347
348 export async function ensureUserFontsForProject(
349 fontFamilies: string[],
350 projectDir: string
351 ): Promise<void> {
352 const uniqueFamilies = Array.from(new Set(fontFamilies.map(normalizeFamily).filter(Boolean)))
353 if (uniqueFamilies.length === 0) return
354 const registry = await readUserFontRegistry()
355 const userFonts = uniqueFamilies
356 .map((family) => registry.fonts.find((entry) => entry.family === family))
357 .filter((entry): entry is FontRegistryEntry => Boolean(entry))
358 if (userFonts.length === 0) return
359
360 for (const entry of userFonts) {
361 const sourceDir = path.join(getUserFontFilesRoot(), entry.id)
362 const targetDir = path.join(projectDir, 'assets', 'fonts', 'user-fonts', entry.id)
363 await fs.promises.mkdir(targetDir, { recursive: true })
364 for (const file of entry.files) {
365 const sourcePath = path.join(sourceDir, file.file)
366 const targetPath = path.join(targetDir, file.file)
367 await fs.promises.copyFile(sourcePath, targetPath)
368 }
369 }
370 }
371
372 /**
373 * Copy bundled Google Fonts woff2 files into the project assets directory.
374 * Returns the list of relative woff2 file paths copied.
375 */
376 async function ensureGoogleFontsForProject(
377 fontFamilies: string[],
378 projectDir: string
379 ): Promise<void> {
380 const uniqueFamilies = Array.from(new Set(fontFamilies.map(normalizeFamily).filter(Boolean)))
381 if (uniqueFamilies.length === 0) return
382
383 const bundledRoot = getBundledFontsRoot()
384 for (const family of uniqueFamilies) {
385 const google = GOOGLE_FONTS[family]
386 if (!google) continue
387 const sourceDir = path.join(bundledRoot, familyDirName(family))
388 const targetDir = path.join(projectDir, 'assets', 'fonts', 'google-fonts', familyDirName(family))
389 // Read woff2 files from bundled resources
390 let woff2Files: string[]
391 try {
392 woff2Files = (await fs.promises.readdir(sourceDir)).filter((f) => f.endsWith('.woff2'))
393 } catch {
394 throw new Error(`内置字体文件缺失:${family}(期望目录:${sourceDir})`)
395 }
396 if (woff2Files.length === 0) {
397 throw new Error(`内置字体目录为空:${family}`)
398 }
399 await fs.promises.mkdir(targetDir, { recursive: true })
400 for (const file of woff2Files) {
401 const src = path.join(sourceDir, file)
402 const dst = path.join(targetDir, file)
403 try {
404 await fs.promises.copyFile(src, dst)
405 } catch (err) {
406 // Skip if already exists (e.g. race condition on parallel builds)
407 if ((err as NodeJS.ErrnoException)?.code !== 'EEXIST') throw err
408 }
409 }
410 }
411 }
412
413 /**
414 * Build @font-face CSS text from a bundled Google Font's faces.css,
415 * rewriting ./ paths to the project-relative assets path.
416 */
417 async function buildGoogleFontFaceTags(family: string, _projectDir: string): Promise<string[]> {
418 const bundledRoot = getBundledFontsRoot()
419 const sourceDir = path.join(bundledRoot, familyDirName(family))
420 const facesCssPath = path.join(sourceDir, 'faces.css')
421 const cssRaw = await fs.promises.readFile(facesCssPath, 'utf-8')
422 const relPrefix = `./assets/fonts/google-fonts/${familyDirName(family)}/`
423
424 // Parse each @font-face block, rewrite url("./...") to project-relative path
425 const blocks = cssRaw.split(/@font-face\s*\{/).slice(1)
426 return blocks.map((block) => {
427 const body = block.slice(0, block.indexOf('}')).trim()
428 // Replace url("./...") with the project-relative path
429 const rewritten = body.replace(
430 /url\(\s*"\.\/([^"]+)"\s*\)/g,
431 (_, fileName) => `url("${relPrefix}${fileName}")`
432 )
433 return `<style data-ppt-fonts="google">@font-face{${rewritten}}</style>`
434 })
435 }
436
437 export async function resolveProjectFontResources(
438 fontFamilies: string[],
439 projectDir: string
440 ): Promise<ProjectFontResources> {
441 const families = Array.from(new Set(fontFamilies.map(normalizeFamily).filter(Boolean)))
442 const userRegistry = await readUserFontRegistry()
443 const css: string[] = []
444 const assets: ProjectFontResource[] = []
445
446 for (const family of families) {
447 await assertFontFamilyAvailable(family, '母版字体')
448 const google = GOOGLE_FONTS[family]
449 if (google) {
450 const sourceDir = path.join(getBundledFontsRoot(), familyDirName(family))
451 const targetDir = path.join(
452 projectDir,
453 'assets',
454 'fonts',
455 'google-fonts',
456 familyDirName(family)
457 )
458 const files = (await fs.promises.readdir(sourceDir)).filter((file) => file.endsWith('.woff2'))
459 if (files.length === 0) throw new Error(`内置字体目录为空:${family}`)
460 assets.push(
461 ...files.map((file) => ({ sourcePath: path.join(sourceDir, file), targetPath: path.join(targetDir, file) }))
462 )
463 const facesCssPath = path.join(sourceDir, 'faces.css')
464 const facesCss = await fs.promises.readFile(facesCssPath, 'utf-8')
465 css.push(
466 ...facesCss
467 .split(/@font-face\s*\{/)
468 .slice(1)
469 .map((block) => {
470 const body = block.slice(0, block.indexOf('}')).trim()
471 const relPrefix = `./assets/fonts/google-fonts/${familyDirName(family)}/`
472 const rewritten = body.replace(
473 /url\(\s*"\.\/([^"]+)"\s*\)/g,
474 (_, fileName) => `url("${relPrefix}${fileName}")`
475 )
476 return `@font-face{${rewritten}}`
477 })
478 )
479 continue
480 }
481
482 const uploaded = userRegistry.fonts.find((entry) => entry.family === family)
483 if (!uploaded) throw new Error(`字体不在可用字体列表中:${family}`)
484 for (const file of uploaded.files) {
485 assets.push({
486 sourcePath: path.join(getUserFontFilesRoot(), uploaded.id, file.file),
487 targetPath: path.join(projectDir, 'assets', 'fonts', 'user-fonts', uploaded.id, file.file)
488 })
489 css.push(
490 `@font-face{font-family:"${cssEscapeString(uploaded.family)}";src:url("./assets/fonts/user-fonts/${uploaded.id}/${cssEscapeString(file.file)}") format("woff2");font-weight:${file.weight};font-style:${file.style};font-display:swap}`
491 )
492 }
493 }
494
495 return { css: css.join('\n'), assets }
496 }
497
498 export async function copyProjectFontResources(resources: ProjectFontResources): Promise<void> {
499 for (const asset of resources.assets) {
500 await fs.promises.mkdir(path.dirname(asset.targetPath), { recursive: true })
501 await fs.promises.copyFile(asset.sourcePath, asset.targetPath)
502 }
503 }
504
505 /**
506 * Build system-owned font loading tags and CSS variables from design contract font names.
507 * Throws for unknown fonts; the new font design intentionally does not silently fall back.
508 */
509 export async function buildFontHeadTags(args: {
510 titleFont: string
511 bodyFont: string
512 projectDir: string
513 }): Promise<string> {
514 const titleFont = normalizeFamily(args.titleFont)
515 const bodyFont = normalizeFamily(args.bodyFont)
516 await assertFontFamilyAvailable(titleFont, 'titleFont')
517 await assertFontFamilyAvailable(bodyFont, 'bodyFont')
518
519 // Copy font files to project assets
520 await ensureGoogleFontsForProject([titleFont, bodyFont], args.projectDir)
521 await ensureUserFontsForProject([titleFont, bodyFont], args.projectDir)
522
523 const userRegistry = await readUserFontRegistry()
524 const families = Array.from(new Set([titleFont, bodyFont]))
525 const tags: string[] = []
526
527 for (const family of families) {
528 const google = GOOGLE_FONTS[family]
529 if (google) {
530 const faceTags = await buildGoogleFontFaceTags(family, args.projectDir)
531 tags.push(...faceTags)
532 continue
533 }
534 const uploaded = userRegistry.fonts.find((entry) => entry.family === family)
535 if (!uploaded) throw new Error(`字体不在可用字体列表中:${family}`)
536 for (const file of uploaded.files) {
537 const fontUrl = `./assets/fonts/user-fonts/${uploaded.id}/${file.file}`
538 tags.push(
539 `<style data-ppt-fonts="user">@font-face{font-family:"${cssEscapeString(uploaded.family)}";src:url("${cssEscapeString(fontUrl)}") format("woff2");font-weight:${file.weight};font-style:${file.style};font-display:swap}</style>`
540 )
541 }
542 }
543
544 tags.push(
545 `<style data-ppt-fonts="1">:root{--ppt-title-font:"${cssEscapeString(titleFont)}";--ppt-body-font:"${cssEscapeString(bodyFont)}"}</style>`
546 )
547 return tags.join('\n ')
548 }
549
550 /**
551 * JSON-safe array of available fonts for design contract prompt.
552 */
553 export async function buildAvailableFontsForPrompt(): Promise<AvailableFont[]> {
554 const fonts = await getAvailableFonts()
555 return fonts.map(({ id, family, source, category, role, scripts }) => ({
556 id,
557 family,
558 source,
559 category,
560 role,
561 scripts
562 }))
563 }
564
564 lines TYPESCRIPT