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 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
@@ -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,2 @@
1
+ onlyBuiltDependencies:
2
+ - canvas
@@ -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
+ }