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 验
This commit is contained in:
2026-09-04 16:02:28 +08:00
parent 95eeb69ff8
commit fa4a7e92f4
5 changed files with 721 additions and 30 deletions
+313 -8
View File
@@ -1,19 +1,324 @@
<script setup lang="ts"> <script setup lang="ts">
/** import { computed, onMounted, ref } from "vue";
* 【T5 待实现 — 导出与模板面板】 import { useEditor } from "@/editor/useEditor";
* import { fonts } from "@/fonts/useFonts";
* 导出:格式(WebP / PNG)、质量滑杆(只对 WebP 生效)、文件名、下载按钮。 import type { ExportFormat, Template } from "@/types";
* 模板:保存当前画布为模板、列出已存模板、加载、删除。 import {
*/ collectUsedFonts,
deleteTemplate,
exportCanvas,
listTemplates,
missingFonts,
normalizeFilename,
saveTemplate,
} from "@/io";
const { getCanvas, size, setSize } = useEditor();
/* ---------------------------------------------------------------- 导出 */
const format = ref<ExportFormat>("webp");
const quality = ref(0.85);
const filename = ref("");
const exporting = ref(false);
const exportError = ref("");
const qualityPercent = computed(() => Math.round(quality.value * 100));
const defaultFilename = computed(() => `cover-${size.value.width}x${size.value.height}`);
const exportTarget = computed(
() => `${normalizeFilename(filename.value || defaultFilename.value)}.${format.value}`,
);
function pickFormat(next: string) {
if (next === "webp" || next === "png") format.value = next;
}
async function doExport() {
if (exporting.value) return;
exportError.value = "";
exporting.value = true;
try {
await exportCanvas(
getCanvas(),
{ width: size.value.width, height: size.value.height },
{
format: format.value,
quality: quality.value,
filename: filename.value || defaultFilename.value,
},
);
} catch (err) {
exportError.value = err instanceof Error ? err.message : String(err);
} finally {
exporting.value = false;
}
}
/* ---------------------------------------------------------------- 模板 */
const templates = ref<Template[]>([]);
const templateName = ref("");
const saving = ref(false);
const templateMsg = ref("");
const missingFontsNow = ref<string[]>([]);
const confirmingDeleteId = ref<string | null>(null);
function refreshTemplates() {
templates.value = listTemplates();
}
onMounted(refreshTemplates);
function shortDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function saveCurrentTemplate() {
if (saving.value) return;
saving.value = true;
templateMsg.value = "";
missingFontsNow.value = [];
try {
const canvas = getCanvas();
// 存之前扫一遍文本对象的 fontFamily——换台机器复现不出来的正是这些本机字体。
const used = collectUsedFonts(canvas.getObjects());
const saved = saveTemplate({
name: templateName.value,
size: { width: size.value.width, height: size.value.height },
canvasJSON: canvas.toJSON(),
usedFonts: used,
});
refreshTemplates();
templateName.value = "";
templateMsg.value = `已保存模板「${saved.name}」`;
} catch (err) {
templateMsg.value = `保存失败:${err instanceof Error ? err.message : String(err)}`;
} finally {
saving.value = false;
}
}
async function loadTemplateIntoCanvas(t: Template) {
templateMsg.value = "";
missingFontsNow.value = [];
try {
const canvas = getCanvas();
// 先按模板尺寸恢复画布,再灌对象(loadFromJSON 不改变画布尺寸)。
setSize({ width: t.size.width, height: t.size.height });
await canvas.loadFromJSON(t.canvasJSON as Record<string, unknown>);
canvas.requestRenderAll();
// 字体提醒。T2 的字体列表还没拉出来(空数组)就跳过——
// 别让缺字体检查把模板加载一起拖垮。
if (fonts.value.length > 0) {
missingFontsNow.value = missingFonts(
t.usedFonts,
fonts.value.map((f) => f.family),
);
}
templateMsg.value = `已加载「${t.name}」`;
} catch (err) {
templateMsg.value = `加载「${t.name}」失败:${err instanceof Error ? err.message : String(err)}`;
}
}
function askDelete(t: Template) {
confirmingDeleteId.value = t.id;
}
function cancelDelete() {
confirmingDeleteId.value = null;
}
function doDelete(t: Template) {
try {
deleteTemplate(t.id);
refreshTemplates();
confirmingDeleteId.value = null;
templateMsg.value = `已删除「${t.name}」`;
} catch (err) {
templateMsg.value = `删除失败:${err instanceof Error ? err.message : String(err)}`;
}
}
</script> </script>
<template> <template>
<p class="todo">T5 待实现:WebP / PNG 导出,模板保存与加载</p> <!-- ==================== 导出 ==================== -->
<div class="field">
<label>格式</label>
<select :value="format" @change="pickFormat(($event.target as HTMLSelectElement).value)">
<option value="webp">WebP(推荐,体积小)</option>
<option value="png">PNG(无损,一般用不上)</option>
</select>
</div>
<div class="field">
<label>质量 {{ qualityPercent }}%(PNG 无损时忽略)</label>
<input
type="range"
v-model.number="quality"
min="0.1"
max="1"
step="0.05"
:disabled="format === 'png'"
/>
</div>
<div class="field">
<label>文件名</label>
<input
v-model="filename"
:placeholder="defaultFilename"
@keyup.enter="doExport"
/>
</div>
<p class="meta">
导出 {{ size.width }}×{{ size.height }}px 全分辨率(预览缩放不影响),
下载为 <code>{{ exportTarget }}</code>。
</p>
<button :disabled="exporting" @click="doExport">
{{ exporting ? "导出中…" : `下载 ${format.toUpperCase()}` }}
</button>
<p v-if="exportError" class="msg">⚠ {{ exportError }}</p>
<!-- ==================== 模板 ==================== -->
<div class="sep"></div>
<div class="field">
<label>保存当前画布为模板</label>
<div class="row">
<input
v-model="templateName"
class="grow"
placeholder="模板名(可留空自动起名)"
@keyup.enter="saveCurrentTemplate"
/>
<button :disabled="saving" @click="saveCurrentTemplate">保存</button>
</div>
</div>
<p v-if="templateMsg" class="msg">{{ templateMsg }}</p>
<p v-if="missingFontsNow.length" class="msg">
⚠ 该模板用了本机字体:{{ missingFontsNow.join("、") }}。当前机器没有这些字体,
会退回系统字体显示,文字排版可能对不上。
</p>
<h3 class="sub">已存模板({{ templates.length }})</h3>
<p v-if="templates.length === 0" class="empty">
还没有模板。调好一张封面后点上面的「保存」,之后就能一键加载复用。
</p>
<ul v-else class="tpl-list">
<li v-for="t in templates" :key="t.id" class="tpl">
<div class="tpl-head">
<span class="name" :title="`id: ${t.id}`">{{ t.name }}</span>
<span class="dim">{{ t.size.width }}×{{ t.size.height }} · {{ shortDate(t.createdAt) }}</span>
</div>
<div class="tpl-actions row">
<button @click="loadTemplateIntoCanvas(t)">加载</button>
<template v-if="confirmingDeleteId === t.id">
<span class="dim">确认删除?</span>
<button @click="doDelete(t)">删除</button>
<button @click="cancelDelete">取消</button>
</template>
<button v-else @click="askDelete(t)">删除</button>
<span v-if="t.usedFonts.length" class="dim fonts">含 {{ t.usedFonts.length }} 个字体</span>
</div>
</li>
</ul>
</template> </template>
<style scoped> <style scoped>
.todo { .grow {
flex: 1;
min-width: 0;
}
.meta {
color: var(--fg-dim);
margin: 0 0 10px;
line-height: 1.6;
}
.meta code {
color: var(--fg);
}
.msg {
color: var(--fg-dim);
margin: 8px 0 0;
line-height: 1.6;
word-break: break-all;
}
.sep {
border-top: 1px solid var(--line);
margin: 14px 0 12px;
}
.sub {
margin: 10px 0 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--fg-dim);
}
.empty {
color: var(--fg-dim); color: var(--fg-dim);
margin: 0; margin: 0;
line-height: 1.6;
}
.tpl-list {
list-style: none;
margin: 0;
padding: 0;
}
.tpl {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--bg);
padding: 8px 10px;
margin-bottom: 8px;
}
.tpl-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.tpl-head .name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim {
color: var(--fg-dim);
font-size: 12px;
white-space: nowrap;
}
.tpl-actions {
flex-wrap: wrap;
row-gap: 6px;
}
.tpl-actions .fonts {
margin-left: auto;
} }
</style> </style>
+88
View File
@@ -0,0 +1,88 @@
import type { CanvasSize, ExportFormat, ExportOptions } from "@/types";
/**
* 【T5】导出:WebP(默认,带质量)/ PNG(无损),走浏览器下载到下载文件夹。
* **不上传图床、不写 frontmatter**——这两条明确不做。
*/
/**
* 传给 fabric `canvas.toBlob()` 的参数。
*
* `multiplier` 恒为 1:画布 backstore 始终是逻辑尺寸(导出像素,如 1200×630),
* `useEditor().displayScale` / `fitTo()` 只改 CSS 尺寸。所以 `toBlob` 默认就已经
* 是全分辨率,**不要再乘一次 1/displayScale 去“补偿”预览缩放**,那会导出成两倍大小。
*/
export interface ExportRequest {
format: ExportFormat;
multiplier: number;
/** 0–1,只对 webp 生效。 */
quality?: number;
/** 导出像素尺寸 = 画布逻辑尺寸;只作展示/自检用,不进 toBlob。 */
width: number;
height: number;
}
function clampQuality(q: number): number {
if (!Number.isFinite(q)) return 0.85;
return Math.min(1, Math.max(0, q));
}
/** 纯函数:把画布逻辑尺寸 + 面板选项解析成该传给 `toDataURL`/`toBlob` 的参数。 */
export function resolveExportRequest(
size: CanvasSize,
options: Pick<ExportOptions, "format" | "quality">,
): ExportRequest {
const base = { multiplier: 1, width: size.width, height: size.height };
if (options.format === "png") {
// PNG 无损,浏览器会忽略 quality,干脆不带。
return { ...base, format: "png" };
}
return { ...base, format: "webp", quality: clampQuality(options.quality) };
}
/** fabric Canvas 的最小结构面——避免这里直接依赖 fabric 的类型。 */
export interface ExportableCanvas {
toBlob(options: { format: ExportFormat; multiplier: number; quality?: number }): Promise<Blob | null>;
}
/** 文件名清洗:去掉各平台文件名非法字符与控制字符,空结果退回 fallback。 */
export function normalizeFilename(raw: string, fallback = "cover"): string {
const cleaned = raw
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "")
.replace(/\s+/g, " ")
.trim();
return cleaned === "" ? fallback : cleaned;
}
/** 触发一次浏览器下载(object URL + 隐藏的 <a download>)。 */
export function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
// 延后释放,保证下载已经真正开始。
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export async function exportCanvas(
canvas: ExportableCanvas,
size: CanvasSize,
options: ExportOptions,
): Promise<void> {
const req = resolveExportRequest(size, options);
const blob = await canvas.toBlob({
format: req.format,
multiplier: req.multiplier,
...(req.quality !== undefined ? { quality: req.quality } : {}),
});
if (!blob) {
throw new Error(
`这个浏览器导不出 ${req.format.toUpperCase()}(canvas.toBlob 返回空),换 PNG 试试`,
);
}
triggerDownload(blob, `${normalizeFilename(options.filename)}.${req.format}`);
}
+7 -22
View File
@@ -1,25 +1,10 @@
import type { ExportOptions, Template } from "@/types";
/** /**
* 【T5 待实现 — 导出与模板】 * 【T5】导出与模板。
* *
* export.ts: * - `./export`:画布导出成 WebP(默认)/ PNG,浏览器下载。纯函数 `resolveExportRequest`
* 画布导出成 WebP(默认,带质量滑杆)或 PNG,走浏览器下载到下载文件夹。 * 负责解析 toBlob 参数(multiplier 恒为 1,见该文件里的注释),`exportCanvas` 组合下载。
* **不上传图床、不写 frontmatter**——这两条明确不做。 * - `./templates`:模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage;
* 导出必须是画布的逻辑尺寸(全分辨率),不受预览缩放影响; * `collectUsedFonts` / `missingFonts` 是本机字体相关的纯函数。
* `useEditor().displayScale` 只改 CSS 尺寸,所以 `toDataURL` 默认就是对的,
* 但要注意 multiplier 别被重复乘一次。
*
* templates.ts:
* 模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage(见 types.ts 的 Template)。
* 没有内置预设包,全靠用户自己存。加载走 `canvas.loadFromJSON()`。
* 存之前扫一遍文本对象的 fontFamily 填进 `usedFonts`——用了本机字体的模板
* 换台机器复现不出来,加载时要提示。
*/ */
export async function exportCanvas(_options: ExportOptions): Promise<void> { export * from "./export";
throw new Error("T5 未实现:exportCanvas"); export * from "./templates";
}
export function listTemplates(): Template[] {
throw new Error("T5 未实现:listTemplates");
}
+154
View File
@@ -0,0 +1,154 @@
/**
* 【T5】Node 侧自验。运行:`bun src/io/selfcheck.ts`
*
* 覆盖任务书要求的四条自验里能脱离浏览器的部分:
* 1. 导出 multiplier 恒为 1(不被预览缩放带偏);
* 2. 模板在 storage 里序列化往返形状一致;
* 3. `usedFonts` 收集能从对象列表里挑出正确的 fontFamily;
* 4. 缺字体检查与文件名清洗。
*
* 浏览器侧行为(真实下载像素尺寸、质量滑杆改变体积、刷新后加载恢复画布)
* 这里验不了,需要主控用 Chrome 验。
*/
import assert from "node:assert/strict";
import { collectUsedFonts, deleteTemplate, listTemplates, missingFonts, saveTemplate } from "./templates";
import type { TemplateStore } from "./templates";
import { normalizeFilename, resolveExportRequest } from "./export";
/** 假 localStorage 的底层存储:同一块 Map。 */
function makeBacking() {
return new Map<string, string>();
}
/** 一块底层存储上的一个视图(localStorage 是每源单例,刷新前后看到的是同一块)。 */
function viewOf(backing: Map<string, string>): TemplateStore {
return {
getItem: (key) => backing.get(key) ?? null,
setItem: (key, value) => {
backing.set(key, value);
},
};
}
/* ---------------- 1. 导出:multiplier 必须恒为 1 ---------------- */
const logical = { width: 1200, height: 630 };
const webp = resolveExportRequest(logical, { format: "webp", quality: 0.8 });
assert.equal(webp.multiplier, 1, "WebP 导出的 multiplier 必须是 1(逻辑尺寸 1200×630)");
assert.equal(webp.width, 1200, "导出宽 = 逻辑宽");
assert.equal(webp.height, 630, "导出高 = 逻辑高");
assert.equal(webp.format, "webp");
assert.equal(webp.quality, 0.8);
const png = resolveExportRequest(logical, { format: "png", quality: 0.8 });
assert.equal(png.multiplier, 1, "PNG 导出的 multiplier 也必须是 1");
assert.equal(png.format, "png");
assert.equal(png.quality, undefined, "PNG 无损,不传 quality");
// quality 钳制在 [0, 1]
assert.equal(resolveExportRequest(logical, { format: "webp", quality: 1.4 }).quality, 1);
assert.equal(resolveExportRequest(logical, { format: "webp", quality: -0.3 }).quality, 0);
assert.equal(resolveExportRequest(logical, { format: "webp", quality: Number.NaN }).quality, 0.85);
console.log("✓ 导出:1200×630 逻辑尺寸下 multiplier 恒为 1,width/height 与逻辑尺寸一致");
/* ---------------- 2. 文件名清洗 ---------------- */
assert.equal(normalizeFilename('我的 封面 a/b\\c:d*e?f"g<h>i|j'), "我的 封面 abcdefghij");
assert.equal(normalizeFilename(" "), "cover");
assert.equal(normalizeFilename("", "fallback-name"), "fallback-name");
console.log("✓ 文件名:非法字符被清掉,空名退回 fallback");
/* ---------------- 3. usedFonts 收集 ---------------- */
const fakeObjects = [
{ type: "Textbox", fontFamily: "阿里巴巴普惠体-H" },
{ type: "IText", fontFamily: "阿里巴巴普惠体-H" }, // 重复要合并
{ type: "Text", fontFamily: " System Font " }, // 两侧空白要清
{ type: "textbox", fontFamily: "legacy-name" }, // 老版本小写 type 也要认
{ type: "Image", fontFamily: "Times New Roman" }, // 非文本对象不算
{ type: "rect", fontFamily: "RectFont" }, // 非文本对象不算
{ type: "Text", fontFamily: "" }, // 空 family 不要
{},
];
const collected = collectUsedFonts(fakeObjects);
assert.deepEqual(collected, ["阿里巴巴普惠体-H", "System Font", "legacy-name"]);
console.log("✓ usedFonts:只收文本对象,去重去空,输出:", JSON.stringify(collected));
/* ---------------- 4. 缺字体检查 ---------------- */
assert.deepEqual(missingFonts(["A", "B", "C"], ["A", "D"]), ["B", "C"]);
assert.deepEqual(missingFonts(["A"], ["A"]), []);
assert.deepEqual(missingFonts([], ["A"]), []);
console.log("✓ missingFonts:只返回当前机器没有的字体");
/* ---------------- 5. 模板 storage 往返 ---------------- */
const backing = makeBacking();
const storeA = viewOf(backing); // 保存
const storeB = viewOf(backing); // 模拟「刷新后重新读到 localStorage」——同一块存储
const saved = saveTemplate(
{
name: "tech-cover",
size: { width: 1200, height: 630 },
canvasJSON: { version: "6.6.0", objects: [{ type: "Rect", width: 10 }] },
usedFonts: ["A", " A ", "B", ""],
},
storeA,
);
// saveTemplate 内部应归一化 usedFonts
assert.deepEqual(saved.usedFonts, ["A", "B"]);
assert.equal(saved.name, "tech-cover");
assert.ok(saved.id.length > 0);
assert.ok(!Number.isNaN(new Date(saved.createdAt).getTime()), "createdAt 是合法 ISO 时间");
// 空名自动起名
const autoNamed = saveTemplate(
{ name: " ", size: { width: 800, height: 400 }, canvasJSON: { objects: [] }, usedFonts: [] },
storeA,
);
assert.equal(autoNamed.name, "封面 800×400");
// 刷新后:从 storeB(同一底层)读回来,形状与内容一致
const afterReload = listTemplates(storeB);
assert.equal(afterReload.length, 2);
const found = afterReload.find((t) => t.id === saved.id);
assert.ok(found, "刷新后还能找到保存的模板");
// 每个字段都原样往返(含 canvasJSON 这种嵌套对象)
assert.deepEqual(
{
id: found.id,
name: found.name,
size: found.size,
canvasJSON: found.canvasJSON,
createdAt: found.createdAt,
usedFonts: found.usedFonts,
},
{
id: saved.id,
name: saved.name,
size: saved.size,
canvasJSON: saved.canvasJSON,
createdAt: saved.createdAt,
usedFonts: saved.usedFonts,
},
"Template 存入 storage 的形状与读回的一致(JSON.stringify/parse 往返无损)",
);
// 删除后消失
deleteTemplate(saved.id, storeA);
const afterDelete = listTemplates(storeB);
assert.equal(afterDelete.length, 1);
assert.equal(afterDelete[0].id, autoNamed.id, "删掉的只剩 autoNamed");
console.log("✓ 模板:localStorage 序列化往返形状一致,保存/列出/删除都工作");
console.log("\n全部 Node 侧自验通过 ✅");
+159
View File
@@ -0,0 +1,159 @@
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));
}