@zzclub/pipeline 0.12.0 → 0.13.0

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 (44) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -2
  3. package/src/adapter-types.ts +3 -0
  4. package/src/adapters/builtin-image-renderer.test.ts +32 -0
  5. package/src/adapters/builtin-image-renderer.ts +41 -73
  6. package/src/commands/config.ts +14 -1
  7. package/src/commands/imgx.ts +3 -3
  8. package/src/commands/ingest-handoff.ts +15 -0
  9. package/src/commands/init.ts +5 -0
  10. package/src/commands/render-theme.test.ts +115 -0
  11. package/src/commands/render.ts +27 -9
  12. package/src/config.ts +5 -0
  13. package/src/cover-theme-presets.json +59 -0
  14. package/src/imgx/assets/browser/cover.css +25 -0
  15. package/src/imgx/assets/browser/cover.js +40 -0
  16. package/src/imgx/assets/templates/longform-3-4.html +14 -36
  17. package/src/imgx/assets/templates/poster-3-4.html +15 -394
  18. package/src/imgx/assets/templates/wechat-cover-split.html +15 -334
  19. package/src/imgx/chrome-render.test.ts +38 -0
  20. package/src/imgx/chrome-render.ts +110 -0
  21. package/src/imgx/cover.test.ts +80 -0
  22. package/src/imgx/cover.ts +130 -0
  23. package/src/imgx/inline-text.ts +56 -0
  24. package/src/imgx/longform-theme.ts +74 -114
  25. package/src/imgx/obstacle-flow.test.ts +52 -0
  26. package/src/imgx/obstacle-flow.ts +177 -0
  27. package/src/imgx/pretext-adapter.ts +3 -0
  28. package/src/imgx/pretext-runtime.ts +0 -1
  29. package/src/imgx/render-article.test.ts +26 -1
  30. package/src/imgx/render-article.ts +165 -382
  31. package/src/imgx/render-card.ts +64 -204
  32. package/src/imgx/runtime.ts +0 -90
  33. package/src/render-branding.test.ts +26 -0
  34. package/src/render-branding.ts +24 -0
  35. package/src/runtime-paths.ts +12 -51
  36. package/src/schema/config.ts +15 -0
  37. package/src/schema/cover-theme.test.ts +54 -0
  38. package/src/schema/cover-theme.ts +164 -0
  39. package/src/schema/state.ts +2 -0
  40. package/src/text.test.ts +7 -7
  41. package/src/text.ts +2 -27
  42. package/src/workflow.test.ts +2 -1
  43. package/src/imgx/assets/browser/obstacle-flow.d.ts +0 -27
  44. package/src/imgx/assets/browser/obstacle-flow.js +0 -326
package/README.md CHANGED
@@ -747,6 +747,8 @@ bun run src/cli.ts init ... --note-id NOTE_ID
747
747
 
748
748
  ## imgx 渲染子系统
749
749
 
750
+ 封面支持 `render.cover` 配置、十套主题、背景图与透明边框,以及供 App 使用的 JSON Schema 和预览输出。详见 [封面主题与图片排版](docs/render-themes.md),可直接导入 [配置示例](examples/cover-themes.json)。
751
+
750
752
  - 基于 Chrome headless 和 `@napi-rs/canvas`
751
753
  - 模板包括 `longform-3-4`(newspic 长文)、`poster-3-4`(单页卡片)和 `wechat-cover-split`(文章封面)
752
754
  - 主题包括 `paper-sage`(默认账号)和 `linen-news`(`ancientone` 账号)
@@ -853,7 +855,7 @@ bun install --global .
853
855
  运行时依赖检查:
854
856
  - `@napi-rs/canvas` — 图片渲染需要,懒加载,缺失时提示安装命令
855
857
  - Chrome — HTML 导出需要,缺失时提示安装命令
856
- - CJK 字体 — npm 模式下自动从 CDN 下载到 `~/.config/zzhub-pipeline/fonts/`
858
+ - CJK 字体 — 随 CLI 附带,生成图片时按需读取本地文件,不触发网络下载;封面也支持本机字体名称
857
859
 
858
860
  ## 发布提供者
859
861
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -24,7 +24,7 @@
24
24
  "release:major": "changelogen --release --major --push"
25
25
  },
26
26
  "dependencies": {
27
- "@chenglou/pretext": "^0.0.5",
27
+ "@chenglou/pretext": "0.0.8",
28
28
  "@zzclub/milkdown-article-style": "0.1.1",
29
29
  "image-size": "^2.0.2",
30
30
  "pngjs": "^7.0.0",
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { RenderAsset, RoutePrimary, WorkflowState } from "./state";
12
12
  import type { MonitorProgress } from "./monitor/types";
13
+ import type { ResolvedCoverTheme } from "./schema/cover-theme";
13
14
 
14
15
  // ── Doctor check ──────────────────────────────────────────────────
15
16
 
@@ -27,6 +28,8 @@ export interface PipelinePluginDoctorCheck {
27
28
  * to runRenderArticleCli and runRenderCardCli via CLI argv.
28
29
  */
29
30
  export interface ImageRenderInput {
31
+ /** 已解析的封面主题;旧插件可忽略。 */
32
+ coverTheme?: ResolvedCoverTheme;
30
33
  /** 可选进度通知,旧插件可忽略。 */
31
34
  onProgress?: (progress: MonitorProgress) => void;
32
35
  /** Workflow state providing context (route, metadata, etc.) */
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { defaultState, normalizeNewspicRenderSpec } from "../state";
6
+ import { builtinImageRenderer } from "./builtin-image-renderer";
7
+
8
+ test("multi mode without page specs renders body pages and excludes stale files", async () => {
9
+ const workspace = await mkdtemp(join(tmpdir(), "zzhub-auto-pages-"));
10
+ const previous = process.env.TEST_RENDER_CARD_STUB;
11
+ process.env.TEST_RENDER_CARD_STUB = "1";
12
+ try {
13
+ const outputDir = join(workspace, "images");
14
+ await mkdir(outputDir);
15
+ await writeFile(join(outputDir, "article-99.png"), "previous output");
16
+ const state = defaultState();
17
+ state.run_id = "auto-pages";
18
+ state.workspace_root = workspace;
19
+ state.route.primary = "wechat-newspic";
20
+ state.intent.newspic_render = normalizeNewspicRenderSpec({ pagination_mode: "multi", min_pages: 2, target_fill_ratio: 0.8 });
21
+ const result = await builtinImageRenderer.render({ state, route: "wechat-newspic", outputDir, title: "完整标题:副标题", bodyText: "正文应该舒适易读,不需要手工指定每一页。".repeat(24) });
22
+ expect(result.pageCount).toBeGreaterThanOrEqual(2);
23
+ expect(result.assets.filter(asset => asset.kind === "page")).toHaveLength(result.pageCount);
24
+ expect(result.assets.every(asset => !asset.path.endsWith("article-99.png"))).toBe(true);
25
+ expect((await readFile(result.assets.find(asset => asset.kind === "page")!.path)).subarray(1, 4).toString()).toBe("PNG");
26
+ expect(await readFile(join(outputDir, "article-99.png"), "utf8")).toBe("previous output");
27
+ } finally {
28
+ if (previous === undefined) delete process.env.TEST_RENDER_CARD_STUB;
29
+ else process.env.TEST_RENDER_CARD_STUB = previous;
30
+ await rm(workspace, { recursive: true, force: true });
31
+ }
32
+ }, 30_000);
@@ -5,7 +5,7 @@
5
5
  * when no user-provided plugin is configured.
6
6
  */
7
7
 
8
- import { mkdir, readdir, rm, rmdir, writeFile } from "fs/promises";
8
+ import { mkdir, rm, rmdir, writeFile } from "fs/promises";
9
9
  import { join } from "path";
10
10
 
11
11
  import { runRenderArticleCli, runRenderCardCli } from "../imgx";
@@ -17,13 +17,15 @@ import {
17
17
  generateCoverTitle,
18
18
  } from "../text";
19
19
  import { getLongformTheme } from "../routes";
20
- import { resolveWorkspacePaths } from "../config";
20
+ import { loadConfig, resolveWorkspacePaths } from "../config";
21
+ import { resolveRenderBranding } from "../render-branding";
21
22
  import type {
22
23
  ImageRenderPlugin,
23
24
  ImageRenderInput,
24
25
  ImageRenderOutput,
25
26
  PipelinePluginDoctorCheck,
26
27
  } from "../adapter-types";
28
+ import { normalizeNewspicRenderSpec } from "../state";
27
29
  import type {
28
30
  NewspicRenderSpec,
29
31
  RenderAsset,
@@ -32,20 +34,6 @@ import type {
32
34
 
33
35
  // ── Helpers ──────────────────────────────────────────────────────
34
36
 
35
- function appendPosterVisualArgs(
36
- cmdParts: string[],
37
- visualParams: AccountVisualParams | null,
38
- highlightWords: string[],
39
- ): void {
40
- if (visualParams) {
41
- cmdParts.push("--highlight", visualParams.highlight);
42
- cmdParts.push("--bg", visualParams.bg);
43
- cmdParts.push("--footer", visualParams.footer);
44
- cmdParts.push("--fallback-icon", visualParams.fallback_icon);
45
- }
46
- cmdParts.push("--highlight-words", highlightWords.join(","));
47
- }
48
-
49
37
  type ResolvedPageImageSpecFile = {
50
38
  default_image_layout: string;
51
39
  target_fill_ratio: number;
@@ -139,29 +127,18 @@ async function renderCover(
139
127
  visualParams: AccountVisualParams | null,
140
128
  highlightWords: string[],
141
129
  onProgress?: ImageRenderInput["onProgress"],
130
+ coverTheme?: ImageRenderInput["coverTheme"],
142
131
  ): Promise<RenderAsset> {
143
132
  const coverOut = join(outputDir, "cover.png");
144
133
  const route = template === "wechat-cover-split" ? "wechat-article" : "wechat-newspic";
145
-
146
- if (template === "wechat-cover-split") {
147
- const cmdParts = ["--template", "wechat-cover-split", "--text", title];
148
- if (visualParams) {
149
- cmdParts.push("--highlight", visualParams.highlight);
150
- cmdParts.push("--bg", visualParams.bg);
151
- cmdParts.push("--footer", visualParams.footer);
152
- cmdParts.push("--fallback-icon", visualParams.fallback_icon);
153
- }
154
- cmdParts.push("--highlight-words", highlightWords.join(","));
155
- cmdParts.push("--out", coverOut);
156
- runRenderCardCli(cmdParts, onProgress);
157
- } else {
158
- const cmdParts = ["--template", "poster-3-4", "--text", title];
159
- appendPosterVisualArgs(cmdParts, visualParams, highlightWords);
160
- cmdParts.push("--out", coverOut);
161
- runRenderCardCli(cmdParts, onProgress);
134
+ const args = ["--template", template, "--text", title, "--out", coverOut, "--highlight-words", highlightWords.join(",")];
135
+ if (visualParams) {
136
+ args.push("--footer", visualParams.footer, "--fallback-icon", visualParams.fallback_icon);
137
+ // 旧直接调用仍支持账号配色;已选主题使用自己的颜色。
138
+ if (!coverTheme) args.push("--bg", visualParams.bg, "--highlight", visualParams.highlight);
162
139
  }
163
-
164
- return { kind: "cover", route: route as RenderAsset["route"], path: coverOut };
140
+ await runRenderCardCli(args, onProgress, coverTheme);
141
+ return { kind: "cover", route, path: coverOut };
165
142
  }
166
143
 
167
144
  async function renderLongformPages(
@@ -197,6 +174,7 @@ async function renderLongformPages(
197
174
  "--text-file", tempBodyPath,
198
175
  "--out-dir", outputDir,
199
176
  "--theme", theme,
177
+ "--target-fill-ratio", String(newspicRenderSpec.target_fill_ratio),
200
178
  ];
201
179
 
202
180
  if (newspicRenderSpec.min_pages > 1) {
@@ -205,10 +183,9 @@ async function renderLongformPages(
205
183
  if (newspicRenderSpec.max_pages > 0) {
206
184
  pageParts.push("--max-pages", String(newspicRenderSpec.max_pages));
207
185
  }
186
+ if (newspicRenderSpec.require_image_every_page) pageParts.push("--require-image-every-page");
208
187
 
209
- if (vp?.footer) {
210
- pageParts.push("--footer", vp.footer);
211
- }
188
+ if (vp) pageParts.push("--footer", vp.footer, "--icon", vp.fallback_icon);
212
189
 
213
190
  const highlightWords = input.state.route.highlight_words ?? [];
214
191
  if (highlightWords.length > 0) {
@@ -232,7 +209,7 @@ async function renderLongformPages(
232
209
 
233
210
  let result: RenderArticleResult;
234
211
  try {
235
- result = runRenderArticleCli(pageParts, input.onProgress);
212
+ result = await runRenderArticleCli(pageParts, input.onProgress);
236
213
  } finally {
237
214
  if (!keepTempFiles) {
238
215
  await rm(tempDir, { recursive: true, force: true });
@@ -245,20 +222,13 @@ async function renderLongformPages(
245
222
  throw new Error(`newspic render constraints not satisfied: ${errors.join("; ")}`);
246
223
  }
247
224
 
248
- // Discover generated page files
249
- const files = await readdir(outputDir);
250
- const pageFiles = files.filter((f) => /^article-\d+\.png$/.test(f)).sort();
251
-
252
- const assets: RenderAsset[] = [];
253
- for (let i = 0; i < pageFiles.length; i++) {
254
- assets.push({
255
- kind: "page",
256
- route: "wechat-newspic",
257
- index: i + 1,
258
- path: join(outputDir, pageFiles[i]),
259
- });
260
- }
261
-
225
+ // 仅返回本次实际生成的页,目录中的旧页不参与结果。
226
+ const assets: RenderAsset[] = result.pages.map(page => ({
227
+ kind: "page",
228
+ route: "wechat-newspic",
229
+ index: page.page,
230
+ path: join(outputDir, "article-" + String(page.page).padStart(2, "0") + ".png"),
231
+ }));
262
232
  return { assets, pageCount: result.pageCount, pages: result.pages };
263
233
  }
264
234
 
@@ -266,7 +236,7 @@ async function renderLongformPages(
266
236
 
267
237
  export const builtinImageRenderer: ImageRenderPlugin = {
268
238
  name: "builtin-imgx",
269
- version: "1.0.0",
239
+ version: "1.1.0",
270
240
 
271
241
  async doctor(): Promise<PipelinePluginDoctorCheck[]> {
272
242
  const checks: PipelinePluginDoctorCheck[] = [];
@@ -297,20 +267,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
297
267
  // Normalize visual params: adapter input uses camelCase, state uses snake_case
298
268
  const adapterVp = input.accountVisualParams;
299
269
  const stateVp = state.route.account_visual_params;
300
- const vp: AccountVisualParams | null = adapterVp
301
- ? {
302
- footer: adapterVp.footer ?? "",
303
- bg: adapterVp.bg ?? "",
304
- highlight: adapterVp.highlight ?? "",
305
- fallback_icon: adapterVp.fallbackIcon ?? "",
306
- }
307
- : stateVp;
270
+ const branding = resolveRenderBranding(loadConfig(), state.route.account);
271
+ const vp: AccountVisualParams = {
272
+ footer: adapterVp?.footer ?? branding.footerText,
273
+ bg: adapterVp?.bg ?? stateVp?.bg ?? "",
274
+ highlight: adapterVp?.highlight ?? stateVp?.highlight ?? "",
275
+ fallback_icon: adapterVp?.fallbackIcon ?? branding.logo,
276
+ };
308
277
 
309
278
  await mkdir(outputDir, { recursive: true });
310
279
 
311
280
  if (route === "wechat-article") {
312
281
  // Article: cover only (wechat-cover-split)
313
- const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress);
282
+ const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
314
283
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
315
284
  }
316
285
 
@@ -319,20 +288,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
319
288
  }
320
289
 
321
290
  // wechat-newspic: determine short vs long
291
+ const savedSpec = normalizeNewspicRenderSpec(state.intent.newspic_render);
322
292
  const newspicRenderSpec: NewspicRenderSpec = {
323
- pagination_mode: input.pageSpecs && input.pageSpecs.length > 0 ? "multi" : "single",
324
- min_pages: input.minPages ?? 1,
325
- max_pages: input.maxPages ?? 0,
326
- require_image_every_page: false,
327
- default_image_layout: "staggered",
328
- target_fill_ratio: 0.8,
329
- page_specs: (input.pageSpecs ?? []).map((ps) => ({
293
+ ...savedSpec,
294
+ pagination_mode: input.template === "longform-3-4" ? "multi" : input.template === "poster-3-4" ? "single" : savedSpec.pagination_mode,
295
+ min_pages: input.minPages ?? savedSpec.min_pages,
296
+ max_pages: input.maxPages ?? savedSpec.max_pages,
297
+ page_specs: input.pageSpecs?.map(ps => ({
330
298
  page: ps.page,
331
299
  image_markers: ps.imageMarkers ?? [],
332
300
  image_layout: ps.imageLayout ?? null,
333
301
  target_fill_ratio: ps.targetFillRatio ?? null,
334
302
  note: ps.note ?? null,
335
- })),
303
+ })) ?? savedSpec.page_specs,
336
304
  };
337
305
 
338
306
  const isLong = newspicRenderSpec.pagination_mode === "multi";
@@ -340,13 +308,13 @@ export const builtinImageRenderer: ImageRenderPlugin = {
340
308
  if (!isLong) {
341
309
  // Short: single poster cover
342
310
  const coverTitle = generateCoverTitle(title);
343
- const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress);
311
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
344
312
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
345
313
  }
346
314
 
347
315
  // Long: cover + article pages
348
316
  const coverTitle = generateCoverTitle(title);
349
- const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress);
317
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
350
318
 
351
319
  const bodyImages = input.bodyImages ?? [];
352
320
  const pageResult = await renderLongformPages(input, outputDir, newspicRenderSpec, bodyImages, vp);
@@ -1,4 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { z } from "zod";
3
+ import { CoverConfigSchema, mergeCoverSettings } from "../schema/cover-theme";
4
+ import { RenderBrandingSchema, RenderConfigSchema } from "../schema/config";
2
5
  import { parseArgs, optionalArg, flagArg } from "../args";
3
6
  import { printResult, renderConfig } from "../output";
4
7
  import {
@@ -29,6 +32,7 @@ Options:
29
32
  --json Force JSON output for scalar reads
30
33
  --export Print full config as JSON (secrets redacted; use --raw to show all)
31
34
  --import Path to a JSON file to merge into current config
35
+ --schema Print JSON Schema (--key render | render.cover | render.branding)
32
36
  --raw Show secrets unredacted (use with --export)
33
37
 
34
38
  Examples:
@@ -39,6 +43,14 @@ Examples:
39
43
  return;
40
44
  }
41
45
 
46
+ // schema 查询不依赖配置文件是否可用,便于 App 修复配置。
47
+ if (flagArg(parsed, "schema")) {
48
+ const schemas: Record<string, z.ZodType> = { render: RenderConfigSchema, "render.cover": CoverConfigSchema, "render.branding": RenderBrandingSchema };
49
+ const schema = schemas[optionalArg(parsed, "key") ?? ""];
50
+ if (!schema) throw new Error("--schema requires --key render | render.cover | render.branding");
51
+ printResult(z.toJSONSchema(schema), data => JSON.stringify(data, null, 2));
52
+ return;
53
+ }
42
54
  const config = loadConfig();
43
55
 
44
56
  // --export
@@ -95,6 +107,7 @@ Examples:
95
107
  ...(isPlainObject(importedObj.plugins) ? importedObj.plugins : {}),
96
108
  },
97
109
  imgx: { ...config.imgx, ...(isPlainObject(importedObj.imgx) ? importedObj.imgx : {}) },
110
+ render: mergeCoverSettings(config.render, importedObj.render ?? {}),
98
111
  };
99
112
  // Soft-fill known account display names + Zod defaults / strip unknowns
100
113
  const merged = normalizeConfig(raw);
@@ -118,7 +131,7 @@ Examples:
118
131
  const currentValue = getConfigValue(config, key);
119
132
  const redactedValue = redactConfigValue(key, currentValue);
120
133
  if (forceJson || typeof currentValue !== "string") {
121
- printResult({ key, value: redactedValue }, renderConfig);
134
+ printResult({ key, value: redactedValue }, forceJson ? data => JSON.stringify(data, null, 2) : renderConfig);
122
135
  return;
123
136
  }
124
137
  printResult(String(redactedValue), renderConfig);
@@ -6,7 +6,7 @@ import {
6
6
  } from "../imgx";
7
7
  import { ensureFonts } from "../runtime-paths";
8
8
 
9
- type ImgxHandler = (args: string[]) => void;
9
+ type ImgxHandler = (args: string[]) => unknown;
10
10
 
11
11
  const IMGX_COMMANDS: Record<string, ImgxHandler> = {
12
12
  "render-article": runRenderArticleCli,
@@ -28,9 +28,9 @@ export async function imgxCommand(args: string[]): Promise<void> {
28
28
  throw new Error(`Unknown imgx subcommand: ${subcommand}`);
29
29
  }
30
30
 
31
- await ensureFonts();
31
+ if (subcommand !== "render-card" && subcommand !== "render-article") await ensureFonts();
32
32
  try {
33
- handler(args.slice(1));
33
+ await handler(args.slice(1));
34
34
  } catch (err) {
35
35
  const msg = err instanceof Error ? err.message : String(err);
36
36
  if (msg.includes("@napi-rs/canvas") || msg.includes("Cannot find package")) {
@@ -5,6 +5,7 @@ import { parseArgs, optionalArg, requireArg } from "../args";
5
5
  import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "../config";
6
6
  import { printResult } from "../output";
7
7
  import { resolveFullRoute } from "../routes";
8
+ import { resolveCoverTheme } from "../schema/cover-theme";
8
9
  import {
9
10
  defaultContentReview,
10
11
  defaultState,
@@ -12,6 +13,7 @@ import {
12
13
  getRunStatePath,
13
14
  readResolvedState,
14
15
  resetDerivedState,
16
+ reenterRender,
15
17
  writeState,
16
18
  type ContentForm,
17
19
  type HandoffAuthoringPolicy,
@@ -23,6 +25,7 @@ import {
23
25
  import { findTask, getTaskByStatePath } from "../task-manager";
24
26
 
25
27
  interface PublishHandoffInput {
28
+ cover_theme?: string | null;
26
29
  content_form: ContentForm;
27
30
  body_path: string;
28
31
  target_account: string;
@@ -34,6 +37,7 @@ interface PublishHandoffInput {
34
37
  type WorkflowHandoffMode = "new" | "resume";
35
38
 
36
39
  interface WorkflowHandoffInput {
40
+ cover_theme?: string | null;
37
41
  mode?: WorkflowHandoffMode;
38
42
  state_path?: string;
39
43
  run_id?: string;
@@ -50,6 +54,7 @@ interface WorkflowHandoffInput {
50
54
  }
51
55
 
52
56
  interface ResolvedWorkflowHandoff {
57
+ cover_theme?: string | null;
53
58
  source: "publish_handoff" | "workflow_handoff";
54
59
  mode: WorkflowHandoffMode;
55
60
  state_path?: string;
@@ -162,6 +167,7 @@ function parsePublishHandoff(raw: unknown): ResolvedWorkflowHandoff {
162
167
 
163
168
  return {
164
169
  source: "publish_handoff",
170
+ cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "publish_handoff.cover_theme"),
165
171
  mode: "new",
166
172
  content_form: contentForm,
167
173
  body_path: bodyPath,
@@ -193,6 +199,7 @@ function parseWorkflowHandoff(raw: unknown): ResolvedWorkflowHandoff {
193
199
  : "new");
194
200
  const handoff: ResolvedWorkflowHandoff = {
195
201
  source: "workflow_handoff",
202
+ cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "workflow_handoff.cover_theme"),
196
203
  mode,
197
204
  state_path: cleanString(input.state_path, "workflow_handoff.state_path"),
198
205
  run_id: cleanString(input.run_id, "workflow_handoff.run_id"),
@@ -319,6 +326,11 @@ async function applyHandoffToState(
319
326
  ): Promise<void> {
320
327
  const targets: Target[] = state.intent.targets.length > 0 ? state.intent.targets : ["wechat"];
321
328
  let restartFromPrepare = false;
329
+ const themeChanged = handoff.cover_theme !== undefined && handoff.cover_theme !== state.intent.cover_theme;
330
+ if (themeChanged) {
331
+ resolveCoverTheme(loadConfig().render.cover, "poster-3-4", handoff.target_account ?? state.route.account, handoff.cover_theme);
332
+ state.intent.cover_theme = handoff.cover_theme ?? null;
333
+ }
322
334
 
323
335
  const nextContentForm = handoff.content_form ?? state.intent.content_form;
324
336
  if (handoff.content_form && handoff.content_form !== state.intent.content_form) {
@@ -409,6 +421,9 @@ async function applyHandoffToState(
409
421
 
410
422
  if (restartFromPrepare) {
411
423
  resetDerivedState(state);
424
+ } else if (themeChanged && state.asset_path && state.content_review.status === "passed") {
425
+ // 仅更换主题时保留正文、审核和 prepare 产物。
426
+ reenterRender(state);
412
427
  }
413
428
 
414
429
  if (state.handoff.review_policy === "trust_user" && state.source_body_path) {
@@ -28,6 +28,7 @@ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
28
28
  import { printResult, renderInit } from "../output";
29
29
  import { loadConfig, resolveWorkspaceRoot } from "../config";
30
30
  import { resolveFullRoute } from "../routes";
31
+ import { resolveCoverTheme } from "../schema/cover-theme";
31
32
  import {
32
33
  parseAccountName,
33
34
  parseContentForm,
@@ -60,6 +61,7 @@ Options:
60
61
  --account Explicit account override (optional)
61
62
  --newspic-render-spec-file JSON file for newspic pagination / page-image intent (optional)
62
63
  --style-hint e.g. fact_report (optional)
64
+ --cover-theme Cover theme ID (optional)
63
65
  --requires-research Flag
64
66
  --requires-style Flag
65
67
  --requires-render Flag
@@ -76,11 +78,13 @@ Options:
76
78
  const intentText = optionalArg(parsed, "intent-text") ?? "";
77
79
  const accountOverride = optionalArg(parsed, "account");
78
80
  const styleHint = optionalArg(parsed, "style-hint") ?? null;
81
+ const coverTheme = parsed["cover-theme"] === undefined ? null : requireArg(parsed, "cover-theme", "cover theme ID").trim();
79
82
  const newspicRenderSpecFile = optionalArg(parsed, "newspic-render-spec-file");
80
83
  const existingDraftMediaId = optionalArg(parsed, "existing-draft-media-id") ?? null;
81
84
  const noteId = optionalArg(parsed, "note-id") ?? null;
82
85
  const config = loadConfig();
83
86
  const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
87
+ if (coverTheme !== null) resolveCoverTheme(config.render.cover, "poster-3-4", accountOverride, coverTheme);
84
88
 
85
89
  const defaultAccount = parseAccountName(accountOverride ?? "default");
86
90
  const parsedTargets = parsePublishTargets(targetsRaw, {
@@ -126,6 +130,7 @@ Options:
126
130
  intent_text: intentText || null,
127
131
  explicit_constraints: [],
128
132
  style_hint: styleHint,
133
+ cover_theme: coverTheme,
129
134
  newspic_render: newspicRender,
130
135
  requires: {
131
136
  research: flagArg(parsed, "requires-research"),
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadConfig, normalizeConfig, saveConfig } from "../config";
6
+ import { defaultState, readState, writeState } from "../state";
7
+ import { resolveFullRoute } from "../routes";
8
+ import { init } from "./init";
9
+ import { ingestHandoff } from "./ingest-handoff";
10
+ import { render } from "./render";
11
+ import { configCommand } from "./config";
12
+
13
+ let workspace: string;
14
+ let previousConfig: string | undefined;
15
+ let output: ReturnType<typeof spyOn>;
16
+ beforeEach(async () => {
17
+ workspace = await mkdtemp(join(tmpdir(), "zzhub-cover-contract-"));
18
+ previousConfig = process.env.ZZHUB_PIPELINE_CONFIG;
19
+ process.env.ZZHUB_PIPELINE_CONFIG = join(workspace, "config.json");
20
+ saveConfig(normalizeConfig({}));
21
+ output = spyOn(console, "log").mockImplementation(() => {});
22
+ });
23
+ afterEach(async () => {
24
+ output.mockRestore();
25
+ if (previousConfig === undefined) delete process.env.ZZHUB_PIPELINE_CONFIG;
26
+ else process.env.ZZHUB_PIPELINE_CONFIG = previousConfig;
27
+ await rm(workspace, { recursive: true, force: true });
28
+ });
29
+
30
+ async function preparedTask() {
31
+ const assetPath = join(workspace, "posts", "2026-09-07-cover");
32
+ await mkdir(assetPath, { recursive: true });
33
+ await writeFile(join(assetPath, "post.md"), "完整正文保持不变。", "utf8");
34
+ const state = defaultState();
35
+ state.run_id = "cover-contract";
36
+ state.workspace_root = workspace;
37
+ state.asset_path = assetPath;
38
+ state.state_path = join(assetPath, "workflow-state.json");
39
+ state.route = resolveFullRoute("发公众号文章", { account: "default", contentForm: "article", targets: ["wechat"] });
40
+ state.intent.intent_text = "发公众号文章";
41
+ state.intent.content_form = "article";
42
+ state.intent.targets = ["wechat"];
43
+ state.intent.cover_theme = "business";
44
+ state.metadata = { ...state.metadata, title: "完整标题:保留副标题", slug: "cover", date: "2026-09-07" };
45
+ state.content_review.status = "passed";
46
+ state.phase.prepare.status = "done";
47
+ await writeState(state.state_path, state);
48
+ return state;
49
+ }
50
+
51
+ describe("cover CLI contract", () => {
52
+ test("init persists the selected theme and rejects unknown themes", async () => {
53
+ const args = ["--workspace", workspace, "--task-kind", "publish", "--content-form", "article", "--targets", "wechat", "--content-origin", "user"];
54
+ await init([...args, "--cover-theme", "business"]);
55
+ const runs = join(workspace, ".zzhub-media", "runs");
56
+ const file = (await readdir(runs)).find(name => name.endsWith(".json"))!;
57
+ expect((await readState(join(runs, file))).intent.cover_theme).toBe("business");
58
+ await expect(init([...args, "--cover-theme", "missing"])).rejects.toThrow("主题不存在");
59
+ });
60
+
61
+ test("a theme-only handoff retains reviewed content and reenters render", async () => {
62
+ const state = await preparedTask();
63
+ state.phase.current = "done";
64
+ state.phase.render.status = "done";
65
+ state.mode = "done";
66
+ await writeState(state.state_path, state);
67
+ const handoff = join(workspace, "handoff.json");
68
+ await writeFile(handoff, JSON.stringify({ workflow_handoff: { mode: "resume", state_path: state.state_path, cover_theme: "technology" } }));
69
+ await ingestHandoff(["--file", handoff, "--workspace", workspace]);
70
+ const result = await readState(state.state_path);
71
+ expect(result.intent.cover_theme).toBe("technology");
72
+ expect(result.asset_path).toBe(state.asset_path);
73
+ expect(result.content_review.status).toBe("passed");
74
+ expect(result.phase.prepare.status).toBe("done");
75
+ expect(result.phase.current).toBe("render");
76
+ expect(await readFile(join(state.asset_path, "post.md"), "utf8")).toBe("完整正文保持不变。");
77
+ });
78
+
79
+ test("render forwards the latest theme to an existing plugin and command choice wins", async () => {
80
+ const state = await preparedTask();
81
+ const pluginPath = join(workspace, "plugin.mjs");
82
+ await writeFile(pluginPath, 'import {writeFile} from "node:fs/promises"; import {join} from "node:path"; export default {name:"contract-probe",async render(input){await writeFile(join(input.state.asset_path,"received.json"),JSON.stringify(input));const path=join(input.state.asset_path,"cover.png");await writeFile(path,"stub");return {assets:[{kind:"cover",route:input.route,path}],pageCount:1,pages:[]}}};');
83
+ const config = loadConfig();
84
+ config.plugins.imageRenderer = pluginPath;
85
+ config.render.cover.themes.business!.typography.title.fontSize = 101;
86
+ config.render.cover.themes.business!.colors.accent = "#123789";
87
+ config.render.branding.logo = "https://example.com/custom-logo.png";
88
+ config.render.branding.footerText = "公众号:自定义名称";
89
+ saveConfig(config);
90
+ await render(["--state", state.state_path]);
91
+ let received = JSON.parse(await readFile(join(state.asset_path, "received.json"), "utf8"));
92
+ expect(received.coverTheme.id).toBe("business");
93
+ expect(received.coverTheme.typography.title.fontSize).toBe(88);
94
+ expect(received.coverTheme.colors.accent).toBe("#123789");
95
+ expect(received.title).toBe(state.metadata.title);
96
+ expect(received.accountVisualParams.footer).toBe("公众号:自定义名称");
97
+ expect(received.accountVisualParams.fallbackIcon).toBe("https://example.com/custom-logo.png");
98
+ await render(["--state", state.state_path, "--cover-theme", "minimal"]);
99
+ received = JSON.parse(await readFile(join(state.asset_path, "received.json"), "utf8"));
100
+ expect(received.coverTheme.id).toBe("minimal");
101
+ expect((await readState(state.state_path)).intent.cover_theme).toBe("minimal");
102
+ expect((await readState(state.state_path)).images.plan.cover_theme).toBe("minimal");
103
+ });
104
+
105
+ test("config import merges one theme and schema works even with a broken config file", async () => {
106
+ const path = join(workspace, "import.json");
107
+ await writeFile(path, JSON.stringify({ render: { cover: { themes: { business: { colors: { accent: "#135790" } } } } } }));
108
+ await configCommand(["--import", path]);
109
+ expect(loadConfig().render.cover.themes.business!.colors.accent).toBe("#135790");
110
+ expect(Object.keys(loadConfig().render.cover.themes)).toHaveLength(10);
111
+ await writeFile(process.env.ZZHUB_PIPELINE_CONFIG!, "invalid json");
112
+ await configCommand(["--schema", "--key", "render.cover"]);
113
+ expect(output.mock.calls.at(-1)?.[0]).toContain('"defaultTheme"');
114
+ });
115
+ });