@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.
- package/README.md +90 -93
- package/dist/assets/index-3Ko1UCBU.css +1 -0
- package/dist/assets/index-C9J0f4Lf.js +317 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +6 -7582
- package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
- package/dist/lib/font-loader-j28mt43t.js +11 -0
- package/dist/lib/index-37x76zdn.js +4 -0
- package/dist/lib/index-6c43c7h4.js +3880 -0
- package/dist/lib/index-qhpfzwj4.js +303 -0
- package/dist/lib/index-t9w3p2p9.js +3366 -0
- package/dist/lib/index-zdhqg5f0.js +459 -0
- package/dist/lib/index.js +26 -7582
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +2 -3
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +122 -31
- package/src/core/canvas-draw.ts +141 -38
- package/src/core/canvas-renderer.test.ts +58 -0
- package/src/core/canvas-renderer.ts +2 -1
- package/src/core/data-dir.ts +13 -0
- package/src/core/engine.ts +74 -36
- package/src/core/font-loader.test.ts +20 -0
- package/src/core/font-loader.ts +6 -2
- package/src/core/image-layout.ts +4 -0
- package/src/core/inline-text.ts +6 -4
- package/src/core/obstacle-flow.ts +29 -15
- package/src/core/page-images.test.ts +90 -0
- package/src/core/pretext.ts +4 -2
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +52 -40
- package/src/core/types.ts +46 -0
- package/src/index.ts +4 -1
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +10 -15
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +34 -35
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -4
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +35 -0
- package/src/web/components/TemplateModal.tsx +22 -14
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +119 -32
- package/data/templates/.gitkeep +0 -0
- package/data/templates/tpl_1791681833354_iofsk.json +0 -46
- package/data/templates/tpl_1791682603199_875ny.json +0 -66
- package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
- package/data/templates/tpl_longform_paper_sage.json +0 -47
- package/data/templates/tpl_longform_vintage_news.json +0 -47
- package/data/templates/tpl_newspic_poster_cover.json +0 -64
- package/data/templates/tpl_square_quote_card.json +0 -46
- package/data/templates/tpl_wechat_cover_editorial.json +0 -64
- package/data/templates/tpl_wechat_cover_minimal.json +0 -64
- package/data/templates/tpl_wide_tech_banner.json +0 -46
- package/dist/assets/index-CZ352D7X.css +0 -1
- package/dist/assets/index-DDeEGeHD.js +0 -317
package/src/core/template.ts
CHANGED
|
@@ -1,49 +1,61 @@
|
|
|
1
1
|
import { FlowImage } from "./types";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
24
|
+
templateImages: FlowImage[] = [],
|
|
25
|
+
overrides: Record<string, string> = {},
|
|
16
26
|
): FlowImage[] {
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
...
|
|
33
|
-
layoutPreset:
|
|
34
|
-
isAbsolute:
|
|
35
|
-
x:
|
|
36
|
-
y:
|
|
37
|
-
width:
|
|
38
|
-
height:
|
|
39
|
-
rotate:
|
|
40
|
-
opacity:
|
|
41
|
-
zIndex:
|
|
42
|
-
borderRadius:
|
|
43
|
-
captionHeight:
|
|
44
|
-
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
|
-
|
|
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,
|
package/src/server/auth.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
...(
|
|
124
|
+
...(overrides || {}),
|
|
106
125
|
algorithm: {
|
|
107
126
|
...baseConfig.algorithm,
|
|
108
|
-
...(
|
|
127
|
+
...(overrides?.algorithm || {}),
|
|
109
128
|
},
|
|
110
129
|
theme: {
|
|
111
130
|
...baseConfig.theme,
|
|
112
|
-
...(
|
|
131
|
+
...(overrides?.theme || {}),
|
|
113
132
|
},
|
|
114
133
|
typography: {
|
|
115
134
|
...baseConfig.typography,
|
|
116
|
-
...(
|
|
135
|
+
...(overrides?.typography || {}),
|
|
117
136
|
},
|
|
137
|
+
chrome: { ...baseConfig.chrome, ...overrides?.chrome },
|
|
118
138
|
};
|
|
119
139
|
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
+
});
|