Native bindings for the WHATWG Canvas2D / OffscreenCanvas surface for the Perry TypeScript-to-native compiler. Backed by skia-safe for rasterization, text shaping, gradients, and image encoding.
Closes PerryTS/perry#570. Discussion thread: #563.
A Perry "native library" package: a Rust crate exporting extern "C" symbols that the Perry compiler links into your TypeScript program. From your TypeScript code you import @perryts/canvas like any npm package; under the hood every ctx.fillRect(...) call resolves to a direct call into the bundled staticlib — no Node addon, no IPC, no JSON marshalling.
This package contains:
src/lib.rs— the Rust crate that wrapsskia-safeand exposesjs_canvas_*extern "C"symbols.src/index.ts— class-shaped TypeScript surface (OffscreenCanvas,CanvasRenderingContext2D,Path2D,CanvasGradient,ImageData) that delegates to those symbols.Cargo.toml— staticlib build config consumed by the Perry linker.package.json— includes theperry.nativeLibrarymanifest block that lists every FFI symbol.
Skia adds 10-20 MB to a linked binary. Most Perry programs don't need 2D graphics, so canvas ships as an opt-in package — same pattern as @perryts/iroh, @perryts/tursodb, @perryts/mysql, etc. Add it to dependencies and the binding links in only when you actually import it.
bun add @perryts/canvas
# or
npm install @perryts/canvasThe package's package.json declares a perry.nativeLibrary block which Perry's compiler reads at link time to discover the staticlib + extern "C" symbols. No post-install build step — Perry compiles the Rust crate as part of your project's build.
import { OffscreenCanvas } from "@perryts/canvas";
import { writeFile } from "node:fs/promises";
const c = new OffscreenCanvas(200, 200);
const ctx = c.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, 200, 200);
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 100, 100);
const blob = await c.convertToBlob();
const bytes = new Uint8Array(await blob.arrayBuffer());
await writeFile("out.png", bytes);This produces a 200×200 PNG with a white background and a red square in the upper-left quadrant — the acceptance criterion for v1.
OG images, charts, PDF page rasters, social-share previews. The Node ecosystem leans on @napi-rs/canvas for this; on Perry, this package fills the same role without the napi shim layer.
import { OffscreenCanvas, GlobalFonts, loadImage } from "@perryts/canvas";
import { writeFile } from "node:fs/promises";
await GlobalFonts.registerFromPath("./fonts/Inter-Bold.ttf", "Inter");
const logo = await loadImage("./assets/logo.png");
const c = new OffscreenCanvas(1200, 630);
const ctx = c.getContext("2d");
ctx.fillStyle = "#0f172a";
ctx.fillRect(0, 0, 1200, 630);
ctx.drawImage(logo, 40, 40, 160, 160);
ctx.fillStyle = "white";
ctx.font = "64px Inter";
ctx.fillText("Hello, world", 220, 120);
const blob = await c.convertToBlob();
await writeFile("og.png", new Uint8Array(await blob.arrayBuffer()));Many libraries (chart.js, fabric.js, every "auto-fit text" widget) call ctx.measureText(...). Without a real implementation, those libs can't be ported.
const c = new OffscreenCanvas(1, 1);
const ctx = c.getContext("2d");
ctx.font = "16px sans-serif";
const m = ctx.measureText("Quick brown fox");
console.log(m.width, m.actualBoundingBoxAscent, m.actualBoundingBoxDescent);The TypeScript surface mirrors the WHATWG specification — same names, same shapes, same semantics. Browser-typed code generally works unchanged.
new OffscreenCanvas(width: number, height: number): OffscreenCanvas| Member | Description |
|---|---|
width / height |
Reading returns the current pixel dimensions. Assigning resets the canvas (clears all pixels and reallocates the surface). |
getContext("2d") |
Returns a memoized CanvasRenderingContext2D. Only "2d" is supported in v1; passing anything else throws. |
convertToBlob({type, quality}) |
Renders to a Blob. Default type: "image/png", quality: 0.92. Supports "image/png", "image/jpeg", "image/webp". |
State (assignable):
| Property | Type |
|---|---|
fillStyle / strokeStyle |
CSS color string OR CanvasGradient |
lineWidth |
number |
globalAlpha |
number (0–1) |
lineCap |
"butt" | "round" | "square" |
lineJoin |
"miter" | "round" | "bevel" |
miterLimit |
number |
globalCompositeOperation |
every Porter-Duff blend mode + extras |
font |
CSS font shorthand (e.g. "14px Arial") |
textAlign |
"start" | "end" | "left" | "right" | "center" |
textBaseline |
"top" | "hanging" | "middle" | "alphabetic" | "ideographic" | "bottom" |
Methods:
// Primitive draws
fillRect(x, y, w, h)
strokeRect(x, y, w, h)
clearRect(x, y, w, h)
// Path construction (current path)
beginPath()
closePath()
moveTo(x, y)
lineTo(x, y)
rect(x, y, w, h)
arc(x, y, radius, startAngle, endAngle, counterclockwise?)
arcTo(x1, y1, x2, y2, radius)
ellipse(x, y, rx, ry, rotation, startAngle, endAngle, counterclockwise?)
quadraticCurveTo(cx, cy, x, y)
bezierCurveTo(c1x, c1y, c2x, c2y, x, y)
// Apply current path or a Path2D
fill(path?)
stroke(path?)
clip(path?)
// State + transforms
save()
restore()
translate(x, y)
rotate(rad)
scale(sx, sy)
transform(a, b, c, d, e, f)
setTransform(a, b, c, d, e, f)
resetTransform()
// Text
fillText(text, x, y)
strokeText(text, x, y)
measureText(text): TextMetrics
// Gradients
createLinearGradient(x0, y0, x1, y1): CanvasGradient
createRadialGradient(x0, y0, r0, x1, y1, r1): CanvasGradient
createConicGradient(startAngle, x, y): CanvasGradient
// Image data
getImageData(x, y, w, h): ImageData
putImageData(imageData, dx, dy)new Path2D()
new Path2D(source: Path2D) // copy-constructionMethods: closePath, moveTo, lineTo, quadraticCurveTo, bezierCurveTo, rect, arc. Pass an instance to ctx.fill(p) / ctx.stroke(p) / ctx.clip(p).
Returned by createLinearGradient / createRadialGradient / createConicGradient. Build with addColorStop(offset, css), then assign to ctx.fillStyle or ctx.strokeStyle.
fillStyle / strokeStyle / addColorStop accept:
- Named colors (basic 16 + the long tail most generators reach for:
coral,salmon,aliceblue, …). - Hex:
#rgb,#rgba,#rrggbb,#rrggbbaa. rgb(r, g, b)/rgba(r, g, b, a).
Unrecognized strings are silently ignored and the previous color stays in effect — this matches the spec's "discard invalid" behavior.
The first cut of this binding covers the substantive part of the WHATWG Canvas2D surface — enough to host the real-world libraries the issue called out (chart.js, fabric.js, OG-image generators):
- Surface lifecycle —
OffscreenCanvas+getContext("2d")+width/heightresize +convertToBlob(PNG/JPEG/WebP) +transferToImageBitmap. - Drawing primitives —
fillRect/strokeRect/clearRect/roundRect. - Path construction —
moveTo,lineTo,rect,arc,arcTo,ellipse,quadraticCurveTo,bezierCurveTo,closePath. - Fill / stroke / clip — current path or any
Path2D. - Hit testing —
isPointInPath(x, y, fillRule?),isPointInPath(path, x, y),isPointInStroke. - Transforms —
save/restore,translate,rotate,scale,transform,setTransform(6-arg + matrix overload),resetTransform,getTransform. - Style —
fillStyle/strokeStyle(color string,CanvasGradient, orCanvasPattern),lineWidth,globalAlpha,lineCap,lineJoin,miterLimit,globalCompositeOperation(every Porter-Duff blend mode + non-separable blends). - Line dash —
setLineDash,getLineDash,lineDashOffset. - Shadows —
shadowColor,shadowBlur,shadowOffsetX,shadowOffsetY(composed via skiaDropShadowfilter). - Text —
font,textAlign,textBaseline,direction(rtl/ltr),letterSpacing,wordSpacing,fillText,strokeText,measureTextreturning real bounding-box metrics from skia. - Gradients —
createLinearGradient,createRadialGradient,createConicGradient,addColorStop. - Patterns —
createPattern(image, repetition)withsetTransform. - Filters —
ctx.filterparser coveringblur,drop-shadow,brightness,contrast,grayscale,hue-rotate,invert,saturate,sepia,opacity— composable into a chain ("blur(2px) brightness(1.2)"). - Image smoothing —
imageSmoothingEnabled+imageSmoothingQuality(low / medium / high → bilinear / bilinear+mip / Mitchell cubic). Path2D— empty / clone / SVG-string constructors,addPathwith optional transform,roundRect,ellipse, plus all the standard path commands.ImageBitmap—await createImageBitmap(buffer, options?)decodes PNG/JPEG/WebP/GIF/BMP. Options bag acceptspremultiplyAlpha,resizeWidth/Height,resizeQuality.drawImageaccepts the 3 / 5 / 9-arg overloads from the spec;OffscreenCanvas.transferToImageBitmapsnapshots and resets.loadImage(source)— pure-TS helper accepting a filesystem path,http(s)://URL,file://URL,data:URL,Uint8Array, orBlob. Reads vianode:fs/fetchand pipes the bytes throughcreateImageBitmap.encodeImage(rgba, w, h, options)— standalone encoder. Pipe raw RGBA bytes straight to PNG/JPEG/WebP without allocating a canvas.GlobalFonts— process-wide custom font registry, analogous to@napi-rs/canvas's.GlobalFonts.register(buffer, family),registerFromPath(path, family),registerFromURL(url, family),has,remove,families(). Resolution order at draw time: registry → platformFontMgr→ platform default → last-ditch fallback. Family names are aliases you choose — they don't have to match the family name embedded in the font file.loadFont(source, family)— pure-TS convenience that picksregisterFromPathorregisterFromURLbased on the source string.- Image data —
getImageData(x, y, w, h)/putImageData(imageData, dx, dy)with raw RGBA bytes. - Named colors — full WHATWG list (~150 names including
rebeccapurple,dodgerblue, …);#rgb/#rgba/#rrggbb/#rrggbbaahex;rgb(...)/rgba(...)functional.
- WebGL / WebGL2 contexts (separate ticket — unrelated implementation).
- The full
DOMMatrixshape (getTransformreturns the 6-tuple{a, b, c, d, e, f}rather than a method-bearingDOMMatrix). - Per-corner
roundRectradii (we average a 4-tuple to a single radius for now). - Spec text shaping for
direction: "rtl"/letterSpacing/wordSpacing— the state is tracked, but native rendering still uses skia's default shaper. Real RTL layout requires HarfBuzz + ICU. - On-screen
Canvaswidget forperry/uiintegration.
- Bit-exact pixel match with Chrome/Firefox/Safari. Skia gives us "very close"; identity is impossible across font hinting + AA differences.
node-canvas(Cairo) compatibility quirks. We track the WHATWG spec, not node-canvas.
The Rust crate depends on skia-safe, which downloads prebuilt Skia binaries from rust-skia GitHub releases for the common targets (macOS x86/arm, Linux x86/arm, Windows x86) on first build. Uncommon targets fall back to a from-source build, which requires a working C++ toolchain (clang + GN + ninja).
If you're seeing very long initial build times, that's the prebuilt download — subsequent builds are cached.
MIT