@zzclub/pipeline 0.2.0 → 0.4.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 (194) hide show
  1. package/README.md +366 -170
  2. package/package.json +4 -7
  3. package/src/adapter-loader.ts +138 -0
  4. package/src/adapter-types.ts +172 -0
  5. package/src/adapters/builtin-image-renderer.ts +353 -0
  6. package/src/adapters/builtin-markdown-renderer.ts +63 -0
  7. package/src/adapters/index.ts +2 -0
  8. package/src/args.test.ts +118 -0
  9. package/src/args.ts +90 -0
  10. package/src/cli.ts +51 -0
  11. package/src/commands/abandon.test.ts +104 -0
  12. package/src/commands/abandon.ts +202 -0
  13. package/src/commands/attach-body-images.ts +88 -0
  14. package/src/commands/attach-body.ts +86 -0
  15. package/src/commands/attach-newspic-spec.ts +41 -0
  16. package/src/commands/checkpoint.test.ts +91 -0
  17. package/src/commands/checkpoint.ts +66 -0
  18. package/src/commands/config.ts +106 -0
  19. package/src/commands/cos-upload.ts +62 -0
  20. package/src/commands/doctor.ts +66 -0
  21. package/src/commands/find-run.ts +55 -0
  22. package/src/commands/hermes-metrics.ts +226 -0
  23. package/src/commands/imgx.ts +60 -0
  24. package/src/commands/ingest-handoff.ts +509 -0
  25. package/src/commands/init.ts +126 -0
  26. package/src/commands/prepare-finalize.ts +348 -0
  27. package/src/commands/prepare.ts +310 -0
  28. package/src/commands/publish.ts +147 -0
  29. package/src/commands/reconcile.ts +36 -0
  30. package/src/commands/render.ts +244 -0
  31. package/src/commands/reset.test.ts +151 -0
  32. package/src/commands/reset.ts +159 -0
  33. package/src/commands/review.ts +77 -0
  34. package/src/commands/status.ts +68 -0
  35. package/src/commands/sync-blog.ts +39 -0
  36. package/src/commands/tasks.ts +50 -0
  37. package/src/commands/wechat-export.ts +68 -0
  38. package/src/config.ts +0 -0
  39. package/src/imgx/ascii-portrait.ts +77 -0
  40. package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
  41. package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
  42. package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
  43. package/src/imgx/assets/icons/logo.png +0 -0
  44. package/src/imgx/assets/icons/logo.svg +8 -0
  45. package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
  46. package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
  47. package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
  48. package/src/imgx/cli.ts +57 -0
  49. package/src/imgx/geometry.ts +68 -0
  50. package/src/imgx/index.ts +5 -0
  51. package/src/imgx/longform-theme.ts +243 -0
  52. package/src/imgx/poster-recipe.ts +81 -0
  53. package/src/imgx/pretext-adapter.ts +14 -0
  54. package/src/imgx/pretext-runtime.ts +61 -0
  55. package/src/imgx/references/ascii-portrait-3-4.md +59 -0
  56. package/src/imgx/references/longform-3-4.md +173 -0
  57. package/src/imgx/references/poster-3-4.md +87 -0
  58. package/src/imgx/references/tips-3-4.md +69 -0
  59. package/src/imgx/references/wechat-cover-split.md +89 -0
  60. package/src/imgx/references/x-like-posts.md +139 -0
  61. package/src/imgx/render-article.test.ts +53 -0
  62. package/src/imgx/render-article.ts +1364 -0
  63. package/src/imgx/render-ascii-portrait.ts +53 -0
  64. package/src/imgx/render-card.ts +232 -0
  65. package/src/imgx/render-x-like-posts.ts +235 -0
  66. package/src/imgx/runtime.ts +403 -0
  67. package/src/imgx/typographic-scale.test.ts +56 -0
  68. package/src/imgx/typographic-scale.ts +44 -0
  69. package/src/output.ts +632 -0
  70. package/src/plugins.ts +101 -0
  71. package/src/profiles.ts +149 -0
  72. package/src/providers/blog.test.ts +58 -0
  73. package/src/providers/blog.ts +154 -0
  74. package/src/providers/cos.ts +183 -0
  75. package/src/providers/index.test.ts +26 -0
  76. package/src/providers/index.ts +185 -0
  77. package/src/providers/wechat.test.ts +215 -0
  78. package/src/providers/wechat.ts +548 -0
  79. package/src/routes.ts +287 -0
  80. package/src/runtime-paths.ts +179 -0
  81. package/src/schema/config.ts +133 -0
  82. package/src/schema/state.ts +376 -0
  83. package/src/spawn.ts +103 -0
  84. package/src/state.ts +265 -0
  85. package/src/task-manager.ts +657 -0
  86. package/src/task-views.ts +222 -0
  87. package/src/text.test.ts +689 -0
  88. package/src/text.ts +683 -0
  89. package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
  90. package/src/wechat-preview/browser/editor-export.css +241 -0
  91. package/src/wechat-preview/browser/editor-export.ts +103 -0
  92. package/src/wechat-preview/frontmatter-handler.ts +73 -0
  93. package/src/wechat-preview/index.test.ts +154 -0
  94. package/src/wechat-preview/index.ts +294 -0
  95. package/src/wechat-preview/themes.ts +141 -0
  96. package/src/wechat-preview/wechat-formatter.test.ts +288 -0
  97. package/src/wechat-preview/wechat-formatter.ts +1506 -0
  98. package/src/wechat-preview.test.ts +73 -0
  99. package/src/workflow-materials.test.ts +225 -0
  100. package/src/workflow-materials.ts +217 -0
  101. package/src/workflow.test.ts +2087 -0
  102. package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
  103. package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
  104. package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
  105. package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
  106. package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
  107. package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
  108. package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
  109. package/dist/cli.js +0 -432
  110. package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
  111. package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
  112. package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
  113. package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
  114. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
  115. package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
  116. package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
  117. package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
  118. package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
  119. package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
  120. package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
  121. package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
  122. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
  123. package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
  124. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
  125. /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
  126. /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
  127. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
  128. /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
  129. /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
  130. /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
  131. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
  132. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  133. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  134. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  135. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  136. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  137. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  138. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  139. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  140. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  141. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  142. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  143. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  144. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  145. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  146. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  147. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  148. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  149. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  150. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  151. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  152. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  153. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  154. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  155. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  156. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  157. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  158. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  159. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  160. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  161. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  162. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  163. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  164. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  165. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  166. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  167. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  168. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  169. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  170. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  171. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  172. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  173. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  174. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  175. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  176. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  177. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  178. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  179. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  180. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  181. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  182. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  183. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  184. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  185. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  186. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  187. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  188. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  189. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  190. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  191. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
  192. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
  193. /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
  194. /package/{dist/assets → src}/wechat-preview/assets/templates/export-shell.html +0 -0
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bun
2
+ import { getArg, getIntArg, parseArgs, requireArg } from "./cli";
3
+ import { ensureParentDir, findChrome, printSaved, resolveInputPath } from "./runtime";
4
+ import { renderAsciiPortraitPng } from "./ascii-portrait";
5
+
6
+ export function runRenderAsciiPortraitCli(argv: string[]): void {
7
+ const parsed = parseArgs(argv);
8
+ const outPath = requireArg(parsed, "out");
9
+ const avatarPath = resolveInputPath(requireArg(parsed, "avatar"));
10
+ const template = getArg(parsed, "template", "ascii-portrait-3-4");
11
+ const defaultChars =
12
+ template === "ascii-portrait-tile"
13
+ ? "@#X$%WM*+=-"
14
+ : "@#X$%WM*+=-";
15
+ const title = getArg(parsed, "title");
16
+ const footer = getArg(parsed, "footer", "公众号 · 早早集市");
17
+ const iconPath = resolveInputPath(getArg(parsed, "icon"));
18
+ const bg = getArg(parsed, "bg", template === "ascii-portrait-tile" ? "#f5f0eb" : "auto");
19
+ const chars = getArg(parsed, "chars", defaultChars);
20
+ const columns = getIntArg(parsed, "columns", 0);
21
+ const fontSize = getIntArg(parsed, "font-size", 0);
22
+ const lineHeight = getIntArg(parsed, "line-height", 0);
23
+ const width = getIntArg(parsed, "width", 900);
24
+ const height = getIntArg(parsed, "height", 1200);
25
+
26
+ const chromePath = findChrome();
27
+ if (chromePath === null) {
28
+ throw new Error("Chrome/Chromium not found");
29
+ }
30
+
31
+ ensureParentDir(outPath);
32
+ renderAsciiPortraitPng({
33
+ chromePath,
34
+ outPath,
35
+ avatarPath,
36
+ bg,
37
+ chars,
38
+ columns,
39
+ fontSize,
40
+ lineHeight,
41
+ width,
42
+ height,
43
+ templateName: template === "ascii-portrait-tile" ? "ascii-portrait-tile" : "ascii-portrait-3-4",
44
+ title: title.length > 0 ? title : undefined,
45
+ footer: footer.length > 0 ? footer : undefined,
46
+ iconPath: iconPath.length > 0 ? iconPath : undefined,
47
+ });
48
+ printSaved(outPath);
49
+ }
50
+
51
+ if (import.meta.main) {
52
+ runRenderAsciiPortraitCli(process.argv.slice(2));
53
+ }
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bun
2
+ import { rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { getArg, getArgs, hasFlag, parseArgs, requireArg } from "./cli";
6
+ import { buildPosterConfig, serializePosterConfig, type TipItem } from "./poster-recipe";
7
+ import {
8
+ PACKAGE_ROOT,
9
+ cropTop,
10
+ ensureParentDir,
11
+ escapeHtml,
12
+ findChrome,
13
+ FONTS_DIR,
14
+ ICONS_DIR,
15
+ printSaved,
16
+ readUtf8,
17
+ renderTemplate,
18
+ resolveInputPath,
19
+ screenshotHtml,
20
+ TEMPLATES_DIR,
21
+ } from "./runtime";
22
+
23
+ const SIZE_MAP: Record<string, { width: number; height: number }> = {
24
+ "poster-3-4": { width: 900, height: 1200 },
25
+ "wechat-cover-split": { width: 1340, height: 400 },
26
+ "tips-3-4": { width: 900, height: 1200 },
27
+ };
28
+ const DEFAULT_SIZE = { width: 900, height: 1200 } as const;
29
+
30
+ const WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT = 87;
31
+ const PRETEXT_SCREENSHOT_VIRTUAL_TIME_BUDGET_MS = 6_000;
32
+
33
+ function detectIcon(template: string, _text: string, _line1: string, _line2: string, _line3: string, fallbackIcon: string): string {
34
+ if (template === "wechat-cover-split") {
35
+ return join(ICONS_DIR, "logo.png");
36
+ }
37
+
38
+ if (fallbackIcon.length > 0) return fallbackIcon;
39
+ return join(ICONS_DIR, "logo.png");
40
+ }
41
+
42
+ function splitWechatTitle(text: string): { line1: string; line2: string } {
43
+ const normalized = text.replace(/\s+/g, " ").trim();
44
+ if (normalized.length === 0) return { line1: "", line2: "" };
45
+
46
+ const chars = Array.from(normalized);
47
+ if (chars.length <= 16) return { line1: normalized, line2: "" };
48
+
49
+ // Find all word boundaries: any position where adjacent chars cross
50
+ // script boundaries (CJK↔Latin) or have a space.
51
+ type Candidate = { index: number; score: number };
52
+ const candidates: Candidate[] = [];
53
+ const minIndex = Math.max(2, Math.min(8, chars.length - 2));
54
+ const maxIndex = Math.min(chars.length - 2, 20);
55
+
56
+ const CJK_RE = /[一-鿿]/;
57
+ const splitHints = new Set([" ", ",", "。", "、", ":", ";", "-", "|", "|", "·"]);
58
+
59
+ for (let idx = minIndex; idx <= maxIndex; idx++) {
60
+ const left = chars.slice(0, idx).join("").trim();
61
+ const right = chars.slice(idx).join("").trim();
62
+ if (left.length === 0 || right.length === 0) continue;
63
+
64
+ let score = 0;
65
+
66
+ // 1. Balance penalty (weight reduced — visual balance matters more than
67
+ // character-count balance for mixed text)
68
+ score += Math.abs(left.length - right.length) * 2;
69
+
70
+ // 2. Punctuation / word boundary at split point = strong signal
71
+ const prevChar = chars[idx - 1] ?? "";
72
+ const nextChar = chars[idx] ?? "";
73
+
74
+ if (splitHints.has(prevChar)) score -= 25; // Strong signal: split after
75
+ if (splitHints.has(nextChar)) score -= 20; // Moderate signal: split before
76
+
77
+ // 3. Script boundary bonus: splitting between CJK and Latin is natural
78
+ const prevIsCJK = CJK_RE.test(prevChar);
79
+ const nextIsCJK = CJK_RE.test(nextChar);
80
+ if (prevIsCJK !== nextIsCJK) score -= 10;
81
+
82
+ // 4. Word-internal penalty: don't split inside an English word
83
+ const prevIsAlpha = /[A-Za-z]/.test(prevChar);
84
+ const nextIsAlpha = /[A-Za-z]/.test(nextChar);
85
+ if (prevIsAlpha && nextIsAlpha) score += 15;
86
+
87
+ // 5. Line length caps (visual estimate: CJK ≈ 2 units, Latin ≈ 1 unit)
88
+ const leftVisual = [...left].reduce((sum, ch) => sum + (CJK_RE.test(ch) ? 2 : 1), 0);
89
+ const rightVisual = [...right].reduce((sum, ch) => sum + (CJK_RE.test(ch) ? 2 : 1), 0);
90
+ if (leftVisual > 28 || rightVisual > 28) score += 15;
91
+
92
+ // 6. Slight midpoint preference
93
+ score += Math.abs(idx - chars.length / 2) * 0.5;
94
+
95
+ candidates.push({ index: idx, score });
96
+ }
97
+
98
+ candidates.sort((a, b) => a.score - b.score);
99
+ const best = candidates[0] ?? { index: Math.ceil(chars.length / 2) };
100
+
101
+ return {
102
+ line1: chars.slice(0, best.index).join("").trim(),
103
+ line2: chars.slice(best.index).join("").trim(),
104
+ };
105
+ }
106
+
107
+ export function runRenderCardCli(argv: string[]): void {
108
+ const parsed = parseArgs(argv);
109
+ const template = getArg(parsed, "template", "poster-3-4");
110
+ const outPath = requireArg(parsed, "out");
111
+ let line1 = getArg(parsed, "line1");
112
+ let line2 = getArg(parsed, "line2");
113
+ let line3 = getArg(parsed, "line3");
114
+ let hl2 = hasFlag(parsed, "hl2");
115
+ let hl3 = hasFlag(parsed, "hl3");
116
+ const text = getArg(parsed, "text").replace(/\\n/g, "\n");
117
+ const highlight = getArg(parsed, "highlight", "#22a854");
118
+ const bg = getArg(parsed, "bg", "#e6f5ef");
119
+ const footer = getArg(parsed, "footer", "公众号 · 早早集市");
120
+ const fallbackIcon = getArg(parsed, "fallback-icon");
121
+ const highlightWords = getArg(parsed, "highlight-words");
122
+ const tips: TipItem[] = getArgs(parsed, "tip").map(raw => {
123
+ const sep = raw.indexOf("::");
124
+ if (sep === -1) return { title: raw.trim(), description: "" };
125
+ return { title: raw.slice(0, sep).trim(), description: raw.slice(sep + 2).trim() };
126
+ });
127
+
128
+ if (template === "wechat-cover-split") {
129
+ if (!line1 && !line2 && !line3 && text.trim().length > 0) {
130
+ const split = splitWechatTitle(text);
131
+ line1 = split.line1;
132
+ line2 = split.line2;
133
+ }
134
+ const lines = [line1, line2, line3].filter(Boolean);
135
+ line1 = lines[0] ?? "";
136
+ line2 = lines.slice(1).join("");
137
+ line3 = "";
138
+ hl2 = hl2 || hl3;
139
+ hl3 = false;
140
+ }
141
+
142
+ const chromePath = findChrome();
143
+ if (chromePath === null) {
144
+ throw new Error("Chrome/Chromium not found");
145
+ }
146
+ const usesPretext = template === "poster-3-4" || template === "wechat-cover-split" || template === "tips-3-4";
147
+
148
+ const iconPath = resolveInputPath(getArg(parsed, "icon") || detectIcon(template, text, line1, line2, line3, fallbackIcon));
149
+ const templatePath = join(TEMPLATES_DIR, `${template}.html`);
150
+ const templateHtml = readUtf8(templatePath);
151
+
152
+ const debugArgsPath = process.env.TEST_RENDER_CARD_ARGS_PATH;
153
+ if (debugArgsPath) {
154
+ ensureParentDir(debugArgsPath);
155
+ writeFileSync(debugArgsPath, JSON.stringify(argv), "utf-8");
156
+ }
157
+ if (process.env.TEST_RENDER_CARD_STUB === "1") {
158
+ ensureParentDir(outPath);
159
+ writeFileSync(outPath, "stub", "utf-8");
160
+ return;
161
+ }
162
+
163
+ const html = renderTemplate(templateHtml, {
164
+ "{{MAIN_TEXT_LINE1}}": escapeHtml(line1),
165
+ "{{MAIN_TEXT_LINE2}}": escapeHtml(line2),
166
+ "{{MAIN_TEXT_LINE3}}": escapeHtml(line3),
167
+ "{{LINE1_CLASS}}": hasFlag(parsed, "hl1") ? "highlight" : "",
168
+ "{{LINE2_CLASS}}": hl2 ? "highlight" : "",
169
+ "{{LINE3_CLASS}}": hl3 ? "highlight" : "",
170
+ "{{HIGHLIGHT_COLOR}}": highlight,
171
+ "{{BG_COLOR}}": bg,
172
+ "{{FOOTER_TEXT}}": escapeHtml(footer),
173
+ "{{ICON_PATH}}": iconPath,
174
+ "{{FONT_PATH}}": join(FONTS_DIR, "AlimamaShuHeiTi-Bold.ttf"),
175
+ "{{AVATAR_PATH}}": join(ICONS_DIR, "logo.png"),
176
+ "{{POSTER_CONFIG_JSON}}": serializePosterConfig(
177
+ buildPosterConfig({
178
+ text,
179
+ line1,
180
+ line2,
181
+ line3,
182
+ hl1: hasFlag(parsed, "hl1"),
183
+ hl2,
184
+ hl3,
185
+ highlightWords,
186
+ highlightColor: highlight,
187
+ tips,
188
+ }),
189
+ ),
190
+ "{{PRETEXT_MODULE_URL}}": pathToFileURL(
191
+ join(PACKAGE_ROOT, "node_modules/@chenglou/pretext/dist/layout.js"),
192
+ ).href,
193
+ });
194
+
195
+ const size = SIZE_MAP[template] ?? DEFAULT_SIZE;
196
+ ensureParentDir(outPath);
197
+
198
+ if (template === "wechat-cover-split") {
199
+ const rawPath = outPath.replace(/(\.[^.]+)?$/, ".raw$1");
200
+ screenshotHtml({
201
+ chromePath,
202
+ html,
203
+ outPath: rawPath,
204
+ width: size.width,
205
+ height: size.height + WECHAT_SPLIT_WINDOW_EXTRA_HEIGHT,
206
+ virtualTimeBudgetMs: usesPretext ? PRETEXT_SCREENSHOT_VIRTUAL_TIME_BUDGET_MS : undefined,
207
+ });
208
+ cropTop({
209
+ inputPath: rawPath,
210
+ outPath,
211
+ width: size.width,
212
+ height: size.height,
213
+ });
214
+ rmSync(rawPath, { force: true });
215
+ printSaved(outPath);
216
+ return;
217
+ }
218
+
219
+ screenshotHtml({
220
+ chromePath,
221
+ html,
222
+ outPath,
223
+ width: size.width,
224
+ height: size.height,
225
+ virtualTimeBudgetMs: usesPretext ? PRETEXT_SCREENSHOT_VIRTUAL_TIME_BUDGET_MS : undefined,
226
+ });
227
+ printSaved(outPath);
228
+ }
229
+
230
+ if (import.meta.main) {
231
+ runRenderCardCli(process.argv.slice(2));
232
+ }
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bun
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getArg, getArgs, parseArgs, requireArg } from "./cli";
5
+ import {
6
+ escapeHtml,
7
+ findChrome,
8
+ FONTS_DIR,
9
+ ICONS_DIR,
10
+ printSaved,
11
+ readUtf8,
12
+ renderTemplate,
13
+ resolveInputPath,
14
+ screenshotHtml,
15
+ TEMPLATES_DIR,
16
+ } from "./runtime";
17
+
18
+ const WIDTH = 900;
19
+ const MIN_HEIGHT = 1200;
20
+ const MAX_HEIGHT = 16000;
21
+ const TEXT_WIDTH_CHARS = 26;
22
+
23
+ type PostRecord = {
24
+ text: string;
25
+ created_at?: string;
26
+ url?: string;
27
+ favorite_count?: number;
28
+ retweet_count?: number;
29
+ };
30
+
31
+ function weightedLength(text: string): number {
32
+ let total = 0;
33
+ for (const ch of text) {
34
+ if (ch === "\n") total += 8;
35
+ else if (ch.charCodeAt(0) < 128) total += 0.55;
36
+ else total += 1;
37
+ }
38
+ return total;
39
+ }
40
+
41
+ function estimatePostHeight(text: string): number {
42
+ const paragraphs = text.split(/\r?\n/).filter(block => block.trim().length > 0);
43
+ const blocks = paragraphs.length > 0 ? paragraphs : [text];
44
+ let wrappedLines = 0;
45
+ for (const block of blocks) {
46
+ wrappedLines += Math.max(1, Math.ceil(weightedLength(block) / TEXT_WIDTH_CHARS));
47
+ }
48
+ wrappedLines += Math.max(0, blocks.length - 1);
49
+ return 120 + wrappedLines * 48;
50
+ }
51
+
52
+ function estimateCanvasHeight(posts: PostRecord[]): number {
53
+ let total = 210;
54
+ for (const post of posts) {
55
+ total += estimatePostHeight(post.text) + 20;
56
+ }
57
+ return Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, total + 120));
58
+ }
59
+
60
+ function loadPosts(parsed: ReturnType<typeof parseArgs>): PostRecord[] {
61
+ const posts: PostRecord[] = [];
62
+
63
+ for (const text of [...getArgs(parsed, "post"), ...getArgs(parsed, "tweet")]) {
64
+ if (text.trim().length > 0) posts.push({ text: text.trim() });
65
+ }
66
+
67
+ const inputFile = getArg(parsed, "posts-file") || getArg(parsed, "tweets-file");
68
+ if (inputFile.length > 0) {
69
+ const raw = readFileSync(inputFile, "utf8");
70
+ const parsedJson = JSON.parse(raw);
71
+ if (!Array.isArray(parsedJson)) {
72
+ throw new Error("posts file must be a JSON array");
73
+ }
74
+
75
+ for (const item of parsedJson) {
76
+ if (typeof item === "string" && item.trim().length > 0) {
77
+ posts.push({ text: item.trim() });
78
+ continue;
79
+ }
80
+ if (typeof item === "object" && item !== null) {
81
+ const text = String((item as Record<string, unknown>).text ?? "").trim();
82
+ if (text.length === 0) continue;
83
+ posts.push({
84
+ text,
85
+ created_at: String((item as Record<string, unknown>).created_at ?? "").trim() || undefined,
86
+ url: String((item as Record<string, unknown>).url ?? "").trim() || undefined,
87
+ favorite_count: toInt((item as Record<string, unknown>).favorite_count),
88
+ retweet_count: toInt((item as Record<string, unknown>).retweet_count),
89
+ });
90
+ }
91
+ }
92
+ }
93
+
94
+ if (posts.length === 0) {
95
+ throw new Error("需要至少一条帖子:传 --post/--tweet 或 --posts-file/--tweets-file");
96
+ }
97
+ return posts;
98
+ }
99
+
100
+ function toInt(value: unknown): number {
101
+ const num = Number.parseInt(String(value ?? "0"), 10);
102
+ return Number.isFinite(num) ? num : 0;
103
+ }
104
+
105
+ function textToHtml(text: string): string {
106
+ const paragraphs = text
107
+ .split(/\r?\n/)
108
+ .map(block => block.trim())
109
+ .filter(Boolean)
110
+ .map(block => `<p>${escapeHtml(block)}</p>`);
111
+ return paragraphs.length > 0 ? paragraphs.join("") : `<p>${escapeHtml(text)}</p>`;
112
+ }
113
+
114
+ function parseCreatedAt(value: string | undefined): Date | null {
115
+ if (value === undefined || value.length === 0) return null;
116
+ const date = new Date(value);
117
+ return Number.isNaN(date.getTime()) ? null : date;
118
+ }
119
+
120
+ function formatCreatedAt(value: string | undefined): string {
121
+ const date = parseCreatedAt(value);
122
+ if (date === null) return value ?? "";
123
+ const formatter = new Intl.DateTimeFormat("zh-CN", {
124
+ timeZone: "Asia/Shanghai",
125
+ year: "numeric",
126
+ month: "2-digit",
127
+ day: "2-digit",
128
+ hour: "2-digit",
129
+ minute: "2-digit",
130
+ hour12: false,
131
+ });
132
+ return `${formatter.format(date).replace(/\//g, "-")} UTC+8`;
133
+ }
134
+
135
+ function buildDateLabel(posts: PostRecord[]): string {
136
+ const dates = posts
137
+ .map(post => parseCreatedAt(post.created_at))
138
+ .filter((date): date is Date => date !== null)
139
+ .sort((a, b) => b.getTime() - a.getTime());
140
+ if (dates.length === 0) return "日期未知";
141
+ const formatter = new Intl.DateTimeFormat("en-CA", {
142
+ timeZone: "Asia/Shanghai",
143
+ year: "numeric",
144
+ month: "2-digit",
145
+ day: "2-digit",
146
+ });
147
+ return formatter.format(dates[0]);
148
+ }
149
+
150
+ function buildStats(post: PostRecord): string {
151
+ const parts: string[] = [];
152
+ const createdAt = formatCreatedAt(post.created_at);
153
+ if (createdAt.length > 0) {
154
+ parts.push(`<span class="stat">${escapeHtml(createdAt)}</span>`);
155
+ }
156
+
157
+ if ((post.retweet_count ?? 0) > 0) {
158
+ parts.push(`<span class="stat"><strong>${post.retweet_count}</strong> RT</span>`);
159
+ }
160
+ if ((post.favorite_count ?? 0) > 0) {
161
+ parts.push(`<span class="stat"><strong>${post.favorite_count}</strong> Likes</span>`);
162
+ }
163
+ if ((post.url ?? "").length > 0) {
164
+ parts.push('<span class="stat">x.com</span>');
165
+ }
166
+ return parts.length > 0 ? parts.join("") : '<span class="stat">Forwarded from X</span>';
167
+ }
168
+
169
+ function buildPostItems(posts: PostRecord[], author: string, handle: string, avatarPath: string): string {
170
+ return posts
171
+ .map((post, index) => {
172
+ return `
173
+ <article class="tweet">
174
+ <div class="avatar-wrap">
175
+ <img src="${avatarPath}" alt="avatar">
176
+ </div>
177
+ <div class="tweet-main">
178
+ <div class="tweet-top">
179
+ <span class="name">${escapeHtml(author)}</span>
180
+ <span class="handle">${escapeHtml(handle)}</span>
181
+ <span class="meta">· Post</span>
182
+ </div>
183
+ <div class="tweet-text">${textToHtml(post.text)}</div>
184
+ <div class="tweet-footer">
185
+ <span class="tweet-index">${index + 1} / ${posts.length}</span>
186
+ <span class="tweet-stats">${buildStats(post)}</span>
187
+ </div>
188
+ </div>
189
+ </article>`.trim();
190
+ })
191
+ .join("\n");
192
+ }
193
+
194
+ export function runRenderXLikePostsCli(argv: string[]): void {
195
+ const parsed = parseArgs(argv);
196
+ const chromePath = findChrome();
197
+ if (chromePath === null) throw new Error("Chrome/Chromium not found");
198
+
199
+ const posts = loadPosts(parsed);
200
+ const author = getArg(parsed, "author", "Unknown Author");
201
+ const handle = getArg(parsed, "handle", "@twitter");
202
+ const avatarPath = resolveInputPath(getArg(parsed, "avatar") || join(ICONS_DIR, "logo.png"));
203
+ const template = readUtf8(join(TEMPLATES_DIR, "x-like-posts.html"));
204
+ const html = renderTemplate(template, {
205
+ "{{BG_COLOR}}": getArg(parsed, "bg", "#f5f8fa"),
206
+ "{{CARD_BG}}": getArg(parsed, "card-bg", "#ffffff"),
207
+ "{{TEXT_COLOR}}": getArg(parsed, "text", "#0f1419"),
208
+ "{{MUTED_COLOR}}": getArg(parsed, "muted", "#536471"),
209
+ "{{BORDER_COLOR}}": getArg(parsed, "border", "#e6ecf0"),
210
+ "{{ACCENT_COLOR}}": getArg(parsed, "accent", "#1d9bf0"),
211
+ "{{AUTHOR_NAME}}": escapeHtml(author),
212
+ "{{AUTHOR_HANDLE}}": escapeHtml(handle),
213
+ "{{AUTHOR_AVATAR}}": avatarPath,
214
+ "{{HEADER_LABEL}}": escapeHtml(getArg(parsed, "header-label", "X-like 帖子分享图")),
215
+ "{{TIME_RANGE_LABEL}}": escapeHtml(buildDateLabel(posts)),
216
+ "{{FOOTER_TEXT}}": escapeHtml(getArg(parsed, "footer", "整理转发 · via zzhub-media-imgx")),
217
+ "{{FONT_PATH}}": join(FONTS_DIR, "AlimamaShuHeiTi-Bold.ttf"),
218
+ "{{TWEET_ITEMS}}": buildPostItems(posts, author, handle, avatarPath),
219
+ });
220
+
221
+ const outPath = requireArg(parsed, "out");
222
+ screenshotHtml({
223
+ chromePath,
224
+ html,
225
+ outPath,
226
+ width: WIDTH,
227
+ height: estimateCanvasHeight(posts),
228
+ hideScrollbars: true,
229
+ });
230
+ printSaved(outPath);
231
+ }
232
+
233
+ if (import.meta.main) {
234
+ runRenderXLikePostsCli(process.argv.slice(2));
235
+ }