@akairos/typeset-studio 0.2.1 → 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/README.md +2 -3
- package/dist/assets/index-DDeEGeHD.js +317 -0
- package/dist/index.html +1 -1
- package/dist/lib/adapter.js +82 -234
- package/dist/lib/index.js +82 -234
- package/package.json +2 -5
- package/src/adapter.ts +85 -267
- package/src/core/canvas-draw.ts +9 -9
- package/src/core/engine.ts +14 -22
- package/src/core/pretext.ts +0 -8
- package/src/core/template.ts +1 -1
- package/src/core/typography.ts +0 -14
- package/src/index.ts +2 -2
- package/src/server/index.ts +1 -1
- package/src/server/routes/fonts.ts +1 -1
- package/src/server/routes/templates.ts +4 -14
- package/src/web/App.tsx +0 -2
- package/src/web/components/EditorSidebar.tsx +24 -48
- package/src/web/components/Header.tsx +1 -1
- package/src/web/components/TemplateModal.tsx +3 -14
- package/src/web/hooks/useTypeset.ts +21 -69
- package/dist/assets/index-Dpnz-F2g.js +0 -317
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
|
|
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:
|
|
149
|
+
body: RenderRequestBody,
|
|
146
150
|
outputDir: string,
|
|
147
|
-
filenamePrefix: string,
|
|
148
151
|
route: "wechat-article" | "wechat-newspic",
|
|
149
152
|
kind: "cover" | "page",
|
|
150
|
-
): Promise<
|
|
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 (
|
|
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" :
|
|
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.
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
266
|
-
const
|
|
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
|
|
437
|
-
markdown: `# ${effectiveTitle}`,
|
|
302
|
+
title,
|
|
438
303
|
template: input.template,
|
|
439
|
-
images:
|
|
440
|
-
highlightWords,
|
|
441
|
-
config: {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
package/src/core/canvas-draw.ts
CHANGED
|
@@ -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")
|
|
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")
|
|
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")
|
|
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 === "
|
|
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")
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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;
|
package/src/core/engine.ts
CHANGED
|
@@ -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"
|
|
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
|
|
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
|
|
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,
|
package/src/core/pretext.ts
CHANGED
|
@@ -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
|
/**
|
package/src/core/template.ts
CHANGED
package/src/core/typography.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
+
AspectRatioType,
|
|
35
35
|
CanvasSpec,
|
|
36
36
|
ContentBlockKind,
|
|
37
37
|
FlowBlock,
|
|
38
38
|
FlowImage,
|
|
39
39
|
FlowLine,
|
|
40
40
|
FlowPage,
|
|
41
|
-
|
|
41
|
+
FlowLineFragment,
|
|
42
42
|
TemplateSlot,
|
|
43
43
|
ThemeColors,
|
|
44
44
|
TypesetChromeConfig,
|
package/src/server/index.ts
CHANGED