@shotstack/shotstack-canvas 1.0.2 → 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.
- package/dist/entry.node.cjs +1548 -0
- package/dist/entry.node.cjs.map +1 -0
- package/dist/entry.node.d.cts +229 -0
- package/dist/entry.node.d.ts +229 -0
- package/dist/entry.node.js +1511 -0
- package/dist/entry.node.js.map +1 -0
- package/dist/entry.web.d.ts +207 -0
- package/dist/entry.web.js +1285 -0
- package/dist/entry.web.js.map +1 -0
- package/package.json +20 -7
- package/.eslintrc.cjs +0 -7
- package/.prettierrc +0 -1
- package/pnpm-workspace.yaml +0 -3
- package/scripts/vendor-harfbuzz.js +0 -64
- package/src/config/canvas-constants.ts +0 -30
- package/src/core/animations.ts +0 -570
- package/src/core/colors.ts +0 -11
- package/src/core/decoration.ts +0 -9
- package/src/core/drawops.ts +0 -206
- package/src/core/font-registry.ts +0 -77
- package/src/core/gradients.ts +0 -12
- package/src/core/layout.ts +0 -184
- package/src/core/utils.ts +0 -3
- package/src/core/video-generator.ts +0 -157
- package/src/env/entry.node.ts +0 -167
- package/src/env/entry.web.ts +0 -146
- package/src/index.ts +0 -1
- package/src/io/node.ts +0 -45
- package/src/io/web.ts +0 -5
- package/src/painters/node.ts +0 -290
- package/src/painters/web.ts +0 -224
- package/src/schema/asset-schema.ts +0 -166
- package/src/types.ts +0 -36
- package/src/wasm/hb-loader.ts +0 -31
- package/tsconfig.base.json +0 -22
- package/tsconfig.json +0 -16
- package/tsup.config.ts +0 -52
package/src/env/entry.node.ts
DELETED
|
@@ -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";
|
package/src/env/entry.web.ts
DELETED
|
@@ -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
package/src/painters/node.ts
DELETED
|
@@ -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
|
-
}
|