@akairos/typeset-studio 0.2.8 → 0.3.0

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.
@@ -0,0 +1,13 @@
1
+ import {
2
+ renderRouter,
3
+ resolveTypesetResult
4
+ } from "./index-1yt7sbqe.js";
5
+ import"./index-r4swepwa.js";
6
+ import"./index-dz9pmgbk.js";
7
+ import"./index-2vgqm8ms.js";
8
+ import"./index-xg6pyfw6.js";
9
+ import"./index-37x76zdn.js";
10
+ export {
11
+ resolveTypesetResult,
12
+ renderRouter
13
+ };
@@ -0,0 +1,8 @@
1
+ import {
2
+ registerArticleCoverFont
3
+ } from "./index-r4swepwa.js";
4
+ import"./index-xg6pyfw6.js";
5
+ import"./index-37x76zdn.js";
6
+ export {
7
+ registerArticleCoverFont
8
+ };
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.0",
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",
package/src/adapter.ts CHANGED
@@ -266,7 +266,7 @@ async function renderLocalBatch(
266
266
 
267
267
  export const typesetStudioAdapter: ImageRenderPlugin = {
268
268
  name: "typeset-studio",
269
- version: "0.2.8",
269
+ version: "0.3.0",
270
270
 
271
271
  async doctor(): Promise<PipelinePluginDoctorCheck[]> {
272
272
  const checks: PipelinePluginDoctorCheck[] = [];
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ARTICLE_COVER_FONT, ARTICLE_COVER_IMAGE_KEY, createArticleCoverConfig, 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("normalizes finite controls and rejects paths, extra schema and invalid ranges", () => {
13
+ expect(validateArticleCoverControls(controls)).toMatchObject({ backgroundColor: "#f1f8ff", titleColor: "#123abc" });
14
+ 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 }]) {
15
+ expect(() => validateArticleCoverControls(input)).toThrow();
16
+ }
17
+ expect(() => createArticleCoverConfig({ id, name: "非法路径", controls, assetId: "/private/image" })).toThrow();
18
+ });
19
+
20
+ it("fixes title and square regions while retaining the historical canvas id and font identity", async () => {
21
+ const config = createArticleCoverConfig({ id, name: "有限封面", controls, assetId });
22
+ expect(getCanvasSpec(config.aspectRatio)).toMatchObject({ width: 3350, height: 1000, ratio: 3.35 });
23
+ const padding = config.algorithm.safeArea;
24
+ expect(3350 - padding.left - padding.right).toBe(2350 - 240);
25
+ expect(config.typography.headingFont).toBe(ARTICLE_COVER_FONT.family);
26
+ for (const font of Object.values(config.coverTheme!.typography)) expect(font).toMatchObject({ fontFamily: ARTICLE_COVER_FONT.family, fontSize: 120, minFontSize: 120 });
27
+ const images = applyTemplateImages([], config.images, { [ARTICLE_COVER_IMAGE_KEY]: "/trusted/square.jpg" });
28
+ expect(images[0]!.src).toBe("/trusted/square.jpg");
29
+ expect(images[0]!.x! + padding.left).toBe(2350); expect(images[0]!.y! + padding.top).toBe(0);
30
+ const draws: unknown[][] = [];
31
+ const context = new Proxy({ drawImage: (...args: unknown[]) => draws.push(args) } as any, { get: (target, key) => key in target ? target[key] : () => {} });
32
+ const source = { width: 2000, height: 1000 };
33
+ await drawPageToContext(context, {
34
+ page: { pageNumber: 1, lines: [], images: images as any, textBottom: 0, fillRatio: 0 },
35
+ spec: getCanvasSpec(config.aspectRatio), config, getImage: async () => source,
36
+ });
37
+ expect(draws).toEqual([[source, 500, 0, 1000, 1000, -500, -500, 1000, 1000]]);
38
+ });
39
+ });
@@ -0,0 +1,92 @@
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
+ function object(value: unknown): Record<string, unknown> {
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new RangeError("Invalid article cover object");
24
+ return value as Record<string, unknown>;
25
+ }
26
+
27
+ function exactKeys(value: Record<string, unknown>, keys: string[]) {
28
+ if (Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key))) throw new RangeError("Unsupported article cover fields");
29
+ }
30
+
31
+ function color(value: unknown) {
32
+ if (typeof value !== "string" || !/^#[a-f\d]{6}$/i.test(value)) throw new RangeError("Article cover colors must be six-digit hex");
33
+ return value.toLowerCase();
34
+ }
35
+
36
+ /** 只开放已验证的封面控件,模板不携带字体、路径、URL 或自由布局。 */
37
+ export function validateArticleCoverControls(value: unknown): ArticleCoverControls {
38
+ const input = object(value);
39
+ exactKeys(input, ["backgroundColor", "titleColor", "fontSize", "textAlign", "safePadding"]);
40
+ 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");
41
+ 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");
42
+ if (!["left", "center", "right"].includes(input.textAlign as string)) throw new RangeError("Invalid article cover textAlign");
43
+ return {
44
+ backgroundColor: color(input.backgroundColor), titleColor: color(input.titleColor),
45
+ fontSize: input.fontSize as number, textAlign: input.textAlign as ArticleCoverControls["textAlign"], safePadding: input.safePadding as number,
46
+ };
47
+ }
48
+
49
+ /** 方图素材仅用身份占位,可信后端通过 templateImageOverrides 绑定实际文件。 */
50
+ export function createArticleCoverConfig(value: {
51
+ id: string; name: string; controls: unknown; assetId: string;
52
+ }): TypesetConfig {
53
+ const input = object(value);
54
+ exactKeys(input, ["id", "name", "controls", "assetId"]);
55
+ const uuid = /^[a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}$/i;
56
+ 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");
57
+ 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");
58
+ const controls = validateArticleCoverControls(input.controls);
59
+ const safeArea = { top: controls.safePadding, right: 1000 + controls.safePadding, bottom: controls.safePadding, left: controls.safePadding };
60
+ const font: CoverFont = { fontFamily: ARTICLE_COVER_FONT.family, fontSize: controls.fontSize, minFontSize: controls.fontSize, fontWeight: 400, lineHeight: 1.2, letterSpacing: 0 };
61
+ return {
62
+ id: input.id, name: input.name.trim(), slot: "article_cover", aspectRatio: "wechat-cover-2.35",
63
+ algorithm: {
64
+ baseFontSize: controls.fontSize, scaleRatio: 1, lineHeightMultiplier: 1.2,
65
+ obstacleGap: 0, targetFillRatio: 1, contentBottomGap: 0,
66
+ textAlign: controls.textAlign, verticalAlign: "center", safeArea,
67
+ },
68
+ theme: {
69
+ bgColor: controls.backgroundColor, textColor: controls.titleColor, headingColor: controls.titleColor,
70
+ accentColor: controls.titleColor, quoteColor: controls.titleColor, captionColor: controls.titleColor,
71
+ watermarkColor: controls.titleColor, cardBg: controls.backgroundColor,
72
+ },
73
+ typography: { headingFont: ARTICLE_COVER_FONT.family, bodyFont: ARTICLE_COVER_FONT.family },
74
+ footerText: "", watermarkText: "",
75
+ chrome: { showHeader: false, showWatermark: false, showPageNumber: false, showFooter: false, showFooterDivider: false, showFooterText: false, showFillRatio: false },
76
+ coverTheme: {
77
+ id: input.id, format: "wechat-cover-split",
78
+ typography: { title: { ...font }, subtitle: { ...font }, footer: { ...font } },
79
+ colors: { text: controls.titleColor, accent: controls.titleColor, muted: controls.titleColor },
80
+ background: { fill: { type: "solid", color: controls.backgroundColor }, image: null, overlay: null },
81
+ decoration: { pattern: "none", color: controls.titleColor, opacity: 0, size: 1, image: null },
82
+ layout: { textAlign: controls.textAlign, verticalAlign: "center", safeArea: { ...safeArea } },
83
+ },
84
+ images: [{
85
+ id: ARTICLE_COVER_IMAGE_KEY, templateAssetKey: ARTICLE_COVER_IMAGE_KEY,
86
+ src: `asset:${input.assetId}`, alt: "文章封面方图", fit: "cover", isAbsolute: true,
87
+ // 绘制坐标相对文字 stage,抵消留白后落在画布右侧 1000 方图区域。
88
+ x: 2350 - controls.safePadding, y: -controls.safePadding,
89
+ width: 1000, height: 1000, borderRadius: 0, captionHeight: 0, pageIndex: 1, zIndex: 20,
90
+ }],
91
+ };
92
+ }
@@ -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,9 @@
1
+ export {
2
+ ARTICLE_COVER_FONT,
3
+ ARTICLE_COVER_IMAGE_KEY,
4
+ DEFAULT_ARTICLE_COVER_CONTROLS,
5
+ validateArticleCoverControls,
6
+ createArticleCoverConfig,
7
+ type ArticleCoverControls,
8
+ } from "./core/article-cover";
9
+ 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.0",
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.0</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
- };