@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.
Files changed (62) hide show
  1. package/README.md +91 -95
  2. package/dist/assets/index-3Ko1UCBU.css +1 -0
  3. package/dist/assets/index-C9J0f4Lf.js +317 -0
  4. package/dist/index.html +2 -2
  5. package/dist/lib/adapter.js +6 -7734
  6. package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
  7. package/dist/lib/font-loader-j28mt43t.js +11 -0
  8. package/dist/lib/index-0370qz9e.js +303 -0
  9. package/dist/lib/index-37x76zdn.js +4 -0
  10. package/dist/lib/index-6c43c7h4.js +3880 -0
  11. package/dist/lib/index-t9w3p2p9.js +3366 -0
  12. package/dist/lib/index-zdhqg5f0.js +459 -0
  13. package/dist/lib/index.js +26 -7734
  14. package/dist/lib/render-xxd348m1.js +11 -0
  15. package/package.json +3 -7
  16. package/src/adapter.test.ts +117 -0
  17. package/src/adapter.ts +190 -281
  18. package/src/core/canvas-draw.ts +150 -47
  19. package/src/core/canvas-renderer.test.ts +58 -0
  20. package/src/core/canvas-renderer.ts +2 -1
  21. package/src/core/data-dir.ts +13 -0
  22. package/src/core/engine.ts +86 -56
  23. package/src/core/font-loader.test.ts +20 -0
  24. package/src/core/font-loader.ts +6 -2
  25. package/src/core/image-layout.ts +4 -0
  26. package/src/core/inline-text.ts +6 -4
  27. package/src/core/obstacle-flow.ts +29 -15
  28. package/src/core/page-images.test.ts +90 -0
  29. package/src/core/pretext.ts +4 -10
  30. package/src/core/template.test.ts +38 -1
  31. package/src/core/template.ts +53 -41
  32. package/src/core/types.ts +46 -0
  33. package/src/core/typography.ts +0 -14
  34. package/src/index.ts +6 -3
  35. package/src/server/auth.ts +18 -13
  36. package/src/server/index.ts +11 -16
  37. package/src/server/routes/fonts.ts +1 -1
  38. package/src/server/routes/render.ts +60 -17
  39. package/src/server/routes/slots.test.ts +96 -0
  40. package/src/server/routes/templates.ts +37 -48
  41. package/src/server/routes/upload.ts +2 -1
  42. package/src/web/App.tsx +15 -6
  43. package/src/web/components/CanvasViewport.tsx +56 -42
  44. package/src/web/components/EditorSidebar.tsx +59 -48
  45. package/src/web/components/Header.tsx +1 -1
  46. package/src/web/components/TemplateModal.tsx +25 -28
  47. package/src/web/hooks/image-layout.ts +31 -0
  48. package/src/web/hooks/useLocalFonts.ts +10 -1
  49. package/src/web/hooks/useTypeset.ts +139 -100
  50. package/data/templates/.gitkeep +0 -0
  51. package/data/templates/tpl_1791681833354_iofsk.json +0 -46
  52. package/data/templates/tpl_1791682603199_875ny.json +0 -66
  53. package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
  54. package/data/templates/tpl_longform_paper_sage.json +0 -47
  55. package/data/templates/tpl_longform_vintage_news.json +0 -47
  56. package/data/templates/tpl_newspic_poster_cover.json +0 -64
  57. package/data/templates/tpl_square_quote_card.json +0 -46
  58. package/data/templates/tpl_wechat_cover_editorial.json +0 -64
  59. package/data/templates/tpl_wechat_cover_minimal.json +0 -64
  60. package/data/templates/tpl_wide_tech_banner.json +0 -46
  61. package/dist/assets/index-CZ352D7X.css +0 -1
  62. package/dist/assets/index-Dpnz-F2g.js +0 -317
@@ -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 (highlightWords.length === 0) {
21
+ if (!hlRegex) {
18
22
  runs.push({ text: str, ...baseStyle });
19
23
  return;
20
24
  }
21
25
 
22
- // Match any of highlightWords
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 <= stageHeight && lines.length < limit) {
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 ?? 50;
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
- // Emergency escape to prevent infinite loop
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
+ });
@@ -2,17 +2,13 @@ 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
-
8
+ // Node ESM 使用标准加载器,浏览器不会加载原生模块。
9
+ const requireFn = typeof process !== "undefined" && process.versions?.node
10
+ ? (await import("node:module")).createRequire(import.meta.url)
11
+ : undefined;
16
12
  let initialized = false;
17
13
 
18
14
  /**
@@ -45,8 +41,6 @@ export function ensurePretextRuntime(): void {
45
41
 
46
42
  // Node/Bun headless environment
47
43
  try {
48
- // Dynamic import to prevent bundler errors in browser
49
- const requireFn = (import.meta as any).require || (globalThis as any).require;
50
44
  if (requireFn) {
51
45
  const { createCanvas } = requireFn("@napi-rs/canvas");
52
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
+ });
@@ -1,49 +1,61 @@
1
- import { FlowImage, TypesetConfig } from "./types";
1
+ import { FlowImage } from "./types";
2
2
 
3
- /**
4
- * Applies template image layout configurations to current content images based on index intersection:
5
- * - If current images exist and template has images:
6
- * For 0 <= i < min(template.images.length, current.images.length), apply template layout properties
7
- * (isAbsolute, layoutPreset, x, y, width, height, rotate, opacity, zIndex, borderRadius, captionHeight, pageIndex)
8
- * while preserving the current image's content identity (id, src, alt, caption).
9
- * Current images beyond template.images.length remain untouched ("取交集,另外俩图用户自己调").
10
- * - If current images are empty (0 images), adopt the template's images (with freshly generated IDs).
11
- * - If template has no images, current images remain untouched.
12
- */
3
+ // 固定素材标识用于显式替换,同一模板内必须唯一。
4
+ export function validateTemplateAssets(images: FlowImage[] = []): void {
5
+ if (!Array.isArray(images)) throw new RangeError("模板图片必须是数组");
6
+ const keys = new Set<string>();
7
+ for (const image of images) {
8
+ if (!image || typeof image !== "object") throw new RangeError("无效的模板图片");
9
+ if (image.templateAssetKey === undefined) continue;
10
+ const key = image.templateAssetKey;
11
+ if (image.repeatEveryPage !== undefined && typeof image.repeatEveryPage !== "boolean") throw new RangeError(`素材 ${key} 的每页重复开关必须是布尔值`);
12
+ if (typeof key !== "string" || !/^[a-zA-Z0-9_-]+$/.test(key)) {
13
+ throw new RangeError("素材标识只能包含字母、数字、下划线和短横线");
14
+ }
15
+ if (image.repeatEveryPage && !image.isAbsolute) throw new RangeError(`素材 ${key} 的每页重复仅支持自由定位模式`);
16
+ if (keys.has(key)) throw new RangeError(`重复的模板素材标识:${key}`);
17
+ keys.add(key);
18
+ }
19
+ }
20
+
21
+ // 普通配图按位置套用布局,固定素材仅通过标识替换来源。
13
22
  export function applyTemplateImages(
14
23
  currentImages: FlowImage[],
15
- templateImages?: FlowImage[],
24
+ templateImages: FlowImage[] = [],
25
+ overrides: Record<string, string> = {},
16
26
  ): FlowImage[] {
17
- if (!templateImages || templateImages.length === 0) {
18
- return currentImages;
27
+ validateTemplateAssets(templateImages);
28
+ if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) throw new RangeError("素材替换必须是标识到图片地址的映射");
29
+ const fixed = templateImages.filter((image) => image.templateAssetKey);
30
+ for (const [key, src] of Object.entries(overrides)) {
31
+ if (!fixed.some((image) => image.templateAssetKey === key)) throw new RangeError(`未知模板素材:${key}`);
32
+ if (typeof src !== "string" || !src.trim()) throw new RangeError(`素材 ${key} 的替换图片不能为空`);
19
33
  }
20
-
21
- if (currentImages.length === 0) {
22
- return templateImages.map((tplImg) => ({
23
- ...tplImg,
24
- id: `img_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
25
- }));
26
- }
27
-
28
- return currentImages.map((currentImg, idx) => {
29
- if (idx < templateImages.length) {
30
- const tplImg = templateImages[idx];
34
+ const content = currentImages.filter((image) => !image.templateAssetKey);
35
+ const slots = templateImages.filter((image) => !image.templateAssetKey);
36
+ const mapped = content.length === 0
37
+ ? slots.map((image) => ({ ...image, id: `img_${crypto.randomUUID()}` }))
38
+ : content.map((image, index) => {
39
+ const slot = slots[index];
40
+ if (!slot) return image;
31
41
  return {
32
- ...currentImg,
33
- layoutPreset: tplImg.layoutPreset ?? currentImg.layoutPreset,
34
- isAbsolute: tplImg.isAbsolute ?? currentImg.isAbsolute,
35
- x: tplImg.x ?? currentImg.x,
36
- y: tplImg.y ?? currentImg.y,
37
- width: tplImg.width ?? currentImg.width,
38
- height: tplImg.height ?? currentImg.height,
39
- rotate: tplImg.rotate ?? currentImg.rotate,
40
- opacity: tplImg.opacity ?? currentImg.opacity,
41
- zIndex: tplImg.zIndex ?? currentImg.zIndex,
42
- borderRadius: tplImg.borderRadius ?? currentImg.borderRadius,
43
- captionHeight: tplImg.captionHeight ?? currentImg.captionHeight,
44
- pageIndex: tplImg.pageIndex ?? currentImg.pageIndex,
42
+ ...image,
43
+ layoutPreset: slot.layoutPreset ?? image.layoutPreset,
44
+ isAbsolute: slot.isAbsolute ?? image.isAbsolute,
45
+ x: slot.x ?? image.x,
46
+ y: slot.y ?? image.y,
47
+ width: slot.width ?? image.width,
48
+ height: slot.height ?? image.height,
49
+ rotate: slot.rotate ?? image.rotate,
50
+ opacity: slot.opacity ?? image.opacity,
51
+ zIndex: slot.zIndex ?? image.zIndex,
52
+ borderRadius: slot.borderRadius ?? image.borderRadius,
53
+ captionHeight: slot.captionHeight ?? image.captionHeight,
54
+ pageIndex: slot.pageIndex ?? image.pageIndex,
45
55
  };
46
- }
47
- return currentImg;
48
- });
56
+ });
57
+ return [...mapped, ...fixed.map((image) => ({
58
+ ...image,
59
+ src: Object.hasOwn(overrides, image.templateAssetKey!) ? overrides[image.templateAssetKey!] : image.src,
60
+ }))];
49
61
  }
package/src/core/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface InlineTextRun {
20
20
  italic?: boolean;
21
21
  highlight?: boolean;
22
22
  font?: string;
23
+ letterSpacing?: number;
23
24
  }
24
25
 
25
26
  export type ContentBlockKind =
@@ -55,6 +56,8 @@ export interface FlowImage {
55
56
  src: string;
56
57
  alt: string;
57
58
  caption?: string;
59
+ templateAssetKey?: string; // 固定模板素材的唯一标识
60
+ repeatEveryPage?: boolean; // 开启时忽略页码,在所有输出页重复
58
61
  x?: number;
59
62
  y?: number;
60
63
  width?: number;
@@ -77,6 +80,7 @@ export interface FlowLineFragment {
77
80
  highlight?: boolean;
78
81
  gapBefore: number;
79
82
  occupiedWidth: number;
83
+ letterSpacing?: number;
80
84
  }
81
85
 
82
86
  export interface FlowLine {
@@ -163,6 +167,7 @@ export interface TypesetConfig {
163
167
  watermarkText: string;
164
168
  chrome?: TypesetChromeConfig;
165
169
  images?: FlowImage[];
170
+ coverTheme?: PipelineCoverTheme;
166
171
  }
167
172
 
168
173
  export interface TypesetResult {
@@ -171,3 +176,44 @@ export interface TypesetResult {
171
176
  canvasSpec: CanvasSpec;
172
177
  config: TypesetConfig;
173
178
  }
179
+
180
+ // 流水线解析后的封面主题,与 pipeline 的公开契约保持一致。
181
+ export interface CoverFont {
182
+ fontFamily: string;
183
+ fontSize: number;
184
+ minFontSize: number;
185
+ fontWeight: number;
186
+ lineHeight: number;
187
+ letterSpacing: number;
188
+ }
189
+ export interface CoverImage {
190
+ src: string;
191
+ fit: "cover" | "contain" | "fill";
192
+ position: { x: number; y: number };
193
+ opacity: number;
194
+ }
195
+ export interface PipelineCoverTheme {
196
+ id: string;
197
+ format: "poster-3-4" | "wechat-cover-split";
198
+ typography: { title: CoverFont; subtitle: CoverFont; footer: CoverFont };
199
+ colors: { text: string; accent: string; muted: string };
200
+ background: {
201
+ fill: { type: "solid"; color: string } |
202
+ { type: "linear"; angle: number; stops: Array<{ color: string; offset: number }> } |
203
+ { type: "radial"; position: { x: number; y: number }; stops: Array<{ color: string; offset: number }> };
204
+ image: CoverImage | null;
205
+ overlay: { color: string; opacity: number } | null;
206
+ };
207
+ decoration: {
208
+ pattern: "none" | "lines" | "grid" | "dots" | "rings" | "burst";
209
+ color: string;
210
+ opacity: number;
211
+ size: number;
212
+ image: CoverImage | null;
213
+ };
214
+ layout: {
215
+ textAlign: "left" | "center" | "right";
216
+ verticalAlign: "top" | "center" | "bottom";
217
+ safeArea: AlgorithmParams["safeArea"];
218
+ };
219
+ }
@@ -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
@@ -15,7 +15,7 @@ export {
15
15
  readFontFamilyName,
16
16
  type LocalFontInfo,
17
17
  } from "./core/font-loader";
18
- export { applyTemplateImages } from "./core/template";
18
+ export { applyTemplateImages, validateTemplateAssets } from "./core/template";
19
19
 
20
20
  export {
21
21
  typesetStudioAdapter,
@@ -31,14 +31,17 @@ export { resolveTypesetResult, type RenderRequestBody } from "./server/routes/re
31
31
 
32
32
  export type {
33
33
  AlgorithmParams,
34
- AspectRatioPreset,
34
+ AspectRatioType,
35
35
  CanvasSpec,
36
36
  ContentBlockKind,
37
+ CoverFont,
38
+ CoverImage,
39
+ PipelineCoverTheme,
37
40
  FlowBlock,
38
41
  FlowImage,
39
42
  FlowLine,
40
43
  FlowPage,
41
- InlineFragment,
44
+ FlowLineFragment,
42
45
  TemplateSlot,
43
46
  ThemeColors,
44
47
  TypesetChromeConfig,
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { resolve } from "path";
3
+ import { randomBytes } from "node:crypto";
3
4
  import type { Context, Next } from "hono";
4
5
 
5
6
  const ENV_PATH = resolve(process.cwd(), ".env");
@@ -26,7 +27,7 @@ export function getOrGenerateAdminKey(): string {
26
27
  }
27
28
 
28
29
  // Generate a new secure key
29
- const randomSuffix = Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);
30
+ const randomSuffix = randomBytes(24).toString("hex");
30
31
  const newKey = `tsk_${randomSuffix}`;
31
32
 
32
33
  try {
@@ -53,18 +54,6 @@ export async function authMiddleware(c: Context, next: Next) {
53
54
 
54
55
  const providedKey = headerKey || bearerKey || queryKey;
55
56
 
56
- // Seamless local use: requests from localhost / loopback are trusted for dev
57
- const host = c.req.header("host") || "";
58
- const isLocal =
59
- host.includes("localhost") ||
60
- host.includes("127.0.0.1") ||
61
- host.includes("0.0.0.0") ||
62
- host.includes("[::1]");
63
-
64
- if (isLocal) {
65
- return next();
66
- }
67
-
68
57
  if (!providedKey || providedKey !== adminKey) {
69
58
  return c.json(
70
59
  {
@@ -78,3 +67,19 @@ export async function authMiddleware(c: Context, next: Next) {
78
67
 
79
68
  return next();
80
69
  }
70
+
71
+ // 仅真实回环连接且来源为本机网页时允许自动取钥。
72
+ export function isLocalRequest(c: Context): boolean {
73
+ if (c.req.header("forwarded") || c.req.header("x-forwarded-for") || c.req.header("x-forwarded-host")) return false;
74
+ const peer = c.env?.peerAddress;
75
+ if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(peer)) return false;
76
+ const isLoopback = (value: string) => {
77
+ try {
78
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(value).hostname);
79
+ } catch {
80
+ return false;
81
+ }
82
+ };
83
+ return isLoopback(c.req.url) && isLoopback(`http://${c.req.header("host") || ""}`) &&
84
+ (!c.req.header("origin") || isLoopback(c.req.header("origin")!));
85
+ }