@akairos/typeset-studio 0.2.0 → 0.2.2

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.
@@ -18,7 +18,7 @@ app.get("/api/health", (c) => {
18
18
  return c.json({
19
19
  status: "ok",
20
20
  name: "Typeset Studio Server",
21
- version: "0.1.0",
21
+ version: "0.2.2",
22
22
  time: new Date().toISOString(),
23
23
  });
24
24
  });
@@ -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
 
@@ -1,20 +1,38 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
- import { join } from "path";
2
+ import { dirname, join, resolve } from "path";
3
+ import { fileURLToPath } from "url";
3
4
  import { Hono } from "hono";
4
5
  import { getDefaultConfig } from "../../core/engine";
5
6
  import { TemplateSlot, TypesetConfig } from "../../core/types";
6
7
 
7
- const TEMPLATES_DIR = join(process.cwd(), "data", "templates");
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
+ }
21
+
22
+ const TEMPLATES_DIR = join(resolveDataDir(), "templates");
8
23
 
9
24
  function ensureTemplatesDir(): void {
10
25
  if (!existsSync(TEMPLATES_DIR)) {
11
- mkdirSync(TEMPLATES_DIR, { recursive: true });
26
+ try {
27
+ mkdirSync(TEMPLATES_DIR, { recursive: true });
28
+ } catch {
29
+ // Ignore write errors in read-only environments
30
+ }
12
31
  }
13
32
  seedDefaultTemplates();
14
33
  }
15
34
 
16
- function seedDefaultTemplates(): void {
17
- const presets: TypesetConfig[] = [
35
+ export const BUILTIN_PRESETS: TypesetConfig[] = [
18
36
  {
19
37
  ...getDefaultConfig("wechat-cover-2.35"),
20
38
  id: "tpl_wechat_cover_minimal",
@@ -325,13 +343,21 @@ function seedDefaultTemplates(): void {
325
343
  },
326
344
  ];
327
345
 
328
- for (const preset of presets) {
346
+ function seedDefaultTemplates(): void {
347
+ if (!existsSync(TEMPLATES_DIR)) return;
348
+ for (const preset of BUILTIN_PRESETS) {
329
349
  const filePath = join(TEMPLATES_DIR, `${preset.id}.json`);
330
- writeFileSync(filePath, JSON.stringify(preset, null, 2), "utf-8");
350
+ if (!existsSync(filePath)) {
351
+ try {
352
+ writeFileSync(filePath, JSON.stringify(preset, null, 2), "utf-8");
353
+ } catch {
354
+ // Ignore write failures in package directories
355
+ }
356
+ }
331
357
  }
332
358
  }
333
359
 
334
- const DEFAULT_SETTINGS_PATH = join(process.cwd(), "data", "default-template.json");
360
+ const DEFAULT_SETTINGS_PATH = join(resolveDataDir(), "default-template.json");
335
361
 
336
362
  export const BUILTIN_SLOT_DEFAULTS: Record<TemplateSlot, string> = {
337
363
  article_cover: "tpl_wechat_cover_minimal",
@@ -411,7 +437,21 @@ export function setDefaultTemplateId(id: string): void {
411
437
  }
412
438
 
413
439
  export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
414
- ensureTemplatesDir();
440
+ const lowerKey = key.trim().toLowerCase();
441
+
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;
454
+
415
455
  const directPath = join(TEMPLATES_DIR, `${key}.json`);
416
456
  if (existsSync(directPath)) {
417
457
  try {
@@ -421,10 +461,10 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
421
461
  }
422
462
  }
423
463
 
424
- // Scan all templates
464
+ // Scan all templates on disk
425
465
  const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".json"));
426
- const lowerKey = key.trim().toLowerCase();
427
466
 
467
+ const templates: TypesetConfig[] = [];
428
468
  for (const file of files) {
429
469
  try {
430
470
  const content = readFileSync(join(TEMPLATES_DIR, file), "utf-8");
@@ -432,25 +472,14 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
432
472
  if (tpl.id?.toLowerCase() === lowerKey || tpl.name.toLowerCase() === lowerKey) {
433
473
  return tpl;
434
474
  }
475
+ templates.push(tpl);
435
476
  } catch {
436
477
  // ignore
437
478
  }
438
479
  }
439
480
 
440
- // Partial match by name
441
- for (const file of files) {
442
- try {
443
- const content = readFileSync(join(TEMPLATES_DIR, file), "utf-8");
444
- const tpl = JSON.parse(content) as TypesetConfig;
445
- if (tpl.name.toLowerCase().includes(lowerKey)) {
446
- return tpl;
447
- }
448
- } catch {
449
- // ignore
450
- }
451
- }
452
-
453
- return null;
481
+ // 精确匹配优先,名称模糊匹配复用已读取的模板。
482
+ return templates.find((tpl) => tpl.name.toLowerCase().includes(lowerKey)) ?? null;
454
483
  }
455
484
 
456
485
  export const templatesRouter = new Hono();
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";
@@ -197,7 +196,6 @@ export const App: React.FC = () => {
197
196
  }
198
197
  }
199
198
  if (exportSucceeded) {
200
- setIsExporting(false);
201
199
  return;
202
200
  }
203
201
  }
@@ -56,6 +56,26 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
56
56
  const file = e.target.files?.[0];
57
57
  if (!file) return;
58
58
 
59
+ // 上传失败时仍可用本地图片继续编辑。
60
+ const addUploadedImage = (src: string) => onAddImage({
61
+ src,
62
+ alt: file.name,
63
+ caption: "",
64
+ x: 40,
65
+ y: 400,
66
+ width: 440,
67
+ height: 300,
68
+ captionHeight: 36,
69
+ layoutPreset: "auto",
70
+ });
71
+ const readLocalImage = () => {
72
+ const reader = new FileReader();
73
+ reader.onload = () => {
74
+ if (typeof reader.result === "string") addUploadedImage(reader.result);
75
+ };
76
+ reader.readAsDataURL(file);
77
+ };
78
+
59
79
  setIsUploading(true);
60
80
  try {
61
81
  const formData = new FormData();
@@ -69,56 +89,12 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
69
89
 
70
90
  const data = await res.json();
71
91
  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
- });
92
+ addUploadedImage(data.url);
83
93
  } 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);
94
+ readLocalImage();
102
95
  }
103
96
  } 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);
97
+ readLocalImage();
122
98
  } finally {
123
99
  setIsUploading(false);
124
100
  if (fileInputRef.current) fileInputRef.current.value = "";
@@ -375,7 +351,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
375
351
  <select
376
352
  value={img.layoutPreset || "auto"}
377
353
  onChange={(e) =>
378
- onUpdateImage(img.id, { layoutPreset: e.target.value as any })
354
+ onUpdateImage(img.id, { layoutPreset: e.target.value as FlowImage["layoutPreset"] })
379
355
  }
380
356
  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
357
  >
@@ -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,5 @@
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
3
  import { TemplateSlot, TypesetConfig } from "../../core/types";
4
4
 
5
5
  interface TemplateModalProps {
@@ -20,12 +20,6 @@ interface SlotsResponse {
20
20
  };
21
21
  }
22
22
 
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
23
  export const TemplateModal: React.FC<TemplateModalProps> = ({
30
24
  isOpen,
31
25
  onClose,
@@ -114,7 +108,6 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
114
108
  id: undefined, // auto generate
115
109
  name: templateName.trim(),
116
110
  slot: saveSlot !== "none" ? saveSlot : undefined,
117
- images: currentConfig.images ? [...currentConfig.images] : undefined,
118
111
  };
119
112
 
120
113
  const res = await fetch("/api/templates", {
@@ -192,7 +185,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
192
185
  />
193
186
  <select
194
187
  value={saveSlot}
195
- onChange={(e) => setSaveSlot(e.target.value as any)}
188
+ onChange={(e) => setSaveSlot(e.target.value as TemplateSlot | "none")}
196
189
  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
190
  >
198
191
  <option value="article_cover">槽位: 文章封面 (2.35:1)</option>
@@ -224,11 +217,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
224
217
  <span>默认槽位配置账号:</span>
225
218
  <select
226
219
  value={account}
227
- onChange={(e) => {
228
- const acc = e.target.value;
229
- setAccount(acc);
230
- fetchSlots(acc);
231
- }}
220
+ onChange={(e) => setAccount(e.target.value)}
232
221
  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
222
  >
234
223
  <option value="default">default (主账号)</option>
@@ -27,9 +27,23 @@ const INITIAL_MARKDOWN = `# 大模型纯算法排版深度剖析
27
27
  - 任意画板比例智能自适应
28
28
  `;
29
29
 
30
+ // 统一预设配图的页内序号计算,手动坐标和自由图层保持原样。
31
+ function resolvePresetImage(
32
+ image: FlowImage,
33
+ images: FlowImage[],
34
+ stageWidth: number,
35
+ stageHeight: number,
36
+ ): FlowImage {
37
+ if (image.isAbsolute || !image.layoutPreset || image.layoutPreset === "custom") return image;
38
+ const page = image.pageIndex || 1;
39
+ const indexOnPage = images.filter((item) => (item.pageIndex || 1) === page)
40
+ .findIndex((item) => item.id === image.id);
41
+ return resolveImagePlacement(image, indexOnPage, stageWidth, stageHeight);
42
+ }
43
+
30
44
  export function useTypeset() {
31
- const [aspectRatio, setAspectRatio] = useState<AspectRatioType>("longform-3-4");
32
45
  const [config, setConfig] = useState<TypesetConfig>(() => getDefaultConfig("longform-3-4"));
46
+ const aspectRatio = config.aspectRatio;
33
47
  const [markdown, setMarkdown] = useState<string>(INITIAL_MARKDOWN);
34
48
  const [images, setImages] = useState<FlowImage[]>([
35
49
  {
@@ -117,7 +131,6 @@ export function useTypeset() {
117
131
  }, []);
118
132
 
119
133
  const switchAspectRatio = (ratio: AspectRatioType) => {
120
- setAspectRatio(ratio);
121
134
  const spec = getCanvasSpec(ratio);
122
135
  const nextDefault = getDefaultConfig(ratio);
123
136
  const { stageWidth, stageHeight } = computeStageDimensions(
@@ -125,24 +138,7 @@ export function useTypeset() {
125
138
  nextDefault.algorithm.safeArea,
126
139
  nextDefault.algorithm.contentBottomGap,
127
140
  );
128
- setImages((prev) =>
129
- prev.map((img) => {
130
- if (!img.isAbsolute && img.layoutPreset && img.layoutPreset !== "custom") {
131
- const page = img.pageIndex || 1;
132
- const samePageList = prev.filter((im) => (im.pageIndex || 1) === page);
133
- const indexOnPage = Math.max(0, samePageList.findIndex((im) => im.id === img.id));
134
- const resolved = resolveImagePlacement(img, indexOnPage, stageWidth, stageHeight);
135
- return {
136
- ...img,
137
- x: resolved.x,
138
- y: resolved.y,
139
- width: resolved.width,
140
- height: resolved.height,
141
- };
142
- }
143
- return img;
144
- }),
145
- );
141
+ setImages((prev) => prev.map((image) => resolvePresetImage(image, prev, stageWidth, stageHeight)));
146
142
  setConfig((prev) => {
147
143
  return {
148
144
  ...prev,
@@ -235,28 +231,15 @@ export function useTypeset() {
235
231
 
236
232
  // If non-custom flow preset, recalculate clean coordinates based on its page index
237
233
  if (!updatedImg.isAbsolute && updatedImg.layoutPreset && updatedImg.layoutPreset !== "custom") {
238
- const page = updatedImg.pageIndex || 1;
239
- const samePageList = nextImages.filter((im) => (im.pageIndex || 1) === page);
240
- const indexOnPage = Math.max(0, samePageList.findIndex((im) => im.id === id));
241
-
242
234
  const spec = getCanvasSpec(aspectRatio);
243
235
  const { stageWidth, stageHeight } = computeStageDimensions(
244
236
  spec,
245
237
  config.algorithm.safeArea,
246
238
  config.algorithm.contentBottomGap,
247
239
  );
248
- const resolved = resolveImagePlacement(updatedImg, indexOnPage, stageWidth, stageHeight);
249
- return nextImages.map((im) =>
250
- im.id === id
251
- ? {
252
- ...im,
253
- x: resolved.x,
254
- y: resolved.y,
255
- width: resolved.width,
256
- height: resolved.height,
257
- }
258
- : im,
259
- );
240
+ return nextImages.map((image) => image.id === id
241
+ ? resolvePresetImage(image, nextImages, stageWidth, stageHeight)
242
+ : image);
260
243
  }
261
244
 
262
245
  return nextImages;
@@ -272,22 +255,7 @@ export function useTypeset() {
272
255
  config.algorithm.safeArea,
273
256
  config.algorithm.contentBottomGap,
274
257
  );
275
- return remaining.map((img) => {
276
- if (!img.isAbsolute && img.layoutPreset && img.layoutPreset !== "custom") {
277
- const page = img.pageIndex || 1;
278
- const samePageList = remaining.filter((im) => (im.pageIndex || 1) === page);
279
- const indexOnPage = Math.max(0, samePageList.findIndex((im) => im.id === img.id));
280
- const resolved = resolveImagePlacement(img, indexOnPage, stageWidth, stageHeight);
281
- return {
282
- ...img,
283
- x: resolved.x,
284
- y: resolved.y,
285
- width: resolved.width,
286
- height: resolved.height,
287
- };
288
- }
289
- return img;
290
- });
258
+ return remaining.map((image) => resolvePresetImage(image, remaining, stageWidth, stageHeight));
291
259
  });
292
260
  };
293
261
 
@@ -309,28 +277,12 @@ export function useTypeset() {
309
277
  config.algorithm.contentBottomGap,
310
278
  );
311
279
 
312
- return next.map((img) => {
313
- if (!img.isAbsolute && img.layoutPreset && img.layoutPreset !== "custom") {
314
- const page = img.pageIndex || 1;
315
- const samePageList = next.filter((im) => (im.pageIndex || 1) === page);
316
- const indexOnPage = Math.max(0, samePageList.findIndex((im) => im.id === img.id));
317
- const resolved = resolveImagePlacement(img, indexOnPage, stageWidth, stageHeight);
318
- return {
319
- ...img,
320
- x: resolved.x,
321
- y: resolved.y,
322
- width: resolved.width,
323
- height: resolved.height,
324
- };
325
- }
326
- return img;
327
- });
280
+ return next.map((image) => resolvePresetImage(image, next, stageWidth, stageHeight));
328
281
  });
329
282
  };
330
283
 
331
284
  const loadTemplate = (tpl: TypesetConfig) => {
332
285
  setConfig(tpl);
333
- setAspectRatio(tpl.aspectRatio);
334
286
  if (tpl.images) {
335
287
  setImages((prev) => applyTemplateImages(prev, tpl.images));
336
288
  }