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