@zzclub/pipeline 0.3.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 -431
  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,1364 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { renderAsciiPortraitPng } from "./ascii-portrait";
5
+ import { getArg, getArgs, getIntArg, parseArgs } from "./cli";
6
+ import { createObstacleFlowRuntime } from "./assets/browser/obstacle-flow.js";
7
+ import { applyGeometryOverrides } from "./geometry";
8
+ import {
9
+ getLongformTheme,
10
+ getLongformThemeCssVars,
11
+ applyContentHeightOverride,
12
+ applyFontSizeMax,
13
+ getLongformGeometry,
14
+ type LongformTheme,
15
+ } from "./longform-theme";
16
+ import {
17
+ escapeHtml,
18
+ findChrome,
19
+ FONTS_DIR,
20
+ ICONS_DIR,
21
+ printSaved,
22
+ readImageSize,
23
+ readUtf8,
24
+ renderInlineMarkdown,
25
+ renderTemplate,
26
+ resolveInputPath,
27
+ screenshotHtml,
28
+ TEMPLATES_DIR,
29
+ } from "./runtime";
30
+ import { layoutNextLineRange, prepareWithSegments } from "./pretext-adapter";
31
+ import { ensurePretextRuntime } from "./pretext-runtime";
32
+
33
+ const DEFAULT_CONTENT_WIDTH = getLongformGeometry(getLongformTheme("paper-sage")).contentWidth;
34
+
35
+ function proportionalObstacleGap(imageMinDim: number): number {
36
+ const gap = Math.round(imageMinDim * 0.10);
37
+ return Math.min(48, Math.max(16, gap));
38
+ }
39
+
40
+ function proportionalMinSlotWidth(bodyFontSize: number): number {
41
+ return Math.round(bodyFontSize * 5.3);
42
+ }
43
+
44
+ function imageShadowStyle(image: BodyImageSpec): string {
45
+ const area = image.width * image.height;
46
+ const maxArea = 720 * 560;
47
+ const t = Math.min(1, Math.max(0, area / maxArea));
48
+ const shadowY = Math.round(8 + t * 16);
49
+ const shadowBlur = Math.round(16 + t * 32);
50
+ return `--img-shadow-y:${shadowY}px;--img-shadow-blur:${shadowBlur}px`;
51
+ }
52
+
53
+ type BodyImageSpec = {
54
+ src: string;
55
+ alt: string;
56
+ caption?: string;
57
+ x: number;
58
+ y: number;
59
+ width: number;
60
+ height: number;
61
+ side?: "left" | "right";
62
+ layoutPreset?: string;
63
+ lockedPosition?: boolean;
64
+ lockedSize?: boolean;
65
+ };
66
+
67
+ type ContentBlock = {
68
+ kind: "paragraph" | "heading" | "quote" | "list-item";
69
+ text: string;
70
+ bullet?: string;
71
+ };
72
+
73
+ type FlowBlock = {
74
+ text: string;
75
+ font: string;
76
+ lineHeight: number;
77
+ className: string;
78
+ gapBefore?: number;
79
+ gapAfter?: number;
80
+ bullet?: string;
81
+ textIndent?: number;
82
+ };
83
+
84
+ type LongformPageLayout = {
85
+ lines: Array<{
86
+ text: string;
87
+ x: number;
88
+ y: number;
89
+ className: string;
90
+ maxWidth?: number;
91
+ bullet?: string;
92
+ bulletX?: number;
93
+ }>;
94
+ images: Array<BodyImageSpec & { captionHeight?: number }>;
95
+ textBottom?: number;
96
+ };
97
+
98
+ type PageImageSpecInput = {
99
+ src: string;
100
+ alt?: string;
101
+ caption?: string;
102
+ x?: number;
103
+ y?: number;
104
+ width?: number;
105
+ height?: number;
106
+ side?: string;
107
+ layout?: string;
108
+ };
109
+
110
+ type PageImageGroupSpecInput = {
111
+ page: number;
112
+ image_layout?: string;
113
+ target_fill_ratio?: number;
114
+ images: PageImageSpecInput[];
115
+ };
116
+
117
+ type PageImageSpecFileInput = {
118
+ default_image_layout?: string;
119
+ target_fill_ratio?: number;
120
+ page_specs?: PageImageGroupSpecInput[];
121
+ };
122
+
123
+ type ExplicitPagePlan = {
124
+ page: number;
125
+ imageLayout: string;
126
+ targetFillRatio: number;
127
+ images: BodyImageSpec[];
128
+ };
129
+
130
+ export type RenderArticlePageSummary = {
131
+ page: number;
132
+ imageCount: number;
133
+ imageSources: string[];
134
+ };
135
+
136
+ export type RenderArticleResult = {
137
+ pageCount: number;
138
+ pages: RenderArticlePageSummary[];
139
+ };
140
+
141
+ function parseIntWithFallback(raw: string | undefined, fallback: number): number {
142
+ if (raw === undefined || raw.length === 0) return fallback;
143
+ const value = Number.parseInt(raw, 10);
144
+ return Number.isFinite(value) ? value : fallback;
145
+ }
146
+
147
+ function normalizeMixedTextSpacing(text: string): string {
148
+ // Match any ASCII token regardless of length (removed {0,11} limit)
149
+ const asciiToken = "([A-Za-z0-9][A-Za-z0-9.+-]*)";
150
+
151
+ // Pass 1: CJK + space(s) + ASCII_token + space(s) + CJK → CJK + token + CJK
152
+ let result = text.replace(
153
+ new RegExp(`([\\p{Script=Han}])\\s+${asciiToken}\\s+([\\p{Script=Han}])`, "gu"),
154
+ "$1$2$3",
155
+ );
156
+
157
+ // Pass 2: CJK + space(s) + ASCII_token before Chinese punctuation or EOL
158
+ result = result.replace(
159
+ new RegExp(`([\\p{Script=Han}])\\s+${asciiToken}(?=[,。!?;:、)】》」』]|$)`, "gu"),
160
+ "$1$2",
161
+ );
162
+
163
+ // Pass 3: Start-of-text or opening bracket + ASCII_token + space(s) + CJK
164
+ result = result.replace(
165
+ new RegExp(`(^|[(【《「『])${asciiToken}\\s+([\\p{Script=Han}])`, "gu"),
166
+ "$1$2$3",
167
+ );
168
+
169
+ // Pass 4: Handle consecutive ASCII tokens separated by spaces.
170
+ // After Pass 1-3, we may still have "使用 AI Agent 管理" where AI and Agent
171
+ // are two tokens with a space between them. This pass joins consecutive
172
+ // ASCII tokens, removing the space between them.
173
+ // The \\s* before the first asciiToken handles the space between CJK and the first token.
174
+ // Re-run until no more matches (handles 3+ token chains).
175
+ let prev = "";
176
+ while (prev !== result) {
177
+ prev = result;
178
+ result = result.replace(
179
+ new RegExp(`([\\p{Script=Han}])\\s*${asciiToken}\\s+${asciiToken}(?=\\s*(?:[\\p{Script=Han},。!?;:、)】》」』]|[A-Za-z0-9]))`, "gu"),
180
+ (_m, cjk: string, t1: string, t2: string) => `${cjk}${t1}${t2}`,
181
+ );
182
+ }
183
+
184
+ // After joining tokens, clean up spaces between the joined phrase and following CJK.
185
+ // Pattern: CJK + ASCIIchain + space + CJK (the space is on the RIGHT of the chain)
186
+ // NOTE: asciiToken introduces a capturing group, so the second CJK capture is group 3, not group 2.
187
+ result = result.replace(
188
+ new RegExp(`([\\p{Script=Han}]${asciiToken})\\s+([\\p{Script=Han}])`, "gu"),
189
+ "$1$3",
190
+ );
191
+
192
+ return result;
193
+ }
194
+
195
+ function fitImageToBox(
196
+ sourceWidth: number,
197
+ sourceHeight: number,
198
+ maxWidth: number,
199
+ maxHeight: number,
200
+ ): { width: number; height: number } {
201
+ if (sourceWidth <= 0 || sourceHeight <= 0) {
202
+ return { width: maxWidth, height: maxHeight };
203
+ }
204
+ const scale = Math.min(maxWidth / sourceWidth, maxHeight / sourceHeight);
205
+ return {
206
+ width: Math.max(1, Math.round(sourceWidth * scale)),
207
+ height: Math.max(1, Math.round(sourceHeight * scale)),
208
+ };
209
+ }
210
+
211
+ function parseContentBlocks(text: string): ContentBlock[] {
212
+ const lines = text.replaceAll("\r\n", "\n").split("\n");
213
+ const blocks: ContentBlock[] = [];
214
+ let paragraphBuffer: string[] = [];
215
+
216
+ function flushParagraph(): void {
217
+ const value = normalizeMixedTextSpacing(paragraphBuffer.join(" ").trim());
218
+ if (value.length > 0) blocks.push({ kind: "paragraph", text: value });
219
+ paragraphBuffer = [];
220
+ }
221
+
222
+ for (const rawLine of lines) {
223
+ const line = rawLine.trim();
224
+ if (line.length === 0) {
225
+ flushParagraph();
226
+ continue;
227
+ }
228
+
229
+ if (line.startsWith("## ")) {
230
+ flushParagraph();
231
+ blocks.push({ kind: "heading", text: normalizeMixedTextSpacing(line.slice(3).trim()) });
232
+ continue;
233
+ }
234
+
235
+ if (line.startsWith("# ")) {
236
+ flushParagraph();
237
+ blocks.push({ kind: "heading", text: normalizeMixedTextSpacing(line.slice(2).trim()) });
238
+ continue;
239
+ }
240
+
241
+ if (line.startsWith(">")) {
242
+ flushParagraph();
243
+ blocks.push({ kind: "quote", text: normalizeMixedTextSpacing(line.replace(/^>\s?/, "").trim()) });
244
+ continue;
245
+ }
246
+
247
+ if (/^[-*]\s+/.test(line)) {
248
+ flushParagraph();
249
+ blocks.push({
250
+ kind: "list-item",
251
+ text: normalizeMixedTextSpacing(line.replace(/^[-*]\s+/, "").trim()),
252
+ bullet: "•",
253
+ });
254
+ continue;
255
+ }
256
+
257
+ paragraphBuffer.push(line);
258
+ }
259
+
260
+ flushParagraph();
261
+ return blocks;
262
+ }
263
+
264
+ function normalizeSide(raw: string | undefined): "left" | "right" {
265
+ return raw === "right" ? "right" : "left";
266
+ }
267
+
268
+ function normalizeLayoutPreset(raw: string): string {
269
+ const value = raw.trim().toLowerCase();
270
+ const aliases: Record<string, string> = {
271
+ auto: "auto",
272
+ default: "auto",
273
+ conservative: "auto",
274
+ fill: "fill",
275
+ staggered: "staggered",
276
+ "split-dual": "staggered",
277
+ editorial: "editorial",
278
+ "editorial-float": "editorial",
279
+ "corner-soft": "corner-soft",
280
+ "mid-left": "mid-left",
281
+ "mid-right": "mid-right",
282
+ };
283
+ return aliases[value] ?? "auto";
284
+ }
285
+
286
+ function buildAutoImagePlacement(
287
+ index: number,
288
+ imageWidth: number,
289
+ imageHeight: number,
290
+ layoutPreset: string,
291
+ side: "left" | "right",
292
+ ): Pick<BodyImageSpec, "x" | "y"> {
293
+ const preset = normalizeLayoutPreset(layoutPreset);
294
+ const horizontalPadding = 24;
295
+ const topTextBuffer = 240;
296
+ const leftX = horizontalPadding;
297
+ const rightX = Math.max(horizontalPadding, DEFAULT_CONTENT_WIDTH - imageWidth - horizontalPadding);
298
+
299
+ if (preset === "corner-soft") {
300
+ return {
301
+ x: side === "right" ? rightX : leftX,
302
+ y: topTextBuffer - 24 + index * (imageHeight + 22),
303
+ };
304
+ }
305
+
306
+ if (preset === "mid-left") {
307
+ return {
308
+ x: leftX,
309
+ y: 210 + index * (imageHeight + 32),
310
+ };
311
+ }
312
+
313
+ if (preset === "mid-right") {
314
+ return {
315
+ x: rightX,
316
+ y: 210 + index * (imageHeight + 32),
317
+ };
318
+ }
319
+
320
+ if (preset === "editorial") {
321
+ return {
322
+ x: side === "right" ? rightX : leftX,
323
+ y: topTextBuffer + index * 182 + (index % 2) * 42,
324
+ };
325
+ }
326
+
327
+ if (preset === "staggered") {
328
+ const staggerSide = index % 2 === 0 ? side : side === "left" ? "right" : "left";
329
+ return {
330
+ x: staggerSide === "right" ? rightX : leftX,
331
+ y: topTextBuffer - 12 + index * (imageHeight + 64),
332
+ };
333
+ }
334
+
335
+ return {
336
+ x: side === "right" ? rightX : leftX,
337
+ y: topTextBuffer + index * (imageHeight + 30),
338
+ };
339
+ }
340
+
341
+ function buildResolvedBodyImageSpec(
342
+ input: PageImageSpecInput,
343
+ index: number,
344
+ fallbackLayout: string,
345
+ ): BodyImageSpec {
346
+ const resolvedSrc = resolveInputPath(input.src);
347
+ const intrinsicSize = readImageSize(resolvedSrc);
348
+ const widthRaw = input.width === undefined ? undefined : String(input.width);
349
+ const heightRaw = input.height === undefined ? undefined : String(input.height);
350
+ // Resolve layout early so the size calculation below can use it.
351
+ const layoutPreset = input.layout?.trim() || fallbackLayout;
352
+
353
+ // Fill layout: place a conservative full-width placeholder near the bottom
354
+ // of the page. In spec-driven mode this becomes the starting point for the
355
+ // page-local image scaling search; in auto-flow mode it still behaves as a
356
+ // bottom obstacle that reserves space for the image.
357
+ if (normalizeLayoutPreset(layoutPreset) === "fill") {
358
+ // Use a conservative placeholder height; the real height is determined
359
+ // later by the page-local sizing pass when spec-driven layout is active.
360
+ const FILL_PLACEHOLDER_HEIGHT = 200;
361
+ const stageH = getLongformGeometry(getLongformTheme("paper-sage")).contentStageHeight;
362
+ return {
363
+ src: resolveInputPath(input.src),
364
+ alt: input.alt ?? "body image",
365
+ caption: input.caption ?? "",
366
+ x: 0,
367
+ y: stageH - FILL_PLACEHOLDER_HEIGHT,
368
+ width: DEFAULT_CONTENT_WIDTH,
369
+ height: FILL_PLACEHOLDER_HEIGHT,
370
+ layoutPreset,
371
+ side: "left",
372
+ lockedPosition: false,
373
+ lockedSize: false,
374
+ };
375
+ }
376
+
377
+ let width = 210;
378
+ let height = 210;
379
+
380
+ if (widthRaw && heightRaw) {
381
+ width = parseIntWithFallback(widthRaw, 210);
382
+ height = parseIntWithFallback(heightRaw, 210);
383
+ } else if (intrinsicSize !== null && widthRaw) {
384
+ width = parseIntWithFallback(widthRaw, 210);
385
+ height = Math.max(1, Math.round(width * (intrinsicSize.height / intrinsicSize.width)));
386
+ } else if (intrinsicSize !== null && heightRaw) {
387
+ height = parseIntWithFallback(heightRaw, 210);
388
+ width = Math.max(1, Math.round(height * (intrinsicSize.width / intrinsicSize.height)));
389
+ } else if (intrinsicSize !== null) {
390
+ // For side-by-side layouts (staggered/editorial/corner-soft), the image
391
+ // occupies roughly half the content width as an obstacle. Fitting it into
392
+ // a fixed 210×210 box ignores the actual available width and can produce
393
+ // tall images that push too much content onto extra pages.
394
+ //
395
+ // Instead, compute max dimensions based on the layout:
396
+ // - Half-width layouts: maxW = half content width minus padding; maxH capped at 200
397
+ // - Full-width / unknown layouts: fall back to 210×210 (previous behaviour)
398
+ const halfWidth = Math.floor((DEFAULT_CONTENT_WIDTH - 24 * 2) / 2); // ≈ 336
399
+ const normalizedPreset = normalizeLayoutPreset(layoutPreset);
400
+ const isHalfWidthLayout = normalizedPreset === "staggered"
401
+ || normalizedPreset === "editorial"
402
+ || normalizedPreset === "corner-soft"
403
+ || normalizedPreset === "mid-left"
404
+ || normalizedPreset === "mid-right";
405
+ const boxW = isHalfWidthLayout ? halfWidth : 210;
406
+ const boxH = isHalfWidthLayout ? 200 : 210;
407
+ const fitted = fitImageToBox(intrinsicSize.width, intrinsicSize.height, boxW, boxH);
408
+ width = fitted.width;
409
+ height = fitted.height;
410
+ } else {
411
+ width = parseIntWithFallback(widthRaw, 210);
412
+ height = parseIntWithFallback(heightRaw, 210);
413
+ }
414
+
415
+ const side = normalizeSide(input.side);
416
+ const autoPlacement = buildAutoImagePlacement(index, width, height, layoutPreset, side);
417
+ const x = input.x === undefined ? autoPlacement.x : parseIntWithFallback(String(input.x), autoPlacement.x);
418
+ const y = input.y === undefined ? autoPlacement.y : parseIntWithFallback(String(input.y), autoPlacement.y);
419
+ return {
420
+ src: resolvedSrc,
421
+ alt: input.alt ?? "body image",
422
+ caption: input.caption ?? "",
423
+ x,
424
+ y,
425
+ width,
426
+ height,
427
+ side,
428
+ layoutPreset,
429
+ lockedPosition: input.x !== undefined || input.y !== undefined,
430
+ lockedSize: input.width !== undefined || input.height !== undefined,
431
+ };
432
+ }
433
+
434
+ function clampFillRatio(raw: number | undefined, fallback: number): number {
435
+ if (!Number.isFinite(raw)) {
436
+ return fallback;
437
+ }
438
+ return Math.min(0.95, Math.max(0.35, raw!));
439
+ }
440
+
441
+ function buildExplicitPagePlans(parsed: ReturnType<typeof parseArgs>): ExplicitPagePlan[] | null {
442
+ const specFile = getArg(parsed, "page-image-spec-file");
443
+ if (specFile.length === 0) {
444
+ return null;
445
+ }
446
+
447
+ const raw = JSON.parse(readFileSync(resolveInputPath(specFile), "utf8")) as PageImageSpecFileInput;
448
+ const defaultLayout = raw.default_image_layout?.trim() || getArg(parsed, "image-layout", "auto");
449
+ const defaultTargetFillRatio = clampFillRatio(raw.target_fill_ratio, 0.8);
450
+ const pageSpecs = Array.isArray(raw.page_specs) ? raw.page_specs : [];
451
+ return pageSpecs
452
+ .map((pageSpec) => {
453
+ const page = Number.isFinite(pageSpec.page) ? Math.max(1, Math.floor(pageSpec.page)) : 0;
454
+ if (page < 1) {
455
+ return null;
456
+ }
457
+ const pageLayout = pageSpec.image_layout?.trim() || defaultLayout;
458
+ const images = Array.isArray(pageSpec.images) ? pageSpec.images : [];
459
+ return {
460
+ page,
461
+ imageLayout: pageLayout,
462
+ targetFillRatio: clampFillRatio(pageSpec.target_fill_ratio, defaultTargetFillRatio),
463
+ images: images
464
+ .map((image, index) => buildResolvedBodyImageSpec(image, index, pageLayout))
465
+ .filter((image) => image.src.length > 0),
466
+ } satisfies ExplicitPagePlan;
467
+ })
468
+ .filter((item): item is ExplicitPagePlan => item !== null)
469
+ .sort((a, b) => a.page - b.page);
470
+ }
471
+
472
+ function buildBodyImages(parsed: ReturnType<typeof parseArgs>): BodyImageSpec[] {
473
+ const sources = getArgs(parsed, "body-image");
474
+ const alts = getArgs(parsed, "body-image-alt");
475
+ const captions = getArgs(parsed, "image-caption");
476
+ const xs = getArgs(parsed, "image-x");
477
+ const ys = getArgs(parsed, "image-y");
478
+ const widths = getArgs(parsed, "image-width");
479
+ const heights = getArgs(parsed, "image-height");
480
+ const sides = getArgs(parsed, "image-side");
481
+ const layoutPreset = getArg(parsed, "image-layout", "auto");
482
+
483
+ return sources
484
+ .map((src, index) =>
485
+ buildResolvedBodyImageSpec(
486
+ {
487
+ src,
488
+ alt: alts[index],
489
+ caption: captions[index],
490
+ x: xs[index] === undefined ? undefined : Number(xs[index]),
491
+ y: ys[index] === undefined ? undefined : Number(ys[index]),
492
+ width: widths[index] === undefined ? undefined : Number(widths[index]),
493
+ height: heights[index] === undefined ? undefined : Number(heights[index]),
494
+ side: sides[index],
495
+ layout: layoutPreset,
496
+ },
497
+ index,
498
+ layoutPreset,
499
+ )
500
+ )
501
+ .filter(image => image.src.length > 0);
502
+ }
503
+
504
+ function buildAsciiBodyImages(parsed: ReturnType<typeof parseArgs>, chromePath: string): BodyImageSpec[] {
505
+ const avatars = getArgs(parsed, "body-ascii-portrait");
506
+ const captions = getArgs(parsed, "ascii-caption");
507
+ const xs = getArgs(parsed, "ascii-x");
508
+ const ys = getArgs(parsed, "ascii-y");
509
+ const widths = getArgs(parsed, "ascii-width");
510
+ const heights = getArgs(parsed, "ascii-height");
511
+ const sides = getArgs(parsed, "ascii-side");
512
+ const layouts = getArgs(parsed, "ascii-layout");
513
+ const bg = getArg(parsed, "ascii-bg", "#111111");
514
+ const chars = getArg(parsed, "ascii-chars", "@#W$9876543210?!abc;:+=-,._ ");
515
+ const columns = getIntArg(parsed, "ascii-columns", 34);
516
+
517
+ return avatars.map((avatarPath, index) => {
518
+ const width = parseIntWithFallback(widths[index], 210);
519
+ const height = parseIntWithFallback(heights[index], 210);
520
+ const side = normalizeSide(sides[index]);
521
+ const layoutPreset = layouts[index] ?? "editorial";
522
+ const autoPlacement = buildAutoImagePlacement(index, width, height, layoutPreset, side);
523
+ const x = parseIntWithFallback(xs[index], autoPlacement.x);
524
+ const y = parseIntWithFallback(ys[index], autoPlacement.y);
525
+ const outPath = join(tmpdir(), `ascii-body-${Date.now()}-${index}.png`);
526
+ renderAsciiPortraitPng({
527
+ chromePath,
528
+ outPath,
529
+ avatarPath: resolveInputPath(avatarPath),
530
+ bg,
531
+ chars,
532
+ columns,
533
+ fontSize: Math.max(9, Math.round(width / 18)),
534
+ lineHeight: Math.max(10, Math.round(height / 18)),
535
+ width,
536
+ height,
537
+ templateName: "ascii-portrait-tile",
538
+ });
539
+ return {
540
+ src: outPath,
541
+ alt: "ascii portrait",
542
+ caption: captions[index] ?? "",
543
+ x,
544
+ y,
545
+ width,
546
+ height,
547
+ side,
548
+ layoutPreset,
549
+ lockedPosition: xs[index] !== undefined || ys[index] !== undefined,
550
+ lockedSize: widths[index] !== undefined || heights[index] !== undefined,
551
+ };
552
+ });
553
+ }
554
+
555
+ function toFlowBlocks(blocks: ContentBlock[], theme: LongformTheme): FlowBlock[] {
556
+ let consecutiveParagraphs = 0;
557
+
558
+ const RHYTHM_MODIFIER: Record<string, number> = {
559
+ normal: 1.0,
560
+ compressed: 0.65,
561
+ };
562
+
563
+ return blocks.map(block => {
564
+ const style = theme.bodyStyles[block.kind];
565
+
566
+ if (block.kind === "paragraph") {
567
+ consecutiveParagraphs += 1;
568
+ } else {
569
+ consecutiveParagraphs = 0;
570
+ }
571
+
572
+ // Every 3rd consecutive paragraph gets slightly compressed spacing
573
+ const rhythm = (block.kind === "paragraph" && consecutiveParagraphs > 0 && consecutiveParagraphs % 3 === 0)
574
+ ? "compressed"
575
+ : "normal";
576
+ const modifier = RHYTHM_MODIFIER[rhythm];
577
+
578
+ return {
579
+ text: block.text,
580
+ font: style.font,
581
+ lineHeight: style.lineHeight,
582
+ className: style.className,
583
+ gapBefore: Math.round((style.gapBefore ?? 0) * modifier),
584
+ gapAfter: Math.round((style.gapAfter ?? 0) * modifier),
585
+ bullet: block.kind === "list-item" ? block.bullet ?? "•" : undefined,
586
+ // Reserve a stable hanging indent so the bullet never gets wrapped onto its own line.
587
+ textIndent: block.kind === "list-item" ? 34 : 0,
588
+ };
589
+ });
590
+ }
591
+
592
+ function parseChinesePageNumber(raw: string): number | null {
593
+ const digits: Record<string, number> = {
594
+ "零": 0,
595
+ "一": 1,
596
+ "二": 2,
597
+ "两": 2,
598
+ "三": 3,
599
+ "四": 4,
600
+ "五": 5,
601
+ "六": 6,
602
+ "七": 7,
603
+ "八": 8,
604
+ "九": 9,
605
+ };
606
+ if (/^\d+$/.test(raw)) {
607
+ return Number.parseInt(raw, 10);
608
+ }
609
+ let total = 0;
610
+ let current = 0;
611
+ for (const char of raw) {
612
+ if (char === "百") {
613
+ total += (current || 1) * 100;
614
+ current = 0;
615
+ continue;
616
+ }
617
+ if (char === "十") {
618
+ total += (current || 1) * 10;
619
+ current = 0;
620
+ continue;
621
+ }
622
+ const digit = digits[char];
623
+ if (digit === undefined) {
624
+ return null;
625
+ }
626
+ current = digit;
627
+ }
628
+ return total + current;
629
+ }
630
+
631
+ function parsePageMarkerLine(line: string): number | null {
632
+ const trimmed = line.trim();
633
+ const chinese = trimmed.match(/^【第([一二三四五六七八九十百两\d]+)页】$/);
634
+ if (chinese) {
635
+ return parseChinesePageNumber(chinese[1] ?? "");
636
+ }
637
+ const english = trimmed.match(/^【Page\s*(\d+)】$/i);
638
+ if (english) {
639
+ return Number.parseInt(english[1] ?? "0", 10);
640
+ }
641
+ return null;
642
+ }
643
+
644
+ export function splitTextByPageMarkers(text: string): Array<{ page: number; text: string }> {
645
+ const lines = text.replaceAll("\r\n", "\n").split("\n");
646
+ const segments = new Map<number, string[]>();
647
+ const prelude: string[] = [];
648
+ let currentPage: number | null = null;
649
+
650
+ for (const line of lines) {
651
+ const page = parsePageMarkerLine(line);
652
+ if (page !== null && page > 0) {
653
+ currentPage = page;
654
+ if (!segments.has(page)) {
655
+ segments.set(page, []);
656
+ }
657
+ continue;
658
+ }
659
+ if (currentPage === null) {
660
+ prelude.push(line);
661
+ continue;
662
+ }
663
+ segments.get(currentPage)!.push(line);
664
+ }
665
+
666
+ if (segments.size === 0) {
667
+ return [];
668
+ }
669
+
670
+ const firstPage = [...segments.keys()].sort((a, b) => a - b)[0] ?? 1;
671
+ if (prelude.some((line) => line.trim().length > 0)) {
672
+ const target = segments.get(firstPage) ?? [];
673
+ segments.set(firstPage, [...prelude, ...target]);
674
+ }
675
+
676
+ return [...segments.entries()]
677
+ .sort((a, b) => a[0] - b[0])
678
+ .map(([page, pageLines]) => ({
679
+ page,
680
+ text: pageLines.join("\n").trim(),
681
+ }));
682
+ }
683
+
684
+ function stripPageMarkerLines(text: string): string {
685
+ return text
686
+ .replaceAll("\r\n", "\n")
687
+ .split("\n")
688
+ .filter((line) => parsePageMarkerLine(line) === null)
689
+ .join("\n");
690
+ }
691
+
692
+ function materializeExplicitPagePlans(
693
+ explicitPagePlans: ExplicitPagePlan[],
694
+ targetPageCount: number,
695
+ ): ExplicitPagePlan[] {
696
+ const fallbackLayout = explicitPagePlans[0]?.imageLayout ?? "auto";
697
+ const fallbackFillRatio = explicitPagePlans[0]?.targetFillRatio ?? 0.8;
698
+ const planMap = new Map(explicitPagePlans.map((plan) => [plan.page, plan]));
699
+ return Array.from({ length: targetPageCount }, (_, index) => {
700
+ const page = index + 1;
701
+ return planMap.get(page) ?? {
702
+ page,
703
+ imageLayout: fallbackLayout,
704
+ targetFillRatio: fallbackFillRatio,
705
+ images: [],
706
+ };
707
+ });
708
+ }
709
+
710
+ function buildLineHeightMap(flowBlocks: FlowBlock[]): Map<string, number> {
711
+ const map = new Map<string, number>();
712
+ for (const block of flowBlocks) {
713
+ const previous = map.get(block.className) ?? 0;
714
+ map.set(block.className, Math.max(previous, block.lineHeight));
715
+ }
716
+ return map;
717
+ }
718
+
719
+ function createImageOnlyPageLayout(images: BodyImageSpec[]): LongformPageLayout {
720
+ return {
721
+ lines: [],
722
+ images: images.map((image) => ({
723
+ ...image,
724
+ captionHeight: image.caption ? 50 : 0,
725
+ })),
726
+ textBottom: 0,
727
+ };
728
+ }
729
+
730
+ function computePageFillRatio(
731
+ page: LongformPageLayout,
732
+ lineHeights: Map<string, number>,
733
+ theme: LongformTheme,
734
+ ): number {
735
+ const geometry = getLongformGeometry(theme);
736
+ const textBottom = page.lines.reduce((max, line) => {
737
+ const lineHeight = lineHeights.get(line.className) ?? 0;
738
+ return Math.max(max, line.y + lineHeight);
739
+ }, 0);
740
+ const imageBottom = page.images.reduce((max, image) => {
741
+ const captionExtra = image.caption ? (image.captionHeight ?? 50) + 10 : 0;
742
+ return Math.max(max, image.y + image.height + captionExtra);
743
+ }, 0);
744
+ const contentEnd = Math.max(textBottom, imageBottom);
745
+ if (geometry.contentStageHeight <= 0) {
746
+ return 0;
747
+ }
748
+ return Math.min(1, Math.max(0, contentEnd / geometry.contentStageHeight));
749
+ }
750
+
751
+ function scalePageImages(
752
+ images: BodyImageSpec[],
753
+ _scale: number,
754
+ theme: LongformTheme,
755
+ ): BodyImageSpec[] {
756
+ const geometry = getLongformGeometry(theme);
757
+ const halfWidth = Math.floor((geometry.contentWidth - 24 * 2) / 2);
758
+ return images.map((image, index) => {
759
+ if (image.lockedSize && image.lockedPosition) {
760
+ return image;
761
+ }
762
+
763
+ const preset = normalizeLayoutPreset(image.layoutPreset ?? "auto");
764
+ let width = image.width;
765
+ let height = image.height;
766
+
767
+ if (!image.lockedSize) {
768
+ if (preset === "fill") {
769
+ // Preserve the original aspect ratio strictly — fit the image into a
770
+ // box of (contentWidth × maxFillHeight) using contain semantics so
771
+ // neither dimension is ever stretched or clipped.
772
+ const intrinsic = readImageSize(image.src);
773
+ const intrinsicW = intrinsic !== null && intrinsic.width > 0 ? intrinsic.width : image.width;
774
+ const intrinsicH = intrinsic !== null && intrinsic.height > 0 ? intrinsic.height : image.height;
775
+ const maxFillW = geometry.contentWidth;
776
+ const maxFillH = Math.floor(geometry.contentStageHeight * 0.60); // cap at 60% of stage height
777
+ const scaleByW = maxFillW / intrinsicW;
778
+ const scaleByH = maxFillH / intrinsicH;
779
+ const fitScale = Math.min(scaleByW, scaleByH, 1); // never upscale beyond intrinsic
780
+ width = Math.max(120, Math.round(intrinsicW * fitScale));
781
+ height = Math.max(80, Math.round(intrinsicH * fitScale));
782
+ } else {
783
+ const maxWidth = preset === "staggered" || preset === "editorial" || preset === "corner-soft" || preset === "mid-left" || preset === "mid-right"
784
+ ? halfWidth
785
+ : geometry.contentWidth;
786
+ const maxHeight = preset === "staggered" || preset === "editorial" || preset === "corner-soft" || preset === "mid-left" || preset === "mid-right"
787
+ ? Math.max(180, Math.floor(geometry.contentStageHeight * 0.6))
788
+ : Math.max(220, Math.floor(geometry.contentStageHeight * 0.72));
789
+ // Preserve the original aspect ratio — use the intrinsic image size
790
+ // (not the 210×210 placeholder) and fit into the maxWidth×maxHeight
791
+ // box with contain semantics.
792
+ const intrinsic = readImageSize(image.src);
793
+ const intrinsicW = intrinsic !== null && intrinsic.width > 0 ? intrinsic.width : image.width;
794
+ const intrinsicH = intrinsic !== null && intrinsic.height > 0 ? intrinsic.height : image.height;
795
+ const scaleByW = maxWidth / intrinsicW;
796
+ const scaleByH = maxHeight / intrinsicH;
797
+ const fitScale = Math.min(scaleByW, scaleByH); // contain semantics
798
+ width = Math.max(72, Math.round(intrinsicW * fitScale));
799
+ height = Math.max(72, Math.round(intrinsicH * fitScale));
800
+ }
801
+ }
802
+
803
+ let x = image.x;
804
+ let y = image.y;
805
+ if (!image.lockedPosition) {
806
+ if (preset === "fill") {
807
+ x = 0;
808
+ y = Math.max(0, geometry.contentStageHeight - height);
809
+ } else {
810
+ const autoPlacement = buildAutoImagePlacement(index, width, height, image.layoutPreset ?? "auto", image.side ?? "left");
811
+ x = autoPlacement.x;
812
+ y = autoPlacement.y;
813
+ }
814
+ }
815
+
816
+ return {
817
+ ...image,
818
+ width,
819
+ height,
820
+ x,
821
+ y,
822
+ };
823
+ });
824
+ }
825
+
826
+ type SpecPageMeasure = {
827
+ layout: LongformPageLayout;
828
+ fit: boolean;
829
+ fillRatio: number;
830
+ scale: number;
831
+ };
832
+
833
+ function measureLongformPages(params: {
834
+ flowBlocks: FlowBlock[];
835
+ bodyImages: BodyImageSpec[];
836
+ pageImageGroups?: BodyImageSpec[][] | null;
837
+ pageImageLimit?: number;
838
+ theme: LongformTheme;
839
+ }): LongformPageLayout[] {
840
+ ensurePretextRuntime();
841
+ const geometry = getLongformGeometry(params.theme);
842
+ const allImages = params.pageImageGroups?.flat() ?? params.bodyImages;
843
+ const maxImgMinDim = allImages.reduce(
844
+ (max, img) => Math.max(max, Math.min(img.width, img.height)),
845
+ 210,
846
+ );
847
+ const obstacleGap = proportionalObstacleGap(maxImgMinDim);
848
+ const minSlotWidth = proportionalMinSlotWidth(32);
849
+ const flow = createObstacleFlowRuntime({
850
+ prepareWithSegments,
851
+ layoutNextLineRange,
852
+ obstacleGap,
853
+ minSlotWidth,
854
+ renderLine() {},
855
+ renderImage() {},
856
+ });
857
+ return flow.paginateBlocks(
858
+ params.flowBlocks,
859
+ geometry.contentWidth,
860
+ geometry.contentHeight,
861
+ params.bodyImages,
862
+ {
863
+ pageImageLimit: params.pageImageLimit ?? 2,
864
+ pageImageGroups: params.pageImageGroups ?? null,
865
+ },
866
+ );
867
+ }
868
+
869
+ function measureSpecPage(
870
+ flowBlocks: FlowBlock[],
871
+ images: BodyImageSpec[],
872
+ theme: LongformTheme,
873
+ scale: number,
874
+ ): SpecPageMeasure {
875
+ const lineHeights = buildLineHeightMap(flowBlocks);
876
+ if (flowBlocks.length === 0) {
877
+ const layout = createImageOnlyPageLayout(images);
878
+ return {
879
+ layout,
880
+ fit: true,
881
+ fillRatio: computePageFillRatio(layout, lineHeights, theme),
882
+ scale,
883
+ };
884
+ }
885
+
886
+ // For fill-layout images, reduce contentHeight so the text measurement pass
887
+ // cannot place lines in the image's reserved bottom zone. Without this,
888
+ // paginateBlocks may allow text lines up to contentHeight (932) while the
889
+ // image obstacle only covers contentStageHeight-imgH ~ contentStageHeight (920),
890
+ // leaving a 12px gap where lines can slip through and visually overlap the image.
891
+ //
892
+ // The correct text ceiling is imgY = contentStageHeight - imgH (where the
893
+ // obstacle actually starts). We pass that as the reduced contentHeight so
894
+ // paginateBlocks stops placing lines exactly at the obstacle boundary.
895
+ const geometry = getLongformGeometry(theme);
896
+ const fillImages = images.filter(
897
+ (img) => normalizeLayoutPreset(img.layoutPreset ?? "auto") === "fill",
898
+ );
899
+ let measureTheme = theme;
900
+ if (fillImages.length > 0) {
901
+ // imgY = contentStageHeight - maxFillHeight (= where the tallest fill image starts)
902
+ // Use that as the contentHeight cap so text cannot enter the image zone.
903
+ // Add contentBottomGap back because getLongformGeometry subtracts it:
904
+ // contentStageHeight = contentHeight - contentBottomGap
905
+ // so: imgY = (contentHeight - contentBottomGap) - maxFillHeight
906
+ // We want paginateBlocks' height = imgY, which means:
907
+ // reducedContentHeight = imgY + contentBottomGap (getLongformGeometry will subtract it again)
908
+ const maxFillHeight = fillImages.reduce((max, img) => Math.max(max, img.height), 0);
909
+ const imgY = Math.max(0, geometry.contentStageHeight - maxFillHeight);
910
+ // Add a small gap so text doesn't butt right up against the image
911
+ const textCeiling = Math.max(80, imgY - 16);
912
+ const reducedHeight = textCeiling + theme.contentBottomGap;
913
+ measureTheme = applyContentHeightOverride(theme, reducedHeight);
914
+ }
915
+
916
+ // When fill images are present we pass an empty bodyImages list to
917
+ // measureLongformPages — the reduced contentHeight already prevents text
918
+ // from entering the image zone. Passing the actual fill images would cause
919
+ // obstacle-flow to clamp their y-coordinates to fit within the reduced
920
+ // contentHeight, corrupting the final rendered position.
921
+ const measureBodyImages = fillImages.length > 0 ? [] : images;
922
+
923
+ const pages = measureLongformPages({
924
+ flowBlocks,
925
+ bodyImages: measureBodyImages,
926
+ pageImageLimit: Math.max(images.length, 1),
927
+ theme: measureTheme,
928
+ });
929
+ // Restore the real scaled images into the layout so renderPage uses the
930
+ // correct coordinates.
931
+ const rawLayout = pages[0] ?? createImageOnlyPageLayout(images);
932
+ const layout: typeof rawLayout = fillImages.length > 0
933
+ ? { ...rawLayout, images: images.map(img => ({ ...img, captionHeight: img.caption ? 50 : 0 })) }
934
+ : rawLayout;
935
+ return {
936
+ layout,
937
+ fit: pages.length <= 1,
938
+ fillRatio: computePageFillRatio(layout, lineHeights, theme),
939
+ scale,
940
+ };
941
+ }
942
+
943
+ function optimizeSpecPageLayout(params: {
944
+ chromePath: string;
945
+ flowBlocks: FlowBlock[];
946
+ pagePlan: ExplicitPagePlan;
947
+ theme: LongformTheme;
948
+ }): SpecPageMeasure | null {
949
+ const { flowBlocks, pagePlan, theme } = params;
950
+ const baseImages = pagePlan.images;
951
+ const targetFillRatio = pagePlan.targetFillRatio;
952
+ if (baseImages.length === 0) {
953
+ const measured = measureSpecPage(flowBlocks, [], theme, 1);
954
+ return measured.fit ? measured : null;
955
+ }
956
+
957
+ const cache = new Map<string, SpecPageMeasure>();
958
+ const evaluate = (scale: number): SpecPageMeasure => {
959
+ const key = scale.toFixed(4);
960
+ const cached = cache.get(key);
961
+ if (cached) {
962
+ return cached;
963
+ }
964
+ const scaledImages = scalePageImages(baseImages, scale, theme);
965
+ const measured = measureSpecPage(flowBlocks, scaledImages, theme, scale);
966
+ cache.set(key, measured);
967
+ return measured;
968
+ };
969
+
970
+ const minimum = evaluate(0.45);
971
+ if (!minimum.fit) {
972
+ return null;
973
+ }
974
+
975
+ let best = minimum;
976
+ for (const scale of [0.6, 0.75, 0.9, 1, 1.15, 1.3, 1.45, 1.6, 1.8]) {
977
+ const measured = evaluate(scale);
978
+ if (!measured.fit) {
979
+ continue;
980
+ }
981
+ const measuredDelta = Math.abs(measured.fillRatio - targetFillRatio);
982
+ const bestDelta = Math.abs(best.fillRatio - targetFillRatio);
983
+ if (
984
+ measuredDelta < bestDelta ||
985
+ (measuredDelta === bestDelta && measured.scale > best.scale)
986
+ ) {
987
+ best = measured;
988
+ }
989
+ }
990
+
991
+ let low = minimum.scale;
992
+ let high = 1.8;
993
+ for (let index = 0; index < 7; index += 1) {
994
+ const mid = (low + high) / 2;
995
+ const measured = evaluate(mid);
996
+ if (!measured.fit) {
997
+ high = mid;
998
+ continue;
999
+ }
1000
+ const measuredDelta = Math.abs(measured.fillRatio - targetFillRatio);
1001
+ const bestDelta = Math.abs(best.fillRatio - targetFillRatio);
1002
+ if (
1003
+ measuredDelta < bestDelta ||
1004
+ (measuredDelta === bestDelta && measured.scale > best.scale)
1005
+ ) {
1006
+ best = measured;
1007
+ }
1008
+ if (measured.fillRatio <= targetFillRatio + 0.02) {
1009
+ low = mid;
1010
+ } else {
1011
+ high = mid;
1012
+ }
1013
+ }
1014
+
1015
+ return best;
1016
+ }
1017
+
1018
+ function buildSpecDrivenPagesFromSegments(params: {
1019
+ chromePath: string;
1020
+ pagePlans: ExplicitPagePlan[];
1021
+ pageTexts: string[];
1022
+ theme: LongformTheme;
1023
+ }): LongformPageLayout[] | null {
1024
+ const pages: LongformPageLayout[] = [];
1025
+ for (let index = 0; index < params.pagePlans.length; index += 1) {
1026
+ const pagePlan = params.pagePlans[index]!;
1027
+ const blocks = parseContentBlocks(params.pageTexts[index] ?? "");
1028
+ const flowBlocks = toFlowBlocks(blocks, params.theme);
1029
+ const measured = optimizeSpecPageLayout({
1030
+ chromePath: params.chromePath,
1031
+ flowBlocks,
1032
+ pagePlan,
1033
+ theme: params.theme,
1034
+ });
1035
+ if (measured === null) {
1036
+ return null;
1037
+ }
1038
+ pages.push(measured.layout);
1039
+ }
1040
+ return pages;
1041
+ }
1042
+
1043
+ function buildSpecDrivenPagesByPartition(params: {
1044
+ chromePath: string;
1045
+ pagePlans: ExplicitPagePlan[];
1046
+ blocks: ContentBlock[];
1047
+ theme: LongformTheme;
1048
+ }): LongformPageLayout[] | null {
1049
+ const { chromePath, pagePlans, blocks, theme } = params;
1050
+ const memo = new Map<string, { score: number; pages: LongformPageLayout[] } | null>();
1051
+
1052
+ const solve = (pageIndex: number, blockIndex: number): { score: number; pages: LongformPageLayout[] } | null => {
1053
+ const key = `${pageIndex}:${blockIndex}`;
1054
+ if (memo.has(key)) {
1055
+ return memo.get(key) ?? null;
1056
+ }
1057
+
1058
+ if (pageIndex >= pagePlans.length) {
1059
+ const result = blockIndex >= blocks.length ? { score: 0, pages: [] } : null;
1060
+ memo.set(key, result);
1061
+ return result;
1062
+ }
1063
+
1064
+ const remainingPages = pagePlans.length - pageIndex;
1065
+ const remainingBlocks = blocks.length - blockIndex;
1066
+ const minEndExclusive = remainingBlocks > remainingPages - 1 ? blockIndex + 1 : blockIndex;
1067
+ const maxEndExclusive = remainingBlocks >= remainingPages
1068
+ ? blocks.length - (remainingPages - 1)
1069
+ : Math.min(blocks.length, blockIndex + 1);
1070
+ let best: { score: number; pages: LongformPageLayout[] } | null = null;
1071
+
1072
+ for (let endExclusive = minEndExclusive; endExclusive <= maxEndExclusive; endExclusive += 1) {
1073
+ const flowBlocks = toFlowBlocks(blocks.slice(blockIndex, endExclusive), theme);
1074
+ const measured = optimizeSpecPageLayout({
1075
+ chromePath,
1076
+ flowBlocks,
1077
+ pagePlan: pagePlans[pageIndex]!,
1078
+ theme,
1079
+ });
1080
+ if (measured === null) {
1081
+ continue;
1082
+ }
1083
+ const rest = solve(pageIndex + 1, endExclusive);
1084
+ if (rest === null) {
1085
+ continue;
1086
+ }
1087
+ const hasVisibleContent = measured.layout.lines.length > 0 || measured.layout.images.length > 0;
1088
+ const score = Math.abs(measured.fillRatio - pagePlans[pageIndex]!.targetFillRatio)
1089
+ + (hasVisibleContent ? 0 : 0.35)
1090
+ + rest.score;
1091
+ if (best === null || score < best.score) {
1092
+ best = {
1093
+ score,
1094
+ pages: [measured.layout, ...rest.pages],
1095
+ };
1096
+ }
1097
+ }
1098
+
1099
+ memo.set(key, best);
1100
+ return best;
1101
+ };
1102
+
1103
+ return solve(0, 0)?.pages ?? null;
1104
+ }
1105
+
1106
+ function renderPage(params: {
1107
+ chromePath: string;
1108
+ outPath: string;
1109
+ pageLabel: string;
1110
+ footer: string;
1111
+ iconPath: string;
1112
+ templateName: string;
1113
+ page: LongformPageLayout;
1114
+ themeCssVars: string;
1115
+ theme: LongformTheme;
1116
+ highlightWords?: string[];
1117
+ }): void {
1118
+ const words = params.highlightWords ?? [];
1119
+
1120
+ function highlightBodyText(text: string): string {
1121
+ let html = renderInlineMarkdown(text);
1122
+ for (const word of words) {
1123
+ if (!word) continue;
1124
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1125
+ html = html.replace(
1126
+ new RegExp(escaped, "g"),
1127
+ `<strong style="color:${params.theme.accentColor}">${word}</strong>`,
1128
+ );
1129
+ }
1130
+ return html;
1131
+ }
1132
+
1133
+ const template = readUtf8(join(TEMPLATES_DIR, `${params.templateName}.html`));
1134
+ const stageHtml = [
1135
+ ...params.page.images.map(image => {
1136
+ const captionHtml = image.caption
1137
+ ? `<div class="body-caption" style="left:${image.x}px;top:${image.y + image.height + 10}px;width:${image.width}px">${escapeHtml(image.caption)}</div>`
1138
+ : "";
1139
+ return `<img class="body-image" src="${escapeHtml(image.src)}" alt="${escapeHtml(image.alt)}" style="left:${image.x}px;top:${image.y}px;width:${image.width}px;height:${image.height}px;${imageShadowStyle(image)}">${captionHtml}`;
1140
+ }),
1141
+ ...params.page.lines
1142
+ .filter(line => line.className !== "body-caption")
1143
+ .map(line => {
1144
+ const bulletHtml =
1145
+ line.bullet && typeof line.bulletX === "number"
1146
+ ? `<div class="${escapeHtml(line.className)} body-list-bullet" style="left:${line.bulletX}px;top:${line.y}px">${escapeHtml(line.bullet)}</div>`
1147
+ : "";
1148
+ const maxWidthStyle = line.maxWidth ? `;max-width:${line.maxWidth}px` : "";
1149
+ return `${bulletHtml}<div class="${escapeHtml(line.className)}" style="left:${line.x}px;top:${line.y}px${maxWidthStyle}">${highlightBodyText(line.text)}</div>`;
1150
+ }),
1151
+ ].join("");
1152
+ const html = renderTemplate(template, {
1153
+ "{{PAGE_LABEL}}": escapeHtml(params.pageLabel),
1154
+ "{{THEME_CSS_VARS}}": params.themeCssVars,
1155
+ "{{FOOTER_TEXT}}": escapeHtml(params.footer),
1156
+ "{{ICON_PATH}}": params.iconPath,
1157
+ "{{AVATAR_PATH}}": join(ICONS_DIR, "logo.png"),
1158
+ "{{FONT_PATH}}": join(FONTS_DIR, "AlimamaShuHeiTi-Bold.ttf"),
1159
+ "{{BODY_FONT_PATH}}": join(FONTS_DIR, "LXGWNeoZhiSongPlus.ttf"),
1160
+ "{{LONGFORM_STAGE_HTML}}": stageHtml,
1161
+ });
1162
+
1163
+ screenshotHtml({
1164
+ chromePath: params.chromePath,
1165
+ html,
1166
+ outPath: params.outPath,
1167
+ width: params.theme.pageWidth,
1168
+ height: params.theme.pageHeight,
1169
+ virtualTimeBudgetMs: 1200,
1170
+ });
1171
+ }
1172
+
1173
+ export function runRenderArticleCli(argv: string[]): RenderArticleResult {
1174
+ const parsed = parseArgs(argv);
1175
+ const title = getArg(parsed, "title");
1176
+ if (title.length === 0) throw new Error("需要 --title");
1177
+
1178
+ const textFile = getArg(parsed, "text-file");
1179
+ const text = textFile.length > 0 ? readFileSync(textFile, "utf8") : getArg(parsed, "text");
1180
+ if (text.length === 0) throw new Error("需要 --text 或 --text-file");
1181
+
1182
+ const highlightWordsRaw = getArg(parsed, "highlight-words");
1183
+ const highlightWords = highlightWordsRaw.length > 0 ? highlightWordsRaw.split(",").filter(w => w.length > 0) : [];
1184
+
1185
+ const chromePath = findChrome();
1186
+ if (chromePath === null) throw new Error("Chrome/Chromium not found");
1187
+
1188
+ const footer = getArg(parsed, "footer", "公众号 · 早早集市");
1189
+ const iconPath = resolveInputPath(getArg(parsed, "icon") || join(ICONS_DIR, "logo.png"));
1190
+ const templateName = getArg(parsed, "template", "longform-3-4");
1191
+ const themeName = getArg(parsed, "theme", "paper-sage");
1192
+ let theme = getLongformTheme(themeName);
1193
+ theme = applyGeometryOverrides(theme, {
1194
+ pageWidth: getIntArg(parsed, "page-width", 0) || undefined,
1195
+ pageHeight: getIntArg(parsed, "page-height", 0) || undefined,
1196
+ bodyPaddingX: getIntArg(parsed, "body-padding-x", 0) || undefined,
1197
+ bodyPaddingY: getIntArg(parsed, "body-padding-y", 0) || undefined,
1198
+ logoSize: getIntArg(parsed, "logo-size", 0) || undefined,
1199
+ logoGap: getIntArg(parsed, "logo-gap", 0) || undefined,
1200
+ footerMarginTop: getIntArg(parsed, "footer-margin-top", 0) || undefined,
1201
+ footerHeight: getIntArg(parsed, "footer-height", 0) || undefined,
1202
+ contentBottomGap: getIntArg(parsed, "content-bottom-gap", 0) || undefined,
1203
+ contentWidth: getIntArg(parsed, "content-width", 0) || undefined,
1204
+ contentHeight: getIntArg(parsed, "content-height", 0) || undefined,
1205
+ });
1206
+
1207
+ // Apply font size cap if explicitly requested (manual override, rarely needed)
1208
+ const fontSizeMax = getIntArg(parsed, "font-size-max", 0);
1209
+ if (fontSizeMax > 0) {
1210
+ theme = applyFontSizeMax(theme, fontSizeMax);
1211
+ }
1212
+
1213
+ const explicitPagePlansInput = buildExplicitPagePlans(parsed);
1214
+ const bodyImages = [
1215
+ ...buildBodyImages(parsed),
1216
+ ...buildAsciiBodyImages(parsed, chromePath),
1217
+ ];
1218
+
1219
+ // ── Adaptive pagination ──────────────────────────────────────────
1220
+ // If min_pages > 1, iteratively shrink contentHeight until the pagination
1221
+ // engine produces at least that many pages. This naturally accounts for
1222
+ // image height, line heights, and gaps — no manual estimation needed.
1223
+ // Each iteration reduces contentHeight by 10% with a floor of 80px.
1224
+ const minPages = getIntArg(parsed, "min-pages", 1);
1225
+ const maxPages = getIntArg(parsed, "max-pages", 0); // 0 = no upper limit
1226
+ const FONT_SIZE_CAP_FOR_FORCED_PAGES = 30;
1227
+ let pages: LongformPageLayout[];
1228
+ if (explicitPagePlansInput !== null) {
1229
+ const markerSegments = splitTextByPageMarkers(text);
1230
+ const highestMarkerPage = markerSegments.reduce((max, item) => Math.max(max, item.page), 0);
1231
+ const highestSpecPage = explicitPagePlansInput.reduce((max, item) => Math.max(max, item.page), 0);
1232
+ const explicitPagePlans = materializeExplicitPagePlans(
1233
+ explicitPagePlansInput,
1234
+ Math.max(minPages, highestMarkerPage, highestSpecPage),
1235
+ );
1236
+
1237
+ if (markerSegments.length > 0) {
1238
+ const pageTextMap = new Map(markerSegments.map((segment) => [segment.page, segment.text]));
1239
+ const pageTexts = explicitPagePlans.map((plan) => pageTextMap.get(plan.page) ?? "");
1240
+ pages = buildSpecDrivenPagesFromSegments({
1241
+ chromePath,
1242
+ pagePlans: explicitPagePlans,
1243
+ pageTexts,
1244
+ theme,
1245
+ }) ?? [];
1246
+ } else {
1247
+ pages = buildSpecDrivenPagesByPartition({
1248
+ chromePath,
1249
+ pagePlans: explicitPagePlans,
1250
+ blocks: parseContentBlocks(stripPageMarkerLines(text)),
1251
+ theme,
1252
+ }) ?? [];
1253
+ }
1254
+
1255
+ if (pages.length === 0) {
1256
+ throw new Error("spec-driven pagination failed to fit the requested text/images into the configured pages");
1257
+ }
1258
+ if (maxPages > 0 && pages.length > maxPages) {
1259
+ throw new Error(`spec-driven pagination produced ${pages.length} page(s), exceeding max_pages=${maxPages}`);
1260
+ }
1261
+ } else {
1262
+ const blocks = parseContentBlocks(stripPageMarkerLines(text));
1263
+ const flowBlocks = toFlowBlocks(blocks, theme);
1264
+ pages = measureLongformPages({
1265
+ flowBlocks,
1266
+ bodyImages,
1267
+ pageImageLimit: 2,
1268
+ theme,
1269
+ });
1270
+ if (minPages > 1 && pages.length < minPages) {
1271
+ theme = applyFontSizeMax(theme, FONT_SIZE_CAP_FOR_FORCED_PAGES);
1272
+ const MIN_CONTENT_HEIGHT = 80;
1273
+ while (pages.length < minPages && getLongformGeometry(theme).contentHeight > MIN_CONTENT_HEIGHT) {
1274
+ const next = Math.max(MIN_CONTENT_HEIGHT, Math.floor(getLongformGeometry(theme).contentHeight * 0.9));
1275
+ theme = applyContentHeightOverride(theme, next);
1276
+ pages = measureLongformPages({
1277
+ flowBlocks: toFlowBlocks(blocks, theme),
1278
+ bodyImages,
1279
+ pageImageLimit: 2,
1280
+ theme,
1281
+ });
1282
+ }
1283
+ }
1284
+
1285
+ if (maxPages > 0 && pages.length > maxPages) {
1286
+ const originalContentHeight = getLongformGeometry(theme).contentHeight;
1287
+ const MAX_CONTENT_HEIGHT = originalContentHeight * 4;
1288
+ while (pages.length > maxPages && getLongformGeometry(theme).contentHeight < MAX_CONTENT_HEIGHT) {
1289
+ const next = Math.min(MAX_CONTENT_HEIGHT, Math.ceil(getLongformGeometry(theme).contentHeight * 1.15));
1290
+ theme = applyContentHeightOverride(theme, next);
1291
+ pages = measureLongformPages({
1292
+ flowBlocks: toFlowBlocks(blocks, theme),
1293
+ bodyImages,
1294
+ pageImageLimit: 2,
1295
+ theme,
1296
+ });
1297
+ }
1298
+ }
1299
+ }
1300
+ const themeCssVars = getLongformThemeCssVars(theme);
1301
+ const pageNum = getIntArg(parsed, "page-num", 0);
1302
+ const pageTotal = getIntArg(parsed, "page-total", 0);
1303
+ const outPath = getArg(parsed, "out");
1304
+
1305
+ const result: RenderArticleResult = {
1306
+ pageCount: pages.length,
1307
+ pages: pages.map((page, index) => ({
1308
+ page: index + 1,
1309
+ imageCount: page.images.length,
1310
+ imageSources: page.images.map((image) => image.src),
1311
+ })),
1312
+ };
1313
+
1314
+ if (outPath.length > 0) {
1315
+ const requestedPage = pageNum > 0 ? pageNum : 1;
1316
+ const page = pages[Math.max(0, Math.min(requestedPage - 1, pages.length - 1))]!;
1317
+ const total = pageTotal > 0 ? pageTotal : pages.length;
1318
+ renderPage({
1319
+ chromePath,
1320
+ outPath,
1321
+ pageLabel: `${Math.min(requestedPage, pages.length)} / ${total}`,
1322
+ footer,
1323
+ iconPath,
1324
+ templateName,
1325
+ page,
1326
+ themeCssVars,
1327
+ theme,
1328
+ highlightWords,
1329
+ });
1330
+ printSaved(outPath);
1331
+ return result;
1332
+ }
1333
+
1334
+ const outDir = getArg(parsed, "out-dir");
1335
+ if (outDir.length === 0) {
1336
+ throw new Error("需要 --out (单页模式) 或 --out-dir (批量模式)");
1337
+ }
1338
+ for (let index = 0; index < pages.length; index++) {
1339
+ const pageOut = join(outDir, `article-${String(index + 1).padStart(2, "0")}.png`);
1340
+ // DEBUG: dump layout coordinates
1341
+ const dbgPage = pages[index]!;
1342
+ const lastLine = dbgPage.lines.length > 0 ? dbgPage.lines[dbgPage.lines.length - 1] : null;
1343
+ const firstImg = dbgPage.images.length > 0 ? dbgPage.images[0] : null;
1344
+ process.stderr.write(`[DEBUG page ${index+1}] lastLine y=${lastLine?.y ?? 'N/A'} | firstImg y=${firstImg?.y ?? 'N/A'} h=${firstImg?.height ?? 'N/A'}\n`);
1345
+ renderPage({
1346
+ chromePath,
1347
+ outPath: pageOut,
1348
+ pageLabel: `${index + 1} / ${pages.length}`,
1349
+ footer,
1350
+ iconPath,
1351
+ templateName,
1352
+ page: pages[index]!,
1353
+ themeCssVars,
1354
+ theme,
1355
+ highlightWords,
1356
+ });
1357
+ printSaved(pageOut);
1358
+ }
1359
+ return result;
1360
+ }
1361
+
1362
+ if (import.meta.main) {
1363
+ runRenderArticleCli(process.argv.slice(2));
1364
+ }