@akairos/typeset-studio 0.2.2 → 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.
Files changed (59) hide show
  1. package/README.md +90 -93
  2. package/dist/assets/index-3Ko1UCBU.css +1 -0
  3. package/dist/assets/index-C9J0f4Lf.js +317 -0
  4. package/dist/index.html +2 -2
  5. package/dist/lib/adapter.js +6 -7582
  6. package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
  7. package/dist/lib/font-loader-j28mt43t.js +11 -0
  8. package/dist/lib/index-0370qz9e.js +303 -0
  9. package/dist/lib/index-37x76zdn.js +4 -0
  10. package/dist/lib/index-6c43c7h4.js +3880 -0
  11. package/dist/lib/index-t9w3p2p9.js +3366 -0
  12. package/dist/lib/index-zdhqg5f0.js +459 -0
  13. package/dist/lib/index.js +26 -7582
  14. package/dist/lib/render-xxd348m1.js +11 -0
  15. package/package.json +2 -3
  16. package/src/adapter.test.ts +117 -0
  17. package/src/adapter.ts +121 -30
  18. package/src/core/canvas-draw.ts +141 -38
  19. package/src/core/canvas-renderer.test.ts +58 -0
  20. package/src/core/canvas-renderer.ts +2 -1
  21. package/src/core/data-dir.ts +13 -0
  22. package/src/core/engine.ts +74 -36
  23. package/src/core/font-loader.test.ts +20 -0
  24. package/src/core/font-loader.ts +6 -2
  25. package/src/core/image-layout.ts +4 -0
  26. package/src/core/inline-text.ts +6 -4
  27. package/src/core/obstacle-flow.ts +29 -15
  28. package/src/core/page-images.test.ts +90 -0
  29. package/src/core/pretext.ts +4 -2
  30. package/src/core/template.test.ts +38 -1
  31. package/src/core/template.ts +52 -40
  32. package/src/core/types.ts +46 -0
  33. package/src/index.ts +4 -1
  34. package/src/server/auth.ts +18 -13
  35. package/src/server/index.ts +10 -15
  36. package/src/server/routes/render.ts +60 -17
  37. package/src/server/routes/slots.test.ts +96 -0
  38. package/src/server/routes/templates.ts +34 -35
  39. package/src/server/routes/upload.ts +2 -1
  40. package/src/web/App.tsx +15 -4
  41. package/src/web/components/CanvasViewport.tsx +56 -42
  42. package/src/web/components/EditorSidebar.tsx +35 -0
  43. package/src/web/components/TemplateModal.tsx +22 -14
  44. package/src/web/hooks/image-layout.ts +31 -0
  45. package/src/web/hooks/useLocalFonts.ts +10 -1
  46. package/src/web/hooks/useTypeset.ts +119 -32
  47. package/data/templates/.gitkeep +0 -0
  48. package/data/templates/tpl_1791681833354_iofsk.json +0 -46
  49. package/data/templates/tpl_1791682603199_875ny.json +0 -66
  50. package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
  51. package/data/templates/tpl_longform_paper_sage.json +0 -47
  52. package/data/templates/tpl_longform_vintage_news.json +0 -47
  53. package/data/templates/tpl_newspic_poster_cover.json +0 -64
  54. package/data/templates/tpl_square_quote_card.json +0 -46
  55. package/data/templates/tpl_wechat_cover_editorial.json +0 -64
  56. package/data/templates/tpl_wechat_cover_minimal.json +0 -64
  57. package/data/templates/tpl_wide_tech_banner.json +0 -46
  58. package/dist/assets/index-CZ352D7X.css +0 -1
  59. package/dist/assets/index-DDeEGeHD.js +0 -317
@@ -0,0 +1,11 @@
1
+ import {
2
+ renderRouter,
3
+ resolveTypesetResult
4
+ } from "./index-t9w3p2p9.js";
5
+ import"./index-zdhqg5f0.js";
6
+ import"./index-6c43c7h4.js";
7
+ import"./index-37x76zdn.js";
8
+ export {
9
+ resolveTypesetResult,
10
+ renderRouter
11
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/typeset-studio",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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",
@@ -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 { renderTypesetResultToPngs } from "./core/canvas-renderer";
4
- import { scanLocalFonts } from "./core/font-loader";
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
- coverTheme?: any;
19
- onProgress?: (progress: any) => void;
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 REMOTE_STUDIO_URL = process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
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(body),
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
- throw new Error(`Cloud Typeset Studio returned HTTP ${res.status}: ${res.statusText}`);
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
- for (const pageItem of data.pages) {
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: 0,
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: data.pageCount, pages: pagesSummary };
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,
@@ -233,14 +271,18 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
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/health`);
239
- const data = (await res.json()) as { name?: string };
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: `Connected to Cloud Typeset Studio at ${REMOTE_STUDIO_URL} (${data.name || "active"})`,
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
- template: input.template,
304
- images: mapBodyImages(input.bodyImages, input.pageSpecs),
360
+ preserveTemplateStyle: true,
361
+ images: mapBodyImages(input.bodyImages, pageSpecs),
305
362
  highlightWords: state.route.highlight_words || [],
306
- config: { footerText: input.accountVisualParams?.footer, watermarkText: `@${account}` },
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
- route: renderRoute,
320
- slot: articleCover ? "article_cover" : "newspic_cover",
321
- ...(articleCover ? {} : { kind: "cover" }),
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
  };
@@ -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
- for (const img of sortedImages) {
158
- const loadedImg = await getImage(img.src);
159
- const imgX = stageX + img.x;
160
- const imgY = stageY + img.y;
161
- const centerX = imgX + img.width / 2;
162
- const centerY = imgY + img.height / 2;
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
- ctx.save();
165
- ctx.translate(centerX, centerY);
166
- if (img.rotate) {
167
- ctx.rotate((img.rotate * Math.PI) / 180);
168
- }
169
- ctx.globalAlpha = img.opacity ?? 1;
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
- const w = img.width;
172
- const h = img.height;
173
- const radius = Math.min(img.borderRadius ?? 12, w / 2, h / 2);
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
- ctx.beginPath();
176
- ctx.roundRect(-w / 2, -h / 2, w, h, radius);
177
- ctx.clip();
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
- // Image Caption (e.g., "图 1:建筑结构中的空间秩序感")
188
- if (img.caption?.trim()) {
189
- ctx.save();
190
- const capFontSize = Math.max(14, Math.round(algorithm.baseFontSize * 0.45));
191
- ctx.font = resolveCanvasFont(`400 ${capFontSize}px PingFang SC`, isServer);
192
- ctx.fillStyle = theme.captionColor;
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
- ctx.font = resolveCanvasFont('500 13px PingFang SC', isServer);
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";