declare-render 1.0.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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/examples/output/sunscreen-guide.png +0 -0
- package/examples/sunscreen-guide.ts +206 -0
- package/package.json +28 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/canvas-renderers/base-renderer.ts +16 -0
- package/src/canvas-renderers/container-renderer/index.ts +151 -0
- package/src/canvas-renderers/img-renderer/index.ts +198 -0
- package/src/canvas-renderers/img-renderer/utils.ts +15 -0
- package/src/canvas-renderers/text-renderer/highlighter.ts +78 -0
- package/src/canvas-renderers/text-renderer/index.ts +390 -0
- package/src/canvas-renderers/text-renderer/types.ts +48 -0
- package/src/index.ts +47 -0
- package/src/types.ts +40 -0
- package/tsconfig.json +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CaiZhuo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Declare Render
|
|
2
|
+
|
|
3
|
+
A TypeScript library for declaratively drawing canvas graphics using JSON schema.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm install
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Renderer } from './src/index.ts';
|
|
15
|
+
import { RenderData } from './src/types.ts';
|
|
16
|
+
|
|
17
|
+
const schema: RenderData = {
|
|
18
|
+
id: "my-canvas",
|
|
19
|
+
width: 800,
|
|
20
|
+
height: 600,
|
|
21
|
+
renderers: [
|
|
22
|
+
{
|
|
23
|
+
id: "text-1",
|
|
24
|
+
type: "text",
|
|
25
|
+
x: 50,
|
|
26
|
+
y: 50,
|
|
27
|
+
width: 700,
|
|
28
|
+
height: 100,
|
|
29
|
+
content: "Hello, World!",
|
|
30
|
+
style: {
|
|
31
|
+
fontName: "Arial",
|
|
32
|
+
fontSize: 32,
|
|
33
|
+
color: "#000000",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const renderer = new Renderer(schema);
|
|
40
|
+
const buffer = await renderer.draw();
|
|
41
|
+
// buffer is a PNG or JPG image buffer
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Text Renderers**: Render text with various styling options
|
|
47
|
+
- **Image Renderers**: Render images or solid color rectangles
|
|
48
|
+
- **Container Renderers**: Organize renderers with flexbox-like layouts
|
|
49
|
+
- **Multiple Output Formats**: PNG and JPG support
|
|
Binary file
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Renderer } from "../src/index";
|
|
2
|
+
import { RenderData, RendererType } from "../src/types";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
|
|
10
|
+
// Content data in JSON format for easy reading and maintenance
|
|
11
|
+
const contentData = {
|
|
12
|
+
title: "防晒指南",
|
|
13
|
+
sections: [
|
|
14
|
+
{
|
|
15
|
+
question: "该不该防晒:",
|
|
16
|
+
answer: "适度防晒"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
question: "怎么防晒:",
|
|
20
|
+
answer: "做好物理防晒,选适配防晒霜(矿物款更安全,适合敏感肌/儿童,化学款慎选成分),避开正午强紫外线,早晚适度日晒。"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
question: "为什么适度:",
|
|
24
|
+
answer: "紫外线会致皮肤DNA突变、加速老化,防晒对护肤和防病至关重要;但完全避光不利健康,且长波长光能穿透防晒和衣物,足够满足合成需求,不用刻意不防晒。维生素D能够调节激素,还与更好的生活质量和寿命相关。"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Dark & Elegant theme canvas configuration (4:3 aspect ratio)
|
|
30
|
+
const CANVAS_CONFIG = {
|
|
31
|
+
width: 1200,
|
|
32
|
+
height: 900, // 4:3 ratio
|
|
33
|
+
padding: 50,
|
|
34
|
+
colors: {
|
|
35
|
+
background: "#121212", // Deep black
|
|
36
|
+
title: "#FFD700", // Gold
|
|
37
|
+
question: "#FFFFFF", // White
|
|
38
|
+
answer: "#B0B0B0", // Light Gray
|
|
39
|
+
cardBg: "#1E1E1E", // Dark Gray Card
|
|
40
|
+
accent: "#FFD700", // Gold
|
|
41
|
+
highlight: "#333333" // Subtle Dark Circle
|
|
42
|
+
},
|
|
43
|
+
fonts: {
|
|
44
|
+
title: "PingFang SC",
|
|
45
|
+
body: "PingFang SC"
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Calculate text height based on content and width
|
|
50
|
+
function calcTextHeight(text: string, fontSize: number, containerWidth: number, lineGap = 8): number {
|
|
51
|
+
// CJK characters are roughly square (width ≈ fontSize)
|
|
52
|
+
const charsPerLine = Math.floor(containerWidth / fontSize);
|
|
53
|
+
const lines = Math.ceil(text.length / charsPerLine);
|
|
54
|
+
return lines * (fontSize + lineGap);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build renderers from content data
|
|
58
|
+
function buildRenderers() {
|
|
59
|
+
const renderers: RenderData["renderers"] = [];
|
|
60
|
+
const contentWidth = CANVAS_CONFIG.width - CANVAS_CONFIG.padding * 2;
|
|
61
|
+
let currentY = CANVAS_CONFIG.padding;
|
|
62
|
+
|
|
63
|
+
// 1. Background
|
|
64
|
+
renderers.push({
|
|
65
|
+
id: "background",
|
|
66
|
+
type: RendererType.IMG,
|
|
67
|
+
x: 0,
|
|
68
|
+
y: 0,
|
|
69
|
+
width: CANVAS_CONFIG.width,
|
|
70
|
+
height: CANVAS_CONFIG.height,
|
|
71
|
+
color: CANVAS_CONFIG.colors.background,
|
|
72
|
+
objectFit: "cover"
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 2. Decorative Dark Sun Circle (top right)
|
|
76
|
+
renderers.push({
|
|
77
|
+
id: "sun-circle",
|
|
78
|
+
type: RendererType.IMG,
|
|
79
|
+
x: CANVAS_CONFIG.width - 180,
|
|
80
|
+
y: -60,
|
|
81
|
+
width: 240,
|
|
82
|
+
height: 240,
|
|
83
|
+
color: CANVAS_CONFIG.colors.highlight,
|
|
84
|
+
radius: 120,
|
|
85
|
+
objectFit: "cover"
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 3. Title
|
|
89
|
+
const titleHeight = 80;
|
|
90
|
+
renderers.push({
|
|
91
|
+
id: "title",
|
|
92
|
+
type: RendererType.TEXT,
|
|
93
|
+
x: CANVAS_CONFIG.padding,
|
|
94
|
+
y: currentY,
|
|
95
|
+
width: contentWidth,
|
|
96
|
+
height: titleHeight,
|
|
97
|
+
content: "☀️ " + contentData.title,
|
|
98
|
+
style: {
|
|
99
|
+
fontName: CANVAS_CONFIG.fonts.title,
|
|
100
|
+
fontSize: 48,
|
|
101
|
+
color: CANVAS_CONFIG.colors.title,
|
|
102
|
+
fontWeight: "bold",
|
|
103
|
+
verticalAlign: "center"
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
currentY += titleHeight + 20;
|
|
107
|
+
|
|
108
|
+
// 4. Content Sections
|
|
109
|
+
const questionFontSize = 24;
|
|
110
|
+
const answerFontSize = 20;
|
|
111
|
+
const cardPadding = 24;
|
|
112
|
+
|
|
113
|
+
contentData.sections.forEach((section, index) => {
|
|
114
|
+
// Card background first (dark rounded rectangle)
|
|
115
|
+
const answerTextWidth = contentWidth - cardPadding * 2;
|
|
116
|
+
const textHeight = calcTextHeight(section.answer, answerFontSize, answerTextWidth, 12);
|
|
117
|
+
const cardHeight = textHeight + cardPadding * 2 + 50; // Extra space for question
|
|
118
|
+
|
|
119
|
+
renderers.push({
|
|
120
|
+
id: `card-bg-${index}`,
|
|
121
|
+
type: RendererType.IMG,
|
|
122
|
+
x: CANVAS_CONFIG.padding,
|
|
123
|
+
y: currentY,
|
|
124
|
+
width: contentWidth,
|
|
125
|
+
height: cardHeight,
|
|
126
|
+
color: CANVAS_CONFIG.colors.cardBg,
|
|
127
|
+
radius: 16,
|
|
128
|
+
objectFit: "cover"
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Question label inside card
|
|
132
|
+
const questionY = currentY + cardPadding;
|
|
133
|
+
renderers.push({
|
|
134
|
+
id: `q-${index}`,
|
|
135
|
+
type: RendererType.TEXT,
|
|
136
|
+
x: CANVAS_CONFIG.padding + cardPadding,
|
|
137
|
+
y: questionY,
|
|
138
|
+
width: contentWidth - cardPadding * 2,
|
|
139
|
+
height: 32,
|
|
140
|
+
content: "▸ " + section.question,
|
|
141
|
+
style: {
|
|
142
|
+
fontName: CANVAS_CONFIG.fonts.body,
|
|
143
|
+
fontSize: questionFontSize,
|
|
144
|
+
color: CANVAS_CONFIG.colors.question,
|
|
145
|
+
fontWeight: "bold",
|
|
146
|
+
verticalAlign: "center"
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Answer text inside card
|
|
151
|
+
const answerY = questionY + 40;
|
|
152
|
+
renderers.push({
|
|
153
|
+
id: `a-${index}`,
|
|
154
|
+
type: RendererType.TEXT,
|
|
155
|
+
x: CANVAS_CONFIG.padding + cardPadding,
|
|
156
|
+
y: answerY,
|
|
157
|
+
width: answerTextWidth,
|
|
158
|
+
height: textHeight + 20,
|
|
159
|
+
content: section.answer,
|
|
160
|
+
style: {
|
|
161
|
+
fontName: CANVAS_CONFIG.fonts.body,
|
|
162
|
+
fontSize: answerFontSize,
|
|
163
|
+
color: CANVAS_CONFIG.colors.answer,
|
|
164
|
+
verticalGap: 12,
|
|
165
|
+
verticalAlign: "top"
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
currentY += cardHeight + 24;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return renderers;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Create canvas schema
|
|
176
|
+
const schema: RenderData = {
|
|
177
|
+
id: "sunscreen-guide",
|
|
178
|
+
width: CANVAS_CONFIG.width,
|
|
179
|
+
height: CANVAS_CONFIG.height,
|
|
180
|
+
renderers: buildRenderers(),
|
|
181
|
+
output: {
|
|
182
|
+
type: "png"
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Main execution
|
|
187
|
+
async function main() {
|
|
188
|
+
console.log("Generating fresh sunscreen guide image...");
|
|
189
|
+
console.log("Content:", JSON.stringify(contentData, null, 2));
|
|
190
|
+
|
|
191
|
+
const renderer = new Renderer(schema);
|
|
192
|
+
const buffer = await renderer.draw();
|
|
193
|
+
|
|
194
|
+
// Ensure output directory exists
|
|
195
|
+
const outputDir = path.join(__dirname, "output");
|
|
196
|
+
if (!fs.existsSync(outputDir)) {
|
|
197
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const outputPath = path.join(outputDir, "sunscreen-guide.png");
|
|
201
|
+
fs.writeFileSync(outputPath, buffer);
|
|
202
|
+
|
|
203
|
+
console.log(`Image saved to: ${outputPath}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "declare-render",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "You can declare canvas shapes by JSON format.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"JSON",
|
|
8
|
+
"Canvas",
|
|
9
|
+
"Renderer"
|
|
10
|
+
],
|
|
11
|
+
"author": "Joey",
|
|
12
|
+
"license": "ISC",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"canvas": "^3.2.1",
|
|
15
|
+
"lodash-es": "^4.17.21"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/lodash-es": "^4.17.12",
|
|
19
|
+
"@types/node": "^20.19.30",
|
|
20
|
+
"ts-node": "^10.9.2",
|
|
21
|
+
"tsx": "^4.7.0",
|
|
22
|
+
"typescript": "^5.3.3"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"rebuild:canvas": "pnpm rebuild canvas"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ContainerRenderData } from "./container-renderer";
|
|
2
|
+
import { ImgRenderData } from "./img-renderer";
|
|
3
|
+
import { TextRenderData } from "./text-renderer/types";
|
|
4
|
+
|
|
5
|
+
export abstract class BaseRender<
|
|
6
|
+
T extends TextRenderData | ImgRenderData | ContainerRenderData,
|
|
7
|
+
> {
|
|
8
|
+
protected data!: T;
|
|
9
|
+
abstract get container(): { x1: number; x2: number; y1: number; y2: number };
|
|
10
|
+
abstract layout(): Promise<this>;
|
|
11
|
+
public setPosition = (x: number, y: number) => {
|
|
12
|
+
this.data.x = x;
|
|
13
|
+
this.data.y = y;
|
|
14
|
+
return this.layout();
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ChildRenderers, RendererType } from "../../types";
|
|
2
|
+
import { type CanvasRenderingContext2D } from "canvas";
|
|
3
|
+
import { cloneDeep, isNumber, isObject, isUndefined } from "lodash-es";
|
|
4
|
+
import { BaseRender } from "../base-renderer";
|
|
5
|
+
import { ImgRenderData, ImgRender } from "../img-renderer/index";
|
|
6
|
+
import { TextRenderData } from "../text-renderer/types";
|
|
7
|
+
import { TextRender } from "../text-renderer/index";
|
|
8
|
+
|
|
9
|
+
export interface ContainerRenderData {
|
|
10
|
+
id: string | number
|
|
11
|
+
type: RendererType.CONTAINER,
|
|
12
|
+
x: number, // The x coordinate of the container's start point. In container renderers, as a child renderer, 0 means relative to the container's 0; if not declared, its position depends on adjacent renderers and spacing.
|
|
13
|
+
y: number, // The y coordinate of the container's start point. As a child renderer, 0 means relative to the container's 0; if not declared, its position depends on adjacent renderers and spacing.
|
|
14
|
+
width: number, // The width of the container renderer
|
|
15
|
+
height: number // The height of the container renderer
|
|
16
|
+
direction?: "row" | "column" // The layout direction of the container (horizontal/vertical)
|
|
17
|
+
itemAlign?: "center" // The alignment of container elements in the secondary direction (vertical/horizontal)
|
|
18
|
+
gap?: number | {x: number, y: number} // The distance between elements
|
|
19
|
+
wrap?: boolean; // Whether elements wrap if overflow
|
|
20
|
+
renderers: ChildRenderers // Child renderers declaration
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ContainerRenderer extends BaseRender<ContainerRenderData> {
|
|
24
|
+
private ctx: CanvasRenderingContext2D
|
|
25
|
+
private renderers: Array<ImgRender | TextRender | ContainerRenderer> = []
|
|
26
|
+
|
|
27
|
+
constructor(ctx: CanvasRenderingContext2D, data: ContainerRenderData) {
|
|
28
|
+
super()
|
|
29
|
+
this.ctx = ctx
|
|
30
|
+
this.data = cloneDeep(data)
|
|
31
|
+
this.ctx.patternQuality = 'best'
|
|
32
|
+
this.ctx.quality = 'best'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get container() {
|
|
36
|
+
return this.getContainerCoordinates()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private createRenderer = (renderData: ImgRenderData | TextRenderData | ContainerRenderData) => {
|
|
40
|
+
switch (renderData.type) {
|
|
41
|
+
case RendererType.TEXT: {
|
|
42
|
+
return new TextRender(this.ctx, renderData, { inFlexFlow: isUndefined(renderData.x) || isUndefined(renderData.y) })
|
|
43
|
+
}
|
|
44
|
+
case RendererType.IMG:
|
|
45
|
+
return new ImgRender(this.ctx, renderData)
|
|
46
|
+
case RendererType.CONTAINER:
|
|
47
|
+
return new ContainerRenderer(this.ctx, renderData)
|
|
48
|
+
default:
|
|
49
|
+
throw new Error("[Renderer] unknown renderer type")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// TODO center the renderers, vertical center, left align, wrap the renderers;
|
|
54
|
+
public layout = async () => {
|
|
55
|
+
const layoutedRenderers = [] as Array<ImgRender | TextRender | ContainerRenderer>
|
|
56
|
+
|
|
57
|
+
for (const index in this.data.renderers) {
|
|
58
|
+
const renderData = cloneDeep(this.data.renderers[index]) as ImgRenderData | TextRenderData | ContainerRenderData
|
|
59
|
+
|
|
60
|
+
let renderX = renderData.x
|
|
61
|
+
let renderY = renderData.y
|
|
62
|
+
// 子 renderer 的 x、y 相对容器的 x、y 进行定位
|
|
63
|
+
if (isNumber(renderX)) {
|
|
64
|
+
renderX += this.data.x
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isNumber(renderY)) {
|
|
68
|
+
renderY += this.data.y
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// justify
|
|
72
|
+
if (isUndefined(renderX) || isUndefined(renderY)) {
|
|
73
|
+
const preRenderer = layoutedRenderers.at(-1) as undefined | ImgRender | TextRender | ContainerRenderer
|
|
74
|
+
const { x2, y1, x1, y2 } = preRenderer?.container || { x1: this.data.x, x2: this.data.x, y1: this.data.y, y2: this.data.y }
|
|
75
|
+
|
|
76
|
+
const gapX = !preRenderer ?
|
|
77
|
+
0 : isObject(this.data.gap) ?
|
|
78
|
+
this.data.gap.x : (this.data.gap || 0)
|
|
79
|
+
|
|
80
|
+
const gapY = !preRenderer ?
|
|
81
|
+
0 : isObject(this.data.gap) ?
|
|
82
|
+
this.data.gap.y : (this.data.gap || 0)
|
|
83
|
+
|
|
84
|
+
if (this.data.direction === 'column') {
|
|
85
|
+
renderX = renderX || x1
|
|
86
|
+
renderY = renderY || (y2 + gapY)
|
|
87
|
+
} else {
|
|
88
|
+
renderX = renderX || (x2 + gapX)
|
|
89
|
+
renderY = renderY || y1
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const currentRenderer = this.createRenderer({ ...renderData, x: renderX, y: renderY })
|
|
94
|
+
|
|
95
|
+
await currentRenderer.layout()
|
|
96
|
+
|
|
97
|
+
layoutedRenderers.push(currentRenderer)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// align items
|
|
101
|
+
if (this.data.itemAlign === 'center') {
|
|
102
|
+
const squares = layoutedRenderers.map(r => {
|
|
103
|
+
const { x1, x2, y1, y2 } = r.container
|
|
104
|
+
return { ...r.container, width: x2 - x1, height: y2 - y1, renderer: r }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const maxWidth = Math.max(...squares.map(s => s.width))
|
|
108
|
+
const maxHeight = Math.max(...squares.map(s => s.height))
|
|
109
|
+
|
|
110
|
+
squares.forEach(s => {
|
|
111
|
+
if (this.data.direction === 'column') {
|
|
112
|
+
const offset = (maxWidth - s.width) / 2
|
|
113
|
+
s.renderer.setPosition(s.x1 + offset, s.y1)
|
|
114
|
+
} else {
|
|
115
|
+
const offset = (maxHeight - s.height) / 2
|
|
116
|
+
s.renderer.setPosition(s.x1, s.y1 + offset)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.renderers = layoutedRenderers
|
|
122
|
+
return this
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// TODO rotate the whole renderer
|
|
126
|
+
public draw = async() => {
|
|
127
|
+
if (!this.renderers.length) {
|
|
128
|
+
return this
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const pickedOne = this.renderers.shift()!
|
|
132
|
+
await pickedOne.draw()
|
|
133
|
+
|
|
134
|
+
await this.draw()
|
|
135
|
+
|
|
136
|
+
return this
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// TODO handle the renderers wrapping, add gap;
|
|
140
|
+
private getContainerCoordinates = () => {
|
|
141
|
+
if (!this.renderers.length) throw new Error("can not get container coordinates before layouted")
|
|
142
|
+
const firstChild = this.renderers.at(0) as ImgRender | TextRender
|
|
143
|
+
const lastChild = this.renderers.at(-1) as ImgRender | TextRender
|
|
144
|
+
return {
|
|
145
|
+
x1: firstChild.container.x1,
|
|
146
|
+
y1: firstChild.container.y1,
|
|
147
|
+
x2: lastChild.container.x2,
|
|
148
|
+
y2: lastChild.container.y2,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { BaseRender } from "../base-renderer";
|
|
2
|
+
import { cloneDeep, isNumber } from "lodash-es";
|
|
3
|
+
import { loadImage, Image, type CanvasRenderingContext2D } from "canvas";
|
|
4
|
+
import { getImageHeight, getImageRatio, getImageWidth } from "./utils";
|
|
5
|
+
|
|
6
|
+
export type ImgRenderData = {
|
|
7
|
+
id: string;
|
|
8
|
+
type: "img";
|
|
9
|
+
x: number; // The x-coordinate of the image container's starting point. When used as a child in a container renderer, 0 means positioned relative to the container's 0. If not specified, the position is determined by adjacent renderers and gap.
|
|
10
|
+
y: number; // The y-coordinate of the image container's starting point. As a child, 0 means positioned relative to the container's 0. If not specified, the position is determined by adjacent renderers and gap.
|
|
11
|
+
/*
|
|
12
|
+
If both width and height are provided, they determine the image's width and height.
|
|
13
|
+
If only width or height is provided, the other will be determined proportionally.
|
|
14
|
+
If neither width nor height is provided, the image will be rendered at its original dimensions.
|
|
15
|
+
*/
|
|
16
|
+
width?: number; // The width of the image container
|
|
17
|
+
height?: number; // The height of the image container
|
|
18
|
+
shadow?: {
|
|
19
|
+
// The shadow of the image container. Can be added directly to the rendered shape, instead of rendering a separate renderer.
|
|
20
|
+
color: string; // The shadow color
|
|
21
|
+
blur: number; // Shadow blur
|
|
22
|
+
X: number; // Shadow offset on the x-axis
|
|
23
|
+
Y: number; // Shadow offset on the y-axis
|
|
24
|
+
};
|
|
25
|
+
radius?: number; // The corner radius of the image container
|
|
26
|
+
url?: string; // Image source
|
|
27
|
+
color?: string; // Background color. If url is not provided, a pure color block will be rendered.
|
|
28
|
+
rotate?: number; // The angle, calculated from the center point
|
|
29
|
+
globalAlpha?: number; // Opacity
|
|
30
|
+
objectFit: "contain" | "cover"; // If both width and height are provided, determines how the image fits within the container
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class ImgRender extends BaseRender<ImgRenderData> {
|
|
34
|
+
ctx: CanvasRenderingContext2D;
|
|
35
|
+
width: number = 0;
|
|
36
|
+
height: number = 0;
|
|
37
|
+
imageWidth: number = 0;
|
|
38
|
+
imageHeight: number = 0;
|
|
39
|
+
data: ImgRenderData;
|
|
40
|
+
|
|
41
|
+
private image?: Image;
|
|
42
|
+
|
|
43
|
+
constructor(ctx: CanvasRenderingContext2D, data: ImgRenderData) {
|
|
44
|
+
super();
|
|
45
|
+
this.ctx = ctx;
|
|
46
|
+
this.data = cloneDeep(data);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get container() {
|
|
50
|
+
return this.getContainerCoordinates();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async drawImage(x: number, y: number, image: Image) {
|
|
54
|
+
const marginX = this.width - this.imageWidth;
|
|
55
|
+
const marginY = this.height - this.imageHeight;
|
|
56
|
+
|
|
57
|
+
this.ctx.save();
|
|
58
|
+
|
|
59
|
+
if (isNumber(this.data.globalAlpha)) {
|
|
60
|
+
this.ctx.globalAlpha = this.data.globalAlpha;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.data.shadow) {
|
|
64
|
+
this.ctx.shadowBlur = this.data.shadow.blur;
|
|
65
|
+
this.ctx.shadowColor = this.data.shadow.color;
|
|
66
|
+
this.ctx.shadowOffsetX = this.data.shadow.X || 0;
|
|
67
|
+
this.ctx.shadowOffsetY = this.data.shadow.Y || 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.ctx.drawImage(
|
|
71
|
+
image,
|
|
72
|
+
x + marginX / 2,
|
|
73
|
+
y + marginY / 2,
|
|
74
|
+
this.imageWidth,
|
|
75
|
+
this.imageHeight,
|
|
76
|
+
);
|
|
77
|
+
this.ctx.restore();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private drawColor(x: number, y: number) {
|
|
81
|
+
const { width, height } = this;
|
|
82
|
+
this.ctx.save();
|
|
83
|
+
|
|
84
|
+
if (!this.data.color) return;
|
|
85
|
+
|
|
86
|
+
this.ctx.fillStyle = this.data.color;
|
|
87
|
+
this.ctx.beginPath();
|
|
88
|
+
this.ctx.roundRect(x, y, width, height, this.data.radius || 0);
|
|
89
|
+
this.ctx.fill();
|
|
90
|
+
this.ctx.restore();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
layout = async () => {
|
|
94
|
+
const { url, width, height } = this.data;
|
|
95
|
+
const image = url ? await loadImage(url) : null;
|
|
96
|
+
|
|
97
|
+
if (!image) {
|
|
98
|
+
if (!width || !height) {
|
|
99
|
+
throw new Error("img renderer without url should specify size");
|
|
100
|
+
}
|
|
101
|
+
this.width = width;
|
|
102
|
+
this.height = height;
|
|
103
|
+
this.imageWidth = 0;
|
|
104
|
+
this.imageHeight = 0;
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.image = image;
|
|
109
|
+
|
|
110
|
+
if (!width) {
|
|
111
|
+
if (!height) {
|
|
112
|
+
this.imageWidth = image.naturalWidth;
|
|
113
|
+
this.imageHeight = image.naturalHeight;
|
|
114
|
+
this.width = this.imageWidth;
|
|
115
|
+
this.height = this.imageHeight;
|
|
116
|
+
} else {
|
|
117
|
+
this.imageWidth = getImageWidth(image, height);
|
|
118
|
+
this.imageHeight = height;
|
|
119
|
+
this.width = this.imageWidth;
|
|
120
|
+
this.height = this.imageHeight;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
if (!height) {
|
|
124
|
+
this.imageWidth = width;
|
|
125
|
+
this.imageHeight = getImageHeight(image, width);
|
|
126
|
+
this.width = this.imageWidth;
|
|
127
|
+
this.height = this.imageHeight;
|
|
128
|
+
} else {
|
|
129
|
+
const ratio = getImageRatio(image);
|
|
130
|
+
const objectFit = this.data.objectFit || "contain";
|
|
131
|
+
|
|
132
|
+
if (objectFit === "cover" ? ratio > 1 : ratio <= 1) {
|
|
133
|
+
this.imageWidth = getImageWidth(image, height);
|
|
134
|
+
this.imageHeight = height;
|
|
135
|
+
} else {
|
|
136
|
+
this.imageWidth = width;
|
|
137
|
+
this.imageHeight = getImageHeight(image, width);
|
|
138
|
+
}
|
|
139
|
+
this.width = width;
|
|
140
|
+
this.height = height;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return this;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
draw = async () => {
|
|
147
|
+
const { x, y, rotate } = this.data;
|
|
148
|
+
const drawImpl = async (x: number, y: number) => {
|
|
149
|
+
if (this.data.color) {
|
|
150
|
+
this.drawColor(x, y);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.image) {
|
|
154
|
+
await this.drawImage(x, y, this.image);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (!rotate) {
|
|
159
|
+
await drawImpl(x, y);
|
|
160
|
+
} else {
|
|
161
|
+
const { width, height } = this;
|
|
162
|
+
|
|
163
|
+
this.ctx.save();
|
|
164
|
+
|
|
165
|
+
const actualHeight = height
|
|
166
|
+
? height
|
|
167
|
+
: !this.image
|
|
168
|
+
? 0
|
|
169
|
+
: (this.image.naturalHeight / this.image.naturalWidth) * width;
|
|
170
|
+
|
|
171
|
+
this.ctx.translate(x + width / 2, y + actualHeight / 2);
|
|
172
|
+
|
|
173
|
+
this.ctx.rotate((rotate * Math.PI) / 180);
|
|
174
|
+
|
|
175
|
+
this.ctx.translate(-(x + width / 2), -(y + actualHeight / 2));
|
|
176
|
+
|
|
177
|
+
await drawImpl(x, y);
|
|
178
|
+
|
|
179
|
+
this.ctx.restore();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return this;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
public getContainerCoordinates = () => {
|
|
186
|
+
const { x, y } = this.data;
|
|
187
|
+
|
|
188
|
+
const { width, height } = this;
|
|
189
|
+
return {
|
|
190
|
+
x1: x,
|
|
191
|
+
y1: y,
|
|
192
|
+
|
|
193
|
+
x2: x + width,
|
|
194
|
+
|
|
195
|
+
y2: y + height!,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Image } from "canvas";
|
|
2
|
+
|
|
3
|
+
export function getImageRatio(image: Image) {
|
|
4
|
+
return image.naturalWidth / image.naturalHeight
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getImageHeight(image: Image, width: number) {
|
|
8
|
+
const ratio = image.naturalWidth / image.naturalHeight
|
|
9
|
+
return width / ratio
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getImageWidth(image: Image, height: number) {
|
|
13
|
+
const ratio = image.naturalWidth / image.naturalHeight
|
|
14
|
+
return height * ratio
|
|
15
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { CanvasRenderingContext2D, Image } from "canvas";
|
|
2
|
+
import { MetricsCharWithCoordinates } from "../../types";
|
|
3
|
+
import { TextRenderData } from "./types";
|
|
4
|
+
|
|
5
|
+
export enum HighlightType {
|
|
6
|
+
UNDERLINE = "underline",
|
|
7
|
+
COLORED = "colored",
|
|
8
|
+
HALF_RECTANGLE = "halfRectangle",
|
|
9
|
+
SVG = "svg",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class Highlighter {
|
|
13
|
+
ctx: CanvasRenderingContext2D;
|
|
14
|
+
static highlightByChar = [
|
|
15
|
+
HighlightType.UNDERLINE,
|
|
16
|
+
HighlightType.COLORED,
|
|
17
|
+
HighlightType.HALF_RECTANGLE,
|
|
18
|
+
] as const;
|
|
19
|
+
static highlightByWord = [HighlightType.SVG] as const;
|
|
20
|
+
|
|
21
|
+
constructor(ctx: CanvasRenderingContext2D) {
|
|
22
|
+
this.ctx = ctx;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
svg(
|
|
26
|
+
word: MetricsCharWithCoordinates[],
|
|
27
|
+
svgImage: Image,
|
|
28
|
+
style: NonNullable<TextRenderData["style"]["highlight"]>["style"],
|
|
29
|
+
) {
|
|
30
|
+
const width = word.reduce((sum, c) => sum + c.metrics.width, 0);
|
|
31
|
+
const startY = style?.coverText
|
|
32
|
+
? word[0].Y - word[0].boundingHeight
|
|
33
|
+
: word[0].Y;
|
|
34
|
+
const height = style?.coverText ? word[0].boundingHeight : style?.height;
|
|
35
|
+
this.ctx.drawImage(
|
|
36
|
+
svgImage,
|
|
37
|
+
word[0].X,
|
|
38
|
+
startY + (style?.offsetY || 0),
|
|
39
|
+
width,
|
|
40
|
+
height || word[0].boundingHeight,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
colorText = (c: MetricsCharWithCoordinates, color: string) => {
|
|
45
|
+
this.ctx.save();
|
|
46
|
+
this.ctx.fillStyle = color;
|
|
47
|
+
this.ctx.fillText(c.char, c.X, c.Y);
|
|
48
|
+
this.ctx.restore();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
rectFill = (c: MetricsCharWithCoordinates, color: string) => {
|
|
52
|
+
this.ctx.save();
|
|
53
|
+
this.ctx.fillStyle = color;
|
|
54
|
+
this.ctx.fillRect(
|
|
55
|
+
c.X,
|
|
56
|
+
c.Y - Math.abs(c.metrics.alphabeticBaseline) - 4,
|
|
57
|
+
c.metrics.width,
|
|
58
|
+
c.emHeight / 3,
|
|
59
|
+
);
|
|
60
|
+
this.ctx.restore();
|
|
61
|
+
this.ctx.fillText(c.char, c.X, c.Y);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
underLine = (c: MetricsCharWithCoordinates, color: string) => {
|
|
65
|
+
const x = c.X;
|
|
66
|
+
const y = c.Y;
|
|
67
|
+
this.ctx.beginPath();
|
|
68
|
+
this.ctx.strokeStyle = color;
|
|
69
|
+
this.ctx.lineWidth = 8;
|
|
70
|
+
this.ctx.moveTo(x, y + c.metrics.actualBoundingBoxDescent);
|
|
71
|
+
this.ctx.lineTo(
|
|
72
|
+
x + c.metrics.width,
|
|
73
|
+
y + c.metrics.actualBoundingBoxDescent,
|
|
74
|
+
);
|
|
75
|
+
this.ctx.stroke();
|
|
76
|
+
this.ctx.fillText(c.char, c.X, c.Y);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { Image, loadImage, type CanvasRenderingContext2D } from "canvas";
|
|
2
|
+
import {
|
|
3
|
+
cloneDeep,
|
|
4
|
+
groupBy,
|
|
5
|
+
isNumber,
|
|
6
|
+
isObject,
|
|
7
|
+
isUndefined,
|
|
8
|
+
last,
|
|
9
|
+
map,
|
|
10
|
+
values,
|
|
11
|
+
} from "lodash-es";
|
|
12
|
+
import { BaseRender } from "../base-renderer";
|
|
13
|
+
import { TextRenderData, highlightLogics } from "./types";
|
|
14
|
+
|
|
15
|
+
import { MetricsCharWithCoordinates } from "../../types";
|
|
16
|
+
import { MetricsChar } from "../../types";
|
|
17
|
+
import { Highlighter, HighlightType } from "./highlighter";
|
|
18
|
+
|
|
19
|
+
const rightFlow =
|
|
20
|
+
<T>(opts: any[]) =>
|
|
21
|
+
() =>
|
|
22
|
+
opts.reverse().reduce((ret, cur) => cur(ret), undefined) as T;
|
|
23
|
+
|
|
24
|
+
export class TextRender extends BaseRender<TextRenderData> {
|
|
25
|
+
private ctx: CanvasRenderingContext2D;
|
|
26
|
+
private highlighter: Highlighter;
|
|
27
|
+
private lines: MetricsCharWithCoordinates[][] = [];
|
|
28
|
+
private svg?: Image;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
ctx: CanvasRenderingContext2D,
|
|
32
|
+
data: TextRenderData,
|
|
33
|
+
options?: { inFlexFlow?: boolean },
|
|
34
|
+
) {
|
|
35
|
+
super();
|
|
36
|
+
this.ctx = ctx;
|
|
37
|
+
this.data = cloneDeep(data);
|
|
38
|
+
this.highlighter = new Highlighter(ctx);
|
|
39
|
+
|
|
40
|
+
if (options?.inFlexFlow) {
|
|
41
|
+
this.data.style.verticalAlign = undefined;
|
|
42
|
+
this.data.style.align = undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get container() {
|
|
47
|
+
if (!this.lines) {
|
|
48
|
+
throw new Error("can not get container before layout in text renderer");
|
|
49
|
+
}
|
|
50
|
+
return this.getContainerCoordinates(this.lines);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public layout = async () => {
|
|
54
|
+
const highlightSVGUrl = this.data.style.highlight?.style?.url;
|
|
55
|
+
|
|
56
|
+
if (highlightSVGUrl) {
|
|
57
|
+
const image = await loadImage(highlightSVGUrl);
|
|
58
|
+
this.svg = image;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lines = rightFlow<MetricsCharWithCoordinates[][]>([
|
|
62
|
+
this.coordinateLines,
|
|
63
|
+
this.metricsLines,
|
|
64
|
+
this.ensureFontStyles,
|
|
65
|
+
this.parseFontSize,
|
|
66
|
+
])();
|
|
67
|
+
|
|
68
|
+
this.lines = lines;
|
|
69
|
+
return this;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
public draw = () => {
|
|
73
|
+
rightFlow<MetricsCharWithCoordinates[][]>([
|
|
74
|
+
this.restoreRotatedCanvas,
|
|
75
|
+
this.drawChars,
|
|
76
|
+
() => this.drawBackground(this.lines),
|
|
77
|
+
this.rotateCanvas,
|
|
78
|
+
this.ensureFontStyles,
|
|
79
|
+
])();
|
|
80
|
+
return this;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
private restoreRotatedCanvas = () => {
|
|
84
|
+
const { rotate } = this.data;
|
|
85
|
+
if (rotate) {
|
|
86
|
+
this.ctx.restore();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
private parseFontSize = () => {
|
|
91
|
+
const fontSize = this.data.style.fontSize;
|
|
92
|
+
|
|
93
|
+
if (isObject(fontSize)) {
|
|
94
|
+
const fontObject = fontSize as { max: number; min: number };
|
|
95
|
+
const count = this.data.content.length;
|
|
96
|
+
const { horizonalGap = 0 } = this.data.style;
|
|
97
|
+
const maxWidth = this.data.width;
|
|
98
|
+
|
|
99
|
+
this.data.style.fontSize = Math.min(
|
|
100
|
+
Math.max(
|
|
101
|
+
(maxWidth - (count - 1) * horizonalGap) / count,
|
|
102
|
+
fontObject.min,
|
|
103
|
+
),
|
|
104
|
+
fontObject.max,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
private rotateCanvas = () => {
|
|
110
|
+
const { x, y, rotate, width, height } = this.data;
|
|
111
|
+
|
|
112
|
+
if (rotate) {
|
|
113
|
+
this.ctx.save();
|
|
114
|
+
this.ctx.translate(x + width / 2, y + height / 2);
|
|
115
|
+
this.ctx.rotate((rotate * Math.PI) / 180);
|
|
116
|
+
this.ctx.translate(-(x + width / 2), -(y + height / 2));
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
private ensureFontStyles = () => {
|
|
121
|
+
const fontSize = this.data.style.fontSize;
|
|
122
|
+
const fontName = this.data.style.fontName;
|
|
123
|
+
const fontWeight = this.data.style.fontWeight || "";
|
|
124
|
+
this.ctx.font = `${fontSize}px ${fontWeight} "${fontName}"`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
private metricsLines = () => {
|
|
128
|
+
const containerWidth = this.data.width;
|
|
129
|
+
const horizonalGap = this.data.style.horizonalGap || 0;
|
|
130
|
+
const fontSize = this.data.style.fontSize;
|
|
131
|
+
const oneLineMaxWidth = containerWidth;
|
|
132
|
+
|
|
133
|
+
if (!isNumber(fontSize)) {
|
|
134
|
+
throw new Error("[Renderer] field fontSize should be number type");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return this.data.content
|
|
138
|
+
.split("")
|
|
139
|
+
.map((char, index) => {
|
|
140
|
+
const metrics = this.ctx.measureText(char) as MetricsChar["metrics"];
|
|
141
|
+
const boundingHeight =
|
|
142
|
+
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
|
143
|
+
const emHeight = metrics.emHeightAscent + metrics.emHeightDescent;
|
|
144
|
+
return { char, metrics, index, fontSize, boundingHeight, emHeight };
|
|
145
|
+
})
|
|
146
|
+
.reduce<MetricsChar[][]>(
|
|
147
|
+
(lines, metricsChar) => {
|
|
148
|
+
const lastLine = lines.at(-1)!;
|
|
149
|
+
|
|
150
|
+
const curWidth = lastLine.reduce((sumWidth, cur, index) => {
|
|
151
|
+
if (index !== 0 && index !== lastLine.length) {
|
|
152
|
+
return cur.metrics.width + horizonalGap + sumWidth;
|
|
153
|
+
}
|
|
154
|
+
return cur.metrics.width + sumWidth;
|
|
155
|
+
}, 0);
|
|
156
|
+
if (curWidth + metricsChar.metrics.width > oneLineMaxWidth) {
|
|
157
|
+
lines.push([metricsChar]);
|
|
158
|
+
} else {
|
|
159
|
+
lastLine.push(metricsChar);
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
},
|
|
163
|
+
[[]],
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
private drawChar = (c: MetricsCharWithCoordinates) => {
|
|
168
|
+
this.ctx.fillStyle = this.data.style.color;
|
|
169
|
+
this.ctx.fillText(c.char, c.X, c.Y);
|
|
170
|
+
if (this.data.style.border) {
|
|
171
|
+
this.ctx.save();
|
|
172
|
+
|
|
173
|
+
this.ctx.lineWidth = this.data.style.border.width || 1;
|
|
174
|
+
this.ctx.strokeStyle = this.data.style.border.color;
|
|
175
|
+
this.ctx.strokeText(c.char, c.X, c.Y);
|
|
176
|
+
|
|
177
|
+
this.ctx.restore();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
private coordinateLines = (lines: MetricsChar[][]) => {
|
|
182
|
+
const {
|
|
183
|
+
width: containerWidth,
|
|
184
|
+
height: containerHeight,
|
|
185
|
+
x: containerStartX,
|
|
186
|
+
y: containerStartY,
|
|
187
|
+
} = this.data;
|
|
188
|
+
const { fontSize, verticalGap = 0, horizonalGap = 0 } = this.data.style;
|
|
189
|
+
|
|
190
|
+
if (!isNumber(fontSize)) {
|
|
191
|
+
throw new Error("[Renderer] field fontSize should be number type");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const boundingHeightsSum = lines
|
|
195
|
+
.map((line) => Math.max(...line.map((c) => c.boundingHeight)))
|
|
196
|
+
.reduce((ret, maxHeightAline) => ret + maxHeightAline, 0);
|
|
197
|
+
const totalVerticalGap = (lines.length - 1) * verticalGap;
|
|
198
|
+
|
|
199
|
+
const paddingY = (() => {
|
|
200
|
+
switch (this.data.style.verticalAlign) {
|
|
201
|
+
case "center":
|
|
202
|
+
return (containerHeight - boundingHeightsSum - totalVerticalGap) / 2;
|
|
203
|
+
case "bottom":
|
|
204
|
+
return containerHeight - boundingHeightsSum - totalVerticalGap;
|
|
205
|
+
case "top":
|
|
206
|
+
default:
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
|
|
211
|
+
let preY = containerStartY + paddingY;
|
|
212
|
+
|
|
213
|
+
return lines.map((mline, lineIndex) => {
|
|
214
|
+
const currentLineWidth =
|
|
215
|
+
mline.reduce((sum, mc) => sum + mc.metrics.width, 0) +
|
|
216
|
+
(horizonalGap * mline.length - 1);
|
|
217
|
+
|
|
218
|
+
const paddingLeft = (() => {
|
|
219
|
+
switch (this.data.style.align) {
|
|
220
|
+
case "center":
|
|
221
|
+
return (containerWidth - currentLineWidth) / 2;
|
|
222
|
+
case "right":
|
|
223
|
+
return containerWidth - currentLineWidth;
|
|
224
|
+
default:
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
})();
|
|
228
|
+
|
|
229
|
+
let preX = containerStartX + paddingLeft;
|
|
230
|
+
|
|
231
|
+
const maxHeightAline = Math.max(
|
|
232
|
+
...mline.map((c) => c.metrics.actualBoundingBoxAscent),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
let Y = (preY =
|
|
236
|
+
preY + maxHeightAline + (lineIndex === 0 ? 0 : verticalGap));
|
|
237
|
+
|
|
238
|
+
return mline.map((mseg, index) => {
|
|
239
|
+
const _gap = index === 0 ? 0 : horizonalGap;
|
|
240
|
+
|
|
241
|
+
const preWidth = mline[index - 1]?.metrics.width || 0;
|
|
242
|
+
|
|
243
|
+
let X = (preX = preX + _gap + preWidth);
|
|
244
|
+
|
|
245
|
+
return { ...mseg, X, Y };
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
private drawChars = (lines: MetricsCharWithCoordinates[][]) => {
|
|
251
|
+
const { highlight } = this.data.style;
|
|
252
|
+
if (!highlight || !highlight.type) {
|
|
253
|
+
lines.flat().forEach(this.drawChar);
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const highlightWord = highlightLogics.word;
|
|
258
|
+
|
|
259
|
+
const startIndex = this.data.content.indexOf(highlightWord);
|
|
260
|
+
|
|
261
|
+
const highlightChars = lines
|
|
262
|
+
.map((line, lineIndex) => line.map((c) => ({ lineIndex, c })))
|
|
263
|
+
.flat()
|
|
264
|
+
.map(({ lineIndex, c }, cIndex) => ({ lineIndex, cIndex, c }))
|
|
265
|
+
.slice(startIndex, startIndex + highlightWord.length)
|
|
266
|
+
.map(({ c, cIndex, lineIndex }) => ({
|
|
267
|
+
...c,
|
|
268
|
+
cIndex: cIndex,
|
|
269
|
+
lineIndex: lineIndex,
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
const highlightType = highlight.type;
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
Highlighter.highlightByChar.includes(
|
|
276
|
+
highlightType as (typeof Highlighter.highlightByChar)[number],
|
|
277
|
+
)
|
|
278
|
+
) {
|
|
279
|
+
const indexes = map(highlightChars, "cIndex");
|
|
280
|
+
lines
|
|
281
|
+
.flat()
|
|
282
|
+
.filter((c) => indexes.includes(c.index))
|
|
283
|
+
.forEach((c) => {
|
|
284
|
+
if (!highlight.color)
|
|
285
|
+
throw new Error(
|
|
286
|
+
"color is required for highlight type:" + highlightType,
|
|
287
|
+
);
|
|
288
|
+
switch (highlightType) {
|
|
289
|
+
case HighlightType.COLORED:
|
|
290
|
+
this.highlighter.colorText(c, highlight.color);
|
|
291
|
+
break;
|
|
292
|
+
case HighlightType.HALF_RECTANGLE:
|
|
293
|
+
this.highlighter.rectFill(c, highlight.color);
|
|
294
|
+
break;
|
|
295
|
+
case HighlightType.UNDERLINE:
|
|
296
|
+
this.highlighter.underLine(c, highlight.color);
|
|
297
|
+
break;
|
|
298
|
+
default:
|
|
299
|
+
throw new Error(
|
|
300
|
+
`[Renderer] unknown highlight type with ${highlightType})`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
lines
|
|
305
|
+
.flat()
|
|
306
|
+
.filter((c) =>
|
|
307
|
+
highlight && indexes.length ? !indexes.includes(c.index) : true,
|
|
308
|
+
)
|
|
309
|
+
.forEach(this.drawChar);
|
|
310
|
+
return lines;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
switch (highlight.type) {
|
|
314
|
+
case HighlightType.SVG:
|
|
315
|
+
values(groupBy(highlightChars, "lineIndex")).forEach((chars) => {
|
|
316
|
+
if (!highlight.style)
|
|
317
|
+
throw new Error("style field is required for highlight type: ");
|
|
318
|
+
if (!this.svg)
|
|
319
|
+
throw new Error(
|
|
320
|
+
"svg url in highlight style is not correct: " + highlight.style,
|
|
321
|
+
);
|
|
322
|
+
this.highlighter.svg(chars, this.svg, highlight.style);
|
|
323
|
+
});
|
|
324
|
+
lines.flat().forEach(this.drawChar);
|
|
325
|
+
break;
|
|
326
|
+
default:
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return lines;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
private drawBackground = (lines: MetricsCharWithCoordinates[][]) => {
|
|
334
|
+
if (!this.data.style.backgroundColor) return lines;
|
|
335
|
+
const { x1, y1, x2, y2 } = this.getContainerCoordinates(lines);
|
|
336
|
+
|
|
337
|
+
this.ctx.save();
|
|
338
|
+
|
|
339
|
+
this.ctx.fillStyle = this.data.style.backgroundColor;
|
|
340
|
+
|
|
341
|
+
this.ctx.beginPath();
|
|
342
|
+
|
|
343
|
+
this.ctx.roundRect(x1, y1, x2 - x1, y2 - y1, this.data.style.radius || 0);
|
|
344
|
+
|
|
345
|
+
this.ctx.fill();
|
|
346
|
+
|
|
347
|
+
this.ctx.restore();
|
|
348
|
+
|
|
349
|
+
return lines;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
private getContainerCoordinates = (lines: MetricsCharWithCoordinates[][]) => {
|
|
353
|
+
const { horizonalGap = 0, padding } = this.data.style;
|
|
354
|
+
|
|
355
|
+
if (!lines) throw new Error("can not get text coordinates before layouted");
|
|
356
|
+
|
|
357
|
+
const maxWidth = lines.reduce(
|
|
358
|
+
(maxWidth, line) =>
|
|
359
|
+
Math.max(
|
|
360
|
+
maxWidth,
|
|
361
|
+
line.reduce((sum, mc) => sum + mc.metrics.width, 0) +
|
|
362
|
+
horizonalGap * (line.length - 1),
|
|
363
|
+
),
|
|
364
|
+
0,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const startWord = lines[0][0];
|
|
368
|
+
const endWord = last(last(lines))!;
|
|
369
|
+
|
|
370
|
+
const actualPadding = isUndefined(padding)
|
|
371
|
+
? { x: 0, y: 0 }
|
|
372
|
+
: isObject(padding)
|
|
373
|
+
? padding
|
|
374
|
+
: { x: padding, y: padding };
|
|
375
|
+
|
|
376
|
+
const firstLineTopest = Math.max(
|
|
377
|
+
...lines[0].map((c) => c.metrics.actualBoundingBoxAscent),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const x1 = startWord.X - actualPadding.x;
|
|
381
|
+
const y1 = startWord.Y - firstLineTopest - actualPadding.y;
|
|
382
|
+
return {
|
|
383
|
+
x1,
|
|
384
|
+
y1,
|
|
385
|
+
x2: x1 + maxWidth + actualPadding.x * 2,
|
|
386
|
+
y2:
|
|
387
|
+
endWord.Y + endWord.metrics.actualBoundingBoxDescent + actualPadding.y,
|
|
388
|
+
};
|
|
389
|
+
};
|
|
390
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { RendererType } from "../../types";
|
|
2
|
+
import { HighlightType } from "./highlighter";
|
|
3
|
+
|
|
4
|
+
export enum highlightLogics {
|
|
5
|
+
word = "word",
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TextRenderData {
|
|
9
|
+
id: string | number;
|
|
10
|
+
type: RendererType.TEXT; // Always "text"
|
|
11
|
+
x: number; // The x-coordinate of the text container's starting point. As a child, 0 means relative to the container's origin. If not specified, the position depends on adjacent renderers and spacing.
|
|
12
|
+
y: number; // The y-coordinate of the text container's starting point. As a child, 0 means relative to the container's origin. If not specified, the position depends on adjacent renderers and spacing.
|
|
13
|
+
width: number; // The width of the text container. Exceeding text will wrap.
|
|
14
|
+
height: number; // The height of the text container. Exceeding text will overflow.
|
|
15
|
+
rotate?: number; // Rotation angle, calculated from the center of the object
|
|
16
|
+
content: string; // Text content
|
|
17
|
+
style: {
|
|
18
|
+
// Text style customization
|
|
19
|
+
/* This property should currently be disabled when under a container; */
|
|
20
|
+
align?: "center" | "right"; // Text horizontal alignment
|
|
21
|
+
/* This property should currently be disabled when under a container; */
|
|
22
|
+
verticalAlign?: "center" | "top" | "bottom"; // Text vertical alignment
|
|
23
|
+
fontName: string; // Font name. Supported fonts are:
|
|
24
|
+
fontSize: number | { max: number; min: number }; // Font size. Supports objects for adaptive width to fill container.
|
|
25
|
+
backgroundColor?: string; // Text background color
|
|
26
|
+
padding?: number | { x: number; y: number }; // Text padding
|
|
27
|
+
border?: { color: string; width?: number }; // Text border
|
|
28
|
+
color: string; // Text color
|
|
29
|
+
radius?: number; // Text background border radius
|
|
30
|
+
verticalGap?: number; // Line gap
|
|
31
|
+
horizonalGap?: number; // Gap between characters in the same line
|
|
32
|
+
fontWeight?: string; // Font weight, same as CSS
|
|
33
|
+
highlight?: {
|
|
34
|
+
// Text highlight style
|
|
35
|
+
logics: highlightLogics; // Highlight word logic
|
|
36
|
+
color?: string; // Highlight color. When highlight type is not svg, this field is optional.
|
|
37
|
+
content?: string; // Highlighted text content. When highlight type is not svg, this field is optional.
|
|
38
|
+
type?: HighlightType; // Highlight style
|
|
39
|
+
style?: {
|
|
40
|
+
// When highlight type is not svg, this field should be empty
|
|
41
|
+
height?: number; // Height of the highlight graphic
|
|
42
|
+
offsetY?: number; // Vertical offset of the highlight graphic from the top, starting at the text rectangle point
|
|
43
|
+
coverText?: boolean; // Whether the highlight should cover the text. If true, height is set to text height
|
|
44
|
+
url: string; // Source of the highlight graphic
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Canvas, createCanvas, type CanvasRenderingContext2D } from "canvas";
|
|
2
|
+
import { RenderData, RendererType } from "./types";
|
|
3
|
+
import { ContainerRenderer } from "./canvas-renderers/container-renderer/index";
|
|
4
|
+
|
|
5
|
+
export class Renderer {
|
|
6
|
+
canvas: Canvas;
|
|
7
|
+
schema: RenderData;
|
|
8
|
+
ctx: CanvasRenderingContext2D;
|
|
9
|
+
|
|
10
|
+
constructor(schema: RenderData) {
|
|
11
|
+
const { width, height } = schema;
|
|
12
|
+
this.schema = schema;
|
|
13
|
+
this.canvas = createCanvas(width, height);
|
|
14
|
+
this.ctx = this.canvas.getContext("2d");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
draw = async () => {
|
|
18
|
+
if (!this.schema.renderers.length) {
|
|
19
|
+
throw new Error("[Renderer] empty canvas with no renderers");
|
|
20
|
+
}
|
|
21
|
+
const container = new ContainerRenderer(this.ctx, {
|
|
22
|
+
id: this.schema.id,
|
|
23
|
+
type: RendererType.CONTAINER,
|
|
24
|
+
x: 0,
|
|
25
|
+
y: 0,
|
|
26
|
+
width: this.canvas.width,
|
|
27
|
+
height: this.canvas.height,
|
|
28
|
+
renderers: this.schema.renderers,
|
|
29
|
+
});
|
|
30
|
+
await container.layout().then((d) => d.draw());
|
|
31
|
+
return this.toBuffer();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
private toBuffer() {
|
|
35
|
+
const type = this.schema.output?.type || "png";
|
|
36
|
+
switch (type) {
|
|
37
|
+
case "jpg":
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
return this.canvas.toBuffer("image/jpeg", { alpha: true });
|
|
40
|
+
case "png":
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
return this.canvas.toBuffer("image/png", { alpha: true });
|
|
43
|
+
default:
|
|
44
|
+
throw new Error("[Renderer] unknown output type");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ContainerRenderData } from "./canvas-renderers/container-renderer";
|
|
2
|
+
import { ImgRenderData } from "./canvas-renderers/img-renderer";
|
|
3
|
+
import { TextRenderData } from "./canvas-renderers/text-renderer/types";
|
|
4
|
+
import type { TextMetrics } from "canvas";
|
|
5
|
+
|
|
6
|
+
export interface RenderData {
|
|
7
|
+
id: string;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
renderers: (ImgRenderData | TextRenderData | ContainerRenderData)[];
|
|
11
|
+
output?: {
|
|
12
|
+
type?: "png" | "jpg";
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export enum RendererType {
|
|
17
|
+
CONTAINER = "container",
|
|
18
|
+
TEXT = "text",
|
|
19
|
+
IMG = "img",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MetricsChar {
|
|
23
|
+
char: string;
|
|
24
|
+
index: number;
|
|
25
|
+
emHeight: number;
|
|
26
|
+
fontSize: number;
|
|
27
|
+
metrics: TextMetrics & {
|
|
28
|
+
alphabeticBaseline: number;
|
|
29
|
+
emHeightAscent: number;
|
|
30
|
+
emHeightDescent: number;
|
|
31
|
+
};
|
|
32
|
+
boundingHeight: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MetricsCharWithCoordinates extends MetricsChar {
|
|
36
|
+
X: number;
|
|
37
|
+
Y: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ChildRenderers = (ImgRenderData | TextRenderData | ContainerRenderData)[];
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"allowSyntheticDefaultImports": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true
|
|
15
|
+
},
|
|
16
|
+
"ts-node": {
|
|
17
|
+
"esm": true,
|
|
18
|
+
"experimentalSpecifierResolution": "node"
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*"],
|
|
21
|
+
"exclude": ["node_modules"]
|
|
22
|
+
}
|