@shotstack/shotstack-canvas 1.0.2 → 1.0.3

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.
@@ -1,167 +0,0 @@
1
- import { RichTextAssetSchema, type RichTextValidated } from "../schema/asset-schema";
2
- import { CANVAS_CONFIG } from "../config/canvas-constants";
3
- import { FontRegistry } from "../core/font-registry";
4
- import { LayoutEngine } from "../core/layout";
5
- import { buildDrawOps } from "../core/drawops";
6
- import { applyAnimation } from "../core/animations";
7
- import { createNodePainter } from "../painters/node";
8
- import { loadFileOrHttpToArrayBuffer } from "../io/node";
9
- import { VideoGenerator, VideoGenerationOptions } from "../core/video-generator";
10
-
11
- export async function createTextEngine(
12
- opts: {
13
- width?: number;
14
- height?: number;
15
- pixelRatio?: number;
16
- fps?: number;
17
- wasmBaseURL?: string;
18
- } = {}
19
- ) {
20
- const width = opts.width ?? CANVAS_CONFIG.DEFAULTS.width;
21
- const height = opts.height ?? CANVAS_CONFIG.DEFAULTS.height;
22
- const pixelRatio = opts.pixelRatio ?? CANVAS_CONFIG.DEFAULTS.pixelRatio;
23
- const fps = opts.fps ?? 30;
24
- const wasmBaseURL = opts.wasmBaseURL;
25
-
26
- const fonts = new FontRegistry(wasmBaseURL);
27
- const layout = new LayoutEngine(fonts);
28
- const videoGenerator = new VideoGenerator();
29
-
30
- async function ensureFonts(asset: RichTextValidated) {
31
- if (asset.customFonts) {
32
- for (const cf of asset.customFonts) {
33
- const bytes = await loadFileOrHttpToArrayBuffer(cf.src);
34
- await fonts.registerFromBytes(bytes, {
35
- family: cf.family,
36
- weight: cf.weight ?? "400",
37
- style: cf.style ?? "normal",
38
- });
39
- }
40
- }
41
- const main = asset.font ?? {
42
- family: "Roboto",
43
- weight: "400",
44
- style: "normal",
45
- size: 48,
46
- color: "#000000",
47
- opacity: 1,
48
- };
49
- return main;
50
- }
51
-
52
- return {
53
- validate(input: unknown): { value: RichTextValidated } {
54
- const { value, error } = RichTextAssetSchema.validate(input, {
55
- abortEarly: false,
56
- convert: true,
57
- });
58
- if (error) throw error;
59
- return { value: value as RichTextValidated };
60
- },
61
-
62
- async registerFontFromFile(
63
- path: string,
64
- desc: { family: string; weight?: string | number; style?: string }
65
- ) {
66
- const bytes = await loadFileOrHttpToArrayBuffer(path);
67
- await fonts.registerFromBytes(bytes, desc);
68
- },
69
- async registerFontFromUrl(
70
- url: string,
71
- desc: { family: string; weight?: string | number; style?: string }
72
- ) {
73
- const bytes = await loadFileOrHttpToArrayBuffer(url);
74
- await fonts.registerFromBytes(bytes, desc);
75
- },
76
-
77
- async renderFrame(asset: RichTextValidated, tSeconds: number) {
78
- const main = await ensureFonts(asset);
79
- const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
80
-
81
- const lines = layout.layout({
82
- text: asset.text,
83
- width: asset.width ?? width,
84
- letterSpacing: asset.style?.letterSpacing ?? 0,
85
- fontSize: main.size,
86
- lineHeight: asset.style?.lineHeight ?? 1.2,
87
- desc,
88
- textTransform: asset.style?.textTransform ?? "none",
89
- });
90
-
91
- const textRect = { x: 0, y: 0, width: asset.width ?? width, height: asset.height ?? height };
92
-
93
- const canvasW = asset.width ?? width;
94
- const canvasH = asset.height ?? height;
95
- const canvasPR = asset.pixelRatio ?? pixelRatio;
96
-
97
- const ops0 = buildDrawOps({
98
- canvas: { width: canvasW, height: canvasH, pixelRatio: canvasPR },
99
- textRect,
100
- lines,
101
- font: {
102
- family: main.family,
103
- size: main.size,
104
- weight: `${main.weight}`,
105
- style: main.style,
106
- color: main.color,
107
- opacity: main.opacity,
108
- },
109
- style: {
110
- lineHeight: asset.style?.lineHeight ?? 1.2,
111
- textDecoration: asset.style?.textDecoration ?? "none",
112
- gradient: asset.style?.gradient,
113
- },
114
- stroke: asset.stroke,
115
- shadow: asset.shadow,
116
- align: asset.align ?? { horizontal: "left", vertical: "middle" },
117
- background: asset.background,
118
- glyphPathProvider: (gid) => fonts.glyphPath(desc, gid),
119
- /** NEW: provide UPEM so drawops can compute scale */
120
- getUnitsPerEm: () => fonts.getUnitsPerEm(desc),
121
- });
122
-
123
- const ops = applyAnimation(ops0, lines, {
124
- t: tSeconds,
125
- fontSize: main.size,
126
- anim: asset.animation
127
- ? {
128
- preset: asset.animation.preset as any,
129
- speed: asset.animation.speed,
130
- duration: asset.animation.duration,
131
- style: asset.animation.style as any,
132
- direction: asset.animation.direction as any,
133
- }
134
- : undefined,
135
- });
136
-
137
- return ops;
138
- },
139
-
140
- async createRenderer(p: { width?: number; height?: number; pixelRatio?: number }) {
141
- return createNodePainter({
142
- width: p.width ?? width,
143
- height: p.height ?? height,
144
- pixelRatio: p.pixelRatio ?? pixelRatio,
145
- });
146
- },
147
-
148
- async generateVideo(asset: RichTextValidated, options: Partial<VideoGenerationOptions>) {
149
- const finalOptions: VideoGenerationOptions = {
150
- width: asset.width ?? width,
151
- height: asset.height ?? height,
152
- fps: fps,
153
- duration: asset.animation?.duration ?? 3,
154
- outputPath: options.outputPath ?? "output.mp4",
155
- pixelRatio: asset.pixelRatio ?? pixelRatio,
156
- };
157
-
158
- const frameGenerator = async (time: number) => {
159
- return this.renderFrame(asset, time);
160
- };
161
-
162
- await videoGenerator.generateVideo(frameGenerator, finalOptions);
163
- },
164
- };
165
- }
166
-
167
- export * from "../types";
@@ -1,146 +0,0 @@
1
- // src/env/entry.web.ts
2
- import { RichTextAssetSchema, type RichTextValidated } from "../schema/asset-schema";
3
- import { CANVAS_CONFIG } from "../config/canvas-constants";
4
- import { FontRegistry } from "../core/font-registry";
5
- import { LayoutEngine } from "../core/layout";
6
- import { buildDrawOps } from "../core/drawops";
7
- import { applyAnimation } from "../core/animations";
8
- import { createWebPainter } from "../painters/web";
9
- import { fetchToArrayBuffer } from "../io/web";
10
-
11
- export async function createTextEngine(
12
- opts: {
13
- width?: number;
14
- height?: number;
15
- pixelRatio?: number;
16
- fps?: number;
17
- wasmBaseURL?: string;
18
- } = {}
19
- ) {
20
- const width = opts.width ?? CANVAS_CONFIG.DEFAULTS.width;
21
- const height = opts.height ?? CANVAS_CONFIG.DEFAULTS.height;
22
- const pixelRatio = opts.pixelRatio ?? CANVAS_CONFIG.DEFAULTS.pixelRatio;
23
- const wasmBaseURL = opts.wasmBaseURL;
24
-
25
- const fonts = new FontRegistry(wasmBaseURL);
26
- // initHB via registry as needed
27
- const layout = new LayoutEngine(fonts);
28
-
29
- async function ensureFonts(asset: RichTextValidated) {
30
- // Load custom fonts (bytes) if provided
31
- if (asset.customFonts) {
32
- for (const cf of asset.customFonts) {
33
- const bytes = await fetchToArrayBuffer(cf.src);
34
- await fonts.registerFromBytes(bytes, {
35
- family: cf.family,
36
- weight: cf.weight ?? "400",
37
- style: cf.style ?? "normal",
38
- });
39
- }
40
- }
41
- // Ensure main font is loaded (if same as custom, already there)
42
- const main = asset.font ?? {
43
- family: "Roboto",
44
- weight: "400",
45
- style: "normal",
46
- size: 48,
47
- color: "#000000",
48
- opacity: 1,
49
- };
50
- // No system fonts: require the user to register customFonts for deterministic results.
51
- return main;
52
- }
53
-
54
- return {
55
- validate(input: unknown): { value: RichTextValidated } {
56
- const { value, error } = RichTextAssetSchema.validate(input, {
57
- abortEarly: false,
58
- convert: true,
59
- });
60
- if (error) throw error;
61
- return { value: value as RichTextValidated };
62
- },
63
- async registerFontFromUrl(
64
- url: string,
65
- desc: { family: string; weight?: string | number; style?: string }
66
- ) {
67
- const bytes = await fetchToArrayBuffer(url);
68
- await fonts.registerFromBytes(bytes, desc);
69
- },
70
- async registerFontFromFile(
71
- source: string | Blob,
72
- desc: { family: string; weight?: string | number; style?: string }
73
- ) {
74
- let bytes: ArrayBuffer;
75
- if (typeof source === "string") {
76
- bytes = await fetchToArrayBuffer(source);
77
- } else {
78
- bytes = await source.arrayBuffer();
79
- }
80
- await fonts.registerFromBytes(bytes, desc);
81
- },
82
-
83
- async renderFrame(asset: RichTextValidated, tSeconds: number) {
84
- const main = await ensureFonts(asset);
85
- const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
86
-
87
- const lines = layout.layout({
88
- text: asset.text,
89
- width: asset.width ?? width,
90
- letterSpacing: asset.style?.letterSpacing ?? 0,
91
- fontSize: main.size,
92
- lineHeight: asset.style?.lineHeight ?? 1.2,
93
- desc,
94
- textTransform: asset.style?.textTransform ?? "none",
95
- });
96
-
97
- const textRect = { x: 0, y: 0, width: asset.width ?? width, height: asset.height ?? height };
98
- const ops0 = buildDrawOps({
99
- canvas: { width, height, pixelRatio },
100
- textRect,
101
- lines,
102
- font: {
103
- family: main.family,
104
- size: main.size,
105
- weight: `${main.weight}`,
106
- style: main.style,
107
- color: main.color,
108
- opacity: main.opacity,
109
- },
110
- style: {
111
- lineHeight: asset.style?.lineHeight ?? 1.2,
112
- textDecoration: asset.style?.textDecoration ?? "none",
113
- gradient: asset.style?.gradient,
114
- },
115
- stroke: asset.stroke,
116
- shadow: asset.shadow,
117
- align: asset.align ?? { horizontal: "left", vertical: "middle" },
118
- background: asset.background,
119
- glyphPathProvider: (gid) => fonts.glyphPath(desc, gid),
120
- /** NEW: provide UPEM so drawops can compute scale */
121
- getUnitsPerEm: () => fonts.getUnitsPerEm(desc),
122
- });
123
-
124
- const ops = applyAnimation(ops0, lines, {
125
- t: tSeconds,
126
- fontSize: main.size,
127
- anim: asset.animation
128
- ? {
129
- preset: asset.animation.preset as any,
130
- speed: asset.animation.speed,
131
- duration: asset.animation.duration,
132
- style: asset.animation.style as any,
133
- direction: asset.animation.direction as any,
134
- }
135
- : undefined,
136
- });
137
-
138
- return ops;
139
- },
140
- createRenderer(canvas: HTMLCanvasElement | OffscreenCanvas) {
141
- return createWebPainter(canvas);
142
- },
143
- };
144
- }
145
-
146
- export * from "../types";
package/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export * from "./types";
package/src/io/node.ts DELETED
@@ -1,45 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import * as http from "node:http";
3
- import * as https from "node:https";
4
-
5
- /** Ensure we always return a plain ArrayBuffer (never SharedArrayBuffer). */
6
- function bufferToArrayBuffer(buf: Buffer): ArrayBuffer {
7
- const { buffer, byteOffset, byteLength } = buf as unknown as {
8
- buffer: ArrayBuffer | SharedArrayBuffer;
9
- byteOffset: number;
10
- byteLength: number;
11
- };
12
-
13
- // If backing store is SharedArrayBuffer, copy to a fresh ArrayBuffer
14
- if (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer) {
15
- const ab = new ArrayBuffer(byteLength);
16
- new Uint8Array(ab).set(buf);
17
- return ab;
18
- }
19
-
20
- // Backing store is ArrayBuffer; slice to the exact view range
21
- // (works even when Buffer is a view into a larger ArrayBuffer)
22
- const ab = buffer as ArrayBuffer;
23
- // ArrayBuffer#slice returns ArrayBuffer
24
- return ab.slice(byteOffset, byteOffset + byteLength);
25
- }
26
-
27
- export async function loadFileOrHttpToArrayBuffer(pathOrUrl: string): Promise<ArrayBuffer> {
28
- if (/^https?:\/\//.test(pathOrUrl)) {
29
- const client = pathOrUrl.startsWith("https:") ? https : http;
30
- const buf: Buffer = await new Promise((resolve, reject) => {
31
- client
32
- .get(pathOrUrl, (res) => {
33
- const chunks: Buffer[] = [];
34
- res.on("data", (d) => chunks.push(d));
35
- res.on("end", () => resolve(Buffer.concat(chunks)));
36
- res.on("error", reject);
37
- })
38
- .on("error", reject);
39
- });
40
- return bufferToArrayBuffer(buf);
41
- }
42
-
43
- const buf = await readFile(pathOrUrl);
44
- return bufferToArrayBuffer(buf);
45
- }
package/src/io/web.ts DELETED
@@ -1,5 +0,0 @@
1
- export async function fetchToArrayBuffer(url: string): Promise<ArrayBuffer> {
2
- const res = await fetch(url);
3
- if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
4
- return await res.arrayBuffer();
5
- }
@@ -1,290 +0,0 @@
1
- import type { DrawOp, GradientSpec } from "../types";
2
- import { parseHex6 } from "../core/colors";
3
-
4
- /* eslint-disable @typescript-eslint/no-explicit-any */
5
-
6
- type Ctx = any;
7
-
8
- export async function createNodePainter(opts: {
9
- width: number;
10
- height: number;
11
- pixelRatio: number;
12
- }) {
13
- const canvasMod = await import("canvas");
14
- const { createCanvas } = canvasMod as any;
15
-
16
- const canvas = createCanvas(
17
- Math.floor(opts.width * opts.pixelRatio),
18
- Math.floor(opts.height * opts.pixelRatio)
19
- );
20
- const ctx: Ctx = canvas.getContext("2d");
21
- if (!ctx) throw new Error("2D context unavailable in Node (canvas).");
22
-
23
- const api = {
24
- async render(ops: DrawOp[]) {
25
- // Compute once; used for whole-text gradient
26
- const globalBox = computeGlobalTextBounds(ops);
27
-
28
- for (const op of ops) {
29
- if (op.op === "BeginFrame") {
30
- // Ensure canvas pixel size matches this frame
31
- const dpr = op.pixelRatio ?? opts.pixelRatio;
32
- const wantW = Math.floor(op.width * dpr);
33
- const wantH = Math.floor(op.height * dpr);
34
-
35
- if ((canvas as any).width !== wantW || (canvas as any).height !== wantH) {
36
- (canvas as any).width = wantW;
37
- (canvas as any).height = wantH;
38
- }
39
-
40
- // Re-apply DPR transform every frame (resize resets state)
41
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
42
-
43
- if (op.clear) ctx.clearRect(0, 0, op.width, op.height);
44
- if (op.bg) {
45
- const { color, opacity, radius } = op.bg;
46
- if (color) {
47
- const c = parseHex6(color, opacity);
48
- ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
49
- if (radius && radius > 0) {
50
- ctx.save();
51
- ctx.beginPath();
52
- roundRectPath(ctx, 0, 0, op.width, op.height, radius);
53
- ctx.fill();
54
- ctx.restore();
55
- } else {
56
- ctx.fillRect(0, 0, op.width, op.height);
57
- }
58
- }
59
- }
60
- continue;
61
- }
62
-
63
- if (op.op === "FillPath") {
64
- ctx.save();
65
- ctx.translate(op.x, op.y);
66
-
67
- const s = (op as any).scale ?? 1;
68
- ctx.scale(s, -s);
69
-
70
- ctx.beginPath();
71
- drawSvgPathOnCtx(ctx, op.path);
72
-
73
- const bbox = (op as any).gradientBBox ?? globalBox;
74
- const fill = makeGradientFromBBox(ctx, op.fill, bbox);
75
- ctx.fillStyle = fill as any;
76
- ctx.fill();
77
-
78
- ctx.restore();
79
- continue;
80
- }
81
-
82
- if (op.op === "StrokePath") {
83
- ctx.save();
84
- ctx.translate(op.x, op.y);
85
-
86
- const s = (op as any).scale ?? 1;
87
- ctx.scale(s, -s);
88
- const invAbs = 1 / Math.abs(s);
89
-
90
- ctx.beginPath();
91
- drawSvgPathOnCtx(ctx, op.path);
92
-
93
- const c = parseHex6((op as any).color, (op as any).opacity);
94
- ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
95
- ctx.lineWidth = (op as any).width * invAbs; // keep px width
96
- ctx.lineJoin = "round";
97
- ctx.lineCap = "round";
98
- ctx.stroke();
99
-
100
- ctx.restore();
101
- continue;
102
- }
103
-
104
- if (op.op === "DecorationLine") {
105
- ctx.save();
106
- const c = parseHex6((op as any).color, (op as any).opacity);
107
- ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
108
- ctx.lineWidth = (op as any).width;
109
- ctx.beginPath();
110
- ctx.moveTo((op as any).from.x, (op as any).from.y);
111
- ctx.lineTo((op as any).to.x, (op as any).to.y);
112
- ctx.stroke();
113
- ctx.restore();
114
- continue;
115
- }
116
- }
117
- },
118
-
119
- async toPNG(): Promise<Buffer> {
120
- return (canvas as any).toBuffer("image/png");
121
- },
122
- };
123
-
124
- return api;
125
- }
126
-
127
- // --------- helpers ---------
128
-
129
- function makeGradientFromBBox(
130
- ctx: any,
131
- spec: GradientSpec,
132
- box: { x: number; y: number; w: number; h: number }
133
- ) {
134
- if (spec.kind === "solid") {
135
- const c = parseHex6((spec as any).color, (spec as any).opacity);
136
- return `rgba(${c.r},${c.g},${c.b},${c.a})`;
137
- }
138
- const cx = box.x + box.w / 2,
139
- cy = box.y + box.h / 2,
140
- r = Math.max(box.w, box.h) / 2;
141
- const addStops = (g: any) => {
142
- const op = (spec as any).opacity ?? 1;
143
- for (const s of (spec as any).stops) {
144
- const c = parseHex6(s.color, op);
145
- g.addColorStop(s.offset, `rgba(${c.r},${c.g},${c.b},${c.a})`);
146
- }
147
- return g;
148
- };
149
- if (spec.kind === "linear") {
150
- const rad = (((spec as any).angle || 0) * Math.PI) / 180;
151
- const x1 = cx + Math.cos(rad + Math.PI) * r;
152
- const y1 = cy + Math.sin(rad + Math.PI) * r;
153
- const x2 = cx + Math.cos(rad) * r;
154
- const y2 = cy + Math.sin(rad) * r;
155
- return addStops(ctx.createLinearGradient(x1, y1, x2, y2));
156
- } else {
157
- return addStops(ctx.createRadialGradient(cx, cy, 0, cx, cy, r));
158
- }
159
- }
160
-
161
- function computeGlobalTextBounds(ops: DrawOp[]) {
162
- let minX = Infinity,
163
- minY = Infinity,
164
- maxX = -Infinity,
165
- maxY = -Infinity;
166
- for (const op of ops) {
167
- if (op.op !== "FillPath" || (op as any).isShadow) continue;
168
- const b = computePathBounds(op.path);
169
- const s = (op as any).scale ?? 1;
170
- const x1 = op.x + s * b.x;
171
- const x2 = op.x + s * (b.x + b.w);
172
- const y1 = op.y - s * (b.y + b.h);
173
- const y2 = op.y - s * b.y;
174
- if (x1 < minX) minX = x1;
175
- if (y1 < minY) minY = y1;
176
- if (x2 > maxX) maxX = x2;
177
- if (y2 > maxY) maxY = y2;
178
- }
179
- if (minX === Infinity) return { x: 0, y: 0, w: 1, h: 1 };
180
- return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
181
- }
182
-
183
- function drawSvgPathOnCtx(ctx: any, d: string) {
184
- const t = tokenizePath(d);
185
- let i = 0;
186
- while (i < t.length) {
187
- const cmd = t[i++];
188
- switch (cmd) {
189
- case "M": {
190
- const x = parseFloat(t[i++]);
191
- const y = parseFloat(t[i++]);
192
- ctx.moveTo(x, y);
193
- break;
194
- }
195
- case "L": {
196
- const x = parseFloat(t[i++]);
197
- const y = parseFloat(t[i++]);
198
- ctx.lineTo(x, y);
199
- break;
200
- }
201
- case "C": {
202
- const c1x = parseFloat(t[i++]);
203
- const c1y = parseFloat(t[i++]);
204
- const c2x = parseFloat(t[i++]);
205
- const c2y = parseFloat(t[i++]);
206
- const x = parseFloat(t[i++]);
207
- const y = parseFloat(t[i++]);
208
- ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
209
- break;
210
- }
211
- case "Q": {
212
- const cx = parseFloat(t[i++]);
213
- const cy = parseFloat(t[i++]);
214
- const x = parseFloat(t[i++]);
215
- const y = parseFloat(t[i++]);
216
- ctx.quadraticCurveTo(cx, cy, x, y);
217
- break;
218
- }
219
- case "Z": {
220
- ctx.closePath();
221
- break;
222
- }
223
- }
224
- }
225
- }
226
-
227
- function computePathBounds(d: string) {
228
- const t = tokenizePath(d);
229
- let i = 0;
230
- let minX = Infinity,
231
- minY = Infinity,
232
- maxX = -Infinity,
233
- maxY = -Infinity;
234
- const touch = (x: number, y: number) => {
235
- if (x < minX) minX = x;
236
- if (y < minY) minY = y;
237
- if (x > maxX) maxX = x;
238
- if (y > maxY) maxY = y;
239
- };
240
- while (i < t.length) {
241
- const cmd = t[i++];
242
- switch (cmd) {
243
- case "M":
244
- case "L": {
245
- const x = parseFloat(t[i++]);
246
- const y = parseFloat(t[i++]);
247
- touch(x, y);
248
- break;
249
- }
250
- case "C": {
251
- const c1x = parseFloat(t[i++]);
252
- const c1y = parseFloat(t[i++]);
253
- const c2x = parseFloat(t[i++]);
254
- const c2y = parseFloat(t[i++]);
255
- const x = parseFloat(t[i++]);
256
- const y = parseFloat(t[i++]);
257
- touch(c1x, c1y);
258
- touch(c2x, c2y);
259
- touch(x, y);
260
- break;
261
- }
262
- case "Q": {
263
- const cx = parseFloat(t[i++]);
264
- const cy = parseFloat(t[i++]);
265
- const x = parseFloat(t[i++]);
266
- const y = parseFloat(t[i++]);
267
- touch(cx, cy);
268
- touch(x, y);
269
- break;
270
- }
271
- case "Z":
272
- break;
273
- }
274
- }
275
- if (minX === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
276
- return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
277
- }
278
-
279
- function roundRectPath(ctx: any, x: number, y: number, w: number, h: number, r: number) {
280
- ctx.moveTo(x + r, y);
281
- ctx.arcTo(x + w, y, x + w, y + h, r);
282
- ctx.arcTo(x + w, y + h, x, y + h, r);
283
- ctx.arcTo(x, y + h, x, y, r);
284
- ctx.arcTo(x, y, x + w, y, r);
285
- ctx.closePath();
286
- }
287
-
288
- function tokenizePath(d: string): string[] {
289
- return d.match(/[MLCQZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
290
- }