From a2c95a83b7121113b1863aa2fd14ecb4a6d5825f Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Fri, 4 Sep 2026 16:03:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E5=AD=90=E7=B3=BB=E7=BB=9F=EF=BC=9A=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E4=B8=AD=E9=97=B4=E4=BB=B6=E3=80=81=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20FontFace=20=E6=8C=82=E8=BD=BD=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E5=AD=97=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/panels/TextPanel.vue | 248 +++++++++++++++++++++++++++- src/fonts/useFonts.ts | 52 +++++- src/fonts/vite-plugin-fonts.ts | 226 ++++++++++++++++++++++++- 3 files changed, 505 insertions(+), 21 deletions(-) diff --git a/src/components/panels/TextPanel.vue b/src/components/panels/TextPanel.vue index ab5ceeb..098793d 100644 --- a/src/components/panels/TextPanel.vue +++ b/src/components/panels/TextPanel.vue @@ -1,21 +1,255 @@ diff --git a/src/fonts/useFonts.ts b/src/fonts/useFonts.ts index 32523f4..65e4cde 100644 --- a/src/fonts/useFonts.ts +++ b/src/fonts/useFonts.ts @@ -1,23 +1,61 @@ import { ref } from "vue"; import type { FontEntry } from "@/types"; +import { useEditor } from "@/editor/useEditor"; /** - * 【T2 待实现 — 字体子系统】 + * 【T2 — 字体子系统】前端这一半。 * - * 前端这一半:向 `/__fonts` 取列表,选中某款时向 `/__fonts/:id` 取文件, + * 向 `/__fonts` 取本机字体列表,选中某款时向 `/__fonts/:id` 取字体文件, * 用 `FontFace` API 动态挂载到 `document.fonts`,之后 Fabric 的 Textbox 就能用 * `fontFamily: entry.family` 渲染。 * - * 注意 Fabric 在字体真正 ready 之前渲染会退到 fallback 字形, - * 挂载后要 `await document.fonts.load(...)` 再 `canvas.requestRenderAll()`。 + * Fabric 在字体真正 ready 之前渲染会退到 fallback 字形(看起来像没生效), + * 所以挂载后要 `await document.fonts.load(...)`,再 `canvas.requestRenderAll()` 重绘。 */ export const fonts = ref([]); export const loadingFonts = ref(false); +const loaded = new Set(); + export async function listFonts(): Promise { - throw new Error("T2 未实现:listFonts"); + if (fonts.value.length > 0) return fonts.value; + + loadingFonts.value = true; + try { + const res = await fetch("/__fonts"); + if (!res.ok) throw new Error(`取字体列表失败:${res.status}`); + const data = (await res.json()) as { fonts: FontEntry[] }; + fonts.value = data.fonts ?? []; + return fonts.value; + } finally { + loadingFonts.value = false; + } } -export async function ensureFontLoaded(_entry: FontEntry): Promise { - throw new Error("T2 未实现:ensureFontLoaded"); +export async function ensureFontLoaded(entry: FontEntry): Promise { + // 同一款字体不重复加载。.ttc 是合集包,浏览器不能直接当 webfont,跳过。 + if (loaded.has(entry.id) || entry.needsExtraction) return; + + loadingFonts.value = true; + try { + const res = await fetch(`/__fonts/${entry.id}`); + if (!res.ok) throw new Error(`取字体文件失败:${res.status}`); + const data = await res.arrayBuffer(); + + const face = new FontFace(entry.family, data, { + style: "normal", + weight: "normal", + }); + await face.load(); + document.fonts.add(face); + // 强制浏览器把这款字体真正纳入可用集合,避免 Fabric 退到 fallback 字形。 + await document.fonts.load(`16px "${entry.family}"`); + + loaded.add(entry.id); + + // 字体 ready 后重绘画布,否则画布上的字还停在旧字形。 + useEditor().canvas.value?.requestRenderAll(); + } finally { + loadingFonts.value = false; + } } diff --git a/src/fonts/vite-plugin-fonts.ts b/src/fonts/vite-plugin-fonts.ts index e29d775..fb47bfb 100644 --- a/src/fonts/vite-plugin-fonts.ts +++ b/src/fonts/vite-plugin-fonts.ts @@ -1,28 +1,240 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { readFile, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, join } from "node:path"; +import { promisify } from "node:util"; import type { Plugin } from "vite"; +import type { FontEntry, FontListResponse } from "@/types"; /** - * 【T2 待实现 — 字体子系统】 + * 【T2 — 字体子系统】Vite 开发服务器中间件。 * - * Vite 开发服务器中间件,给前端提供本机安装的字体。两个接口: + * 给前端提供本机安装的字体。两个接口: * * GET /__fonts -> FontListResponse(见 src/types.ts) * GET /__fonts/:id -> 字体文件二进制,Content-Type 按 ext 给 * * 枚举来源是 Windows 注册表的两个键(本机实测共 651 条): - * HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts 系统字体,值是文件名,目录 C:\Windows\Fonts + * HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts 系统字体,值多为文件名,目录 C:\Windows\Fonts * HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts 用户字体,值多为绝对路径 * - * 注意 `.ttc` 是合集包(如 msyh.ttc 里有微软雅黑的多个 face),浏览器不能直接当 webfont 用, - * 需要抽出单个 face 再返回;`FontEntry.needsExtraction` 标记这种情况。 + * 读注册表用 PowerShell `Get-ItemProperty` 并让脚本把结果写成 UTF-8 JSON 临时文件—— + * 中文值名在控制台管道里会随 codepage 乱码,写文件最稳。 + * + * `.ttc` 是合集包(如 msyh.ttc 里有微软雅黑的多个 face),浏览器不能直接当 webfont 用。 + * 第一版不抽出 face,只把 `needsExtraction` 标成 true,前端据此在下拉里禁用。 * * 只在 `apply: "serve"` 下生效,构建产物里不存在这两个接口。 */ + +const execFileAsync = promisify(execFile); + +/** 浏览器可直接当 webfont 用的后缀(ttc 会被标记但不禁列表)。 */ +const FONT_EXTS = new Set(["ttf", "otf", "ttc", "woff", "woff2"]); + +const CONTENT_TYPES: Record = { + ttf: "font/ttf", + otf: "font/otf", + ttc: "font/collection", + woff: "font/woff", + woff2: "font/woff2", +}; + +/** + * 读两个注册表键,把「值名 → 文件路径」转成 JSON 写进 $env:COVER_STUDIO_FONTS_OUT 指定的 + * 临时文件。用 String.raw 保住反斜杠;结果强制包成数组(PS5.1 单元素时 ConvertTo-Json 会退化成对象)。 + */ +const REG_QUERY_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$outPath = $env:COVER_STUDIO_FONTS_OUT +if (-not $outPath) { throw 'COVER_STUDIO_FONTS_OUT is not set' } +$result = @() +foreach ($hive in @('HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts', 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts')) { + if (-not (Test-Path $hive)) { continue } + $kind = if ($hive -like 'HKLM*') { 'HKLM' } else { 'HKCU' } + $props = Get-ItemProperty -Path $hive + foreach ($p in $props.PSObject.Properties) { + if ($p.Name -like 'PS*') { continue } + $v = $p.Value + if ($v -is [System.Array]) { $v = $v[0] } + if ($null -eq $v) { continue } + $result += [PSCustomObject]@{ kind = $kind; name = $p.Name; value = [string]$v } + } +} +$json = ConvertTo-Json -InputObject @($result) -Compress -Depth 3 +[System.IO.File]::WriteAllText($outPath, $json, (New-Object System.Text.UTF8Encoding($false))) +`; + +interface RawFontEntry { + kind?: "HKLM" | "HKCU"; + name?: string; + value?: string; +} + +interface FontRecord { + entry: FontEntry; + path: string; + ext: string; +} + +/** Windows 绝对路径 / UNC 路径判定。 */ +function isAbsoluteWindowsPath(p: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("\\\\"); +} + +/** 注册表值名形如 "阿里巴巴普惠体-H (TrueType)",把末尾类型标注剥掉,剩下字族名。 */ +function familyOf(valueName: string): string { + return valueName.replace(/\s*\([^)]*\)\s*$/, "").trim(); +} + +/** 值里是裸文件名时,按来源拼回目录;绝对路径直接原样返回。 */ +function resolvePath(value: string, kind: "HKLM" | "HKCU"): string { + const v = value.trim(); + if (isAbsoluteWindowsPath(v)) return v; + if (kind === "HKCU") { + const base = process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, "Microsoft", "Windows", "Fonts") + : "C:\\Windows\\Fonts"; + return join(base, v); + } + return join("C:\\Windows\\Fonts", v); +} + +/** 幂等 id:路径的 sha1,base64url。稳定,前端据此取文件。 */ +function makeId(path: string): string { + return createHash("sha1").update(path).digest("base64url"); +} + +async function enumerateFonts(): Promise { + const tmpPath = join(tmpdir(), `cover-studio-fonts-${process.pid}-${Date.now()}.json`); + try { + await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + REG_QUERY_SCRIPT, + ], + { + env: { ...process.env, COVER_STUDIO_FONTS_OUT: tmpPath }, + timeout: 20000, + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + }, + ); + + const raw = JSON.parse(await readFile(tmpPath, "utf8")) as unknown; + const list: RawFontEntry[] = Array.isArray(raw) ? raw : [raw]; + const seen = new Set(); + const records: FontRecord[] = []; + + for (const item of list) { + const name = (item.name ?? "").trim(); + // @ 前缀是同一文件的竖排变体,跳过避免下拉里重复。 + if (!name || name.startsWith("@")) continue; + const value = (item.value ?? "").trim(); + if (!value) continue; + + const path = resolvePath(value, item.kind ?? "HKLM"); + const ext = extname(path).slice(1).toLowerCase(); + if (!FONT_EXTS.has(ext)) continue; + + const key = path.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + if (!existsSync(path)) continue; + + records.push({ + path, + ext, + entry: { + id: makeId(path), + family: familyOf(name), + path, + ext, + needsExtraction: ext === "ttc", + }, + }); + } + + return records; + } finally { + // 临时文件用一次就删,失败也忽略,不在仓库里散落。 + await unlink(tmpPath).catch(() => {}); + } +} + export function fontsPlugin(): Plugin { + let cache: Promise | null = null; + const byId = new Map(); + + async function getRecords(): Promise { + if (!cache) { + cache = enumerateFonts().then((records) => { + byId.clear(); + for (const r of records) byId.set(r.entry.id, r); + return records; + }); + } + return cache; + } + return { name: "cover-studio:fonts", apply: "serve", - configureServer() { - // T2 在这里挂 server.middlewares.use(...) + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = (req.url ?? "").split("?")[0]; + if (!url.startsWith("/__fonts")) { + next(); + return; + } + + if (url === "/__fonts") { + try { + const records = await getRecords(); + const body: FontListResponse = { + fonts: records.map((r) => r.entry), + }; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); + } catch (err) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); + } + return; + } + + const id = url.slice("/__fonts/".length); + if (!id) { + res.statusCode = 404; + res.end("font id missing"); + return; + } + + try { + await getRecords(); + const rec = byId.get(id); + if (!rec) { + res.statusCode = 404; + res.end("font not found"); + return; + } + const data = await readFile(rec.path); + res.setHeader("Content-Type", CONTENT_TYPES[rec.ext] ?? "application/octet-stream"); + res.setHeader("Content-Length", data.length); + res.end(data); + } catch (err) { + res.statusCode = 500; + res.end(err instanceof Error ? err.message : String(err)); + } + }); }, }; } From 8b986de5b3f36f0fd423d5b8114dfd97ae1591e4 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Fri, 4 Sep 2026 16:48:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=EF=BC=9Aname=20=E8=A1=A8=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E5=AD=97=E6=97=8F=E5=90=8D=E3=80=81=E5=85=88?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E5=90=8E=20webfont=E3=80=81=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=A7=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/panels/TextPanel.vue | 80 +++++++++++++---- src/fonts/useFonts.ts | 93 +++++++++++++++++--- src/fonts/vite-plugin-fonts.ts | 130 +++++++++++++++++++++++++++- 3 files changed, 270 insertions(+), 33 deletions(-) diff --git a/src/components/panels/TextPanel.vue b/src/components/panels/TextPanel.vue index 098793d..0de90e4 100644 --- a/src/components/panels/TextPanel.vue +++ b/src/components/panels/TextPanel.vue @@ -2,16 +2,22 @@ import { computed, ref, watch } from "vue"; import { Shadow, Textbox } from "fabric"; import { useEditor } from "@/editor/useEditor"; -import { ensureFontLoaded, fonts, listFonts, loadingFonts } from "@/fonts/useFonts"; +import { + ensureFontLoaded, + fonts, + listFonts, + loadingFonts, + styleOfSubfamily, +} from "@/fonts/useFonts"; /** * 【T2 — 文字面板】选中 Textbox 时显示。 * - * 字体下拉列出 `useFonts` 里的本机字体,选中即挂载并应用到当前文本的 `fontFamily`; - * 字号、颜色、对齐、行高、描边(stroke/strokeWidth)与阴影(shadow)。 - * 描边和阴影是刚需:现有封面全靠它们保证白字压在图上还读得清。 + * 字体下拉列出本机字体(family + subfamily),选中后把 fontFamily / fontWeight / + * fontStyle 应用到当前文本,再交给 ensureFontLoaded 走「先本机、后 webfont」; + * 加载失败要在这里给出可见提示,不能静默。 * - * 画布选中对象是 Fabric 实例,不是响应式的,所以这里用一批本地 ref 做 UI 状态: + * 画布选中对象是 Fabric 实例,不是响应式的,所以用一批本地 ref 做 UI 状态: * 选中对象变化时从对象同步进来,用户改动时写回对象并 `requestRenderAll()`。 */ const { activeObject, getCanvas } = useEditor(); @@ -33,6 +39,35 @@ const shadowBlur = ref(0); const shadowOffsetX = ref(0); const shadowOffsetY = ref(0); +// 字体选择状态。 +const selectedId = ref(""); +const fontError = ref(""); + +/** 把 Fabric 的 fontWeight("normal" / "bold" / 数字)归一成数字字符串再比对。 */ +function normWeight(w: string | number | null | undefined): string { + if (w === "normal" || w === null || w === undefined || w === "") return "400"; + if (w === "bold") return "700"; + return String(w); +} + +/** 用对象上的 fontFamily + weight/style 反查出下拉里对应的字体 id。 */ +function matchEntryId(): string { + const obj = textObj.value; + if (!obj) return ""; + const fam = obj.fontFamily; + if (!fam) return ""; + const candidates = fonts.value.filter((f) => f.family === fam); + if (candidates.length === 0) return ""; + if (candidates.length === 1) return candidates[0].id; + const w = normWeight(obj.fontWeight); + const st = obj.fontStyle ?? "normal"; + const exact = candidates.find((f) => { + const s = styleOfSubfamily(f.subfamily); + return String(s.fontWeight) === w && s.fontStyle === st; + }); + return exact?.id ?? candidates[0].id; +} + /** 选中对象变化 → 把对象上的值同步进 UI。 */ function syncFromObject() { const obj = textObj.value; @@ -49,9 +84,13 @@ function syncFromObject() { shadowBlur.value = typeof s?.blur === "number" ? s.blur : 0; shadowOffsetX.value = typeof s?.offsetX === "number" ? s.offsetX : 0; shadowOffsetY.value = typeof s?.offsetY === "number" ? s.offsetY : 0; + selectedId.value = matchEntryId(); } watch(textObj, syncFromObject, { immediate: true }); +watch(fonts, () => { + selectedId.value = matchEntryId(); +}); /** 用户改动 → 写回对象并重绘。 */ function applyToObject() { @@ -106,20 +145,22 @@ watch( applyToObject, ); -/** 当前文本的 fontFamily 对应到列表里的 id;找不到(如默认 Times New Roman)就留空。 */ -const selectedFontId = computed(() => { - const fam = textObj.value?.fontFamily; - if (!fam) return ""; - return fonts.value.find((f) => f.family === fam)?.id ?? ""; -}); - async function pickFont(id: string) { const entry = fonts.value.find((f) => f.id === id); const obj = textObj.value; if (!entry || !obj) return; - obj.set("fontFamily", entry.family); + + selectedId.value = id; + const { fontWeight, fontStyle } = styleOfSubfamily(entry.subfamily); + obj.set({ fontFamily: entry.family, fontWeight, fontStyle }); getCanvas().requestRenderAll(); - await ensureFontLoaded(entry); + + try { + await ensureFontLoaded(entry); + fontError.value = ""; + } catch { + fontError.value = "这款字体用不了:本机解析不到,浏览器也加载不了,文字暂时用默认字体。"; + } } listFonts(); @@ -129,13 +170,14 @@ listFonts();
- -

正在读取本机字体…

+

{{ fontError }}

+

正在读取本机字体…

未找到可用字体(本机需装 .ttf / .otf 字体)。

@@ -227,6 +269,10 @@ listFonts(); line-height: 1.6; } +.meta.error { + color: var(--accent); +} + .group { border: 1px solid var(--line); border-radius: var(--radius); diff --git a/src/fonts/useFonts.ts b/src/fonts/useFonts.ts index 65e4cde..6be021a 100644 --- a/src/fonts/useFonts.ts +++ b/src/fonts/useFonts.ts @@ -5,17 +5,46 @@ import { useEditor } from "@/editor/useEditor"; /** * 【T2 — 字体子系统】前端这一半。 * - * 向 `/__fonts` 取本机字体列表,选中某款时向 `/__fonts/:id` 取字体文件, - * 用 `FontFace` API 动态挂载到 `document.fonts`,之后 Fabric 的 Textbox 就能用 - * `fontFamily: entry.family` 渲染。 + * 策略是「先本机、后 webfont」: * - * Fabric 在字体真正 ready 之前渲染会退到 fallback 字形(看起来像没生效), - * 所以挂载后要 `await document.fonts.load(...)`,再 `canvas.requestRenderAll()` 重绘。 + * 1. 本机已装的字体浏览器能直接按 `fontFamily` 解析,不需要下载 webfont + * (Local Font Access 限制的是*枚举*,不是按名使用)。用 `ctx.measureText` + * 跟一个不存在的字族做基线比宽度,确认解析成功就直接用。 + * 2. 解析不到才回落到 `/__fonts/:id` + `FontFace` 把字体挂进 `document.fonts`。 + * 3. 两条都失败,抛错给上层(TextPanel)提示,不静默。 + * + * 这么做的直接好处:选本机字体是零传输(webfont 每条要 2–9 MB), + * 而且绕开 Chrome OTS 对 webfont 的校验——那一步实测会拒掉不少字体。 */ export const fonts = ref([]); export const loadingFonts = ref(false); +/** 已确认可用(本机解析或 webfont 挂载)的字体,避免重复处理。 */ const loaded = new Set(); +/** 已确认本机能按名解析的字族,缓存避免反复 measureText。 */ +const resolvedLocally = new Set(); + +export interface FontStyle { + fontWeight: number; + fontStyle: "normal" | "italic"; +} + +/** 把 name 表读出的样式名(如 Heavy / Bold / 斜体)映射成 CSS fontWeight / fontStyle。 */ +export function styleOfSubfamily(subfamily: string): FontStyle { + const s = (subfamily ?? "").trim().toLowerCase(); + const italic = /(italic|oblique|斜体|倾斜)/.test(s); + let weight = 400; + if (/thin|hairline/.test(s)) weight = 100; + else if (/extra\s?light|ultra\s?light/.test(s)) weight = 200; + else if (/(^|\s)light(\s|$)|细体|纤细/.test(s)) weight = 300; + else if (/regular|normal|roman|book|常规|正常/.test(s)) weight = 400; + else if (/(^|\s)medium(\s|$)/.test(s)) weight = 500; + else if (/semi\s?bold|demi\s?bold/.test(s)) weight = 600; + else if (/bold|粗体|加粗/.test(s)) weight = 700; + else if (/extra\s?bold|ultra\s?bold/.test(s)) weight = 800; + else if (/black|heavy|特粗|特黑/.test(s)) weight = 900; + return { fontWeight: weight, fontStyle: italic ? "italic" : "normal" }; +} export async function listFonts(): Promise { if (fonts.value.length > 0) return fonts.value; @@ -32,29 +61,65 @@ export async function listFonts(): Promise { } } -export async function ensureFontLoaded(entry: FontEntry): Promise { - // 同一款字体不重复加载。.ttc 是合集包,浏览器不能直接当 webfont,跳过。 - if (loaded.has(entry.id) || entry.needsExtraction) return; +let measureCanvas: HTMLCanvasElement | null = null; +/** 用指定的 family + weight/style 量一段中英混排的宽度。 */ +function measureWidth(family: string, fontWeight: number, fontStyle: string): number { + if (!measureCanvas) measureCanvas = document.createElement("canvas"); + const ctx = measureCanvas.getContext("2d"); + if (!ctx) return 0; + ctx.font = `${fontStyle} ${fontWeight} 48px "${family}"`; + return ctx.measureText("测试标题 Test Title 0123456789,。!?").width; +} + +/** + * 本机是否已装这个字族:拿一个不可能存在的字族做基线, + * 同一 weight/style 下量出来的宽度不同,就说明目标字族解析到了(不是退回默认字)。 + * 基线必须用相同的 weight/style,否则粗体默认字和常规默认字本身就宽度不同,会误判。 + */ +function isFamilyResolved(entry: FontEntry): boolean { + const { fontWeight, fontStyle } = styleOfSubfamily(entry.subfamily); + const base = measureWidth("__im0o_nonexistent_font__", fontWeight, fontStyle); + const target = measureWidth(entry.family, fontWeight, fontStyle); + return Math.abs(target - base) > 0.5; +} + +export async function ensureFontLoaded(entry: FontEntry): Promise { + if (entry.needsExtraction) { + throw new Error("合集字体(.ttc)暂不支持"); + } + if (loaded.has(entry.id)) return; + + // 1) 先试本机解析:装了的字体直接按名用,零传输。 + if (resolvedLocally.has(entry.family) || isFamilyResolved(entry)) { + resolvedLocally.add(entry.family); + loaded.add(entry.id); + useEditor().canvas.value?.requestRenderAll(); + return; + } + + // 2) 本机解析不到,回落到 webfont。 loadingFonts.value = true; try { const res = await fetch(`/__fonts/${entry.id}`); if (!res.ok) throw new Error(`取字体文件失败:${res.status}`); const data = await res.arrayBuffer(); + const { fontWeight, fontStyle } = styleOfSubfamily(entry.subfamily); const face = new FontFace(entry.family, data, { - style: "normal", - weight: "normal", + weight: String(fontWeight), + style: fontStyle, }); await face.load(); document.fonts.add(face); - // 强制浏览器把这款字体真正纳入可用集合,避免 Fabric 退到 fallback 字形。 - await document.fonts.load(`16px "${entry.family}"`); + // 强制浏览器把这款字体纳入可用集合,避免 Fabric 退到 fallback 字形。 + await document.fonts.load(`${fontStyle} ${fontWeight} 16px "${entry.family}"`); loaded.add(entry.id); - - // 字体 ready 后重绘画布,否则画布上的字还停在旧字形。 useEditor().canvas.value?.requestRenderAll(); + } catch (err) { + // 抛给上层(TextPanel)提示,不在这里吞掉。 + throw err instanceof Error ? err : new Error(String(err)); } finally { loadingFonts.value = false; } diff --git a/src/fonts/vite-plugin-fonts.ts b/src/fonts/vite-plugin-fonts.ts index fb47bfb..b99d8ac 100644 --- a/src/fonts/vite-plugin-fonts.ts +++ b/src/fonts/vite-plugin-fonts.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { readFile, unlink } from "node:fs/promises"; +import { open, readFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { extname, join } from "node:path"; import { promisify } from "node:util"; @@ -107,6 +107,128 @@ function makeId(path: string): string { return createHash("sha1").update(path).digest("base64url"); } +/* ---------------------------------------------------------------- name 表解析 */ + +/** + * 注册表值名不是字体的身份(阿里巴巴普惠体-H 这种后缀是安装程序起的标签)。 + * 权威来源是字体文件里的 `name` 表:nameID 16/17 是 typographic family/subfamily, + * 缺失才退回 nameID 1/2。见 src/types.ts 里 FontEntry.family / subfamily 的契约。 + * + * `.ttc` 是合集包、结构不同,且第一版在下拉里禁用,这里不解析,用注册表名兜底。 + */ + +const UTF16BE_DECODER = new TextDecoder("utf-16be"); + +interface NameRecord { + platformID: number; + languageID: number; + nameID: number; + text: string; +} + +/** platformID 0/3(Unicode / Windows)是 UTF-16BE,其余按 latin1 近似。 */ +function decodeNameString(bytes: Buffer, platformID: number): string { + let text: string; + if (platformID === 0 || platformID === 3) { + try { + text = UTF16BE_DECODER.decode(bytes); + } catch { + text = bytes.toString("latin1"); + } + } else { + text = bytes.toString("latin1"); + } + return text.replace(/\0+$/, "").trim(); +} + +/** + * 同一个 nameID 有中英(langID 2052 / 1033)等多份记录,取分数最高的一份: + * 平台 Windows > Unicode > Mac,语言 简体中文 > 英文 > 其它。 + */ +function pickName(records: NameRecord[], nameID: number): string | null { + let best: NameRecord | null = null; + let bestScore = -1; + for (const rec of records) { + if (rec.nameID !== nameID) continue; + let s = 0; + if (rec.platformID === 3) s += 100; + else if (rec.platformID === 0) s += 80; + else if (rec.platformID === 1) s += 60; + if (rec.languageID === 2052) s += 10; + else if (rec.languageID === 1033) s += 5; + if (s > bestScore) { + bestScore = s; + best = rec; + } + } + return best?.text ?? null; +} + +function parseNameTable(buf: Buffer): { family: string; subfamily: string } { + const count = buf.readUInt16BE(2); + const stringOffset = buf.readUInt16BE(4); + const records: NameRecord[] = []; + for (let i = 0; i < count; i++) { + const r = 6 + i * 12; + const platformID = buf.readUInt16BE(r); + const languageID = buf.readUInt16BE(r + 4); + const nameID = buf.readUInt16BE(r + 6); + if (nameID !== 1 && nameID !== 2 && nameID !== 16 && nameID !== 17) continue; + const length = buf.readUInt16BE(r + 8); + const offset = buf.readUInt16BE(r + 10); + const start = stringOffset + offset; + if (start + length > buf.length) continue; + const text = decodeNameString(buf.subarray(start, start + length), platformID); + if (!text) continue; + records.push({ platformID, languageID, nameID, text }); + } + const family = pickName(records, 16) ?? pickName(records, 1) ?? ""; + const subfamily = pickName(records, 17) ?? pickName(records, 2) ?? ""; + return { family, subfamily }; +} + +/** 只读字体文件头 + name 表,不把整份字体(可能 2–10 MB)全读进内存。 */ +async function readNameTable(path: string): Promise { + const fh = await open(path, "r"); + try { + const header = Buffer.alloc(12); + if ((await fh.read(header, 0, 12, 0)).bytesRead < 12) return null; + const numTables = header.readUInt16BE(4); + const dir = Buffer.alloc(numTables * 16); + if ((await fh.read(dir, 0, dir.length, 12)).bytesRead < dir.length) return null; + for (let i = 0; i < numTables; i++) { + const rec = i * 16; + if (dir.toString("ascii", rec, rec + 4) !== "name") continue; + const tableOffset = dir.readUInt32BE(rec + 8); + const tableLength = dir.readUInt32BE(rec + 12); + if (tableLength <= 0 || tableLength > 1024 * 1024) continue; + const nameBuf = Buffer.alloc(tableLength); + if ((await fh.read(nameBuf, 0, tableLength, tableOffset)).bytesRead < tableLength) return null; + return nameBuf; + } + return null; + } finally { + await fh.close(); + } +} + +/** 读出真实字族名 + 样式名;读不到就退回注册表值名(剥掉类型标注)。 */ +async function readFamilySubfamily( + path: string, + ext: string, + fallback: string, +): Promise<{ family: string; subfamily: string }> { + if (ext !== "ttf" && ext !== "otf") return { family: fallback, subfamily: "" }; + try { + const buf = await readNameTable(path); + if (!buf) return { family: fallback, subfamily: "" }; + const { family, subfamily } = parseNameTable(buf); + return { family: family || fallback, subfamily }; + } catch { + return { family: fallback, subfamily: "" }; + } +} + async function enumerateFonts(): Promise { const tmpPath = join(tmpdir(), `cover-studio-fonts-${process.pid}-${Date.now()}.json`); try { @@ -149,12 +271,16 @@ async function enumerateFonts(): Promise { seen.add(key); if (!existsSync(path)) continue; + const fallback = familyOf(name); + const { family, subfamily } = await readFamilySubfamily(path, ext, fallback); + records.push({ path, ext, entry: { id: makeId(path), - family: familyOf(name), + family, + subfamily, path, ext, needsExtraction: ext === "ttc",