@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,23 @@
1
+ import {
2
+ drawPageToContext,
3
+ renderPageToCanvas,
4
+ renderPageToPng,
5
+ renderTypesetResultToPngs,
6
+ resolveCanvasFont,
7
+ resolveCanvasFontFamily
8
+ } from "./index-zdhqg5f0.js";
9
+ import {
10
+ registerServerFonts,
11
+ scanLocalFonts
12
+ } from "./index-6c43c7h4.js";
13
+ import"./index-37x76zdn.js";
14
+ export {
15
+ scanLocalFonts,
16
+ resolveCanvasFontFamily,
17
+ resolveCanvasFont,
18
+ renderTypesetResultToPngs,
19
+ renderPageToPng,
20
+ renderPageToCanvas,
21
+ registerServerFonts,
22
+ drawPageToContext
23
+ };
@@ -0,0 +1,11 @@
1
+ import {
2
+ readFontFamilyName,
3
+ registerServerFonts,
4
+ scanLocalFonts
5
+ } from "./index-6c43c7h4.js";
6
+ import"./index-37x76zdn.js";
7
+ export {
8
+ scanLocalFonts,
9
+ registerServerFonts,
10
+ readFontFamilyName
11
+ };
@@ -0,0 +1,303 @@
1
+ import {
2
+ __require
3
+ } from "./index-37x76zdn.js";
4
+
5
+ // src/adapter.ts
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { extname, join } from "node:path";
8
+ function cleanTitle(raw) {
9
+ if (!raw)
10
+ return "未命名文章";
11
+ return raw.replace(/^#+\s*/, "").trim();
12
+ }
13
+ function mapBodyImages(bodyImages, pageSpecs) {
14
+ if (!bodyImages || bodyImages.length === 0)
15
+ return [];
16
+ const markerToSpec = new Map;
17
+ if (pageSpecs) {
18
+ for (const spec of pageSpecs) {
19
+ if (spec.imageMarkers) {
20
+ for (const marker of spec.imageMarkers) {
21
+ markerToSpec.set(marker, { page: spec.page, imageLayout: spec.imageLayout });
22
+ }
23
+ }
24
+ }
25
+ }
26
+ return bodyImages.map((img, idx) => {
27
+ const spec = markerToSpec.get(img.marker);
28
+ return {
29
+ id: `img_${idx}_${img.marker.replace(/[^a-zA-Z0-9_-]/g, "_")}`,
30
+ src: img.path,
31
+ alt: img.marker,
32
+ pageIndex: spec?.page,
33
+ layoutPreset: spec?.imageLayout || "mid-right"
34
+ };
35
+ });
36
+ }
37
+ var remoteStudioUrl = () => process.env.TYPESET_STUDIO_URL?.replace(/\/$/, "");
38
+ async function renderRemoteBatch(studioUrl, body, outputDir, route, kind) {
39
+ const sources = new Map;
40
+ const originalImages = new Map([...body.config?.images || [], ...body.images || []].map((image) => [image.id, image.src]));
41
+ const transfer = async (src) => {
42
+ if (/^(https?:|data:)/i.test(src) || src.startsWith("/uploads/"))
43
+ return src;
44
+ const bytes = await readFile(src);
45
+ const mime = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".gif": "image/gif" }[extname(src).toLowerCase()] || "image/png";
46
+ const dataUrl = `data:${mime};base64,${bytes.toString("base64")}`;
47
+ sources.set(dataUrl, src);
48
+ return dataUrl;
49
+ };
50
+ const requestBody = structuredClone(body);
51
+ for (const image of requestBody.images || [])
52
+ image.src = await transfer(image.src);
53
+ if (!requestBody.preserveTemplateStyle) {
54
+ for (const image of requestBody.config?.images || [])
55
+ image.src = await transfer(image.src);
56
+ }
57
+ for (const [key, src] of Object.entries(requestBody.templateImageOverrides || {})) {
58
+ try {
59
+ requestBody.templateImageOverrides[key] = await transfer(src);
60
+ } catch (error) {
61
+ throw new Error(`模板素材 ${key} 加载失败:${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ }
64
+ const coverTheme = requestBody.config?.coverTheme;
65
+ for (const image of requestBody.preserveTemplateStyle ? [] : [coverTheme?.background.image, coverTheme?.decoration.image]) {
66
+ if (image)
67
+ image.src = await transfer(image.src);
68
+ }
69
+ const res = await fetch(`${studioUrl}/api/render/export`, {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json", "X-Admin-Key": process.env.TYPESET_ADMIN_KEY || "" },
72
+ body: JSON.stringify(requestBody)
73
+ });
74
+ if (!res.ok) {
75
+ const detail = (await res.text()).slice(0, 500);
76
+ throw new Error(`Cloud Typeset Studio returned HTTP ${res.status}: ${detail || res.statusText}`);
77
+ }
78
+ const data = await res.json();
79
+ const assets = [];
80
+ const pagesSummary = [];
81
+ if (!data.ok || !Array.isArray(data.pages))
82
+ throw new Error("Invalid Typeset Studio export response");
83
+ for (const pageItem of kind === "cover" ? data.pages.slice(0, 1) : data.pages) {
84
+ const pageNum = pageItem.pageNumber;
85
+ const base64Data = pageItem.dataUrl.replace(/^data:image\/png;base64,/, "");
86
+ const buffer = Buffer.from(base64Data, "base64");
87
+ const filename = kind === "cover" ? "cover.png" : `article-${String(pageNum).padStart(2, "0")}.png`;
88
+ const filePath = join(outputDir, filename);
89
+ await writeFile(filePath, buffer);
90
+ assets.push({
91
+ kind,
92
+ route,
93
+ index: kind === "page" ? pageNum : undefined,
94
+ path: filePath
95
+ });
96
+ pagesSummary.push({
97
+ page: pageNum,
98
+ imageCount: pageItem.bodyImageCount,
99
+ imageSources: pageItem.bodyImageSources.map((src, index) => originalImages.get(pageItem.bodyImageIds[index]) || sources.get(src) || src)
100
+ });
101
+ }
102
+ return { assets, pageCount: assets.length, pages: pagesSummary };
103
+ }
104
+ async function renderLocalBatch(body, outputDir, route, kind) {
105
+ const [{ resolveTypesetResult }, { renderTypesetResultToPngs }] = await Promise.all([
106
+ import("./render-xxd348m1.js"),
107
+ import("./canvas-renderer-cxnar75n.js")
108
+ ]);
109
+ const { result } = resolveTypesetResult(body);
110
+ const pngs = await renderTypesetResultToPngs(result);
111
+ const outputs = kind === "cover" ? pngs.slice(0, 1) : pngs;
112
+ if (kind === "cover" && outputs.length === 0) {
113
+ throw new Error("Typeset Studio failed to render cover buffer");
114
+ }
115
+ const assets = [];
116
+ const pages = [];
117
+ for (const [index, output] of outputs.entries()) {
118
+ const pageNumber = index + 1;
119
+ const filename = kind === "cover" ? "cover.png" : `article-${String(pageNumber).padStart(2, "0")}.png`;
120
+ const path = join(outputDir, filename);
121
+ await writeFile(path, output.buffer);
122
+ assets.push({ kind, route, path, ...kind === "page" ? { index: pageNumber } : {} });
123
+ const images = result.pages[index].images.filter((image) => !image.templateAssetKey);
124
+ pages.push({
125
+ page: pageNumber,
126
+ imageCount: images.length,
127
+ imageSources: images.map((image) => image.src)
128
+ });
129
+ }
130
+ return { assets, pageCount: outputs.length, pages };
131
+ }
132
+ var typesetStudioAdapter = {
133
+ name: "typeset-studio",
134
+ version: "0.2.2",
135
+ async doctor() {
136
+ const checks = [];
137
+ const REMOTE_STUDIO_URL = remoteStudioUrl();
138
+ if (REMOTE_STUDIO_URL) {
139
+ try {
140
+ const res = await fetch(`${REMOTE_STUDIO_URL}/api/auth/verify`, {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({ key: process.env.TYPESET_ADMIN_KEY || "" })
144
+ });
145
+ const data = await res.json();
146
+ checks.push({
147
+ name: "remote-service",
148
+ ok: res.ok && data.ok === true,
149
+ message: data.ok ? `Authenticated with Typeset Studio at ${REMOTE_STUDIO_URL}` : "Remote admin key is missing or invalid"
150
+ });
151
+ } catch (err) {
152
+ checks.push({
153
+ name: "remote-service",
154
+ ok: false,
155
+ message: `Failed to connect to Cloud Typeset Studio at ${REMOTE_STUDIO_URL}: ${err}`
156
+ });
157
+ }
158
+ }
159
+ try {
160
+ const { createCanvas } = await import("@napi-rs/canvas");
161
+ const canvas = createCanvas(10, 10);
162
+ const ctx = canvas.getContext("2d");
163
+ ctx.fillStyle = "#ffffff";
164
+ ctx.fillRect(0, 0, 10, 10);
165
+ checks.push({
166
+ name: "@napi-rs/canvas",
167
+ ok: true,
168
+ message: "Native Skia Canvas 2D engine is ready"
169
+ });
170
+ } catch (err) {
171
+ checks.push({
172
+ name: "@napi-rs/canvas",
173
+ ok: Boolean(REMOTE_STUDIO_URL),
174
+ message: REMOTE_STUDIO_URL ? "@napi-rs/canvas not installed locally (using Cloud Typeset Studio microservice)" : `@napi-rs/canvas initialization failed: ${err}`
175
+ });
176
+ }
177
+ try {
178
+ const { scanLocalFonts } = await import("./font-loader-j28mt43t.js");
179
+ const localFonts = scanLocalFonts();
180
+ checks.push({
181
+ name: "local-fonts",
182
+ ok: true,
183
+ message: `Registered ${localFonts.length} project-local font(s)`
184
+ });
185
+ } catch (err) {
186
+ checks.push({
187
+ name: "local-fonts",
188
+ ok: false,
189
+ message: `Font loader error: ${err}`
190
+ });
191
+ }
192
+ return checks;
193
+ },
194
+ async render(input) {
195
+ const { route, outputDir, bodyText, state } = input;
196
+ await mkdir(outputDir, { recursive: true });
197
+ if (route === "blog")
198
+ return { assets: [], pageCount: 0, pages: [] };
199
+ const REMOTE_STUDIO_URL = remoteStudioUrl();
200
+ const account = state.route.account || "default";
201
+ const saved = state.intent?.newspic_render;
202
+ const pageSpecs = input.pageSpecs ?? saved?.page_specs?.map((spec) => ({
203
+ page: spec.page,
204
+ imageMarkers: spec.image_markers,
205
+ imageLayout: spec.image_layout,
206
+ targetFillRatio: spec.target_fill_ratio
207
+ }));
208
+ const visual = input.accountVisualParams || {};
209
+ const stateVisual = state.route.account_visual_params;
210
+ const footer = visual.footer ?? stateVisual?.footer;
211
+ const bg = visual.bg ?? stateVisual?.bg;
212
+ const highlight = visual.highlight ?? stateVisual?.highlight;
213
+ const fallbackIcon = visual.fallbackIcon ?? stateVisual?.fallback_icon;
214
+ const template = ["wechat-cover-split", "poster-3-4", "longform-3-4"].includes(input.template || "") ? undefined : input.template;
215
+ input.onProgress?.({ stage: "typeset", message: "开始排版", route, account });
216
+ const title = cleanTitle(input.title || state.metadata?.title || "");
217
+ const common = {
218
+ account,
219
+ title,
220
+ preserveTemplateStyle: true,
221
+ images: mapBodyImages(input.bodyImages, pageSpecs),
222
+ highlightWords: state.route.highlight_words || [],
223
+ config: {
224
+ ...footer !== undefined ? { footerText: footer } : {},
225
+ watermarkText: `@${account}`,
226
+ ...bg || highlight ? { theme: {
227
+ ...bg ? { bgColor: bg } : {},
228
+ ...highlight ? { accentColor: highlight } : {}
229
+ } } : {}
230
+ }
231
+ };
232
+ const articleCover = route === "wechat-article";
233
+ const renderRoute = articleCover ? "wechat-article" : "wechat-newspic";
234
+ const renderBatch = (body, kind) => REMOTE_STUDIO_URL && (route === "wechat-article" || route === "wechat-newspic") ? renderRemoteBatch(REMOTE_STUDIO_URL, body, outputDir, renderRoute, kind) : renderLocalBatch(body, outputDir, renderRoute, kind);
235
+ const coverTheme = input.coverTheme;
236
+ const coverConfig = { ...common.config, algorithm: { targetFillRatio: 1 } };
237
+ if (coverTheme) {
238
+ coverConfig.coverTheme = coverTheme;
239
+ if (footer)
240
+ coverConfig.chrome = { showHeader: false, showFooter: true, showFooterDivider: false, showFooterText: true };
241
+ coverConfig.algorithm = { ...coverTheme.layout, targetFillRatio: 1 };
242
+ coverConfig.theme = {
243
+ bgColor: coverTheme.background.fill.type === "solid" ? coverTheme.background.fill.color : "transparent",
244
+ textColor: coverTheme.colors.text,
245
+ headingColor: coverTheme.colors.text,
246
+ accentColor: coverTheme.colors.accent,
247
+ quoteColor: coverTheme.colors.muted,
248
+ captionColor: coverTheme.colors.muted,
249
+ watermarkColor: coverTheme.colors.muted
250
+ };
251
+ }
252
+ const firstPageImages = (common.images || []).filter((image) => !image.pageIndex || image.pageIndex === 1).map((image) => ({ ...image, pageIndex: 1 }));
253
+ if (fallbackIcon)
254
+ coverConfig.images = [{
255
+ id: "account_icon",
256
+ src: fallbackIcon,
257
+ alt: "账号标识",
258
+ isAbsolute: true,
259
+ x: 0,
260
+ y: 0,
261
+ width: 48,
262
+ height: 48,
263
+ zIndex: 20,
264
+ pageIndex: 1,
265
+ templateAssetKey: "account_icon"
266
+ }];
267
+ const cover = await renderBatch({
268
+ ...common,
269
+ config: coverConfig,
270
+ images: firstPageImages,
271
+ template: input.studioTemplates?.cover || template,
272
+ templateImageOverrides: input.templateImageOverrides?.cover,
273
+ route: renderRoute,
274
+ slot: articleCover ? "article_cover" : "newspic_cover",
275
+ kind: "cover",
276
+ maxPages: 1,
277
+ markdown: `# ${title}`
278
+ }, "cover");
279
+ input.onProgress?.({ stage: "cover", message: "封面已生成", current: 1, total: 1, unit: "pages", route, account });
280
+ const paginationMode = input.template === "longform-3-4" ? "multi" : input.template === "poster-3-4" ? "single" : state.intent?.newspic_render?.pagination_mode || "multi";
281
+ if (articleCover || paginationMode !== "multi")
282
+ return cover;
283
+ const content = await renderBatch({
284
+ ...common,
285
+ route: "wechat-newspic",
286
+ template: input.studioTemplates?.content || template,
287
+ templateImageOverrides: input.templateImageOverrides?.content,
288
+ kind: "content",
289
+ slot: "newspic_content",
290
+ markdown: bodyText || `# ${title}`,
291
+ config: { ...common.config, ...saved?.target_fill_ratio != null ? { algorithm: { targetFillRatio: saved.target_fill_ratio } } : {} },
292
+ minPages: input.minPages ?? saved?.min_pages,
293
+ maxPages: input.maxPages ?? saved?.max_pages,
294
+ pageFillRatios: Object.fromEntries((pageSpecs || []).filter((spec) => spec.targetFillRatio != null).map((spec) => [spec.page, spec.targetFillRatio]))
295
+ }, "page");
296
+ input.onProgress?.({ stage: "content", message: "正文已生成", current: content.pageCount, total: content.pageCount, unit: "pages", route, account });
297
+ return { ...content, assets: [...cover.assets, ...content.assets] };
298
+ }
299
+ };
300
+ var plugin = typesetStudioAdapter;
301
+ var adapter_default = typesetStudioAdapter;
302
+
303
+ export { typesetStudioAdapter, plugin, adapter_default };
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
+
4
+ export { __require };