@akairos/typeset-studio 0.2.2 → 0.2.4
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 +90 -93
- 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 -7582
- package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
- package/dist/lib/font-loader-j28mt43t.js +11 -0
- package/dist/lib/index-37x76zdn.js +4 -0
- package/dist/lib/index-6c43c7h4.js +3880 -0
- package/dist/lib/index-qhpfzwj4.js +303 -0
- package/dist/lib/index-t9w3p2p9.js +3366 -0
- package/dist/lib/index-zdhqg5f0.js +459 -0
- package/dist/lib/index.js +26 -7582
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +2 -3
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +122 -31
- package/src/core/canvas-draw.ts +141 -38
- 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 +74 -36
- 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 -2
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +52 -40
- package/src/core/types.ts +46 -0
- package/src/index.ts +4 -1
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +10 -15
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +34 -35
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -4
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +35 -0
- package/src/web/components/TemplateModal.tsx +22 -14
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +119 -32
- 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-DDeEGeHD.js +0 -317
|
@@ -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,6 +3,7 @@ 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
8
|
FlowBlock,
|
|
8
9
|
FlowImage,
|
|
@@ -76,8 +77,18 @@ export function runTypeset(params: {
|
|
|
76
77
|
images?: FlowImage[];
|
|
77
78
|
config: TypesetConfig;
|
|
78
79
|
highlightWords?: string[];
|
|
80
|
+
minPages?: number;
|
|
81
|
+
maxPages?: number;
|
|
82
|
+
pageFillRatios?: Record<number, number>;
|
|
79
83
|
}): TypesetResult {
|
|
80
|
-
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
|
+
}
|
|
81
92
|
const spec = getCanvasSpec(config.aspectRatio);
|
|
82
93
|
const { stageWidth, stageHeight } = computeStageDimensions(
|
|
83
94
|
spec,
|
|
@@ -93,78 +104,105 @@ export function runTypeset(params: {
|
|
|
93
104
|
bodyFont: config.typography.bodyFont,
|
|
94
105
|
});
|
|
95
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
|
+
|
|
96
118
|
// 自动分页和手动分页共用相同的文字块样式。
|
|
97
119
|
const createFlowBlocks = (text: string): FlowBlock[] => parseContentBlocks(text).map((block) => ({
|
|
98
120
|
...blockStyles[block.kind],
|
|
99
121
|
kind: block.kind,
|
|
100
122
|
text: block.text,
|
|
101
|
-
runs: parseInlineRuns(block.text, highlightWords)
|
|
123
|
+
runs: parseInlineRuns(block.text, highlightWords).map((run) => ({
|
|
124
|
+
...run, letterSpacing: config.coverTheme?.typography[block.kind.includes("heading") ? "title" : "subtitle"].letterSpacing,
|
|
125
|
+
})),
|
|
102
126
|
bullet: block.kind === "list-item" ? block.bullet ?? "•" : undefined,
|
|
103
127
|
textIndent: block.kind === "list-item" ? 28 : block.kind === "quote" ? 20 : 0,
|
|
104
128
|
keepWithNext: ["heading", "subheading", "minor-heading"].includes(block.kind),
|
|
105
129
|
}));
|
|
106
130
|
|
|
131
|
+
const templateImages = images.filter((image) => image.templateAssetKey);
|
|
132
|
+
const contentImages = images.filter((image) => !image.templateAssetKey);
|
|
133
|
+
|
|
107
134
|
// Check if explicit page markers exist
|
|
108
135
|
const pageSegments = splitTextByPages(markdown);
|
|
109
136
|
let pages: FlowPage[] = [];
|
|
110
137
|
|
|
111
138
|
if (pageSegments.length > 1) {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// If an image has no pageIndex, fallback to Page 1 (index === 0).
|
|
119
|
-
const pageImages = images.filter((img) => {
|
|
120
|
-
if (img.pageIndex !== undefined && img.pageIndex !== null && img.pageIndex > 0) {
|
|
121
|
-
return img.pageIndex === pageNum;
|
|
122
|
-
}
|
|
123
|
-
return index === 0;
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
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, {
|
|
127
145
|
obstacleGap: config.algorithm.obstacleGap,
|
|
128
146
|
pageImageLimit: 2,
|
|
129
147
|
textAlign: config.algorithm.textAlign,
|
|
130
148
|
verticalAlign: config.algorithm.verticalAlign,
|
|
131
|
-
initialPageNumber:
|
|
149
|
+
initialPageNumber: pages.length + 1,
|
|
150
|
+
templateImages,
|
|
151
|
+
targetFillRatio: pageFillRatios?.[index + 1] ?? config.algorithm.targetFillRatio,
|
|
132
152
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
...firstSubPage,
|
|
146
|
-
pageNumber: pageNum,
|
|
147
|
-
};
|
|
148
|
-
});
|
|
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
|
+
}
|
|
149
163
|
} else {
|
|
150
164
|
// Single page or auto-flowing pagination
|
|
151
165
|
const flowBlocks = createFlowBlocks(markdown);
|
|
152
166
|
|
|
153
|
-
pages = paginateBlocks(flowBlocks, stageWidth, stageHeight,
|
|
167
|
+
pages = paginateBlocks(flowBlocks, stageWidth, stageHeight, contentImages, {
|
|
154
168
|
obstacleGap: config.algorithm.obstacleGap,
|
|
155
169
|
pageImageLimit: config.aspectRatio === "longform-3-4" ? 2 : 1,
|
|
156
170
|
textAlign: config.algorithm.textAlign,
|
|
157
171
|
verticalAlign: config.algorithm.verticalAlign,
|
|
172
|
+
targetFillRatio: config.algorithm.targetFillRatio,
|
|
173
|
+
pageFillRatios,
|
|
174
|
+
templateImages,
|
|
158
175
|
});
|
|
159
176
|
}
|
|
160
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
|
+
|
|
161
199
|
// Ensure at least 1 page exists
|
|
162
200
|
if (pages.length === 0) {
|
|
163
201
|
pages = [
|
|
164
202
|
{
|
|
165
203
|
pageNumber: 1,
|
|
166
204
|
lines: [],
|
|
167
|
-
images: images.map((img, idx) =>
|
|
205
|
+
images: images.filter((image) => !image.templateAssetKey || image.repeatEveryPage || (image.pageIndex || 1) === 1).map((img, idx) =>
|
|
168
206
|
resolveImagePlacement(img, idx, stageWidth, stageHeight),
|
|
169
207
|
),
|
|
170
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;
|
package/src/core/inline-text.ts
CHANGED
|
@@ -6,6 +6,10 @@ export function normalizeMixedTextSpacing(text: string): string {
|
|
|
6
6
|
|
|
7
7
|
export function parseInlineRuns(text: string, highlightWords: string[] = []): InlineTextRun[] {
|
|
8
8
|
const runs: InlineTextRun[] = [];
|
|
9
|
+
// 空词不能参与正则匹配,否则游标无法前进。
|
|
10
|
+
const escapedWords = highlightWords.filter((word) => word.length > 0)
|
|
11
|
+
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
12
|
+
const hlRegex = escapedWords.length ? new RegExp(`(${escapedWords.join("|")})`, "g") : null;
|
|
9
13
|
// Tokenize markdown bold **text**, italic *text*, highlight ==text==, or matching highlightWords
|
|
10
14
|
// Simple token regex
|
|
11
15
|
const pattern = /(\*\*[^*]+\*\*|\*[^*]+\*|==[^=]+==)/g;
|
|
@@ -14,14 +18,12 @@ export function parseInlineRuns(text: string, highlightWords: string[] = []): In
|
|
|
14
18
|
|
|
15
19
|
function pushTextWithHighlights(str: string, baseStyle: Partial<InlineTextRun> = {}) {
|
|
16
20
|
if (!str) return;
|
|
17
|
-
if (
|
|
21
|
+
if (!hlRegex) {
|
|
18
22
|
runs.push({ text: str, ...baseStyle });
|
|
19
23
|
return;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
const escapedWords = highlightWords.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
24
|
-
const hlRegex = new RegExp(`(${escapedWords.join("|")})`, "g");
|
|
26
|
+
hlRegex.lastIndex = 0;
|
|
25
27
|
let hlLast = 0;
|
|
26
28
|
let hlMatch: RegExpExecArray | null;
|
|
27
29
|
|
|
@@ -31,6 +31,10 @@ export interface PaginateOptions {
|
|
|
31
31
|
textAlign?: "left" | "center" | "right";
|
|
32
32
|
verticalAlign?: "top" | "center" | "bottom";
|
|
33
33
|
initialPageNumber?: number;
|
|
34
|
+
targetFillRatio?: number;
|
|
35
|
+
pageFillRatios?: Record<number, number>;
|
|
36
|
+
templateImages?: FlowImage[];
|
|
37
|
+
lastPageNumber?: number;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -46,6 +50,7 @@ export function paginateBlocks(
|
|
|
46
50
|
): FlowPage[] {
|
|
47
51
|
ensurePretextRuntime();
|
|
48
52
|
|
|
53
|
+
let textHeight = stageHeight;
|
|
49
54
|
const gap = options.obstacleGap ?? 24;
|
|
50
55
|
const imageLimit = options.pageImageLimit ?? 2;
|
|
51
56
|
|
|
@@ -75,7 +80,7 @@ export function paginateBlocks(
|
|
|
75
80
|
const lines: PlacedLine[] = [];
|
|
76
81
|
let y = initialY;
|
|
77
82
|
|
|
78
|
-
while (y + block.lineHeight <=
|
|
83
|
+
while (y + block.lineHeight <= textHeight && lines.length < limit) {
|
|
79
84
|
if (!layoutNextRichInlineLineRange(block.prepared, stageWidth, cursor)) {
|
|
80
85
|
return { lines, cursor, y, done: true };
|
|
81
86
|
}
|
|
@@ -154,6 +159,7 @@ export function paginateBlocks(
|
|
|
154
159
|
highlight: originalRun.highlight,
|
|
155
160
|
gapBefore: frag.gapBefore,
|
|
156
161
|
occupiedWidth: frag.occupiedWidth,
|
|
162
|
+
letterSpacing: originalRun.letterSpacing,
|
|
157
163
|
};
|
|
158
164
|
});
|
|
159
165
|
|
|
@@ -209,16 +215,27 @@ export function paginateBlocks(
|
|
|
209
215
|
const unassignedImages = allImages.filter((img) => !img.pageIndex);
|
|
210
216
|
let segmentImagesPlaced = false;
|
|
211
217
|
|
|
212
|
-
const MAX_PAGES = options.maxPages
|
|
218
|
+
const MAX_PAGES = options.maxPages || 50;
|
|
219
|
+
// 固定素材独立选择,不占正文图片配额;重复素材不驱动新分页。
|
|
220
|
+
const templateImages = options.templateImages || [];
|
|
221
|
+
const lastTemplatePage = Math.max(0, ...templateImages.filter((image) => !image.repeatEveryPage).map((image) => image.pageIndex || 1));
|
|
222
|
+
const lastPageNumber = Math.max(options.lastPageNumber || 0, isSegmentMode ? 0 : lastTemplatePage);
|
|
223
|
+
const templatesForPage = (page: number) => templateImages.filter((image) => image.repeatEveryPage || (image.pageIndex || 1) === page);
|
|
224
|
+
// 显式图片页之间允许空页,不能提前结束丢图。
|
|
225
|
+
const lastImagePage = isSegmentMode ? 0 : Math.max(0, ...allImages.map((image) => image.pageIndex || 0));
|
|
226
|
+
const hasRemaining = () => blockIndex < prepared.length ||
|
|
227
|
+
(!isSegmentMode && (unassignedIndex < unassignedImages.length || initialPageNumber + pages.length <= lastImagePage)) ||
|
|
228
|
+
(isSegmentMode && !segmentImagesPlaced && (allImages.length > 0 || templatesForPage(initialPageNumber).length > 0)) ||
|
|
229
|
+
initialPageNumber + pages.length <= lastPageNumber;
|
|
213
230
|
|
|
214
231
|
// Pagination loop
|
|
215
|
-
while (
|
|
216
|
-
pages.length < MAX_PAGES &&
|
|
217
|
-
(blockIndex < prepared.length ||
|
|
218
|
-
(!isSegmentMode && unassignedIndex < unassignedImages.length) ||
|
|
219
|
-
(isSegmentMode && !segmentImagesPlaced && allImages.length > 0))
|
|
220
|
-
) {
|
|
232
|
+
while (pages.length < MAX_PAGES && hasRemaining()) {
|
|
221
233
|
const pageNum = initialPageNumber + pages.length;
|
|
234
|
+
const targetFill = options.pageFillRatios?.[pageNum] ?? options.targetFillRatio ?? 1;
|
|
235
|
+
if (!Number.isFinite(targetFill) || targetFill <= 0 || targetFill > 1) {
|
|
236
|
+
throw new RangeError("targetFillRatio must be greater than 0 and at most 1");
|
|
237
|
+
}
|
|
238
|
+
textHeight = stageHeight * targetFill;
|
|
222
239
|
let targetImages: FlowImage[] = [];
|
|
223
240
|
|
|
224
241
|
if (isSegmentMode) {
|
|
@@ -236,7 +253,7 @@ export function paginateBlocks(
|
|
|
236
253
|
}
|
|
237
254
|
}
|
|
238
255
|
|
|
239
|
-
const images = targetImages.map((img, idx) =>
|
|
256
|
+
const images = [...targetImages, ...templatesForPage(pageNum)].map((img, idx) =>
|
|
240
257
|
resolveImagePlacement(img, idx, stageWidth, stageHeight),
|
|
241
258
|
);
|
|
242
259
|
const lines: FlowLine[] = [];
|
|
@@ -296,15 +313,11 @@ export function paginateBlocks(
|
|
|
296
313
|
if (
|
|
297
314
|
blockIndex === initialBlockIndex &&
|
|
298
315
|
compareCursor(cursor, initialCursor) === 0 &&
|
|
299
|
-
!images.length
|
|
316
|
+
!targetImages.length && !images.some((image) => !image.isAbsolute && !image.repeatEveryPage) && blockIndex < prepared.length
|
|
300
317
|
) {
|
|
301
|
-
|
|
302
|
-
break;
|
|
318
|
+
throw new RangeError("Text cannot fit in the available page area; reduce font size or enlarge the safe area");
|
|
303
319
|
}
|
|
304
320
|
|
|
305
|
-
if (!lines.length && !images.length) {
|
|
306
|
-
break;
|
|
307
|
-
}
|
|
308
321
|
|
|
309
322
|
const textBottom = lines.reduce((max, l) => Math.max(max, l.y + l.lineHeight), 0);
|
|
310
323
|
const flowImages = images.filter((img) => !img.isAbsolute);
|
|
@@ -354,5 +367,6 @@ export function paginateBlocks(
|
|
|
354
367
|
});
|
|
355
368
|
}
|
|
356
369
|
|
|
370
|
+
if (hasRemaining()) throw new RangeError(`Content exceeds maxPages=${MAX_PAGES}; no content was discarded`);
|
|
357
371
|
return pages;
|
|
358
372
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { getDefaultConfig, runTypeset } from "./engine";
|
|
3
3
|
import { FlowImage } from "./types";
|
|
4
|
+
import { parseInlineRuns } from "./inline-text";
|
|
4
5
|
|
|
5
6
|
describe("Multi-Page Image Assignment & Intra-Page Ordering", () => {
|
|
6
7
|
const config = getDefaultConfig("longform-3-4");
|
|
@@ -106,3 +107,92 @@ describe("Multi-Page Image Assignment & Intra-Page Ordering", () => {
|
|
|
106
107
|
expect(page2Images[1]?.id).toBe("img_a");
|
|
107
108
|
});
|
|
108
109
|
});
|
|
110
|
+
|
|
111
|
+
const config = getDefaultConfig();
|
|
112
|
+
const textOf = (result: ReturnType<typeof runTypeset>) => result.pages.flatMap((page) => page.lines).map((line) => line.text).join("");
|
|
113
|
+
|
|
114
|
+
describe("Pagination regressions", () => {
|
|
115
|
+
it("ignores empty highlight terms and reuses matching across styled fragments", () => {
|
|
116
|
+
const runs = parseInlineRuns("正文**正文**正文", ["", "正文"]);
|
|
117
|
+
expect(runs.map((run) => run.text).join("")).toBe("正文正文正文");
|
|
118
|
+
expect(runs.every((run) => run.highlight)).toBe(true);
|
|
119
|
+
expect(parseInlineRuns("a.b", ["", "."])[1]).toEqual({ text: ".", highlight: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("retains every character of overflowing manual segments and their images", () => {
|
|
123
|
+
const first = "甲乙丙丁戊己庚辛壬癸".repeat(180);
|
|
124
|
+
const result = runTypeset({ markdown: first + "\n---\n第二段", config, images: [
|
|
125
|
+
{ id: "second", src: "data:,", alt: "", pageIndex: 2, isAbsolute: true, width: 20, height: 20 },
|
|
126
|
+
] });
|
|
127
|
+
expect(textOf(result)).toBe(first + "第二段");
|
|
128
|
+
expect(result.pages.at(-1)?.images[0]?.id).toBe("second");
|
|
129
|
+
expect(result.pages.map((page) => page.pageNumber)).toEqual(result.pages.map((_, index) => index + 1));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps explicit later images and intervening empty pages", () => {
|
|
133
|
+
const result = runTypeset({ markdown: "正文", config, images: [
|
|
134
|
+
{ id: "late", src: "data:,", alt: "", pageIndex: 4, width: 20, height: 20 },
|
|
135
|
+
] });
|
|
136
|
+
expect(result.pageCount).toBe(4);
|
|
137
|
+
expect(result.pages[1]?.lines).toHaveLength(0);
|
|
138
|
+
expect(result.pages[3]?.images[0]?.id).toBe("late");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("applies global and per-page target fills without losing content", () => {
|
|
142
|
+
const markdown = "正文段落测试".repeat(250);
|
|
143
|
+
const normal = runTypeset({ markdown, config });
|
|
144
|
+
const limited = runTypeset({ markdown, config, pageFillRatios: { 1: 0.35 } });
|
|
145
|
+
expect(limited.pages[0].lines.length).toBeLessThan(normal.pages[0].lines.length);
|
|
146
|
+
expect(textOf(limited)).toBe(markdown);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("enforces min and max pages without truncating or inventing blank pages", () => {
|
|
150
|
+
const markdown = "正文段落测试".repeat(80);
|
|
151
|
+
const result = runTypeset({ markdown, config, minPages: 3 });
|
|
152
|
+
expect(result.pageCount).toBeGreaterThanOrEqual(3);
|
|
153
|
+
expect(textOf(result)).toBe(markdown);
|
|
154
|
+
expect(() => runTypeset({ markdown: markdown.repeat(10), config, maxPages: 1 })).toThrow("maxPages=1");
|
|
155
|
+
expect(() => runTypeset({ markdown: "短文", config, minPages: 3 })).toThrow("minPages=3");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// 固定素材页码对应最终输出页,重复素材不能驱动额外分页。
|
|
160
|
+
describe("Fixed asset pagination", () => {
|
|
161
|
+
const fixed: FlowImage = { id: "bg", src: "bg.png", alt: "", templateAssetKey: "background", isAbsolute: true, x: 0, y: 0, width: 20, height: 20, repeatEveryPage: true };
|
|
162
|
+
it("repeats on automatic pages without consuming content image slots", () => {
|
|
163
|
+
const result = runTypeset({ markdown: "正文测试".repeat(400), config, images: [fixed,
|
|
164
|
+
{ id: "body", src: "body.png", alt: "", isAbsolute: true, width: 20, height: 20 },
|
|
165
|
+
] });
|
|
166
|
+
expect(result.pageCount).toBeGreaterThan(1);
|
|
167
|
+
expect(result.pages.every((page) => page.images.filter((image) => image.id === "bg").length === 1)).toBe(true);
|
|
168
|
+
expect(result.pages[0].images.some((image) => image.id === "body")).toBe(true);
|
|
169
|
+
const short = runTypeset({ markdown: "短文", config, images: [{ ...fixed, pageIndex: 40 }] });
|
|
170
|
+
expect(short.pageCount).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
it("uses final output page numbers across manual overflow and repeats on continuation pages", () => {
|
|
173
|
+
const result = runTypeset({ markdown: "正文测试".repeat(400) + "\n---\n第二段", config, images: [fixed,
|
|
174
|
+
{ ...fixed, id: "only", templateAssetKey: "logo", repeatEveryPage: false, pageIndex: 2 },
|
|
175
|
+
] });
|
|
176
|
+
expect(result.pages.every((page) => page.images.some((image) => image.id === "bg"))).toBe(true);
|
|
177
|
+
expect(result.pages.filter((page) => page.images.some((image) => image.id === "only")).map((page) => page.pageNumber)).toEqual([2]);
|
|
178
|
+
const gaps = runTypeset({ markdown: "甲\n---\n乙", config, images: [{ ...fixed, repeatEveryPage: false, pageIndex: 4 }] });
|
|
179
|
+
expect(gaps.pageCount).toBe(4);
|
|
180
|
+
expect(gaps.pages[3].images[0].id).toBe("bg");
|
|
181
|
+
});
|
|
182
|
+
it("preserves saved flow asset coordinates when ordinary image counts change", () => {
|
|
183
|
+
const asset = { ...fixed, repeatEveryPage: false, isAbsolute: false, layoutPreset: "mid-right" as const, x: 100, y: 300, width: 120, height: 80 };
|
|
184
|
+
const ordinary: FlowImage = { id: "ordinary", src: "body.png", alt: "", isAbsolute: true, width: 20, height: 20 };
|
|
185
|
+
for (const images of [[asset], [ordinary, { ...ordinary, id: "second" }, asset]]) {
|
|
186
|
+
const result = runTypeset({ markdown: "正文测试".repeat(400), config, images });
|
|
187
|
+
for (const page of result.pages) {
|
|
188
|
+
if (page.pageNumber === 1) expect(page.images.find((image) => image.id === asset.id)).toMatchObject({ x: 100, y: 300, width: 120, height: 80 });
|
|
189
|
+
else expect(page.images.some((image) => image.id === asset.id)).toBe(false);
|
|
190
|
+
}
|
|
191
|
+
expect(result.pages[0].images.filter((image) => !image.templateAssetKey)).toHaveLength(images.length - 1);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
it("rejects repeating flow assets before pagination", () => {
|
|
195
|
+
expect(() => runTypeset({ markdown: "正文", config, images: [{ ...fixed, isAbsolute: false, width: 2000, height: 2000 }] }))
|
|
196
|
+
.toThrow("每页重复仅支持自由定位模式");
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/core/pretext.ts
CHANGED
|
@@ -5,6 +5,10 @@ export {
|
|
|
5
5
|
} from "@chenglou/pretext/rich-inline";
|
|
6
6
|
export type { RichInlineCursor } from "@chenglou/pretext/rich-inline";
|
|
7
7
|
|
|
8
|
+
// Node ESM 使用标准加载器,浏览器不会加载原生模块。
|
|
9
|
+
const requireFn = typeof process !== "undefined" && process.versions?.node
|
|
10
|
+
? (await import("node:module")).createRequire(import.meta.url)
|
|
11
|
+
: undefined;
|
|
8
12
|
let initialized = false;
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -37,8 +41,6 @@ export function ensurePretextRuntime(): void {
|
|
|
37
41
|
|
|
38
42
|
// Node/Bun headless environment
|
|
39
43
|
try {
|
|
40
|
-
// Dynamic import to prevent bundler errors in browser
|
|
41
|
-
const requireFn = (import.meta as any).require || (globalThis as any).require;
|
|
42
44
|
if (requireFn) {
|
|
43
45
|
const { createCanvas } = requireFn("@napi-rs/canvas");
|
|
44
46
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { applyTemplateImages } from "./template";
|
|
2
|
+
import { applyTemplateImages, validateTemplateAssets } from "./template";
|
|
3
3
|
import { FlowImage } from "./types";
|
|
4
4
|
|
|
5
5
|
describe("applyTemplateImages - Template Image Persistence & Intersection Logic", () => {
|
|
@@ -177,3 +177,40 @@ describe("applyTemplateImages - Template Image Persistence & Intersection Logic"
|
|
|
177
177
|
expect(result).toEqual(actualImages);
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
// 固定素材不能占普通图片布局位置,也不能随普通图片一起替换。
|
|
182
|
+
describe("Fixed template assets", () => {
|
|
183
|
+
const fixed: FlowImage = { id: "background", src: "bg.png", alt: "背景", templateAssetKey: "background", isAbsolute: true, x: -50, y: -50, width: 1080, height: 1440, zIndex: 0 };
|
|
184
|
+
const slot: FlowImage = { id: "slot", src: "sample.png", alt: "示例", x: 100, y: 200, width: 300, height: 200, layoutPreset: "custom" };
|
|
185
|
+
const actual: FlowImage = { id: "actual", src: "new.png", alt: "新图" };
|
|
186
|
+
|
|
187
|
+
it("keeps fixed sources and maps normal images only to normal slots", () => {
|
|
188
|
+
const result = applyTemplateImages([actual], [fixed, slot]);
|
|
189
|
+
expect(result).toHaveLength(2);
|
|
190
|
+
expect(result[0]).toMatchObject({ ...actual, x: 100, y: 200, width: 300 });
|
|
191
|
+
expect(result[1]).toEqual(fixed);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("replaces only the named source without mutating the template", () => {
|
|
195
|
+
const result = applyTemplateImages([actual], [fixed, slot], { background: "replacement.png" });
|
|
196
|
+
expect(result[1]).toEqual({ ...fixed, src: "replacement.png" });
|
|
197
|
+
expect(fixed.src).toBe("bg.png");
|
|
198
|
+
expect(applyTemplateImages([], [{ ...fixed, templateAssetKey: "constructor" }])[0].src).toBe("bg.png");
|
|
199
|
+
expect(() => applyTemplateImages([], [fixed], { missing: "new.png" })).toThrow("未知模板素材");
|
|
200
|
+
expect(() => applyTemplateImages([], [fixed, { ...fixed, id: "duplicate" }])).toThrow("重复");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("reapplying and switching templates removes old fixed layers", () => {
|
|
204
|
+
const first = applyTemplateImages([actual], [fixed, slot]);
|
|
205
|
+
expect(applyTemplateImages(first, [fixed, slot])).toEqual(first);
|
|
206
|
+
expect(applyTemplateImages(first, [slot]).some((image) => image.templateAssetKey)).toBe(false);
|
|
207
|
+
expect(applyTemplateImages(first, [])).toEqual([first[0]]);
|
|
208
|
+
expect(applyTemplateImages([], [fixed])).toEqual([fixed]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("only allows repeating fixed assets in absolute mode", () => {
|
|
212
|
+
expect(() => validateTemplateAssets([{ ...fixed, isAbsolute: false, repeatEveryPage: true }])).toThrow("每页重复仅支持自由定位模式");
|
|
213
|
+
expect(() => validateTemplateAssets([{ ...fixed, isAbsolute: false, repeatEveryPage: false }])).not.toThrow();
|
|
214
|
+
expect(() => validateTemplateAssets([{ ...fixed, repeatEveryPage: true }])).not.toThrow();
|
|
215
|
+
});
|
|
216
|
+
});
|