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();
-
@@ -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",