@akairos/typeset-studio 0.2.2 → 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 (59) hide show
  1. package/README.md +90 -93
  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 -7582
  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 -7582
  14. package/dist/lib/render-xxd348m1.js +11 -0
  15. package/package.json +2 -3
  16. package/src/adapter.test.ts +117 -0
  17. package/src/adapter.ts +121 -30
  18. package/src/core/canvas-draw.ts +141 -38
  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 +74 -36
  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 -2
  30. package/src/core/template.test.ts +38 -1
  31. package/src/core/template.ts +52 -40
  32. package/src/core/types.ts +46 -0
  33. package/src/index.ts +4 -1
  34. package/src/server/auth.ts +18 -13
  35. package/src/server/index.ts +10 -15
  36. package/src/server/routes/render.ts +60 -17
  37. package/src/server/routes/slots.test.ts +96 -0
  38. package/src/server/routes/templates.ts +34 -35
  39. package/src/server/routes/upload.ts +2 -1
  40. package/src/web/App.tsx +15 -4
  41. package/src/web/components/CanvasViewport.tsx +56 -42
  42. package/src/web/components/EditorSidebar.tsx +35 -0
  43. package/src/web/components/TemplateModal.tsx +22 -14
  44. package/src/web/hooks/image-layout.ts +31 -0
  45. package/src/web/hooks/useLocalFonts.ts +10 -1
  46. package/src/web/hooks/useTypeset.ts +119 -32
  47. package/data/templates/.gitkeep +0 -0
  48. package/data/templates/tpl_1791681833354_iofsk.json +0 -46
  49. package/data/templates/tpl_1791682603199_875ny.json +0 -66
  50. package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
  51. package/data/templates/tpl_longform_paper_sage.json +0 -47
  52. package/data/templates/tpl_longform_vintage_news.json +0 -47
  53. package/data/templates/tpl_newspic_poster_cover.json +0 -64
  54. package/data/templates/tpl_square_quote_card.json +0 -46
  55. package/data/templates/tpl_wechat_cover_editorial.json +0 -64
  56. package/data/templates/tpl_wechat_cover_minimal.json +0 -64
  57. package/data/templates/tpl_wide_tech_banner.json +0 -46
  58. package/dist/assets/index-CZ352D7X.css +0 -1
  59. package/dist/assets/index-DDeEGeHD.js +0 -317
@@ -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 {
@@ -482,6 +467,11 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
482
467
  return templates.find((tpl) => tpl.name.toLowerCase().includes(lowerKey)) ?? null;
483
468
  }
484
469
 
470
+ // 模板标识只用作文件名,名称查询继续支持中文和模糊匹配。
471
+ function templatePath(id: string): string | null {
472
+ return /^[a-zA-Z0-9_-]+$/.test(id) ? join(TEMPLATES_DIR, `${id}.json`) : null;
473
+ }
474
+
485
475
  export const templatesRouter = new Hono();
486
476
 
487
477
  // List all templates
@@ -599,7 +589,8 @@ templatesRouter.get("/resolve/:key", (c) => {
599
589
  templatesRouter.get("/:id", (c) => {
600
590
  ensureTemplatesDir();
601
591
  const id = c.req.param("id");
602
- 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);
603
594
 
604
595
  if (!existsSync(filePath)) {
605
596
  return c.json({ ok: false, error: "Template not found" }, 404);
@@ -618,13 +609,20 @@ templatesRouter.post("/", async (c) => {
618
609
  return c.json({ ok: false, error: "Template name is required" }, 400);
619
610
  }
620
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
+
621
618
  const id = body.id || `tpl_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
622
619
  const templateToSave: TypesetConfig = {
623
620
  ...body,
624
621
  id,
625
622
  };
626
623
 
627
- 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);
628
626
  writeFileSync(filePath, JSON.stringify(templateToSave, null, 2), "utf-8");
629
627
 
630
628
  return c.json({ ok: true, template: templateToSave });
@@ -634,7 +632,8 @@ templatesRouter.post("/", async (c) => {
634
632
  templatesRouter.delete("/:id", (c) => {
635
633
  ensureTemplatesDir();
636
634
  const id = c.req.param("id");
637
- 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);
638
637
 
639
638
  if (existsSync(filePath)) {
640
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
@@ -9,7 +9,9 @@ import { useAuth } from "./hooks/useAuth";
9
9
  import { useTypeset } from "./hooks/useTypeset";
10
10
 
11
11
  export const App: React.FC = () => {
12
+ const { adminKey, setAdminKey, isVerified } = useAuth();
12
13
  const {
14
+ error: templateError,
13
15
  aspectRatio,
14
16
  switchAspectRatio,
15
17
  config,
@@ -18,6 +20,9 @@ export const App: React.FC = () => {
18
20
  images,
19
21
  addImage,
20
22
  updateImage,
23
+ toggleImageBackground,
24
+ resetImageLayout,
25
+ backgroundImageIds,
21
26
  removeImage,
22
27
  moveImage,
23
28
  highlightWords,
@@ -29,9 +34,7 @@ export const App: React.FC = () => {
29
34
  updateSafeArea,
30
35
  loadTemplate,
31
36
  setConfig,
32
- } = useTypeset();
33
-
34
- const { adminKey, setAdminKey, isVerified } = useAuth();
37
+ } = useTypeset(adminKey, isVerified);
35
38
 
36
39
  // Default to Light Mode per user requirement
37
40
  const [themeMode, setThemeMode] = useState<"light" | "dark">(() => {
@@ -179,6 +182,9 @@ export const App: React.FC = () => {
179
182
  setIsExporting(true);
180
183
  try {
181
184
  const canvasElements = document.querySelectorAll<HTMLCanvasElement>("canvas.typeset-page");
185
+ if ([...canvasElements].some((canvas) => canvas.dataset.renderState !== "ready")) {
186
+ throw new Error("画布尚未完成绘制,请检查素材或稍后再导出");
187
+ }
182
188
  if (canvasElements.length > 0) {
183
189
  let exportSucceeded = true;
184
190
  for (let i = 0; i < canvasElements.length; i++) {
@@ -229,7 +235,7 @@ export const App: React.FC = () => {
229
235
  }
230
236
  } catch (err) {
231
237
  console.error("Export failed:", err);
232
- alert("导出失败,请检查网络或后端服务连接");
238
+ alert(err instanceof Error ? err.message : "导出失败,请检查网络或后端服务连接");
233
239
  } finally {
234
240
  setIsExporting(false);
235
241
  }
@@ -255,6 +261,8 @@ export const App: React.FC = () => {
255
261
  onToggleThemeMode={toggleThemeMode}
256
262
  />
257
263
 
264
+ {templateError && <div role="alert" className="px-4 py-2 text-sm text-red-600">{templateError}</div>}
265
+
258
266
  {/* Main Workspace (3-column layout) */}
259
267
  <div className="flex flex-1 min-h-0 overflow-hidden relative">
260
268
  {/* Left: Content Editor Sidebar */}
@@ -263,6 +271,9 @@ export const App: React.FC = () => {
263
271
  onMarkdownChange={setMarkdown}
264
272
  images={images}
265
273
  onAddImage={addImage}
274
+ onToggleBackground={toggleImageBackground}
275
+ onResetImageLayout={resetImageLayout}
276
+ backgroundImageIds={backgroundImageIds}
266
277
  onUpdateImage={updateImage}
267
278
  onRemoveImage={removeImage}
268
279
  onMoveImage={moveImage}
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef } from "react";
1
+ import React, { useEffect, useRef, useState } from "react";
2
2
  import { LayoutGrid, Rows, ZoomIn } from "lucide-react";
3
3
  import { drawPageToContext } from "../../core/canvas-draw";
4
4
  import { CanvasSpec, FlowPage, TypesetConfig, TypesetResult } from "../../core/types";
@@ -19,6 +19,7 @@ const CanvasCard: React.FC<CanvasCardProps> = ({
19
19
  zoom,
20
20
  }) => {
21
21
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
22
+ const [paintError, setPaintError] = useState<string | null>(null);
22
23
 
23
24
  useEffect(() => {
24
25
  const canvas = canvasRef.current;
@@ -26,69 +27,82 @@ const CanvasCard: React.FC<CanvasCardProps> = ({
26
27
  const ctx = canvas.getContext("2d");
27
28
  if (!ctx) return;
28
29
 
30
+ canvas.dataset.renderState = "loading";
31
+ setPaintError(null);
29
32
  let cancelled = false;
33
+ const controller = new AbortController();
30
34
 
31
35
  const getImage = (src: string) => {
32
36
  if (!src) return Promise.resolve(null);
33
37
  return new Promise((resolve) => {
34
38
  const img = new Image();
35
- img.crossOrigin = "anonymous";
36
- img.onload = () => {
37
- if (!cancelled) resolve(img);
39
+ let fallback: HTMLImageElement | undefined;
40
+ const finish = (image: HTMLImageElement | null) => {
41
+ controller.signal.removeEventListener("abort", abort);
42
+ img.onload = img.onerror = null;
43
+ if (fallback) fallback.onload = fallback.onerror = null;
44
+ resolve(image);
38
45
  };
46
+ const abort = () => finish(null);
47
+ if (controller.signal.aborted) { finish(null); return; }
48
+ controller.signal.addEventListener("abort", abort, { once: true });
49
+ img.crossOrigin = "anonymous";
50
+ img.onload = () => finish(img);
39
51
  img.onerror = () => {
40
- const img2 = new Image();
41
- img2.onload = () => {
42
- if (!cancelled) resolve(img2);
43
- };
44
- img2.onerror = () => {
45
- if (!cancelled) resolve(null);
46
- };
47
- img2.src = src;
52
+ fallback = new Image();
53
+ fallback.onload = () => finish(fallback!);
54
+ fallback.onerror = () => finish(null);
55
+ fallback.src = src;
48
56
  };
49
57
  img.src = src;
50
58
  });
51
59
  };
52
60
 
53
- const paint = () => {
54
- if (cancelled || !canvas) return;
55
- drawPageToContext(ctx, {
56
- page,
57
- spec,
58
- config,
59
- totalPageCount,
60
- getImage,
61
- isServer: false,
61
+ // 每次绘制使用独立缓冲画布,只提交当前代的完整结果。
62
+ const paint = async () => {
63
+ const buffer = document.createElement("canvas");
64
+ buffer.width = spec.width;
65
+ buffer.height = spec.height;
66
+ const bufferContext = buffer.getContext("2d");
67
+ if (!bufferContext) return;
68
+ await drawPageToContext(bufferContext, {
69
+ page, spec, config, totalPageCount, getImage, isServer: false,
62
70
  });
71
+ if (!cancelled) {
72
+ ctx.clearRect(0, 0, spec.width, spec.height);
73
+ ctx.drawImage(buffer, 0, 0);
74
+ canvas.dataset.renderState = "ready";
75
+ }
63
76
  };
64
-
65
- paint();
66
-
67
- // Trigger re-render as soon as external web fonts (Google Fonts, Wenkai) finish loading
68
- if (typeof document !== "undefined" && (document as any).fonts) {
69
- (document as any).fonts.ready.then(() => {
70
- paint();
71
- });
72
- }
77
+ paint().catch((error) => {
78
+ if (cancelled) return;
79
+ canvas.dataset.renderState = "error";
80
+ ctx.clearRect(0, 0, spec.width, spec.height);
81
+ setPaintError(error instanceof Error ? error.message : "画布绘制失败");
82
+ });
73
83
 
74
84
  return () => {
75
85
  cancelled = true;
86
+ controller.abort();
76
87
  };
77
88
  }, [page, spec, config, totalPageCount]);
78
89
 
79
90
  return (
80
- <canvas
81
- id={`typeset-page-${page.pageNumber}`}
82
- ref={canvasRef}
83
- width={spec.width}
84
- height={spec.height}
85
- className="typeset-page absolute top-0 left-0 border border-[#e0e0e0] dark:border-[#222222] shadow-sm select-none"
86
- style={{
87
- width: spec.width * zoom,
88
- height: spec.height * zoom,
89
- backgroundColor: config.theme.bgColor,
90
- }}
91
- />
91
+ <>
92
+ <canvas
93
+ id={`typeset-page-${page.pageNumber}`}
94
+ ref={canvasRef}
95
+ width={spec.width}
96
+ height={spec.height}
97
+ className="typeset-page absolute top-0 left-0 border border-[#e0e0e0] dark:border-[#222222] shadow-sm select-none"
98
+ style={{
99
+ width: spec.width * zoom,
100
+ height: spec.height * zoom,
101
+ backgroundColor: config.theme.bgColor,
102
+ }}
103
+ />
104
+ {paintError && <div role="alert" className="absolute inset-0 flex items-center justify-center bg-white/90 p-4 text-sm text-red-600">{paintError}</div>}
105
+ </>
92
106
  );
93
107
  };
94
108
 
@@ -28,6 +28,9 @@ interface EditorSidebarProps {
28
28
  highlightWords: string[];
29
29
  onHighlightWordsChange: (words: string[]) => void;
30
30
  adminKey: string;
31
+ onToggleBackground: (id: string, enabled: boolean) => void;
32
+ onResetImageLayout: (id: string) => void;
33
+ backgroundImageIds: string[];
31
34
  }
32
35
 
33
36
  export const EditorSidebar: React.FC<EditorSidebarProps> = ({
@@ -35,6 +38,9 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
35
38
  onMarkdownChange,
36
39
  images,
37
40
  onAddImage,
41
+ onToggleBackground,
42
+ onResetImageLayout,
43
+ backgroundImageIds,
38
44
  onUpdateImage,
39
45
  onRemoveImage,
40
46
  onMoveImage,
@@ -342,6 +348,35 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
342
348
  </button>
343
349
  </div>
344
350
 
351
+ <div className="flex flex-col gap-2 text-[11px]">
352
+ <label className="flex items-center gap-2">
353
+ <input type="checkbox" checked={img.templateAssetKey !== undefined} onChange={(event) => onUpdateImage(img.id, {
354
+ templateAssetKey: event.target.checked ? `asset_${img.id.replace(/[^a-zA-Z0-9_-]/g, "_")}` : undefined,
355
+ repeatEveryPage: false,
356
+ })} />
357
+ 固定模板素材
358
+ </label>
359
+ {img.templateAssetKey !== undefined && <>
360
+ <label className="flex flex-col gap-1">素材标识
361
+ <input aria-label="素材标识" value={img.templateAssetKey} onChange={(event) => onUpdateImage(img.id, { templateAssetKey: event.target.value })}
362
+ className="w-full bg-white dark:bg-[#181818] border border-[#d4d4d4] dark:border-[#262626] rounded px-1.5 py-1" />
363
+ </label>
364
+ {images.some((other) => other.id !== img.id && other.templateAssetKey === img.templateAssetKey) &&
365
+ <span role="alert" className="text-red-600">素材标识不能重复</span>}
366
+ <label className="flex items-center gap-2">
367
+ <input type="checkbox" disabled={!img.isAbsolute} checked={Boolean(img.isAbsolute && img.repeatEveryPage)} onChange={(event) => onUpdateImage(img.id, { repeatEveryPage: event.target.checked })} />
368
+ 每页重复
369
+ </label>
370
+ {!img.isAbsolute && <span className="text-[#888888]">每页重复仅支持自由图层,避免阻塞文字排版。</span>}
371
+ <span className="text-[#888888]">默认保留此图,仅按素材标识明确替换。</span>
372
+ </>}
373
+ {img.isAbsolute && <label className="flex items-center gap-2">
374
+ <input type="checkbox" checked={backgroundImageIds.includes(img.id)} onChange={(event) => onToggleBackground(img.id, event.target.checked)} />
375
+ 设为整页背景
376
+ </label>}
377
+ <button type="button" onClick={() => onResetImageLayout(img.id)} title="恢复上传或应用模板时的尺寸、位置和图层设置" className="rounded border border-[#d4d4d4] dark:border-[#333333] px-2 py-1.5 hover:bg-[#eeeeee] dark:hover:bg-[#222222]">重置布局</button>
378
+ </div>
379
+
345
380
  {!img.isAbsolute ? (
346
381
  <>
347
382
  {/* Flow Settings */}
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import { Check, FolderPlus, Trash2, X, Sparkles, Star } from "lucide-react";
3
+ import { validateTemplateAssets } from "../../core/template";
3
4
  import { TemplateSlot, TypesetConfig } from "../../core/types";
4
5
 
5
6
  interface TemplateModalProps {
@@ -13,6 +14,8 @@ interface TemplateModalProps {
13
14
  interface SlotsResponse {
14
15
  ok: boolean;
15
16
  account: string;
17
+ message?: string;
18
+ error?: string;
16
19
  slots: {
17
20
  article_cover?: { id: string; template?: TypesetConfig };
18
21
  newspic_cover?: { id: string; template?: TypesetConfig };
@@ -41,13 +44,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
41
44
 
42
45
  const fetchSlots = async (acc = account) => {
43
46
  try {
44
- const res = await fetch(`/api/templates/slots?account=${encodeURIComponent(acc)}`);
47
+ const res = await fetch(`/api/templates/slots?account=${encodeURIComponent(acc)}`, { headers: { "x-admin-key": adminKey } });
45
48
  const data: SlotsResponse = await res.json();
49
+ if (!res.ok || !data.ok) throw new Error(data.message || data.error || "获取槽位失败");
46
50
  if (data.ok && data.slots) {
47
51
  setSlotsData(data.slots);
48
52
  }
49
- } catch {
50
- // ignore
53
+ } catch (err) {
54
+ setError(err instanceof Error ? err.message : "模板请求失败");
51
55
  }
52
56
  };
53
57
 
@@ -62,11 +66,12 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
62
66
  body: JSON.stringify({ slot, templateId, account }),
63
67
  });
64
68
  const data = await res.json();
69
+ if (!res.ok || !data.ok) throw new Error(data.message || data.error || "设置默认模板失败");
65
70
  if (data.ok) {
66
71
  await fetchSlots(account);
67
72
  }
68
- } catch {
69
- // ignore
73
+ } catch (err) {
74
+ setError(err instanceof Error ? err.message : "模板请求失败");
70
75
  }
71
76
  };
72
77
 
@@ -78,7 +83,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
78
83
  headers: adminKey ? { "x-admin-key": adminKey } : {},
79
84
  });
80
85
  const data = await res.json();
81
- if (data.ok && Array.isArray(data.templates)) {
86
+ if (res.ok && data.ok && Array.isArray(data.templates)) {
82
87
  setTemplates(data.templates);
83
88
  } else {
84
89
  setError(data.message || "获取模板失败,请检查密钥");
@@ -97,12 +102,13 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
97
102
  setTemplateName(currentConfig.name ? `${currentConfig.name} 副本` : "我的自定义模板");
98
103
  setSaveSlot(currentConfig.aspectRatio === "wechat-cover-2.35" ? "article_cover" : "newspic_content");
99
104
  }
100
- }, [isOpen, account]);
105
+ }, [isOpen, account, adminKey]);
101
106
 
102
107
  const handleSave = async () => {
103
108
  if (!templateName.trim()) return;
104
109
  setIsSaving(true);
105
110
  try {
111
+ validateTemplateAssets(currentConfig.images);
106
112
  const tplToSave: TypesetConfig = {
107
113
  ...currentConfig,
108
114
  id: undefined, // auto generate
@@ -120,14 +126,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
120
126
  });
121
127
 
122
128
  const data = await res.json();
123
- if (data.ok) {
129
+ if (res.ok && data.ok) {
124
130
  await fetchTemplates();
125
131
  await fetchSlots(account);
126
132
  } else {
127
- setError(data.message || "保存模板失败");
133
+ setError(data.message || data.error || "保存模板失败");
128
134
  }
129
- } catch {
130
- setError("保存模板请求异常");
135
+ } catch (err) {
136
+ setError(err instanceof Error ? err.message : "保存模板请求异常");
131
137
  } finally {
132
138
  setIsSaving(false);
133
139
  }
@@ -135,14 +141,16 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
135
141
 
136
142
  const handleDelete = async (id: string) => {
137
143
  try {
138
- await fetch(`/api/templates/${id}`, {
144
+ const res = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
139
145
  method: "DELETE",
140
146
  headers: adminKey ? { "x-admin-key": adminKey } : {},
141
147
  });
148
+ const data = await res.json();
149
+ if (!res.ok || !data.ok) throw new Error(data.message || data.error || "删除模板失败");
142
150
  setTemplates((prev) => prev.filter((t) => t.id !== id));
143
151
  await fetchSlots(account);
144
- } catch {
145
- // ignore
152
+ } catch (err) {
153
+ setError(err instanceof Error ? err.message : "模板请求失败");
146
154
  }
147
155
  };
148
156
 
@@ -0,0 +1,31 @@
1
+ import type { FlowImage, TypesetConfig } from "../../core/types";
2
+ import { getCanvasSpec } from "../../core/geometry";
3
+
4
+ export type ImageLayout = Pick<FlowImage, "x" | "y" | "width" | "height" | "isAbsolute" | "layoutPreset" | "pageIndex" | "zIndex" | "rotate" | "opacity" | "borderRadius" | "captionHeight">;
5
+
6
+ // 仅保存布局,恢复时不覆盖图片来源、说明和固定素材标识。
7
+ export function snapshotImageLayout(image: FlowImage): ImageLayout {
8
+ return {
9
+ x: image.x, y: image.y, width: image.width, height: image.height,
10
+ isAbsolute: image.isAbsolute, layoutPreset: image.layoutPreset, pageIndex: image.pageIndex,
11
+ zIndex: image.zIndex, rotate: image.rotate, opacity: image.opacity,
12
+ borderRadius: image.borderRadius, captionHeight: image.captionHeight,
13
+ };
14
+ }
15
+
16
+ // 切回绕排或取消固定素材时关闭重复,防止图片占满每一页。
17
+ export function restoreImageLayout(image: FlowImage, layout: Partial<FlowImage>): FlowImage {
18
+ const restored = { ...image, ...layout };
19
+ if (!restored.isAbsolute || !restored.templateAssetKey) restored.repeatEveryPage = false;
20
+ return restored;
21
+ }
22
+
23
+ // 背景使用整张画布,旋转归零,取消后由布局快照还原。
24
+ export function backgroundImageLayout(config: TypesetConfig): Partial<FlowImage> {
25
+ const spec = getCanvasSpec(config.aspectRatio);
26
+ return {
27
+ isAbsolute: true, layoutPreset: "custom", zIndex: 0, rotate: 0, borderRadius: 0, captionHeight: 0,
28
+ x: -config.algorithm.safeArea.left, y: -config.algorithm.safeArea.top,
29
+ width: spec.width, height: spec.height,
30
+ };
31
+ }
@@ -14,6 +14,7 @@ export function useLocalFonts() {
14
14
  if (!data.ok || !Array.isArray(data.fonts)) return;
15
15
 
16
16
  setFonts(data.fonts);
17
+ let added = false;
17
18
 
18
19
  // Register newly discovered fonts dynamically in browser DOM via FontFace API
19
20
  if (typeof window !== "undefined" && "FontFace" in window && (document as any).fonts) {
@@ -21,22 +22,30 @@ export function useLocalFonts() {
21
22
  try {
22
23
  // Check if font is already added
23
24
  let alreadyAdded = false;
25
+ const existing: FontFace[] = [];
24
26
  (document.fonts as any).forEach((f: any) => {
25
27
  if (f.family === font.family || f.family === `"${font.family}"`) {
26
28
  alreadyAdded = true;
29
+ existing.push(f);
27
30
  }
28
31
  });
29
32
 
33
+ if (refresh) {
34
+ existing.forEach((face) => document.fonts.delete(face));
35
+ alreadyAdded = false;
36
+ }
30
37
  if (!alreadyAdded) {
31
- const fontFace = new FontFace(font.family, `url(${font.url})`);
38
+ const fontFace = new FontFace(font.family, `url(${font.url}${refresh ? `?v=${Date.now()}` : ""})`);
32
39
  const loaded = await fontFace.load();
33
40
  (document.fonts as any).add(loaded);
41
+ added = true;
34
42
  }
35
43
  } catch (err) {
36
44
  console.warn(`[useLocalFonts] Failed to load web font ${font.family}:`, err);
37
45
  }
38
46
  }
39
47
  }
48
+ if (added) window.dispatchEvent(new Event("typeset-fonts-loaded"));
40
49
  } catch (err) {
41
50
  console.warn("[useLocalFonts] Failed to fetch local fonts:", err);
42
51
  } finally {