@akairos/typeset-studio 0.2.5 → 0.2.6

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 (33) hide show
  1. package/README.md +16 -2
  2. package/dist/assets/index-JCv_QmUn.css +1 -0
  3. package/dist/assets/index-X9ezH_RG.js +317 -0
  4. package/dist/index.html +2 -2
  5. package/dist/lib/adapter.js +1 -1
  6. package/dist/lib/{canvas-renderer-cxnar75n.js → canvas-renderer-4jg80agt.js} +1 -1
  7. package/dist/lib/{index-zdhqg5f0.js → index-bf1zs3mv.js} +291 -54
  8. package/dist/lib/{index-t9w3p2p9.js → index-hegsbxc0.js} +71 -3
  9. package/dist/lib/{index-h2580xma.js → index-p8ttqbs6.js} +3 -3
  10. package/dist/lib/index.js +3 -3
  11. package/dist/lib/{render-xxd348m1.js → render-3043w6s6.js} +2 -2
  12. package/package.json +3 -3
  13. package/src/adapter.ts +1 -1
  14. package/src/core/canvas-draw.ts +53 -43
  15. package/src/core/canvas-presets.ts +37 -0
  16. package/src/core/canvas-renderer.test.ts +56 -0
  17. package/src/core/canvas-style.ts +149 -0
  18. package/src/core/engine.ts +2 -0
  19. package/src/core/types.ts +30 -3
  20. package/src/index.ts +4 -0
  21. package/src/server/index.ts +1 -1
  22. package/src/server/routes/slots.test.ts +22 -0
  23. package/src/server/routes/templates.ts +15 -0
  24. package/src/web/App.tsx +27 -7
  25. package/src/web/components/CanvasStyleInspector.tsx +95 -0
  26. package/src/web/components/Header.tsx +24 -9
  27. package/src/web/components/ParamsInspector.tsx +6 -1
  28. package/src/web/components/SaveTemplateModal.tsx +106 -0
  29. package/src/web/components/TemplateDrawer.tsx +323 -0
  30. package/src/web/hooks/useTypeset.ts +3 -1
  31. package/dist/assets/index-3Ko1UCBU.css +0 -1
  32. package/dist/assets/index-De-aHsvp.js +0 -317
  33. package/src/web/components/TemplateModal.tsx +0 -393
@@ -1,4 +1,5 @@
1
1
  import { computeStageDimensions } from "./geometry";
2
+ import { drawCanvasFill, drawCanvasPattern, drawCanvasBorder, validateCanvasStyle } from "./canvas-style";
2
3
  import { CanvasSpec, FlowPage, TypesetConfig } from "./types";
3
4
 
4
5
  /**
@@ -85,12 +86,14 @@ export async function drawPageToContext(
85
86
  ): Promise<void> {
86
87
  const { page, spec, config, totalPageCount = 1, getImage, isServer = false } = options;
87
88
  const { algorithm, theme, chrome } = config;
89
+ validateCanvasStyle(config.canvasStyle);
88
90
 
89
91
  // 1. Clear & Background Fill
90
92
  ctx.save();
91
93
  ctx.fillStyle = theme.bgColor;
92
94
  ctx.fillRect(0, 0, spec.width, spec.height);
93
95
  ctx.restore();
96
+ if (config.canvasStyle?.fill) drawCanvasFill(ctx, spec, config.canvasStyle.fill);
94
97
 
95
98
  // 主题背景、纹理和装饰图使用整张画布坐标。
96
99
  const coverTheme = config.coverTheme;
@@ -109,26 +112,28 @@ export async function drawPageToContext(
109
112
  ctx.restore();
110
113
  };
111
114
  if (coverTheme) {
112
- const fill = coverTheme.background.fill;
113
- ctx.save();
114
- if (fill.type === "solid") ctx.fillStyle = fill.color;
115
- else {
116
- let gradient;
117
- if (fill.type === "linear") {
118
- const angle = fill.angle * Math.PI / 180;
119
- const dx = Math.cos(angle) * spec.width / 2;
120
- const dy = Math.sin(angle) * spec.height / 2;
121
- gradient = ctx.createLinearGradient(spec.width / 2 - dx, spec.height / 2 - dy, spec.width / 2 + dx, spec.height / 2 + dy);
122
- } else {
123
- const x = spec.width * fill.position.x / 100;
124
- const y = spec.height * fill.position.y / 100;
125
- gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.hypot(spec.width, spec.height));
115
+ if (!config.canvasStyle?.fill) {
116
+ const fill = coverTheme.background.fill;
117
+ ctx.save();
118
+ if (fill.type === "solid") ctx.fillStyle = fill.color;
119
+ else {
120
+ let gradient;
121
+ if (fill.type === "linear") {
122
+ const angle = fill.angle * Math.PI / 180;
123
+ const dx = Math.cos(angle) * spec.width / 2;
124
+ const dy = Math.sin(angle) * spec.height / 2;
125
+ gradient = ctx.createLinearGradient(spec.width / 2 - dx, spec.height / 2 - dy, spec.width / 2 + dx, spec.height / 2 + dy);
126
+ } else {
127
+ const x = spec.width * fill.position.x / 100;
128
+ const y = spec.height * fill.position.y / 100;
129
+ gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.hypot(spec.width, spec.height));
130
+ }
131
+ for (const stop of fill.stops) gradient.addColorStop(stop.offset / 100, stop.color);
132
+ ctx.fillStyle = gradient;
126
133
  }
127
- for (const stop of fill.stops) gradient.addColorStop(stop.offset / 100, stop.color);
128
- ctx.fillStyle = gradient;
134
+ ctx.fillRect(0, 0, spec.width, spec.height);
135
+ ctx.restore();
129
136
  }
130
- ctx.fillRect(0, 0, spec.width, spec.height);
131
- ctx.restore();
132
137
  await drawCoverImage(coverTheme.background.image);
133
138
  if (coverTheme.background.overlay) {
134
139
  ctx.save();
@@ -138,37 +143,41 @@ export async function drawPageToContext(
138
143
  ctx.restore();
139
144
  }
140
145
  const decoration = coverTheme.decoration;
141
- ctx.save();
142
- ctx.strokeStyle = ctx.fillStyle = decoration.color;
143
- ctx.globalAlpha = decoration.opacity;
144
- const size = Math.max(8, decoration.size);
145
- ctx.beginPath();
146
- if (["lines", "grid", "dots"].includes(decoration.pattern)) {
147
- for (let x = 0; x < spec.width; x += size) {
148
- if (decoration.pattern === "grid") { ctx.moveTo(x, 0); ctx.lineTo(x, spec.height); }
149
- if (decoration.pattern === "dots") {
150
- for (let y = 0; y < spec.height; y += size) { ctx.moveTo(x + 2, y); ctx.arc(x, y, 2, 0, Math.PI * 2); }
146
+ if (!config.canvasStyle?.pattern) {
147
+ ctx.save();
148
+ ctx.strokeStyle = ctx.fillStyle = decoration.color;
149
+ ctx.globalAlpha = decoration.opacity;
150
+ const size = Math.max(8, decoration.size);
151
+ ctx.beginPath();
152
+ if (["lines", "grid", "dots"].includes(decoration.pattern)) {
153
+ for (let x = 0; x < spec.width; x += size) {
154
+ if (decoration.pattern === "grid") { ctx.moveTo(x, 0); ctx.lineTo(x, spec.height); }
155
+ if (decoration.pattern === "dots") {
156
+ for (let y = 0; y < spec.height; y += size) { ctx.moveTo(x + 2, y); ctx.arc(x, y, 2, 0, Math.PI * 2); }
157
+ }
158
+ }
159
+ if (decoration.pattern !== "dots") {
160
+ for (let y = 0; y < spec.height; y += size) { ctx.moveTo(0, y); ctx.lineTo(spec.width, y); }
161
+ }
162
+ } else if (decoration.pattern === "rings") {
163
+ for (let radius = size; radius < Math.hypot(spec.width, spec.height); radius += size) {
164
+ ctx.moveTo(spec.width / 2 + radius, spec.height / 2);
165
+ ctx.arc(spec.width / 2, spec.height / 2, radius, 0, Math.PI * 2);
166
+ }
167
+ } else if (decoration.pattern === "burst") {
168
+ for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / Math.max(8, Math.round(size / 3))) {
169
+ ctx.moveTo(spec.width / 2, spec.height / 2);
170
+ ctx.lineTo(spec.width / 2 + Math.cos(angle) * spec.width, spec.height / 2 + Math.sin(angle) * spec.height);
151
171
  }
152
172
  }
153
- if (decoration.pattern !== "dots") {
154
- for (let y = 0; y < spec.height; y += size) { ctx.moveTo(0, y); ctx.lineTo(spec.width, y); }
155
- }
156
- } else if (decoration.pattern === "rings") {
157
- for (let radius = size; radius < Math.hypot(spec.width, spec.height); radius += size) {
158
- ctx.moveTo(spec.width / 2 + radius, spec.height / 2);
159
- ctx.arc(spec.width / 2, spec.height / 2, radius, 0, Math.PI * 2);
160
- }
161
- } else if (decoration.pattern === "burst") {
162
- for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / Math.max(8, Math.round(size / 3))) {
163
- ctx.moveTo(spec.width / 2, spec.height / 2);
164
- ctx.lineTo(spec.width / 2 + Math.cos(angle) * spec.width, spec.height / 2 + Math.sin(angle) * spec.height);
165
- }
173
+ if (decoration.pattern === "dots") ctx.fill(); else ctx.stroke();
174
+ ctx.restore();
166
175
  }
167
- if (decoration.pattern === "dots") ctx.fill(); else ctx.stroke();
168
- ctx.restore();
169
176
  await drawCoverImage(decoration.image);
170
177
  }
171
178
 
179
+ if (config.canvasStyle?.pattern) drawCanvasPattern(ctx, spec, config.canvasStyle.pattern);
180
+
172
181
  // 2. Compute Stage Layout Dimensions
173
182
  const { stageX, stageY } = computeStageDimensions(
174
183
  spec,
@@ -400,4 +409,5 @@ export async function drawPageToContext(
400
409
  ctx.restore();
401
410
  }
402
411
  }
412
+ drawCanvasBorder(ctx, spec, config.canvasStyle?.border);
403
413
  }
@@ -0,0 +1,37 @@
1
+ import { getDefaultConfig } from "./engine";
2
+ import { DEFAULT_BORDER, DEFAULT_PATTERN } from "./canvas-style";
3
+ import type { CanvasFill, CanvasPattern, CanvasBorder, TypesetConfig } from "./types";
4
+
5
+ // 预设仅使用程序绘制,不依赖图片文件或远程素材。
6
+ const designs: Array<{
7
+ id: string; name: string; fill: CanvasFill; pattern: CanvasPattern["pattern"];
8
+ ink: string; accent: string; texture: string; border: CanvasBorder["style"];
9
+ size: number; opacity: number; font: string;
10
+ }> = [
11
+ { id: "sage", name: "浅草信笺 · 径向渐变 / 圆点", fill: { type: "radial", position: { x: 15, y: 10 }, stops: [{ color: "#f8f6e8", offset: 0 }, { color: "#d0e5d7", offset: 100 }] }, pattern: "dots", ink: "#243c32", accent: "#47785c", texture: "#47785c", border: "solid", size: 48, opacity: 0.16, font: "Songti SC" },
12
+ { id: "sunset", name: "落日来信 · 线性渐变 / 波浪", fill: { type: "linear", angle: 70, stops: [{ color: "#fff2d8", offset: 0 }, { color: "#f1c2a2", offset: 55 }, { color: "#df9b91", offset: 100 }] }, pattern: "waves", ink: "#663b32", accent: "#ab503d", texture: "#a55f4c", border: "gradient", size: 88, opacity: 0.12, font: "Kaiti SC" },
13
+ { id: "blueprint", name: "工程蓝图 · 深蓝渐变 / 网格", fill: { type: "linear", angle: 115, stops: [{ color: "#142f48", offset: 0 }, { color: "#071923", offset: 100 }] }, pattern: "grid", ink: "#e6f1ec", accent: "#a1d7c6", texture: "#91c3d0", border: "corners", size: 56, opacity: 0.12, font: "PingFang SC" },
14
+ { id: "eclipse", name: "月蚀光场 · 径向渐变 / 圆环", fill: { type: "radial", position: { x: 85, y: 12 }, stops: [{ color: "#53747a", offset: 0 }, { color: "#182e38", offset: 45 }, { color: "#0e1d27", offset: 100 }] }, pattern: "rings", ink: "#f1eddf", accent: "#ddba80", texture: "#e3c899", border: "gradient", size: 92, opacity: 0.10, font: "Songti SC" },
15
+ { id: "paper", name: "旧纸书页 · 纸张颗粒 / 双线框", fill: { type: "solid", color: "#f2ead8" }, pattern: "grain", ink: "#403b30", accent: "#8a503b", texture: "#6b533e", border: "double", size: 24, opacity: 0.16, font: "Songti SC" },
16
+ { id: "rose", name: "玫瑰邮笺 · 柔粉渐变 / 斜线", fill: { type: "linear", angle: 120, stops: [{ color: "#fff6ef", offset: 0 }, { color: "#efd6d4", offset: 100 }] }, pattern: "diagonal", ink: "#583f45", accent: "#9f5f68", texture: "#ae777a", border: "dashed", size: 64, opacity: 0.10, font: "Kaiti SC" },
17
+ { id: "checker", name: "黄油棋盘 · 奶油底色 / 棋盘格", fill: { type: "solid", color: "#faf2d9" }, pattern: "checker", ink: "#3c4937", accent: "#66784c", texture: "#b5bb89", border: "solid", size: 104, opacity: 0.10, font: "PingFang SC" },
18
+ { id: "burst", name: "日光放射 · 暖黄渐变 / 放射线", fill: { type: "radial", position: { x: 80, y: 20 }, stops: [{ color: "#fff7d1", offset: 0 }, { color: "#e8c982", offset: 100 }] }, pattern: "burst", ink: "#58452b", accent: "#976333", texture: "#aa7739", border: "corners", size: 100, opacity: 0.12, font: "PingFang SC" },
19
+ { id: "ruled", name: "雾蓝手账 · 浅蓝渐变 / 横线", fill: { type: "linear", angle: 90, stops: [{ color: "#f0f5f4", offset: 0 }, { color: "#d3e3e8", offset: 100 }] }, pattern: "lines", ink: "#304d5b", accent: "#4b7484", texture: "#7294a3", border: "double", size: 64, opacity: 0.14, font: "Kaiti SC" },
20
+ ];
21
+
22
+ export const CANVAS_PRESETS: TypesetConfig[] = designs.map((design) => {
23
+ const base = getDefaultConfig("longform-3-4");
24
+ return {
25
+ ...base, id: `tpl_canvas_${design.id}`, name: design.name, slot: "newspic_content", images: [],
26
+ algorithm: { ...base.algorithm, baseFontSize: 42, scaleRatio: 1.3, targetFillRatio: 0.83,
27
+ safeArea: { top: 126, right: 108, bottom: 110, left: 108 } },
28
+ typography: { headingFont: design.font, bodyFont: design.font },
29
+ theme: { ...base.theme, bgColor: design.fill.type === "solid" ? design.fill.color : design.fill.stops[0].color,
30
+ textColor: design.ink, headingColor: design.ink, accentColor: design.accent, quoteColor: design.accent,
31
+ captionColor: design.accent, watermarkColor: design.accent },
32
+ chrome: { ...base.chrome, showHeader: false, showFooter: false }, footerText: "", watermarkText: "",
33
+ canvasStyle: { fill: design.fill,
34
+ pattern: { ...DEFAULT_PATTERN, pattern: design.pattern, color: design.texture, size: design.size, opacity: design.opacity },
35
+ border: { ...DEFAULT_BORDER, style: design.border, color: design.accent, endColor: design.texture, width: 2, inset: 38, radius: design.id === "paper" ? 0 : 22, opacity: 0.65 } },
36
+ };
37
+ });
@@ -105,3 +105,59 @@ it("renders pipeline cover theme gradients, decoration, typography and minimum f
105
105
  expect(smallArea.config.coverTheme!.typography.title.fontSize).toBeLessThan(font.fontSize);
106
106
  expect(smallArea.config.coverTheme!.typography.title.fontSize).toBeGreaterThanOrEqual(font.minFontSize);
107
107
  });
108
+
109
+ // 实际像素校验渐变、花纹、边框以及重复渲染的确定性。
110
+ describe("Canvas backgrounds and borders", () => {
111
+ it("draws editable fills, every pattern and every border deterministically", async () => {
112
+ const { createCanvas } = await import("@napi-rs/canvas");
113
+ const { drawCanvasFill, drawCanvasPattern, drawCanvasBorder, validateCanvasStyle, DEFAULT_PATTERN, DEFAULT_BORDER, PATTERN_LABELS, BORDER_LABELS } = await import("./canvas-style");
114
+ const spec = { ...runTypeset({ markdown: "", config: getDefaultConfig() }).canvasSpec, width: 120, height: 160 };
115
+ const canvas = createCanvas(spec.width, spec.height);
116
+ const ctx = canvas.getContext("2d");
117
+ const reset = () => { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, spec.width, spec.height); };
118
+ const pixel = (x: number, y: number) => Array.from(ctx.getImageData(x, y, 1, 1).data);
119
+ const changedPixels = () => Array.from(ctx.getImageData(0, 0, spec.width, spec.height).data).filter((value, index) => index % 4 !== 3 && value !== 255).length;
120
+ drawCanvasFill(ctx as any, spec, { type: "linear", angle: 0, stops: [{ color: "#ff0000", offset: 0 }, { color: "#0000ff", offset: 100 }] });
121
+ expect(pixel(0, 80)[0]).toBeGreaterThan(250);
122
+ expect(pixel(119, 80)[2]).toBeGreaterThan(250);
123
+ drawCanvasFill(ctx as any, spec, { type: "radial", position: { x: 0, y: 0 }, stops: [{ color: "#ffffff", offset: 0 }, { color: "#000000", offset: 100 }] });
124
+ expect(pixel(0, 0)[0]).toBeGreaterThan(pixel(119, 159)[0] + 200);
125
+ for (const pattern of Object.keys(PATTERN_LABELS) as Array<keyof typeof PATTERN_LABELS>) {
126
+ reset();
127
+ drawCanvasPattern(ctx as any, spec, { ...DEFAULT_PATTERN, pattern, color: "#123456", opacity: 1, size: 24 });
128
+ expect(changedPixels() > 0).toBe(pattern !== "none");
129
+ const first = canvas.toBuffer("image/png");
130
+ reset();
131
+ drawCanvasPattern(ctx as any, spec, { ...DEFAULT_PATTERN, pattern, color: "#123456", opacity: 1, size: 24 });
132
+ expect(first.equals(canvas.toBuffer("image/png"))).toBe(true);
133
+ }
134
+ for (const style of Object.keys(BORDER_LABELS) as Array<keyof typeof BORDER_LABELS>) {
135
+ reset();
136
+ drawCanvasBorder(ctx as any, spec, { ...DEFAULT_BORDER, style, inset: 10, width: 2, color: "#123456" });
137
+ expect(changedPixels() > 0).toBe(style !== "none");
138
+ }
139
+ expect(() => validateCanvasStyle({ pattern: { ...DEFAULT_PATTERN, size: 0 } })).toThrow(RangeError);
140
+ expect(() => validateCanvasStyle({ fill: { type: "linear", angle: 0, stops: [{ color: "bad", offset: 0 }, { color: "#000000", offset: 100 }] } })).toThrow(RangeError);
141
+ });
142
+
143
+ it("renders all preset templates and retains borders above a fixed image", async () => {
144
+ const { CANVAS_PRESETS } = await import("./canvas-presets");
145
+ const { validateCanvasStyle } = await import("./canvas-style");
146
+ const { createCanvas } = await import("@napi-rs/canvas");
147
+ const { renderPageToCanvas } = await import("./canvas-renderer");
148
+ for (const config of CANVAS_PRESETS) {
149
+ validateCanvasStyle(config.canvasStyle);
150
+ const result = runTypeset({ markdown: "# 模板试色\n记录想法,让文字留在喜欢的背景上。", config });
151
+ expect(result.pageCount).toBe(1);
152
+ const png = await renderPageToPng(result.pages[0], result.canvasSpec, config);
153
+ expect(png.length).toBeGreaterThan(1000);
154
+ }
155
+ const white = createCanvas(10, 10);
156
+ white.getContext("2d").fillStyle = "#ffffff";
157
+ white.getContext("2d").fillRect(0, 0, 10, 10);
158
+ const config = { ...getDefaultConfig(), canvasStyle: { border: { style: "solid" as const, color: "#ff0000", endColor: "#ff0000", inset: 20, width: 8, radius: 0, opacity: 1 } } };
159
+ const result = runTypeset({ markdown: "正文", config, images: [{ id: "bg", src: `data:image/png;base64,${white.toBuffer("image/png").toString("base64")}`, alt: "", isAbsolute: true, zIndex: 0, x: -100, y: -100, width: 2000, height: 2000, templateAssetKey: "background" }] });
160
+ const canvas = await renderPageToCanvas(result.pages[0], result.canvasSpec, config);
161
+ expect(Array.from(canvas.getContext("2d").getImageData(24, 300, 1, 1).data)).toEqual([255, 0, 0, 255]);
162
+ });
163
+ });
@@ -0,0 +1,149 @@
1
+ import type { CanvasBorder, CanvasFill, CanvasPattern, CanvasSpec, CanvasStyle } from "./types";
2
+
3
+ export const DEFAULT_PATTERN: CanvasPattern = { pattern: "none", color: "#527561", opacity: 0.12, size: 56 };
4
+ export const DEFAULT_BORDER: CanvasBorder = { style: "none", color: "#527561", endColor: "#d7ad72", width: 3, inset: 32, radius: 24, opacity: 0.8 };
5
+ export const PATTERN_LABELS: Record<CanvasPattern["pattern"], string> = {
6
+ none: "无花纹", lines: "横线", grid: "网格", dots: "圆点", diagonal: "斜线", waves: "波浪",
7
+ rings: "同心圆", burst: "放射线", checker: "棋盘格", grain: "纸张颗粒",
8
+ };
9
+ export const BORDER_LABELS: Record<CanvasBorder["style"], string> = {
10
+ none: "无边框", solid: "实线", dashed: "虚线", double: "双线", corners: "四角装饰", gradient: "渐变描边",
11
+ };
12
+
13
+ // 限制颜色和密度,避免无效配置或过密花纹拖慢渲染。
14
+ export function validateCanvasStyle(style?: CanvasStyle): void {
15
+ if (style === undefined) return;
16
+ if (!style || typeof style !== "object" || Array.isArray(style)) throw new RangeError("无效的背景与边框配置");
17
+ const color = (value: string) => { if (typeof value !== "string" || !/^#[0-9a-f]{6}$/i.test(value)) throw new RangeError("背景装饰颜色必须是六位十六进制颜色"); };
18
+ const number = (value: number, min: number, max: number) => { if (!Number.isFinite(value) || value < min || value > max) throw new RangeError(`背景装饰参数必须在 ${min} 到 ${max} 之间`); };
19
+ if (style.fill) {
20
+ const fill = style.fill;
21
+ if (fill.type === "solid") color(fill.color);
22
+ else {
23
+ if (fill.type === "linear") number(fill.angle, 0, 360);
24
+ else if (fill.type === "radial") { number(fill.position?.x, 0, 100); number(fill.position?.y, 0, 100); }
25
+ else throw new RangeError("未知背景类型");
26
+ if (!Array.isArray(fill.stops) || fill.stops.length < 2 || fill.stops.length > 8) throw new RangeError("渐变需要 2 到 8 个颜色节点");
27
+ for (const stop of fill.stops) { if (!stop) throw new RangeError("无效的渐变节点"); color(stop.color); number(stop.offset, 0, 100); }
28
+ }
29
+ }
30
+ if (style.pattern) {
31
+ const pattern = style.pattern;
32
+ if (!Object.hasOwn(PATTERN_LABELS, pattern.pattern)) throw new RangeError("未知背景花纹");
33
+ color(pattern.color); number(pattern.opacity, 0, 1); number(pattern.size, 16, 240);
34
+ }
35
+ if (style.border) {
36
+ const border = style.border;
37
+ if (!Object.hasOwn(BORDER_LABELS, border.style)) throw new RangeError("未知边框样式");
38
+ color(border.color); color(border.endColor); number(border.width, 1, 24);
39
+ number(border.inset, 0, 200); number(border.radius, 0, 200); number(border.opacity, 0, 1);
40
+ }
41
+ }
42
+
43
+ // 浏览器与 Skia 使用同一组 Canvas 2D 指令。
44
+ export function drawCanvasFill(ctx: CanvasRenderingContext2D, spec: CanvasSpec, fill: CanvasFill): void {
45
+ ctx.save();
46
+ if (fill.type === "solid") ctx.fillStyle = fill.color;
47
+ else {
48
+ let gradient: CanvasGradient;
49
+ if (fill.type === "linear") {
50
+ const angle = fill.angle * Math.PI / 180;
51
+ const distance = Math.abs(Math.cos(angle)) * spec.width / 2 + Math.abs(Math.sin(angle)) * spec.height / 2;
52
+ const dx = Math.cos(angle) * distance;
53
+ const dy = Math.sin(angle) * distance;
54
+ gradient = ctx.createLinearGradient(spec.width / 2 - dx, spec.height / 2 - dy, spec.width / 2 + dx, spec.height / 2 + dy);
55
+ } else {
56
+ const x = spec.width * fill.position.x / 100;
57
+ const y = spec.height * fill.position.y / 100;
58
+ const radius = Math.hypot(Math.max(x, spec.width - x), Math.max(y, spec.height - y));
59
+ gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
60
+ }
61
+ for (const stop of fill.stops) gradient.addColorStop(stop.offset / 100, stop.color);
62
+ ctx.fillStyle = gradient;
63
+ }
64
+ ctx.fillRect(0, 0, spec.width, spec.height);
65
+ ctx.restore();
66
+ }
67
+
68
+ // 颗粒使用固定种子,重复导出和远程出图保持一致。
69
+ export function drawCanvasPattern(ctx: CanvasRenderingContext2D, spec: CanvasSpec, decoration: CanvasPattern): void {
70
+ if (decoration.pattern === "none" || decoration.opacity === 0) return;
71
+ const { width, height } = spec;
72
+ const size = Math.max(16, decoration.size);
73
+ ctx.save();
74
+ ctx.strokeStyle = ctx.fillStyle = decoration.color;
75
+ ctx.globalAlpha = decoration.opacity;
76
+ ctx.lineWidth = 1.5;
77
+ ctx.beginPath();
78
+ switch (decoration.pattern) {
79
+ case "grid":
80
+ case "lines":
81
+ if (decoration.pattern === "grid") for (let x = size / 2; x < width; x += size) { ctx.moveTo(x, 0); ctx.lineTo(x, height); }
82
+ for (let y = size / 2; y < height; y += size) { ctx.moveTo(0, y); ctx.lineTo(width, y); }
83
+ break;
84
+ case "dots":
85
+ for (let x = size / 2; x < width; x += size) for (let y = size / 2; y < height; y += size) { ctx.moveTo(x + 2, y); ctx.arc(x, y, 2, 0, Math.PI * 2); }
86
+ break;
87
+ case "diagonal":
88
+ for (let x = -height; x < width; x += size) { ctx.moveTo(x, 0); ctx.lineTo(x + height, height); }
89
+ break;
90
+ case "waves":
91
+ for (let y = -size; y < height + size; y += size) {
92
+ ctx.moveTo(0, y);
93
+ for (let x = 0; x < width; x += size * 2) ctx.bezierCurveTo(x + size * 0.5, y - size * 0.45, x + size * 1.5, y + size * 0.45, x + size * 2, y);
94
+ }
95
+ break;
96
+ case "rings":
97
+ for (let r = size; r < Math.hypot(width, height); r += size) { ctx.moveTo(width * 0.82 + r, height * 0.18); ctx.arc(width * 0.82, height * 0.18, r, 0, Math.PI * 2); }
98
+ break;
99
+ case "burst":
100
+ for (let a = 0; a < Math.PI * 2; a += Math.PI / Math.max(8, Math.round(size / 3))) {
101
+ ctx.moveTo(width * 0.8, height * 0.2); ctx.lineTo(width * 0.8 + Math.cos(a) * width * 2, height * 0.2 + Math.sin(a) * height * 2);
102
+ }
103
+ break;
104
+ case "checker":
105
+ for (let x = 0; x < width; x += size) for (let y = 0; y < height; y += size) if ((Math.round(x / size) + Math.round(y / size)) % 2 === 0) ctx.rect(x, y, size, size);
106
+ break;
107
+ case "grain": {
108
+ let seed = 42;
109
+ const random = () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
110
+ for (let i = 0; i < width * height / (size * 3); i++) { const x = random() * width; const y = random() * height; ctx.rect(x, y, 0.6 + random() * 1.2, 0.6 + random() * 1.2); }
111
+ break;
112
+ }
113
+ }
114
+ if (["dots", "checker", "grain"].includes(decoration.pattern)) ctx.fill(); else ctx.stroke();
115
+ ctx.restore();
116
+ }
117
+
118
+ // 边框最后绘制,固定背景图不会将边框盖住。
119
+ export function drawCanvasBorder(ctx: CanvasRenderingContext2D, spec: CanvasSpec, border?: CanvasBorder): void {
120
+ if (!border || border.style === "none" || border.opacity === 0) return;
121
+ const inset = Math.min(border.inset + border.width / 2, Math.min(spec.width, spec.height) / 2 - 1);
122
+ const width = spec.width - inset * 2;
123
+ const height = spec.height - inset * 2;
124
+ ctx.save();
125
+ ctx.globalAlpha = border.opacity;
126
+ ctx.lineWidth = border.width;
127
+ ctx.strokeStyle = border.color;
128
+ if (border.style === "gradient") {
129
+ const gradient = ctx.createLinearGradient(0, 0, spec.width, spec.height);
130
+ gradient.addColorStop(0, border.color); gradient.addColorStop(1, border.endColor);
131
+ ctx.strokeStyle = gradient;
132
+ }
133
+ if (border.style === "dashed") ctx.setLineDash([border.width * 4, border.width * 3]);
134
+ const outline = (gap: number) => {
135
+ ctx.beginPath(); ctx.roundRect(inset + gap, inset + gap, width - gap * 2, height - gap * 2, Math.max(0, border.radius - gap)); ctx.stroke();
136
+ };
137
+ if (border.style === "corners") {
138
+ const length = Math.min(width, height) * 0.12;
139
+ for (const x of [inset, spec.width - inset]) for (const y of [inset, spec.height - inset]) {
140
+ const dx = x === inset ? 1 : -1;
141
+ const dy = y === inset ? 1 : -1;
142
+ ctx.beginPath(); ctx.moveTo(x + dx * length, y); ctx.lineTo(x, y); ctx.lineTo(x, y + dy * length); ctx.stroke();
143
+ }
144
+ } else {
145
+ outline(0);
146
+ if (border.style === "double") outline(border.width * 3 + 4);
147
+ }
148
+ ctx.restore();
149
+ }
@@ -4,6 +4,7 @@ import { paginateBlocks } from "./obstacle-flow";
4
4
  import { computeBlockStyles } from "./typography";
5
5
  import { resolveImagePlacement } from "./image-layout";
6
6
  import { validateTemplateAssets } from "./template";
7
+ import { validateCanvasStyle } from "./canvas-style";
7
8
  import {
8
9
  FlowBlock,
9
10
  FlowImage,
@@ -86,6 +87,7 @@ export function runTypeset(params: {
86
87
  throw new RangeError("Invalid page constraints: maxPages must be 0 or at least minPages");
87
88
  }
88
89
  validateTemplateAssets(images);
90
+ validateCanvasStyle(config.canvasStyle);
89
91
  if (images.some((image) => !image.repeatEveryPage && image.pageIndex != null && (!Number.isInteger(image.pageIndex) || image.pageIndex < 1 || image.pageIndex > 50))) {
90
92
  throw new RangeError("Image pageIndex must be an integer between 1 and 50");
91
93
  }
package/src/core/types.ts CHANGED
@@ -152,6 +152,34 @@ export interface TypesetChromeConfig {
152
152
 
153
153
  export type TemplateSlot = "article_cover" | "newspic_cover" | "newspic_content";
154
154
 
155
+ export type CanvasFill = { type: "solid"; color: string } |
156
+ { type: "linear"; angle: number; stops: Array<{ color: string; offset: number }> } |
157
+ { type: "radial"; position: { x: number; y: number }; stops: Array<{ color: string; offset: number }> };
158
+
159
+ export interface CanvasPattern {
160
+ pattern: "none" | "lines" | "grid" | "dots" | "rings" | "burst" | "diagonal" | "waves" | "checker" | "grain";
161
+ color: string;
162
+ opacity: number;
163
+ size: number;
164
+ }
165
+
166
+ export interface CanvasBorder {
167
+ style: "none" | "solid" | "dashed" | "double" | "corners" | "gradient";
168
+ color: string;
169
+ endColor: string;
170
+ width: number;
171
+ inset: number;
172
+ radius: number;
173
+ opacity: number;
174
+ }
175
+
176
+ // 背景装饰独立于文字主题,封面和正文模板共用。
177
+ export interface CanvasStyle {
178
+ fill?: CanvasFill;
179
+ pattern?: CanvasPattern;
180
+ border?: CanvasBorder;
181
+ }
182
+
155
183
  export interface TypesetConfig {
156
184
  id?: string;
157
185
  name: string;
@@ -168,6 +196,7 @@ export interface TypesetConfig {
168
196
  chrome?: TypesetChromeConfig;
169
197
  images?: FlowImage[];
170
198
  coverTheme?: PipelineCoverTheme;
199
+ canvasStyle?: CanvasStyle;
171
200
  }
172
201
 
173
202
  export interface TypesetResult {
@@ -198,9 +227,7 @@ export interface PipelineCoverTheme {
198
227
  typography: { title: CoverFont; subtitle: CoverFont; footer: CoverFont };
199
228
  colors: { text: string; accent: string; muted: string };
200
229
  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 }> };
230
+ fill: CanvasFill;
204
231
  image: CoverImage | null;
205
232
  overlay: { color: string; opacity: number } | null;
206
233
  };
package/src/index.ts CHANGED
@@ -33,6 +33,10 @@ export type {
33
33
  AlgorithmParams,
34
34
  AspectRatioType,
35
35
  CanvasSpec,
36
+ CanvasStyle,
37
+ CanvasFill,
38
+ CanvasPattern,
39
+ CanvasBorder,
36
40
  ContentBlockKind,
37
41
  CoverFont,
38
42
  CoverImage,
@@ -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.5",
20
+ version: "0.2.6",
21
21
  time: new Date().toISOString(),
22
22
  });
23
23
  });
@@ -183,3 +183,25 @@ describe("Fixed asset rendering API", () => {
183
183
  }
184
184
  });
185
185
  });
186
+
187
+ it("adds Canvas presets once, preserves edits and renders saved colors through the template API", async () => {
188
+ const { CANVAS_PRESETS } = await import("../../core/canvas-presets");
189
+ const list = await (await app.request("/api/templates", { headers: authHeaders })).json();
190
+ for (const preset of CANVAS_PRESETS) expect(list.templates.some((template: { id: string }) => template.id === preset.id)).toBe(true);
191
+ const preset = CANVAS_PRESETS[0];
192
+ try {
193
+ const custom = { ...preset, canvasStyle: { ...preset.canvasStyle, fill: { type: "solid", color: "#123456" } } };
194
+ expect((await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify(custom) })).status).toBe(200);
195
+ const loaded = await (await app.request(`/api/templates/${preset.id}`, { headers: authHeaders })).json();
196
+ expect(loaded.template.canvasStyle.fill.color).toBe("#123456");
197
+ const rendered = await (await app.request("/api/render", { method: "POST", headers: authHeaders, body: JSON.stringify({ template: preset.id, markdown: "# 自定义背景", preserveTemplateStyle: true, config: { canvasStyle: { fill: { type: "solid", color: "#ffffff" } } } }) })).json();
198
+ expect(rendered.result.config.canvasStyle.fill.color).toBe("#123456");
199
+ const invalid = await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify({ ...custom, canvasStyle: { pattern: { pattern: "grid", color: "#123456", opacity: 1, size: 0 } } }) });
200
+ expect(invalid.status).toBe(400);
201
+ await app.request(`/api/templates/${preset.id}`, { method: "DELETE", headers: authHeaders });
202
+ const again = await (await app.request("/api/templates", { headers: authHeaders })).json();
203
+ expect(again.templates.some((template: { id: string }) => template.id === preset.id)).toBe(false);
204
+ } finally {
205
+ await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify(preset) });
206
+ }
207
+ });
@@ -5,6 +5,8 @@ import { getDefaultConfig } from "../../core/engine";
5
5
  import { TemplateSlot, TypesetConfig } from "../../core/types";
6
6
  import { resolveDataDir } from "../../core/data-dir";
7
7
  import { validateTemplateAssets } from "../../core/template";
8
+ import { validateCanvasStyle } from "../../core/canvas-style";
9
+ import { CANVAS_PRESETS } from "../../core/canvas-presets";
8
10
 
9
11
  export { resolveDataDir } from "../../core/data-dir";
10
12
 
@@ -23,6 +25,17 @@ function ensureTemplatesDir(): void {
23
25
  seedDefaultTemplates();
24
26
  try { writeFileSync(seedMarker, "1"); } catch { /* 只读安装目录保留读取能力。 */ }
25
27
  }
28
+ // 新预设仅补充一次,不覆盖已编辑模板,也不重新生成用户删除的预设。
29
+ const canvasMarker = join(TEMPLATES_DIR, ".canvas-presets-v1");
30
+ if (!existsSync(canvasMarker)) {
31
+ try {
32
+ for (const preset of CANVAS_PRESETS) {
33
+ const path = join(TEMPLATES_DIR, `${preset.id}.json`);
34
+ if (!existsSync(path)) writeFileSync(path, JSON.stringify(preset, null, 2), "utf-8");
35
+ }
36
+ writeFileSync(canvasMarker, "1");
37
+ } catch { /* 只读安装目录继续使用现有模板。 */ }
38
+ }
26
39
  }
27
40
 
28
41
  export const BUILTIN_PRESETS: TypesetConfig[] = [
@@ -334,6 +347,7 @@ export const BUILTIN_PRESETS: TypesetConfig[] = [
334
347
  showFillRatio: false,
335
348
  },
336
349
  },
350
+ ...CANVAS_PRESETS,
337
351
  ];
338
352
 
339
353
  function seedDefaultTemplates(): void {
@@ -611,6 +625,7 @@ templatesRouter.post("/", async (c) => {
611
625
 
612
626
  try {
613
627
  validateTemplateAssets(body.images);
628
+ validateCanvasStyle(body.canvasStyle);
614
629
  } catch (error) {
615
630
  return c.json({ ok: false, error: error instanceof Error ? error.message : "无效的模板素材" }, 400);
616
631
  }
package/src/web/App.tsx CHANGED
@@ -4,7 +4,8 @@ import { CanvasViewport } from "./components/CanvasViewport";
4
4
  import { EditorSidebar } from "./components/EditorSidebar";
5
5
  import { Header } from "./components/Header";
6
6
  import { ParamsInspector } from "./components/ParamsInspector";
7
- import { TemplateModal } from "./components/TemplateModal";
7
+ import { TemplateDrawer } from "./components/TemplateDrawer";
8
+ import { SaveTemplateModal } from "./components/SaveTemplateModal";
8
9
  import { useAuth } from "./hooks/useAuth";
9
10
  import { useTypeset } from "./hooks/useTypeset";
10
11
 
@@ -57,7 +58,9 @@ export const App: React.FC = () => {
57
58
  const [viewMode, setViewMode] = useState<"grid" | "stack">("grid");
58
59
  const [zoom, setZoom] = useState<number>(0.45);
59
60
  const [isAutoFit, setIsAutoFit] = useState<boolean>(true);
60
- const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
61
+ const [isTemplateDrawerOpen, setIsTemplateDrawerOpen] = useState(false);
62
+ const [isSaveTemplateOpen, setIsSaveTemplateOpen] = useState(false);
63
+ const [templateRefreshKey, setTemplateRefreshKey] = useState(0);
61
64
  const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
62
65
  const [isExporting, setIsExporting] = useState(false);
63
66
  const viewportRef = useRef<HTMLDivElement>(null);
@@ -251,7 +254,10 @@ export const App: React.FC = () => {
251
254
  onZoomChange={handleZoomChange}
252
255
  onAutoFit={handleAutoFit}
253
256
  isAutoFit={isAutoFit}
254
- onOpenTemplates={() => setIsTemplateModalOpen(true)}
257
+ onOpenTemplates={() => setIsTemplateDrawerOpen((open) => !open)}
258
+ isTemplateDrawerOpen={isTemplateDrawerOpen}
259
+ templateName={config.name}
260
+ onSaveTemplate={() => setIsSaveTemplateOpen(true)}
255
261
  onOpenAuth={() => setIsAuthModalOpen(true)}
256
262
  onExport={handleExport}
257
263
  isExporting={isExporting}
@@ -300,6 +306,10 @@ export const App: React.FC = () => {
300
306
  config={config}
301
307
  onUpdateAlgorithm={updateAlgorithm}
302
308
  onUpdateTheme={updateTheme}
309
+ onUpdateCanvasStyle={(canvasStyle) => setConfig((prev) => ({
310
+ ...prev, canvasStyle,
311
+ theme: { ...prev.theme, bgColor: canvasStyle.fill?.type === "solid" ? canvasStyle.fill.color : canvasStyle.fill?.stops[0].color || prev.theme.bgColor },
312
+ }))}
303
313
  onUpdateTypography={updateTypography}
304
314
  onUpdateSafeArea={updateSafeArea}
305
315
  onUpdateBranding={(footer, watermark) => {
@@ -319,17 +329,27 @@ export const App: React.FC = () => {
319
329
  }));
320
330
  }}
321
331
  />
332
+ <TemplateDrawer
333
+ isOpen={isTemplateDrawerOpen}
334
+ onClose={() => setIsTemplateDrawerOpen(false)}
335
+ refreshKey={templateRefreshKey}
336
+ onApplyTemplate={loadTemplate}
337
+ adminKey={adminKey}
338
+ />
322
339
  </div>
323
340
 
324
341
  {/* Modals */}
325
- <TemplateModal
326
- isOpen={isTemplateModalOpen}
327
- onClose={() => setIsTemplateModalOpen(false)}
342
+ <SaveTemplateModal
343
+ isOpen={isSaveTemplateOpen}
344
+ onClose={() => setIsSaveTemplateOpen(false)}
328
345
  currentConfig={{
329
346
  ...config,
330
347
  images,
331
348
  }}
332
- onApplyTemplate={loadTemplate}
349
+ onSaved={(template) => {
350
+ setConfig((prev) => ({ ...prev, id: template.id, name: template.name, slot: template.slot }));
351
+ setTemplateRefreshKey((key) => key + 1);
352
+ }}
333
353
  adminKey={adminKey}
334
354
  />
335
355