@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,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,
@@ -56,6 +62,26 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
56
62
  const file = e.target.files?.[0];
57
63
  if (!file) return;
58
64
 
65
+ // 上传失败时仍可用本地图片继续编辑。
66
+ const addUploadedImage = (src: string) => onAddImage({
67
+ src,
68
+ alt: file.name,
69
+ caption: "",
70
+ x: 40,
71
+ y: 400,
72
+ width: 440,
73
+ height: 300,
74
+ captionHeight: 36,
75
+ layoutPreset: "auto",
76
+ });
77
+ const readLocalImage = () => {
78
+ const reader = new FileReader();
79
+ reader.onload = () => {
80
+ if (typeof reader.result === "string") addUploadedImage(reader.result);
81
+ };
82
+ reader.readAsDataURL(file);
83
+ };
84
+
59
85
  setIsUploading(true);
60
86
  try {
61
87
  const formData = new FormData();
@@ -69,56 +95,12 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
69
95
 
70
96
  const data = await res.json();
71
97
  if (data.ok && data.url) {
72
- onAddImage({
73
- src: data.url,
74
- alt: file.name,
75
- caption: "",
76
- x: 40,
77
- y: 400,
78
- width: 440,
79
- height: 300,
80
- captionHeight: 36,
81
- layoutPreset: "auto",
82
- });
98
+ addUploadedImage(data.url);
83
99
  } else {
84
- // Fallback to FileReader base64
85
- const reader = new FileReader();
86
- reader.onload = (event) => {
87
- if (event.target?.result) {
88
- onAddImage({
89
- src: event.target.result as string,
90
- alt: file.name,
91
- caption: "",
92
- x: 40,
93
- y: 400,
94
- width: 440,
95
- height: 300,
96
- captionHeight: 36,
97
- layoutPreset: "auto",
98
- });
99
- }
100
- };
101
- reader.readAsDataURL(file);
100
+ readLocalImage();
102
101
  }
103
102
  } catch {
104
- // Fallback to base64
105
- const reader = new FileReader();
106
- reader.onload = (event) => {
107
- if (event.target?.result) {
108
- onAddImage({
109
- src: event.target.result as string,
110
- alt: file.name,
111
- caption: "",
112
- x: 40,
113
- y: 400,
114
- width: 440,
115
- height: 300,
116
- captionHeight: 36,
117
- layoutPreset: "auto",
118
- });
119
- }
120
- };
121
- reader.readAsDataURL(file);
103
+ readLocalImage();
122
104
  } finally {
123
105
  setIsUploading(false);
124
106
  if (fileInputRef.current) fileInputRef.current.value = "";
@@ -366,6 +348,35 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
366
348
  </button>
367
349
  </div>
368
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
+
369
380
  {!img.isAbsolute ? (
370
381
  <>
371
382
  {/* Flow Settings */}
@@ -375,7 +386,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
375
386
  <select
376
387
  value={img.layoutPreset || "auto"}
377
388
  onChange={(e) =>
378
- onUpdateImage(img.id, { layoutPreset: e.target.value as any })
389
+ onUpdateImage(img.id, { layoutPreset: e.target.value as FlowImage["layoutPreset"] })
379
390
  }
380
391
  className="w-full bg-white dark:bg-[#181818] border border-[#d4d4d4] dark:border-[#262626] rounded px-1.5 py-1 text-black dark:text-white text-[11px] focus:outline-none"
381
392
  >
@@ -52,7 +52,7 @@ export const Header: React.FC<HeaderProps> = ({
52
52
  </span>
53
53
  <span>Typeset Studio</span>
54
54
  </div>
55
- <span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden md:inline">v0.1.0</span>
55
+ <span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden md:inline">v0.2.2</span>
56
56
  <div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-[#f5f5f5] dark:bg-[#111111] border border-[#e5e5e5] dark:border-[#222222] text-[11px] text-emerald-600 dark:text-[#00e676]">
57
57
  <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-[#00e676] animate-pulse" />
58
58
  <span className="font-mono">Ready</span>
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useState } from "react";
2
- import { Check, FolderPlus, Trash2, X, Sparkles, Star, Tag, ChevronDown } from "lucide-react";
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 };
@@ -20,12 +23,6 @@ interface SlotsResponse {
20
23
  };
21
24
  }
22
25
 
23
- const SLOT_LABELS: Record<TemplateSlot, string> = {
24
- article_cover: "文章封面 (2.35:1)",
25
- newspic_cover: "贴图封面 (3:4)",
26
- newspic_content: "贴图内容 (3:4)",
27
- };
28
-
29
26
  export const TemplateModal: React.FC<TemplateModalProps> = ({
30
27
  isOpen,
31
28
  onClose,
@@ -47,13 +44,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
47
44
 
48
45
  const fetchSlots = async (acc = account) => {
49
46
  try {
50
- 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 } });
51
48
  const data: SlotsResponse = await res.json();
49
+ if (!res.ok || !data.ok) throw new Error(data.message || data.error || "获取槽位失败");
52
50
  if (data.ok && data.slots) {
53
51
  setSlotsData(data.slots);
54
52
  }
55
- } catch {
56
- // ignore
53
+ } catch (err) {
54
+ setError(err instanceof Error ? err.message : "模板请求失败");
57
55
  }
58
56
  };
59
57
 
@@ -68,11 +66,12 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
68
66
  body: JSON.stringify({ slot, templateId, account }),
69
67
  });
70
68
  const data = await res.json();
69
+ if (!res.ok || !data.ok) throw new Error(data.message || data.error || "设置默认模板失败");
71
70
  if (data.ok) {
72
71
  await fetchSlots(account);
73
72
  }
74
- } catch {
75
- // ignore
73
+ } catch (err) {
74
+ setError(err instanceof Error ? err.message : "模板请求失败");
76
75
  }
77
76
  };
78
77
 
@@ -84,7 +83,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
84
83
  headers: adminKey ? { "x-admin-key": adminKey } : {},
85
84
  });
86
85
  const data = await res.json();
87
- if (data.ok && Array.isArray(data.templates)) {
86
+ if (res.ok && data.ok && Array.isArray(data.templates)) {
88
87
  setTemplates(data.templates);
89
88
  } else {
90
89
  setError(data.message || "获取模板失败,请检查密钥");
@@ -103,18 +102,18 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
103
102
  setTemplateName(currentConfig.name ? `${currentConfig.name} 副本` : "我的自定义模板");
104
103
  setSaveSlot(currentConfig.aspectRatio === "wechat-cover-2.35" ? "article_cover" : "newspic_content");
105
104
  }
106
- }, [isOpen, account]);
105
+ }, [isOpen, account, adminKey]);
107
106
 
108
107
  const handleSave = async () => {
109
108
  if (!templateName.trim()) return;
110
109
  setIsSaving(true);
111
110
  try {
111
+ validateTemplateAssets(currentConfig.images);
112
112
  const tplToSave: TypesetConfig = {
113
113
  ...currentConfig,
114
114
  id: undefined, // auto generate
115
115
  name: templateName.trim(),
116
116
  slot: saveSlot !== "none" ? saveSlot : undefined,
117
- images: currentConfig.images ? [...currentConfig.images] : undefined,
118
117
  };
119
118
 
120
119
  const res = await fetch("/api/templates", {
@@ -127,14 +126,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
127
126
  });
128
127
 
129
128
  const data = await res.json();
130
- if (data.ok) {
129
+ if (res.ok && data.ok) {
131
130
  await fetchTemplates();
132
131
  await fetchSlots(account);
133
132
  } else {
134
- setError(data.message || "保存模板失败");
133
+ setError(data.message || data.error || "保存模板失败");
135
134
  }
136
- } catch {
137
- setError("保存模板请求异常");
135
+ } catch (err) {
136
+ setError(err instanceof Error ? err.message : "保存模板请求异常");
138
137
  } finally {
139
138
  setIsSaving(false);
140
139
  }
@@ -142,14 +141,16 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
142
141
 
143
142
  const handleDelete = async (id: string) => {
144
143
  try {
145
- await fetch(`/api/templates/${id}`, {
144
+ const res = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
146
145
  method: "DELETE",
147
146
  headers: adminKey ? { "x-admin-key": adminKey } : {},
148
147
  });
148
+ const data = await res.json();
149
+ if (!res.ok || !data.ok) throw new Error(data.message || data.error || "删除模板失败");
149
150
  setTemplates((prev) => prev.filter((t) => t.id !== id));
150
151
  await fetchSlots(account);
151
- } catch {
152
- // ignore
152
+ } catch (err) {
153
+ setError(err instanceof Error ? err.message : "模板请求失败");
153
154
  }
154
155
  };
155
156
 
@@ -192,7 +193,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
192
193
  />
193
194
  <select
194
195
  value={saveSlot}
195
- onChange={(e) => setSaveSlot(e.target.value as any)}
196
+ onChange={(e) => setSaveSlot(e.target.value as TemplateSlot | "none")}
196
197
  className="bg-white dark:bg-[#0a0a0a] border border-[#d4d4d4] dark:border-[#2a2a2a] rounded px-2 py-1.5 text-xs text-black dark:text-white focus:outline-none focus:border-black dark:focus:border-[#444444]"
197
198
  >
198
199
  <option value="article_cover">槽位: 文章封面 (2.35:1)</option>
@@ -224,11 +225,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
224
225
  <span>默认槽位配置账号:</span>
225
226
  <select
226
227
  value={account}
227
- onChange={(e) => {
228
- const acc = e.target.value;
229
- setAccount(acc);
230
- fetchSlots(acc);
231
- }}
228
+ onChange={(e) => setAccount(e.target.value)}
232
229
  className="bg-white dark:bg-[#141414] border border-[#d4d4d4] dark:border-[#2a2a2a] rounded px-2 py-0.5 text-xs text-black dark:text-white focus:outline-none font-mono"
233
230
  >
234
231
  <option value="default">default (主账号)</option>
@@ -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 {