@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.
- package/README.md +91 -95
- 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 -7734
- package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
- package/dist/lib/font-loader-j28mt43t.js +11 -0
- package/dist/lib/index-0370qz9e.js +303 -0
- package/dist/lib/index-37x76zdn.js +4 -0
- package/dist/lib/index-6c43c7h4.js +3880 -0
- package/dist/lib/index-t9w3p2p9.js +3366 -0
- package/dist/lib/index-zdhqg5f0.js +459 -0
- package/dist/lib/index.js +26 -7734
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +3 -7
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +190 -281
- package/src/core/canvas-draw.ts +150 -47
- 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 +86 -56
- 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 -10
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +53 -41
- package/src/core/types.ts +46 -0
- package/src/core/typography.ts +0 -14
- package/src/index.ts +6 -3
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +11 -16
- package/src/server/routes/fonts.ts +1 -1
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +37 -48
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -6
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +59 -48
- package/src/web/components/Header.tsx +1 -1
- package/src/web/components/TemplateModal.tsx +25 -28
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +139 -100
- 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-Dpnz-F2g.js +0 -317
package/src/server/index.ts
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
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) => {
|
|
18
17
|
return c.json({
|
|
19
18
|
status: "ok",
|
|
20
19
|
name: "Typeset Studio Server",
|
|
21
|
-
version: "0.
|
|
20
|
+
version: "0.2.2",
|
|
22
21
|
time: new Date().toISOString(),
|
|
23
22
|
});
|
|
24
23
|
});
|
|
@@ -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
|
+
});
|
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
-
import { dirname, join
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
2
|
+
import { dirname, join } from "path";
|
|
4
3
|
import { Hono } from "hono";
|
|
5
4
|
import { getDefaultConfig } from "../../core/engine";
|
|
6
5
|
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
6
|
+
import { resolveDataDir } from "../../core/data-dir";
|
|
7
|
+
import { validateTemplateAssets } from "../../core/template";
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
if (process.env.TYPESET_DATA_DIR) {
|
|
10
|
-
return process.env.TYPESET_DATA_DIR;
|
|
11
|
-
}
|
|
12
|
-
const currentDir = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const candidateLib = resolve(currentDir, "../../data");
|
|
14
|
-
if (existsSync(candidateLib)) return candidateLib;
|
|
15
|
-
const candidateSrc = resolve(currentDir, "../../../data");
|
|
16
|
-
if (existsSync(candidateSrc)) return candidateSrc;
|
|
17
|
-
const candidateCwd = resolve(process.cwd(), "data");
|
|
18
|
-
if (existsSync(candidateCwd)) return candidateCwd;
|
|
19
|
-
return candidateLib;
|
|
20
|
-
}
|
|
9
|
+
export { resolveDataDir } from "../../core/data-dir";
|
|
21
10
|
|
|
22
11
|
const TEMPLATES_DIR = join(resolveDataDir(), "templates");
|
|
23
12
|
|
|
@@ -29,7 +18,11 @@ function ensureTemplatesDir(): void {
|
|
|
29
18
|
// Ignore write errors in read-only environments
|
|
30
19
|
}
|
|
31
20
|
}
|
|
32
|
-
|
|
21
|
+
const seedMarker = join(TEMPLATES_DIR, ".initialized");
|
|
22
|
+
if (!existsSync(seedMarker)) {
|
|
23
|
+
seedDefaultTemplates();
|
|
24
|
+
try { writeFileSync(seedMarker, "1"); } catch { /* 只读安装目录保留读取能力。 */ }
|
|
25
|
+
}
|
|
33
26
|
}
|
|
34
27
|
|
|
35
28
|
export const BUILTIN_PRESETS: TypesetConfig[] = [
|
|
@@ -383,7 +376,7 @@ export function getSlotDefaultTemplateId(slot: TemplateSlot, account = "default"
|
|
|
383
376
|
}
|
|
384
377
|
|
|
385
378
|
export function setSlotDefaultTemplateId(slot: TemplateSlot, templateId: string, account = "default"): void {
|
|
386
|
-
const dir =
|
|
379
|
+
const dir = dirname(DEFAULT_SETTINGS_PATH);
|
|
387
380
|
if (!existsSync(dir)) {
|
|
388
381
|
mkdirSync(dir, { recursive: true });
|
|
389
382
|
}
|
|
@@ -420,7 +413,7 @@ export function getDefaultTemplateId(): string | null {
|
|
|
420
413
|
}
|
|
421
414
|
|
|
422
415
|
export function setDefaultTemplateId(id: string): void {
|
|
423
|
-
const dir =
|
|
416
|
+
const dir = dirname(DEFAULT_SETTINGS_PATH);
|
|
424
417
|
if (!existsSync(dir)) {
|
|
425
418
|
mkdirSync(dir, { recursive: true });
|
|
426
419
|
}
|
|
@@ -439,21 +432,13 @@ export function setDefaultTemplateId(id: string): void {
|
|
|
439
432
|
export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
|
|
440
433
|
const lowerKey = key.trim().toLowerCase();
|
|
441
434
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (builtin) return builtin;
|
|
447
|
-
|
|
448
|
-
// 2. Partial match in built-in memory presets
|
|
449
|
-
const builtinPartial = BUILTIN_PRESETS.find((p) => p.name.toLowerCase().includes(lowerKey));
|
|
450
|
-
if (builtinPartial) return builtinPartial;
|
|
451
|
-
|
|
452
|
-
// 3. Scan disk if templates directory exists
|
|
453
|
-
if (!existsSync(TEMPLATES_DIR)) return null;
|
|
435
|
+
if (!lowerKey) return null;
|
|
436
|
+
ensureTemplatesDir();
|
|
437
|
+
if (!existsSync(TEMPLATES_DIR)) return BUILTIN_PRESETS.find((p) =>
|
|
438
|
+
p.id?.toLowerCase() === lowerKey || p.name.toLowerCase() === lowerKey) ?? null;
|
|
454
439
|
|
|
455
|
-
const directPath =
|
|
456
|
-
if (existsSync(directPath)) {
|
|
440
|
+
const directPath = templatePath(key);
|
|
441
|
+
if (directPath && existsSync(directPath)) {
|
|
457
442
|
try {
|
|
458
443
|
return JSON.parse(readFileSync(directPath, "utf-8"));
|
|
459
444
|
} catch {
|
|
@@ -464,6 +449,7 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
|
|
|
464
449
|
// Scan all templates on disk
|
|
465
450
|
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith(".json"));
|
|
466
451
|
|
|
452
|
+
const templates: TypesetConfig[] = [];
|
|
467
453
|
for (const file of files) {
|
|
468
454
|
try {
|
|
469
455
|
const content = readFileSync(join(TEMPLATES_DIR, file), "utf-8");
|
|
@@ -471,25 +457,19 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
|
|
|
471
457
|
if (tpl.id?.toLowerCase() === lowerKey || tpl.name.toLowerCase() === lowerKey) {
|
|
472
458
|
return tpl;
|
|
473
459
|
}
|
|
460
|
+
templates.push(tpl);
|
|
474
461
|
} catch {
|
|
475
462
|
// ignore
|
|
476
463
|
}
|
|
477
464
|
}
|
|
478
465
|
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
const content = readFileSync(join(TEMPLATES_DIR, file), "utf-8");
|
|
483
|
-
const tpl = JSON.parse(content) as TypesetConfig;
|
|
484
|
-
if (tpl.name.toLowerCase().includes(lowerKey)) {
|
|
485
|
-
return tpl;
|
|
486
|
-
}
|
|
487
|
-
} catch {
|
|
488
|
-
// ignore
|
|
489
|
-
}
|
|
490
|
-
}
|
|
466
|
+
// 精确匹配优先,名称模糊匹配复用已读取的模板。
|
|
467
|
+
return templates.find((tpl) => tpl.name.toLowerCase().includes(lowerKey)) ?? null;
|
|
468
|
+
}
|
|
491
469
|
|
|
492
|
-
|
|
470
|
+
// 模板标识只用作文件名,名称查询继续支持中文和模糊匹配。
|
|
471
|
+
function templatePath(id: string): string | null {
|
|
472
|
+
return /^[a-zA-Z0-9_-]+$/.test(id) ? join(TEMPLATES_DIR, `${id}.json`) : null;
|
|
493
473
|
}
|
|
494
474
|
|
|
495
475
|
export const templatesRouter = new Hono();
|
|
@@ -609,7 +589,8 @@ templatesRouter.get("/resolve/:key", (c) => {
|
|
|
609
589
|
templatesRouter.get("/:id", (c) => {
|
|
610
590
|
ensureTemplatesDir();
|
|
611
591
|
const id = c.req.param("id");
|
|
612
|
-
const filePath =
|
|
592
|
+
const filePath = templatePath(id);
|
|
593
|
+
if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
|
|
613
594
|
|
|
614
595
|
if (!existsSync(filePath)) {
|
|
615
596
|
return c.json({ ok: false, error: "Template not found" }, 404);
|
|
@@ -628,13 +609,20 @@ templatesRouter.post("/", async (c) => {
|
|
|
628
609
|
return c.json({ ok: false, error: "Template name is required" }, 400);
|
|
629
610
|
}
|
|
630
611
|
|
|
612
|
+
try {
|
|
613
|
+
validateTemplateAssets(body.images);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : "无效的模板素材" }, 400);
|
|
616
|
+
}
|
|
617
|
+
|
|
631
618
|
const id = body.id || `tpl_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
632
619
|
const templateToSave: TypesetConfig = {
|
|
633
620
|
...body,
|
|
634
621
|
id,
|
|
635
622
|
};
|
|
636
623
|
|
|
637
|
-
const filePath =
|
|
624
|
+
const filePath = templatePath(id);
|
|
625
|
+
if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
|
|
638
626
|
writeFileSync(filePath, JSON.stringify(templateToSave, null, 2), "utf-8");
|
|
639
627
|
|
|
640
628
|
return c.json({ ok: true, template: templateToSave });
|
|
@@ -644,7 +632,8 @@ templatesRouter.post("/", async (c) => {
|
|
|
644
632
|
templatesRouter.delete("/:id", (c) => {
|
|
645
633
|
ensureTemplatesDir();
|
|
646
634
|
const id = c.req.param("id");
|
|
647
|
-
const filePath =
|
|
635
|
+
const filePath = templatePath(id);
|
|
636
|
+
if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
|
|
648
637
|
|
|
649
638
|
if (existsSync(filePath)) {
|
|
650
639
|
unlinkSync(filePath);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
+
import { resolveDataDir } from "../../core/data-dir";
|
|
4
5
|
|
|
5
|
-
const UPLOAD_DIR = join(
|
|
6
|
+
const UPLOAD_DIR = join(resolveDataDir(), "uploads");
|
|
6
7
|
|
|
7
8
|
function ensureUploadDir(): void {
|
|
8
9
|
if (!existsSync(UPLOAD_DIR)) {
|
package/src/web/App.tsx
CHANGED
|
@@ -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";
|
|
@@ -10,7 +9,9 @@ import { useAuth } from "./hooks/useAuth";
|
|
|
10
9
|
import { useTypeset } from "./hooks/useTypeset";
|
|
11
10
|
|
|
12
11
|
export const App: React.FC = () => {
|
|
12
|
+
const { adminKey, setAdminKey, isVerified } = useAuth();
|
|
13
13
|
const {
|
|
14
|
+
error: templateError,
|
|
14
15
|
aspectRatio,
|
|
15
16
|
switchAspectRatio,
|
|
16
17
|
config,
|
|
@@ -19,6 +20,9 @@ export const App: React.FC = () => {
|
|
|
19
20
|
images,
|
|
20
21
|
addImage,
|
|
21
22
|
updateImage,
|
|
23
|
+
toggleImageBackground,
|
|
24
|
+
resetImageLayout,
|
|
25
|
+
backgroundImageIds,
|
|
22
26
|
removeImage,
|
|
23
27
|
moveImage,
|
|
24
28
|
highlightWords,
|
|
@@ -30,9 +34,7 @@ export const App: React.FC = () => {
|
|
|
30
34
|
updateSafeArea,
|
|
31
35
|
loadTemplate,
|
|
32
36
|
setConfig,
|
|
33
|
-
} = useTypeset();
|
|
34
|
-
|
|
35
|
-
const { adminKey, setAdminKey, isVerified } = useAuth();
|
|
37
|
+
} = useTypeset(adminKey, isVerified);
|
|
36
38
|
|
|
37
39
|
// Default to Light Mode per user requirement
|
|
38
40
|
const [themeMode, setThemeMode] = useState<"light" | "dark">(() => {
|
|
@@ -180,6 +182,9 @@ export const App: React.FC = () => {
|
|
|
180
182
|
setIsExporting(true);
|
|
181
183
|
try {
|
|
182
184
|
const canvasElements = document.querySelectorAll<HTMLCanvasElement>("canvas.typeset-page");
|
|
185
|
+
if ([...canvasElements].some((canvas) => canvas.dataset.renderState !== "ready")) {
|
|
186
|
+
throw new Error("画布尚未完成绘制,请检查素材或稍后再导出");
|
|
187
|
+
}
|
|
183
188
|
if (canvasElements.length > 0) {
|
|
184
189
|
let exportSucceeded = true;
|
|
185
190
|
for (let i = 0; i < canvasElements.length; i++) {
|
|
@@ -197,7 +202,6 @@ export const App: React.FC = () => {
|
|
|
197
202
|
}
|
|
198
203
|
}
|
|
199
204
|
if (exportSucceeded) {
|
|
200
|
-
setIsExporting(false);
|
|
201
205
|
return;
|
|
202
206
|
}
|
|
203
207
|
}
|
|
@@ -231,7 +235,7 @@ export const App: React.FC = () => {
|
|
|
231
235
|
}
|
|
232
236
|
} catch (err) {
|
|
233
237
|
console.error("Export failed:", err);
|
|
234
|
-
alert("导出失败,请检查网络或后端服务连接");
|
|
238
|
+
alert(err instanceof Error ? err.message : "导出失败,请检查网络或后端服务连接");
|
|
235
239
|
} finally {
|
|
236
240
|
setIsExporting(false);
|
|
237
241
|
}
|
|
@@ -257,6 +261,8 @@ export const App: React.FC = () => {
|
|
|
257
261
|
onToggleThemeMode={toggleThemeMode}
|
|
258
262
|
/>
|
|
259
263
|
|
|
264
|
+
{templateError && <div role="alert" className="px-4 py-2 text-sm text-red-600">{templateError}</div>}
|
|
265
|
+
|
|
260
266
|
{/* Main Workspace (3-column layout) */}
|
|
261
267
|
<div className="flex flex-1 min-h-0 overflow-hidden relative">
|
|
262
268
|
{/* Left: Content Editor Sidebar */}
|
|
@@ -265,6 +271,9 @@ export const App: React.FC = () => {
|
|
|
265
271
|
onMarkdownChange={setMarkdown}
|
|
266
272
|
images={images}
|
|
267
273
|
onAddImage={addImage}
|
|
274
|
+
onToggleBackground={toggleImageBackground}
|
|
275
|
+
onResetImageLayout={resetImageLayout}
|
|
276
|
+
backgroundImageIds={backgroundImageIds}
|
|
268
277
|
onUpdateImage={updateImage}
|
|
269
278
|
onRemoveImage={removeImage}
|
|
270
279
|
onMoveImage={moveImage}
|