sdxc

[ Interface ]

@sdxc/raster

2D canvas for Workers: antialiased paths, gradients, masks and text, out to PNG

npm add @sdxc/raster
pnpm add @sdxc/raster
yarn add @sdxc/raster
bun add @sdxc/raster
Installs with
@sdxc/font@sdxc/png
Used by
blog

2D canvas for Workers: antialiased paths, gradients, masks and text, out to PNG.

Installation

npm add @sdxc/raster

Text is set with fonts from @sdxc/font and images encode through @sdxc/png; both install alongside. The canvas is TypeScript over typed arrays, with no WebAssembly, so it runs in Workers, Bun, Node and the browser.

Usage

Draw shapes and encode them

import { Canvas, color, Path } from "@sdxc/raster";

let canvas = new Canvas(1200, 630);
canvas.fillRect(0, 0, 1200, 630, color("#0b0c10"));
canvas.fillPath(Path.roundedRect(100, 100, 400, 200, 24), color("oklch(0.52 0.17 275)"));

return new Response(await canvas.toPNG(), { headers: { "content-type": "image/png" } });

Gradients and masks

import { Canvas, color, radialGradient, TRANSPARENT } from "@sdxc/raster";

let glow = radialGradient({
	center: [1020, 0],
	radius: [720, 440],
	stops: [
		[0, color("#6366f140")],
		[0.7, TRANSPARENT],
	],
});
canvas.fillRect(0, 0, 1200, 630, glow);

/* Everything drawn inside fades out from the centre. */
canvas.mask(
	radialGradient({
		center: [600, 315],
		radius: 700,
		stops: [
			[0, color("#fff")],
			[0.75, TRANSPARENT],
		],
	}),
	() => {
		for (let x = 0; x < 1200; x += 48) canvas.fillRect(x, 0, 1, 630, color("#2b2d38"));
	},
);

Fit text to a box

import { parseFont } from "@sdxc/font";

import { isFailure } from "@sdxc/result";

let font = parseFont(fontBytes);
if (isFailure(font)) return font;

let metrics = canvas.text("A title that may need two lines", {
	font: font.data,
	size: 76,
	minSize: 52,
	maxLines: 2,
	maxWidth: 880,
	tracking: -3,
	x: 600,
	y: 280,
	align: "center",
	paint: color("#f4f4f6"),
});
metrics.height; // where the next block starts

The text wraps at spaces, shrinks in 2 px steps down to minSize until it fits maxLines, and ends with … when it still does not.

API

new Canvas(width, height)

A transparent image. Pixels are premultiplied floats blended source-over in sRGB, as browsers composite, and become bytes only on output.

  • fillRect(x, y, width, height, paint) — fractional edges cover pixels partially.

  • fillPath(path, paint, { rule }) — "nonzero" (default) or "evenodd", with exact-area antialiasing.

  • layer({ opacity }, draw) — draws offscreen and composites the result once.

  • mask(paint, draw) — composites what draw drew, scaled by the paint's alpha at each pixel.

  • text(content, options) and measureText(content, options) — return { size, lines, width, height }.

  • toImage() — straight RGBA bytes in the ImageData layout. toPNG(options?) — PNG bytes.

Path

moveTo, lineTo, quadTo, cubicTo and close, plus Path.rect, Path.roundedRect and Path.from(commands) for @sdxc/font glyph outlines. An open contour fills as if closed.

color(input) / withAlpha(color, opacity) / TRANSPARENT

Parses #rgb[a], #rrggbb[aa], rgb()/rgba() and oklch() into straight-alpha sRGB, clipping out-of-gamut oklch. Throws a RangeError on anything else, since colours are written by hand.

linearGradient({ from, to, stops }) / radialGradient({ center, radius, stops })

Paints whose stops are [offset, color] pairs, interpolated in premultiplied sRGB and padded with the end colours. A [rx, ry] radius makes an elliptical gradient.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.