declare-render 1.0.3 → 1.0.4

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.
@@ -2,6 +2,7 @@ import {
2
2
  ChildRenderers,
3
3
  ImgRenderData,
4
4
  RendererType,
5
+ ShapeRenderData,
5
6
  TextRenderData,
6
7
  } from "../../types";
7
8
  import type { ContainerRenderData } from "../../types";
@@ -10,12 +11,13 @@ import { cloneDeep, isNumber, isObject, isUndefined } from "lodash-es";
10
11
  import { BaseRender } from "../base-renderer";
11
12
  import { ImgRender } from "../img-renderer/index";
12
13
  import { TextRender } from "../text-renderer/index";
14
+ import { ShapeRender } from "../shape-render/index";
13
15
 
14
16
  export type { ContainerRenderData } from "../../types";
15
17
 
16
18
  export class ContainerRenderer extends BaseRender<ContainerRenderData> {
17
19
  private ctx: CanvasRenderingContext2D
18
- private renderers: Array<ImgRender | TextRender | ContainerRenderer> = []
20
+ private renderers: Array<ImgRender | TextRender | ShapeRender | ContainerRenderer> = []
19
21
 
20
22
  constructor(ctx: CanvasRenderingContext2D, data: ContainerRenderData) {
21
23
  super()
@@ -29,13 +31,15 @@ export class ContainerRenderer extends BaseRender<ContainerRenderData> {
29
31
  return this.getContainerCoordinates()
30
32
  }
31
33
 
32
- private createRenderer = (renderData: ImgRenderData | TextRenderData | ContainerRenderData) => {
34
+ private createRenderer = (renderData: ImgRenderData | TextRenderData | ShapeRenderData | ContainerRenderData) => {
33
35
  switch (renderData.type) {
34
36
  case RendererType.TEXT: {
35
37
  return new TextRender(this.ctx, renderData, { inFlexFlow: isUndefined(renderData.x) || isUndefined(renderData.y) })
36
38
  }
37
39
  case RendererType.IMG:
38
40
  return new ImgRender(this.ctx, renderData)
41
+ case RendererType.SHAPE:
42
+ return new ShapeRender(this.ctx, renderData)
39
43
  case RendererType.CONTAINER:
40
44
  return new ContainerRenderer(this.ctx, renderData)
41
45
  default:
@@ -45,10 +49,10 @@ export class ContainerRenderer extends BaseRender<ContainerRenderData> {
45
49
 
46
50
  // TODO center the renderers, vertical center, left align, wrap the renderers;
47
51
  public layout = async () => {
48
- const layoutedRenderers = [] as Array<ImgRender | TextRender | ContainerRenderer>
52
+ const layoutedRenderers = [] as Array<ImgRender | TextRender | ShapeRender | ContainerRenderer>
49
53
 
50
54
  for (const index in this.data.renderers) {
51
- const renderData = cloneDeep(this.data.renderers[index]) as ImgRenderData | TextRenderData | ContainerRenderData
55
+ const renderData = cloneDeep(this.data.renderers[index]) as ImgRenderData | TextRenderData | ShapeRenderData | ContainerRenderData
52
56
 
53
57
  let renderX = renderData.x
54
58
  let renderY = renderData.y
@@ -63,7 +67,7 @@ export class ContainerRenderer extends BaseRender<ContainerRenderData> {
63
67
 
64
68
  // justify
65
69
  if (isUndefined(renderX) || isUndefined(renderY)) {
66
- const preRenderer = layoutedRenderers.at(-1) as undefined | ImgRender | TextRender | ContainerRenderer
70
+ const preRenderer = layoutedRenderers.at(-1) as undefined | ImgRender | TextRender | ShapeRender | ContainerRenderer
67
71
  const { x2, y1, x1, y2 } = preRenderer?.container || { x1: this.data.x, x2: this.data.x, y1: this.data.y, y2: this.data.y }
68
72
 
69
73
  const gapX = !preRenderer ?
@@ -132,8 +136,8 @@ export class ContainerRenderer extends BaseRender<ContainerRenderData> {
132
136
  // TODO handle the renderers wrapping, add gap;
133
137
  private getContainerCoordinates = () => {
134
138
  if (!this.renderers.length) throw new Error("can not get container coordinates before layouted")
135
- const firstChild = this.renderers.at(0) as ImgRender | TextRender
136
- const lastChild = this.renderers.at(-1) as ImgRender | TextRender
139
+ const firstChild = this.renderers.at(0) as ImgRender | TextRender | ShapeRender
140
+ const lastChild = this.renderers.at(-1) as ImgRender | TextRender | ShapeRender
137
141
  return {
138
142
  x1: firstChild.container.x1,
139
143
  y1: firstChild.container.y1,
@@ -0,0 +1,279 @@
1
+ import { BaseRender } from "../base-renderer";
2
+ import type { ShapeRenderData, ShapeCommand } from "../../types";
3
+ import { cloneDeep, isNumber } from "lodash-es";
4
+ import { type CanvasRenderingContext2D } from "canvas";
5
+
6
+ export type { ShapeRenderData } from "../../types";
7
+
8
+ export class ShapeRender extends BaseRender<ShapeRenderData> {
9
+ private ctx: CanvasRenderingContext2D;
10
+ private computedWidth: number = 0;
11
+ private computedHeight: number = 0;
12
+
13
+ constructor(ctx: CanvasRenderingContext2D, data: ShapeRenderData) {
14
+ super();
15
+ this.ctx = ctx;
16
+ this.data = cloneDeep(data);
17
+ }
18
+
19
+ get container() {
20
+ return this.getContainerCoordinates();
21
+ }
22
+
23
+ private applyStyle() {
24
+ const style = this.data.style;
25
+ if (!style) return;
26
+
27
+ if (style.fillStyle) {
28
+ this.ctx.fillStyle = style.fillStyle;
29
+ }
30
+ if (style.strokeStyle) {
31
+ this.ctx.strokeStyle = style.strokeStyle;
32
+ }
33
+ if (isNumber(style.lineWidth)) {
34
+ this.ctx.lineWidth = style.lineWidth;
35
+ }
36
+ if (style.lineCap) {
37
+ this.ctx.lineCap = style.lineCap;
38
+ }
39
+ if (style.lineJoin) {
40
+ this.ctx.lineJoin = style.lineJoin;
41
+ }
42
+ if (isNumber(style.miterLimit)) {
43
+ this.ctx.miterLimit = style.miterLimit;
44
+ }
45
+ if (style.lineDash) {
46
+ this.ctx.setLineDash(style.lineDash);
47
+ }
48
+ if (isNumber(style.lineDashOffset)) {
49
+ this.ctx.lineDashOffset = style.lineDashOffset;
50
+ }
51
+ if (isNumber(style.globalAlpha)) {
52
+ this.ctx.globalAlpha = style.globalAlpha;
53
+ }
54
+ }
55
+
56
+ private applyShadow() {
57
+ if (this.data.shadow) {
58
+ this.ctx.shadowBlur = this.data.shadow.blur;
59
+ this.ctx.shadowColor = this.data.shadow.color;
60
+ this.ctx.shadowOffsetX = this.data.shadow.X;
61
+ this.ctx.shadowOffsetY = this.data.shadow.Y;
62
+ }
63
+ }
64
+
65
+ private executeCommand(cmd: ShapeCommand, offsetX: number, offsetY: number) {
66
+ switch (cmd.type) {
67
+ case "rect":
68
+ this.ctx.rect(cmd.x + offsetX, cmd.y + offsetY, cmd.width, cmd.height);
69
+ break;
70
+ case "fillRect":
71
+ this.ctx.fillRect(
72
+ cmd.x + offsetX,
73
+ cmd.y + offsetY,
74
+ cmd.width,
75
+ cmd.height,
76
+ );
77
+ break;
78
+ case "strokeRect":
79
+ this.ctx.strokeRect(
80
+ cmd.x + offsetX,
81
+ cmd.y + offsetY,
82
+ cmd.width,
83
+ cmd.height,
84
+ );
85
+ break;
86
+ case "clearRect":
87
+ this.ctx.clearRect(
88
+ cmd.x + offsetX,
89
+ cmd.y + offsetY,
90
+ cmd.width,
91
+ cmd.height,
92
+ );
93
+ break;
94
+ case "beginPath":
95
+ this.ctx.beginPath();
96
+ break;
97
+ case "closePath":
98
+ this.ctx.closePath();
99
+ break;
100
+ case "moveTo":
101
+ this.ctx.moveTo(cmd.x + offsetX, cmd.y + offsetY);
102
+ break;
103
+ case "lineTo":
104
+ this.ctx.lineTo(cmd.x + offsetX, cmd.y + offsetY);
105
+ break;
106
+ case "arc":
107
+ this.ctx.arc(
108
+ cmd.x + offsetX,
109
+ cmd.y + offsetY,
110
+ cmd.radius,
111
+ cmd.startAngle,
112
+ cmd.endAngle,
113
+ cmd.counterclockwise || false,
114
+ );
115
+ break;
116
+ case "arcTo":
117
+ this.ctx.arcTo(
118
+ cmd.x1 + offsetX,
119
+ cmd.y1 + offsetY,
120
+ cmd.x2 + offsetX,
121
+ cmd.y2 + offsetY,
122
+ cmd.radius,
123
+ );
124
+ break;
125
+ case "quadraticCurveTo":
126
+ this.ctx.quadraticCurveTo(
127
+ cmd.cp1x + offsetX,
128
+ cmd.cp1y + offsetY,
129
+ cmd.x + offsetX,
130
+ cmd.y + offsetY,
131
+ );
132
+ break;
133
+ case "bezierCurveTo":
134
+ this.ctx.bezierCurveTo(
135
+ cmd.cp1x + offsetX,
136
+ cmd.cp1y + offsetY,
137
+ cmd.cp2x + offsetX,
138
+ cmd.cp2y + offsetY,
139
+ cmd.x + offsetX,
140
+ cmd.y + offsetY,
141
+ );
142
+ break;
143
+ case "fill":
144
+ this.ctx.fill();
145
+ break;
146
+ case "stroke":
147
+ this.ctx.stroke();
148
+ break;
149
+ case "fillAndStroke":
150
+ this.ctx.fill();
151
+ this.ctx.stroke();
152
+ break;
153
+ }
154
+ }
155
+
156
+ private computeBounds(): { width: number; height: number } {
157
+ if (this.data.width && this.data.height) {
158
+ return { width: this.data.width, height: this.data.height };
159
+ }
160
+
161
+ if (!this.data.shapes || this.data.shapes.length === 0) {
162
+ return { width: 0, height: 0 };
163
+ }
164
+
165
+ let minX = Infinity;
166
+ let minY = Infinity;
167
+ let maxX = -Infinity;
168
+ let maxY = -Infinity;
169
+
170
+ for (const cmd of this.data.shapes) {
171
+ switch (cmd.type) {
172
+ case "rect":
173
+ case "fillRect":
174
+ case "strokeRect":
175
+ case "clearRect":
176
+ minX = Math.min(minX, cmd.x);
177
+ minY = Math.min(minY, cmd.y);
178
+ maxX = Math.max(maxX, cmd.x + cmd.width);
179
+ maxY = Math.max(maxY, cmd.y + cmd.height);
180
+ break;
181
+ case "moveTo":
182
+ case "lineTo":
183
+ minX = Math.min(minX, cmd.x);
184
+ minY = Math.min(minY, cmd.y);
185
+ maxX = Math.max(maxX, cmd.x);
186
+ maxY = Math.max(maxY, cmd.y);
187
+ break;
188
+ case "arc":
189
+ minX = Math.min(minX, cmd.x - cmd.radius);
190
+ minY = Math.min(minY, cmd.y - cmd.radius);
191
+ maxX = Math.max(maxX, cmd.x + cmd.radius);
192
+ maxY = Math.max(maxY, cmd.y + cmd.radius);
193
+ break;
194
+ case "arcTo":
195
+ minX = Math.min(minX, cmd.x1, cmd.x2);
196
+ minY = Math.min(minY, cmd.y1, cmd.y2);
197
+ maxX = Math.max(maxX, cmd.x1, cmd.x2);
198
+ maxY = Math.max(maxY, cmd.y1, cmd.y2);
199
+ break;
200
+ case "quadraticCurveTo":
201
+ case "bezierCurveTo":
202
+ minX = Math.min(minX, cmd.cp1x, cmd.x);
203
+ minY = Math.min(minY, cmd.cp1y, cmd.y);
204
+ maxX = Math.max(maxX, cmd.cp1x, cmd.x);
205
+ maxY = Math.max(maxY, cmd.cp1y, cmd.y);
206
+ if (cmd.type === "bezierCurveTo") {
207
+ minX = Math.min(minX, cmd.cp2x);
208
+ minY = Math.min(minY, cmd.cp2y);
209
+ maxX = Math.max(maxX, cmd.cp2x);
210
+ maxY = Math.max(maxY, cmd.cp2y);
211
+ }
212
+ break;
213
+ }
214
+ }
215
+
216
+ if (minX === Infinity) {
217
+ return { width: 0, height: 0 };
218
+ }
219
+
220
+ return {
221
+ width: maxX - minX,
222
+ height: maxY - minY,
223
+ };
224
+ }
225
+
226
+ public layout = async () => {
227
+ const bounds = this.computeBounds();
228
+ this.computedWidth = this.data.width ?? bounds.width;
229
+ this.computedHeight = this.data.height ?? bounds.height;
230
+ return this;
231
+ };
232
+
233
+ public draw = async () => {
234
+ const { x, y, rotate } = this.data;
235
+ const offsetX = x;
236
+ const offsetY = y;
237
+
238
+ const drawImpl = () => {
239
+ this.ctx.save();
240
+ this.applyStyle();
241
+ this.applyShadow();
242
+
243
+ for (const cmd of this.data.shapes) {
244
+ this.executeCommand(cmd, offsetX, offsetY);
245
+ }
246
+
247
+ this.ctx.restore();
248
+ };
249
+
250
+ if (!rotate) {
251
+ drawImpl();
252
+ } else {
253
+ const { computedWidth, computedHeight } = this;
254
+ const centerX = x + computedWidth / 2;
255
+ const centerY = y + computedHeight / 2;
256
+
257
+ this.ctx.save();
258
+ this.ctx.translate(centerX, centerY);
259
+ this.ctx.rotate((rotate * Math.PI) / 180);
260
+ this.ctx.translate(-centerX, -centerY);
261
+
262
+ drawImpl();
263
+
264
+ this.ctx.restore();
265
+ }
266
+
267
+ return this;
268
+ };
269
+
270
+ public getContainerCoordinates = () => {
271
+ const { x, y } = this.data;
272
+ return {
273
+ x1: x,
274
+ y1: y,
275
+ x2: x + this.computedWidth,
276
+ y2: y + this.computedHeight,
277
+ };
278
+ };
279
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { ContainerRenderer } from "./canvas-renderers/container-renderer/index";
5
5
  export type {
6
6
  ContainerRenderData,
7
7
  ImgRenderData,
8
+ ShapeRenderData,
8
9
  TextRenderData,
9
10
  RenderData,
10
11
  } from "./types";
@@ -23,7 +24,7 @@ export class Renderer {
23
24
  }
24
25
 
25
26
  draw = async () => {
26
- if (!this.schema.renderers.length) {
27
+ if (!this.schema.layers.length) {
27
28
  throw new Error("[Renderer] empty canvas with no renderers");
28
29
  }
29
30
  const container = new ContainerRenderer(this.ctx, {
@@ -33,7 +34,7 @@ export class Renderer {
33
34
  y: 0,
34
35
  width: this.canvas.width,
35
36
  height: this.canvas.height,
36
- renderers: this.schema.renderers,
37
+ renderers: this.schema.layers,
37
38
  });
38
39
  await container.layout().then((d) => d.draw());
39
40
  return this.toBuffer();
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ export enum RendererType {
4
4
  CONTAINER = "container",
5
5
  TEXT = "text",
6
6
  IMG = "img",
7
+ SHAPE = "shape",
7
8
  }
8
9
 
9
10
  // ----- TypeScript types (primitives only) -----
@@ -70,13 +71,97 @@ export interface ContainerRenderData {
70
71
  renderers: ChildRenderers;
71
72
  }
72
73
 
73
- export type ChildRenderers = (ImgRenderData | TextRenderData | ContainerRenderData)[];
74
+ export interface ShapeRenderData {
75
+ id: string | number;
76
+ type: "shape";
77
+ x: number;
78
+ y: number;
79
+ width?: number;
80
+ height?: number;
81
+ rotate?: number;
82
+ style?: {
83
+ fillStyle?: string;
84
+ strokeStyle?: string;
85
+ lineWidth?: number;
86
+ lineCap?: "butt" | "round" | "square";
87
+ lineJoin?: "bevel" | "round" | "miter";
88
+ miterLimit?: number;
89
+ lineDash?: number[];
90
+ lineDashOffset?: number;
91
+ globalAlpha?: number;
92
+ };
93
+ shadow?: {
94
+ color: string;
95
+ blur: number;
96
+ X: number;
97
+ Y: number;
98
+ };
99
+ shapes: ShapeCommand[];
100
+ }
101
+
102
+ export type ShapeCommand =
103
+ | { type: "rect"; x: number; y: number; width: number; height: number }
104
+ | { type: "fillRect"; x: number; y: number; width: number; height: number }
105
+ | { type: "strokeRect"; x: number; y: number; width: number; height: number }
106
+ | { type: "clearRect"; x: number; y: number; width: number; height: number }
107
+ | { type: "beginPath" }
108
+ | { type: "closePath" }
109
+ | { type: "moveTo"; x: number; y: number }
110
+ | { type: "lineTo"; x: number; y: number }
111
+ | {
112
+ type: "arc";
113
+ x: number;
114
+ y: number;
115
+ radius: number;
116
+ startAngle: number;
117
+ endAngle: number;
118
+ counterclockwise?: boolean;
119
+ }
120
+ | {
121
+ type: "arcTo";
122
+ x1: number;
123
+ y1: number;
124
+ x2: number;
125
+ y2: number;
126
+ radius: number;
127
+ }
128
+ | {
129
+ type: "quadraticCurveTo";
130
+ cp1x: number;
131
+ cp1y: number;
132
+ x: number;
133
+ y: number;
134
+ }
135
+ | {
136
+ type: "bezierCurveTo";
137
+ cp1x: number;
138
+ cp1y: number;
139
+ cp2x: number;
140
+ cp2y: number;
141
+ x: number;
142
+ y: number;
143
+ }
144
+ | { type: "fill" }
145
+ | { type: "stroke" }
146
+ | { type: "fillAndStroke" };
147
+
148
+ export type ChildRenderers = (
149
+ | ImgRenderData
150
+ | TextRenderData
151
+ | ContainerRenderData
152
+ | ShapeRenderData
153
+ )[];
74
154
 
75
155
  export interface RenderData {
76
156
  id: string;
77
157
  width: number;
78
158
  height: number;
79
- renderers: (ImgRenderData | TextRenderData | ContainerRenderData)[];
159
+ layers: (
160
+ | ImgRenderData
161
+ | TextRenderData
162
+ | ContainerRenderData
163
+ | ShapeRenderData
164
+ )[];
80
165
  output?: {
81
166
  type?: "png" | "jpg";
82
167
  };
@@ -85,13 +170,17 @@ export interface RenderData {
85
170
  // ----- String schema for AI (readable as string) -----
86
171
 
87
172
  export const RENDER_DATA_SCHEMA = `
88
- RenderData: { "id": string, "width": number, "height": number, "renderers": Array<TextRenderData | ImgRenderData | ContainerRenderData>, "output"?: { "type"?: "png" | "jpg" } }
173
+ RenderData: { "id": string, "width": number, "height": number, "layers": Array<TextRenderData | ImgRenderData | ContainerRenderData | ShapeRenderData>, "output"?: { "type"?: "png" | "jpg" } }
89
174
 
90
175
  TextRenderData: { "id": string|number, "type": "text", "x": number, "y": number, "width": number, "height": number, "content": string, "style": { "fontName": string, "fontSize": number | { "max": number, "min": number }, "color": string, "align"?: "center"|"right", "verticalAlign"?: "center"|"top"|"bottom", "fontWeight"?: string, "verticalGap"?: number, "backgroundColor"?: string, "padding"?: number|{ "x": number, "y": number }, "border"?: { "color": string, "width"?: number }, "radius"?: number }, "rotate"?: number }
91
176
 
92
177
  ImgRenderData: { "id": string, "type": "img", "x": number, "y": number, "width"?: number, "height"?: number, "url"?: string, "color"?: string, "objectFit": "contain"|"cover", "radius"?: number, "rotate"?: number, "globalAlpha"?: number, "shadow"?: { "color": string, "blur": number, "X": number, "Y": number } }
93
178
 
94
179
  ContainerRenderData: { "id": string|number, "type": "container", "x": number, "y": number, "width": number, "height": number, "renderers": ChildRenderers[], "direction"?: "row"|"column", "gap"?: number|{ "x": number, "y": number }, "itemAlign"?: "center", "wrap"?: boolean }
180
+
181
+ ShapeRenderData: { "id": string|number, "type": "shape", "x": number, "y": number, "width"?: number, "height"?: number, "rotate"?: number, "style"?: { "fillStyle"?: string, "strokeStyle"?: string, "lineWidth"?: number, "lineCap"?: "butt"|"round"|"square", "lineJoin"?: "bevel"|"round"|"miter", "miterLimit"?: number, "lineDash"?: number[], "lineDashOffset"?: number, "globalAlpha"?: number }, "shadow"?: { "color": string, "blur": number, "X": number, "Y": number }, "shapes": Array<ShapeCommand> }
182
+
183
+ ShapeCommand: { "type": "rect"|"fillRect"|"strokeRect"|"clearRect"|"beginPath"|"closePath"|"moveTo"|"lineTo"|"arc"|"arcTo"|"quadraticCurveTo"|"bezierCurveTo"|"fill"|"stroke"|"fillAndStroke", ...additional properties based on type }
95
184
  `.trim();
96
185
 
97
186
  // ----- Metrics (canvas-dependent) -----
package/tsconfig.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "target": "ES2020",
4
4
  "module": "ESNext",
5
5
  "lib": ["ES2020"],
6
- "moduleResolution": "node",
6
+ "moduleResolution": "bundler",
7
7
  "esModuleInterop": true,
8
8
  "allowSyntheticDefaultImports": true,
9
9
  "strict": true,
@@ -11,12 +11,14 @@
11
11
  "forceConsistentCasingInFileNames": true,
12
12
  "resolveJsonModule": true,
13
13
  "isolatedModules": true,
14
- "noEmit": true
14
+ "noEmit": true,
15
+ "types": ["node"],
16
+ "allowImportingTsExtensions": true
15
17
  },
16
18
  "ts-node": {
17
19
  "esm": true,
18
20
  "experimentalSpecifierResolution": "node"
19
21
  },
20
- "include": ["src/**/*"],
22
+ "include": ["src/**/*", "examples/**/*"],
21
23
  "exclude": ["node_modules"]
22
24
  }