@akairos/typeset-studio 0.2.5 → 0.2.7
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.
- package/README.md +67 -17
- package/dist/assets/index-CVzTRIjV.js +317 -0
- package/dist/assets/index-JCv_QmUn.css +1 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +1 -1
- package/dist/lib/{canvas-renderer-cxnar75n.js → canvas-renderer-wce6xgb2.js} +1 -1
- package/dist/lib/{index-zdhqg5f0.js → index-697fkv5y.js} +294 -57
- package/dist/lib/{index-h2580xma.js → index-dkwbnaj7.js} +3 -3
- package/dist/lib/{index-t9w3p2p9.js → index-qszn98j2.js} +73 -5
- package/dist/lib/index.js +3 -3
- package/dist/lib/{render-xxd348m1.js → render-064weavx.js} +2 -2
- package/package.json +3 -3
- package/src/adapter.test.ts +3 -1
- package/src/adapter.ts +1 -1
- package/src/core/canvas-draw.ts +53 -43
- package/src/core/canvas-presets.ts +37 -0
- package/src/core/canvas-renderer.test.ts +60 -1
- package/src/core/canvas-style.ts +149 -0
- package/src/core/engine.ts +2 -0
- package/src/core/geometry.ts +4 -3
- package/src/core/types.ts +30 -3
- package/src/index.ts +4 -0
- package/src/server/index.ts +1 -1
- package/src/server/routes/slots.test.ts +24 -0
- package/src/server/routes/templates.ts +17 -2
- package/src/web/App.tsx +27 -7
- package/src/web/components/CanvasStyleInspector.tsx +95 -0
- package/src/web/components/Header.tsx +25 -10
- package/src/web/components/ParamsInspector.tsx +6 -1
- package/src/web/components/SaveTemplateModal.tsx +106 -0
- package/src/web/components/TemplateDrawer.tsx +323 -0
- package/src/web/hooks/useTypeset.ts +3 -1
- package/dist/assets/index-3Ko1UCBU.css +0 -1
- package/dist/assets/index-De-aHsvp.js +0 -317
- package/src/web/components/TemplateModal.tsx +0 -393
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_BORDER,
|
|
3
|
+
DEFAULT_PATTERN,
|
|
2
4
|
computeStageDimensions,
|
|
3
5
|
getCanvasSpec,
|
|
4
6
|
renderPageToPng,
|
|
5
7
|
renderTypesetResultToPngs,
|
|
6
|
-
resolveDataDir
|
|
7
|
-
|
|
8
|
+
resolveDataDir,
|
|
9
|
+
validateCanvasStyle
|
|
10
|
+
} from "./index-697fkv5y.js";
|
|
8
11
|
import {
|
|
9
12
|
buildLineTextFromRange,
|
|
10
13
|
getLineTextCache,
|
|
@@ -2487,6 +2490,7 @@ function runTypeset(params) {
|
|
|
2487
2490
|
throw new RangeError("Invalid page constraints: maxPages must be 0 or at least minPages");
|
|
2488
2491
|
}
|
|
2489
2492
|
validateTemplateAssets(images);
|
|
2493
|
+
validateCanvasStyle(config.canvasStyle);
|
|
2490
2494
|
if (images.some((image) => !image.repeatEveryPage && image.pageIndex != null && (!Number.isInteger(image.pageIndex) || image.pageIndex < 1 || image.pageIndex > 50))) {
|
|
2491
2495
|
throw new RangeError("Image pageIndex must be an integer between 1 and 50");
|
|
2492
2496
|
}
|
|
@@ -2614,6 +2618,57 @@ function runTypeset(params) {
|
|
|
2614
2618
|
// src/server/routes/templates.ts
|
|
2615
2619
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
2616
2620
|
import { dirname, join } from "path";
|
|
2621
|
+
|
|
2622
|
+
// src/core/canvas-presets.ts
|
|
2623
|
+
var designs = [
|
|
2624
|
+
{ 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" },
|
|
2625
|
+
{ 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" },
|
|
2626
|
+
{ 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" },
|
|
2627
|
+
{ 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.1, font: "Songti SC" },
|
|
2628
|
+
{ 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" },
|
|
2629
|
+
{ 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.1, font: "Kaiti SC" },
|
|
2630
|
+
{ id: "checker", name: "黄油棋盘 · 奶油底色 / 棋盘格", fill: { type: "solid", color: "#faf2d9" }, pattern: "checker", ink: "#3c4937", accent: "#66784c", texture: "#b5bb89", border: "solid", size: 104, opacity: 0.1, font: "PingFang SC" },
|
|
2631
|
+
{ 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" },
|
|
2632
|
+
{ 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" }
|
|
2633
|
+
];
|
|
2634
|
+
var CANVAS_PRESETS = designs.map((design) => {
|
|
2635
|
+
const base = getDefaultConfig("longform-3-4");
|
|
2636
|
+
return {
|
|
2637
|
+
...base,
|
|
2638
|
+
id: `tpl_canvas_${design.id}`,
|
|
2639
|
+
name: design.name,
|
|
2640
|
+
slot: "newspic_content",
|
|
2641
|
+
images: [],
|
|
2642
|
+
algorithm: {
|
|
2643
|
+
...base.algorithm,
|
|
2644
|
+
baseFontSize: 42,
|
|
2645
|
+
scaleRatio: 1.3,
|
|
2646
|
+
targetFillRatio: 0.83,
|
|
2647
|
+
safeArea: { top: 126, right: 108, bottom: 110, left: 108 }
|
|
2648
|
+
},
|
|
2649
|
+
typography: { headingFont: design.font, bodyFont: design.font },
|
|
2650
|
+
theme: {
|
|
2651
|
+
...base.theme,
|
|
2652
|
+
bgColor: design.fill.type === "solid" ? design.fill.color : design.fill.stops[0].color,
|
|
2653
|
+
textColor: design.ink,
|
|
2654
|
+
headingColor: design.ink,
|
|
2655
|
+
accentColor: design.accent,
|
|
2656
|
+
quoteColor: design.accent,
|
|
2657
|
+
captionColor: design.accent,
|
|
2658
|
+
watermarkColor: design.accent
|
|
2659
|
+
},
|
|
2660
|
+
chrome: { ...base.chrome, showHeader: false, showFooter: false },
|
|
2661
|
+
footerText: "",
|
|
2662
|
+
watermarkText: "",
|
|
2663
|
+
canvasStyle: {
|
|
2664
|
+
fill: design.fill,
|
|
2665
|
+
pattern: { ...DEFAULT_PATTERN, pattern: design.pattern, color: design.texture, size: design.size, opacity: design.opacity },
|
|
2666
|
+
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 }
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
// src/server/routes/templates.ts
|
|
2617
2672
|
var TEMPLATES_DIR = join(resolveDataDir(), "templates");
|
|
2618
2673
|
function ensureTemplatesDir() {
|
|
2619
2674
|
if (!existsSync(TEMPLATES_DIR)) {
|
|
@@ -2628,12 +2683,23 @@ function ensureTemplatesDir() {
|
|
|
2628
2683
|
writeFileSync(seedMarker, "1");
|
|
2629
2684
|
} catch {}
|
|
2630
2685
|
}
|
|
2686
|
+
const canvasMarker = join(TEMPLATES_DIR, ".canvas-presets-v1");
|
|
2687
|
+
if (!existsSync(canvasMarker)) {
|
|
2688
|
+
try {
|
|
2689
|
+
for (const preset of CANVAS_PRESETS) {
|
|
2690
|
+
const path = join(TEMPLATES_DIR, `${preset.id}.json`);
|
|
2691
|
+
if (!existsSync(path))
|
|
2692
|
+
writeFileSync(path, JSON.stringify(preset, null, 2), "utf-8");
|
|
2693
|
+
}
|
|
2694
|
+
writeFileSync(canvasMarker, "1");
|
|
2695
|
+
} catch {}
|
|
2696
|
+
}
|
|
2631
2697
|
}
|
|
2632
2698
|
var BUILTIN_PRESETS = [
|
|
2633
2699
|
{
|
|
2634
2700
|
...getDefaultConfig("wechat-cover-2.35"),
|
|
2635
2701
|
id: "tpl_wechat_cover_minimal",
|
|
2636
|
-
name: "微信纯净大图封面 (
|
|
2702
|
+
name: "微信纯净大图封面 (3.35:1)",
|
|
2637
2703
|
slot: "article_cover",
|
|
2638
2704
|
algorithm: {
|
|
2639
2705
|
...getDefaultConfig("wechat-cover-2.35").algorithm,
|
|
@@ -2688,7 +2754,7 @@ var BUILTIN_PRESETS = [
|
|
|
2688
2754
|
{
|
|
2689
2755
|
...getDefaultConfig("wechat-cover-2.35"),
|
|
2690
2756
|
id: "tpl_wechat_cover_editorial",
|
|
2691
|
-
name: "微信杂志标封面 (
|
|
2757
|
+
name: "微信杂志标封面 (3.35:1)",
|
|
2692
2758
|
slot: "article_cover",
|
|
2693
2759
|
algorithm: {
|
|
2694
2760
|
...getDefaultConfig("wechat-cover-2.35").algorithm,
|
|
@@ -2937,7 +3003,8 @@ var BUILTIN_PRESETS = [
|
|
|
2937
3003
|
showFooterText: true,
|
|
2938
3004
|
showFillRatio: false
|
|
2939
3005
|
}
|
|
2940
|
-
}
|
|
3006
|
+
},
|
|
3007
|
+
...CANVAS_PRESETS
|
|
2941
3008
|
];
|
|
2942
3009
|
function seedDefaultTemplates() {
|
|
2943
3010
|
if (!existsSync(TEMPLATES_DIR))
|
|
@@ -3161,6 +3228,7 @@ templatesRouter.post("/", async (c) => {
|
|
|
3161
3228
|
}
|
|
3162
3229
|
try {
|
|
3163
3230
|
validateTemplateAssets(body.images);
|
|
3231
|
+
validateCanvasStyle(body.canvasStyle);
|
|
3164
3232
|
} catch (error) {
|
|
3165
3233
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "无效的模板素材" }, 400);
|
|
3166
3234
|
}
|
package/dist/lib/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
plugin,
|
|
3
3
|
typesetStudioAdapter
|
|
4
|
-
} from "./index-
|
|
4
|
+
} from "./index-dkwbnaj7.js";
|
|
5
5
|
import {
|
|
6
6
|
applyTemplateImages,
|
|
7
7
|
getDefaultConfig,
|
|
8
8
|
resolveTypesetResult,
|
|
9
9
|
runTypeset,
|
|
10
10
|
validateTemplateAssets
|
|
11
|
-
} from "./index-
|
|
11
|
+
} from "./index-qszn98j2.js";
|
|
12
12
|
import {
|
|
13
13
|
drawPageToContext,
|
|
14
14
|
renderPageToCanvas,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
renderTypesetResultToPngs,
|
|
17
17
|
resolveCanvasFont,
|
|
18
18
|
resolveCanvasFontFamily
|
|
19
|
-
} from "./index-
|
|
19
|
+
} from "./index-697fkv5y.js";
|
|
20
20
|
import {
|
|
21
21
|
readFontFamilyName,
|
|
22
22
|
registerServerFonts,
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akairos/typeset-studio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Algorithmic typesetting studio & zzhub-pipeline image renderer plugin",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
10
|
+
"url": "git+https://github.com/k7airos/typeset-studio.git"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://github.com/
|
|
12
|
+
"homepage": "https://github.com/k7airos/typeset-studio#readme",
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "./dist/lib/index.js",
|
package/src/adapter.test.ts
CHANGED
|
@@ -24,7 +24,7 @@ describe("Typeset Studio — zzhub-pipeline ImageRenderPlugin Adapter", () => {
|
|
|
24
24
|
expect(fontCheck?.ok).toBe(true);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it("renders wechat-article
|
|
27
|
+
it("renders wechat-article 3.35:1 cover asset correctly", async () => {
|
|
28
28
|
const outDir = join(tempTestDir, "article");
|
|
29
29
|
const input: ImageRenderInput = {
|
|
30
30
|
title: "大模型架构演进:从密集注意力到专家混合架构",
|
|
@@ -57,6 +57,8 @@ describe("Typeset Studio — zzhub-pipeline ImageRenderPlugin Adapter", () => {
|
|
|
57
57
|
expect(buf[1]).toBe(0x50); // 'P'
|
|
58
58
|
expect(buf[2]).toBe(0x4e); // 'N'
|
|
59
59
|
expect(buf[3]).toBe(0x47); // 'G'
|
|
60
|
+
expect(buf.readUInt32BE(16)).toBe(3350);
|
|
61
|
+
expect(buf.readUInt32BE(20)).toBe(1000);
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
it("renders wechat-newspic 3:4 multi-page poster and articles", async () => {
|
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.
|
|
269
|
+
version: "0.2.6",
|
|
270
270
|
|
|
271
271
|
async doctor(): Promise<PipelinePluginDoctorCheck[]> {
|
|
272
272
|
const checks: PipelinePluginDoctorCheck[] = [];
|
package/src/core/canvas-draw.ts
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
ctx.
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
154
|
-
|
|
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
|
+
});
|
|
@@ -4,7 +4,7 @@ import { renderPageToPng, renderTypesetResultToPngs } from "./canvas-renderer";
|
|
|
4
4
|
import type { PipelineCoverTheme } from "./types";
|
|
5
5
|
|
|
6
6
|
describe("Server-Side Canvas Native PNG Renderer", () => {
|
|
7
|
-
it("renders a single
|
|
7
|
+
it("renders a single 3.35:1 WeChat cover with space for the square thumbnail", async () => {
|
|
8
8
|
const config = getDefaultConfig("wechat-cover-2.35");
|
|
9
9
|
const result = runTypeset({
|
|
10
10
|
markdown: "# 纯算法无头出图测试\n基于 @napi-rs/canvas 毫秒级原生渲染",
|
|
@@ -21,6 +21,9 @@ describe("Server-Side Canvas Native PNG Renderer", () => {
|
|
|
21
21
|
expect(pngBuffer[1]).toBe(0x50);
|
|
22
22
|
expect(pngBuffer[2]).toBe(0x4e);
|
|
23
23
|
expect(pngBuffer[3]).toBe(0x47);
|
|
24
|
+
expect(pngBuffer.readUInt32BE(16)).toBe(3350);
|
|
25
|
+
expect(pngBuffer.readUInt32BE(20)).toBe(1000);
|
|
26
|
+
expect(result.canvasSpec.ratio).toBe(3.35);
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
it("renders multi-page 3:4 longform article into multiple PNG buffers", async () => {
|
|
@@ -105,3 +108,59 @@ it("renders pipeline cover theme gradients, decoration, typography and minimum f
|
|
|
105
108
|
expect(smallArea.config.coverTheme!.typography.title.fontSize).toBeLessThan(font.fontSize);
|
|
106
109
|
expect(smallArea.config.coverTheme!.typography.title.fontSize).toBeGreaterThanOrEqual(font.minFontSize);
|
|
107
110
|
});
|
|
111
|
+
|
|
112
|
+
// 实际像素校验渐变、花纹、边框以及重复渲染的确定性。
|
|
113
|
+
describe("Canvas backgrounds and borders", () => {
|
|
114
|
+
it("draws editable fills, every pattern and every border deterministically", async () => {
|
|
115
|
+
const { createCanvas } = await import("@napi-rs/canvas");
|
|
116
|
+
const { drawCanvasFill, drawCanvasPattern, drawCanvasBorder, validateCanvasStyle, DEFAULT_PATTERN, DEFAULT_BORDER, PATTERN_LABELS, BORDER_LABELS } = await import("./canvas-style");
|
|
117
|
+
const spec = { ...runTypeset({ markdown: "", config: getDefaultConfig() }).canvasSpec, width: 120, height: 160 };
|
|
118
|
+
const canvas = createCanvas(spec.width, spec.height);
|
|
119
|
+
const ctx = canvas.getContext("2d");
|
|
120
|
+
const reset = () => { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, spec.width, spec.height); };
|
|
121
|
+
const pixel = (x: number, y: number) => Array.from(ctx.getImageData(x, y, 1, 1).data);
|
|
122
|
+
const changedPixels = () => Array.from(ctx.getImageData(0, 0, spec.width, spec.height).data).filter((value, index) => index % 4 !== 3 && value !== 255).length;
|
|
123
|
+
drawCanvasFill(ctx as any, spec, { type: "linear", angle: 0, stops: [{ color: "#ff0000", offset: 0 }, { color: "#0000ff", offset: 100 }] });
|
|
124
|
+
expect(pixel(0, 80)[0]).toBeGreaterThan(250);
|
|
125
|
+
expect(pixel(119, 80)[2]).toBeGreaterThan(250);
|
|
126
|
+
drawCanvasFill(ctx as any, spec, { type: "radial", position: { x: 0, y: 0 }, stops: [{ color: "#ffffff", offset: 0 }, { color: "#000000", offset: 100 }] });
|
|
127
|
+
expect(pixel(0, 0)[0]).toBeGreaterThan(pixel(119, 159)[0] + 200);
|
|
128
|
+
for (const pattern of Object.keys(PATTERN_LABELS) as Array<keyof typeof PATTERN_LABELS>) {
|
|
129
|
+
reset();
|
|
130
|
+
drawCanvasPattern(ctx as any, spec, { ...DEFAULT_PATTERN, pattern, color: "#123456", opacity: 1, size: 24 });
|
|
131
|
+
expect(changedPixels() > 0).toBe(pattern !== "none");
|
|
132
|
+
const first = canvas.toBuffer("image/png");
|
|
133
|
+
reset();
|
|
134
|
+
drawCanvasPattern(ctx as any, spec, { ...DEFAULT_PATTERN, pattern, color: "#123456", opacity: 1, size: 24 });
|
|
135
|
+
expect(first.equals(canvas.toBuffer("image/png"))).toBe(true);
|
|
136
|
+
}
|
|
137
|
+
for (const style of Object.keys(BORDER_LABELS) as Array<keyof typeof BORDER_LABELS>) {
|
|
138
|
+
reset();
|
|
139
|
+
drawCanvasBorder(ctx as any, spec, { ...DEFAULT_BORDER, style, inset: 10, width: 2, color: "#123456" });
|
|
140
|
+
expect(changedPixels() > 0).toBe(style !== "none");
|
|
141
|
+
}
|
|
142
|
+
expect(() => validateCanvasStyle({ pattern: { ...DEFAULT_PATTERN, size: 0 } })).toThrow(RangeError);
|
|
143
|
+
expect(() => validateCanvasStyle({ fill: { type: "linear", angle: 0, stops: [{ color: "bad", offset: 0 }, { color: "#000000", offset: 100 }] } })).toThrow(RangeError);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("renders all preset templates and retains borders above a fixed image", async () => {
|
|
147
|
+
const { CANVAS_PRESETS } = await import("./canvas-presets");
|
|
148
|
+
const { validateCanvasStyle } = await import("./canvas-style");
|
|
149
|
+
const { createCanvas } = await import("@napi-rs/canvas");
|
|
150
|
+
const { renderPageToCanvas } = await import("./canvas-renderer");
|
|
151
|
+
for (const config of CANVAS_PRESETS) {
|
|
152
|
+
validateCanvasStyle(config.canvasStyle);
|
|
153
|
+
const result = runTypeset({ markdown: "# 模板试色\n记录想法,让文字留在喜欢的背景上。", config });
|
|
154
|
+
expect(result.pageCount).toBe(1);
|
|
155
|
+
const png = await renderPageToPng(result.pages[0], result.canvasSpec, config);
|
|
156
|
+
expect(png.length).toBeGreaterThan(1000);
|
|
157
|
+
}
|
|
158
|
+
const white = createCanvas(10, 10);
|
|
159
|
+
white.getContext("2d").fillStyle = "#ffffff";
|
|
160
|
+
white.getContext("2d").fillRect(0, 0, 10, 10);
|
|
161
|
+
const config = { ...getDefaultConfig(), canvasStyle: { border: { style: "solid" as const, color: "#ff0000", endColor: "#ff0000", inset: 20, width: 8, radius: 0, opacity: 1 } } };
|
|
162
|
+
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" }] });
|
|
163
|
+
const canvas = await renderPageToCanvas(result.pages[0], result.canvasSpec, config);
|
|
164
|
+
expect(Array.from(canvas.getContext("2d").getImageData(24, 300, 1, 1).data)).toEqual([255, 0, 0, 255]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -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
|
+
}
|