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