@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
@@ -164,10 +164,10 @@
164
164
  }
165
165
 
166
166
  function getLetterSpacing(fontSize) {
167
- if (fontSize >= 110) return "-2.8px";
168
- if (fontSize >= 90) return "-2px";
169
- if (fontSize >= 70) return "-1px";
170
- return "-0.4px";
167
+ const t = (fontSize - 34) / (132 - 34);
168
+ const clamped = Math.min(1, Math.max(0, t));
169
+ const spacing = -2.8 + clamped * 2.6;
170
+ return `${spacing.toFixed(1)}px`;
171
171
  }
172
172
 
173
173
  function measureLayout(fontSize, maxWidth) {
@@ -202,26 +202,42 @@
202
202
  };
203
203
  }
204
204
 
205
- function countVisibleChars(text) {
206
- let total = 0;
207
- total += Array.from(text).filter(ch => ch.trim().length > 0).length;
208
- return total;
205
+ // Character width coefficients for visual estimation.
206
+ // CJK characters are roughly 2x the width of Latin characters in CJK fonts.
207
+ const CJK_RE = /[一-鿿㐀-䶿豈-﫿 -〿＀-￯]/;
208
+ const LATIN_WIDE_RE = /[A-Z0-9@#%&]/;
209
+ const LATIN_NARROW_RE = /[a-z \t\n\r.,;:!?()[\]{}\-+=*/\\|]/;
210
+
211
+ function estimateVisualWidth(text) {
212
+ let width = 0;
213
+ for (const ch of Array.from(text)) {
214
+ if (CJK_RE.test(ch)) {
215
+ width += 2.0;
216
+ } else if (LATIN_WIDE_RE.test(ch)) {
217
+ width += 1.0;
218
+ } else if (LATIN_NARROW_RE.test(ch)) {
219
+ width += 0.55;
220
+ } else {
221
+ width += 1.0;
222
+ }
223
+ }
224
+ return width;
209
225
  }
210
226
 
211
- function getDesiredLineRange(chars) {
212
- if (chars <= 8) return [1, 2];
213
- if (chars <= 16) return [2, 3];
214
- if (chars <= 28) return [3, 4];
215
- if (chars <= 44) return [4, 6];
216
- if (chars <= 64) return [5, 7];
227
+ function getDesiredLineRange(visualWidth) {
228
+ if (visualWidth <= 16) return [1, 2];
229
+ if (visualWidth <= 32) return [2, 3];
230
+ if (visualWidth <= 50) return [3, 4];
231
+ if (visualWidth <= 72) return [4, 6];
232
+ if (visualWidth <= 100) return [5, 7];
217
233
  return [6, 8];
218
234
  }
219
235
 
220
- function getBlockMaxWidth(chars) {
221
- if (chars <= 10) return Math.floor(textBlock.clientWidth);
222
- if (chars <= 22) return Math.floor(textBlock.clientWidth * 0.92);
223
- if (chars <= 40) return Math.floor(textBlock.clientWidth * 0.86);
224
- if (chars <= 64) return Math.floor(textBlock.clientWidth * 0.8);
236
+ function getBlockMaxWidth(visualWidth) {
237
+ if (visualWidth <= 18) return Math.floor(textBlock.clientWidth);
238
+ if (visualWidth <= 38) return Math.floor(textBlock.clientWidth * 0.92);
239
+ if (visualWidth <= 60) return Math.floor(textBlock.clientWidth * 0.86);
240
+ if (visualWidth <= 90) return Math.floor(textBlock.clientWidth * 0.80);
225
241
  return Math.floor(textBlock.clientWidth * 0.74);
226
242
  }
227
243
 
@@ -229,28 +245,51 @@
229
245
  if (layout.totalHeight > availableHeight) return Number.POSITIVE_INFINITY;
230
246
 
231
247
  let score = 0;
248
+
249
+ // 1. Optical centering: text block should sit slightly above mathematical center
250
+ // (~46% from top, not 50%) — this is a well-established magazine design rule.
251
+ const opticalCenter = availableHeight * 0.46;
252
+ const topPadding = (availableHeight - layout.totalHeight) / 2;
253
+ const centerError = Math.abs(topPadding - opticalCenter) / availableHeight;
254
+ score += centerError * 400;
255
+
256
+ // 2. Per-block line quality
232
257
  for (const block of layout.blocks) {
233
- const charCount = countVisibleChars(block.text);
234
- const [minLines, maxLines] = getDesiredLineRange(charCount);
258
+ const visualWidth = estimateVisualWidth(block.text);
259
+ const [minLines, maxLines] = getDesiredLineRange(visualWidth);
235
260
  const lineCount = block.lineCount;
236
- if (lineCount < minLines) score += (minLines - lineCount) * 120;
237
- if (lineCount > maxLines) score += (lineCount - maxLines) * 150;
261
+
262
+ if (lineCount < minLines) score += (minLines - lineCount) * 80;
263
+ if (lineCount > maxLines) score += (lineCount - maxLines) * 120;
238
264
 
239
265
  if (block.widths.length > 1) {
240
266
  const widest = Math.max(...block.widths);
241
267
  const narrowest = Math.min(...block.widths);
242
268
  const fillRatio = widest / Math.max(1, maxWidth);
243
269
  const raggedRatio = narrowest / Math.max(1, widest);
244
- score += Math.max(0, 0.78 - fillRatio) * 220;
245
- score += Math.max(0, 0.58 - raggedRatio) * 200;
270
+
271
+ // Last line sweet spot: 65-85% of widest line
272
+ const lastLine = block.widths[block.widths.length - 1];
273
+ const lastRatio = lastLine / Math.max(1, widest);
274
+ score += Math.abs(lastRatio - 0.75) * 150;
275
+
276
+ score += Math.max(0, 0.78 - fillRatio) * 180;
277
+ score += Math.max(0, 0.55 - raggedRatio) * 170;
246
278
  } else if (block.widths.length === 1) {
247
279
  const fillRatio = block.widths[0] / Math.max(1, maxWidth);
248
- if (charCount > 10) score += Math.max(0, fillRatio - 0.88) * 180;
280
+ if (visualWidth > 12) {
281
+ score += Math.abs(fillRatio - 0.84) * 200;
282
+ }
249
283
  }
250
284
  }
251
285
 
252
- score += Math.max(0, 0.92 - layout.totalHeight / Math.max(1, availableHeight)) * 30;
253
- score += layout.fontSize * 0.015;
286
+ // 3. Height utilization sweet spot: 80-92%
287
+ const heightRatio = layout.totalHeight / Math.max(1, availableHeight);
288
+ score += Math.abs(heightRatio - 0.86) * 80;
289
+
290
+ // 4. Slight bias toward larger fonts
291
+ score -= (layout.fontSize / 132) * 5;
292
+
254
293
  return score;
255
294
  }
256
295
 
@@ -328,8 +367,8 @@
328
367
 
329
368
  async function boot() {
330
369
  await document.fonts.ready;
331
- const sourceChars = blocks.reduce((sum, block) => sum + Array.from(block.text).filter(ch => ch.trim().length > 0).length, 0);
332
- const maxWidth = getBlockMaxWidth(sourceChars);
370
+ const sourceVisualWidth = blocks.reduce((sum, block) => sum + estimateVisualWidth(block.text), 0);
371
+ const maxWidth = getBlockMaxWidth(sourceVisualWidth);
333
372
  const iconHeight = Math.ceil(iconNode.getBoundingClientRect().height);
334
373
  const watermarkHeight = Math.ceil(watermarkNode.getBoundingClientRect().height);
335
374
  const availableHeight = Math.max(
@@ -196,20 +196,23 @@
196
196
  }
197
197
 
198
198
  function getLineHeight(fontSize) {
199
- if (fontSize >= 80) return Math.round(fontSize * 1.06);
200
- if (fontSize >= 60) return Math.round(fontSize * 1.05);
201
- return Math.round(fontSize * 1.04);
199
+ // Smooth interpolation: 1.04 at 36px → 1.08 at 88px
200
+ const t = (fontSize - 36) / (88 - 36);
201
+ const ratio = 1.04 + Math.min(1, Math.max(0, t)) * 0.04;
202
+ return Math.round(fontSize * ratio);
202
203
  }
203
204
 
204
205
  function getBlockGap(fontSize) {
205
- return fontSize >= 64 ? 12 : fontSize >= 52 ? 10 : 8;
206
+ // Smooth: 8px at 36px → 14px at 88px
207
+ const t = (fontSize - 36) / (88 - 36);
208
+ return Math.round(8 + Math.min(1, Math.max(0, t)) * 6);
206
209
  }
207
210
 
208
211
  function getLetterSpacing(fontSize) {
209
- if (fontSize >= 80) return "-2px";
210
- if (fontSize >= 64) return "-1.5px";
211
- if (fontSize >= 48) return "-1px";
212
- return "-0.5px";
212
+ // Smooth: -0.5px at 36px → -2.5px at 88px
213
+ const t = (fontSize - 36) / (88 - 36);
214
+ const val = -0.5 + Math.min(1, Math.max(0, t)) * (-2.0);
215
+ return `${val.toFixed(1)}px`;
213
216
  }
214
217
 
215
218
  function measureLayout(fontSize, maxWidth) {
@@ -265,39 +268,67 @@
265
268
  .join("");
266
269
  }
267
270
 
271
+ function scoreCoverLayout(layout, availableHeight, maxWidth) {
272
+ let score = 0;
273
+
274
+ // 1. Height within sweet zone (75-90%)
275
+ const heightRatio = layout.totalHeight / Math.max(1, availableHeight);
276
+ score += Math.abs(heightRatio - 0.82) * 200;
277
+
278
+ // 2. Per-block line quality
279
+ for (const block of layout.blocks) {
280
+ if (block.widths.length === 1) {
281
+ const fill = block.widths[0] / maxWidth;
282
+ score += Math.abs(fill - 0.85) * 150;
283
+ } else if (block.widths.length === 2) {
284
+ const line1Fill = block.widths[0] / maxWidth;
285
+ const line2Fill = block.widths[1] / maxWidth;
286
+ // Prefer pyramid: line1 wider than line2
287
+ if (line2Fill > line1Fill) score += 80;
288
+ // Line2 should be 60-85% of line1
289
+ const ratio = block.widths[1] / Math.max(1, block.widths[0]);
290
+ score += Math.abs(ratio - 0.72) * 180;
291
+ }
292
+ }
293
+
294
+ // 3. Slight bias toward larger fonts
295
+ score -= (layout.fontSize / 88) * 10;
296
+
297
+ return score;
298
+ }
299
+
268
300
  async function boot() {
269
301
  await document.fonts.ready;
270
302
 
271
- const maxWidth = Math.min(MAX_WIDTH, Math.floor(textBlock.clientWidth));
272
- const leftEl = document.querySelector(".left");
303
+ const maxWidth = Math.min(MAX_WIDTH, Math.floor(textBlock.clientWidth));
304
+ const leftEl = document.querySelector(".left");
273
305
  const eyebrowEl = document.querySelector(".eyebrow");
274
- const leftH = leftEl ? leftEl.clientHeight : 400;
275
- const eyebrowH = eyebrowEl ? Math.ceil(eyebrowEl.getBoundingClientRect().height) + 14 : 32;
276
- const padV = 34 + 28; // .left padding-top + padding-bottom
277
- const footerRes = 52; // footer 16px 字 + bottom 24px + buffer
306
+ const leftH = leftEl ? leftEl.clientHeight : 400;
307
+ const eyebrowH = eyebrowEl ? Math.ceil(eyebrowEl.getBoundingClientRect().height) + 14 : 32;
308
+ const padV = 34 + 28;
309
+ const footerRes = 52;
278
310
  const availableHeight = Math.max(
279
311
  FALLBACK_AVAILABLE_HEIGHT,
280
312
  leftH - padV - eyebrowH - footerRes,
281
313
  );
282
314
 
283
- let chosen = null;
315
+ let best = null;
316
+ let bestScore = Number.POSITIVE_INFINITY;
284
317
 
285
- // Pass 1:最大字号,同时满足行数约束 + 高度约束
286
- for (let fs = MAX_FONT_SIZE; fs >= MIN_FONT_SIZE; fs -= 2) {
318
+ for (let fs = MAX_FONT_SIZE; fs >= MIN_FONT_SIZE; fs -= 1) {
287
319
  const layout = measureLayout(fs, maxWidth);
288
- if (layout.valid && layout.totalHeight <= availableHeight) { chosen = layout; break; }
289
- }
290
- // Pass 2:放宽高度约束,仍满足行数约束(超长标题兜底)
291
- if (!chosen) {
292
- for (let fs = MAX_FONT_SIZE; fs >= MIN_FONT_SIZE; fs -= 2) {
293
- const layout = measureLayout(fs, maxWidth);
294
- if (layout.valid) { chosen = layout; break; }
320
+ if (!layout.valid) continue;
321
+
322
+ const score = scoreCoverLayout(layout, availableHeight, maxWidth);
323
+ if (score < bestScore) {
324
+ bestScore = score;
325
+ best = layout;
295
326
  }
296
327
  }
297
- // Pass 3:无条件兜底(文字极长时)
298
- if (!chosen) chosen = measureLayout(MIN_FONT_SIZE, maxWidth);
299
328
 
300
- renderLayout(chosen);
329
+ if (!best) best = measureLayout(MIN_FONT_SIZE, maxWidth);
330
+
331
+ renderLayout(best);
301
332
  document.body.classList.add("ready");
302
333
  }
303
334
 
@@ -0,0 +1,57 @@
1
+ export type ParsedArgs = {
2
+ flags: Map<string, string[]>;
3
+ booleans: Set<string>;
4
+ };
5
+
6
+ export function parseArgs(argv: string[]): ParsedArgs {
7
+ const flags = new Map<string, string[]>();
8
+ const booleans = new Set<string>();
9
+
10
+ for (let index = 0; index < argv.length; index++) {
11
+ const token = argv[index];
12
+ if (!token?.startsWith("--")) continue;
13
+
14
+ const name = token.slice(2);
15
+ const next = argv[index + 1];
16
+ if (next === undefined || next.startsWith("--")) {
17
+ booleans.add(name);
18
+ continue;
19
+ }
20
+
21
+ const current = flags.get(name) ?? [];
22
+ current.push(next);
23
+ flags.set(name, current);
24
+ index += 1;
25
+ }
26
+
27
+ return { flags, booleans };
28
+ }
29
+
30
+ export function getArg(parsed: ParsedArgs, name: string, fallback = ""): string {
31
+ const values = parsed.flags.get(name);
32
+ if (values === undefined || values.length === 0) return fallback;
33
+ return values[values.length - 1] ?? fallback;
34
+ }
35
+
36
+ export function getArgs(parsed: ParsedArgs, name: string): string[] {
37
+ return parsed.flags.get(name) ?? [];
38
+ }
39
+
40
+ export function hasFlag(parsed: ParsedArgs, name: string): boolean {
41
+ return parsed.booleans.has(name);
42
+ }
43
+
44
+ export function requireArg(parsed: ParsedArgs, name: string): string {
45
+ const value = getArg(parsed, name);
46
+ if (value.length === 0) {
47
+ throw new Error(`Missing required argument --${name}`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ export function getIntArg(parsed: ParsedArgs, name: string, fallback: number): number {
53
+ const raw = getArg(parsed, name);
54
+ if (raw.length === 0) return fallback;
55
+ const value = Number.parseInt(raw, 10);
56
+ return Number.isFinite(value) ? value : fallback;
57
+ }
@@ -0,0 +1,68 @@
1
+ import type { LongformGeometry, LongformTheme } from "./longform-theme";
2
+
3
+ export type LongformGeometryOverrides = Partial<{
4
+ pageWidth: number;
5
+ pageHeight: number;
6
+ bodyPaddingX: number;
7
+ bodyPaddingY: number;
8
+ logoSize: number;
9
+ logoGap: number;
10
+ footerMarginTop: number;
11
+ footerHeight: number;
12
+ contentBottomGap: number;
13
+ contentWidth: number;
14
+ contentHeight: number;
15
+ }>;
16
+
17
+ function sanitizePositiveInt(value: number | undefined | null): number | null {
18
+ if (!Number.isFinite(value)) {
19
+ return null;
20
+ }
21
+ const rounded = Math.round(value!);
22
+ return rounded > 0 ? rounded : null;
23
+ }
24
+
25
+ export function applyGeometryOverrides(theme: LongformTheme, overrides: LongformGeometryOverrides): LongformTheme {
26
+ return {
27
+ ...theme,
28
+ pageWidth: sanitizePositiveInt(overrides.pageWidth) ?? theme.pageWidth,
29
+ pageHeight: sanitizePositiveInt(overrides.pageHeight) ?? theme.pageHeight,
30
+ bodyPaddingX: sanitizePositiveInt(overrides.bodyPaddingX) ?? theme.bodyPaddingX,
31
+ bodyPaddingY: sanitizePositiveInt(overrides.bodyPaddingY) ?? theme.bodyPaddingY,
32
+ logoSize: sanitizePositiveInt(overrides.logoSize) ?? theme.logoSize,
33
+ logoGap: sanitizePositiveInt(overrides.logoGap) ?? theme.logoGap,
34
+ footerMarginTop: sanitizePositiveInt(overrides.footerMarginTop) ?? theme.footerMarginTop,
35
+ footerHeight: sanitizePositiveInt(overrides.footerHeight) ?? theme.footerHeight,
36
+ contentBottomGap: sanitizePositiveInt(overrides.contentBottomGap) ?? theme.contentBottomGap,
37
+ contentWidth: sanitizePositiveInt(overrides.contentWidth) ?? theme.contentWidth,
38
+ contentHeight: sanitizePositiveInt(overrides.contentHeight) ?? theme.contentHeight,
39
+ };
40
+ }
41
+
42
+ export function getDerivedGeometry(theme: LongformTheme): LongformGeometry {
43
+ const pageWidth = theme.pageWidth;
44
+ const pageHeight = theme.pageHeight;
45
+ const bodyWidth = Math.max(120, pageWidth - theme.bodyPaddingX * 2);
46
+ const headerHeight = theme.logoSize + theme.logoGap;
47
+ const footerReservedHeight = theme.footerHeight + theme.footerMarginTop;
48
+ const derivedContentHeight = Math.max(
49
+ 120,
50
+ pageHeight - theme.bodyPaddingY * 2 - headerHeight - footerReservedHeight,
51
+ );
52
+ const contentWidth = Math.min(theme.contentWidth ?? bodyWidth, bodyWidth);
53
+ const contentHeight = Math.min(theme.contentHeight ?? derivedContentHeight, derivedContentHeight);
54
+ return {
55
+ pageWidth,
56
+ pageHeight,
57
+ bodyPaddingX: theme.bodyPaddingX,
58
+ bodyPaddingY: theme.bodyPaddingY,
59
+ logoSize: theme.logoSize,
60
+ logoGap: theme.logoGap,
61
+ footerMarginTop: theme.footerMarginTop,
62
+ footerHeight: theme.footerHeight,
63
+ contentBottomGap: theme.contentBottomGap,
64
+ contentWidth,
65
+ contentHeight,
66
+ contentStageHeight: Math.max(80, contentHeight - theme.contentBottomGap),
67
+ };
68
+ }
@@ -0,0 +1,5 @@
1
+ export { runRenderArticleCli } from "./render-article";
2
+ export type { RenderArticleResult } from "./render-article";
3
+ export { runRenderAsciiPortraitCli } from "./render-ascii-portrait";
4
+ export { runRenderCardCli } from "./render-card";
5
+ export { runRenderXLikePostsCli } from "./render-x-like-posts";
@@ -0,0 +1,243 @@
1
+ import { computeSpacing, proportionalLineHeight } from "./typographic-scale";
2
+
3
+ export type ContentBlockKind = "paragraph" | "heading" | "quote" | "list-item";
4
+
5
+ export const LONGFORM_PAGE_WIDTH = 900;
6
+ export const LONGFORM_PAGE_HEIGHT = 1200;
7
+
8
+ export type LongformLineStyle = {
9
+ font: string;
10
+ lineHeight: number;
11
+ className: string;
12
+ gapBefore?: number;
13
+ gapAfter?: number;
14
+ };
15
+
16
+ export type LongformTheme = {
17
+ name: string;
18
+ bgColor: string;
19
+ bodyColor: string;
20
+ accentColor: string;
21
+ quoteColor: string;
22
+ captionColor: string;
23
+ watermarkColor: string;
24
+ watermarkOpacity: number;
25
+ pageWidth: number;
26
+ pageHeight: number;
27
+ imageRadius: number;
28
+ bodyPaddingX: number;
29
+ bodyPaddingY: number;
30
+ logoSize: number;
31
+ logoGap: number;
32
+ footerMarginTop: number;
33
+ footerHeight: number;
34
+ contentBottomGap: number;
35
+ contentWidth: number | null;
36
+ contentHeight: number | null;
37
+ bodyStyles: Record<ContentBlockKind, LongformLineStyle>;
38
+ };
39
+
40
+ export type LongformGeometry = {
41
+ pageWidth: number;
42
+ pageHeight: number;
43
+ bodyPaddingX: number;
44
+ bodyPaddingY: number;
45
+ logoSize: number;
46
+ logoGap: number;
47
+ footerMarginTop: number;
48
+ footerHeight: number;
49
+ contentBottomGap: number;
50
+ contentWidth: number;
51
+ contentHeight: number;
52
+ contentStageHeight: number;
53
+ };
54
+
55
+ /**
56
+ * Return a new theme with contentHeight overridden.
57
+ * Used when min_pages > 1 to shrink the per-page content area so the
58
+ * pagination engine naturally splits content across more pages.
59
+ */
60
+ export function applyContentHeightOverride(theme: LongformTheme, contentHeight: number): LongformTheme {
61
+ return { ...theme, contentHeight: Math.max(100, Math.round(contentHeight)) };
62
+ }
63
+
64
+ /**
65
+ * Parse font size from a CSS font shorthand string like `400 32px "Font", sans-serif`.
66
+ * Returns the numeric pixel value, or null if not found.
67
+ */
68
+ function parseFontSize(font: string): number | null {
69
+ const m = font.match(/\b(\d+(?:\.\d+)?)px\b/);
70
+ return m ? parseFloat(m[1]!) : null;
71
+ }
72
+
73
+ /**
74
+ * Return a new theme where every bodyStyle font size is capped at fontSizeMax (px).
75
+ * Line heights are scaled proportionally when the font size is reduced.
76
+ * Used alongside applyContentHeightOverride to prevent overly large text when
77
+ * per-page content area is small (few words forced onto a fixed number of pages).
78
+ */
79
+ export function applyFontSizeMax(theme: LongformTheme, fontSizeMax: number): LongformTheme {
80
+ const clampedStyles = {} as Record<ContentBlockKind, LongformLineStyle>;
81
+ for (const [kind, style] of Object.entries(theme.bodyStyles) as [ContentBlockKind, LongformLineStyle][]) {
82
+ const original = parseFontSize(style.font);
83
+ if (original !== null && original > fontSizeMax) {
84
+ const ratio = fontSizeMax / original;
85
+ const newFont = style.font.replace(/\b\d+(?:\.\d+)?px\b/, `${fontSizeMax}px`);
86
+ const newLineHeight = Math.round(style.lineHeight * ratio);
87
+ clampedStyles[kind] = { ...style, font: newFont, lineHeight: newLineHeight };
88
+ } else {
89
+ clampedStyles[kind] = style;
90
+ }
91
+ }
92
+ return { ...theme, bodyStyles: clampedStyles };
93
+ }
94
+
95
+ export function getLongformGeometry(theme: LongformTheme): LongformGeometry {
96
+ const bodyWidth = Math.max(120, theme.pageWidth - theme.bodyPaddingX * 2);
97
+ const headerHeight = theme.logoSize + theme.logoGap;
98
+ const footerReservedHeight = theme.footerHeight + theme.footerMarginTop;
99
+ const derivedContentHeight = Math.max(
100
+ 120,
101
+ theme.pageHeight - theme.bodyPaddingY * 2 - headerHeight - footerReservedHeight,
102
+ );
103
+ const contentWidth = Math.min(theme.contentWidth ?? bodyWidth, bodyWidth);
104
+ const contentHeight = Math.min(theme.contentHeight ?? derivedContentHeight, derivedContentHeight);
105
+ return {
106
+ pageWidth: theme.pageWidth,
107
+ pageHeight: theme.pageHeight,
108
+ bodyPaddingX: theme.bodyPaddingX,
109
+ bodyPaddingY: theme.bodyPaddingY,
110
+ logoSize: theme.logoSize,
111
+ logoGap: theme.logoGap,
112
+ footerMarginTop: theme.footerMarginTop,
113
+ footerHeight: theme.footerHeight,
114
+ contentBottomGap: theme.contentBottomGap,
115
+ contentWidth,
116
+ contentHeight,
117
+ contentStageHeight: Math.max(80, contentHeight - theme.contentBottomGap),
118
+ };
119
+ }
120
+
121
+ const BODY_FS = 32;
122
+ const HEADING_FS = 50; // 32 * 1.25^2
123
+ const QUOTE_FS = 30;
124
+
125
+ const BASE_THEME: Omit<LongformTheme, "name" | "bgColor" | "bodyColor" | "accentColor" | "quoteColor" | "captionColor" | "watermarkColor" | "watermarkOpacity"> = {
126
+ pageWidth: LONGFORM_PAGE_WIDTH,
127
+ pageHeight: LONGFORM_PAGE_HEIGHT,
128
+ imageRadius: 22,
129
+
130
+ // Proportional geometry: ~4.5% of page height for vertical padding
131
+ bodyPaddingX: Math.round(LONGFORM_PAGE_WIDTH * 0.10), // 90
132
+ bodyPaddingY: Math.round(LONGFORM_PAGE_HEIGHT * 0.045), // 54 (was 48)
133
+ logoSize: 72,
134
+ logoGap: Math.round(LONGFORM_PAGE_HEIGHT * 0.045 * 0.78), // ~42 (was 40)
135
+ footerMarginTop: Math.round(BODY_FS * 1.2), // 38 (was 28)
136
+ footerHeight: Math.round(BODY_FS * 1.0), // 32 (unchanged)
137
+ contentBottomGap: Math.round(BODY_FS * 0.6), // 19 (was 12)
138
+
139
+ contentWidth: null,
140
+ contentHeight: null,
141
+
142
+ bodyStyles: {
143
+ paragraph: {
144
+ font: `400 ${BODY_FS}px "LXGWNeoZhiSongPlus", "PingFang SC", "Noto Serif SC", serif`,
145
+ lineHeight: proportionalLineHeight(BODY_FS),
146
+ className: "body-line",
147
+ gapAfter: computeSpacing(BODY_FS, BODY_FS, 0.55),
148
+ },
149
+ heading: {
150
+ font: `700 ${HEADING_FS}px "AlimamaShuHeiTi", "PingFang SC", sans-serif`,
151
+ lineHeight: proportionalLineHeight(HEADING_FS),
152
+ className: "body-heading",
153
+ gapBefore: computeSpacing(BODY_FS, HEADING_FS, 0.85),
154
+ gapAfter: computeSpacing(HEADING_FS, BODY_FS, 0.45),
155
+ },
156
+ quote: {
157
+ font: `400 ${QUOTE_FS}px "LXGWNeoZhiSongPlus", "PingFang SC", "Noto Serif SC", serif`,
158
+ lineHeight: proportionalLineHeight(QUOTE_FS),
159
+ className: "body-quote",
160
+ gapBefore: computeSpacing(BODY_FS, QUOTE_FS, 0.6),
161
+ gapAfter: computeSpacing(QUOTE_FS, BODY_FS, 0.5),
162
+ },
163
+ "list-item": {
164
+ font: `400 ${BODY_FS}px "LXGWNeoZhiSongPlus", "PingFang SC", "Noto Serif SC", serif`,
165
+ lineHeight: proportionalLineHeight(BODY_FS),
166
+ className: "body-line",
167
+ gapAfter: computeSpacing(BODY_FS, BODY_FS, 0.3),
168
+ },
169
+ },
170
+ };
171
+
172
+ const THEMES: Record<string, LongformTheme> = {
173
+ "paper-sage": {
174
+ ...BASE_THEME,
175
+ name: "paper-sage",
176
+ bgColor: "#f9fcfa",
177
+ bodyColor: "#22201d",
178
+ accentColor: "#1d4f39",
179
+ quoteColor: "#474038",
180
+ captionColor: "#6a6257",
181
+ watermarkColor: "#555555",
182
+ watermarkOpacity: 0.55,
183
+ },
184
+ "linen-news": {
185
+ ...BASE_THEME,
186
+ name: "linen-news",
187
+ bgColor: "#f7f3eb",
188
+ bodyColor: "#2b2621",
189
+ accentColor: "#7a2e24",
190
+ quoteColor: "#625449",
191
+ captionColor: "#7a6a5d",
192
+ watermarkColor: "#6a6158",
193
+ watermarkOpacity: 0.62,
194
+ bodyStyles: {
195
+ ...BASE_THEME.bodyStyles,
196
+ heading: {
197
+ ...BASE_THEME.bodyStyles.heading,
198
+ font: `700 36px "AlimamaShuHeiTi", "PingFang SC", sans-serif`,
199
+ lineHeight: proportionalLineHeight(36),
200
+ gapBefore: computeSpacing(BODY_FS, 36, 0.85),
201
+ gapAfter: computeSpacing(36, BODY_FS, 0.45),
202
+ },
203
+ paragraph: {
204
+ ...BASE_THEME.bodyStyles.paragraph,
205
+ font: `400 31px "LXGWNeoZhiSongPlus", "PingFang SC", "Noto Serif SC", serif`,
206
+ lineHeight: 56,
207
+ },
208
+ quote: {
209
+ ...BASE_THEME.bodyStyles.quote,
210
+ lineHeight: 54,
211
+ },
212
+ },
213
+ },
214
+ };
215
+
216
+ export function getLongformTheme(name: string): LongformTheme {
217
+ return THEMES[name] ?? THEMES["paper-sage"]!;
218
+ }
219
+
220
+ export function getLongformThemeCssVars(theme: LongformTheme): string {
221
+ const geometry = getLongformGeometry(theme);
222
+ return [
223
+ `--page-width:${theme.pageWidth}px`,
224
+ `--page-height:${theme.pageHeight}px`,
225
+ `--page-bg:${theme.bgColor}`,
226
+ `--body-color:${theme.bodyColor}`,
227
+ `--accent-color:${theme.accentColor}`,
228
+ `--quote-color:${theme.quoteColor}`,
229
+ `--caption-color:${theme.captionColor}`,
230
+ `--watermark-color:${theme.watermarkColor}`,
231
+ `--watermark-opacity:${theme.watermarkOpacity}`,
232
+ `--body-padding-x:${theme.bodyPaddingX}px`,
233
+ `--body-padding-y:${theme.bodyPaddingY}px`,
234
+ `--logo-size:${theme.logoSize}px`,
235
+ `--logo-gap:${theme.logoGap}px`,
236
+ `--footer-margin-top:${theme.footerMarginTop}px`,
237
+ `--footer-height:${theme.footerHeight}px`,
238
+ `--content-width:${geometry.contentWidth}px`,
239
+ `--content-height:${geometry.contentHeight}px`,
240
+ `--content-bottom-gap:${theme.contentBottomGap}px`,
241
+ `--image-radius:${theme.imageRadius}px`,
242
+ ].join(";");
243
+ }