@akairos/typeset-studio 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +91 -95
  2. package/dist/assets/index-3Ko1UCBU.css +1 -0
  3. package/dist/assets/index-C9J0f4Lf.js +317 -0
  4. package/dist/index.html +2 -2
  5. package/dist/lib/adapter.js +6 -7734
  6. package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
  7. package/dist/lib/font-loader-j28mt43t.js +11 -0
  8. package/dist/lib/index-0370qz9e.js +303 -0
  9. package/dist/lib/index-37x76zdn.js +4 -0
  10. package/dist/lib/index-6c43c7h4.js +3880 -0
  11. package/dist/lib/index-t9w3p2p9.js +3366 -0
  12. package/dist/lib/index-zdhqg5f0.js +459 -0
  13. package/dist/lib/index.js +26 -7734
  14. package/dist/lib/render-xxd348m1.js +11 -0
  15. package/package.json +3 -7
  16. package/src/adapter.test.ts +117 -0
  17. package/src/adapter.ts +190 -281
  18. package/src/core/canvas-draw.ts +150 -47
  19. package/src/core/canvas-renderer.test.ts +58 -0
  20. package/src/core/canvas-renderer.ts +2 -1
  21. package/src/core/data-dir.ts +13 -0
  22. package/src/core/engine.ts +86 -56
  23. package/src/core/font-loader.test.ts +20 -0
  24. package/src/core/font-loader.ts +6 -2
  25. package/src/core/image-layout.ts +4 -0
  26. package/src/core/inline-text.ts +6 -4
  27. package/src/core/obstacle-flow.ts +29 -15
  28. package/src/core/page-images.test.ts +90 -0
  29. package/src/core/pretext.ts +4 -10
  30. package/src/core/template.test.ts +38 -1
  31. package/src/core/template.ts +53 -41
  32. package/src/core/types.ts +46 -0
  33. package/src/core/typography.ts +0 -14
  34. package/src/index.ts +6 -3
  35. package/src/server/auth.ts +18 -13
  36. package/src/server/index.ts +11 -16
  37. package/src/server/routes/fonts.ts +1 -1
  38. package/src/server/routes/render.ts +60 -17
  39. package/src/server/routes/slots.test.ts +96 -0
  40. package/src/server/routes/templates.ts +37 -48
  41. package/src/server/routes/upload.ts +2 -1
  42. package/src/web/App.tsx +15 -6
  43. package/src/web/components/CanvasViewport.tsx +56 -42
  44. package/src/web/components/EditorSidebar.tsx +59 -48
  45. package/src/web/components/Header.tsx +1 -1
  46. package/src/web/components/TemplateModal.tsx +25 -28
  47. package/src/web/hooks/image-layout.ts +31 -0
  48. package/src/web/hooks/useLocalFonts.ts +10 -1
  49. package/src/web/hooks/useTypeset.ts +139 -100
  50. package/data/templates/.gitkeep +0 -0
  51. package/data/templates/tpl_1791681833354_iofsk.json +0 -46
  52. package/data/templates/tpl_1791682603199_875ny.json +0 -66
  53. package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
  54. package/data/templates/tpl_longform_paper_sage.json +0 -47
  55. package/data/templates/tpl_longform_vintage_news.json +0 -47
  56. package/data/templates/tpl_newspic_poster_cover.json +0 -64
  57. package/data/templates/tpl_square_quote_card.json +0 -46
  58. package/data/templates/tpl_wechat_cover_editorial.json +0 -64
  59. package/data/templates/tpl_wechat_cover_minimal.json +0 -64
  60. package/data/templates/tpl_wide_tech_banner.json +0 -46
  61. package/dist/assets/index-CZ352D7X.css +0 -1
  62. package/dist/assets/index-Dpnz-F2g.js +0 -317
@@ -1,24 +1,23 @@
1
1
  import { existsSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { Hono } from "hono";
4
- import { cors } from "hono/cors";
5
4
  import { serveStatic } from "hono/bun";
6
- import { authMiddleware, getOrGenerateAdminKey } from "./auth";
5
+ import { authMiddleware, getOrGenerateAdminKey, isLocalRequest } from "./auth";
7
6
  import { renderRouter } from "./routes/render";
8
7
  import { templatesRouter } from "./routes/templates";
9
8
  import { uploadRouter } from "./routes/upload";
10
9
  import { fontsRouter } from "./routes/fonts";
10
+ import { resolveDataDir } from "../core/data-dir";
11
11
 
12
12
  export const app = new Hono();
13
13
 
14
- app.use("*", cors());
15
14
 
16
15
  // Health check (public)
17
16
  app.get("/api/health", (c) => {
18
17
  return c.json({
19
18
  status: "ok",
20
19
  name: "Typeset Studio Server",
21
- version: "0.1.0",
20
+ version: "0.2.2",
22
21
  time: new Date().toISOString(),
23
22
  });
24
23
  });
@@ -36,17 +35,12 @@ app.post("/api/auth/verify", async (c) => {
36
35
 
37
36
  // Get active admin key for seamless local development
38
37
  app.get("/api/auth/current-key", (c) => {
39
- const host = c.req.header("host") || "";
40
- const isLocal =
41
- host.includes("localhost") ||
42
- host.includes("127.0.0.1") ||
43
- host.includes("0.0.0.0") ||
44
- host.includes("[::1]");
38
+ if (!isLocalRequest(c)) return c.json({ ok: false, isLocal: false }, 403);
45
39
  const adminKey = getOrGenerateAdminKey();
46
40
  return c.json({
47
41
  ok: true,
48
42
  key: adminKey,
49
- isLocal,
43
+ isLocal: true,
50
44
  });
51
45
  });
52
46
 
@@ -60,11 +54,11 @@ app.route("/api/upload", uploadRouter);
60
54
  app.route("/api/render", renderRouter);
61
55
 
62
56
  // Static uploads serving
63
- const UPLOAD_DIR = join(process.cwd(), "data", "uploads");
57
+ const UPLOAD_DIR = join(resolveDataDir(), "uploads");
64
58
  if (!existsSync(UPLOAD_DIR)) {
65
59
  mkdirSync(UPLOAD_DIR, { recursive: true });
66
60
  }
67
- app.use("/uploads/*", serveStatic({ root: "./data" }));
61
+ app.use("/uploads/*", serveStatic({ root: resolveDataDir() }));
68
62
 
69
63
  // Static local font serving
70
64
  app.get("/fonts/:filename", async (c) => {
@@ -111,19 +105,20 @@ if (existsSync(DIST_DIR)) {
111
105
  }
112
106
 
113
107
  const PORT = Number(process.env.PORT || 4890);
114
- const adminKey = getOrGenerateAdminKey();
108
+ getOrGenerateAdminKey();
115
109
 
116
110
  console.log(`
117
111
  ┌────────────────────────────────────────────────────────┐
118
112
  │ ⚡ Typeset Studio Server │
119
113
  ├────────────────────────────────────────────────────────┤
120
114
  │ Web URL: http://localhost:${PORT} │
121
- │ Admin Key: ${adminKey} │
122
115
  │ Protected: X-Admin-Key header required │
123
116
  └────────────────────────────────────────────────────────┘
124
117
  `);
125
118
 
126
119
  export default {
120
+ hostname: process.env.TYPESET_HOST || "127.0.0.1",
127
121
  port: PORT,
128
- fetch: app.fetch,
122
+ fetch: (request: Request, server: Bun.Server<undefined>) =>
123
+ app.fetch(request, { peerAddress: server.requestIP(request)?.address }),
129
124
  };
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import { registerServerFonts, scanLocalFonts } from "../../core/font-loader";
2
+ import { registerServerFonts } from "../../core/font-loader";
3
3
 
4
4
  export const fontsRouter = new Hono();
5
5
 
@@ -10,6 +10,12 @@ import {
10
10
  } from "./templates";
11
11
 
12
12
  export const renderRouter = new Hono();
13
+ renderRouter.onError((error, c) => {
14
+ if (error.message.startsWith("模板素材 ")) return c.json({ ok: false, error: error.message }, 422);
15
+ if (error instanceof RangeError) return c.json({ ok: false, error: error.message }, 400);
16
+ console.error("渲染失败", error);
17
+ return c.json({ ok: false, error: "Render failed" }, 500);
18
+ });
13
19
 
14
20
  export interface RenderRequestBody {
15
21
  result?: TypesetResult;
@@ -26,7 +32,16 @@ export interface RenderRequestBody {
26
32
  text?: string;
27
33
  images?: FlowImage[];
28
34
  highlightWords?: string[];
29
- config?: Partial<TypesetConfig>;
35
+ config?: Omit<Partial<TypesetConfig>, "algorithm" | "theme" | "typography"> & {
36
+ algorithm?: Partial<TypesetConfig["algorithm"]>;
37
+ theme?: Partial<TypesetConfig["theme"]>;
38
+ typography?: Partial<TypesetConfig["typography"]>;
39
+ };
40
+ minPages?: number;
41
+ maxPages?: number;
42
+ pageFillRatios?: Record<number, number>;
43
+ templateImageOverrides?: Record<string, string>;
44
+ preserveTemplateStyle?: boolean;
30
45
  }
31
46
 
32
47
  function inferSlot(route?: string, kind?: string, aspectRatio?: string): TemplateSlot | undefined {
@@ -74,6 +89,7 @@ export function resolveTypesetResult(body: RenderRequestBody): {
74
89
  let baseConfig: TypesetConfig | null = null;
75
90
  if (targetKey) {
76
91
  baseConfig = findTemplateByKeyOrName(targetKey);
92
+ if (!baseConfig) throw new RangeError(`Template '${targetKey}' not found`);
77
93
  }
78
94
 
79
95
  // 2. Resolve by slot and account
@@ -99,35 +115,56 @@ export function resolveTypesetResult(body: RenderRequestBody): {
99
115
  baseConfig = getDefaultConfig(isCover ? "wechat-cover-2.35" : "longform-3-4");
100
116
  }
101
117
 
118
+ // 插件保留模板样式;直接 API 调用仍允许显式覆盖配置。
119
+ const overrides = body.preserveTemplateStyle && baseConfig.id ? {} : body.config;
120
+
102
121
  // Merge any config overrides
103
122
  const effectiveConfig: TypesetConfig = {
104
123
  ...baseConfig,
105
- ...(body.config || {}),
124
+ ...(overrides || {}),
106
125
  algorithm: {
107
126
  ...baseConfig.algorithm,
108
- ...(body.config?.algorithm || {}),
127
+ ...(overrides?.algorithm || {}),
109
128
  },
110
129
  theme: {
111
130
  ...baseConfig.theme,
112
- ...(body.config?.theme || {}),
131
+ ...(overrides?.theme || {}),
113
132
  },
114
133
  typography: {
115
134
  ...baseConfig.typography,
116
- ...(body.config?.typography || {}),
135
+ ...(overrides?.typography || {}),
117
136
  },
137
+ chrome: { ...baseConfig.chrome, ...overrides?.chrome },
118
138
  };
119
139
 
120
- const inputImages = body.images;
121
- const resolvedImages = inputImages && inputImages.length > 0
122
- ? (effectiveConfig.images ? applyTemplateImages(inputImages, effectiveConfig.images) : inputImages)
123
- : (effectiveConfig.images || []);
124
-
125
- const result = runTypeset({
126
- markdown,
127
- images: resolvedImages,
128
- config: effectiveConfig,
129
- highlightWords: body.highlightWords || [],
130
- });
140
+ const resolvedImages = applyTemplateImages(body.images || [], effectiveConfig.images, body.templateImageOverrides);
141
+
142
+ // 封面主题先使用目标字号,单页放不下时缩到契约允许的最小字号。
143
+ let result: TypesetResult;
144
+ while (true) {
145
+ try {
146
+ result = runTypeset({
147
+ markdown, images: resolvedImages, config: effectiveConfig,
148
+ highlightWords: body.highlightWords || [],
149
+ minPages: body.minPages, maxPages: body.maxPages, pageFillRatios: body.pageFillRatios,
150
+ });
151
+ break;
152
+ } catch (error) {
153
+ const theme = effectiveConfig.coverTheme;
154
+ if (!theme || body.kind !== "cover" || !(error instanceof Error) ||
155
+ !/exceeds maxPages|cannot fit/.test(error.message)) throw error;
156
+ const title = theme.typography.title;
157
+ if (!Number.isFinite(title.fontSize) || !Number.isFinite(title.minFontSize) || title.minFontSize <= 0) {
158
+ throw new RangeError("Invalid cover theme font size");
159
+ }
160
+ if (title.fontSize <= title.minFontSize) throw error;
161
+ effectiveConfig.coverTheme = {
162
+ ...theme, typography: { ...theme.typography, title: {
163
+ ...title, fontSize: Math.max(title.minFontSize, Math.floor(title.fontSize * 0.9)),
164
+ } },
165
+ };
166
+ }
167
+ }
131
168
 
132
169
  return {
133
170
  templateId: baseConfig.id,
@@ -162,7 +199,7 @@ renderRouter.post("/png", async (c) => {
162
199
  }
163
200
 
164
201
  const pngBuffer = await renderPageToPng(targetPage, result.canvasSpec, result.config, result.pageCount);
165
- return new Response(pngBuffer, {
202
+ return new Response(new Uint8Array(pngBuffer), {
166
203
  status: 200,
167
204
  headers: {
168
205
  "Content-Type": "image/png",
@@ -186,6 +223,12 @@ renderRouter.post("/export", async (c) => {
186
223
  pageNumber: item.pageNumber,
187
224
  dataUrl: `data:image/png;base64,${item.buffer.toString("base64")}`,
188
225
  byteLength: item.buffer.length,
226
+ imageIds: result.pages.find((page) => page.pageNumber === item.pageNumber)!.images.map((image) => image.id),
227
+ bodyImageCount: result.pages.find((page) => page.pageNumber === item.pageNumber)!.images.filter((image) => !image.templateAssetKey).length,
228
+ bodyImageSources: result.pages.find((page) => page.pageNumber === item.pageNumber)!.images.filter((image) => !image.templateAssetKey).map((image) => image.src),
229
+ bodyImageIds: result.pages.find((page) => page.pageNumber === item.pageNumber)!.images.filter((image) => !image.templateAssetKey).map((image) => image.id),
230
+ imageCount: result.pages.find((page) => page.pageNumber === item.pageNumber)!.images.length,
231
+ imageSources: result.pages.find((page) => page.pageNumber === item.pageNumber)!.images.map((image) => image.src),
189
232
  })),
190
233
  });
191
234
  });
@@ -87,3 +87,99 @@ describe("Template Slots & Fallback Compatibility", () => {
87
87
  expect(data.result.pageCount).toBeGreaterThanOrEqual(1);
88
88
  });
89
89
  });
90
+
91
+ // 鉴权、模板覆盖和数据目录共用隔离测试环境。
92
+ describe("Storage and authentication regressions", () => {
93
+ it("rejects forged local Hosts and never returns a remote key", async () => {
94
+ for (const host of ["localhost.attacker.example", "localhost", "127.0.0.1"]) {
95
+ const response = await app.request("http://remote.example/api/templates", { headers: { host } });
96
+ expect(response.status).toBe(401);
97
+ const keyResponse = await app.request("http://remote.example/api/auth/current-key", { headers: { host } });
98
+ expect(keyResponse.status).toBe(403);
99
+ expect(await keyResponse.text()).not.toContain(adminKey);
100
+ }
101
+ const untrustedOrigin = await app.request("http://localhost/api/auth/current-key", {
102
+ headers: { host: "localhost", origin: "https://attacker.example" },
103
+ }, { peerAddress: "127.0.0.1" });
104
+ expect(untrustedOrigin.status).toBe(403);
105
+ const local = await app.request("http://localhost/api/auth/current-key", {
106
+ headers: { host: "localhost", origin: "http://localhost:5173" },
107
+ }, { peerAddress: "127.0.0.1" });
108
+ expect(local.status).toBe(200);
109
+ });
110
+
111
+ it("rejects traversal for template CRUD and resolver paths", async () => {
112
+ const template = { ...(await (await app.request("/api/templates/tpl_wechat_cover_minimal", { headers: authHeaders })).json()).template, id: "../escaped" };
113
+ const save = await app.request("/api/templates", {
114
+ method: "POST", headers: authHeaders, body: JSON.stringify(template),
115
+ });
116
+ expect(save.status).toBe(400);
117
+ for (const method of ["GET", "DELETE"]) {
118
+ const response = await app.request("/api/templates/..%2Fescaped", { method, headers: authHeaders });
119
+ expect(response.status).toBe(400);
120
+ }
121
+ const resolve = await app.request("/api/templates/resolve/..%2Fescaped", { headers: authHeaders });
122
+ expect(resolve.status).toBe(404);
123
+ });
124
+
125
+ it("uses saved builtin overrides and does not recreate deleted templates", async () => {
126
+ const id = "tpl_longform_vintage_news";
127
+ const original = (await (await app.request(`/api/templates/${id}`, { headers: authHeaders })).json()).template;
128
+ try {
129
+ const save = await app.request("/api/templates", {
130
+ method: "POST", headers: authHeaders,
131
+ body: JSON.stringify({ ...original, footerText: "磁盘覆盖" }),
132
+ });
133
+ expect(save.status).toBe(200);
134
+ const resolved = await (await app.request(`/api/templates/resolve/${id}`, { headers: authHeaders })).json();
135
+ expect(resolved.template.footerText).toBe("磁盘覆盖");
136
+ const deleted = await app.request(`/api/templates/${id}`, { method: "DELETE", headers: authHeaders });
137
+ expect(deleted.status).toBe(200);
138
+ expect((await app.request(`/api/templates/${id}`, { headers: authHeaders })).status).toBe(404);
139
+ expect((await app.request(`/api/templates/resolve/${id}`, { headers: authHeaders })).status).toBe(404);
140
+ } finally {
141
+ await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify(original) });
142
+ }
143
+ });
144
+
145
+ it("serves uploaded files from the configured data directory", async () => {
146
+ const data = new FormData();
147
+ data.append("file", new File(["uploaded-image-bytes"], "test.png", { type: "image/png" }));
148
+ const response = await app.request("/api/upload", { method: "POST", headers: { "x-admin-key": adminKey }, body: data });
149
+ expect(response.status).toBe(200);
150
+ const uploaded = await response.json();
151
+ const fetched = await app.request(uploaded.url);
152
+ expect(fetched.status).toBe(200);
153
+ expect(await fetched.text()).toBe("uploaded-image-bytes");
154
+ });
155
+ });
156
+
157
+ describe("Fixed asset rendering API", () => {
158
+ it("preserves template styles, validates identifiers, and fails for missing fixed files", async () => {
159
+ const { getDefaultConfig } = await import("../../core/engine");
160
+ const id = "fixed_asset_api_test";
161
+ const template = { ...getDefaultConfig(), id, images: [
162
+ { id: "bg", src: "/missing-background.png", alt: "", isAbsolute: true, templateAssetKey: "background", zIndex: 0 },
163
+ ] };
164
+ try {
165
+ expect((await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify(template) })).status).toBe(200);
166
+ const response = await app.request("/api/render", { method: "POST", headers: authHeaders, body: JSON.stringify({
167
+ template: id, markdown: "正文", preserveTemplateStyle: true,
168
+ config: { theme: { bgColor: "#123456" }, typography: { bodyFont: "Other Font" } },
169
+ }) });
170
+ const rendered = await response.json();
171
+ expect(rendered.result.config.theme.bgColor).toBe(template.theme.bgColor);
172
+ expect(rendered.result.config.typography.bodyFont).toBe(template.typography.bodyFont);
173
+ expect(rendered.result.pages[0].images[0].templateAssetKey).toBe("background");
174
+ const missing = await app.request("/api/render/export", { method: "POST", headers: authHeaders, body: JSON.stringify({ template: id, markdown: "正文" }) });
175
+ expect(missing.status).toBe(422);
176
+ expect((await missing.json()).error).toContain("background");
177
+ const unknown = await app.request("/api/render", { method: "POST", headers: authHeaders, body: JSON.stringify({ template: id, templateImageOverrides: { unknown: "new.png" } }) });
178
+ expect(unknown.status).toBe(400);
179
+ const duplicate = await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify({ ...template, images: [...template.images, { ...template.images[0], id: "bg2" }] }) });
180
+ expect(duplicate.status).toBe(400);
181
+ } finally {
182
+ await app.request(`/api/templates/${id}`, { method: "DELETE", headers: authHeaders });
183
+ }
184
+ });
185
+ });
@@ -1,23 +1,12 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
- import { dirname, join, resolve } from "path";
3
- import { fileURLToPath } from "url";
2
+ import { dirname, join } from "path";
4
3
  import { Hono } from "hono";
5
4
  import { getDefaultConfig } from "../../core/engine";
6
5
  import { TemplateSlot, TypesetConfig } from "../../core/types";
6
+ import { resolveDataDir } from "../../core/data-dir";
7
+ import { validateTemplateAssets } from "../../core/template";
7
8
 
8
- export function resolveDataDir(): string {
9
- if (process.env.TYPESET_DATA_DIR) {
10
- return process.env.TYPESET_DATA_DIR;
11
- }
12
- const currentDir = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
13
- const candidateLib = resolve(currentDir, "../../data");
14
- if (existsSync(candidateLib)) return candidateLib;
15
- const candidateSrc = resolve(currentDir, "../../../data");
16
- if (existsSync(candidateSrc)) return candidateSrc;
17
- const candidateCwd = resolve(process.cwd(), "data");
18
- if (existsSync(candidateCwd)) return candidateCwd;
19
- return candidateLib;
20
- }
9
+ export { resolveDataDir } from "../../core/data-dir";
21
10
 
22
11
  const TEMPLATES_DIR = join(resolveDataDir(), "templates");
23
12
 
@@ -29,7 +18,11 @@ function ensureTemplatesDir(): void {
29
18
  // Ignore write errors in read-only environments
30
19
  }
31
20
  }
32
- seedDefaultTemplates();
21
+ const seedMarker = join(TEMPLATES_DIR, ".initialized");
22
+ if (!existsSync(seedMarker)) {
23
+ seedDefaultTemplates();
24
+ try { writeFileSync(seedMarker, "1"); } catch { /* 只读安装目录保留读取能力。 */ }
25
+ }
33
26
  }
34
27
 
35
28
  export const BUILTIN_PRESETS: TypesetConfig[] = [
@@ -383,7 +376,7 @@ export function getSlotDefaultTemplateId(slot: TemplateSlot, account = "default"
383
376
  }
384
377
 
385
378
  export function setSlotDefaultTemplateId(slot: TemplateSlot, templateId: string, account = "default"): void {
386
- const dir = join(process.cwd(), "data");
379
+ const dir = dirname(DEFAULT_SETTINGS_PATH);
387
380
  if (!existsSync(dir)) {
388
381
  mkdirSync(dir, { recursive: true });
389
382
  }
@@ -420,7 +413,7 @@ export function getDefaultTemplateId(): string | null {
420
413
  }
421
414
 
422
415
  export function setDefaultTemplateId(id: string): void {
423
- const dir = join(process.cwd(), "data");
416
+ const dir = dirname(DEFAULT_SETTINGS_PATH);
424
417
  if (!existsSync(dir)) {
425
418
  mkdirSync(dir, { recursive: true });
426
419
  }
@@ -439,21 +432,13 @@ export function setDefaultTemplateId(id: string): void {
439
432
  export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
440
433
  const lowerKey = key.trim().toLowerCase();
441
434
 
442
- // 1. Direct match in built-in memory presets
443
- const builtin = BUILTIN_PRESETS.find(
444
- (p) => p.id?.toLowerCase() === lowerKey || p.name.toLowerCase() === lowerKey,
445
- );
446
- if (builtin) return builtin;
447
-
448
- // 2. Partial match in built-in memory presets
449
- const builtinPartial = BUILTIN_PRESETS.find((p) => p.name.toLowerCase().includes(lowerKey));
450
- if (builtinPartial) return builtinPartial;
451
-
452
- // 3. Scan disk if templates directory exists
453
- if (!existsSync(TEMPLATES_DIR)) return null;
435
+ if (!lowerKey) return null;
436
+ ensureTemplatesDir();
437
+ if (!existsSync(TEMPLATES_DIR)) return BUILTIN_PRESETS.find((p) =>
438
+ p.id?.toLowerCase() === lowerKey || p.name.toLowerCase() === lowerKey) ?? null;
454
439
 
455
- const directPath = join(TEMPLATES_DIR, `${key}.json`);
456
- if (existsSync(directPath)) {
440
+ const directPath = templatePath(key);
441
+ if (directPath && existsSync(directPath)) {
457
442
  try {
458
443
  return JSON.parse(readFileSync(directPath, "utf-8"));
459
444
  } catch {
@@ -464,6 +449,7 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
464
449
  // Scan all templates on disk
465
450
  const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".json"));
466
451
 
452
+ const templates: TypesetConfig[] = [];
467
453
  for (const file of files) {
468
454
  try {
469
455
  const content = readFileSync(join(TEMPLATES_DIR, file), "utf-8");
@@ -471,25 +457,19 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
471
457
  if (tpl.id?.toLowerCase() === lowerKey || tpl.name.toLowerCase() === lowerKey) {
472
458
  return tpl;
473
459
  }
460
+ templates.push(tpl);
474
461
  } catch {
475
462
  // ignore
476
463
  }
477
464
  }
478
465
 
479
- // Partial match by name
480
- for (const file of files) {
481
- try {
482
- const content = readFileSync(join(TEMPLATES_DIR, file), "utf-8");
483
- const tpl = JSON.parse(content) as TypesetConfig;
484
- if (tpl.name.toLowerCase().includes(lowerKey)) {
485
- return tpl;
486
- }
487
- } catch {
488
- // ignore
489
- }
490
- }
466
+ // 精确匹配优先,名称模糊匹配复用已读取的模板。
467
+ return templates.find((tpl) => tpl.name.toLowerCase().includes(lowerKey)) ?? null;
468
+ }
491
469
 
492
- return null;
470
+ // 模板标识只用作文件名,名称查询继续支持中文和模糊匹配。
471
+ function templatePath(id: string): string | null {
472
+ return /^[a-zA-Z0-9_-]+$/.test(id) ? join(TEMPLATES_DIR, `${id}.json`) : null;
493
473
  }
494
474
 
495
475
  export const templatesRouter = new Hono();
@@ -609,7 +589,8 @@ templatesRouter.get("/resolve/:key", (c) => {
609
589
  templatesRouter.get("/:id", (c) => {
610
590
  ensureTemplatesDir();
611
591
  const id = c.req.param("id");
612
- const filePath = join(TEMPLATES_DIR, `${id}.json`);
592
+ const filePath = templatePath(id);
593
+ if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
613
594
 
614
595
  if (!existsSync(filePath)) {
615
596
  return c.json({ ok: false, error: "Template not found" }, 404);
@@ -628,13 +609,20 @@ templatesRouter.post("/", async (c) => {
628
609
  return c.json({ ok: false, error: "Template name is required" }, 400);
629
610
  }
630
611
 
612
+ try {
613
+ validateTemplateAssets(body.images);
614
+ } catch (error) {
615
+ return c.json({ ok: false, error: error instanceof Error ? error.message : "无效的模板素材" }, 400);
616
+ }
617
+
631
618
  const id = body.id || `tpl_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
632
619
  const templateToSave: TypesetConfig = {
633
620
  ...body,
634
621
  id,
635
622
  };
636
623
 
637
- const filePath = join(TEMPLATES_DIR, `${id}.json`);
624
+ const filePath = templatePath(id);
625
+ if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
638
626
  writeFileSync(filePath, JSON.stringify(templateToSave, null, 2), "utf-8");
639
627
 
640
628
  return c.json({ ok: true, template: templateToSave });
@@ -644,7 +632,8 @@ templatesRouter.post("/", async (c) => {
644
632
  templatesRouter.delete("/:id", (c) => {
645
633
  ensureTemplatesDir();
646
634
  const id = c.req.param("id");
647
- const filePath = join(TEMPLATES_DIR, `${id}.json`);
635
+ const filePath = templatePath(id);
636
+ if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
648
637
 
649
638
  if (existsSync(filePath)) {
650
639
  unlinkSync(filePath);
@@ -1,8 +1,9 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { Hono } from "hono";
4
+ import { resolveDataDir } from "../../core/data-dir";
4
5
 
5
- const UPLOAD_DIR = join(process.cwd(), "data", "uploads");
6
+ const UPLOAD_DIR = join(resolveDataDir(), "uploads");
6
7
 
7
8
  function ensureUploadDir(): void {
8
9
  if (!existsSync(UPLOAD_DIR)) {
package/src/web/App.tsx CHANGED
@@ -1,5 +1,4 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
- import { toPng } from "html-to-image";
3
2
  import { AuthModal } from "./components/AuthModal";
4
3
  import { CanvasViewport } from "./components/CanvasViewport";
5
4
  import { EditorSidebar } from "./components/EditorSidebar";
@@ -10,7 +9,9 @@ import { useAuth } from "./hooks/useAuth";
10
9
  import { useTypeset } from "./hooks/useTypeset";
11
10
 
12
11
  export const App: React.FC = () => {
12
+ const { adminKey, setAdminKey, isVerified } = useAuth();
13
13
  const {
14
+ error: templateError,
14
15
  aspectRatio,
15
16
  switchAspectRatio,
16
17
  config,
@@ -19,6 +20,9 @@ export const App: React.FC = () => {
19
20
  images,
20
21
  addImage,
21
22
  updateImage,
23
+ toggleImageBackground,
24
+ resetImageLayout,
25
+ backgroundImageIds,
22
26
  removeImage,
23
27
  moveImage,
24
28
  highlightWords,
@@ -30,9 +34,7 @@ export const App: React.FC = () => {
30
34
  updateSafeArea,
31
35
  loadTemplate,
32
36
  setConfig,
33
- } = useTypeset();
34
-
35
- const { adminKey, setAdminKey, isVerified } = useAuth();
37
+ } = useTypeset(adminKey, isVerified);
36
38
 
37
39
  // Default to Light Mode per user requirement
38
40
  const [themeMode, setThemeMode] = useState<"light" | "dark">(() => {
@@ -180,6 +182,9 @@ export const App: React.FC = () => {
180
182
  setIsExporting(true);
181
183
  try {
182
184
  const canvasElements = document.querySelectorAll<HTMLCanvasElement>("canvas.typeset-page");
185
+ if ([...canvasElements].some((canvas) => canvas.dataset.renderState !== "ready")) {
186
+ throw new Error("画布尚未完成绘制,请检查素材或稍后再导出");
187
+ }
183
188
  if (canvasElements.length > 0) {
184
189
  let exportSucceeded = true;
185
190
  for (let i = 0; i < canvasElements.length; i++) {
@@ -197,7 +202,6 @@ export const App: React.FC = () => {
197
202
  }
198
203
  }
199
204
  if (exportSucceeded) {
200
- setIsExporting(false);
201
205
  return;
202
206
  }
203
207
  }
@@ -231,7 +235,7 @@ export const App: React.FC = () => {
231
235
  }
232
236
  } catch (err) {
233
237
  console.error("Export failed:", err);
234
- alert("导出失败,请检查网络或后端服务连接");
238
+ alert(err instanceof Error ? err.message : "导出失败,请检查网络或后端服务连接");
235
239
  } finally {
236
240
  setIsExporting(false);
237
241
  }
@@ -257,6 +261,8 @@ export const App: React.FC = () => {
257
261
  onToggleThemeMode={toggleThemeMode}
258
262
  />
259
263
 
264
+ {templateError && <div role="alert" className="px-4 py-2 text-sm text-red-600">{templateError}</div>}
265
+
260
266
  {/* Main Workspace (3-column layout) */}
261
267
  <div className="flex flex-1 min-h-0 overflow-hidden relative">
262
268
  {/* Left: Content Editor Sidebar */}
@@ -265,6 +271,9 @@ export const App: React.FC = () => {
265
271
  onMarkdownChange={setMarkdown}
266
272
  images={images}
267
273
  onAddImage={addImage}
274
+ onToggleBackground={toggleImageBackground}
275
+ onResetImageLayout={resetImageLayout}
276
+ backgroundImageIds={backgroundImageIds}
268
277
  onUpdateImage={updateImage}
269
278
  onRemoveImage={removeImage}
270
279
  onMoveImage={moveImage}