diff --git a/src/components/panels/TextPanel.vue b/src/components/panels/TextPanel.vue index ab5ceeb..098793d 100644 --- a/src/components/panels/TextPanel.vue +++ b/src/components/panels/TextPanel.vue @@ -1,21 +1,255 @@ - T2 待实现:字体选择与文字样式 + + + 字体 + + — 选择本机字体 — + + {{ f.family }}{{ f.needsExtraction ? "(.ttc 合集,暂不支持)" : "" }} + + + 正在读取本机字体… + 未找到可用字体(本机需装 .ttf / .otf 字体)。 + + + + + 字号 + + + + 行高 + + + + + + + 颜色 + + + + 对齐 + + 左对齐 + 居中 + 右对齐 + 两端对齐 + + + + + + 描边 + + + 颜色 + + + + 宽度 + + + + + + + 阴影 + + + 启用阴影 + + + + 颜色 + + + + + 模糊 + + + + 水平偏移 + + + + 垂直偏移 + + + + + + diff --git a/src/fonts/useFonts.ts b/src/fonts/useFonts.ts index 32523f4..65e4cde 100644 --- a/src/fonts/useFonts.ts +++ b/src/fonts/useFonts.ts @@ -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([]); export const loadingFonts = ref(false); +const loaded = new Set(); + export async function listFonts(): Promise { - 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 { - throw new Error("T2 未实现:ensureFontLoaded"); +export async function ensureFontLoaded(entry: FontEntry): Promise { + // 同一款字体不重复加载。.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; + } } diff --git a/src/fonts/vite-plugin-fonts.ts b/src/fonts/vite-plugin-fonts.ts index e29d775..fb47bfb 100644 --- a/src/fonts/vite-plugin-fonts.ts +++ b/src/fonts/vite-plugin-fonts.ts @@ -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 = { + 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 { + 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; + + 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 | 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 待实现:字体选择与文字样式
正在读取本机字体…
未找到可用字体(本机需装 .ttf / .otf 字体)。