@akairos/typeset-studio 0.2.2 → 0.2.4

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-37x76zdn.js +4 -0
  9. package/dist/lib/index-6c43c7h4.js +3880 -0
  10. package/dist/lib/index-qhpfzwj4.js +303 -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 +122 -31
  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,49 +1,61 @@
1
1
  import { FlowImage } from "./types";
2
2
 
3
- /**
4
- * Applies template image layout configurations to current content images based on index intersection:
5
- * - If current images exist and template has images:
6
- * For 0 <= i < min(template.images.length, current.images.length), apply template layout properties
7
- * (isAbsolute, layoutPreset, x, y, width, height, rotate, opacity, zIndex, borderRadius, captionHeight, pageIndex)
8
- * while preserving the current image's content identity (id, src, alt, caption).
9
- * Current images beyond template.images.length remain untouched ("取交集,另外俩图用户自己调").
10
- * - If current images are empty (0 images), adopt the template's images (with freshly generated IDs).
11
- * - If template has no images, current images remain untouched.
12
- */
3
+ // 固定素材标识用于显式替换,同一模板内必须唯一。
4
+ export function validateTemplateAssets(images: FlowImage[] = []): void {
5
+ if (!Array.isArray(images)) throw new RangeError("模板图片必须是数组");
6
+ const keys = new Set<string>();
7
+ for (const image of images) {
8
+ if (!image || typeof image !== "object") throw new RangeError("无效的模板图片");
9
+ if (image.templateAssetKey === undefined) continue;
10
+ const key = image.templateAssetKey;
11
+ if (image.repeatEveryPage !== undefined && typeof image.repeatEveryPage !== "boolean") throw new RangeError(`素材 ${key} 的每页重复开关必须是布尔值`);
12
+ if (typeof key !== "string" || !/^[a-zA-Z0-9_-]+$/.test(key)) {
13
+ throw new RangeError("素材标识只能包含字母、数字、下划线和短横线");
14
+ }
15
+ if (image.repeatEveryPage && !image.isAbsolute) throw new RangeError(`素材 ${key} 的每页重复仅支持自由定位模式`);
16
+ if (keys.has(key)) throw new RangeError(`重复的模板素材标识:${key}`);
17
+ keys.add(key);
18
+ }
19
+ }
20
+
21
+ // 普通配图按位置套用布局,固定素材仅通过标识替换来源。
13
22
  export function applyTemplateImages(
14
23
  currentImages: FlowImage[],
15
- templateImages?: FlowImage[],
24
+ templateImages: FlowImage[] = [],
25
+ overrides: Record<string, string> = {},
16
26
  ): FlowImage[] {
17
- if (!templateImages || templateImages.length === 0) {
18
- return currentImages;
27
+ validateTemplateAssets(templateImages);
28
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new RangeError("素材替换必须是标识到图片地址的映射");
29
+ const fixed = templateImages.filter((image) => image.templateAssetKey);
30
+ for (const [key, src] of Object.entries(overrides)) {
31
+ if (!fixed.some((image) => image.templateAssetKey === key)) throw new RangeError(`未知模板素材:${key}`);
32
+ if (typeof src !== "string" || !src.trim()) throw new RangeError(`素材 ${key} 的替换图片不能为空`);
19
33
  }
20
-
21
- if (currentImages.length === 0) {
22
- return templateImages.map((tplImg) => ({
23
- ...tplImg,
24
- id: `img_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
25
- }));
26
- }
27
-
28
- return currentImages.map((currentImg, idx) => {
29
- if (idx < templateImages.length) {
30
- const tplImg = templateImages[idx];
34
+ const content = currentImages.filter((image) => !image.templateAssetKey);
35
+ const slots = templateImages.filter((image) => !image.templateAssetKey);
36
+ const mapped = content.length === 0
37
+ ? slots.map((image) => ({ ...image, id: `img_${crypto.randomUUID()}` }))
38
+ : content.map((image, index) => {
39
+ const slot = slots[index];
40
+ if (!slot) return image;
31
41
  return {
32
- ...currentImg,
33
- layoutPreset: tplImg.layoutPreset ?? currentImg.layoutPreset,
34
- isAbsolute: tplImg.isAbsolute ?? currentImg.isAbsolute,
35
- x: tplImg.x ?? currentImg.x,
36
- y: tplImg.y ?? currentImg.y,
37
- width: tplImg.width ?? currentImg.width,
38
- height: tplImg.height ?? currentImg.height,
39
- rotate: tplImg.rotate ?? currentImg.rotate,
40
- opacity: tplImg.opacity ?? currentImg.opacity,
41
- zIndex: tplImg.zIndex ?? currentImg.zIndex,
42
- borderRadius: tplImg.borderRadius ?? currentImg.borderRadius,
43
- captionHeight: tplImg.captionHeight ?? currentImg.captionHeight,
44
- pageIndex: tplImg.pageIndex ?? currentImg.pageIndex,
42
+ ...image,
43
+ layoutPreset: slot.layoutPreset ?? image.layoutPreset,
44
+ isAbsolute: slot.isAbsolute ?? image.isAbsolute,
45
+ x: slot.x ?? image.x,
46
+ y: slot.y ?? image.y,
47
+ width: slot.width ?? image.width,
48
+ height: slot.height ?? image.height,
49
+ rotate: slot.rotate ?? image.rotate,
50
+ opacity: slot.opacity ?? image.opacity,
51
+ zIndex: slot.zIndex ?? image.zIndex,
52
+ borderRadius: slot.borderRadius ?? image.borderRadius,
53
+ captionHeight: slot.captionHeight ?? image.captionHeight,
54
+ pageIndex: slot.pageIndex ?? image.pageIndex,
45
55
  };
46
- }
47
- return currentImg;
48
- });
56
+ });
57
+ return [...mapped, ...fixed.map((image) => ({
58
+ ...image,
59
+ src: Object.hasOwn(overrides, image.templateAssetKey!) ? overrides[image.templateAssetKey!] : image.src,
60
+ }))];
49
61
  }
package/src/core/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface InlineTextRun {
20
20
  italic?: boolean;
21
21
  highlight?: boolean;
22
22
  font?: string;
23
+ letterSpacing?: number;
23
24
  }
24
25
 
25
26
  export type ContentBlockKind =
@@ -55,6 +56,8 @@ export interface FlowImage {
55
56
  src: string;
56
57
  alt: string;
57
58
  caption?: string;
59
+ templateAssetKey?: string; // 固定模板素材的唯一标识
60
+ repeatEveryPage?: boolean; // 开启时忽略页码,在所有输出页重复
58
61
  x?: number;
59
62
  y?: number;
60
63
  width?: number;
@@ -77,6 +80,7 @@ export interface FlowLineFragment {
77
80
  highlight?: boolean;
78
81
  gapBefore: number;
79
82
  occupiedWidth: number;
83
+ letterSpacing?: number;
80
84
  }
81
85
 
82
86
  export interface FlowLine {
@@ -163,6 +167,7 @@ export interface TypesetConfig {
163
167
  watermarkText: string;
164
168
  chrome?: TypesetChromeConfig;
165
169
  images?: FlowImage[];
170
+ coverTheme?: PipelineCoverTheme;
166
171
  }
167
172
 
168
173
  export interface TypesetResult {
@@ -171,3 +176,44 @@ export interface TypesetResult {
171
176
  canvasSpec: CanvasSpec;
172
177
  config: TypesetConfig;
173
178
  }
179
+
180
+ // 流水线解析后的封面主题,与 pipeline 的公开契约保持一致。
181
+ export interface CoverFont {
182
+ fontFamily: string;
183
+ fontSize: number;
184
+ minFontSize: number;
185
+ fontWeight: number;
186
+ lineHeight: number;
187
+ letterSpacing: number;
188
+ }
189
+ export interface CoverImage {
190
+ src: string;
191
+ fit: "cover" | "contain" | "fill";
192
+ position: { x: number; y: number };
193
+ opacity: number;
194
+ }
195
+ export interface PipelineCoverTheme {
196
+ id: string;
197
+ format: "poster-3-4" | "wechat-cover-split";
198
+ typography: { title: CoverFont; subtitle: CoverFont; footer: CoverFont };
199
+ colors: { text: string; accent: string; muted: string };
200
+ background: {
201
+ fill: { type: "solid"; color: string } |
202
+ { type: "linear"; angle: number; stops: Array<{ color: string; offset: number }> } |
203
+ { type: "radial"; position: { x: number; y: number }; stops: Array<{ color: string; offset: number }> };
204
+ image: CoverImage | null;
205
+ overlay: { color: string; opacity: number } | null;
206
+ };
207
+ decoration: {
208
+ pattern: "none" | "lines" | "grid" | "dots" | "rings" | "burst";
209
+ color: string;
210
+ opacity: number;
211
+ size: number;
212
+ image: CoverImage | null;
213
+ };
214
+ layout: {
215
+ textAlign: "left" | "center" | "right";
216
+ verticalAlign: "top" | "center" | "bottom";
217
+ safeArea: AlgorithmParams["safeArea"];
218
+ };
219
+ }
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ export {
15
15
  readFontFamilyName,
16
16
  type LocalFontInfo,
17
17
  } from "./core/font-loader";
18
- export { applyTemplateImages } from "./core/template";
18
+ export { applyTemplateImages, validateTemplateAssets } from "./core/template";
19
19
 
20
20
  export {
21
21
  typesetStudioAdapter,
@@ -34,6 +34,9 @@ export type {
34
34
  AspectRatioType,
35
35
  CanvasSpec,
36
36
  ContentBlockKind,
37
+ CoverFont,
38
+ CoverImage,
39
+ PipelineCoverTheme,
37
40
  FlowBlock,
38
41
  FlowImage,
39
42
  FlowLine,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { resolve } from "path";
3
+ import { randomBytes } from "node:crypto";
3
4
  import type { Context, Next } from "hono";
4
5
 
5
6
  const ENV_PATH = resolve(process.cwd(), ".env");
@@ -26,7 +27,7 @@ export function getOrGenerateAdminKey(): string {
26
27
  }
27
28
 
28
29
  // Generate a new secure key
29
- const randomSuffix = Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);
30
+ const randomSuffix = randomBytes(24).toString("hex");
30
31
  const newKey = `tsk_${randomSuffix}`;
31
32
 
32
33
  try {
@@ -53,18 +54,6 @@ export async function authMiddleware(c: Context, next: Next) {
53
54
 
54
55
  const providedKey = headerKey || bearerKey || queryKey;
55
56
 
56
- // Seamless local use: requests from localhost / loopback are trusted for dev
57
- const host = c.req.header("host") || "";
58
- const isLocal =
59
- host.includes("localhost") ||
60
- host.includes("127.0.0.1") ||
61
- host.includes("0.0.0.0") ||
62
- host.includes("[::1]");
63
-
64
- if (isLocal) {
65
- return next();
66
- }
67
-
68
57
  if (!providedKey || providedKey !== adminKey) {
69
58
  return c.json(
70
59
  {
@@ -78,3 +67,19 @@ export async function authMiddleware(c: Context, next: Next) {
78
67
 
79
68
  return next();
80
69
  }
70
+
71
+ // 仅真实回环连接且来源为本机网页时允许自动取钥。
72
+ export function isLocalRequest(c: Context): boolean {
73
+ if (c.req.header("forwarded") || c.req.header("x-forwarded-for") || c.req.header("x-forwarded-host")) return false;
74
+ const peer = c.env?.peerAddress;
75
+ if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(peer)) return false;
76
+ const isLoopback = (value: string) => {
77
+ try {
78
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(value).hostname);
79
+ } catch {
80
+ return false;
81
+ }
82
+ };
83
+ return isLoopback(c.req.url) && isLoopback(`http://${c.req.header("host") || ""}`) &&
84
+ (!c.req.header("origin") || isLoopback(c.req.header("origin")!));
85
+ }
@@ -1,17 +1,16 @@
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) => {
@@ -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
  };
@@ -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
+ });