@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.
Files changed (62) hide show
  1. package/README.md +91 -95
  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 -7734
  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 -7734
  14. package/dist/lib/render-xxd348m1.js +11 -0
  15. package/package.json +3 -7
  16. package/src/adapter.test.ts +117 -0
  17. package/src/adapter.ts +190 -281
  18. package/src/core/canvas-draw.ts +150 -47
  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 +86 -56
  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 -10
  30. package/src/core/template.test.ts +38 -1
  31. package/src/core/template.ts +53 -41
  32. package/src/core/types.ts +46 -0
  33. package/src/core/typography.ts +0 -14
  34. package/src/index.ts +6 -3
  35. package/src/server/auth.ts +18 -13
  36. package/src/server/index.ts +11 -16
  37. package/src/server/routes/fonts.ts +1 -1
  38. package/src/server/routes/render.ts +60 -17
  39. package/src/server/routes/slots.test.ts +96 -0
  40. package/src/server/routes/templates.ts +37 -48
  41. package/src/server/routes/upload.ts +2 -1
  42. package/src/web/App.tsx +15 -6
  43. package/src/web/components/CanvasViewport.tsx +56 -42
  44. package/src/web/components/EditorSidebar.tsx +59 -48
  45. package/src/web/components/Header.tsx +1 -1
  46. package/src/web/components/TemplateModal.tsx +25 -28
  47. package/src/web/hooks/image-layout.ts +31 -0
  48. package/src/web/hooks/useLocalFonts.ts +10 -1
  49. package/src/web/hooks/useTypeset.ts +139 -100
  50. package/data/templates/.gitkeep +0 -0
  51. package/data/templates/tpl_1791681833354_iofsk.json +0 -46
  52. package/data/templates/tpl_1791682603199_875ny.json +0 -66
  53. package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
  54. package/data/templates/tpl_longform_paper_sage.json +0 -47
  55. package/data/templates/tpl_longform_vintage_news.json +0 -47
  56. package/data/templates/tpl_newspic_poster_cover.json +0 -64
  57. package/data/templates/tpl_square_quote_card.json +0 -46
  58. package/data/templates/tpl_wechat_cover_editorial.json +0 -64
  59. package/data/templates/tpl_wechat_cover_minimal.json +0 -64
  60. package/data/templates/tpl_wide_tech_banner.json +0 -46
  61. package/dist/assets/index-CZ352D7X.css +0 -1
  62. package/dist/assets/index-Dpnz-F2g.js +0 -317
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 { FlowImage } from "./core/types";
6
- import { resolveTypesetResult } 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[];
@@ -102,11 +107,13 @@ export interface ImageRenderPlugin {
102
107
 
103
108
  // ── Helpers ───────────────────────────────────────────────────────
104
109
 
110
+ // 清理流水线标题中的 Markdown 标记。
105
111
  function cleanTitle(raw: string): string {
106
112
  if (!raw) return "未命名文章";
107
113
  return raw.replace(/^#+\s*/, "").trim();
108
114
  }
109
115
 
116
+ // 将流水线图片标记映射为页码和排版预设。
110
117
  function mapBodyImages(
111
118
  bodyImages?: Array<{ marker: string; path: string }>,
112
119
  pageSpecs?: ImageRenderInput["pageSpecs"],
@@ -131,46 +138,76 @@ function mapBodyImages(
131
138
  src: img.path,
132
139
  alt: img.marker,
133
140
  pageIndex: spec?.page,
134
- layoutPreset: (spec?.imageLayout as any) || "mid-right",
141
+ layoutPreset: (spec?.imageLayout as FlowImage["layoutPreset"]) || "mid-right",
135
142
  };
136
143
  });
137
144
  }
138
145
 
139
146
  // ── Typeset Studio Pipeline Adapter ───────────────────────────────
140
147
 
141
- const REMOTE_STUDIO_URL = process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
148
+ // 配置远程地址后,由独立服务负责图片渲染。
149
+ const remoteStudioUrl = () => process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
142
150
 
151
+ // 下载远程生成的页面,并写入流水线输出目录。
143
152
  async function renderRemoteBatch(
144
153
  studioUrl: string,
145
- body: any,
154
+ body: RenderRequestBody,
146
155
  outputDir: string,
147
- filenamePrefix: string,
148
156
  route: "wechat-article" | "wechat-newspic",
149
157
  kind: "cover" | "page",
150
- ): Promise<{ assets: RenderAsset[]; pageCount: number; pages: ImageRenderOutput["pages"] }> {
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
+ }
151
187
  const res = await fetch(`${studioUrl}/api/render/export`, {
152
188
  method: "POST",
153
- headers: { "Content-Type": "application/json" },
154
- body: JSON.stringify(body),
189
+ headers: { "Content-Type": "application/json", "X-Admin-Key": process.env.TYPESET_ADMIN_KEY || "" },
190
+ body: JSON.stringify(requestBody),
155
191
  });
156
192
  if (!res.ok) {
157
- 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}`);
158
195
  }
159
196
  const data = (await res.json()) as {
160
197
  ok: boolean;
161
198
  pageCount: number;
162
- 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[] }>;
163
200
  };
164
201
  const assets: RenderAsset[] = [];
165
202
  const pagesSummary: ImageRenderOutput["pages"] = [];
166
203
 
167
- for (let i = 0; i < data.pages.length; i++) {
168
- const pageItem = data.pages[i];
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)) {
169
206
  const pageNum = pageItem.pageNumber;
170
207
  const base64Data = pageItem.dataUrl.replace(/^data:image\/png;base64,/, "");
171
208
  const buffer = Buffer.from(base64Data, "base64");
172
209
  const filename =
173
- kind === "cover" ? "cover.png" : `${filenamePrefix}-${String(pageNum).padStart(2, "0")}.png`;
210
+ kind === "cover" ? "cover.png" : `article-${String(pageNum).padStart(2, "0")}.png`;
174
211
  const filePath = join(outputDir, filename);
175
212
  await writeFile(filePath, buffer);
176
213
 
@@ -182,30 +219,70 @@ async function renderRemoteBatch(
182
219
  });
183
220
  pagesSummary.push({
184
221
  page: pageNum,
185
- imageCount: 0,
186
- imageSources: [],
222
+ imageCount: pageItem.bodyImageCount,
223
+ imageSources: pageItem.bodyImageSources.map((src, index) => originalImages.get(pageItem.bodyImageIds[index]) || sources.get(src) || src),
187
224
  });
188
225
  }
189
226
 
190
- return { assets, pageCount: data.pageCount, pages: pagesSummary };
227
+ return { assets, pageCount: assets.length, pages: pagesSummary };
228
+ }
229
+
230
+ // 本地渲染与远程服务共用请求,保留各自的图片输出方式。
231
+ async function renderLocalBatch(
232
+ body: RenderRequestBody,
233
+ outputDir: string,
234
+ route: RenderAsset["route"],
235
+ kind: RenderAsset["kind"],
236
+ ): Promise<ImageRenderOutput> {
237
+ const [{ resolveTypesetResult }, { renderTypesetResultToPngs }] = await Promise.all([
238
+ import("./server/routes/render"), import("./core/canvas-renderer"),
239
+ ]);
240
+ const { result } = resolveTypesetResult(body);
241
+ const pngs = await renderTypesetResultToPngs(result);
242
+ const outputs = kind === "cover" ? pngs.slice(0, 1) : pngs;
243
+ if (kind === "cover" && outputs.length === 0) {
244
+ throw new Error("Typeset Studio failed to render cover buffer");
245
+ }
246
+
247
+ const assets: RenderAsset[] = [];
248
+ const pages: ImageRenderOutput["pages"] = [];
249
+ for (const [index, output] of outputs.entries()) {
250
+ const pageNumber = index + 1;
251
+ const filename = kind === "cover"
252
+ ? "cover.png"
253
+ : `article-${String(pageNumber).padStart(2, "0")}.png`;
254
+ const path = join(outputDir, filename);
255
+ await writeFile(path, output.buffer);
256
+ assets.push({ kind, route, path, ...(kind === "page" ? { index: pageNumber } : {}) });
257
+ const images = result.pages[index].images.filter((image) => !image.templateAssetKey);
258
+ pages.push({
259
+ page: pageNumber,
260
+ imageCount: images.length,
261
+ imageSources: images.map((image) => image.src),
262
+ });
263
+ }
264
+ return { assets, pageCount: outputs.length, pages };
191
265
  }
192
266
 
193
267
  export const typesetStudioAdapter: ImageRenderPlugin = {
194
268
  name: "typeset-studio",
195
- version: "0.2.1",
269
+ version: "0.2.2",
196
270
 
197
271
  async doctor(): Promise<PipelinePluginDoctorCheck[]> {
198
272
  const checks: PipelinePluginDoctorCheck[] = [];
199
273
 
200
- // 0. Remote cloud service check (if TYPESET_STUDIO_URL is set)
274
+ const REMOTE_STUDIO_URL = remoteStudioUrl();
201
275
  if (REMOTE_STUDIO_URL) {
202
276
  try {
203
- const res = await fetch(`${REMOTE_STUDIO_URL}/api/health`);
204
- const data = (await res.json()) as any;
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 };
205
282
  checks.push({
206
283
  name: "remote-service",
207
- ok: res.ok,
208
- 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",
209
286
  });
210
287
  } catch (err) {
211
288
  checks.push({
@@ -216,7 +293,6 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
216
293
  }
217
294
  }
218
295
 
219
- // 1. Skia engine check (for local/in-process rendering)
220
296
  try {
221
297
  const { createCanvas } = await import("@napi-rs/canvas");
222
298
  const canvas = createCanvas(10, 10);
@@ -231,15 +307,15 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
231
307
  } catch (err) {
232
308
  checks.push({
233
309
  name: "@napi-rs/canvas",
234
- ok: !REMOTE_STUDIO_URL,
310
+ ok: Boolean(REMOTE_STUDIO_URL),
235
311
  message: REMOTE_STUDIO_URL
236
312
  ? "@napi-rs/canvas not installed locally (using Cloud Typeset Studio microservice)"
237
313
  : `@napi-rs/canvas initialization failed: ${err}`,
238
314
  });
239
315
  }
240
316
 
241
- // 2. Local fonts check
242
317
  try {
318
+ const { scanLocalFonts } = await import("./core/font-loader");
243
319
  const localFonts = scanLocalFonts();
244
320
  checks.push({
245
321
  name: "local-fonts",
@@ -258,266 +334,99 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
258
334
  },
259
335
 
260
336
  async render(input: ImageRenderInput): Promise<ImageRenderOutput> {
261
- const { route, outputDir, title, bodyText, state } = input;
337
+ const { route, outputDir, bodyText, state } = input;
262
338
  await mkdir(outputDir, { recursive: true });
339
+ if (route === "blog") return { assets: [], pageCount: 0, pages: [] };
263
340
 
341
+ const REMOTE_STUDIO_URL = remoteStudioUrl();
264
342
  const account = state.route.account || "default";
265
- const highlightWords = state.route.highlight_words || [];
266
- const footerText = input.accountVisualParams?.footer;
267
- const watermarkText = account ? `@${account}` : undefined;
268
- const effectiveTitle = cleanTitle(title || state.metadata?.title || "");
269
-
270
- // ── 0. Optional: Remote Cloud Microservice Mode ─────────────
271
- if (REMOTE_STUDIO_URL) {
272
- if (route === "wechat-article") {
273
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
274
- return await renderRemoteBatch(
275
- REMOTE_STUDIO_URL,
276
- {
277
- slot: "article_cover",
278
- route: "wechat-article",
279
- account,
280
- title: effectiveTitle,
281
- markdown: `# ${effectiveTitle}`,
282
- template: input.template,
283
- images: coverImages,
284
- highlightWords,
285
- config: { footerText, watermarkText },
286
- },
287
- outputDir,
288
- "cover",
289
- "wechat-article",
290
- "cover",
291
- );
292
- }
293
-
294
- if (route === "wechat-newspic") {
295
- const newspicSpec = state.intent?.newspic_render;
296
- const paginationMode =
297
- input.template === "longform-3-4"
298
- ? "multi"
299
- : input.template === "poster-3-4"
300
- ? "single"
301
- : newspicSpec?.pagination_mode || "multi";
302
-
303
- const isMultiPage = paginationMode === "multi";
304
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
305
-
306
- // Remote cover
307
- const coverRes = await renderRemoteBatch(
308
- REMOTE_STUDIO_URL,
309
- {
310
- slot: "newspic_cover",
311
- route: "wechat-newspic",
312
- kind: "cover",
313
- account,
314
- title: effectiveTitle,
315
- markdown: `# ${effectiveTitle}`,
316
- template: input.template,
317
- images: coverImages,
318
- highlightWords,
319
- config: { footerText, watermarkText },
320
- },
321
- outputDir,
322
- "cover",
323
- "wechat-newspic",
324
- "cover",
325
- );
326
-
327
- if (!isMultiPage) {
328
- return coverRes;
329
- }
330
-
331
- // Remote content pages
332
- const contentImages = mapBodyImages(input.bodyImages, input.pageSpecs);
333
- const contentRes = await renderRemoteBatch(
334
- REMOTE_STUDIO_URL,
335
- {
336
- slot: "newspic_content",
337
- route: "wechat-newspic",
338
- kind: "content",
339
- account,
340
- title: effectiveTitle,
341
- markdown: bodyText || `# ${effectiveTitle}`,
342
- template: input.template,
343
- images: contentImages,
344
- highlightWords,
345
- config: { footerText, watermarkText },
346
- },
347
- outputDir,
348
- "article",
349
- "wechat-newspic",
350
- "page",
351
- );
352
-
353
- return {
354
- assets: [...coverRes.assets, ...contentRes.assets],
355
- pageCount: contentRes.pageCount,
356
- pages: contentRes.pages,
357
- };
358
- }
359
- }
360
-
361
- // ── 1. Route: wechat-article (2.35:1 Cover only) ─────────────
362
- if (route === "wechat-article") {
363
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
364
-
365
- const resolved = resolveTypesetResult({
366
- slot: "article_cover",
367
- route: "wechat-article",
368
- account,
369
- title: effectiveTitle,
370
- markdown: `# ${effectiveTitle}`,
371
- template: input.template,
372
- images: coverImages,
373
- highlightWords,
374
- config: {
375
- footerText: footerText ?? undefined,
376
- watermarkText: watermarkText ?? undefined,
377
- },
378
- });
379
-
380
- const pngOutputs = await renderTypesetResultToPngs(resolved.result);
381
- const coverBuffer = pngOutputs[0]?.buffer;
382
- if (!coverBuffer) {
383
- throw new Error("Typeset Studio failed to render cover buffer");
384
- }
385
- const coverPath = join(outputDir, "cover.png");
386
- await writeFile(coverPath, coverBuffer);
387
-
388
- const asset: RenderAsset = {
389
- kind: "cover",
390
- route: "wechat-article",
391
- path: coverPath,
392
- };
393
-
394
- return {
395
- assets: [asset],
396
- pageCount: 1,
397
- pages: [
398
- {
399
- page: 1,
400
- imageCount: resolved.result.pages[0]?.images.length || 0,
401
- imageSources: (resolved.result.pages[0]?.images || []).map((img) => img.src),
402
- },
403
- ],
404
- };
405
- }
406
-
407
- // ── 2. Route: blog (no image rendering required) ──────────────
408
- if (route === "blog") {
409
- return {
410
- assets: [],
411
- pageCount: 0,
412
- pages: [],
413
- };
414
- }
415
-
416
- // ── 3. Route: wechat-newspic (Poster Cover + Multi-page Content) ─
417
- const newspicSpec = state.intent?.newspic_render;
418
- const paginationMode =
419
- input.template === "longform-3-4"
420
- ? "multi"
421
- : input.template === "poster-3-4"
422
- ? "single"
423
- : newspicSpec?.pagination_mode || "multi";
424
-
425
- const isMultiPage = paginationMode === "multi";
426
- const assets: RenderAsset[] = [];
427
- const pagesSummary: ImageRenderOutput["pages"] = [];
428
-
429
- // 3.1 Render Poster Cover (3:4)
430
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
431
- const resolvedCover = resolveTypesetResult({
432
- slot: "newspic_cover",
433
- route: "wechat-newspic",
434
- kind: "cover",
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 });
356
+ const title = cleanTitle(input.title || state.metadata?.title || "");
357
+ const common: RenderRequestBody = {
435
358
  account,
436
- title: effectiveTitle,
437
- markdown: `# ${effectiveTitle}`,
438
- template: input.template,
439
- images: coverImages,
440
- highlightWords,
359
+ title,
360
+ preserveTemplateStyle: true,
361
+ images: mapBodyImages(input.bodyImages, pageSpecs),
362
+ highlightWords: state.route.highlight_words || [],
441
363
  config: {
442
- footerText: footerText ?? undefined,
443
- watermarkText: watermarkText ?? undefined,
364
+ ...(footer !== undefined ? { footerText: footer } : {}),
365
+ watermarkText: `@${account}`,
366
+ ...((bg || highlight) ? { theme: {
367
+ ...(bg ? { bgColor: bg } : {}), ...(highlight ? { accentColor: highlight } : {}),
368
+ } } : {}),
444
369
  },
445
- });
446
-
447
- const coverPngs = await renderTypesetResultToPngs(resolvedCover.result);
448
- const coverBuffer = coverPngs[0]?.buffer;
449
- if (!coverBuffer) {
450
- throw new Error("Typeset Studio failed to render newspic cover buffer");
451
- }
452
- const coverPath = join(outputDir, "cover.png");
453
- await writeFile(coverPath, coverBuffer);
454
- assets.push({
455
- kind: "cover",
456
- route: "wechat-newspic",
457
- path: coverPath,
458
- });
459
-
460
- // 3.2 If single-page mode, return immediately
461
- if (!isMultiPage) {
462
- return {
463
- assets,
464
- pageCount: 1,
465
- pages: [
466
- {
467
- page: 1,
468
- imageCount: resolvedCover.result.pages[0]?.images.length || 0,
469
- imageSources: (resolvedCover.result.pages[0]?.images || []).map((img) => img.src),
470
- },
471
- ],
370
+ };
371
+ const articleCover = route === "wechat-article";
372
+ const renderRoute = articleCover ? "wechat-article" : "wechat-newspic";
373
+
374
+ // 统一封面和正文的请求构造,传输方式只在这里分流。
375
+ const renderBatch = (body: RenderRequestBody, kind: RenderAsset["kind"]) =>
376
+ REMOTE_STUDIO_URL && (route === "wechat-article" || route === "wechat-newspic")
377
+ ? renderRemoteBatch(REMOTE_STUDIO_URL, body, outputDir, renderRoute, kind)
378
+ : renderLocalBatch(body, outputDir, renderRoute, kind);
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,
472
391
  };
473
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
+ }];
400
+ const cover = await renderBatch({
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}`,
406
+ }, "cover");
407
+ input.onProgress?.({ stage: "cover", message: "封面已生成", current: 1, total: 1, unit: "pages", route, account });
408
+ const paginationMode = input.template === "longform-3-4"
409
+ ? "multi"
410
+ : input.template === "poster-3-4"
411
+ ? "single"
412
+ : state.intent?.newspic_render?.pagination_mode || "multi";
413
+ if (articleCover || paginationMode !== "multi") return cover;
474
414
 
475
- // 3.3 Render Content Pages (3:4 Multi-page longform)
476
- const contentImages = mapBodyImages(input.bodyImages, input.pageSpecs);
477
- const resolvedContent = resolveTypesetResult({
478
- slot: "newspic_content",
415
+ const content = await renderBatch({
416
+ ...common,
479
417
  route: "wechat-newspic",
418
+ template: input.studioTemplates?.content || template,
419
+ templateImageOverrides: input.templateImageOverrides?.content,
480
420
  kind: "content",
481
- account,
482
- title: effectiveTitle,
483
- markdown: bodyText || `# ${effectiveTitle}`,
484
- template: input.template,
485
- images: contentImages,
486
- highlightWords,
487
- config: {
488
- footerText: footerText ?? undefined,
489
- watermarkText: watermarkText ?? undefined,
490
- },
491
- });
492
-
493
- const contentPngs = await renderTypesetResultToPngs(resolvedContent.result);
494
-
495
- for (let i = 0; i < contentPngs.length; i++) {
496
- const pageNum = i + 1;
497
- const fileName = `article-${String(pageNum).padStart(2, "0")}.png`;
498
- const pagePath = join(outputDir, fileName);
499
- await writeFile(pagePath, contentPngs[i].buffer);
500
-
501
- assets.push({
502
- kind: "page",
503
- route: "wechat-newspic",
504
- index: pageNum,
505
- path: pagePath,
506
- });
507
-
508
- const flowPage = resolvedContent.result.pages[i];
509
- pagesSummary.push({
510
- page: pageNum,
511
- imageCount: flowPage?.images.length || 0,
512
- imageSources: (flowPage?.images || []).map((img) => img.src),
513
- });
514
- }
515
-
516
- return {
517
- assets,
518
- pageCount: contentPngs.length,
519
- pages: pagesSummary,
520
- };
421
+ slot: "newspic_content",
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!])),
427
+ }, "page");
428
+ input.onProgress?.({ stage: "content", message: "正文已生成", current: content.pageCount, total: content.pageCount, unit: "pages", route, account });
429
+ return { ...content, assets: [...cover.assets, ...content.assets] };
521
430
  },
522
431
  };
523
432