Skip to content

PerryTS/canvas

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@perryts/canvas

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.

What this is

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 wraps skia-safe and exposes js_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 the perry.nativeLibrary manifest block that lists every FFI symbol.

Why a separate package

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.

Install

bun add @perryts/canvas
# or
npm install @perryts/canvas

The 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.

Quick start — server-side image generation

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.

Two headline use cases

1. Headless server-side image generation

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()));

2. Text measurement / shaping

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);

API reference

The TypeScript surface mirrors the WHATWG specification — same names, same shapes, same semantics. Browser-typed code generally works unchanged.

class OffscreenCanvas

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".

class CanvasRenderingContext2D

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)

class Path2D

new Path2D()
new Path2D(source: Path2D)  // copy-construction

Methods: closePath, moveTo, lineTo, quadraticCurveTo, bezierCurveTo, rect, arc. Pass an instance to ctx.fill(p) / ctx.stroke(p) / ctx.clip(p).

class CanvasGradient

Returned by createLinearGradient / createRadialGradient / createConicGradient. Build with addColorStop(offset, css), then assign to ctx.fillStyle or ctx.strokeStyle.

Color string syntax

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.

What's shipping

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 lifecycleOffscreenCanvas + getContext("2d") + width/height resize + convertToBlob (PNG/JPEG/WebP) + transferToImageBitmap.
  • Drawing primitivesfillRect / strokeRect / clearRect / roundRect.
  • Path constructionmoveTo, lineTo, rect, arc, arcTo, ellipse, quadraticCurveTo, bezierCurveTo, closePath.
  • Fill / stroke / clip — current path or any Path2D.
  • Hit testingisPointInPath(x, y, fillRule?), isPointInPath(path, x, y), isPointInStroke.
  • Transformssave/restore, translate, rotate, scale, transform, setTransform (6-arg + matrix overload), resetTransform, getTransform.
  • StylefillStyle / strokeStyle (color string, CanvasGradient, or CanvasPattern), lineWidth, globalAlpha, lineCap, lineJoin, miterLimit, globalCompositeOperation (every Porter-Duff blend mode + non-separable blends).
  • Line dashsetLineDash, getLineDash, lineDashOffset.
  • ShadowsshadowColor, shadowBlur, shadowOffsetX, shadowOffsetY (composed via skia DropShadow filter).
  • Textfont, textAlign, textBaseline, direction (rtl/ltr), letterSpacing, wordSpacing, fillText, strokeText, measureText returning real bounding-box metrics from skia.
  • GradientscreateLinearGradient, createRadialGradient, createConicGradient, addColorStop.
  • PatternscreatePattern(image, repetition) with setTransform.
  • Filtersctx.filter parser covering blur, drop-shadow, brightness, contrast, grayscale, hue-rotate, invert, saturate, sepia, opacity — composable into a chain ("blur(2px) brightness(1.2)").
  • Image smoothingimageSmoothingEnabled + imageSmoothingQuality (low / medium / high → bilinear / bilinear+mip / Mitchell cubic).
  • Path2D — empty / clone / SVG-string constructors, addPath with optional transform, roundRect, ellipse, plus all the standard path commands.
  • ImageBitmapawait createImageBitmap(buffer, options?) decodes PNG/JPEG/WebP/GIF/BMP. Options bag accepts premultiplyAlpha, resizeWidth/Height, resizeQuality. drawImage accepts the 3 / 5 / 9-arg overloads from the spec; OffscreenCanvas.transferToImageBitmap snapshots and resets.
  • loadImage(source) — pure-TS helper accepting a filesystem path, http(s):// URL, file:// URL, data: URL, Uint8Array, or Blob. Reads via node:fs / fetch and pipes the bytes through createImageBitmap.
  • 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 → platform FontMgr → 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 picks registerFromPath or registerFromURL based on the source string.
  • Image datagetImageData(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/#rrggbbaa hex; rgb(...) / rgba(...) functional.

Deferred

  • WebGL / WebGL2 contexts (separate ticket — unrelated implementation).
  • The full DOMMatrix shape (getTransform returns the 6-tuple {a, b, c, d, e, f} rather than a method-bearing DOMMatrix).
  • Per-corner roundRect radii (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 Canvas widget for perry/ui integration.

Non-goals

  • 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.

Build details

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.

License

MIT

About

WHATWG Canvas2D / OffscreenCanvas bindings backed by skia for the Perry TypeScript-to-native compiler. Closes PerryTS/perry#570.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors