@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akairos/typeset-studio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Algorithmic typesetting studio & zzhub-pipeline image renderer plugin",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"dist/index.html",
|
|
33
33
|
"dist/lib",
|
|
34
34
|
"src",
|
|
35
|
-
"data/templates",
|
|
36
35
|
"public/fonts/README.md",
|
|
37
36
|
"public/fonts/fonts.json",
|
|
38
37
|
"README.md"
|
|
@@ -42,7 +41,7 @@
|
|
|
42
41
|
"dev:server": "bun --watch src/server/index.ts",
|
|
43
42
|
"dev:all": "bun --watch src/server/index.ts & vite",
|
|
44
43
|
"build": "vite build && bun run build:lib",
|
|
45
|
-
"build:lib": "bun build src/index.ts src/adapter.ts --outdir dist/lib --target node --external @napi-rs/canvas",
|
|
44
|
+
"build:lib": "rm -rf dist/lib && bun build src/index.ts src/adapter.ts --outdir dist/lib --target node --splitting --external @napi-rs/canvas",
|
|
46
45
|
"build:watch": "vite build --watch",
|
|
47
46
|
"preview": "vite preview",
|
|
48
47
|
"server": "bun run src/server/index.ts",
|
package/src/adapter.test.ts
CHANGED
|
@@ -140,3 +140,120 @@ describe("Typeset Studio — zzhub-pipeline ImageRenderPlugin Adapter", () => {
|
|
|
140
140
|
expect(result.pageCount).toBe(0);
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
|
+
|
|
144
|
+
describe("Remote adapter regressions", () => {
|
|
145
|
+
it("authenticates, transfers local bytes and preserves page image summaries", async () => {
|
|
146
|
+
const { app } = await import("./server/index");
|
|
147
|
+
const { getOrGenerateAdminKey } = await import("./server/auth");
|
|
148
|
+
const { createCanvas } = await import("@napi-rs/canvas");
|
|
149
|
+
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
150
|
+
const { tmpdir } = await import("node:os");
|
|
151
|
+
const directory = await mkdtemp(join(tmpdir(), "typeset-remote-"));
|
|
152
|
+
const imagePath = join(directory, "source.png");
|
|
153
|
+
await writeFile(imagePath, createCanvas(8, 8).toBuffer("image/png"));
|
|
154
|
+
const requests: any[] = [];
|
|
155
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: async (request) => {
|
|
156
|
+
if (request.url.endsWith("/api/render/export")) requests.push(await request.clone().json());
|
|
157
|
+
return app.fetch(request);
|
|
158
|
+
} });
|
|
159
|
+
const previousUrl = process.env.TYPESET_STUDIO_URL;
|
|
160
|
+
const previousKey = process.env.TYPESET_ADMIN_KEY;
|
|
161
|
+
process.env.TYPESET_STUDIO_URL = server.url.toString();
|
|
162
|
+
process.env.TYPESET_ADMIN_KEY = getOrGenerateAdminKey();
|
|
163
|
+
try {
|
|
164
|
+
const progress: string[] = [];
|
|
165
|
+
const output = await typesetStudioAdapter.render({
|
|
166
|
+
route: "wechat-newspic", outputDir: directory, title: "远程测试", bodyText: "完整正文".repeat(100),
|
|
167
|
+
state: { route: { account: "default" } },
|
|
168
|
+
minPages: 3, maxPages: 8,
|
|
169
|
+
bodyImages: [{ marker: "one", path: imagePath }],
|
|
170
|
+
pageSpecs: [{ page: 2, imageMarkers: ["one"], targetFillRatio: 0.5 }],
|
|
171
|
+
accountVisualParams: { bg: "#123456", highlight: "#abcdef", footer: "测试署名", fallbackIcon: imagePath },
|
|
172
|
+
onProgress: (event) => progress.push(event.stage),
|
|
173
|
+
});
|
|
174
|
+
expect(output.pageCount).toBeGreaterThanOrEqual(3);
|
|
175
|
+
expect(output.pages[1].imageCount).toBe(1);
|
|
176
|
+
expect(output.pages[1].imageSources).toEqual([imagePath]);
|
|
177
|
+
expect(requests[1].images[0].src).toStartWith("data:image/png;base64,");
|
|
178
|
+
expect(requests[1].minPages).toBe(3);
|
|
179
|
+
expect(requests[1].pageFillRatios[2]).toBe(0.5);
|
|
180
|
+
expect(requests[1].config.theme.bgColor).toBe("#123456");
|
|
181
|
+
expect(requests[0].config.images[0].id).toBe("account_icon");
|
|
182
|
+
expect(progress).toEqual(["typeset", "cover", "content"]);
|
|
183
|
+
process.env.TYPESET_ADMIN_KEY = "wrong-test-key";
|
|
184
|
+
await expect(typesetStudioAdapter.render({
|
|
185
|
+
route: "wechat-article", outputDir: directory, title: "失败", bodyText: "", state: { route: {} },
|
|
186
|
+
})).rejects.toThrow("401");
|
|
187
|
+
} finally {
|
|
188
|
+
if (previousUrl === undefined) delete process.env.TYPESET_STUDIO_URL; else process.env.TYPESET_STUDIO_URL = previousUrl;
|
|
189
|
+
if (previousKey === undefined) delete process.env.TYPESET_ADMIN_KEY; else process.env.TYPESET_ADMIN_KEY = previousKey;
|
|
190
|
+
server.stop(true);
|
|
191
|
+
await rm(directory, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("uses selected/default Studio templates and produces identical fixed assets locally and remotely", async () => {
|
|
197
|
+
const { app } = await import("./server/index");
|
|
198
|
+
const { getOrGenerateAdminKey } = await import("./server/auth");
|
|
199
|
+
const { getDefaultConfig } = await import("./core/engine");
|
|
200
|
+
const { getSlotDefaultTemplateId, setSlotDefaultTemplateId } = await import("./server/routes/templates");
|
|
201
|
+
const { createCanvas } = await import("@napi-rs/canvas");
|
|
202
|
+
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
203
|
+
const { tmpdir } = await import("node:os");
|
|
204
|
+
const directory = await mkdtemp(join(tmpdir(), "typeset-fixed-adapter-"));
|
|
205
|
+
const source = join(directory, "background.png");
|
|
206
|
+
const replacement = join(directory, "replacement.png");
|
|
207
|
+
const canvas = createCanvas(20, 20);
|
|
208
|
+
const context = canvas.getContext("2d");
|
|
209
|
+
context.fillStyle = "#ff0000";
|
|
210
|
+
context.fillRect(0, 0, 20, 20);
|
|
211
|
+
await writeFile(source, canvas.toBuffer("image/png"));
|
|
212
|
+
context.fillStyle = "#00ff00";
|
|
213
|
+
context.fillRect(0, 0, 20, 20);
|
|
214
|
+
await writeFile(replacement, canvas.toBuffer("image/png"));
|
|
215
|
+
const headers = { "Content-Type": "application/json", "x-admin-key": getOrGenerateAdminKey() };
|
|
216
|
+
const templateId = "fixed_adapter_test";
|
|
217
|
+
const config = getDefaultConfig("square-1-1");
|
|
218
|
+
const previousUrl = process.env.TYPESET_STUDIO_URL;
|
|
219
|
+
const previousKey = process.env.TYPESET_ADMIN_KEY;
|
|
220
|
+
const previousDefault = getSlotDefaultTemplateId("newspic_cover", "fixed-test");
|
|
221
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: (request) => app.fetch(request) });
|
|
222
|
+
try {
|
|
223
|
+
const saved = await app.request("/api/templates", { method: "POST", headers, body: JSON.stringify({
|
|
224
|
+
...config, id: templateId, images: [{ id: "bg", src: source, alt: "", templateAssetKey: "background", repeatEveryPage: true,
|
|
225
|
+
isAbsolute: true, x: -100, y: -100, width: 1080, height: 1080, zIndex: 0, borderRadius: 0 }],
|
|
226
|
+
}) });
|
|
227
|
+
expect(saved.status).toBe(200);
|
|
228
|
+
setSlotDefaultTemplateId("newspic_cover", templateId, "fixed-test");
|
|
229
|
+
delete process.env.TYPESET_STUDIO_URL;
|
|
230
|
+
const input: ImageRenderInput = { route: "wechat-newspic", title: "模板测试", bodyText: "", outputDir: join(directory, "local"),
|
|
231
|
+
state: { route: { account: "fixed-test" } }, template: "poster-3-4", theme: "unrelated-theme",
|
|
232
|
+
accountVisualParams: { bg: "#123456", fallbackIcon: "/must-not-load.png" },
|
|
233
|
+
};
|
|
234
|
+
const defaultOutput = await typesetStudioAdapter.render(input);
|
|
235
|
+
expect(defaultOutput.pages[0].imageCount).toBe(0);
|
|
236
|
+
const selected = { ...input, studioTemplates: { cover: templateId }, templateImageOverrides: { cover: { background: replacement } } };
|
|
237
|
+
const local = await typesetStudioAdapter.render({ ...selected, outputDir: join(directory, "override") });
|
|
238
|
+
expect(readFileSync(local.assets[0].path).equals(readFileSync(defaultOutput.assets[0].path))).toBe(false);
|
|
239
|
+
process.env.TYPESET_STUDIO_URL = server.url.toString();
|
|
240
|
+
process.env.TYPESET_ADMIN_KEY = getOrGenerateAdminKey();
|
|
241
|
+
const remote = await typesetStudioAdapter.render({ ...selected, outputDir: join(directory, "remote") });
|
|
242
|
+
expect(readFileSync(remote.assets[0].path).equals(readFileSync(local.assets[0].path))).toBe(true);
|
|
243
|
+
expect(remote.pages[0]).toEqual({ page: 1, imageCount: 0, imageSources: [] });
|
|
244
|
+
const resolved = await (await app.request(`/api/templates/${templateId}`, { headers })).json();
|
|
245
|
+
expect(resolved.template.images[0].src).toBe(source);
|
|
246
|
+
const unknown = { ...selected, templateImageOverrides: { cover: { missing: replacement } } };
|
|
247
|
+
await expect(typesetStudioAdapter.render(unknown)).rejects.toThrow("未知模板素材");
|
|
248
|
+
await expect(typesetStudioAdapter.render({ ...selected,
|
|
249
|
+
templateImageOverrides: { cover: { background: join(directory, "missing.png") } },
|
|
250
|
+
})).rejects.toThrow("模板素材 background 加载失败");
|
|
251
|
+
} finally {
|
|
252
|
+
server.stop(true);
|
|
253
|
+
await app.request(`/api/templates/${templateId}`, { method: "DELETE", headers });
|
|
254
|
+
setSlotDefaultTemplateId("newspic_cover", previousDefault, "fixed-test");
|
|
255
|
+
if (previousUrl === undefined) delete process.env.TYPESET_STUDIO_URL; else process.env.TYPESET_STUDIO_URL = previousUrl;
|
|
256
|
+
if (previousKey === undefined) delete process.env.TYPESET_ADMIN_KEY; else process.env.TYPESET_ADMIN_KEY = previousKey;
|
|
257
|
+
await rm(directory, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
package/src/adapter.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type { FlowImage } from "./core/types";
|
|
6
|
-
import { resolveTypesetResult, type RenderRequestBody } from "./server/routes/render";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
import type { FlowImage, PipelineCoverTheme } from "./core/types";
|
|
4
|
+
import type { RenderRequestBody } from "./server/routes/render";
|
|
7
5
|
|
|
8
6
|
// ── Typeset Studio & zzhub-pipeline compatible types ──────────────
|
|
9
7
|
|
|
@@ -15,8 +13,14 @@ export interface PipelinePluginDoctorCheck {
|
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
export interface ImageRenderInput {
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
/** Studio 模板与任务素材替换,旧插件可忽略。 */
|
|
17
|
+
studioTemplates?: { cover?: string; content?: string };
|
|
18
|
+
templateImageOverrides?: { cover?: Record<string, string>; content?: Record<string, string> };
|
|
19
|
+
coverTheme?: PipelineCoverTheme;
|
|
20
|
+
onProgress?: (progress: {
|
|
21
|
+
stage: string; message?: string; current?: number; total?: number;
|
|
22
|
+
unit?: "pages" | "files" | "bytes" | "targets"; route?: string; account?: string;
|
|
23
|
+
}) => void;
|
|
20
24
|
state: {
|
|
21
25
|
route: {
|
|
22
26
|
account?: string;
|
|
@@ -34,6 +38,7 @@ export interface ImageRenderInput {
|
|
|
34
38
|
pagination_mode?: string;
|
|
35
39
|
min_pages?: number;
|
|
36
40
|
max_pages?: number;
|
|
41
|
+
target_fill_ratio?: number;
|
|
37
42
|
page_specs?: Array<{
|
|
38
43
|
page: number;
|
|
39
44
|
image_markers: string[];
|
|
@@ -141,7 +146,7 @@ function mapBodyImages(
|
|
|
141
146
|
// ── Typeset Studio Pipeline Adapter ───────────────────────────────
|
|
142
147
|
|
|
143
148
|
// 配置远程地址后,由独立服务负责图片渲染。
|
|
144
|
-
const
|
|
149
|
+
const remoteStudioUrl = () => process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
|
|
145
150
|
|
|
146
151
|
// 下载远程生成的页面,并写入流水线输出目录。
|
|
147
152
|
async function renderRemoteBatch(
|
|
@@ -151,23 +156,53 @@ async function renderRemoteBatch(
|
|
|
151
156
|
route: "wechat-article" | "wechat-newspic",
|
|
152
157
|
kind: "cover" | "page",
|
|
153
158
|
): Promise<ImageRenderOutput> {
|
|
159
|
+
// 远程进程无法读取客户端文件,发送图片字节并保留来源摘要映射。
|
|
160
|
+
const sources = new Map<string, string>();
|
|
161
|
+
const originalImages = new Map([...(body.config?.images || []), ...(body.images || [])].map((image) => [image.id, image.src]));
|
|
162
|
+
const transfer = async (src: string) => {
|
|
163
|
+
if (/^(https?:|data:)/i.test(src) || src.startsWith("/uploads/")) return src;
|
|
164
|
+
const bytes = await readFile(src);
|
|
165
|
+
const mime = ({ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".gif": "image/gif" } as Record<string, string>)[extname(src).toLowerCase()] || "image/png";
|
|
166
|
+
const dataUrl = `data:${mime};base64,${bytes.toString("base64")}`;
|
|
167
|
+
sources.set(dataUrl, src);
|
|
168
|
+
return dataUrl;
|
|
169
|
+
};
|
|
170
|
+
const requestBody = structuredClone(body);
|
|
171
|
+
for (const image of requestBody.images || []) image.src = await transfer(image.src);
|
|
172
|
+
// 保留模板样式时,备用配置不参与正常模板渲染,也不读取其本地素材。
|
|
173
|
+
if (!requestBody.preserveTemplateStyle) {
|
|
174
|
+
for (const image of requestBody.config?.images || []) image.src = await transfer(image.src);
|
|
175
|
+
}
|
|
176
|
+
for (const [key, src] of Object.entries(requestBody.templateImageOverrides || {})) {
|
|
177
|
+
try {
|
|
178
|
+
requestBody.templateImageOverrides![key] = await transfer(src);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw new Error(`模板素材 ${key} 加载失败:${error instanceof Error ? error.message : String(error)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const coverTheme = requestBody.config?.coverTheme;
|
|
184
|
+
for (const image of requestBody.preserveTemplateStyle ? [] : [coverTheme?.background.image, coverTheme?.decoration.image]) {
|
|
185
|
+
if (image) image.src = await transfer(image.src);
|
|
186
|
+
}
|
|
154
187
|
const res = await fetch(`${studioUrl}/api/render/export`, {
|
|
155
188
|
method: "POST",
|
|
156
|
-
headers: { "Content-Type": "application/json" },
|
|
157
|
-
body: JSON.stringify(
|
|
189
|
+
headers: { "Content-Type": "application/json", "X-Admin-Key": process.env.TYPESET_ADMIN_KEY || "" },
|
|
190
|
+
body: JSON.stringify(requestBody),
|
|
158
191
|
});
|
|
159
192
|
if (!res.ok) {
|
|
160
|
-
|
|
193
|
+
const detail = (await res.text()).slice(0, 500);
|
|
194
|
+
throw new Error(`Cloud Typeset Studio returned HTTP ${res.status}: ${detail || res.statusText}`);
|
|
161
195
|
}
|
|
162
196
|
const data = (await res.json()) as {
|
|
163
197
|
ok: boolean;
|
|
164
198
|
pageCount: number;
|
|
165
|
-
pages: Array<{ pageNumber: number; dataUrl: string }>;
|
|
199
|
+
pages: Array<{ pageNumber: number; dataUrl: string; imageCount: number; imageSources: string[]; imageIds: string[]; bodyImageCount: number; bodyImageSources: string[]; bodyImageIds: string[] }>;
|
|
166
200
|
};
|
|
167
201
|
const assets: RenderAsset[] = [];
|
|
168
202
|
const pagesSummary: ImageRenderOutput["pages"] = [];
|
|
169
203
|
|
|
170
|
-
|
|
204
|
+
if (!data.ok || !Array.isArray(data.pages)) throw new Error("Invalid Typeset Studio export response");
|
|
205
|
+
for (const pageItem of (kind === "cover" ? data.pages.slice(0, 1) : data.pages)) {
|
|
171
206
|
const pageNum = pageItem.pageNumber;
|
|
172
207
|
const base64Data = pageItem.dataUrl.replace(/^data:image\/png;base64,/, "");
|
|
173
208
|
const buffer = Buffer.from(base64Data, "base64");
|
|
@@ -184,12 +219,12 @@ async function renderRemoteBatch(
|
|
|
184
219
|
});
|
|
185
220
|
pagesSummary.push({
|
|
186
221
|
page: pageNum,
|
|
187
|
-
imageCount:
|
|
188
|
-
imageSources: [],
|
|
222
|
+
imageCount: pageItem.bodyImageCount,
|
|
223
|
+
imageSources: pageItem.bodyImageSources.map((src, index) => originalImages.get(pageItem.bodyImageIds[index]) || sources.get(src) || src),
|
|
189
224
|
});
|
|
190
225
|
}
|
|
191
226
|
|
|
192
|
-
return { assets, pageCount:
|
|
227
|
+
return { assets, pageCount: assets.length, pages: pagesSummary };
|
|
193
228
|
}
|
|
194
229
|
|
|
195
230
|
// 本地渲染与远程服务共用请求,保留各自的图片输出方式。
|
|
@@ -199,6 +234,9 @@ async function renderLocalBatch(
|
|
|
199
234
|
route: RenderAsset["route"],
|
|
200
235
|
kind: RenderAsset["kind"],
|
|
201
236
|
): Promise<ImageRenderOutput> {
|
|
237
|
+
const [{ resolveTypesetResult }, { renderTypesetResultToPngs }] = await Promise.all([
|
|
238
|
+
import("./server/routes/render"), import("./core/canvas-renderer"),
|
|
239
|
+
]);
|
|
202
240
|
const { result } = resolveTypesetResult(body);
|
|
203
241
|
const pngs = await renderTypesetResultToPngs(result);
|
|
204
242
|
const outputs = kind === "cover" ? pngs.slice(0, 1) : pngs;
|
|
@@ -216,7 +254,7 @@ async function renderLocalBatch(
|
|
|
216
254
|
const path = join(outputDir, filename);
|
|
217
255
|
await writeFile(path, output.buffer);
|
|
218
256
|
assets.push({ kind, route, path, ...(kind === "page" ? { index: pageNumber } : {}) });
|
|
219
|
-
const images = result.pages[index].images;
|
|
257
|
+
const images = result.pages[index].images.filter((image) => !image.templateAssetKey);
|
|
220
258
|
pages.push({
|
|
221
259
|
page: pageNumber,
|
|
222
260
|
imageCount: images.length,
|
|
@@ -228,19 +266,23 @@ async function renderLocalBatch(
|
|
|
228
266
|
|
|
229
267
|
export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
230
268
|
name: "typeset-studio",
|
|
231
|
-
version: "0.2.
|
|
269
|
+
version: "0.2.4",
|
|
232
270
|
|
|
233
271
|
async doctor(): Promise<PipelinePluginDoctorCheck[]> {
|
|
234
272
|
const checks: PipelinePluginDoctorCheck[] = [];
|
|
235
273
|
|
|
274
|
+
const REMOTE_STUDIO_URL = remoteStudioUrl();
|
|
236
275
|
if (REMOTE_STUDIO_URL) {
|
|
237
276
|
try {
|
|
238
|
-
const res = await fetch(`${REMOTE_STUDIO_URL}/api/
|
|
239
|
-
|
|
277
|
+
const res = await fetch(`${REMOTE_STUDIO_URL}/api/auth/verify`, {
|
|
278
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
279
|
+
body: JSON.stringify({ key: process.env.TYPESET_ADMIN_KEY || "" }),
|
|
280
|
+
});
|
|
281
|
+
const data = (await res.json()) as { ok?: boolean };
|
|
240
282
|
checks.push({
|
|
241
283
|
name: "remote-service",
|
|
242
|
-
ok: res.ok,
|
|
243
|
-
message: `
|
|
284
|
+
ok: res.ok && data.ok === true,
|
|
285
|
+
message: data.ok ? `Authenticated with Typeset Studio at ${REMOTE_STUDIO_URL}` : "Remote admin key is missing or invalid",
|
|
244
286
|
});
|
|
245
287
|
} catch (err) {
|
|
246
288
|
checks.push({
|
|
@@ -273,6 +315,7 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
273
315
|
}
|
|
274
316
|
|
|
275
317
|
try {
|
|
318
|
+
const { scanLocalFonts } = await import("./core/font-loader");
|
|
276
319
|
const localFonts = scanLocalFonts();
|
|
277
320
|
checks.push({
|
|
278
321
|
name: "local-fonts",
|
|
@@ -295,15 +338,35 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
295
338
|
await mkdir(outputDir, { recursive: true });
|
|
296
339
|
if (route === "blog") return { assets: [], pageCount: 0, pages: [] };
|
|
297
340
|
|
|
341
|
+
const REMOTE_STUDIO_URL = remoteStudioUrl();
|
|
298
342
|
const account = state.route.account || "default";
|
|
343
|
+
const saved = state.intent?.newspic_render;
|
|
344
|
+
const pageSpecs = input.pageSpecs ?? saved?.page_specs?.map((spec) => ({
|
|
345
|
+
page: spec.page, imageMarkers: spec.image_markers, imageLayout: spec.image_layout, targetFillRatio: spec.target_fill_ratio,
|
|
346
|
+
}));
|
|
347
|
+
const visual = input.accountVisualParams || {};
|
|
348
|
+
const stateVisual = state.route.account_visual_params;
|
|
349
|
+
const footer = visual.footer ?? stateVisual?.footer;
|
|
350
|
+
const bg = visual.bg ?? stateVisual?.bg;
|
|
351
|
+
const highlight = visual.highlight ?? stateVisual?.highlight;
|
|
352
|
+
const fallbackIcon = visual.fallbackIcon ?? stateVisual?.fallback_icon;
|
|
353
|
+
// pipeline 的模式标记只选择分页方式,具体模板名才参与模板解析。
|
|
354
|
+
const template = ["wechat-cover-split", "poster-3-4", "longform-3-4"].includes(input.template || "") ? undefined : input.template;
|
|
355
|
+
input.onProgress?.({ stage: "typeset", message: "开始排版", route, account });
|
|
299
356
|
const title = cleanTitle(input.title || state.metadata?.title || "");
|
|
300
357
|
const common: RenderRequestBody = {
|
|
301
358
|
account,
|
|
302
359
|
title,
|
|
303
|
-
|
|
304
|
-
images: mapBodyImages(input.bodyImages,
|
|
360
|
+
preserveTemplateStyle: true,
|
|
361
|
+
images: mapBodyImages(input.bodyImages, pageSpecs),
|
|
305
362
|
highlightWords: state.route.highlight_words || [],
|
|
306
|
-
config: {
|
|
363
|
+
config: {
|
|
364
|
+
...(footer !== undefined ? { footerText: footer } : {}),
|
|
365
|
+
watermarkText: `@${account}`,
|
|
366
|
+
...((bg || highlight) ? { theme: {
|
|
367
|
+
...(bg ? { bgColor: bg } : {}), ...(highlight ? { accentColor: highlight } : {}),
|
|
368
|
+
} } : {}),
|
|
369
|
+
},
|
|
307
370
|
};
|
|
308
371
|
const articleCover = route === "wechat-article";
|
|
309
372
|
const renderRoute = articleCover ? "wechat-article" : "wechat-newspic";
|
|
@@ -314,13 +377,34 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
314
377
|
? renderRemoteBatch(REMOTE_STUDIO_URL, body, outputDir, renderRoute, kind)
|
|
315
378
|
: renderLocalBatch(body, outputDir, renderRoute, kind);
|
|
316
379
|
|
|
380
|
+
const coverTheme = input.coverTheme;
|
|
381
|
+
const coverConfig = { ...common.config, algorithm: { targetFillRatio: 1 } };
|
|
382
|
+
if (coverTheme) {
|
|
383
|
+
coverConfig.coverTheme = coverTheme;
|
|
384
|
+
if (footer) coverConfig.chrome = { showHeader: false, showFooter: true, showFooterDivider: false, showFooterText: true };
|
|
385
|
+
coverConfig.algorithm = { ...coverTheme.layout, targetFillRatio: 1 };
|
|
386
|
+
coverConfig.theme = {
|
|
387
|
+
bgColor: coverTheme.background.fill.type === "solid" ? coverTheme.background.fill.color : "transparent",
|
|
388
|
+
textColor: coverTheme.colors.text, headingColor: coverTheme.colors.text,
|
|
389
|
+
accentColor: coverTheme.colors.accent, quoteColor: coverTheme.colors.muted,
|
|
390
|
+
captionColor: coverTheme.colors.muted, watermarkColor: coverTheme.colors.muted,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const firstPageImages = (common.images || []).filter((image) => !image.pageIndex || image.pageIndex === 1)
|
|
394
|
+
.map((image) => ({ ...image, pageIndex: 1 }));
|
|
395
|
+
if (fallbackIcon) coverConfig.images = [{
|
|
396
|
+
id: "account_icon", src: fallbackIcon, alt: "账号标识", isAbsolute: true,
|
|
397
|
+
x: 0, y: 0, width: 48, height: 48, zIndex: 20, pageIndex: 1,
|
|
398
|
+
templateAssetKey: "account_icon",
|
|
399
|
+
}];
|
|
317
400
|
const cover = await renderBatch({
|
|
318
|
-
...common,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
markdown: `# ${title}`,
|
|
401
|
+
...common, config: coverConfig, images: firstPageImages,
|
|
402
|
+
template: input.studioTemplates?.cover || template,
|
|
403
|
+
templateImageOverrides: input.templateImageOverrides?.cover,
|
|
404
|
+
route: renderRoute, slot: articleCover ? "article_cover" : "newspic_cover",
|
|
405
|
+
kind: "cover", maxPages: 1, markdown: `# ${title}`,
|
|
323
406
|
}, "cover");
|
|
407
|
+
input.onProgress?.({ stage: "cover", message: "封面已生成", current: 1, total: 1, unit: "pages", route, account });
|
|
324
408
|
const paginationMode = input.template === "longform-3-4"
|
|
325
409
|
? "multi"
|
|
326
410
|
: input.template === "poster-3-4"
|
|
@@ -331,10 +415,17 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
|
|
|
331
415
|
const content = await renderBatch({
|
|
332
416
|
...common,
|
|
333
417
|
route: "wechat-newspic",
|
|
418
|
+
template: input.studioTemplates?.content || template,
|
|
419
|
+
templateImageOverrides: input.templateImageOverrides?.content,
|
|
334
420
|
kind: "content",
|
|
335
421
|
slot: "newspic_content",
|
|
336
422
|
markdown: bodyText || `# ${title}`,
|
|
423
|
+
config: { ...common.config, ...(saved?.target_fill_ratio != null ? { algorithm: { targetFillRatio: saved.target_fill_ratio } } : {}) },
|
|
424
|
+
minPages: input.minPages ?? saved?.min_pages,
|
|
425
|
+
maxPages: input.maxPages ?? saved?.max_pages,
|
|
426
|
+
pageFillRatios: Object.fromEntries((pageSpecs || []).filter((spec) => spec.targetFillRatio != null).map((spec) => [spec.page, spec.targetFillRatio!])),
|
|
337
427
|
}, "page");
|
|
428
|
+
input.onProgress?.({ stage: "content", message: "正文已生成", current: content.pageCount, total: content.pageCount, unit: "pages", route, account });
|
|
338
429
|
return { ...content, assets: [...cover.assets, ...content.assets] };
|
|
339
430
|
},
|
|
340
431
|
};
|
package/src/core/canvas-draw.ts
CHANGED
|
@@ -92,6 +92,83 @@ export async function drawPageToContext(
|
|
|
92
92
|
ctx.fillRect(0, 0, spec.width, spec.height);
|
|
93
93
|
ctx.restore();
|
|
94
94
|
|
|
95
|
+
// 主题背景、纹理和装饰图使用整张画布坐标。
|
|
96
|
+
const coverTheme = config.coverTheme;
|
|
97
|
+
const drawCoverImage = async (image: NonNullable<typeof coverTheme>["background"]["image"]) => {
|
|
98
|
+
if (!image) return;
|
|
99
|
+
const loaded = await getImage(image.src);
|
|
100
|
+
if (!loaded) throw new Error("Failed to load cover theme image");
|
|
101
|
+
const scale = image.fit === "contain" ? Math.min(spec.width / loaded.width, spec.height / loaded.height)
|
|
102
|
+
: Math.max(spec.width / loaded.width, spec.height / loaded.height);
|
|
103
|
+
const width = image.fit === "fill" ? spec.width : loaded.width * scale;
|
|
104
|
+
const height = image.fit === "fill" ? spec.height : loaded.height * scale;
|
|
105
|
+
ctx.save();
|
|
106
|
+
ctx.globalAlpha = image.opacity;
|
|
107
|
+
ctx.drawImage(loaded, (spec.width - width) * image.position.x / 100,
|
|
108
|
+
(spec.height - height) * image.position.y / 100, width, height);
|
|
109
|
+
ctx.restore();
|
|
110
|
+
};
|
|
111
|
+
if (coverTheme) {
|
|
112
|
+
const fill = coverTheme.background.fill;
|
|
113
|
+
ctx.save();
|
|
114
|
+
if (fill.type === "solid") ctx.fillStyle = fill.color;
|
|
115
|
+
else {
|
|
116
|
+
let gradient;
|
|
117
|
+
if (fill.type === "linear") {
|
|
118
|
+
const angle = fill.angle * Math.PI / 180;
|
|
119
|
+
const dx = Math.cos(angle) * spec.width / 2;
|
|
120
|
+
const dy = Math.sin(angle) * spec.height / 2;
|
|
121
|
+
gradient = ctx.createLinearGradient(spec.width / 2 - dx, spec.height / 2 - dy, spec.width / 2 + dx, spec.height / 2 + dy);
|
|
122
|
+
} else {
|
|
123
|
+
const x = spec.width * fill.position.x / 100;
|
|
124
|
+
const y = spec.height * fill.position.y / 100;
|
|
125
|
+
gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.hypot(spec.width, spec.height));
|
|
126
|
+
}
|
|
127
|
+
for (const stop of fill.stops) gradient.addColorStop(stop.offset / 100, stop.color);
|
|
128
|
+
ctx.fillStyle = gradient;
|
|
129
|
+
}
|
|
130
|
+
ctx.fillRect(0, 0, spec.width, spec.height);
|
|
131
|
+
ctx.restore();
|
|
132
|
+
await drawCoverImage(coverTheme.background.image);
|
|
133
|
+
if (coverTheme.background.overlay) {
|
|
134
|
+
ctx.save();
|
|
135
|
+
ctx.fillStyle = coverTheme.background.overlay.color;
|
|
136
|
+
ctx.globalAlpha = coverTheme.background.overlay.opacity;
|
|
137
|
+
ctx.fillRect(0, 0, spec.width, spec.height);
|
|
138
|
+
ctx.restore();
|
|
139
|
+
}
|
|
140
|
+
const decoration = coverTheme.decoration;
|
|
141
|
+
ctx.save();
|
|
142
|
+
ctx.strokeStyle = ctx.fillStyle = decoration.color;
|
|
143
|
+
ctx.globalAlpha = decoration.opacity;
|
|
144
|
+
const size = Math.max(8, decoration.size);
|
|
145
|
+
ctx.beginPath();
|
|
146
|
+
if (["lines", "grid", "dots"].includes(decoration.pattern)) {
|
|
147
|
+
for (let x = 0; x < spec.width; x += size) {
|
|
148
|
+
if (decoration.pattern === "grid") { ctx.moveTo(x, 0); ctx.lineTo(x, spec.height); }
|
|
149
|
+
if (decoration.pattern === "dots") {
|
|
150
|
+
for (let y = 0; y < spec.height; y += size) { ctx.moveTo(x + 2, y); ctx.arc(x, y, 2, 0, Math.PI * 2); }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (decoration.pattern !== "dots") {
|
|
154
|
+
for (let y = 0; y < spec.height; y += size) { ctx.moveTo(0, y); ctx.lineTo(spec.width, y); }
|
|
155
|
+
}
|
|
156
|
+
} else if (decoration.pattern === "rings") {
|
|
157
|
+
for (let radius = size; radius < Math.hypot(spec.width, spec.height); radius += size) {
|
|
158
|
+
ctx.moveTo(spec.width / 2 + radius, spec.height / 2);
|
|
159
|
+
ctx.arc(spec.width / 2, spec.height / 2, radius, 0, Math.PI * 2);
|
|
160
|
+
}
|
|
161
|
+
} else if (decoration.pattern === "burst") {
|
|
162
|
+
for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / Math.max(8, Math.round(size / 3))) {
|
|
163
|
+
ctx.moveTo(spec.width / 2, spec.height / 2);
|
|
164
|
+
ctx.lineTo(spec.width / 2 + Math.cos(angle) * spec.width, spec.height / 2 + Math.sin(angle) * spec.height);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (decoration.pattern === "dots") ctx.fill(); else ctx.stroke();
|
|
168
|
+
ctx.restore();
|
|
169
|
+
await drawCoverImage(decoration.image);
|
|
170
|
+
}
|
|
171
|
+
|
|
95
172
|
// 2. Compute Stage Layout Dimensions
|
|
96
173
|
const { stageX, stageY } = computeStageDimensions(
|
|
97
174
|
spec,
|
|
@@ -154,48 +231,58 @@ export async function drawPageToContext(
|
|
|
154
231
|
return za - zb;
|
|
155
232
|
});
|
|
156
233
|
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
234
|
+
const drawImages = async (images: typeof sortedImages) => {
|
|
235
|
+
for (const img of images) {
|
|
236
|
+
let loadedImg;
|
|
237
|
+
try {
|
|
238
|
+
loadedImg = await getImage(img.src);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (img.templateAssetKey) throw new Error(`模板素材 ${img.templateAssetKey} 加载失败:${error instanceof Error ? error.message : String(error)}`);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (!loadedImg && img.templateAssetKey) throw new Error(`模板素材 ${img.templateAssetKey} 加载失败:图片不存在或无法读取`);
|
|
244
|
+
const imgX = stageX + img.x;
|
|
245
|
+
const imgY = stageY + img.y;
|
|
246
|
+
const centerX = imgX + img.width / 2;
|
|
247
|
+
const centerY = imgY + img.height / 2;
|
|
163
248
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
249
|
+
ctx.save();
|
|
250
|
+
ctx.translate(centerX, centerY);
|
|
251
|
+
if (img.rotate) {
|
|
252
|
+
ctx.rotate((img.rotate * Math.PI) / 180);
|
|
253
|
+
}
|
|
254
|
+
ctx.globalAlpha = img.opacity ?? 1;
|
|
170
255
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
256
|
+
const w = img.width;
|
|
257
|
+
const h = img.height;
|
|
258
|
+
const radius = Math.min(img.borderRadius ?? 12, w / 2, h / 2);
|
|
174
259
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (loadedImg) {
|
|
180
|
-
ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
|
|
181
|
-
} else {
|
|
182
|
-
ctx.fillStyle = theme.cardBg || "#222222";
|
|
183
|
-
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
184
|
-
}
|
|
185
|
-
ctx.restore();
|
|
260
|
+
ctx.beginPath();
|
|
261
|
+
ctx.roundRect(-w / 2, -h / 2, w, h, radius);
|
|
262
|
+
ctx.clip();
|
|
186
263
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
ctx.textBaseline = "top";
|
|
194
|
-
ctx.textAlign = "center";
|
|
195
|
-
ctx.fillText(img.caption, centerX, imgY + h + 12);
|
|
264
|
+
if (loadedImg) {
|
|
265
|
+
ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
|
|
266
|
+
} else {
|
|
267
|
+
ctx.fillStyle = theme.cardBg || "#222222";
|
|
268
|
+
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
269
|
+
}
|
|
196
270
|
ctx.restore();
|
|
271
|
+
|
|
272
|
+
// Image Caption (e.g., "图 1:建筑结构中的空间秩序感")
|
|
273
|
+
if (img.caption?.trim()) {
|
|
274
|
+
ctx.save();
|
|
275
|
+
const capFontSize = Math.max(14, Math.round(algorithm.baseFontSize * 0.45));
|
|
276
|
+
ctx.font = resolveCanvasFont(`400 ${capFontSize}px PingFang SC`, isServer);
|
|
277
|
+
ctx.fillStyle = theme.captionColor;
|
|
278
|
+
ctx.textBaseline = "top";
|
|
279
|
+
ctx.textAlign = "center";
|
|
280
|
+
ctx.fillText(img.caption, centerX, imgY + h + 12);
|
|
281
|
+
ctx.restore();
|
|
282
|
+
}
|
|
197
283
|
}
|
|
198
|
-
}
|
|
284
|
+
};
|
|
285
|
+
await drawImages(sortedImages.filter((image) => (image.zIndex ?? (image.isAbsolute ? 20 : 5)) < 10));
|
|
199
286
|
|
|
200
287
|
// 5. Text Flow Lines & Fragments
|
|
201
288
|
for (const line of page.lines) {
|
|
@@ -258,15 +345,20 @@ export async function drawPageToContext(
|
|
|
258
345
|
ctx.fillStyle = theme.textColor;
|
|
259
346
|
}
|
|
260
347
|
|
|
348
|
+
ctx.letterSpacing = `${fragment.letterSpacing || 0}px`;
|
|
261
349
|
ctx.fillText(fragment.text, cursorX, lineY);
|
|
262
350
|
|
|
263
351
|
// Measure fragment advance precisely with ctx.measureText
|
|
264
352
|
// to ensure zero text overlap regardless of font metrics
|
|
265
|
-
const measuredWidth = ctx.measureText(fragment.text).width;
|
|
353
|
+
const measuredWidth = fragment.letterSpacing ? fragment.occupiedWidth : ctx.measureText(fragment.text).width;
|
|
266
354
|
cursorX += measuredWidth + (fragment.gapBefore || 0);
|
|
267
355
|
}
|
|
268
356
|
}
|
|
269
357
|
|
|
358
|
+
await drawImages(sortedImages.filter((image) => (image.zIndex ?? (image.isAbsolute ? 20 : 5)) >= 10));
|
|
359
|
+
|
|
360
|
+
ctx.letterSpacing = "0px";
|
|
361
|
+
|
|
270
362
|
// 6. Footer Section
|
|
271
363
|
const showFooter = chrome?.showFooter ?? !isCover;
|
|
272
364
|
const showFooterDivider = chrome?.showFooterDivider ?? (!isCover && Boolean(config.footerText?.trim()));
|
|
@@ -286,9 +378,20 @@ export async function drawPageToContext(
|
|
|
286
378
|
ctx.restore();
|
|
287
379
|
}
|
|
288
380
|
|
|
381
|
+
if (chrome?.showFillRatio) {
|
|
382
|
+
ctx.save();
|
|
383
|
+
ctx.font = resolveCanvasFont("500 13px PingFang SC", isServer);
|
|
384
|
+
ctx.fillStyle = theme.captionColor;
|
|
385
|
+
ctx.textBaseline = "middle";
|
|
386
|
+
ctx.textAlign = "right";
|
|
387
|
+
ctx.fillText(`填充率 ${Math.round(page.fillRatio * 100)}%`, spec.width - algorithm.safeArea.right, footerY);
|
|
388
|
+
ctx.restore();
|
|
389
|
+
}
|
|
289
390
|
if (showFooterText) {
|
|
290
391
|
ctx.save();
|
|
291
|
-
|
|
392
|
+
const font = coverTheme?.typography.footer;
|
|
393
|
+
ctx.font = resolveCanvasFont(font ? `${font.fontWeight} ${font.fontSize}px "${font.fontFamily}"` : '500 13px PingFang SC', isServer);
|
|
394
|
+
ctx.letterSpacing = `${font?.letterSpacing || 0}px`;
|
|
292
395
|
ctx.fillStyle = theme.watermarkColor || theme.captionColor;
|
|
293
396
|
ctx.globalAlpha = 0.6;
|
|
294
397
|
ctx.textBaseline = "middle";
|