Files
cover-studio/src/io/templates.ts
T
im0o fa4a7e92f4 feat(io): 导出 WebP/PNG 下载,模板存 localStorage 可加载复用
- export.ts:toBlob 参数纯函数 resolveExportRequest——multiplier 恒为 1,
  画布 backstore 本就是逻辑尺寸,不再乘预览缩放;WebP 带质量、PNG 无损不带
- templates.ts:模板 = canvas.toJSON() + 尺寸,存 localStorage(key 单键数组);
  save/list/delete 可注入 store 便于 Node 侧自检;collectUsedFonts 扫文本对象
  fontFamily(instanceof FabricText + type 名兜底),missingFonts 查缺字
- ExportPanel.vue:格式/质量/文件名/下载;模板保存命名、列表、加载
  (loadFromJSON + setSize 恢复尺寸)、删除(二次确认);加载时若字体列表
  已就绪且模板用了本机没有的字体则提示;字体列表为空(T2 未实现)跳过检查
- selfcheck.ts:bun 直接跑的 Node 断言,覆盖 multiplier、storage 往返、
  usedFonts 收集、缺字体与文件名清洗

浏览器下载、质量滑杆体积、刷新后加载恢复需主控用 Chrome 验
2026-09-04 16:02:28 +08:00

160 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FabricText } from "fabric";
import type { CanvasSize, Template } from "@/types";
/**
* 【T5】模板:`canvas.toJSON()` 的产物 + 画布尺寸,存 localStorage(见 types.ts 的 Template)。
*
* 没有内置预设包,全靠用户自己存。加载走 `canvas.loadFromJSON()`(在面板里拼 `useEditor()`)。
*
* 存储相关函数都接收一个可注入的 `store`,默认用浏览器 localStorage——
* Node 侧自检传一个假 store 就能验,不用真的开浏览器。
*/
const STORAGE_KEY = "cover-studio:templates.v1";
export interface TemplateStore {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface SaveTemplateInput {
name: string;
size: CanvasSize;
/** `canvas.toJSON()` 的产物,原样存原样取。 */
canvasJSON: unknown;
/** 画布上文本对象用到的 fontFamily(会被去重、去空)。 */
usedFonts: readonly string[];
}
function defaultStore(): TemplateStore {
if (typeof localStorage === "undefined") {
throw new Error("模板需要 localStorage,但当前环境没有");
}
return localStorage;
}
function readAll(store: TemplateStore): Template[] {
const raw = store.getItem(STORAGE_KEY);
if (!raw) return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Template[]) : [];
} catch {
// 本地数据坏了就当没有,别让一份坏模板拖垮整个面板。
return [];
}
}
function writeAll(store: TemplateStore, list: Template[]): void {
try {
store.setItem(STORAGE_KEY, JSON.stringify(list));
} catch (err) {
// 一份模板里如果塞了 base64 大图,很容易顶爆 localStorage 的额度。
throw new Error("模板存不进去:localStorage 可能已满(模板里的大图很占空间)", {
cause: err,
});
}
}
function newId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeFonts(fonts: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const f of fonts) {
const trimmed = (f ?? "").trim();
if (trimmed !== "" && !seen.has(trimmed)) {
seen.add(trimmed);
out.push(trimmed);
}
}
return out;
}
export function listTemplates(store: TemplateStore = defaultStore()): Template[] {
return readAll(store);
}
export function getTemplate(id: string, store: TemplateStore = defaultStore()): Template | null {
return readAll(store).find((t) => t.id === id) ?? null;
}
export function saveTemplate(
input: SaveTemplateInput,
store: TemplateStore = defaultStore(),
): Template {
const name = input.name.trim();
const template: Template = {
id: newId(),
name: name === "" ? `封面 ${input.size.width}×${input.size.height}` : name,
size: { width: input.size.width, height: input.size.height },
canvasJSON: input.canvasJSON,
createdAt: new Date().toISOString(),
usedFonts: normalizeFonts(input.usedFonts),
};
writeAll(store, [...readAll(store), template]);
return template;
}
export function deleteTemplate(id: string, store: TemplateStore = defaultStore()): void {
writeAll(
store,
readAll(store).filter((t) => t.id !== id),
);
}
/* ---------------------------------------------------------------- usedFonts */
/**
* 文本对象在 fabric v7 的序列化 JSON 里 type 是 PascalCase
* ("Textbox" / "IText" / "Text")。老版本的小写别名("textbox" / "i-text" / "text")
* 也认,保证从别处拷来的 JSON 也能识别。
*/
const TEXT_TYPE_NAMES = new Set(["Textbox", "IText", "Text", "textbox", "i-text", "text"]);
function isTextObject(obj: object): boolean {
// 活对象:Text / IText / Textbox 都继承 FabricText(instanceof 最可靠)。
if (obj instanceof FabricText) return true;
// 序列化 JSON / 测试假对象:按 type 名认。
const type = (obj as { type?: unknown }).type;
return typeof type === "string" && TEXT_TYPE_NAMES.has(type);
}
/**
* 收集画布对象里所有文本对象的 fontFamily(去重、去空)。
* 换台机器复现不出来的正是这些本机字体,存模板前扫一遍填进 `usedFonts`。
*/
export function collectUsedFonts(objects: readonly unknown[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const obj of objects) {
if (!obj || typeof obj !== "object") continue;
if (!isTextObject(obj)) continue;
const fontFamily = (obj as { fontFamily?: unknown }).fontFamily;
if (typeof fontFamily !== "string") continue;
const key = fontFamily.trim();
if (key !== "" && !seen.has(key)) {
seen.add(key);
out.push(key);
}
}
return out;
}
/**
* 模板用到的字体里,当前机器没装(不在 `installed` 里)的那些。
*
* `installed` 传空数组时应由调用方跳过检查而不是调用这里报缺字——
* 字体列表拉出来之前(T2 未实现时是空数组),`missingFonts` 会把所有 usedFonts
* 都判成缺失。调用方自己保证:`installed.length === 0` 就不查。
*/
export function missingFonts(usedFonts: readonly string[], installed: readonly string[]): string[] {
const set = new Set(installed);
return usedFonts.filter((f) => !set.has(f));
}