实现字体子系统:注册表枚举中间件、前端 FontFace 挂载与文字面板
This commit is contained in:
+45
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user