@akairos/typeset-studio 0.2.8 → 0.3.1

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 (34) hide show
  1. package/README.md +20 -0
  2. package/dist/assets/{index-Dn5YtK3W.js → index-CrJAVx3W.js} +17 -17
  3. package/dist/cover/cover.js +124 -0
  4. package/dist/editor/editor.js +1 -0
  5. package/dist/index.html +1 -1
  6. package/dist/lib/adapter.js +2 -1
  7. package/dist/lib/{canvas-renderer-wce6xgb2.js → canvas-renderer-hpe2bqkt.js} +3 -2
  8. package/dist/lib/{font-loader-j28mt43t.js → font-loader-cft81crk.js} +2 -1
  9. package/dist/lib/index-2vgqm8ms.js +167 -0
  10. package/dist/lib/{index-qszn98j2.js → index-3ptjkctd.js} +14 -2
  11. package/dist/lib/{index-y2tk7642.js → index-51d5rpf7.js} +11 -5
  12. package/dist/lib/index-94yttj49.js +34 -0
  13. package/dist/lib/{index-697fkv5y.js → index-dz9pmgbk.js} +12 -2
  14. package/dist/lib/index-nwch18wp.js +18 -0
  15. package/dist/lib/{index-6c43c7h4.js → index-xg6pyfw6.js} +1 -163
  16. package/dist/lib/index.js +7 -4
  17. package/dist/lib/render-epbcmzxe.js +14 -0
  18. package/dist/lib/server-font.js +9 -0
  19. package/package.json +16 -2
  20. package/src/adapter.test.ts +41 -0
  21. package/src/adapter.ts +5 -2
  22. package/src/core/article-cover.test.ts +47 -0
  23. package/src/core/article-cover.ts +97 -0
  24. package/src/core/canvas-draw.ts +9 -1
  25. package/src/core/types.ts +1 -0
  26. package/src/cover.ts +10 -0
  27. package/src/server/index.ts +1 -1
  28. package/src/server/routes/render.ts +9 -0
  29. package/src/server-font.test.ts +46 -0
  30. package/src/server-font.ts +23 -0
  31. package/src/web/components/CoverControls.tsx +22 -0
  32. package/src/web/components/Header.tsx +1 -1
  33. package/src/web/editor.ts +1 -0
  34. package/dist/lib/render-064weavx.js +0 -11
package/dist/lib/index.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  plugin,
3
3
  typesetStudioAdapter
4
- } from "./index-y2tk7642.js";
4
+ } from "./index-51d5rpf7.js";
5
5
  import {
6
6
  applyTemplateImages,
7
7
  getDefaultConfig,
8
8
  resolveTypesetResult,
9
9
  runTypeset,
10
10
  validateTemplateAssets
11
- } from "./index-qszn98j2.js";
11
+ } from "./index-3ptjkctd.js";
12
+ import"./index-94yttj49.js";
13
+ import"./index-nwch18wp.js";
12
14
  import {
13
15
  drawPageToContext,
14
16
  renderPageToCanvas,
@@ -16,12 +18,13 @@ import {
16
18
  renderTypesetResultToPngs,
17
19
  resolveCanvasFont,
18
20
  resolveCanvasFontFamily
19
- } from "./index-697fkv5y.js";
21
+ } from "./index-dz9pmgbk.js";
20
22
  import {
21
23
  readFontFamilyName,
22
24
  registerServerFonts,
23
25
  scanLocalFonts
24
- } from "./index-6c43c7h4.js";
26
+ } from "./index-2vgqm8ms.js";
27
+ import"./index-xg6pyfw6.js";
25
28
  import"./index-37x76zdn.js";
26
29
  export {
27
30
  validateTemplateAssets,
@@ -0,0 +1,14 @@
1
+ import {
2
+ renderRouter,
3
+ resolveTypesetResult
4
+ } from "./index-3ptjkctd.js";
5
+ import"./index-94yttj49.js";
6
+ import"./index-nwch18wp.js";
7
+ import"./index-dz9pmgbk.js";
8
+ import"./index-2vgqm8ms.js";
9
+ import"./index-xg6pyfw6.js";
10
+ import"./index-37x76zdn.js";
11
+ export {
12
+ resolveTypesetResult,
13
+ renderRouter
14
+ };
@@ -0,0 +1,9 @@
1
+ import {
2
+ registerArticleCoverFont
3
+ } from "./index-94yttj49.js";
4
+ import"./index-nwch18wp.js";
5
+ import"./index-xg6pyfw6.js";
6
+ import"./index-37x76zdn.js";
7
+ export {
8
+ registerArticleCoverFont
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akairos/typeset-studio",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "Algorithmic typesetting studio & zzhub-pipeline image renderer plugin",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -16,6 +16,18 @@
16
16
  "module": "./dist/lib/index.js",
17
17
  "types": "./src/index.ts",
18
18
  "exports": {
19
+ "./cover": {
20
+ "types": "./src/cover.ts",
21
+ "import": "./dist/cover/cover.js"
22
+ },
23
+ "./editor": {
24
+ "types": "./src/web/editor.ts",
25
+ "import": "./dist/editor/editor.js"
26
+ },
27
+ "./server-font": {
28
+ "types": "./src/server-font.ts",
29
+ "import": "./dist/lib/server-font.js"
30
+ },
19
31
  ".": {
20
32
  "import": "./dist/lib/index.js",
21
33
  "types": "./src/index.ts",
@@ -31,6 +43,8 @@
31
43
  "dist/assets",
32
44
  "dist/index.html",
33
45
  "dist/lib",
46
+ "dist/editor",
47
+ "dist/cover",
34
48
  "src",
35
49
  "public/fonts/README.md",
36
50
  "public/fonts/fonts.json",
@@ -41,7 +55,7 @@
41
55
  "dev:server": "bun --watch src/server/index.ts",
42
56
  "dev:all": "bun --watch src/server/index.ts & vite",
43
57
  "build": "vite build && bun run build:lib",
44
- "build:lib": "rm -rf dist/lib && bun build src/index.ts src/adapter.ts --outdir dist/lib --target node --splitting --external @napi-rs/canvas",
58
+ "build:lib": "rm -rf dist/lib dist/editor dist/cover && bun build src/index.ts src/adapter.ts src/server-font.ts --outdir dist/lib --target node --splitting --external @napi-rs/canvas && bun build src/cover.ts --outdir dist/cover --target browser && bun build src/web/editor.ts --outdir dist/editor --target browser --production --external react --external react/jsx-runtime --external react/jsx-dev-runtime",
45
59
  "build:watch": "vite build --watch",
46
60
  "preview": "vite preview",
47
61
  "server": "bun run src/server/index.ts",
@@ -2,6 +2,47 @@ import { afterAll, describe, expect, it } from "bun:test";
2
2
  import { existsSync, readFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { typesetStudioAdapter, type ImageRenderInput } from "./adapter";
5
+ import { normalizeArticleCoverTitle } from "./cover";
6
+
7
+ it("uses preview title normalization only for trusted cover snapshots and rejects empty normalized titles", async () => {
8
+ const { mkdtemp, rm } = await import("node:fs/promises");
9
+ const { tmpdir } = await import("node:os");
10
+ const directory = await mkdtemp(join(tmpdir(), "typeset-cover-title-"));
11
+ const previousUrl = process.env.TYPESET_STUDIO_URL;
12
+ const previousSnapshot = process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT;
13
+ const requests: any[] = [];
14
+ // 只替换远程传输边界,读取实际 adapter 构造的标题,不能冒充正式渲染结果。
15
+ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: async request => {
16
+ requests.push(await request.json());
17
+ return Response.json({ ok: true, pages: [{ pageNumber: 1, dataUrl: "data:image/png;base64,", bodyImageCount: 0, bodyImageSources: [], bodyImageIds: [] }] });
18
+ } });
19
+ const render = (title: string) => typesetStudioAdapter.render({
20
+ title, route: "wechat-article", bodyText: "", outputDir: directory, state: { route: {} },
21
+ });
22
+ try {
23
+ process.env.TYPESET_STUDIO_URL = server.url.toString();
24
+ process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT = "1";
25
+ for (const raw of ["AI", "#AI", " #AI", "## 标题", "# #AI", "正文 #尾"]) {
26
+ await render(raw);
27
+ const title = normalizeArticleCoverTitle(raw);
28
+ expect(requests.at(-1).title).toBe(title);
29
+ expect(requests.at(-1).markdown).toBe(`# ${title}`);
30
+ }
31
+ const accepted = requests.length;
32
+ for (const raw of ["", " \t ", " ### "]) await expect(render(raw)).rejects.toThrow("title is empty after normalization");
33
+ expect(requests.length).toBe(accepted);
34
+ delete process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT;
35
+ await render(" #AI");
36
+ expect(requests.at(-1).markdown).toBe("# #AI");
37
+ await render("");
38
+ expect(requests.at(-1).markdown).toBe("# 未命名文章");
39
+ } finally {
40
+ if (previousUrl === undefined) delete process.env.TYPESET_STUDIO_URL; else process.env.TYPESET_STUDIO_URL = previousUrl;
41
+ if (previousSnapshot === undefined) delete process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT; else process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT = previousSnapshot;
42
+ server.stop(true);
43
+ await rm(directory, { recursive: true, force: true });
44
+ }
45
+ });
5
46
 
6
47
  describe("Typeset Studio — zzhub-pipeline ImageRenderPlugin Adapter", () => {
7
48
  const tempTestDir = join(process.cwd(), "tmp", `test-adapter-${Date.now()}`);
package/src/adapter.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { extname, join } from "node:path";
3
3
  import type { FlowImage, PipelineCoverTheme } from "./core/types";
4
4
  import type { RenderRequestBody } from "./server/routes/render";
5
+ import { normalizeArticleCoverTitle } from "./core/article-cover";
5
6
 
6
7
  // ── Typeset Studio & zzhub-pipeline compatible types ──────────────
7
8
 
@@ -266,7 +267,7 @@ async function renderLocalBatch(
266
267
 
267
268
  export const typesetStudioAdapter: ImageRenderPlugin = {
268
269
  name: "typeset-studio",
269
- version: "0.2.8",
270
+ version: "0.3.1",
270
271
 
271
272
  async doctor(): Promise<PipelinePluginDoctorCheck[]> {
272
273
  const checks: PipelinePluginDoctorCheck[] = [];
@@ -353,7 +354,9 @@ export const typesetStudioAdapter: ImageRenderPlugin = {
353
354
  // pipeline 的模式标记只选择分页方式,具体模板名才参与模板解析。
354
355
  const template = ["wechat-cover-split", "poster-3-4", "longform-3-4"].includes(input.template || "") ? undefined : input.template;
355
356
  input.onProgress?.({ stage: "typeset", message: "开始排版", route, account });
356
- const title = cleanTitle(input.title || state.metadata?.title || "");
357
+ const rawTitle = input.title || state.metadata?.title || "";
358
+ const title = process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT === "1" ? normalizeArticleCoverTitle(rawTitle) : cleanTitle(rawTitle);
359
+ if (process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT === "1" && !title) throw new RangeError("Article cover title is empty after normalization");
357
360
  const common: RenderRequestBody = {
358
361
  account,
359
362
  title,
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ARTICLE_COVER_FONT, ARTICLE_COVER_IMAGE_KEY, createArticleCoverConfig, normalizeArticleCoverTitle, validateArticleCoverControls } from "./article-cover";
3
+ import { applyTemplateImages } from "./template";
4
+ import { drawPageToContext } from "./canvas-draw";
5
+ import { getCanvasSpec } from "./geometry";
6
+
7
+ const id = "12345678-1234-4234-8234-123456789abc";
8
+ const assetId = "87654321-4321-4321-8321-cba987654321";
9
+ const controls = { backgroundColor: "#F1F8FF", titleColor: "#123ABC", fontSize: 120, textAlign: "center", safePadding: 120 };
10
+
11
+ describe("finite article-cover contract", () => {
12
+ it.each([
13
+ ["AI", "AI"], ["#AI", "AI"], [" #AI", "AI"], ["## 标题", "标题"],
14
+ ["# #AI", "AI"], ["正文 #尾", "正文 #尾"], [" \t ", ""], [" ### ", ""],
15
+ ])("normalizes cover title %j once and remains idempotent", (raw, expected) => {
16
+ expect(normalizeArticleCoverTitle(raw)).toBe(expected);
17
+ expect(normalizeArticleCoverTitle(normalizeArticleCoverTitle(raw))).toBe(expected);
18
+ });
19
+
20
+ it("normalizes finite controls and rejects paths, extra schema and invalid ranges", () => {
21
+ expect(validateArticleCoverControls(controls)).toMatchObject({ backgroundColor: "#f1f8ff", titleColor: "#123abc" });
22
+ for (const input of [{ ...controls, fontSize: 79 }, { ...controls, fontSize: 201 }, { ...controls, safePadding: 59 }, { ...controls, safePadding: 201 }, { ...controls, fontSize: 80.5 }, { ...controls, fontPath: "/private/font" }, { ...controls, backgroundColor: "url(https://invalid)" }, { ...controls, schemaVersion: 99 }]) {
23
+ expect(() => validateArticleCoverControls(input)).toThrow();
24
+ }
25
+ expect(() => createArticleCoverConfig({ id, name: "非法路径", controls, assetId: "/private/image" })).toThrow();
26
+ });
27
+
28
+ it("fixes title and square regions while retaining the historical canvas id and font identity", async () => {
29
+ const config = createArticleCoverConfig({ id, name: "有限封面", controls, assetId });
30
+ expect(getCanvasSpec(config.aspectRatio)).toMatchObject({ width: 3350, height: 1000, ratio: 3.35 });
31
+ const padding = config.algorithm.safeArea;
32
+ expect(3350 - padding.left - padding.right).toBe(2350 - 240);
33
+ expect(config.typography.headingFont).toBe(ARTICLE_COVER_FONT.family);
34
+ for (const font of Object.values(config.coverTheme!.typography)) expect(font).toMatchObject({ fontFamily: ARTICLE_COVER_FONT.family, fontSize: 120, minFontSize: 120 });
35
+ const images = applyTemplateImages([], config.images, { [ARTICLE_COVER_IMAGE_KEY]: "/trusted/square.jpg" });
36
+ expect(images[0]!.src).toBe("/trusted/square.jpg");
37
+ expect(images[0]!.x! + padding.left).toBe(2350); expect(images[0]!.y! + padding.top).toBe(0);
38
+ const draws: unknown[][] = [];
39
+ const context = new Proxy({ drawImage: (...args: unknown[]) => draws.push(args) } as any, { get: (target, key) => key in target ? target[key] : () => {} });
40
+ const source = { width: 2000, height: 1000 };
41
+ await drawPageToContext(context, {
42
+ page: { pageNumber: 1, lines: [], images: images as any, textBottom: 0, fillRatio: 0 },
43
+ spec: getCanvasSpec(config.aspectRatio), config, getImage: async () => source,
44
+ });
45
+ expect(draws).toEqual([[source, 500, 0, 1000, 1000, -500, -500, 1000, 1000]]);
46
+ });
47
+ });
@@ -0,0 +1,97 @@
1
+ import type { CoverFont, TypesetConfig } from "./types";
2
+
3
+ export interface ArticleCoverControls {
4
+ backgroundColor: string;
5
+ titleColor: string;
6
+ fontSize: number;
7
+ textAlign: "left" | "center" | "right";
8
+ safePadding: number;
9
+ }
10
+
11
+ export const ARTICLE_COVER_FONT = Object.freeze({
12
+ id: "publishing-noto-cjk-regular-v1",
13
+ sha256: "b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a",
14
+ family: "Publishing Cover",
15
+ });
16
+ export const ARTICLE_COVER_IMAGE_KEY = "article_cover_image";
17
+ export const DEFAULT_ARTICLE_COVER_CONTROLS: Readonly<ArticleCoverControls> = Object.freeze({
18
+ backgroundColor: "#ffffff", titleColor: "#171717", fontSize: 120,
19
+ textAlign: "left", safePadding: 120,
20
+ });
21
+
22
+ /** 预览与快照渲染共用标题清理,重复调用不会继续改变标题。 */
23
+ export function normalizeArticleCoverTitle(raw: string): string {
24
+ return raw.trim().replace(/^(?:#+\s*)+/, "").trim();
25
+ }
26
+
27
+ function object(value: unknown): Record<string, unknown> {
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new RangeError("Invalid article cover object");
29
+ return value as Record<string, unknown>;
30
+ }
31
+
32
+ function exactKeys(value: Record<string, unknown>, keys: string[]) {
33
+ if (Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key))) throw new RangeError("Unsupported article cover fields");
34
+ }
35
+
36
+ function color(value: unknown) {
37
+ if (typeof value !== "string" || !/^#[a-f\d]{6}$/i.test(value)) throw new RangeError("Article cover colors must be six-digit hex");
38
+ return value.toLowerCase();
39
+ }
40
+
41
+ /** 只开放已验证的封面控件,模板不携带字体、路径、URL 或自由布局。 */
42
+ export function validateArticleCoverControls(value: unknown): ArticleCoverControls {
43
+ const input = object(value);
44
+ exactKeys(input, ["backgroundColor", "titleColor", "fontSize", "textAlign", "safePadding"]);
45
+ if (!Number.isInteger(input.fontSize) || (input.fontSize as number) < 80 || (input.fontSize as number) > 200) throw new RangeError("Article cover fontSize must be an integer between 80 and 200");
46
+ if (!Number.isInteger(input.safePadding) || (input.safePadding as number) < 60 || (input.safePadding as number) > 200) throw new RangeError("Article cover safePadding must be an integer between 60 and 200");
47
+ if (!["left", "center", "right"].includes(input.textAlign as string)) throw new RangeError("Invalid article cover textAlign");
48
+ return {
49
+ backgroundColor: color(input.backgroundColor), titleColor: color(input.titleColor),
50
+ fontSize: input.fontSize as number, textAlign: input.textAlign as ArticleCoverControls["textAlign"], safePadding: input.safePadding as number,
51
+ };
52
+ }
53
+
54
+ /** 方图素材仅用身份占位,可信后端通过 templateImageOverrides 绑定实际文件。 */
55
+ export function createArticleCoverConfig(value: {
56
+ id: string; name: string; controls: unknown; assetId: string;
57
+ }): TypesetConfig {
58
+ const input = object(value);
59
+ exactKeys(input, ["id", "name", "controls", "assetId"]);
60
+ const uuid = /^[a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}$/i;
61
+ if (typeof input.id !== "string" || !uuid.test(input.id) || typeof input.assetId !== "string" || !uuid.test(input.assetId)) throw new RangeError("Article cover and asset ids must be UUIDs");
62
+ if (typeof input.name !== "string" || !input.name.trim() || input.name.trim().length > 80) throw new RangeError("Article cover name must contain 1 to 80 characters");
63
+ const controls = validateArticleCoverControls(input.controls);
64
+ const safeArea = { top: controls.safePadding, right: 1000 + controls.safePadding, bottom: controls.safePadding, left: controls.safePadding };
65
+ const font: CoverFont = { fontFamily: ARTICLE_COVER_FONT.family, fontSize: controls.fontSize, minFontSize: controls.fontSize, fontWeight: 400, lineHeight: 1.2, letterSpacing: 0 };
66
+ return {
67
+ id: input.id, name: input.name.trim(), slot: "article_cover", aspectRatio: "wechat-cover-2.35",
68
+ algorithm: {
69
+ baseFontSize: controls.fontSize, scaleRatio: 1, lineHeightMultiplier: 1.2,
70
+ obstacleGap: 0, targetFillRatio: 1, contentBottomGap: 0,
71
+ textAlign: controls.textAlign, verticalAlign: "center", safeArea,
72
+ },
73
+ theme: {
74
+ bgColor: controls.backgroundColor, textColor: controls.titleColor, headingColor: controls.titleColor,
75
+ accentColor: controls.titleColor, quoteColor: controls.titleColor, captionColor: controls.titleColor,
76
+ watermarkColor: controls.titleColor, cardBg: controls.backgroundColor,
77
+ },
78
+ typography: { headingFont: ARTICLE_COVER_FONT.family, bodyFont: ARTICLE_COVER_FONT.family },
79
+ footerText: "", watermarkText: "",
80
+ chrome: { showHeader: false, showWatermark: false, showPageNumber: false, showFooter: false, showFooterDivider: false, showFooterText: false, showFillRatio: false },
81
+ coverTheme: {
82
+ id: input.id, format: "wechat-cover-split",
83
+ typography: { title: { ...font }, subtitle: { ...font }, footer: { ...font } },
84
+ colors: { text: controls.titleColor, accent: controls.titleColor, muted: controls.titleColor },
85
+ background: { fill: { type: "solid", color: controls.backgroundColor }, image: null, overlay: null },
86
+ decoration: { pattern: "none", color: controls.titleColor, opacity: 0, size: 1, image: null },
87
+ layout: { textAlign: controls.textAlign, verticalAlign: "center", safeArea: { ...safeArea } },
88
+ },
89
+ images: [{
90
+ id: ARTICLE_COVER_IMAGE_KEY, templateAssetKey: ARTICLE_COVER_IMAGE_KEY,
91
+ src: `asset:${input.assetId}`, alt: "文章封面方图", fit: "cover", isAbsolute: true,
92
+ // 绘制坐标相对文字 stage,抵消留白后落在画布右侧 1000 方图区域。
93
+ x: 2350 - controls.safePadding, y: -controls.safePadding,
94
+ width: 1000, height: 1000, borderRadius: 0, captionHeight: 0, pageIndex: 1, zIndex: 20,
95
+ }],
96
+ };
97
+ }
@@ -271,7 +271,15 @@ export async function drawPageToContext(
271
271
  ctx.clip();
272
272
 
273
273
  if (loadedImg) {
274
- ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
274
+ if (img.fit === "cover") {
275
+ const sourceWidth = loadedImg.naturalWidth || loadedImg.width;
276
+ const sourceHeight = loadedImg.naturalHeight || loadedImg.height;
277
+ if (!(sourceWidth > 0 && sourceHeight > 0)) throw new Error("Invalid cover image dimensions");
278
+ const scale = Math.max(w / sourceWidth, h / sourceHeight);
279
+ const cropWidth = w / scale;
280
+ const cropHeight = h / scale;
281
+ ctx.drawImage(loadedImg, (sourceWidth - cropWidth) / 2, (sourceHeight - cropHeight) / 2, cropWidth, cropHeight, -w / 2, -h / 2, w, h);
282
+ } else ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
275
283
  } else {
276
284
  ctx.fillStyle = theme.cardBg || "#222222";
277
285
  ctx.fillRect(-w / 2, -h / 2, w, h);
package/src/core/types.ts CHANGED
@@ -70,6 +70,7 @@ export interface FlowImage {
70
70
  rotate?: number; // Rotation in degrees (e.g. -15 to 45)
71
71
  opacity?: number; // Opacity (0.1 to 1.0)
72
72
  borderRadius?: number; // Border radius in px (default 12)
73
+ fit?: "cover"; // 固定方图可按中心裁切,缺省沿用既有拉伸行为。
73
74
  }
74
75
 
75
76
  export interface FlowLineFragment {
package/src/cover.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ ARTICLE_COVER_FONT,
3
+ ARTICLE_COVER_IMAGE_KEY,
4
+ DEFAULT_ARTICLE_COVER_CONTROLS,
5
+ normalizeArticleCoverTitle,
6
+ validateArticleCoverControls,
7
+ createArticleCoverConfig,
8
+ type ArticleCoverControls,
9
+ } from "./core/article-cover";
10
+ export type { TypesetConfig } from "./core/types";
@@ -17,7 +17,7 @@ app.get("/api/health", (c) => {
17
17
  return c.json({
18
18
  status: "ok",
19
19
  name: "Typeset Studio Server",
20
- version: "0.2.8",
20
+ version: "0.3.1",
21
21
  time: new Date().toISOString(),
22
22
  });
23
23
  });
@@ -8,6 +8,8 @@ import {
8
8
  getDefaultTemplateId,
9
9
  getSlotDefaultTemplateId,
10
10
  } from "./templates";
11
+ import { ARTICLE_COVER_FONT } from "../../core/article-cover";
12
+ import { registerArticleCoverFont } from "../../server-font";
11
13
 
12
14
  export const renderRouter = new Hono();
13
15
  renderRouter.onError((error, c) => {
@@ -139,6 +141,13 @@ export function resolveTypesetResult(body: RenderRequestBody): {
139
141
 
140
142
  const resolvedImages = applyTemplateImages(body.images || [], effectiveConfig.images, body.templateImageOverrides);
141
143
 
144
+ // 插件在独立子进程渲染,字体必须在实际排版进程中校验并注册。
145
+ if (effectiveConfig.typography.headingFont === ARTICLE_COVER_FONT.family || effectiveConfig.typography.bodyFont === ARTICLE_COVER_FONT.family) {
146
+ const path = process.env.TYPESET_ARTICLE_COVER_FONT_PATH;
147
+ if (!path) throw new Error("Article cover font path is not configured for this renderer");
148
+ registerArticleCoverFont({ path });
149
+ }
150
+
142
151
  // 封面主题先使用目标字号,单页放不下时缩到契约允许的最小字号。
143
152
  let result: TypesetResult;
144
153
  while (true) {
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { registerArticleCoverFont } from "./server-font";
6
+ import { ARTICLE_COVER_FONT, ARTICLE_COVER_IMAGE_KEY, createArticleCoverConfig } from "./cover";
7
+ import { runTypeset } from "./core/engine";
8
+ import { applyTemplateImages } from "./core/template";
9
+ import { renderPageToPng } from "./core/canvas-renderer";
10
+ import { createCanvas, loadImage } from "@napi-rs/canvas";
11
+
12
+ test("strict cover font rejects missing, relative and mismatched font bytes", async () => {
13
+ const directory = await mkdtemp(join(tmpdir(), "typeset-font-"));
14
+ try {
15
+ const file = join(directory, "wrong.ttc"); await writeFile(file, "invalid-font");
16
+ expect(() => registerArticleCoverFont({ path: "relative.ttc" })).toThrow("trusted absolute path");
17
+ expect(() => registerArticleCoverFont({ path: join(directory, "missing.ttc") })).toThrow("unavailable");
18
+ expect(() => registerArticleCoverFont({ path: file })).toThrow("hash mismatch");
19
+ } finally { await rm(directory, { recursive: true }); }
20
+ });
21
+
22
+ const fontPath = process.env.TYPESET_ARTICLE_COVER_FONT_PATH;
23
+ test.skipIf(!fontPath)("trusted font renders 3350 PNG, left-only text and a non-stretched right square; long title fails", async () => {
24
+ expect(registerArticleCoverFont({ path: fontPath! })).toEqual(ARTICLE_COVER_FONT);
25
+ const config = createArticleCoverConfig({
26
+ id: "12345678-1234-4234-8234-123456789abc", name: "权威封面",
27
+ controls: { backgroundColor: "#ffffff", titleColor: "#123456", fontSize: 120, textAlign: "center", safePadding: 120 },
28
+ assetId: "87654321-4321-4321-8321-cba987654321",
29
+ });
30
+ const square = createCanvas(1000, 1000); const squareContext = square.getContext("2d");
31
+ squareContext.fillStyle = "#ff0000"; squareContext.fillRect(0, 0, 1000, 1000);
32
+ const images = applyTemplateImages([], config.images, { [ARTICLE_COVER_IMAGE_KEY]: square.toDataURL("image/png") });
33
+ const result = runTypeset({ markdown: "# 可信字体封面", config, images, maxPages: 1 });
34
+ expect(result.pageCount).toBe(1);
35
+ for (const line of result.pages[0]!.lines) {
36
+ expect(line.x + config.algorithm.safeArea.left).toBeGreaterThanOrEqual(120);
37
+ const width = line.fragments.reduce((sum, fragment) => sum + fragment.occupiedWidth + (fragment.gapBefore || 0), 0);
38
+ expect(line.x + width + config.algorithm.safeArea.left).toBeLessThanOrEqual(2230);
39
+ }
40
+ const png = await renderPageToPng(result.pages[0]!, result.canvasSpec, result.config, 1);
41
+ expect(png.readUInt32BE(16)).toBe(3350); expect(png.readUInt32BE(20)).toBe(1000);
42
+ const canvas = createCanvas(3350, 1000); const context = canvas.getContext("2d"); context.drawImage(await loadImage(png), 0, 0);
43
+ expect(Array.from(context.getImageData(3350 - 1, 0, 1, 1).data)).toEqual([255, 0, 0, 255]);
44
+ expect(Array.from(context.getImageData(2350, 999, 1, 1).data)).toEqual([255, 0, 0, 255]);
45
+ expect(() => runTypeset({ markdown: `# ${"超长标题".repeat(200)}`, config, images, maxPages: 1 })).toThrow();
46
+ });
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { isAbsolute } from "node:path";
4
+ import { GlobalFonts } from "@napi-rs/canvas";
5
+ import { clearCache } from "@chenglou/pretext";
6
+ import { ARTICLE_COVER_FONT } from "./core/article-cover";
7
+
8
+ let registered = false;
9
+
10
+ /** 字体路径只由可信部署指定,字节身份不匹配时禁止排版。 */
11
+ export function registerArticleCoverFont({ path }: { path: string }): typeof ARTICLE_COVER_FONT {
12
+ if (typeof path !== "string" || !isAbsolute(path)) throw new Error("Article cover font requires a trusted absolute path");
13
+ let bytes: Buffer;
14
+ try { bytes = readFileSync(path); }
15
+ catch { throw new Error("Article cover font file is unavailable"); }
16
+ if (createHash("sha256").update(bytes).digest("hex") !== ARTICLE_COVER_FONT.sha256) throw new Error("Article cover font hash mismatch");
17
+ if (!registered || !GlobalFonts.has(ARTICLE_COVER_FONT.family)) {
18
+ if (!GlobalFonts.registerFromPath(path, ARTICLE_COVER_FONT.family) || !GlobalFonts.has(ARTICLE_COVER_FONT.family)) throw new Error("Article cover font registration failed");
19
+ registered = true;
20
+ clearCache();
21
+ }
22
+ return ARTICLE_COVER_FONT;
23
+ }
@@ -0,0 +1,22 @@
1
+ import { useId } from "react";
2
+ import type { ArticleCoverControls } from "../../core/article-cover";
3
+
4
+ export interface CoverControlsProps {
5
+ value: ArticleCoverControls;
6
+ onChange: (value: ArticleCoverControls) => void;
7
+ disabled?: boolean;
8
+ }
9
+
10
+ /** 只编辑有限配置;保存、素材上传与服务端 PNG 由宿主接线。 */
11
+ export function CoverControls({ value, onChange, disabled = false }: CoverControlsProps) {
12
+ const id = useId();
13
+ return <fieldset className="typeset-cover-controls" disabled={disabled}>
14
+ <legend>文章封面</legend>
15
+ <label htmlFor={`${id}-background`}>背景色</label><input id={`${id}-background`} type="color" value={value.backgroundColor} onChange={event => onChange({ ...value, backgroundColor: event.target.value })} />
16
+ <label htmlFor={`${id}-title`}>标题色</label><input id={`${id}-title`} type="color" value={value.titleColor} onChange={event => onChange({ ...value, titleColor: event.target.value })} />
17
+ <label htmlFor={`${id}-size`}>标题字号</label><input id={`${id}-size`} type="number" min={80} max={200} step={1} value={value.fontSize} onChange={event => onChange({ ...value, fontSize: Number(event.target.value) })} />
18
+ <label htmlFor={`${id}-align`}>水平对齐</label><select id={`${id}-align`} value={value.textAlign} onChange={event => onChange({ ...value, textAlign: event.target.value as ArticleCoverControls["textAlign"] })}><option value="left">左对齐</option><option value="center">居中</option><option value="right">右对齐</option></select>
19
+ <label htmlFor={`${id}-padding`}>安全留白</label><input id={`${id}-padding`} type="number" min={60} max={200} step={1} value={value.safePadding} onChange={event => onChange({ ...value, safePadding: Number(event.target.value) })} />
20
+ <p>画布 3350×1000:左侧 2350 为标题,右侧 1000 为中心裁切方图。字体固定,预览以服务端 PNG 为准。</p>
21
+ </fieldset>;
22
+ }
@@ -58,7 +58,7 @@ export const Header: React.FC<HeaderProps> = ({
58
58
  </span>
59
59
  <span className="hidden 2xl:inline">Typeset Studio</span>
60
60
  </div>
61
- <span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden 2xl:inline">v0.2.8</span>
61
+ <span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden 2xl:inline">v0.3.1</span>
62
62
  <div title={templateName || "未命名模板"} className="flex min-w-0 items-center gap-1.5 px-2 py-0.5 rounded-full bg-[#f5f5f5] dark:bg-[#111111] border border-[#e5e5e5] dark:border-[#222222] text-[11px] text-emerald-600 dark:text-[#00e676]">
63
63
  <span className="w-1.5 h-1.5 shrink-0 rounded-full bg-emerald-500 dark:bg-[#00e676]" />
64
64
  <span aria-live="polite" className="max-w-40 truncate">{templateName || "未命名模板"}</span>
@@ -0,0 +1 @@
1
+ export { CoverControls, type CoverControlsProps } from "./components/CoverControls";
@@ -1,11 +0,0 @@
1
- import {
2
- renderRouter,
3
- resolveTypesetResult
4
- } from "./index-qszn98j2.js";
5
- import"./index-697fkv5y.js";
6
- import"./index-6c43c7h4.js";
7
- import"./index-37x76zdn.js";
8
- export {
9
- resolveTypesetResult,
10
- renderRouter
11
- };