实现字体子系统:注册表枚举中间件、前端 FontFace 挂载与文字面板

This commit is contained in:
2026-09-04 16:03:47 +08:00
parent 95eeb69ff8
commit a2c95a83b7
3 changed files with 505 additions and 21 deletions
+241 -7
View File
@@ -1,21 +1,255 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Shadow, Textbox } from "fabric";
import { useEditor } from "@/editor/useEditor";
import { ensureFontLoaded, fonts, listFonts, loadingFonts } from "@/fonts/useFonts";
/**
* 【T2 待实现 — 文字面板】
* 【T2 — 文字面板】选中 Textbox 时显示。
*
* 选中 Textbox 时显示。要有:字体下拉(来自 useFonts)、字号、字重/颜色、
* 对齐、行高、描边与阴影(现有封面靠这两样保证白字在图上读得清)。
* 字体下拉列出 `useFonts` 里的本机字体,选中即挂载并应用到当前文本的 `fontFamily`;
* 字号、颜色、对齐、行高、描边(stroke/strokeWidth)与阴影(shadow)。
* 描边和阴影是刚需:现有封面全靠它们保证白字压在图上还读得清。
*
* 拿当前选中对象用 `useEditor().activeObject`,改完记得 `canvas.requestRenderAll()`。
* 画布选中对象是 Fabric 实例,不是响应式的,所以这里用一批本地 ref 做 UI 状态:
* 选中对象变化时从对象同步进来,用户改动时写回对象并 `requestRenderAll()`。
*/
const { activeObject, getCanvas } = useEditor();
const textObj = computed<Textbox | null>(() =>
activeObject.value instanceof Textbox ? activeObject.value : null,
);
// 本地 UI 状态。
const fontSize = ref(40);
const lineHeight = ref(1.16);
const fill = ref("#ffffff");
const textAlign = ref("center");
const stroke = ref("");
const strokeWidth = ref(0);
const shadowEnabled = ref(false);
const shadowColor = ref("#000000");
const shadowBlur = ref(0);
const shadowOffsetX = ref(0);
const shadowOffsetY = ref(0);
/** 选中对象变化 → 把对象上的值同步进 UI。 */
function syncFromObject() {
const obj = textObj.value;
if (!obj) return;
fontSize.value = typeof obj.fontSize === "number" ? obj.fontSize : 40;
lineHeight.value = typeof obj.lineHeight === "number" ? obj.lineHeight : 1.16;
fill.value = typeof obj.fill === "string" ? obj.fill : "#ffffff";
textAlign.value = obj.textAlign ?? "left";
stroke.value = typeof obj.stroke === "string" ? obj.stroke : "";
strokeWidth.value = typeof obj.strokeWidth === "number" ? obj.strokeWidth : 0;
const s = obj.shadow;
shadowEnabled.value = !!s;
shadowColor.value = typeof s?.color === "string" ? s.color : "#000000";
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;
}
watch(textObj, syncFromObject, { immediate: true });
/** 用户改动 → 写回对象并重绘。 */
function applyToObject() {
const obj = textObj.value;
if (!obj) return;
const patch: Record<string, unknown> = {
fill: fill.value,
textAlign: textAlign.value,
stroke: stroke.value || null,
};
const fs = Number(fontSize.value);
if (Number.isFinite(fs) && fs > 0) patch.fontSize = fs;
const lh = Number(lineHeight.value);
if (Number.isFinite(lh) && lh > 0) patch.lineHeight = lh;
const sw = Number(strokeWidth.value);
if (Number.isFinite(sw) && sw >= 0) patch.strokeWidth = sw;
let shadow: Shadow | null = null;
if (shadowEnabled.value) {
const blur = Number(shadowBlur.value);
const ox = Number(shadowOffsetX.value);
const oy = Number(shadowOffsetY.value);
shadow = new Shadow({
color: shadowColor.value || "#000000",
blur: Number.isFinite(blur) && blur >= 0 ? blur : 0,
offsetX: Number.isFinite(ox) ? ox : 0,
offsetY: Number.isFinite(oy) ? oy : 0,
});
}
patch.shadow = shadow;
obj.set(patch);
getCanvas().requestRenderAll();
}
watch(
[
fontSize,
lineHeight,
fill,
textAlign,
stroke,
strokeWidth,
shadowEnabled,
shadowColor,
shadowBlur,
shadowOffsetX,
shadowOffsetY,
],
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);
getCanvas().requestRenderAll();
await ensureFontLoaded(entry);
}
listFonts();
</script>
<template>
<p class="todo">T2 待实现:字体选择与文字样式</p>
<div class="text-panel">
<div class="field">
<label>字体</label>
<select :value="selectedFontId" @change="pickFont(($event.target as HTMLSelectElement).value)">
<option value="" disabled>— 选择本机字体 —</option>
<option v-for="f in fonts" :key="f.id" :value="f.id" :disabled="f.needsExtraction">
{{ f.family }}{{ f.needsExtraction ? "(.ttc 合集,暂不支持)" : "" }}
</option>
</select>
<p v-if="loadingFonts" class="meta">正在读取本机字体…</p>
<p v-else-if="fonts.length === 0" class="meta">未找到可用字体(本机需装 .ttf / .otf 字体)。</p>
</div>
<div class="field row">
<div class="grow">
<label>字号</label>
<input type="number" v-model.number="fontSize" min="1" step="1" />
</div>
<div class="grow">
<label>行高</label>
<input type="number" v-model.number="lineHeight" min="0.1" step="0.1" />
</div>
</div>
<div class="field row">
<div class="grow">
<label>颜色</label>
<input type="color" v-model="fill" />
</div>
<div class="grow">
<label>对齐</label>
<select v-model="textAlign">
<option value="left">左对齐</option>
<option value="center">居中</option>
<option value="right">右对齐</option>
<option value="justify">两端对齐</option>
</select>
</div>
</div>
<fieldset class="group">
<legend>描边</legend>
<div class="field row">
<div class="grow">
<label>颜色</label>
<input type="color" v-model="stroke" />
</div>
<div class="grow">
<label>宽度</label>
<input type="number" v-model.number="strokeWidth" min="0" step="1" />
</div>
</div>
</fieldset>
<fieldset class="group">
<legend>阴影</legend>
<label class="check">
<input type="checkbox" v-model="shadowEnabled" />
启用阴影
</label>
<template v-if="shadowEnabled">
<div class="field">
<label>颜色</label>
<input type="color" v-model="shadowColor" />
</div>
<div class="field row">
<div class="grow">
<label>模糊</label>
<input type="number" v-model.number="shadowBlur" min="0" step="1" />
</div>
<div class="grow">
<label>水平偏移</label>
<input type="number" v-model.number="shadowOffsetX" step="1" />
</div>
<div class="grow">
<label>垂直偏移</label>
<input type="number" v-model.number="shadowOffsetY" step="1" />
</div>
</div>
</template>
</fieldset>
</div>
</template>
<style scoped>
.todo {
.text-panel {
display: flex;
flex-direction: column;
}
.grow {
flex: 1;
min-width: 0;
}
.meta {
color: var(--fg-dim);
margin: 0;
margin: 6px 0 0;
line-height: 1.6;
}
.group {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 8px 10px 4px;
margin: 0 0 10px;
}
.group legend {
padding: 0 4px;
color: var(--fg-dim);
font-size: 12px;
}
.check {
display: flex;
align-items: center;
gap: 6px;
color: var(--fg);
cursor: pointer;
margin-bottom: 8px;
}
.check input {
width: auto;
}
</style>
+45 -7
View File
@@ -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<FontEntry[]>([]);
export const loadingFonts = ref(false);
const loaded = new Set<string>();
export async function listFonts(): Promise<FontEntry[]> {
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<void> {
throw new Error("T2 未实现:ensureFontLoaded");
export async function ensureFontLoaded(entry: FontEntry): Promise<void> {
// 同一款字体不重复加载。.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;
}
}
+219 -7
View File
@@ -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<string, string> = {
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<FontRecord[]> {
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<string>();
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<FontRecord[]> | null = null;
const byId = new Map<string, FontRecord>();
async function getRecords(): Promise<FontRecord[]> {
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));
}
});
},
};
}