Merge branch 't2-fonts' into integration
This commit is contained in:
@@ -1,21 +1,301 @@
|
||||
<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,
|
||||
styleOfSubfamily,
|
||||
} from "@/fonts/useFonts";
|
||||
|
||||
/**
|
||||
* 【T2 待实现 — 文字面板】
|
||||
* 【T2 — 文字面板】选中 Textbox 时显示。
|
||||
*
|
||||
* 选中 Textbox 时显示。要有:字体下拉(来自 useFonts)、字号、字重/颜色、
|
||||
* 对齐、行高、描边与阴影(现有封面靠这两样保证白字在图上读得清)。
|
||||
* 字体下拉列出本机字体(family + subfamily),选中后把 fontFamily / fontWeight /
|
||||
* fontStyle 应用到当前文本,再交给 ensureFontLoaded 走「先本机、后 webfont」;
|
||||
* 加载失败要在这里给出可见提示,不能静默。
|
||||
*
|
||||
* 拿当前选中对象用 `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);
|
||||
|
||||
// 字体选择状态。
|
||||
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;
|
||||
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;
|
||||
selectedId.value = matchEntryId();
|
||||
}
|
||||
|
||||
watch(textObj, syncFromObject, { immediate: true });
|
||||
watch(fonts, () => {
|
||||
selectedId.value = matchEntryId();
|
||||
});
|
||||
|
||||
/** 用户改动 → 写回对象并重绘。 */
|
||||
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,
|
||||
);
|
||||
|
||||
async function pickFont(id: string) {
|
||||
const entry = fonts.value.find((f) => f.id === id);
|
||||
const obj = textObj.value;
|
||||
if (!entry || !obj) return;
|
||||
|
||||
selectedId.value = id;
|
||||
const { fontWeight, fontStyle } = styleOfSubfamily(entry.subfamily);
|
||||
obj.set({ fontFamily: entry.family, fontWeight, fontStyle });
|
||||
getCanvas().requestRenderAll();
|
||||
|
||||
try {
|
||||
await ensureFontLoaded(entry);
|
||||
fontError.value = "";
|
||||
} catch {
|
||||
fontError.value = "这款字体用不了:本机解析不到,浏览器也加载不了,文字暂时用默认字体。";
|
||||
}
|
||||
}
|
||||
|
||||
listFonts();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="todo">T2 待实现:字体选择与文字样式</p>
|
||||
<div class="text-panel">
|
||||
<div class="field">
|
||||
<label>字体</label>
|
||||
<select :value="selectedId" @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.subfamily ? " " + f.subfamily : "" }}{{ f.needsExtraction ? "(.ttc 合集,暂不支持)" : "" }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="fontError" class="meta error">{{ fontError }}</p>
|
||||
<p v-else-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;
|
||||
}
|
||||
|
||||
.meta.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
+113
-10
@@ -1,23 +1,126 @@
|
||||
import { ref } from "vue";
|
||||
import type { FontEntry } from "@/types";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
|
||||
/**
|
||||
* 【T2 待实现 — 字体子系统】
|
||||
* 【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<FontEntry[]>([]);
|
||||
export const loadingFonts = ref(false);
|
||||
|
||||
export async function listFonts(): Promise<FontEntry[]> {
|
||||
throw new Error("T2 未实现:listFonts");
|
||||
/** 已确认可用(本机解析或 webfont 挂载)的字体,避免重复处理。 */
|
||||
const loaded = new Set<string>();
|
||||
/** 已确认本机能按名解析的字族,缓存避免反复 measureText。 */
|
||||
const resolvedLocally = new Set<string>();
|
||||
|
||||
export interface FontStyle {
|
||||
fontWeight: number;
|
||||
fontStyle: "normal" | "italic";
|
||||
}
|
||||
|
||||
export async function ensureFontLoaded(_entry: FontEntry): Promise<void> {
|
||||
throw new Error("T2 未实现:ensureFontLoaded");
|
||||
/** 把 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<FontEntry[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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, {
|
||||
weight: String(fontWeight),
|
||||
style: fontStyle,
|
||||
});
|
||||
await face.load();
|
||||
document.fonts.add(face);
|
||||
// 强制浏览器把这款字体纳入可用集合,避免 Fabric 退到 fallback 字形。
|
||||
await document.fonts.load(`${fontStyle} ${fontWeight} 16px "${entry.family}"`);
|
||||
|
||||
loaded.add(entry.id);
|
||||
useEditor().canvas.value?.requestRenderAll();
|
||||
} catch (err) {
|
||||
// 抛给上层(TextPanel)提示,不在这里吞掉。
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
} finally {
|
||||
loadingFonts.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,366 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { open, 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");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 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<Buffer | null> {
|
||||
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<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;
|
||||
|
||||
const fallback = familyOf(name);
|
||||
const { family, subfamily } = await readFamilySubfamily(path, ext, fallback);
|
||||
|
||||
records.push({
|
||||
path,
|
||||
ext,
|
||||
entry: {
|
||||
id: makeId(path),
|
||||
family,
|
||||
subfamily,
|
||||
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