diff --git a/src/components/panels/TextPanel.vue b/src/components/panels/TextPanel.vue index ab5ceeb..0de90e4 100644 --- a/src/components/panels/TextPanel.vue +++ b/src/components/panels/TextPanel.vue @@ -1,21 +1,301 @@ - T2 待实现:字体选择与文字样式 + + + 字体 + + — 选择本机字体 — + + {{ f.family }}{{ f.subfamily ? " " + f.subfamily : "" }}{{ f.needsExtraction ? "(.ttc 合集,暂不支持)" : "" }} + + + {{ fontError }} + 正在读取本机字体… + 未找到可用字体(本机需装 .ttf / .otf 字体)。 + + + + + 字号 + + + + 行高 + + + + + + + 颜色 + + + + 对齐 + + 左对齐 + 居中 + 右对齐 + 两端对齐 + + + + + + 描边 + + + 颜色 + + + + 宽度 + + + + + + + 阴影 + + + 启用阴影 + + + + 颜色 + + + + + 模糊 + + + + 水平偏移 + + + + 垂直偏移 + + + + + + diff --git a/src/fonts/useFonts.ts b/src/fonts/useFonts.ts index 32523f4..6be021a 100644 --- a/src/fonts/useFonts.ts +++ b/src/fonts/useFonts.ts @@ -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([]); export const loadingFonts = ref(false); -export async function listFonts(): Promise { - throw new Error("T2 未实现:listFonts"); +/** 已确认可用(本机解析或 webfont 挂载)的字体,避免重复处理。 */ +const loaded = new Set(); +/** 已确认本机能按名解析的字族,缓存避免反复 measureText。 */ +const resolvedLocally = new Set(); + +export interface FontStyle { + fontWeight: number; + fontStyle: "normal" | "italic"; } -export async function ensureFontLoaded(_entry: FontEntry): Promise { - 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 { + 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 { + 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; + } } diff --git a/src/fonts/vite-plugin-fonts.ts b/src/fonts/vite-plugin-fonts.ts index e29d775..b99d8ac 100644 --- a/src/fonts/vite-plugin-fonts.ts +++ b/src/fonts/vite-plugin-fonts.ts @@ -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 = { + 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 { + 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 { + 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(); + 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 | null = null; + const byId = new Map(); + + async function getRecords(): Promise { + 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)); + } + }); }, }; }
T2 待实现:字体选择与文字样式
{{ fontError }}
正在读取本机字体…
未找到可用字体(本机需装 .ttf / .otf 字体)。