@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.
- package/README.md +366 -170
- package/package.json +4 -7
- package/src/adapter-loader.ts +138 -0
- package/src/adapter-types.ts +172 -0
- package/src/adapters/builtin-image-renderer.ts +353 -0
- package/src/adapters/builtin-markdown-renderer.ts +63 -0
- package/src/adapters/index.ts +2 -0
- package/src/args.test.ts +118 -0
- package/src/args.ts +90 -0
- package/src/cli.ts +51 -0
- package/src/commands/abandon.test.ts +104 -0
- package/src/commands/abandon.ts +202 -0
- package/src/commands/attach-body-images.ts +88 -0
- package/src/commands/attach-body.ts +86 -0
- package/src/commands/attach-newspic-spec.ts +41 -0
- package/src/commands/checkpoint.test.ts +91 -0
- package/src/commands/checkpoint.ts +66 -0
- package/src/commands/config.ts +106 -0
- package/src/commands/cos-upload.ts +62 -0
- package/src/commands/doctor.ts +66 -0
- package/src/commands/find-run.ts +55 -0
- package/src/commands/hermes-metrics.ts +226 -0
- package/src/commands/imgx.ts +60 -0
- package/src/commands/ingest-handoff.ts +509 -0
- package/src/commands/init.ts +126 -0
- package/src/commands/prepare-finalize.ts +348 -0
- package/src/commands/prepare.ts +310 -0
- package/src/commands/publish.ts +147 -0
- package/src/commands/reconcile.ts +36 -0
- package/src/commands/render.ts +244 -0
- package/src/commands/reset.test.ts +151 -0
- package/src/commands/reset.ts +159 -0
- package/src/commands/review.ts +77 -0
- package/src/commands/status.ts +68 -0
- package/src/commands/sync-blog.ts +39 -0
- package/src/commands/tasks.ts +50 -0
- package/src/commands/wechat-export.ts +68 -0
- package/src/config.ts +0 -0
- package/src/imgx/ascii-portrait.ts +77 -0
- package/src/imgx/assets/fonts/AlimamaShuHeiTi-Bold.ttf +0 -0
- package/src/imgx/assets/fonts/LXGWNeoZhiSongPlus.ttf +0 -0
- package/src/imgx/assets/fonts/LXGWWenKai-Regular.ttf +0 -0
- package/src/imgx/assets/icons/logo.png +0 -0
- package/src/imgx/assets/icons/logo.svg +8 -0
- package/{dist/assets → src}/imgx/assets/templates/longform-3-4.html +1 -1
- package/{dist/assets → src}/imgx/assets/templates/poster-3-4.html +69 -30
- package/{dist/assets → src}/imgx/assets/templates/wechat-cover-split.html +58 -27
- package/src/imgx/cli.ts +57 -0
- package/src/imgx/geometry.ts +68 -0
- package/src/imgx/index.ts +5 -0
- package/src/imgx/longform-theme.ts +243 -0
- package/src/imgx/poster-recipe.ts +81 -0
- package/src/imgx/pretext-adapter.ts +14 -0
- package/src/imgx/pretext-runtime.ts +61 -0
- package/src/imgx/references/ascii-portrait-3-4.md +59 -0
- package/src/imgx/references/longform-3-4.md +173 -0
- package/src/imgx/references/poster-3-4.md +87 -0
- package/src/imgx/references/tips-3-4.md +69 -0
- package/src/imgx/references/wechat-cover-split.md +89 -0
- package/src/imgx/references/x-like-posts.md +139 -0
- package/src/imgx/render-article.test.ts +53 -0
- package/src/imgx/render-article.ts +1364 -0
- package/src/imgx/render-ascii-portrait.ts +53 -0
- package/src/imgx/render-card.ts +232 -0
- package/src/imgx/render-x-like-posts.ts +235 -0
- package/src/imgx/runtime.ts +403 -0
- package/src/imgx/typographic-scale.test.ts +56 -0
- package/src/imgx/typographic-scale.ts +44 -0
- package/src/output.ts +632 -0
- package/src/plugins.ts +101 -0
- package/src/profiles.ts +149 -0
- package/src/providers/blog.test.ts +58 -0
- package/src/providers/blog.ts +154 -0
- package/src/providers/cos.ts +183 -0
- package/src/providers/index.test.ts +26 -0
- package/src/providers/index.ts +185 -0
- package/src/providers/wechat.test.ts +215 -0
- package/src/providers/wechat.ts +548 -0
- package/src/routes.ts +287 -0
- package/src/runtime-paths.ts +179 -0
- package/src/schema/config.ts +133 -0
- package/src/schema/state.ts +376 -0
- package/src/spawn.ts +103 -0
- package/src/state.ts +265 -0
- package/src/task-manager.ts +657 -0
- package/src/task-views.ts +222 -0
- package/src/text.test.ts +689 -0
- package/src/text.ts +683 -0
- package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
- package/src/wechat-preview/browser/editor-export.css +241 -0
- package/src/wechat-preview/browser/editor-export.ts +103 -0
- package/src/wechat-preview/frontmatter-handler.ts +73 -0
- package/src/wechat-preview/index.test.ts +154 -0
- package/src/wechat-preview/index.ts +294 -0
- package/src/wechat-preview/themes.ts +141 -0
- package/src/wechat-preview/wechat-formatter.test.ts +288 -0
- package/src/wechat-preview/wechat-formatter.ts +1506 -0
- package/src/wechat-preview.test.ts +73 -0
- package/src/workflow-materials.test.ts +225 -0
- package/src/workflow-materials.ts +217 -0
- package/src/workflow.test.ts +2087 -0
- package/dist/assets/imgx/assets/icons/avatar_jinx_cartoon.jpg +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo-square.png +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo.jpg +0 -0
- package/dist/assets/imgx/assets/icons/fishbone-logo.png +0 -0
- package/dist/assets/imgx/assets/icons/openclaw-logo.svg +0 -22
- package/dist/assets/imgx/assets/icons/zzclub-logo-black.jpg +0 -0
- package/dist/assets/imgx/assets/icons/zzclub-logo-gray.svg +0 -25
- package/dist/cli.js +0 -431
- package/dist/node_modules/@chenglou/pretext/dist/analysis.d.ts +0 -33
- package/dist/node_modules/@chenglou/pretext/dist/analysis.js +0 -1063
- package/dist/node_modules/@chenglou/pretext/dist/bidi.d.ts +0 -1
- package/dist/node_modules/@chenglou/pretext/dist/bidi.js +0 -175
- package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.d.ts +0 -4
- package/dist/node_modules/@chenglou/pretext/dist/generated/bidi-data.js +0 -979
- package/dist/node_modules/@chenglou/pretext/dist/layout.d.ts +0 -70
- package/dist/node_modules/@chenglou/pretext/dist/layout.js +0 -496
- package/dist/node_modules/@chenglou/pretext/dist/line-break.d.ts +0 -36
- package/dist/node_modules/@chenglou/pretext/dist/line-break.js +0 -820
- package/dist/node_modules/@chenglou/pretext/dist/measurement.d.ts +0 -28
- package/dist/node_modules/@chenglou/pretext/dist/measurement.js +0 -219
- package/dist/node_modules/@chenglou/pretext/dist/rich-inline.d.ts +0 -51
- package/dist/node_modules/@chenglou/pretext/dist/rich-inline.js +0 -401
- /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.d.ts +0 -0
- /package/{dist/assets → src}/imgx/assets/browser/obstacle-flow.js +0 -0
- /package/{dist/assets → src}/imgx/assets/icons/clover.svg +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-3-4.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/ascii-portrait-tile.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/tips-3-4.html +0 -0
- /package/{dist/assets → src}/imgx/assets/templates/x-like-posts.html +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/.vite/manifest.json +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/assets/style-BYNXnUvc.css +0 -0
- /package/{dist/assets → src}/wechat-preview/assets/browser-dist/editor-export.js +0 -0
- /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
|
+
}
|