@akairos/typeset-studio 0.2.2 → 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 (59) hide show
  1. package/README.md +90 -93
  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 -7582
  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 -7582
  14. package/dist/lib/render-xxd348m1.js +11 -0
  15. package/package.json +2 -3
  16. package/src/adapter.test.ts +117 -0
  17. package/src/adapter.ts +121 -30
  18. package/src/core/canvas-draw.ts +141 -38
  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 +74 -36
  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 -2
  30. package/src/core/template.test.ts +38 -1
  31. package/src/core/template.ts +52 -40
  32. package/src/core/types.ts +46 -0
  33. package/src/index.ts +4 -1
  34. package/src/server/auth.ts +18 -13
  35. package/src/server/index.ts +10 -15
  36. package/src/server/routes/render.ts +60 -17
  37. package/src/server/routes/slots.test.ts +96 -0
  38. package/src/server/routes/templates.ts +34 -35
  39. package/src/server/routes/upload.ts +2 -1
  40. package/src/web/App.tsx +15 -4
  41. package/src/web/components/CanvasViewport.tsx +56 -42
  42. package/src/web/components/EditorSidebar.tsx +35 -0
  43. package/src/web/components/TemplateModal.tsx +22 -14
  44. package/src/web/hooks/image-layout.ts +31 -0
  45. package/src/web/hooks/useLocalFonts.ts +10 -1
  46. package/src/web/hooks/useTypeset.ts +119 -32
  47. package/data/templates/.gitkeep +0 -0
  48. package/data/templates/tpl_1791681833354_iofsk.json +0 -46
  49. package/data/templates/tpl_1791682603199_875ny.json +0 -66
  50. package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
  51. package/data/templates/tpl_longform_paper_sage.json +0 -47
  52. package/data/templates/tpl_longform_vintage_news.json +0 -47
  53. package/data/templates/tpl_newspic_poster_cover.json +0 -64
  54. package/data/templates/tpl_square_quote_card.json +0 -46
  55. package/data/templates/tpl_wechat_cover_editorial.json +0 -64
  56. package/data/templates/tpl_wechat_cover_minimal.json +0 -64
  57. package/data/templates/tpl_wide_tech_banner.json +0 -46
  58. package/dist/assets/index-CZ352D7X.css +0 -1
  59. package/dist/assets/index-DDeEGeHD.js +0 -317
@@ -0,0 +1,459 @@
1
+ import {
2
+ registerServerFonts
3
+ } from "./index-6c43c7h4.js";
4
+
5
+ // src/core/canvas-renderer.ts
6
+ import { createCanvas, loadImage } from "@napi-rs/canvas";
7
+ import { existsSync as existsSync2 } from "node:fs";
8
+ import { isAbsolute, join } from "node:path";
9
+
10
+ // src/core/geometry.ts
11
+ var CANVAS_SPECS = {
12
+ "wechat-cover-2.35": {
13
+ id: "wechat-cover-2.35",
14
+ name: "微信文章封面",
15
+ ratio: 2.35,
16
+ width: 2350,
17
+ height: 1000,
18
+ description: "微信公众号官方推荐大图封面 (2.35:1),适合主标题视觉张力、副标题与装饰图层",
19
+ defaultSafeArea: { top: 120, right: 140, bottom: 120, left: 140 }
20
+ },
21
+ "longform-3-4": {
22
+ id: "longform-3-4",
23
+ name: "通用长图文",
24
+ ratio: 3 / 4,
25
+ width: 1080,
26
+ height: 1440,
27
+ description: "3:4 手机端阅读长文/图文卡片,支持多页分页、矩形图片绕排、目标填充率优化",
28
+ defaultSafeArea: { top: 96, right: 88, bottom: 96, left: 88 }
29
+ },
30
+ "wide-16-9": {
31
+ id: "wide-16-9",
32
+ name: "通用宽屏",
33
+ ratio: 16 / 9,
34
+ width: 1920,
35
+ height: 1080,
36
+ description: "16:9 横版宽屏,适合推特/X 动态配图、技术博客 Banner、视频封面与分栏排版",
37
+ defaultSafeArea: { top: 100, right: 120, bottom: 100, left: 120 }
38
+ },
39
+ "square-1-1": {
40
+ id: "square-1-1",
41
+ name: "通用正方形",
42
+ ratio: 1,
43
+ width: 1200,
44
+ height: 1200,
45
+ description: "1:1 正方形卡片,适合金句名言卡、公众号次条、小红书方图与社交媒体方版海报",
46
+ defaultSafeArea: { top: 100, right: 100, bottom: 100, left: 100 }
47
+ }
48
+ };
49
+ function getCanvasSpec(type) {
50
+ return CANVAS_SPECS[type] ?? CANVAS_SPECS["longform-3-4"];
51
+ }
52
+ function computeStageDimensions(spec, safeArea, contentBottomGap = 20) {
53
+ const stageWidth = Math.max(100, spec.width - safeArea.left - safeArea.right);
54
+ const stageHeight = Math.max(100, spec.height - safeArea.top - safeArea.bottom - contentBottomGap);
55
+ return {
56
+ stageWidth,
57
+ stageHeight,
58
+ stageX: safeArea.left,
59
+ stageY: safeArea.top
60
+ };
61
+ }
62
+
63
+ // src/core/canvas-draw.ts
64
+ function resolveCanvasFontFamily(fontName, isServer = false) {
65
+ const rawFirst = (fontName || "").split(",")[0] || "";
66
+ const trimmed = rawFirst.replace(/["'\\]/g, "").trim();
67
+ if (trimmed.includes("Noto Sans")) {
68
+ return isServer ? '"Hiragino Sans GB", sans-serif' : '"Noto Sans SC", "PingFang SC", sans-serif';
69
+ }
70
+ if (trimmed.includes("PingFang")) {
71
+ return isServer ? '"Hiragino Sans GB", sans-serif' : '"PingFang SC", "Noto Sans SC", sans-serif';
72
+ }
73
+ if (trimmed.includes("Noto Serif")) {
74
+ return isServer ? '"Songti SC", serif' : '"Noto Serif SC", "Songti SC", serif';
75
+ }
76
+ if (trimmed.includes("Songti") || trimmed === "STSong") {
77
+ return isServer ? '"Songti SC", serif' : '"Songti SC", "Noto Serif SC", serif';
78
+ }
79
+ if (trimmed.includes("Kai") || trimmed.includes("WenKai")) {
80
+ return isServer ? '"STKaiti", "Kaiti SC", "Songti SC", serif' : '"LXGW WenKai GB Screen", "LXGW WenKai Screen", "Kaiti SC", "STKaiti", serif';
81
+ }
82
+ if (trimmed.includes("Yuan")) {
83
+ return isServer ? '"STYuanti", "Yuanti SC", "Hiragino Sans GB", sans-serif' : '"Yuanti SC", "STYuanti", "PingFang SC", sans-serif';
84
+ }
85
+ if (trimmed.includes("Inter")) {
86
+ return isServer ? 'Inter, "Hiragino Sans GB", sans-serif' : 'Inter, "PingFang SC", sans-serif';
87
+ }
88
+ if (trimmed.includes("Georgia")) {
89
+ return isServer ? 'Georgia, "Songti SC", serif' : 'Georgia, "Songti SC", serif';
90
+ }
91
+ if (trimmed.toLowerCase().includes("serif") || trimmed.includes("Song")) {
92
+ return `"${trimmed}", "Songti SC", serif`;
93
+ }
94
+ return isServer ? `"${trimmed}", "Hiragino Sans GB", "PingFang SC", sans-serif` : `"${trimmed}", "PingFang SC", sans-serif`;
95
+ }
96
+ function resolveCanvasFont(fontDesc, isServer = false) {
97
+ if (!fontDesc)
98
+ return '16px "PingFang SC", sans-serif';
99
+ const match = fontDesc.match(/^(.*?)([\d.]+(?:px|pt|em|rem))\s+(.+)$/);
100
+ if (!match)
101
+ return fontDesc;
102
+ const prefix = match[1];
103
+ const size = match[2];
104
+ const family = match[3];
105
+ return `${prefix}${size} ${resolveCanvasFontFamily(family, isServer)}`;
106
+ }
107
+ async function drawPageToContext(ctx, options) {
108
+ const { page, spec, config, totalPageCount = 1, getImage, isServer = false } = options;
109
+ const { algorithm, theme, chrome } = config;
110
+ ctx.save();
111
+ ctx.fillStyle = theme.bgColor;
112
+ ctx.fillRect(0, 0, spec.width, spec.height);
113
+ ctx.restore();
114
+ const coverTheme = config.coverTheme;
115
+ const drawCoverImage = async (image) => {
116
+ if (!image)
117
+ return;
118
+ const loaded = await getImage(image.src);
119
+ if (!loaded)
120
+ throw new Error("Failed to load cover theme image");
121
+ const scale = image.fit === "contain" ? Math.min(spec.width / loaded.width, spec.height / loaded.height) : Math.max(spec.width / loaded.width, spec.height / loaded.height);
122
+ const width = image.fit === "fill" ? spec.width : loaded.width * scale;
123
+ const height = image.fit === "fill" ? spec.height : loaded.height * scale;
124
+ ctx.save();
125
+ ctx.globalAlpha = image.opacity;
126
+ ctx.drawImage(loaded, (spec.width - width) * image.position.x / 100, (spec.height - height) * image.position.y / 100, width, height);
127
+ ctx.restore();
128
+ };
129
+ if (coverTheme) {
130
+ const fill = coverTheme.background.fill;
131
+ ctx.save();
132
+ if (fill.type === "solid")
133
+ ctx.fillStyle = fill.color;
134
+ else {
135
+ let gradient;
136
+ if (fill.type === "linear") {
137
+ const angle = fill.angle * Math.PI / 180;
138
+ const dx = Math.cos(angle) * spec.width / 2;
139
+ const dy = Math.sin(angle) * spec.height / 2;
140
+ gradient = ctx.createLinearGradient(spec.width / 2 - dx, spec.height / 2 - dy, spec.width / 2 + dx, spec.height / 2 + dy);
141
+ } else {
142
+ const x = spec.width * fill.position.x / 100;
143
+ const y = spec.height * fill.position.y / 100;
144
+ gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.hypot(spec.width, spec.height));
145
+ }
146
+ for (const stop of fill.stops)
147
+ gradient.addColorStop(stop.offset / 100, stop.color);
148
+ ctx.fillStyle = gradient;
149
+ }
150
+ ctx.fillRect(0, 0, spec.width, spec.height);
151
+ ctx.restore();
152
+ await drawCoverImage(coverTheme.background.image);
153
+ if (coverTheme.background.overlay) {
154
+ ctx.save();
155
+ ctx.fillStyle = coverTheme.background.overlay.color;
156
+ ctx.globalAlpha = coverTheme.background.overlay.opacity;
157
+ ctx.fillRect(0, 0, spec.width, spec.height);
158
+ ctx.restore();
159
+ }
160
+ const decoration = coverTheme.decoration;
161
+ ctx.save();
162
+ ctx.strokeStyle = ctx.fillStyle = decoration.color;
163
+ ctx.globalAlpha = decoration.opacity;
164
+ const size = Math.max(8, decoration.size);
165
+ ctx.beginPath();
166
+ if (["lines", "grid", "dots"].includes(decoration.pattern)) {
167
+ for (let x = 0;x < spec.width; x += size) {
168
+ if (decoration.pattern === "grid") {
169
+ ctx.moveTo(x, 0);
170
+ ctx.lineTo(x, spec.height);
171
+ }
172
+ if (decoration.pattern === "dots") {
173
+ for (let y = 0;y < spec.height; y += size) {
174
+ ctx.moveTo(x + 2, y);
175
+ ctx.arc(x, y, 2, 0, Math.PI * 2);
176
+ }
177
+ }
178
+ }
179
+ if (decoration.pattern !== "dots") {
180
+ for (let y = 0;y < spec.height; y += size) {
181
+ ctx.moveTo(0, y);
182
+ ctx.lineTo(spec.width, y);
183
+ }
184
+ }
185
+ } else if (decoration.pattern === "rings") {
186
+ for (let radius = size;radius < Math.hypot(spec.width, spec.height); radius += size) {
187
+ ctx.moveTo(spec.width / 2 + radius, spec.height / 2);
188
+ ctx.arc(spec.width / 2, spec.height / 2, radius, 0, Math.PI * 2);
189
+ }
190
+ } else if (decoration.pattern === "burst") {
191
+ for (let angle = 0;angle < Math.PI * 2; angle += Math.PI / Math.max(8, Math.round(size / 3))) {
192
+ ctx.moveTo(spec.width / 2, spec.height / 2);
193
+ ctx.lineTo(spec.width / 2 + Math.cos(angle) * spec.width, spec.height / 2 + Math.sin(angle) * spec.height);
194
+ }
195
+ }
196
+ if (decoration.pattern === "dots")
197
+ ctx.fill();
198
+ else
199
+ ctx.stroke();
200
+ ctx.restore();
201
+ await drawCoverImage(decoration.image);
202
+ }
203
+ const { stageX, stageY } = computeStageDimensions(spec, algorithm.safeArea, algorithm.contentBottomGap);
204
+ const isCover = config.aspectRatio === "wechat-cover-2.35";
205
+ const showHeader = chrome?.showHeader ?? !isCover;
206
+ const showWatermark = (chrome?.showWatermark ?? !isCover) && Boolean(config.watermarkText?.trim());
207
+ const showPageNum = (chrome?.showPageNumber ?? (!isCover && totalPageCount > 1)) && totalPageCount > 1;
208
+ if (showHeader && (showWatermark || showPageNum)) {
209
+ const headerY = Math.max(24, Math.floor(algorithm.safeArea.top * 0.45));
210
+ if (showWatermark) {
211
+ ctx.save();
212
+ ctx.font = resolveCanvasFont("600 14px PingFang SC", isServer);
213
+ ctx.fillStyle = theme.watermarkColor;
214
+ ctx.globalAlpha = 0.45;
215
+ ctx.textBaseline = "middle";
216
+ ctx.textAlign = "left";
217
+ ctx.fillText(config.watermarkText.toUpperCase(), algorithm.safeArea.left, headerY);
218
+ ctx.restore();
219
+ }
220
+ if (showPageNum) {
221
+ ctx.save();
222
+ const pageText = `P.${String(page.pageNumber).padStart(2, "0")}`;
223
+ ctx.font = '500 12px "SF Mono", Monaco, Menlo, Consolas, monospace';
224
+ ctx.textBaseline = "middle";
225
+ ctx.textAlign = "left";
226
+ const metrics = ctx.measureText(pageText);
227
+ const pillW = metrics.width + 16;
228
+ const pillH = 24;
229
+ const pillX = spec.width - algorithm.safeArea.right - pillW;
230
+ const pillY = headerY - pillH / 2;
231
+ ctx.strokeStyle = theme.textColor;
232
+ ctx.globalAlpha = 0.4;
233
+ ctx.lineWidth = 1;
234
+ ctx.beginPath();
235
+ ctx.roundRect(pillX, pillY, pillW, pillH, 12);
236
+ ctx.stroke();
237
+ ctx.fillStyle = theme.textColor;
238
+ ctx.fillText(pageText, pillX + 8, headerY);
239
+ ctx.restore();
240
+ }
241
+ }
242
+ const sortedImages = [...page.images || []].sort((a, b) => {
243
+ const za = a.zIndex ?? (a.isAbsolute ? 20 : 5);
244
+ const zb = b.zIndex ?? (b.isAbsolute ? 20 : 5);
245
+ return za - zb;
246
+ });
247
+ const drawImages = async (images) => {
248
+ for (const img of images) {
249
+ let loadedImg;
250
+ try {
251
+ loadedImg = await getImage(img.src);
252
+ } catch (error) {
253
+ if (img.templateAssetKey)
254
+ throw new Error(`模板素材 ${img.templateAssetKey} 加载失败:${error instanceof Error ? error.message : String(error)}`);
255
+ throw error;
256
+ }
257
+ if (!loadedImg && img.templateAssetKey)
258
+ throw new Error(`模板素材 ${img.templateAssetKey} 加载失败:图片不存在或无法读取`);
259
+ const imgX = stageX + img.x;
260
+ const imgY = stageY + img.y;
261
+ const centerX = imgX + img.width / 2;
262
+ const centerY = imgY + img.height / 2;
263
+ ctx.save();
264
+ ctx.translate(centerX, centerY);
265
+ if (img.rotate) {
266
+ ctx.rotate(img.rotate * Math.PI / 180);
267
+ }
268
+ ctx.globalAlpha = img.opacity ?? 1;
269
+ const w = img.width;
270
+ const h = img.height;
271
+ const radius = Math.min(img.borderRadius ?? 12, w / 2, h / 2);
272
+ ctx.beginPath();
273
+ ctx.roundRect(-w / 2, -h / 2, w, h, radius);
274
+ ctx.clip();
275
+ if (loadedImg) {
276
+ ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
277
+ } else {
278
+ ctx.fillStyle = theme.cardBg || "#222222";
279
+ ctx.fillRect(-w / 2, -h / 2, w, h);
280
+ }
281
+ ctx.restore();
282
+ if (img.caption?.trim()) {
283
+ ctx.save();
284
+ const capFontSize = Math.max(14, Math.round(algorithm.baseFontSize * 0.45));
285
+ ctx.font = resolveCanvasFont(`400 ${capFontSize}px PingFang SC`, isServer);
286
+ ctx.fillStyle = theme.captionColor;
287
+ ctx.textBaseline = "top";
288
+ ctx.textAlign = "center";
289
+ ctx.fillText(img.caption, centerX, imgY + h + 12);
290
+ ctx.restore();
291
+ }
292
+ }
293
+ };
294
+ await drawImages(sortedImages.filter((image) => (image.zIndex ?? (image.isAbsolute ? 20 : 5)) < 10));
295
+ for (const line of page.lines) {
296
+ const lineX = stageX + line.x;
297
+ const lineY = stageY + line.y;
298
+ if (line.className === "body-quote") {
299
+ ctx.save();
300
+ ctx.fillStyle = theme.accentColor;
301
+ ctx.globalAlpha = 0.85;
302
+ ctx.beginPath();
303
+ const barHeight = Math.max(1, line.lineHeight - 8);
304
+ if (typeof ctx.roundRect === "function") {
305
+ ctx.roundRect(stageX, lineY + 4, 3.5, barHeight, 2);
306
+ } else {
307
+ ctx.rect(stageX, lineY + 4, 3.5, barHeight);
308
+ }
309
+ ctx.fill();
310
+ ctx.restore();
311
+ }
312
+ if (line.bullet && line.bulletX !== undefined) {
313
+ ctx.save();
314
+ ctx.fillStyle = theme.accentColor;
315
+ if (line.bullet === "•") {
316
+ const bulletRadius = Math.max(3, Math.round(algorithm.baseFontSize * 0.08));
317
+ const bulletCenterX = stageX + line.bulletX + 8;
318
+ const bulletCenterY = lineY + Math.round(line.lineHeight * 0.42);
319
+ ctx.beginPath();
320
+ ctx.arc(bulletCenterX, bulletCenterY, bulletRadius, 0, Math.PI * 2);
321
+ ctx.fill();
322
+ } else {
323
+ ctx.font = resolveCanvasFont(line.font, isServer);
324
+ ctx.textBaseline = "top";
325
+ ctx.textAlign = "left";
326
+ ctx.fillText(line.bullet, stageX + line.bulletX, lineY);
327
+ }
328
+ ctx.restore();
329
+ }
330
+ let cursorX = lineX;
331
+ for (const fragment of line.fragments) {
332
+ const rawFont = fragment.font || line.font;
333
+ const fragFont = resolveCanvasFont(rawFont, isServer);
334
+ ctx.font = fragFont;
335
+ ctx.textBaseline = "top";
336
+ ctx.textAlign = "left";
337
+ if (fragment.highlight) {
338
+ ctx.fillStyle = theme.accentColor;
339
+ } else if (line.className.includes("heading")) {
340
+ ctx.fillStyle = theme.headingColor || theme.textColor;
341
+ } else if (line.className.includes("quote")) {
342
+ ctx.fillStyle = theme.quoteColor || theme.textColor;
343
+ } else {
344
+ ctx.fillStyle = theme.textColor;
345
+ }
346
+ ctx.letterSpacing = `${fragment.letterSpacing || 0}px`;
347
+ ctx.fillText(fragment.text, cursorX, lineY);
348
+ const measuredWidth = fragment.letterSpacing ? fragment.occupiedWidth : ctx.measureText(fragment.text).width;
349
+ cursorX += measuredWidth + (fragment.gapBefore || 0);
350
+ }
351
+ }
352
+ await drawImages(sortedImages.filter((image) => (image.zIndex ?? (image.isAbsolute ? 20 : 5)) >= 10));
353
+ ctx.letterSpacing = "0px";
354
+ const showFooter = chrome?.showFooter ?? !isCover;
355
+ const showFooterDivider = chrome?.showFooterDivider ?? (!isCover && Boolean(config.footerText?.trim()));
356
+ const showFooterText = (chrome?.showFooterText ?? !isCover) && Boolean(config.footerText?.trim());
357
+ if (showFooter) {
358
+ const footerY = spec.height - Math.max(24, Math.floor(algorithm.safeArea.bottom * 0.45));
359
+ if (showFooterDivider) {
360
+ ctx.save();
361
+ ctx.strokeStyle = theme.borderSubtle || "#e5e5e5";
362
+ ctx.lineWidth = 1;
363
+ ctx.beginPath();
364
+ ctx.moveTo(algorithm.safeArea.left, footerY - 14);
365
+ ctx.lineTo(spec.width - algorithm.safeArea.right, footerY - 14);
366
+ ctx.stroke();
367
+ ctx.restore();
368
+ }
369
+ if (chrome?.showFillRatio) {
370
+ ctx.save();
371
+ ctx.font = resolveCanvasFont("500 13px PingFang SC", isServer);
372
+ ctx.fillStyle = theme.captionColor;
373
+ ctx.textBaseline = "middle";
374
+ ctx.textAlign = "right";
375
+ ctx.fillText(`填充率 ${Math.round(page.fillRatio * 100)}%`, spec.width - algorithm.safeArea.right, footerY);
376
+ ctx.restore();
377
+ }
378
+ if (showFooterText) {
379
+ ctx.save();
380
+ const font = coverTheme?.typography.footer;
381
+ ctx.font = resolveCanvasFont(font ? `${font.fontWeight} ${font.fontSize}px "${font.fontFamily}"` : "500 13px PingFang SC", isServer);
382
+ ctx.letterSpacing = `${font?.letterSpacing || 0}px`;
383
+ ctx.fillStyle = theme.watermarkColor || theme.captionColor;
384
+ ctx.globalAlpha = 0.6;
385
+ ctx.textBaseline = "middle";
386
+ ctx.textAlign = "left";
387
+ ctx.fillText(config.footerText, algorithm.safeArea.left, footerY);
388
+ ctx.restore();
389
+ }
390
+ }
391
+ }
392
+
393
+ // src/core/data-dir.ts
394
+ import { existsSync } from "node:fs";
395
+ import { dirname, resolve } from "node:path";
396
+ import { fileURLToPath } from "node:url";
397
+ function resolveDataDir() {
398
+ if (process.env.TYPESET_DATA_DIR)
399
+ return resolve(process.env.TYPESET_DATA_DIR);
400
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
401
+ const packageData = resolve(moduleDir, "../../data");
402
+ if (existsSync(packageData))
403
+ return packageData;
404
+ const cwdData = resolve(process.cwd(), "data");
405
+ return existsSync(cwdData) ? cwdData : packageData;
406
+ }
407
+
408
+ // src/core/canvas-renderer.ts
409
+ registerServerFonts();
410
+ async function safeLoadImage(src) {
411
+ if (!src)
412
+ return null;
413
+ try {
414
+ if (src.startsWith("/uploads/") || src.startsWith("uploads/")) {
415
+ const localPath = join(resolveDataDir(), src.replace(/^\//, ""));
416
+ if (existsSync2(localPath)) {
417
+ return await loadImage(localPath);
418
+ }
419
+ }
420
+ if (isAbsolute(src) && existsSync2(src)) {
421
+ return await loadImage(src);
422
+ }
423
+ return await loadImage(src);
424
+ } catch (err) {
425
+ console.warn(`[canvas-renderer] Warning: Failed to load image "${src}":`, err);
426
+ return null;
427
+ }
428
+ }
429
+ async function renderPageToCanvas(page, spec, config, totalPageCount = 1) {
430
+ const canvas = createCanvas(spec.width, spec.height);
431
+ const ctx = canvas.getContext("2d");
432
+ await drawPageToContext(ctx, {
433
+ page,
434
+ spec,
435
+ config,
436
+ totalPageCount,
437
+ getImage: safeLoadImage,
438
+ isServer: true
439
+ });
440
+ return canvas;
441
+ }
442
+ async function renderPageToPng(page, spec, config, totalPageCount = 1) {
443
+ const canvas = await renderPageToCanvas(page, spec, config, totalPageCount);
444
+ return canvas.toBuffer("image/png");
445
+ }
446
+ async function renderTypesetResultToPngs(result) {
447
+ const { pages, canvasSpec, config, pageCount } = result;
448
+ const outputs = [];
449
+ for (const page of pages) {
450
+ const buffer = await renderPageToPng(page, canvasSpec, config, pageCount);
451
+ outputs.push({
452
+ pageNumber: page.pageNumber,
453
+ buffer
454
+ });
455
+ }
456
+ return outputs;
457
+ }
458
+
459
+ export { getCanvasSpec, computeStageDimensions, resolveCanvasFontFamily, resolveCanvasFont, drawPageToContext, resolveDataDir, renderPageToCanvas, renderPageToPng, renderTypesetResultToPngs };