@akairos/typeset-studio 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/typeset-studio",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Algorithmic typesetting studio & zzhub-pipeline image renderer plugin",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -55,13 +55,10 @@
55
55
  "dependencies": {
56
56
  "@chenglou/pretext": "^0.0.8",
57
57
  "@napi-rs/canvas": "^1.0.10",
58
- "clsx": "^2.1.1",
59
58
  "hono": "^4.7.4",
60
- "html-to-image": "^1.11.11",
61
59
  "lucide-react": "^1.16.0",
62
60
  "react": "^19.0.0",
63
- "react-dom": "^19.0.0",
64
- "tailwind-merge": "^3.0.2"
61
+ "react-dom": "^19.0.0"
65
62
  },
66
63
  "devDependencies": {
67
64
  "@types/bun": "latest",
package/src/adapter.ts CHANGED
@@ -2,8 +2,8 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { renderTypesetResultToPngs } from "./core/canvas-renderer";
4
4
  import { scanLocalFonts } from "./core/font-loader";
5
- import { FlowImage } from "./core/types";
6
- import { resolveTypesetResult } from "./server/routes/render";
5
+ import type { FlowImage } from "./core/types";
6
+ import { resolveTypesetResult, type RenderRequestBody } from "./server/routes/render";
7
7
 
8
8
  // ── Typeset Studio & zzhub-pipeline compatible types ──────────────
9
9
 
@@ -102,11 +102,13 @@ export interface ImageRenderPlugin {
102
102
 
103
103
  // ── Helpers ───────────────────────────────────────────────────────
104
104
 
105
+ // 清理流水线标题中的 Markdown 标记。
105
106
  function cleanTitle(raw: string): string {
106
107
  if (!raw) return "未命名文章";
107
108
  return raw.replace(/^#+\s*/, "").trim();
108
109
  }
109
110
 
111
+ // 将流水线图片标记映射为页码和排版预设。
110
112
  function mapBodyImages(
111
113
  bodyImages?: Array<{ marker: string; path: string }>,
112
114
  pageSpecs?: ImageRenderInput["pageSpecs"],
@@ -131,23 +133,24 @@ function mapBodyImages(
131
133
  src: img.path,
132
134
  alt: img.marker,
133
135
  pageIndex: spec?.page,
134
- layoutPreset: (spec?.imageLayout as any) || "mid-right",
136
+ layoutPreset: (spec?.imageLayout as FlowImage["layoutPreset"]) || "mid-right",
135
137
  };
136
138
  });
137
139
  }
138
140
 
139
141
  // ── Typeset Studio Pipeline Adapter ───────────────────────────────
140
142
 
143
+ // 配置远程地址后,由独立服务负责图片渲染。
141
144
  const REMOTE_STUDIO_URL = process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
142
145
 
146
+ // 下载远程生成的页面,并写入流水线输出目录。
143
147
  async function renderRemoteBatch(
144
148
  studioUrl: string,
145
- body: any,
149
+ body: RenderRequestBody,
146
150
  outputDir: string,
147
- filenamePrefix: string,
148
151
  route: "wechat-article" | "wechat-newspic",
149
152
  kind: "cover" | "page",
150
- ): Promise<{ assets: RenderAsset[]; pageCount: number; pages: ImageRenderOutput["pages"] }> {
153
+ ): Promise<ImageRenderOutput> {
151
154
  const res = await fetch(`${studioUrl}/api/render/export`, {
152
155
  method: "POST",
153
156
  headers: { "Content-Type": "application/json" },
@@ -164,13 +167,12 @@ async function renderRemoteBatch(
164
167
  const assets: RenderAsset[] = [];
165
168
  const pagesSummary: ImageRenderOutput["pages"] = [];
166
169
 
167
- for (let i = 0; i < data.pages.length; i++) {
168
- const pageItem = data.pages[i];
170
+ for (const pageItem of data.pages) {
169
171
  const pageNum = pageItem.pageNumber;
170
172
  const base64Data = pageItem.dataUrl.replace(/^data:image\/png;base64,/, "");
171
173
  const buffer = Buffer.from(base64Data, "base64");
172
174
  const filename =
173
- kind === "cover" ? "cover.png" : `${filenamePrefix}-${String(pageNum).padStart(2, "0")}.png`;
175
+ kind === "cover" ? "cover.png" : `article-${String(pageNum).padStart(2, "0")}.png`;
174
176
  const filePath = join(outputDir, filename);
175
177
  await writeFile(filePath, buffer);
176
178
 
@@ -190,18 +192,51 @@ async function renderRemoteBatch(
190
192
  return { assets, pageCount: data.pageCount, pages: pagesSummary };
191
193
  }
192
194
 
195
+ // 本地渲染与远程服务共用请求,保留各自的图片输出方式。
196
+ async function renderLocalBatch(
197
+ body: RenderRequestBody,
198
+ outputDir: string,
199
+ route: RenderAsset["route"],
200
+ kind: RenderAsset["kind"],
201
+ ): Promise<ImageRenderOutput> {
202
+ const { result } = resolveTypesetResult(body);
203
+ const pngs = await renderTypesetResultToPngs(result);
204
+ const outputs = kind === "cover" ? pngs.slice(0, 1) : pngs;
205
+ if (kind === "cover" && outputs.length === 0) {
206
+ throw new Error("Typeset Studio failed to render cover buffer");
207
+ }
208
+
209
+ const assets: RenderAsset[] = [];
210
+ const pages: ImageRenderOutput["pages"] = [];
211
+ for (const [index, output] of outputs.entries()) {
212
+ const pageNumber = index + 1;
213
+ const filename = kind === "cover"
214
+ ? "cover.png"
215
+ : `article-${String(pageNumber).padStart(2, "0")}.png`;
216
+ const path = join(outputDir, filename);
217
+ await writeFile(path, output.buffer);
218
+ assets.push({ kind, route, path, ...(kind === "page" ? { index: pageNumber } : {}) });
219
+ const images = result.pages[index].images;
220
+ pages.push({
221
+ page: pageNumber,
222
+ imageCount: images.length,
223
+ imageSources: images.map((image) => image.src),
224
+ });
225
+ }
226
+ return { assets, pageCount: outputs.length, pages };
227
+ }
228
+
193
229
  export const typesetStudioAdapter: ImageRenderPlugin = {
194
230
  name: "typeset-studio",
195
- version: "0.2.0",
231
+ version: "0.2.2",
196
232
 
197
233
  async doctor(): Promise<PipelinePluginDoctorCheck[]> {
198
234
  const checks: PipelinePluginDoctorCheck[] = [];
199
235
 
200
- // 0. Remote cloud service check (if TYPESET_STUDIO_URL is set)
201
236
  if (REMOTE_STUDIO_URL) {
202
237
  try {
203
238
  const res = await fetch(`${REMOTE_STUDIO_URL}/api/health`);
204
- const data = (await res.json()) as any;
239
+ const data = (await res.json()) as { name?: string };
205
240
  checks.push({
206
241
  name: "remote-service",
207
242
  ok: res.ok,
@@ -216,7 +251,6 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
216
251
  }
217
252
  }
218
253
 
219
- // 1. Skia engine check (for local/in-process rendering)
220
254
  try {
221
255
  const { createCanvas } = await import("@napi-rs/canvas");
222
256
  const canvas = createCanvas(10, 10);
@@ -231,14 +265,13 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
231
265
  } catch (err) {
232
266
  checks.push({
233
267
  name: "@napi-rs/canvas",
234
- ok: !REMOTE_STUDIO_URL,
268
+ ok: Boolean(REMOTE_STUDIO_URL),
235
269
  message: REMOTE_STUDIO_URL
236
270
  ? "@napi-rs/canvas not installed locally (using Cloud Typeset Studio microservice)"
237
271
  : `@napi-rs/canvas initialization failed: ${err}`,
238
272
  });
239
273
  }
240
274
 
241
- // 2. Local fonts check
242
275
  try {
243
276
  const localFonts = scanLocalFonts();
244
277
  checks.push({
@@ -258,266 +291,51 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
258
291
  },
259
292
 
260
293
  async render(input: ImageRenderInput): Promise<ImageRenderOutput> {
261
- const { route, outputDir, title, bodyText, state } = input;
294
+ const { route, outputDir, bodyText, state } = input;
262
295
  await mkdir(outputDir, { recursive: true });
296
+ if (route === "blog") return { assets: [], pageCount: 0, pages: [] };
263
297
 
264
298
  const account = state.route.account || "default";
265
- const highlightWords = state.route.highlight_words || [];
266
- const footerText = input.accountVisualParams?.footer;
267
- const watermarkText = account ? `@${account}` : undefined;
268
- const effectiveTitle = cleanTitle(title || state.metadata?.title || "");
269
-
270
- // ── 0. Optional: Remote Cloud Microservice Mode ─────────────
271
- if (REMOTE_STUDIO_URL) {
272
- if (route === "wechat-article") {
273
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
274
- return await renderRemoteBatch(
275
- REMOTE_STUDIO_URL,
276
- {
277
- slot: "article_cover",
278
- route: "wechat-article",
279
- account,
280
- title: effectiveTitle,
281
- markdown: `# ${effectiveTitle}`,
282
- template: input.template,
283
- images: coverImages,
284
- highlightWords,
285
- config: { footerText, watermarkText },
286
- },
287
- outputDir,
288
- "cover",
289
- "wechat-article",
290
- "cover",
291
- );
292
- }
293
-
294
- if (route === "wechat-newspic") {
295
- const newspicSpec = state.intent?.newspic_render;
296
- const paginationMode =
297
- input.template === "longform-3-4"
298
- ? "multi"
299
- : input.template === "poster-3-4"
300
- ? "single"
301
- : newspicSpec?.pagination_mode || "multi";
302
-
303
- const isMultiPage = paginationMode === "multi";
304
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
305
-
306
- // Remote cover
307
- const coverRes = await renderRemoteBatch(
308
- REMOTE_STUDIO_URL,
309
- {
310
- slot: "newspic_cover",
311
- route: "wechat-newspic",
312
- kind: "cover",
313
- account,
314
- title: effectiveTitle,
315
- markdown: `# ${effectiveTitle}`,
316
- template: input.template,
317
- images: coverImages,
318
- highlightWords,
319
- config: { footerText, watermarkText },
320
- },
321
- outputDir,
322
- "cover",
323
- "wechat-newspic",
324
- "cover",
325
- );
326
-
327
- if (!isMultiPage) {
328
- return coverRes;
329
- }
330
-
331
- // Remote content pages
332
- const contentImages = mapBodyImages(input.bodyImages, input.pageSpecs);
333
- const contentRes = await renderRemoteBatch(
334
- REMOTE_STUDIO_URL,
335
- {
336
- slot: "newspic_content",
337
- route: "wechat-newspic",
338
- kind: "content",
339
- account,
340
- title: effectiveTitle,
341
- markdown: bodyText || `# ${effectiveTitle}`,
342
- template: input.template,
343
- images: contentImages,
344
- highlightWords,
345
- config: { footerText, watermarkText },
346
- },
347
- outputDir,
348
- "article",
349
- "wechat-newspic",
350
- "page",
351
- );
352
-
353
- return {
354
- assets: [...coverRes.assets, ...contentRes.assets],
355
- pageCount: contentRes.pageCount,
356
- pages: contentRes.pages,
357
- };
358
- }
359
- }
360
-
361
- // ── 1. Route: wechat-article (2.35:1 Cover only) ─────────────
362
- if (route === "wechat-article") {
363
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
364
-
365
- const resolved = resolveTypesetResult({
366
- slot: "article_cover",
367
- route: "wechat-article",
368
- account,
369
- title: effectiveTitle,
370
- markdown: `# ${effectiveTitle}`,
371
- template: input.template,
372
- images: coverImages,
373
- highlightWords,
374
- config: {
375
- footerText: footerText ?? undefined,
376
- watermarkText: watermarkText ?? undefined,
377
- },
378
- });
379
-
380
- const pngOutputs = await renderTypesetResultToPngs(resolved.result);
381
- const coverBuffer = pngOutputs[0]?.buffer;
382
- if (!coverBuffer) {
383
- throw new Error("Typeset Studio failed to render cover buffer");
384
- }
385
- const coverPath = join(outputDir, "cover.png");
386
- await writeFile(coverPath, coverBuffer);
387
-
388
- const asset: RenderAsset = {
389
- kind: "cover",
390
- route: "wechat-article",
391
- path: coverPath,
392
- };
393
-
394
- return {
395
- assets: [asset],
396
- pageCount: 1,
397
- pages: [
398
- {
399
- page: 1,
400
- imageCount: resolved.result.pages[0]?.images.length || 0,
401
- imageSources: (resolved.result.pages[0]?.images || []).map((img) => img.src),
402
- },
403
- ],
404
- };
405
- }
406
-
407
- // ── 2. Route: blog (no image rendering required) ──────────────
408
- if (route === "blog") {
409
- return {
410
- assets: [],
411
- pageCount: 0,
412
- pages: [],
413
- };
414
- }
415
-
416
- // ── 3. Route: wechat-newspic (Poster Cover + Multi-page Content) ─
417
- const newspicSpec = state.intent?.newspic_render;
418
- const paginationMode =
419
- input.template === "longform-3-4"
420
- ? "multi"
421
- : input.template === "poster-3-4"
422
- ? "single"
423
- : newspicSpec?.pagination_mode || "multi";
424
-
425
- const isMultiPage = paginationMode === "multi";
426
- const assets: RenderAsset[] = [];
427
- const pagesSummary: ImageRenderOutput["pages"] = [];
428
-
429
- // 3.1 Render Poster Cover (3:4)
430
- const coverImages = mapBodyImages(input.bodyImages, input.pageSpecs);
431
- const resolvedCover = resolveTypesetResult({
432
- slot: "newspic_cover",
433
- route: "wechat-newspic",
434
- kind: "cover",
299
+ const title = cleanTitle(input.title || state.metadata?.title || "");
300
+ const common: RenderRequestBody = {
435
301
  account,
436
- title: effectiveTitle,
437
- markdown: `# ${effectiveTitle}`,
302
+ title,
438
303
  template: input.template,
439
- images: coverImages,
440
- highlightWords,
441
- config: {
442
- footerText: footerText ?? undefined,
443
- watermarkText: watermarkText ?? undefined,
444
- },
445
- });
446
-
447
- const coverPngs = await renderTypesetResultToPngs(resolvedCover.result);
448
- const coverBuffer = coverPngs[0]?.buffer;
449
- if (!coverBuffer) {
450
- throw new Error("Typeset Studio failed to render newspic cover buffer");
451
- }
452
- const coverPath = join(outputDir, "cover.png");
453
- await writeFile(coverPath, coverBuffer);
454
- assets.push({
455
- kind: "cover",
456
- route: "wechat-newspic",
457
- path: coverPath,
458
- });
459
-
460
- // 3.2 If single-page mode, return immediately
461
- if (!isMultiPage) {
462
- return {
463
- assets,
464
- pageCount: 1,
465
- pages: [
466
- {
467
- page: 1,
468
- imageCount: resolvedCover.result.pages[0]?.images.length || 0,
469
- imageSources: (resolvedCover.result.pages[0]?.images || []).map((img) => img.src),
470
- },
471
- ],
472
- };
473
- }
304
+ images: mapBodyImages(input.bodyImages, input.pageSpecs),
305
+ highlightWords: state.route.highlight_words || [],
306
+ config: { footerText: input.accountVisualParams?.footer, watermarkText: `@${account}` },
307
+ };
308
+ const articleCover = route === "wechat-article";
309
+ const renderRoute = articleCover ? "wechat-article" : "wechat-newspic";
310
+
311
+ // 统一封面和正文的请求构造,传输方式只在这里分流。
312
+ const renderBatch = (body: RenderRequestBody, kind: RenderAsset["kind"]) =>
313
+ REMOTE_STUDIO_URL && (route === "wechat-article" || route === "wechat-newspic")
314
+ ? renderRemoteBatch(REMOTE_STUDIO_URL, body, outputDir, renderRoute, kind)
315
+ : renderLocalBatch(body, outputDir, renderRoute, kind);
316
+
317
+ const cover = await renderBatch({
318
+ ...common,
319
+ route: renderRoute,
320
+ slot: articleCover ? "article_cover" : "newspic_cover",
321
+ ...(articleCover ? {} : { kind: "cover" }),
322
+ markdown: `# ${title}`,
323
+ }, "cover");
324
+ const paginationMode = input.template === "longform-3-4"
325
+ ? "multi"
326
+ : input.template === "poster-3-4"
327
+ ? "single"
328
+ : state.intent?.newspic_render?.pagination_mode || "multi";
329
+ if (articleCover || paginationMode !== "multi") return cover;
474
330
 
475
- // 3.3 Render Content Pages (3:4 Multi-page longform)
476
- const contentImages = mapBodyImages(input.bodyImages, input.pageSpecs);
477
- const resolvedContent = resolveTypesetResult({
478
- slot: "newspic_content",
331
+ const content = await renderBatch({
332
+ ...common,
479
333
  route: "wechat-newspic",
480
334
  kind: "content",
481
- account,
482
- title: effectiveTitle,
483
- markdown: bodyText || `# ${effectiveTitle}`,
484
- template: input.template,
485
- images: contentImages,
486
- highlightWords,
487
- config: {
488
- footerText: footerText ?? undefined,
489
- watermarkText: watermarkText ?? undefined,
490
- },
491
- });
492
-
493
- const contentPngs = await renderTypesetResultToPngs(resolvedContent.result);
494
-
495
- for (let i = 0; i < contentPngs.length; i++) {
496
- const pageNum = i + 1;
497
- const fileName = `article-${String(pageNum).padStart(2, "0")}.png`;
498
- const pagePath = join(outputDir, fileName);
499
- await writeFile(pagePath, contentPngs[i].buffer);
500
-
501
- assets.push({
502
- kind: "page",
503
- route: "wechat-newspic",
504
- index: pageNum,
505
- path: pagePath,
506
- });
507
-
508
- const flowPage = resolvedContent.result.pages[i];
509
- pagesSummary.push({
510
- page: pageNum,
511
- imageCount: flowPage?.images.length || 0,
512
- imageSources: (flowPage?.images || []).map((img) => img.src),
513
- });
514
- }
515
-
516
- return {
517
- assets,
518
- pageCount: contentPngs.length,
519
- pages: pagesSummary,
520
- };
335
+ slot: "newspic_content",
336
+ markdown: bodyText || `# ${title}`,
337
+ }, "page");
338
+ return { ...content, assets: [...cover.assets, ...content.assets] };
521
339
  },
522
340
  };
523
341
 
@@ -12,16 +12,16 @@ export function resolveCanvasFontFamily(fontName: string, isServer: boolean = fa
12
12
  const trimmed = rawFirst.replace(/["'\\]/g, "").trim();
13
13
 
14
14
  // 1. Explicit font priority matching: The chosen font family MUST come first
15
- if (trimmed.includes("Noto Sans") || trimmed === "Noto Sans SC") {
15
+ if (trimmed.includes("Noto Sans")) {
16
16
  return isServer ? '"Hiragino Sans GB", sans-serif' : '"Noto Sans SC", "PingFang SC", sans-serif';
17
17
  }
18
- if (trimmed.includes("PingFang") || trimmed === "PingFang SC") {
18
+ if (trimmed.includes("PingFang")) {
19
19
  return isServer ? '"Hiragino Sans GB", sans-serif' : '"PingFang SC", "Noto Sans SC", sans-serif';
20
20
  }
21
- if (trimmed.includes("Noto Serif") || trimmed === "Noto Serif SC") {
21
+ if (trimmed.includes("Noto Serif")) {
22
22
  return isServer ? '"Songti SC", serif' : '"Noto Serif SC", "Songti SC", serif';
23
23
  }
24
- if (trimmed.includes("Songti") || trimmed === "Songti SC" || trimmed === "STSong") {
24
+ if (trimmed.includes("Songti") || trimmed === "STSong") {
25
25
  return isServer ? '"Songti SC", serif' : '"Songti SC", "Noto Serif SC", serif';
26
26
  }
27
27
  if (trimmed.includes("Kai") || trimmed.includes("WenKai")) {
@@ -29,7 +29,7 @@ export function resolveCanvasFontFamily(fontName: string, isServer: boolean = fa
29
29
  ? '"STKaiti", "Kaiti SC", "Songti SC", serif'
30
30
  : '"LXGW WenKai GB Screen", "LXGW WenKai Screen", "Kaiti SC", "STKaiti", serif';
31
31
  }
32
- if (trimmed.includes("Yuan") || trimmed.includes("Yuanti")) {
32
+ if (trimmed.includes("Yuan")) {
33
33
  return isServer
34
34
  ? '"STYuanti", "Yuanti SC", "Hiragino Sans GB", sans-serif'
35
35
  : '"Yuanti SC", "STYuanti", "PingFang SC", sans-serif';
@@ -43,7 +43,7 @@ export function resolveCanvasFontFamily(fontName: string, isServer: boolean = fa
43
43
 
44
44
  // 2. Generic fallbacks (including custom and project-local fonts)
45
45
  if (trimmed.toLowerCase().includes("serif") || trimmed.includes("Song")) {
46
- return isServer ? `"${trimmed}", "Songti SC", serif` : `"${trimmed}", "Songti SC", serif`;
46
+ return `"${trimmed}", "Songti SC", serif`;
47
47
  }
48
48
  return isServer
49
49
  ? `"${trimmed}", "Hiragino Sans GB", "PingFang SC", sans-serif`
@@ -93,7 +93,7 @@ export async function drawPageToContext(
93
93
  ctx.restore();
94
94
 
95
95
  // 2. Compute Stage Layout Dimensions
96
- const { stageWidth, stageHeight, stageX, stageY } = computeStageDimensions(
96
+ const { stageX, stageY } = computeStageDimensions(
97
97
  spec,
98
98
  algorithm.safeArea,
99
99
  algorithm.contentBottomGap,
@@ -111,7 +111,7 @@ export async function drawPageToContext(
111
111
  // Keep header bar in safe upper margin, safely above stageY
112
112
  const headerY = Math.max(24, Math.floor(algorithm.safeArea.top * 0.45));
113
113
 
114
- if (showWatermark && config.watermarkText?.trim()) {
114
+ if (showWatermark) {
115
115
  ctx.save();
116
116
  ctx.font = resolveCanvasFont("600 14px PingFang SC", isServer);
117
117
  ctx.fillStyle = theme.watermarkColor;
@@ -286,7 +286,7 @@ export async function drawPageToContext(
286
286
  ctx.restore();
287
287
  }
288
288
 
289
- if (showFooterText && config.footerText?.trim()) {
289
+ if (showFooterText) {
290
290
  ctx.save();
291
291
  ctx.font = resolveCanvasFont('500 13px PingFang SC', isServer);
292
292
  ctx.fillStyle = theme.watermarkColor || theme.captionColor;
@@ -4,7 +4,6 @@ import { paginateBlocks } from "./obstacle-flow";
4
4
  import { computeBlockStyles } from "./typography";
5
5
  import { resolveImagePlacement } from "./image-layout";
6
6
  import {
7
- ContentBlock,
8
7
  FlowBlock,
9
8
  FlowImage,
10
9
  FlowPage,
@@ -12,7 +11,7 @@ import {
12
11
  TypesetResult,
13
12
  } from "./types";
14
13
 
15
- export function getDefaultConfig(aspectRatio = "longform-3-4" as TypesetConfig["aspectRatio"]): TypesetConfig {
14
+ export function getDefaultConfig(aspectRatio: TypesetConfig["aspectRatio"] = "longform-3-4"): TypesetConfig {
16
15
  const spec = getCanvasSpec(aspectRatio);
17
16
  const isCover = aspectRatio === "wechat-cover-2.35";
18
17
  const isSquare = aspectRatio === "square-1-1";
@@ -94,6 +93,17 @@ export function runTypeset(params: {
94
93
  bodyFont: config.typography.bodyFont,
95
94
  });
96
95
 
96
+ // 自动分页和手动分页共用相同的文字块样式。
97
+ const createFlowBlocks = (text: string): FlowBlock[] => parseContentBlocks(text).map((block) => ({
98
+ ...blockStyles[block.kind],
99
+ kind: block.kind,
100
+ text: block.text,
101
+ runs: parseInlineRuns(block.text, highlightWords),
102
+ bullet: block.kind === "list-item" ? block.bullet ?? "•" : undefined,
103
+ textIndent: block.kind === "list-item" ? 28 : block.kind === "quote" ? 20 : 0,
104
+ keepWithNext: ["heading", "subheading", "minor-heading"].includes(block.kind),
105
+ }));
106
+
97
107
  // Check if explicit page markers exist
98
108
  const pageSegments = splitTextByPages(markdown);
99
109
  let pages: FlowPage[] = [];
@@ -102,16 +112,7 @@ export function runTypeset(params: {
102
112
  // Multi-page explicit segmentation
103
113
  pages = pageSegments.map((segmentText, index) => {
104
114
  const pageNum = index + 1;
105
- const rawBlocks = parseContentBlocks(segmentText);
106
- const flowBlocks: FlowBlock[] = rawBlocks.map((b) => ({
107
- ...blockStyles[b.kind],
108
- kind: b.kind,
109
- text: b.text,
110
- runs: parseInlineRuns(b.text, highlightWords),
111
- bullet: b.kind === "list-item" ? b.bullet ?? "•" : undefined,
112
- textIndent: b.kind === "list-item" ? 28 : b.kind === "quote" ? 20 : 0,
113
- keepWithNext: ["heading", "subheading", "minor-heading"].includes(b.kind),
114
- }));
115
+ const flowBlocks = createFlowBlocks(segmentText);
115
116
 
116
117
  // Match images explicitly configured for this page (img.pageIndex === pageNum).
117
118
  // If an image has no pageIndex, fallback to Page 1 (index === 0).
@@ -147,16 +148,7 @@ export function runTypeset(params: {
147
148
  });
148
149
  } else {
149
150
  // Single page or auto-flowing pagination
150
- const rawBlocks = parseContentBlocks(markdown);
151
- const flowBlocks: FlowBlock[] = rawBlocks.map((b) => ({
152
- ...blockStyles[b.kind],
153
- kind: b.kind,
154
- text: b.text,
155
- runs: parseInlineRuns(b.text, highlightWords),
156
- bullet: b.kind === "list-item" ? b.bullet ?? "•" : undefined,
157
- textIndent: b.kind === "list-item" ? 28 : b.kind === "quote" ? 20 : 0,
158
- keepWithNext: ["heading", "subheading", "minor-heading"].includes(b.kind),
159
- }));
151
+ const flowBlocks = createFlowBlocks(markdown);
160
152
 
161
153
  pages = paginateBlocks(flowBlocks, stageWidth, stageHeight, images, {
162
154
  obstacleGap: config.algorithm.obstacleGap,
@@ -2,17 +2,9 @@ export {
2
2
  prepareRichInline,
3
3
  layoutNextRichInlineLineRange,
4
4
  materializeRichInlineLineRange,
5
- measureRichInlineStats,
6
5
  } from "@chenglou/pretext/rich-inline";
7
6
  export type { RichInlineCursor } from "@chenglou/pretext/rich-inline";
8
7
 
9
- export {
10
- prepare,
11
- layoutNextLineRange,
12
- materializeLineRange,
13
- measureLineStats,
14
- } from "@chenglou/pretext";
15
-
16
8
  let initialized = false;
17
9
 
18
10
  /**
@@ -1,4 +1,4 @@
1
- import { FlowImage, TypesetConfig } from "./types";
1
+ import { FlowImage } from "./types";
2
2
 
3
3
  /**
4
4
  * Applies template image layout configurations to current content images based on index intersection:
@@ -1,19 +1,5 @@
1
1
  import { ContentBlockKind } from "./types";
2
2
 
3
- export interface TypographicScale {
4
- base: number;
5
- ratio: number;
6
- }
7
-
8
- export const MODULAR_SCALES: Record<string, number> = {
9
- minorSecond: 1.067,
10
- majorSecond: 1.125,
11
- minorThird: 1.2,
12
- majorThird: 1.25,
13
- perfectFourth: 1.333,
14
- goldenRatio: 1.618,
15
- };
16
-
17
3
  export function modularScale(base: number, ratio: number, step: number): number {
18
4
  return Math.round(base * Math.pow(ratio, step));
19
5
  }
package/src/index.ts CHANGED
@@ -31,14 +31,14 @@ export { resolveTypesetResult, type RenderRequestBody } from "./server/routes/re
31
31
 
32
32
  export type {
33
33
  AlgorithmParams,
34
- AspectRatioPreset,
34
+ AspectRatioType,
35
35
  CanvasSpec,
36
36
  ContentBlockKind,
37
37
  FlowBlock,
38
38
  FlowImage,
39
39
  FlowLine,
40
40
  FlowPage,
41
- InlineFragment,
41
+ FlowLineFragment,
42
42
  TemplateSlot,
43
43
  ThemeColors,
44
44
  TypesetChromeConfig,