@akairos/typeset-studio 0.2.1 → 0.2.3
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 +91 -95
- package/dist/assets/index-3Ko1UCBU.css +1 -0
- package/dist/assets/index-C9J0f4Lf.js +317 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +6 -7734
- package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
- package/dist/lib/font-loader-j28mt43t.js +11 -0
- package/dist/lib/index-0370qz9e.js +303 -0
- package/dist/lib/index-37x76zdn.js +4 -0
- package/dist/lib/index-6c43c7h4.js +3880 -0
- package/dist/lib/index-t9w3p2p9.js +3366 -0
- package/dist/lib/index-zdhqg5f0.js +459 -0
- package/dist/lib/index.js +26 -7734
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +3 -7
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +190 -281
- package/src/core/canvas-draw.ts +150 -47
- package/src/core/canvas-renderer.test.ts +58 -0
- package/src/core/canvas-renderer.ts +2 -1
- package/src/core/data-dir.ts +13 -0
- package/src/core/engine.ts +86 -56
- package/src/core/font-loader.test.ts +20 -0
- package/src/core/font-loader.ts +6 -2
- package/src/core/image-layout.ts +4 -0
- package/src/core/inline-text.ts +6 -4
- package/src/core/obstacle-flow.ts +29 -15
- package/src/core/page-images.test.ts +90 -0
- package/src/core/pretext.ts +4 -10
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +53 -41
- package/src/core/types.ts +46 -0
- package/src/core/typography.ts +0 -14
- package/src/index.ts +6 -3
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +11 -16
- package/src/server/routes/fonts.ts +1 -1
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +37 -48
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -6
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +59 -48
- package/src/web/components/Header.tsx +1 -1
- package/src/web/components/TemplateModal.tsx +25 -28
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +139 -100
- package/data/templates/.gitkeep +0 -0
- package/data/templates/tpl_1791681833354_iofsk.json +0 -46
- package/data/templates/tpl_1791682603199_875ny.json +0 -66
- package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
- package/data/templates/tpl_longform_paper_sage.json +0 -47
- package/data/templates/tpl_longform_vintage_news.json +0 -47
- package/data/templates/tpl_newspic_poster_cover.json +0 -64
- package/data/templates/tpl_square_quote_card.json +0 -46
- package/data/templates/tpl_wechat_cover_editorial.json +0 -64
- package/data/templates/tpl_wechat_cover_minimal.json +0 -64
- package/data/templates/tpl_wide_tech_banner.json +0 -46
- package/dist/assets/index-CZ352D7X.css +0 -1
- package/dist/assets/index-Dpnz-F2g.js +0 -317
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`
|
|
@@ -92,8 +92,85 @@ export async function drawPageToContext(
|
|
|
92
92
|
ctx.fillRect(0, 0, spec.width, spec.height);
|
|
93
93
|
ctx.restore();
|
|
94
94
|
|
|
95
|
+
// 主题背景、纹理和装饰图使用整张画布坐标。
|
|
96
|
+
const coverTheme = config.coverTheme;
|
|
97
|
+
const drawCoverImage = async (image: NonNullable<typeof coverTheme>["background"]["image"]) => {
|
|
98
|
+
if (!image) return;
|
|
99
|
+
const loaded = await getImage(image.src);
|
|
100
|
+
if (!loaded) throw new Error("Failed to load cover theme image");
|
|
101
|
+
const scale = image.fit === "contain" ? Math.min(spec.width / loaded.width, spec.height / loaded.height)
|
|
102
|
+
: Math.max(spec.width / loaded.width, spec.height / loaded.height);
|
|
103
|
+
const width = image.fit === "fill" ? spec.width : loaded.width * scale;
|
|
104
|
+
const height = image.fit === "fill" ? spec.height : loaded.height * scale;
|
|
105
|
+
ctx.save();
|
|
106
|
+
ctx.globalAlpha = image.opacity;
|
|
107
|
+
ctx.drawImage(loaded, (spec.width - width) * image.position.x / 100,
|
|
108
|
+
(spec.height - height) * image.position.y / 100, width, height);
|
|
109
|
+
ctx.restore();
|
|
110
|
+
};
|
|
111
|
+
if (coverTheme) {
|
|
112
|
+
const fill = coverTheme.background.fill;
|
|
113
|
+
ctx.save();
|
|
114
|
+
if (fill.type === "solid") ctx.fillStyle = fill.color;
|
|
115
|
+
else {
|
|
116
|
+
let gradient;
|
|
117
|
+
if (fill.type === "linear") {
|
|
118
|
+
const angle = fill.angle * Math.PI / 180;
|
|
119
|
+
const dx = Math.cos(angle) * spec.width / 2;
|
|
120
|
+
const dy = Math.sin(angle) * spec.height / 2;
|
|
121
|
+
gradient = ctx.createLinearGradient(spec.width / 2 - dx, spec.height / 2 - dy, spec.width / 2 + dx, spec.height / 2 + dy);
|
|
122
|
+
} else {
|
|
123
|
+
const x = spec.width * fill.position.x / 100;
|
|
124
|
+
const y = spec.height * fill.position.y / 100;
|
|
125
|
+
gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.hypot(spec.width, spec.height));
|
|
126
|
+
}
|
|
127
|
+
for (const stop of fill.stops) gradient.addColorStop(stop.offset / 100, stop.color);
|
|
128
|
+
ctx.fillStyle = gradient;
|
|
129
|
+
}
|
|
130
|
+
ctx.fillRect(0, 0, spec.width, spec.height);
|
|
131
|
+
ctx.restore();
|
|
132
|
+
await drawCoverImage(coverTheme.background.image);
|
|
133
|
+
if (coverTheme.background.overlay) {
|
|
134
|
+
ctx.save();
|
|
135
|
+
ctx.fillStyle = coverTheme.background.overlay.color;
|
|
136
|
+
ctx.globalAlpha = coverTheme.background.overlay.opacity;
|
|
137
|
+
ctx.fillRect(0, 0, spec.width, spec.height);
|
|
138
|
+
ctx.restore();
|
|
139
|
+
}
|
|
140
|
+
const decoration = coverTheme.decoration;
|
|
141
|
+
ctx.save();
|
|
142
|
+
ctx.strokeStyle = ctx.fillStyle = decoration.color;
|
|
143
|
+
ctx.globalAlpha = decoration.opacity;
|
|
144
|
+
const size = Math.max(8, decoration.size);
|
|
145
|
+
ctx.beginPath();
|
|
146
|
+
if (["lines", "grid", "dots"].includes(decoration.pattern)) {
|
|
147
|
+
for (let x = 0; x < spec.width; x += size) {
|
|
148
|
+
if (decoration.pattern === "grid") { ctx.moveTo(x, 0); ctx.lineTo(x, spec.height); }
|
|
149
|
+
if (decoration.pattern === "dots") {
|
|
150
|
+
for (let y = 0; y < spec.height; y += size) { ctx.moveTo(x + 2, y); ctx.arc(x, y, 2, 0, Math.PI * 2); }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (decoration.pattern !== "dots") {
|
|
154
|
+
for (let y = 0; y < spec.height; y += size) { ctx.moveTo(0, y); ctx.lineTo(spec.width, y); }
|
|
155
|
+
}
|
|
156
|
+
} else if (decoration.pattern === "rings") {
|
|
157
|
+
for (let radius = size; radius < Math.hypot(spec.width, spec.height); radius += size) {
|
|
158
|
+
ctx.moveTo(spec.width / 2 + radius, spec.height / 2);
|
|
159
|
+
ctx.arc(spec.width / 2, spec.height / 2, radius, 0, Math.PI * 2);
|
|
160
|
+
}
|
|
161
|
+
} else if (decoration.pattern === "burst") {
|
|
162
|
+
for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / Math.max(8, Math.round(size / 3))) {
|
|
163
|
+
ctx.moveTo(spec.width / 2, spec.height / 2);
|
|
164
|
+
ctx.lineTo(spec.width / 2 + Math.cos(angle) * spec.width, spec.height / 2 + Math.sin(angle) * spec.height);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (decoration.pattern === "dots") ctx.fill(); else ctx.stroke();
|
|
168
|
+
ctx.restore();
|
|
169
|
+
await drawCoverImage(decoration.image);
|
|
170
|
+
}
|
|
171
|
+
|
|
95
172
|
// 2. Compute Stage Layout Dimensions
|
|
96
|
-
const {
|
|
173
|
+
const { stageX, stageY } = computeStageDimensions(
|
|
97
174
|
spec,
|
|
98
175
|
algorithm.safeArea,
|
|
99
176
|
algorithm.contentBottomGap,
|
|
@@ -111,7 +188,7 @@ export async function drawPageToContext(
|
|
|
111
188
|
// Keep header bar in safe upper margin, safely above stageY
|
|
112
189
|
const headerY = Math.max(24, Math.floor(algorithm.safeArea.top * 0.45));
|
|
113
190
|
|
|
114
|
-
if (showWatermark
|
|
191
|
+
if (showWatermark) {
|
|
115
192
|
ctx.save();
|
|
116
193
|
ctx.font = resolveCanvasFont("600 14px PingFang SC", isServer);
|
|
117
194
|
ctx.fillStyle = theme.watermarkColor;
|
|
@@ -154,48 +231,58 @@ export async function drawPageToContext(
|
|
|
154
231
|
return za - zb;
|
|
155
232
|
});
|
|
156
233
|
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
234
|
+
const drawImages = async (images: typeof sortedImages) => {
|
|
235
|
+
for (const img of images) {
|
|
236
|
+
let loadedImg;
|
|
237
|
+
try {
|
|
238
|
+
loadedImg = await getImage(img.src);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (img.templateAssetKey) throw new Error(`模板素材 ${img.templateAssetKey} 加载失败:${error instanceof Error ? error.message : String(error)}`);
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (!loadedImg && img.templateAssetKey) throw new Error(`模板素材 ${img.templateAssetKey} 加载失败:图片不存在或无法读取`);
|
|
244
|
+
const imgX = stageX + img.x;
|
|
245
|
+
const imgY = stageY + img.y;
|
|
246
|
+
const centerX = imgX + img.width / 2;
|
|
247
|
+
const centerY = imgY + img.height / 2;
|
|
163
248
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
249
|
+
ctx.save();
|
|
250
|
+
ctx.translate(centerX, centerY);
|
|
251
|
+
if (img.rotate) {
|
|
252
|
+
ctx.rotate((img.rotate * Math.PI) / 180);
|
|
253
|
+
}
|
|
254
|
+
ctx.globalAlpha = img.opacity ?? 1;
|
|
170
255
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
256
|
+
const w = img.width;
|
|
257
|
+
const h = img.height;
|
|
258
|
+
const radius = Math.min(img.borderRadius ?? 12, w / 2, h / 2);
|
|
174
259
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (loadedImg) {
|
|
180
|
-
ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
|
|
181
|
-
} else {
|
|
182
|
-
ctx.fillStyle = theme.cardBg || "#222222";
|
|
183
|
-
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
184
|
-
}
|
|
185
|
-
ctx.restore();
|
|
260
|
+
ctx.beginPath();
|
|
261
|
+
ctx.roundRect(-w / 2, -h / 2, w, h, radius);
|
|
262
|
+
ctx.clip();
|
|
186
263
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
ctx.textBaseline = "top";
|
|
194
|
-
ctx.textAlign = "center";
|
|
195
|
-
ctx.fillText(img.caption, centerX, imgY + h + 12);
|
|
264
|
+
if (loadedImg) {
|
|
265
|
+
ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
|
|
266
|
+
} else {
|
|
267
|
+
ctx.fillStyle = theme.cardBg || "#222222";
|
|
268
|
+
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
269
|
+
}
|
|
196
270
|
ctx.restore();
|
|
271
|
+
|
|
272
|
+
// Image Caption (e.g., "图 1:建筑结构中的空间秩序感")
|
|
273
|
+
if (img.caption?.trim()) {
|
|
274
|
+
ctx.save();
|
|
275
|
+
const capFontSize = Math.max(14, Math.round(algorithm.baseFontSize * 0.45));
|
|
276
|
+
ctx.font = resolveCanvasFont(`400 ${capFontSize}px PingFang SC`, isServer);
|
|
277
|
+
ctx.fillStyle = theme.captionColor;
|
|
278
|
+
ctx.textBaseline = "top";
|
|
279
|
+
ctx.textAlign = "center";
|
|
280
|
+
ctx.fillText(img.caption, centerX, imgY + h + 12);
|
|
281
|
+
ctx.restore();
|
|
282
|
+
}
|
|
197
283
|
}
|
|
198
|
-
}
|
|
284
|
+
};
|
|
285
|
+
await drawImages(sortedImages.filter((image) => (image.zIndex ?? (image.isAbsolute ? 20 : 5)) < 10));
|
|
199
286
|
|
|
200
287
|
// 5. Text Flow Lines & Fragments
|
|
201
288
|
for (const line of page.lines) {
|
|
@@ -258,15 +345,20 @@ export async function drawPageToContext(
|
|
|
258
345
|
ctx.fillStyle = theme.textColor;
|
|
259
346
|
}
|
|
260
347
|
|
|
348
|
+
ctx.letterSpacing = `${fragment.letterSpacing || 0}px`;
|
|
261
349
|
ctx.fillText(fragment.text, cursorX, lineY);
|
|
262
350
|
|
|
263
351
|
// Measure fragment advance precisely with ctx.measureText
|
|
264
352
|
// to ensure zero text overlap regardless of font metrics
|
|
265
|
-
const measuredWidth = ctx.measureText(fragment.text).width;
|
|
353
|
+
const measuredWidth = fragment.letterSpacing ? fragment.occupiedWidth : ctx.measureText(fragment.text).width;
|
|
266
354
|
cursorX += measuredWidth + (fragment.gapBefore || 0);
|
|
267
355
|
}
|
|
268
356
|
}
|
|
269
357
|
|
|
358
|
+
await drawImages(sortedImages.filter((image) => (image.zIndex ?? (image.isAbsolute ? 20 : 5)) >= 10));
|
|
359
|
+
|
|
360
|
+
ctx.letterSpacing = "0px";
|
|
361
|
+
|
|
270
362
|
// 6. Footer Section
|
|
271
363
|
const showFooter = chrome?.showFooter ?? !isCover;
|
|
272
364
|
const showFooterDivider = chrome?.showFooterDivider ?? (!isCover && Boolean(config.footerText?.trim()));
|
|
@@ -286,9 +378,20 @@ export async function drawPageToContext(
|
|
|
286
378
|
ctx.restore();
|
|
287
379
|
}
|
|
288
380
|
|
|
289
|
-
if (
|
|
381
|
+
if (chrome?.showFillRatio) {
|
|
382
|
+
ctx.save();
|
|
383
|
+
ctx.font = resolveCanvasFont("500 13px PingFang SC", isServer);
|
|
384
|
+
ctx.fillStyle = theme.captionColor;
|
|
385
|
+
ctx.textBaseline = "middle";
|
|
386
|
+
ctx.textAlign = "right";
|
|
387
|
+
ctx.fillText(`填充率 ${Math.round(page.fillRatio * 100)}%`, spec.width - algorithm.safeArea.right, footerY);
|
|
388
|
+
ctx.restore();
|
|
389
|
+
}
|
|
390
|
+
if (showFooterText) {
|
|
290
391
|
ctx.save();
|
|
291
|
-
|
|
392
|
+
const font = coverTheme?.typography.footer;
|
|
393
|
+
ctx.font = resolveCanvasFont(font ? `${font.fontWeight} ${font.fontSize}px "${font.fontFamily}"` : '500 13px PingFang SC', isServer);
|
|
394
|
+
ctx.letterSpacing = `${font?.letterSpacing || 0}px`;
|
|
292
395
|
ctx.fillStyle = theme.watermarkColor || theme.captionColor;
|
|
293
396
|
ctx.globalAlpha = 0.6;
|
|
294
397
|
ctx.textBaseline = "middle";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { getDefaultConfig, runTypeset } from "./engine";
|
|
3
3
|
import { renderPageToPng, renderTypesetResultToPngs } from "./canvas-renderer";
|
|
4
|
+
import type { PipelineCoverTheme } from "./types";
|
|
4
5
|
|
|
5
6
|
describe("Server-Side Canvas Native PNG Renderer", () => {
|
|
6
7
|
it("renders a single 2.35:1 WeChat cover page to a valid PNG buffer", async () => {
|
|
@@ -47,3 +48,60 @@ describe("Server-Side Canvas Native PNG Renderer", () => {
|
|
|
47
48
|
expect(pngs[1]!.buffer.length).toBeGreaterThan(1000);
|
|
48
49
|
});
|
|
49
50
|
});
|
|
51
|
+
|
|
52
|
+
// 绘制顺序必须跨越文字层,页脚开关需要产生真实输出。
|
|
53
|
+
describe("Canvas layer regressions", () => {
|
|
54
|
+
it("draws background images, text and foreground images in order", async () => {
|
|
55
|
+
const { drawPageToContext } = await import("./canvas-draw");
|
|
56
|
+
const config = getDefaultConfig();
|
|
57
|
+
const result = runTypeset({ markdown: "正文", config });
|
|
58
|
+
const page = result.pages[0];
|
|
59
|
+
page.images = [
|
|
60
|
+
{ id: "front", src: "front", alt: "", x: 0, y: 0, width: 20, height: 20, isAbsolute: true },
|
|
61
|
+
{ id: "back", src: "back", alt: "", x: 0, y: 0, width: 20, height: 20, zIndex: 0 },
|
|
62
|
+
];
|
|
63
|
+
const operations: string[] = [];
|
|
64
|
+
const context = new Proxy({
|
|
65
|
+
drawImage: (image: string) => operations.push(image),
|
|
66
|
+
fillText: (text: string) => operations.push(text),
|
|
67
|
+
measureText: () => ({ width: 20 }),
|
|
68
|
+
} as any, { get: (target, key) => key in target ? target[key] : () => {} });
|
|
69
|
+
config.chrome = { showHeader: false, showFooter: true, showFillRatio: true, showFooterText: false };
|
|
70
|
+
await drawPageToContext(context, { page, spec: result.canvasSpec, config, getImage: async (src) => src });
|
|
71
|
+
expect(operations[0]).toBe("back");
|
|
72
|
+
expect(operations[1]).toBe("正文");
|
|
73
|
+
expect(operations[2]).toBe("front");
|
|
74
|
+
expect(operations[3]).toContain("填充率");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders pipeline cover theme gradients, decoration, typography and minimum font size", async () => {
|
|
79
|
+
const { resolveTypesetResult } = await import("../server/routes/render");
|
|
80
|
+
const { createCanvas } = await import("@napi-rs/canvas");
|
|
81
|
+
const font = { fontFamily: "Songti SC", fontSize: 112, minFontSize: 36, fontWeight: 700, lineHeight: 1.3, letterSpacing: 2 };
|
|
82
|
+
const coverTheme: PipelineCoverTheme = {
|
|
83
|
+
id: "test", format: "poster-3-4",
|
|
84
|
+
typography: { title: font, subtitle: font, footer: { ...font, fontSize: 24 } },
|
|
85
|
+
colors: { text: "#171717", accent: "#059669", muted: "#666666" },
|
|
86
|
+
background: { fill: { type: "solid", color: "#ffffff" }, image: null, overlay: null },
|
|
87
|
+
decoration: { pattern: "none", color: "#059669", opacity: 0.1, size: 64, image: null },
|
|
88
|
+
layout: { textAlign: "center", verticalAlign: "center", safeArea: { top: 90, right: 84, bottom: 64, left: 84 } },
|
|
89
|
+
};
|
|
90
|
+
coverTheme.background.fill = { type: "linear", angle: 45, stops: [{ color: "#112233", offset: 0 }, { color: "#334455", offset: 100 }] };
|
|
91
|
+
coverTheme.decoration.pattern = "grid";
|
|
92
|
+
const image = `data:image/png;base64,${createCanvas(5, 5).toBuffer("image/png").toString("base64")}`;
|
|
93
|
+
coverTheme.decoration.image = { src: image, fit: "contain", opacity: 0.3, position: { x: 50, y: 50 } };
|
|
94
|
+
const { result } = resolveTypesetResult({
|
|
95
|
+
kind: "cover", slot: "newspic_cover", maxPages: 1, markdown: "# 封面主题测试",
|
|
96
|
+
config: { coverTheme, algorithm: { ...coverTheme.layout, targetFillRatio: 1 } },
|
|
97
|
+
});
|
|
98
|
+
expect(result.pages[0].lines[0].font).toContain(coverTheme.typography.title.fontFamily);
|
|
99
|
+
const buffer = await renderPageToPng(result.pages[0], result.canvasSpec, result.config, result.pageCount);
|
|
100
|
+
expect(buffer.length).toBeGreaterThan(1000);
|
|
101
|
+
const smallArea = resolveTypesetResult({
|
|
102
|
+
kind: "cover", slot: "newspic_cover", maxPages: 1, markdown: "# 封面主题测试",
|
|
103
|
+
config: { coverTheme, algorithm: { safeArea: { top: 600, bottom: 700, left: 84, right: 84 }, targetFillRatio: 1 } },
|
|
104
|
+
}).result;
|
|
105
|
+
expect(smallArea.config.coverTheme!.typography.title.fontSize).toBeLessThan(font.fontSize);
|
|
106
|
+
expect(smallArea.config.coverTheme!.typography.title.fontSize).toBeGreaterThanOrEqual(font.minFontSize);
|
|
107
|
+
});
|
|
@@ -2,6 +2,7 @@ import { createCanvas, loadImage, type Canvas } from "@napi-rs/canvas";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
4
|
import { drawPageToContext } from "./canvas-draw";
|
|
5
|
+
import { resolveDataDir } from "./data-dir";
|
|
5
6
|
import { registerServerFonts } from "./font-loader";
|
|
6
7
|
import { CanvasSpec, FlowPage, TypesetConfig, TypesetResult } from "./types";
|
|
7
8
|
|
|
@@ -18,7 +19,7 @@ async function safeLoadImage(src: string): Promise<any | null> {
|
|
|
18
19
|
if (!src) return null;
|
|
19
20
|
try {
|
|
20
21
|
if (src.startsWith("/uploads/") || src.startsWith("uploads/")) {
|
|
21
|
-
const localPath = join(
|
|
22
|
+
const localPath = join(resolveDataDir(), src.replace(/^\//, ""));
|
|
22
23
|
if (existsSync(localPath)) {
|
|
23
24
|
return await loadImage(localPath);
|
|
24
25
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// 源码和安装包共用同一数据根目录,环境变量优先。
|
|
6
|
+
export function resolveDataDir(): string {
|
|
7
|
+
if (process.env.TYPESET_DATA_DIR) return resolve(process.env.TYPESET_DATA_DIR);
|
|
8
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const packageData = resolve(moduleDir, "../../data");
|
|
10
|
+
if (existsSync(packageData)) return packageData;
|
|
11
|
+
const cwdData = resolve(process.cwd(), "data");
|
|
12
|
+
return existsSync(cwdData) ? cwdData : packageData;
|
|
13
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { parseContentBlocks, parseInlineRuns, splitTextByPages } from "./inline-
|
|
|
3
3
|
import { paginateBlocks } from "./obstacle-flow";
|
|
4
4
|
import { computeBlockStyles } from "./typography";
|
|
5
5
|
import { resolveImagePlacement } from "./image-layout";
|
|
6
|
+
import { validateTemplateAssets } from "./template";
|
|
6
7
|
import {
|
|
7
|
-
ContentBlock,
|
|
8
8
|
FlowBlock,
|
|
9
9
|
FlowImage,
|
|
10
10
|
FlowPage,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
TypesetResult,
|
|
13
13
|
} from "./types";
|
|
14
14
|
|
|
15
|
-
export function getDefaultConfig(aspectRatio = "longform-3-4"
|
|
15
|
+
export function getDefaultConfig(aspectRatio: TypesetConfig["aspectRatio"] = "longform-3-4"): TypesetConfig {
|
|
16
16
|
const spec = getCanvasSpec(aspectRatio);
|
|
17
17
|
const isCover = aspectRatio === "wechat-cover-2.35";
|
|
18
18
|
const isSquare = aspectRatio === "square-1-1";
|
|
@@ -77,8 +77,18 @@ export function runTypeset(params: {
|
|
|
77
77
|
images?: FlowImage[];
|
|
78
78
|
config: TypesetConfig;
|
|
79
79
|
highlightWords?: string[];
|
|
80
|
+
minPages?: number;
|
|
81
|
+
maxPages?: number;
|
|
82
|
+
pageFillRatios?: Record<number, number>;
|
|
80
83
|
}): TypesetResult {
|
|
81
|
-
const { markdown, images = [], config, highlightWords = [] } = params;
|
|
84
|
+
const { markdown, images = [], config, highlightWords = [], minPages = 1, maxPages = 0, pageFillRatios } = params;
|
|
85
|
+
if (!Number.isInteger(minPages) || minPages < 1 || !Number.isInteger(maxPages) || maxPages < 0 || (maxPages > 0 && maxPages < minPages)) {
|
|
86
|
+
throw new RangeError("Invalid page constraints: maxPages must be 0 or at least minPages");
|
|
87
|
+
}
|
|
88
|
+
validateTemplateAssets(images);
|
|
89
|
+
if (images.some((image) => !image.repeatEveryPage && image.pageIndex != null && (!Number.isInteger(image.pageIndex) || image.pageIndex < 1 || image.pageIndex > 50))) {
|
|
90
|
+
throw new RangeError("Image pageIndex must be an integer between 1 and 50");
|
|
91
|
+
}
|
|
82
92
|
const spec = getCanvasSpec(config.aspectRatio);
|
|
83
93
|
const { stageWidth, stageHeight } = computeStageDimensions(
|
|
84
94
|
spec,
|
|
@@ -94,85 +104,105 @@ export function runTypeset(params: {
|
|
|
94
104
|
bodyFont: config.typography.bodyFont,
|
|
95
105
|
});
|
|
96
106
|
|
|
107
|
+
if (config.coverTheme) {
|
|
108
|
+
const fonts = config.coverTheme.typography;
|
|
109
|
+
for (const [kind, style] of Object.entries(blockStyles)) {
|
|
110
|
+
const font = kind.includes("heading") ? fonts.title : fonts.subtitle;
|
|
111
|
+
Object.assign(style, {
|
|
112
|
+
font: `${font.fontWeight} ${font.fontSize}px "${font.fontFamily}"`,
|
|
113
|
+
lineHeight: Math.round(font.fontSize * font.lineHeight),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 自动分页和手动分页共用相同的文字块样式。
|
|
119
|
+
const createFlowBlocks = (text: string): FlowBlock[] => parseContentBlocks(text).map((block) => ({
|
|
120
|
+
...blockStyles[block.kind],
|
|
121
|
+
kind: block.kind,
|
|
122
|
+
text: block.text,
|
|
123
|
+
runs: parseInlineRuns(block.text, highlightWords).map((run) => ({
|
|
124
|
+
...run, letterSpacing: config.coverTheme?.typography[block.kind.includes("heading") ? "title" : "subtitle"].letterSpacing,
|
|
125
|
+
})),
|
|
126
|
+
bullet: block.kind === "list-item" ? block.bullet ?? "•" : undefined,
|
|
127
|
+
textIndent: block.kind === "list-item" ? 28 : block.kind === "quote" ? 20 : 0,
|
|
128
|
+
keepWithNext: ["heading", "subheading", "minor-heading"].includes(block.kind),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const templateImages = images.filter((image) => image.templateAssetKey);
|
|
132
|
+
const contentImages = images.filter((image) => !image.templateAssetKey);
|
|
133
|
+
|
|
97
134
|
// Check if explicit page markers exist
|
|
98
135
|
const pageSegments = splitTextByPages(markdown);
|
|
99
136
|
let pages: FlowPage[] = [];
|
|
100
137
|
|
|
101
138
|
if (pageSegments.length > 1) {
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
// Match images explicitly configured for this page (img.pageIndex === pageNum).
|
|
117
|
-
// If an image has no pageIndex, fallback to Page 1 (index === 0).
|
|
118
|
-
const pageImages = images.filter((img) => {
|
|
119
|
-
if (img.pageIndex !== undefined && img.pageIndex !== null && img.pageIndex > 0) {
|
|
120
|
-
return img.pageIndex === pageNum;
|
|
121
|
-
}
|
|
122
|
-
return index === 0;
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const subPages = paginateBlocks(flowBlocks, stageWidth, stageHeight, pageImages, {
|
|
139
|
+
// 手动段落溢出继续分页,图片仍归属原始段落,最终页码连续。
|
|
140
|
+
const segmentCount = Math.max(pageSegments.length, ...contentImages.map((image) => image.pageIndex || 1));
|
|
141
|
+
if (segmentCount > 50) throw new RangeError("Content exceeds engine limit of 50 pages");
|
|
142
|
+
for (let index = 0; index < segmentCount; index++) {
|
|
143
|
+
const pageImages = contentImages.filter((image) => (image.pageIndex || 1) === index + 1);
|
|
144
|
+
const subPages = paginateBlocks(createFlowBlocks(pageSegments[index] || ""), stageWidth, stageHeight, pageImages, {
|
|
126
145
|
obstacleGap: config.algorithm.obstacleGap,
|
|
127
146
|
pageImageLimit: 2,
|
|
128
147
|
textAlign: config.algorithm.textAlign,
|
|
129
148
|
verticalAlign: config.algorithm.verticalAlign,
|
|
130
|
-
initialPageNumber:
|
|
149
|
+
initialPageNumber: pages.length + 1,
|
|
150
|
+
templateImages,
|
|
151
|
+
targetFillRatio: pageFillRatios?.[index + 1] ?? config.algorithm.targetFillRatio,
|
|
131
152
|
});
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
...firstSubPage,
|
|
145
|
-
pageNumber: pageNum,
|
|
146
|
-
};
|
|
147
|
-
});
|
|
153
|
+
pages.push(...(subPages.length ? subPages : [{
|
|
154
|
+
pageNumber: pages.length + 1, lines: [], images: [], textBottom: 0, fillRatio: 0,
|
|
155
|
+
}]));
|
|
156
|
+
}
|
|
157
|
+
const lastTemplatePage = Math.max(0, ...templateImages.filter((image) => !image.repeatEveryPage).map((image) => image.pageIndex || 1));
|
|
158
|
+
if (pages.length < lastTemplatePage) {
|
|
159
|
+
pages.push(...paginateBlocks([], stageWidth, stageHeight, [], {
|
|
160
|
+
initialPageNumber: pages.length + 1, lastPageNumber: lastTemplatePage, templateImages,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
148
163
|
} else {
|
|
149
164
|
// 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
|
-
}));
|
|
165
|
+
const flowBlocks = createFlowBlocks(markdown);
|
|
160
166
|
|
|
161
|
-
pages = paginateBlocks(flowBlocks, stageWidth, stageHeight,
|
|
167
|
+
pages = paginateBlocks(flowBlocks, stageWidth, stageHeight, contentImages, {
|
|
162
168
|
obstacleGap: config.algorithm.obstacleGap,
|
|
163
169
|
pageImageLimit: config.aspectRatio === "longform-3-4" ? 2 : 1,
|
|
164
170
|
textAlign: config.algorithm.textAlign,
|
|
165
171
|
verticalAlign: config.algorithm.verticalAlign,
|
|
172
|
+
targetFillRatio: config.algorithm.targetFillRatio,
|
|
173
|
+
pageFillRatios,
|
|
174
|
+
templateImages,
|
|
166
175
|
});
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
// 少页约束通过缩短每页文字区分配正文,无法分配时明确报错。
|
|
179
|
+
if (pageSegments.length === 1 && pages.length < minPages && markdown.trim()) {
|
|
180
|
+
for (let attempt = 1; attempt <= 8 && pages.length < minPages; attempt++) {
|
|
181
|
+
const ratio = config.algorithm.targetFillRatio * Math.pow(0.75, attempt);
|
|
182
|
+
try {
|
|
183
|
+
pages = paginateBlocks(createFlowBlocks(markdown), stageWidth, stageHeight, contentImages, {
|
|
184
|
+
obstacleGap: config.algorithm.obstacleGap,
|
|
185
|
+
pageImageLimit: config.aspectRatio === "longform-3-4" ? 2 : 1,
|
|
186
|
+
textAlign: config.algorithm.textAlign,
|
|
187
|
+
verticalAlign: config.algorithm.verticalAlign,
|
|
188
|
+
targetFillRatio: ratio,
|
|
189
|
+
templateImages,
|
|
190
|
+
pageFillRatios: pageFillRatios && Object.fromEntries(Object.entries(pageFillRatios).map(([page, fill]) => [page, Math.min(fill, ratio)])),
|
|
191
|
+
});
|
|
192
|
+
} catch { break; }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (pages.length < minPages && minPages > 1) throw new RangeError(`Content cannot satisfy minPages=${minPages}; lower the minimum page count`);
|
|
196
|
+
if (pages.length > 50) throw new RangeError("Content exceeds engine limit of 50 pages");
|
|
197
|
+
if (maxPages > 0 && pages.length > maxPages) throw new RangeError(`Content exceeds maxPages=${maxPages}; no content was discarded`);
|
|
198
|
+
|
|
169
199
|
// Ensure at least 1 page exists
|
|
170
200
|
if (pages.length === 0) {
|
|
171
201
|
pages = [
|
|
172
202
|
{
|
|
173
203
|
pageNumber: 1,
|
|
174
204
|
lines: [],
|
|
175
|
-
images: images.map((img, idx) =>
|
|
205
|
+
images: images.filter((image) => !image.templateAssetKey || image.repeatEveryPage || (image.pageIndex || 1) === 1).map((img, idx) =>
|
|
176
206
|
resolveImagePlacement(img, idx, stageWidth, stageHeight),
|
|
177
207
|
),
|
|
178
208
|
textBottom: 0,
|
|
@@ -33,6 +33,26 @@ describe("Project-Local Fonts Scanning & Registration", () => {
|
|
|
33
33
|
expect(Array.isArray(data.fonts)).toBe(true);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
it("decodes Unicode SFNT family records without modifying the source", () => {
|
|
37
|
+
const family = "测试字体";
|
|
38
|
+
const name = Buffer.from(family, "utf16le").swap16();
|
|
39
|
+
const fixture = Buffer.alloc(46 + name.length);
|
|
40
|
+
fixture.writeUInt32BE(0x10000, 0);
|
|
41
|
+
fixture.writeUInt16BE(1, 4);
|
|
42
|
+
fixture.write("name", 12);
|
|
43
|
+
fixture.writeUInt32BE(28, 20);
|
|
44
|
+
fixture.writeUInt16BE(1, 30);
|
|
45
|
+
fixture.writeUInt16BE(18, 32);
|
|
46
|
+
fixture.writeUInt16BE(3, 34);
|
|
47
|
+
fixture.writeUInt16BE(16, 40);
|
|
48
|
+
fixture.writeUInt16BE(name.length, 42);
|
|
49
|
+
name.copy(fixture, 46);
|
|
50
|
+
const path = join(isolatedFontsDir, "Unicode.ttf");
|
|
51
|
+
writeFileSync(path, fixture);
|
|
52
|
+
expect(readFontFamilyName(path)).toBe(family);
|
|
53
|
+
expect(readFontFamilyName(path)).toBe(family);
|
|
54
|
+
});
|
|
55
|
+
|
|
36
56
|
it("recognizes fonts.json manifest metadata when present", () => {
|
|
37
57
|
const dummyFontPath = join(isolatedFontsDir, "MockCustomFont.ttf");
|
|
38
58
|
const manifestPath = join(isolatedFontsDir, "fonts.json");
|
package/src/core/font-loader.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { clearCache } from "@chenglou/pretext";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
1
3
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
4
|
import { basename, extname, join, resolve } from "node:path";
|
|
3
5
|
|
|
@@ -56,7 +58,8 @@ export function readFontFamilyName(filePath: string): string | null {
|
|
|
56
58
|
if (nameId === 1 || nameId === 16) {
|
|
57
59
|
let str = "";
|
|
58
60
|
if (platformId === 0 || platformId === 3) {
|
|
59
|
-
|
|
61
|
+
if (length % 2 !== 0) continue;
|
|
62
|
+
str = Buffer.from(buf.subarray(strOffset, strOffset + length)).swap16().toString("utf16le").trim();
|
|
60
63
|
} else {
|
|
61
64
|
str = buf.toString("utf8", strOffset, strOffset + length).trim();
|
|
62
65
|
}
|
|
@@ -180,7 +183,7 @@ export function registerServerFonts(options?: { refresh?: boolean }): LocalFontI
|
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
try {
|
|
183
|
-
const { GlobalFonts } =
|
|
186
|
+
const { GlobalFonts } = createRequire(import.meta.url)("@napi-rs/canvas");
|
|
184
187
|
if (GlobalFonts && typeof GlobalFonts.registerFromPath === "function") {
|
|
185
188
|
for (const font of fonts) {
|
|
186
189
|
try {
|
|
@@ -194,6 +197,7 @@ export function registerServerFonts(options?: { refresh?: boolean }): LocalFontI
|
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
199
|
registeredServerFonts = true;
|
|
200
|
+
clearCache();
|
|
197
201
|
}
|
|
198
202
|
} catch {
|
|
199
203
|
// Client-side or @napi-rs/canvas not available
|
package/src/core/image-layout.ts
CHANGED
|
@@ -22,6 +22,10 @@ export function resolveImagePlacement(
|
|
|
22
22
|
stageWidth: number,
|
|
23
23
|
stageHeight: number,
|
|
24
24
|
): ResolvedFlowImage {
|
|
25
|
+
// 固定素材沿用模板坐标,普通配图数量变化不能移动它。
|
|
26
|
+
if (img.templateAssetKey && img.x != null && img.y != null && img.width != null && img.height != null) {
|
|
27
|
+
return { ...img, x: img.x, y: img.y, width: img.width, height: img.height };
|
|
28
|
+
}
|
|
25
29
|
const preset = img.layoutPreset || "auto";
|
|
26
30
|
const padding = 16;
|
|
27
31
|
const captionH = img.captionHeight ?? 36;
|