From fa4a7e92f4b78d5cc1a670db60f6b66181530084 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Fri, 4 Sep 2026 16:02:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(io):=20=E5=AF=BC=E5=87=BA=20WebP/PNG=20?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=EF=BC=8C=E6=A8=A1=E6=9D=BF=E5=AD=98=20localS?= =?UTF-8?q?torage=20=E5=8F=AF=E5=8A=A0=E8=BD=BD=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 验 --- src/components/panels/ExportPanel.vue | 321 +++++++++++++++++++++++++- src/io/export.ts | 88 +++++++ src/io/index.ts | 29 +-- src/io/selfcheck.ts | 154 ++++++++++++ src/io/templates.ts | 159 +++++++++++++ 5 files changed, 721 insertions(+), 30 deletions(-) create mode 100644 src/io/export.ts create mode 100644 src/io/selfcheck.ts create mode 100644 src/io/templates.ts diff --git a/src/components/panels/ExportPanel.vue b/src/components/panels/ExportPanel.vue index f89e45b..002393b 100644 --- a/src/components/panels/ExportPanel.vue +++ b/src/components/panels/ExportPanel.vue @@ -1,19 +1,324 @@ diff --git a/src/io/export.ts b/src/io/export.ts new file mode 100644 index 0000000..36bae59 --- /dev/null +++ b/src/io/export.ts @@ -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, +): 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; +} + +/** 文件名清洗:去掉各平台文件名非法字符与控制字符,空结果退回 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 + 隐藏的 )。 */ +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 { + 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}`); +} diff --git a/src/io/index.ts b/src/io/index.ts index 1be4113..2421e95 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,25 +1,10 @@ -import type { ExportOptions, Template } from "@/types"; - /** - * 【T5 待实现 — 导出与模板】 + * 【T5】导出与模板。 * - * export.ts: - * 画布导出成 WebP(默认,带质量滑杆)或 PNG,走浏览器下载到下载文件夹。 - * **不上传图床、不写 frontmatter**——这两条明确不做。 - * 导出必须是画布的逻辑尺寸(全分辨率),不受预览缩放影响; - * `useEditor().displayScale` 只改 CSS 尺寸,所以 `toDataURL` 默认就是对的, - * 但要注意 multiplier 别被重复乘一次。 - * - * templates.ts: - * 模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage(见 types.ts 的 Template)。 - * 没有内置预设包,全靠用户自己存。加载走 `canvas.loadFromJSON()`。 - * 存之前扫一遍文本对象的 fontFamily 填进 `usedFonts`——用了本机字体的模板 - * 换台机器复现不出来,加载时要提示。 + * - `./export`:画布导出成 WebP(默认)/ PNG,浏览器下载。纯函数 `resolveExportRequest` + * 负责解析 toBlob 参数(multiplier 恒为 1,见该文件里的注释),`exportCanvas` 组合下载。 + * - `./templates`:模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage; + * `collectUsedFonts` / `missingFonts` 是本机字体相关的纯函数。 */ -export async function exportCanvas(_options: ExportOptions): Promise { - throw new Error("T5 未实现:exportCanvas"); -} - -export function listTemplates(): Template[] { - throw new Error("T5 未实现:listTemplates"); -} +export * from "./export"; +export * from "./templates"; diff --git a/src/io/selfcheck.ts b/src/io/selfcheck.ts new file mode 100644 index 0000000..bc50965 --- /dev/null +++ b/src/io/selfcheck.ts @@ -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(); +} + +/** 一块底层存储上的一个视图(localStorage 是每源单例,刷新前后看到的是同一块)。 */ +function viewOf(backing: Map): 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"gi|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 侧自验通过 ✅"); diff --git a/src/io/templates.ts b/src/io/templates.ts new file mode 100644 index 0000000..170d60d --- /dev/null +++ b/src/io/templates.ts @@ -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(); + 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(); + 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)); +}