JSPM

@perryts/canvas

0.1.1
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 31
  • Score
    100M100P100Q65159F
  • License MIT

WHATWG Canvas2D / OffscreenCanvas bindings backed by skia for the Perry TypeScript-to-native compiler. Headless image generation, text measurement, and 2D rendering with the same code that runs in a browser.

Package Exports

    This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (@perryts/canvas) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

    Readme

    @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