2d rendering context
drawable.getContext('2d') returns a context implementing a subset of the
HTML CanvasRenderingContext2D
API. It is backed by the XRender extension: fills, gradients, composition and
glyph drawing are executed on the X server, so pixel data does not travel
over the connection for most operations.
On windows, drawing goes into an offscreen backing pixmap and is presented
in single blits (double buffering — flicker-free by default; see
window.md). On pixmaps the context draws directly.
getImageData reads the backing pixmap on double-buffered windows, so it is
valid even where the window is occluded.
const ctx = wnd.getContext('2d');
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
ctx.fillRect(0, 0, 100, 100);
Properties
ctx.canvas— the owning drawable (window or pixmap), like in the browserctx.width,ctx.height— drawable sizectx.fillStyle,ctx.strokeStyle— a CSS color string, a premultiplied[r, g, b, a]array (0..1 floats), aCanvasGradient, aCanvasPattern, or aPicture. Named colors,rgb[a](),hsl[a]()and hex in all four lengths (#rgb,#rgba,#rrggbb,#rrggbbaa) are accepted; anything unparseable throws rather than drawing something arbitrary. See Color below for what alpha doesctx.lineWidth,ctx.lineCap,ctx.lineJoin,ctx.miterLimit— stroke geometry, including'round'caps and joins (rendered as triangle-fan disks unioned with the stroke mesh). A join whose inner corner would fall outside the two segments meeting there — a hairpin, or any turn tight relative to how short they are — is built from the segments' own ends instead, so the stroke of a path stays within half a line width of it (plus the miter itself, whichmiterLimitbounds)ctx.setLineDash(segments),ctx.getLineDash(),ctx.lineDashOffset— canvas-spec dashes: an empty list is solid, an odd-length list doubles, negative/non-finite values invalidate the call,getLineDash()returns a copy, and the state participates insave()/restore(). Dashing splits the flattened polyline by arc length, so caps apply to each dash; on closed subpaths the pattern continues around the loop (no cap at the seam unless a gap lands there)ctx.globalAlpha— multiplies fills, strokes,fillRectanddrawImage(not text)ctx.shadowColor,ctx.shadowBlur,ctx.shadowOffsetX,ctx.shadowOffsetY— drop shadows, off by default (a transparentshadowColor). See Shadows belowctx.globalCompositeOperation— Porter-Duff subset mapped to XRender ops:source-over(default),copy,destination-over,source-in,destination-in,source-out,destination-out,source-atop,destination-atop,xor,lighter. With a shape/clip mask the op only applies inside the mask coveragectx.font— CSS-ish font string ('bold italic 40px "DejaVu Sans"'), resolved through fontconfig; see fonts.md
Everything that puts ink on the surface goes through the clip: fills,
strokes, images, text (fillText, TextLayout.draw) and the vector shapes
SvgView draws. A rectangular clip stack never becomes a mask. It is
either a rectangle handed to the server around the drawing
(SetPictureClipRectangles) or — where the drawing is a single composite of
a box, as fillRect and drawImage are — the same rectangle intersected
into that box, which costs no requests and no pixels at all. That is what
makes a renderer's save()/clip(damage)/…/restore() frame cheap.
Non-rectangular clips build an a8 mask, and an XFIXES region is a third kind
the server applies itself — see Region clips. Where a mask
is genuinely needed, the work it costs is bounded to the box the drawing
composites, not the surface: a translucent fill inside a rounded-corner clip
pays for its own rectangle.
Where the rectangle does go to the server it is context state, not a per-drawing stamp: a second drawing under a clip the picture already carries sends no clip request at all, and neither does a repaint of the same damage rectangle on the next frame. A rounded box under a damage clip — a fill and a border, each of them corner glyphs plus strips — costs one clip request rather than four.
The exception is the composite ops that paint where the clip is not:
copy, source-in, destination-in, source-out and destination-atop
clear the rest of the drawing's box rather than leaving it alone. Under a
clip they go through the mask, and the pixels inside the drawing but outside
the clip come out cleared rather than kept.
Color
XRender colors are premultiplied: each of r, g and b is already
scaled by a, so all three must be <= a. Color strings are converted
for you — 'rgba(255, 0, 0, 0.5)' reaches the server as
[0.5, 0, 0, 0.5] — but an array is taken as already premultiplied and
passes through untouched:
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // half-alpha red
ctx.fillStyle = [0.5, 0, 0, 0.5]; // the same thing
ctx.fillStyle = [1, 0, 0, 0.5]; // NOT half-alpha red: out of gamut
The last line is the mistake to know about. It is not rejected — the
protocol allows it — but it renders brighter than any real color at that
alpha, and over a white background it clamps to the same pixels as the
correct value, so it tends to look fine until something dark is underneath.
White at half alpha is [0.5, 0.5, 0.5, 0.5], not [1, 1, 1, 0.5].
cssColor(value) (exported from the package) does the conversion, returning
premultiplied [r, g, b, a] in 0..1, or null. Gradient stops go through
the same path, so addColorStop(0, 'rgba(255, 0, 0, 0.5)') is right too.
Two companions for the places premultiplied is the wrong form, both exported alongside it:
cssColorStraight(value)— the same parse with straight alpha. OpenGL needs this:glClearColorand material colours take unassociated components, and premultiplied ones render translucent colours dark.premultiply([r, g, b, a])— converts. Interpolating two colours wants both: lerp in straight space, then scale once at the end, because a round trip back to anrgba()string only closes if the components were never scaled. Premultiplying twice is the failure this pair exists to prevent —rgba(255, 0, 0, 0.5)becomesrgba(128, 0, 0, 0.5), a colour half as bright at the same alpha.
Set NTK_STRICT_COLORS=1 to make a component outside 0..1 throw instead of
being clamped (it wires up x11's Render.strictColors); ntk's own test run
sets it. Note what that does not cover: an unpremultiplied [1, 0, 0, 0.5]
is inside 0..1 on every component, so only rendering catches it.
State and transforms
save()/restore()— full state stack: styles, line settings, font, text alignment, shadow,globalAlpha, composite op, transform and cliptranslate(x, y),rotate(angle),scale(x[, y]),transform(a, b, c, d, e, f),setTransform(...),resetTransform(),getTransform()→{a, b, c, d, e, f}
The transform applies to path commands as they are recorded, to
fillRect/strokeRect/clearRect, and to drawImage (server-side, via
the picture transform). Text is the exception: the translation applies
to the anchor point, but glyphs are not rotated/scaled — size text via
ctx.font.
Rectangles and images
fillRect(x, y, w, h)— respects clip, transform,globalAlphaand the composite op. Under an identity transform and a rectangular clip it is oneRender.Compositeover the intersection and nothing elsefillRects(rects)— batchedfillRect:rectsis an array of[x, y, w, h]quadruples or one flat[x0, y0, w0, h0, x1, ...]array; rectangles with non-positive width or height are skipped. Semantically it isfillRectonce per rectangle — same style, alpha, composite op, clip and damage reporting — but a solid-colourfillStyleunder an identity transform and a rectangular (or absent) clip sends the whole list as a singleRender.FillRectanglesrequest, which is what makes many-small-rectangles frames (terminal cell backgrounds, sparkline bars, heat maps, row striping) cheap. Gradient/pattern/Picturestyles, transforms and non-rectangular clips fall back to the per-rectangle loop. Batching paths the same way — many subpaths in onefill()/stroke()— is a different trade, because a path pays for one mask over all of them; see Many pieces in one pathstrokeRect(x, y, w, h)— outlines a rect without touching the current pathclearRect(x, y, w, h)— resets to nothing (honors clip + transform). What "nothing" is depends on the target: transparent black on a drawable with an alpha channel — a depth-32 ARGB window, see Transparent windows — so a compositor shows what is behind it, and opaque white anywhere else, since a depth-24 window has no alpha to write and white is the paper it starts fromdrawImage(image, dx, dy)/drawImage(image, dx, dy, dw, dh)/drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)— draws an ntkImage(decoded PNG/JPEG). The image uploads to the server once and is cached; scaling (and any affine transform) happens server-side with bilinear filtering. Respects clip andglobalAlpha.imagecan also be aSurface(pixels the server drew, including a8 coverage surfaces that paint in the currentfillStyle), anything else exposingwidth/height/picture(app), another ntk 2d context (server-side composite of the whole drawable) or a node-canvas-like object exposingimage.context.getImageData()(pixels are uploaded). A rectangular clip is applied by the server or by narrowing the composite, never through a full-surface maskctx.destroy()/Symbol.dispose— release the context's server-side resources: its GCs, its Picture and its masks. Solid-colour sources are cached on theApp, shared across contexts, and freed withapp.close(). Needed only for contexts created dynamically, such as one perSurface; a context on a window lives as long as the window. A context dropped without it still releases its GCs through a finalizer, asPixmapandPicturedo for theirs
Pixels
Pixel access follows the canvas API: ImageData is straight
(non-premultiplied) RGBA in a Uint8ClampedArray, rows top to bottom, and
ntk converts to and from the drawable's own layout at the boundary.
getImageData(x, y, w, h)— resolves to anImageData. A trailingcb(err, imageData)is still accepted. Reads the backing pixmap on double-buffered windows, so it is valid even where the window is occludedputImageData(data, x, y[, dirtyX, dirtyY, dirtyWidth, dirtyHeight])— writes straight RGBA back, optionally only part of the sourcecreateImageData(w, h)/createImageData(imagedata)— a blankImageData; the second form copies the size, not the pixels
const img = await ctx.getImageData(0, 0, 64, 64);
img.data[0] = 255; // red channel of the top-left pixel
ctx.putImageData(img, 0, 0);
What the drawable actually holds is none of those things — GetImage
returns words in the server's image_byte_order (a different handshake
field from the one the connection speaks), the channel positions come from
the visual's masks, anything XRender composited into is premultiplied, and a
depth-24 drawable's fourth byte is undefined padding rather than opacity.
Converting costs a pass over the pixels: roughly 0.04 ms for a 128×128 read,
0.7 ms at 640×480, 4.6 ms at 1920×1080.
readPixels(x, y, w, h)— the way out when you want the server's own bytes, for handing straight back toPutImageor into a codec. Resolves to{ width, height, data, depth, bitsPerPixel, byteOrder, masks, premultiplied }, so unlike a bareGetImagethe bytes say what they mean.byteOrderis'lsb'or'msb';masks.alphais 0 when the drawable has no alpha channel
Paths
Full canvas path surface:
beginPath(),moveTo(),lineTo(),closePath()bezierCurveTo(),quadraticCurveTo()— flattened adaptively (error-bounded subdivision in device pixels, so curves stay smooth at any transform scale)arc(x, y, r, a0, a1[, ccw]),ellipse(x, y, rx, ry, rot, a0, a1[, ccw]),arcTo(x1, y1, x2, y2, r)— arcs are flattened from their own geometry rather than by subdividing the curves they lower to, so they cost the fewest chords the tolerance allows instead of the next power of two (see How curves are flattened)rect(x, y, w, h),roundRect(x, y, w, h, radii)— radii like the spec: a number, or an array of 1–4 numbers /{x, y}pairs. A rounded rect on integer geometry fills/strokes through cached server-side corner glyphs instead of rasterization (see Rounded rectangles: corner glyphs)fill([path][, fillRule])—'nonzero'(default) or'evenodd'; rasterized here or on the server depending on size (see Where drawings are rasterized)stroke([path])— extrudes the polyline (extrude-polyline) and renders triangles; honors line dashes, round caps/joins, clip,globalAlphaand the composite op. Round-cap/join disks overlap the stroke body, so their coverage is accumulated in a clamped a8 mask and composited in a single pass — semi-transparent strokes (globalAlpha < 1or an alpha stroke style) do not double-darken at the overlaps. Disks are sized by the same flatness tolerance as any other arc, so a 1px round cap is a triangle and a very thick one stays smooth past where the old fixed ceiling of 32 segments started to show. A closed subpath is cut in the middle of one of its edges before extrusion, so every one of its vertices is a join and none of them is an end — a stroked rectangle has four identical corners, andlineCaphas nothing to apply to (per the spec, caps belong to the ends of open subpaths)clip([path][, fillRule])— intersects the clip region; restored byrestore()clipRegion(region)— intersects the clip with a server-side XFIXES region. Also restored byrestore(); see Region clipsisPointInPath([path, ]x, y[, fillRule])— hit test in canvas (device) coordinates
Region clips
A region is a set of rectangles the X server owns. It is how X describes
a non-rectangular area: the damage an expose reports, a window's SHAPE, or
what a compositor has left to paint after subtracting the windows in front.
ctx.clipRegion(region) makes one a clip:
const region = await app.createRegion([
{ x: 0, y: 0, width: 100, height: 100 },
{ x: 140, y: 140, width: 60, height: 60 }
]);
ctx.save();
ctx.clipRegion(region);
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 200, 200); // only the two rectangles are painted
ctx.restore(); // the clip is lifted, as after clip()
region.destroy();
app.createRegion(rects) is a promise because XFIXES is loaded on first use,
not because a region costs a round trip — it does not, and neither does any
operation on one except fetch().
What it is:
- Scoped like
clip().restore()takes it off and nothing else does. Region, rectangle and path clips intersect in any combination and any order. - In device pixels, ignoring the current transform — unlike
clip(), whose path goes through it. A region is a set of integer rectangles, so there is no honest way to rotate or scale one;region.translate(dx, dy)moves one server-side if that is what you want. - Applied by the server, never rasterized into a mask here. Region ∩
rectangle is one
IntersectRegion; a region alongside a path clip costs nothing beyond the mask that path was going to build anyway.
Region
app.createRegion(rects) hands back a Region. Rectangles may be written
{x, y, width, height} (the protocol's spelling) or {x, y, w, h} (ntk's,
which is what getImageData boxes and damage rectangles look like).
region.id— the XFIXES region id, for requests ntk does not wrapregion.set(rects)— replace the contentsregion.copyFrom(other)— replace the contents with another region'sregion.translate(dx, dy)— move itregion.intersect(other),region.union(other),region.subtract(other)— in place, chainable, one request eachregion.fetch() → Promise<{extents, rectangles}>— read it back. The one round trip in the class: for inspecting and testing, not for a paint loopregion.destroy(),Symbol.dispose, and a GC fallback — see resource-management.md
subtract is the compositor loop: paint front to back, taking each window's
shape out of what is left for the ones behind it.
const remaining = await app.createRegion([{ x: 0, y: 0, w: width, h: height }]);
const shape = await app.createRegion([]);
for (const win of frontToBack) {
shape.set([win.bounds]);
ctx.save();
ctx.clipRegion(remaining);
win.paint(ctx);
ctx.restore();
remaining.subtract(shape);
}
Why not install it on the picture yourself
ctx.picture is a real RENDER Picture and a region can be hung on it with
fixes.SetPictureClipRegion(ctx.picture.id, region, 0, 0) — which works,
right up until ntk next draws under a rectangular clip.
A Picture holds exactly one client clip, and ntk's own fast paths use it:
text glyph runs, batched fillRects, rounded boxes and drawImage all narrow
the picture to a rectangle around the drawing and put it back afterwards. Any
region in that slot is overwritten, silently, and which drawings do it depends
on which internal route they take (issue
#292).
Reading ctx.picture does settle the slot first, so a region you install
right after one is not fighting a rectangle left over from the last drawing —
but the next drawing takes the slot back.
clipRegion() is the same region as a clip ntk knows about: it is part of the
save()/restore() state, the fast paths intersect with it and restore to
it, and "no clip" is a state the context tracks rather than a full-plane
rectangle it stamps over whatever was there.

How curves are flattened
Everything curved is a polyline by the time it reaches a rasterizer, and how
many segments that polyline has decides the cost of everything downstream:
roughly a trapezoid per edge for a fill, a pair of triangles per segment for
a stroke. The budget is a flatness tolerance — the furthest the polyline
may stray from the true curve, 0.25 device pixels by default and the last
argument of flattenPath(cmds, matrix, tol).
Curves get there two ways:
- Beziers —
bezierCurveTo,quadraticCurveTo, SVG curve data, font outlines — are bisected until each piece is flat enough. The test is the guaranteed bound on how far a cubic leaves its chord, three quarters of the larger control-point distance, plus a check that the curve does not run past either end of the chord: a piece whose handles point back down the chord can hug its line while overshooting an endpoint and returning, which is within tolerance as a set of points but traversed in the wrong direction — invisible in a fill and a spike in a stroke. - Arcs —
arc,ellipse,arcTo,roundRect, SVGA— lower to cubics but remember the arc they came from, and are split into equal angular steps straight from the sagitta: a chord spanningθof a circle of radiusRmisses byR·(1 - cos(θ/2)), so the fewest chords isceil(sweep / 2·acos(1 - tol/R)). Bisection could only land on powers of two and overshot this by up to 2x — a quarter circle at r=256 took 32 chords where 18 are within tolerance.
The arc tag carries the ellipse as a centre plus two semi-axis vectors, which an affine map takes to the same form, so the exact route survives any transform — rotation, non-uniform scale and shear included — and the tolerance is always measured in device pixels. Ellipses are bounded by their semi-major axis, which is exact for circles and conservative for eccentric ones. Arc endpoints stay bit-identical to the lowering, so a rounded corner still meets the straight edge it joins.
A consequence worth knowing: the flattened polygon is inscribed, so a
filled circle is short of πr² by up to what the tolerance allows (~1.6% at
r=20) and never larger. Pass a smaller tol where that matters.
Where drawings are rasterized
Every fill and stroke ends the same way: coverage lands in a scratch a8 mask
bounded to the drawing's ink (one mask per cluster of its pieces — see
below), the mask is
intersected with the clip and globalAlpha, and the fill style is composited
through it. Only the first step has a choice of where it happens.
- Server-side — the geometry is trapezoidated here
(
lib/trapezoid.js) and sent asAddTraps/Triangles. Cost grows with geometric complexity: per-request overhead plus 40 bytes per trapezoid. - Locally — the geometry is rasterized into 8-bit coverage here
(
lib/rasterize.js) and uploaded with onePutImage. Cost grows with area: one byte per pixel of the drawing's bounding box.
The choice is per drawing, made by routeRaster() from the bounding-box area
and the flattened edge count. Defaults, measured against XQuartz with shapes
bracketing the range (a rounded rectangle at 14–79 trapezoids, a stroked icon
at 181–1053):
{ maxArea: 64 * 64, // at or below this bounding-box area, always local
bytesPerEdge: 150, // above it, local while area <= 150 * edges
maxBytes: 1 << 20 } // never upload more coverage than this at once
An icon-sized drawing takes the local route, and a wall of 400 of them costs ~24 ms of server time instead of ~1.9 s. A large, geometrically simple shape (a full-window rounded rectangle) takes the server route, where a handful of trapezoids beat a megabyte of coverage.
The same choice covers the a8 mask a non-rectangular clip builds: a
clip() whose path is not a rectangle rasterizes its coverage into a temp the
size of its bounding box, locally or as trapezoids, by the same policy. A
rectangle-only clip stack still rasterizes nothing at all, so this is only
reached by rounded corners and genuine paths. It matters where trapezoids are
a software fallback: a screen of
rounded cards can hold more clip masks than fills, and before this they were
the one drawing no policy could move.
Because what crosses the wire is coverage, not colour, the route is
invisible to everything else: gradients, solid fills, globalAlpha, clip and
composite ops all work identically either way, and a widget that draws through
fill()/stroke() — SvgView, anything of your own — is routed
without knowing this exists.
Many pieces in one path: what the mask costs
The mask is sized to the drawing's ink bounding box, and it costs width × height whatever the coverage inside it is. For one shape that bound is right. For a path holding N disjoint subpaths — a graph's edges, a scatter of handles, a wall of icons — the bound is their union, so batching N draws into one path trades N small masks for one big one. Whether that wins depends entirely on whether the pieces span the box anyway, and the two answers are far apart (measured at 1100×700 against XQuartz, per frame):
| scene, batched into one path per pen | one mask | clustered | drawn singly |
|---|---|---|---|
| 735 long edges | 1 mask, 0.73 MB, 27 ms | unchanged | 735 masks, 64 MB, 326 ms |
| 19 edges + 40 handle discs | 2 masks, 1.42 MB, 5.1 ms | 25 masks, 0.74 MB, 3.9 ms | 59 masks, 1.58 MB, 7.0 ms |
Long edges win from batching because each already spans a big box — the union adds nothing. Scattered dots lose: their coverage is ~1% of the union box, and the mask pays for all of it.

The boxes above are the masks themselves (scripts/bench-mask-clusters.mjs --png): on the left the two a8 masks a batched frame used to cost, on the
right the same drawing's clusters. The stroked edges keep one box — they span
it — while the handles each get their own.
So the choice is made per drawing rather than left to the caller. The pieces'
boxes go through a greedy gap partition (lib/maskcluster.js) and the mask is
emitted once per cluster:
- a cut is only made where nothing straddles it, which is what keeps every cluster box disjoint from every other. Disjoint boxes are why the split is invisible: no pixel is composited twice (a translucent colour would blend twice at any overlap), and the winding number a fill rule asks for does not change, because a closed subpath contributes nothing to the winding of a point outside its own box;
- and only where it saves more mask area than the extra mask pass costs.
Every cut therefore removes at least
minSavingpixels of mask, which also bounds how many clusters a drawing can take.
The upshot for a caller is that "fewer, bigger draws" stays the right instinct: where the pieces span the box, nothing is split; where they are scattered, the box around all of them is never paid for.
app.maskPolicy = { minSaving: 64 * 64, // mask pixels a cut must save to pay for its pass
maxMasks: 32 }; // most clusters one drawing may take
app.maskPolicy = { maxMasks: 1 }; // one mask per drawing, whatever it holds
Two things it deliberately leaves alone. Composite ops that write outside
their coverage — copy, source-in, destination-in, source-out,
destination-atop — keep one box, because for those the gaps between boxes
are pixels a single mask would have written too. And a source-over stroke
with no clip, no round caps or joins and globalAlpha 1 never builds a mask
at all: its triangles composite straight onto the destination.
ctx.maskStats is { masks, pixels, split } — mask passes, their total area,
and how many drawings took more than one. Mask area is the cost with no other
symptom, so it is counted rather than inferred;
scripts/bench-mask-clusters.mjs prints the table above with the split on,
forced off, and against drawing the pieces singly.
Rounded rectangles: corner glyphs
A third route sits in front of both of the above and skips rasterization
entirely. A fill() or stroke() whose path is exactly one roundRect() on
integer, axis-aligned geometry is recognized and emitted as corner glyphs +
FillRectangles: the box's only curved ink — the corners — becomes XRender
glyphs, cached server-side after first use and keyed by (radius, border width, corner) but not by the box size, so an animating pill or progress
bar keeps its glyphs while the rectangles stretch. Only the top-left corner of
each family is ever rasterized; the other three are mirror flips of its
bitmap, which also makes the four corners of a box pixel-exact mirror images.
The straight runs between the corners are plain FillRectangles. A steady
state card — background plus 1px border — is 4 small requests (two
CompositeGlyphs, two FillRectangles), with nothing rasterized and nothing
uploaded. The pieces partition the pixels, so translucent colours composite
once, never twice.
The recognizer only fires when it can reproduce the polygon route's output:
- the path is one
roundRect()(the tag any other path verb clears), drawn under a translate-only transform; - the fill/stroke style is a solid colour (
globalAlphafolds into it) and the composite op issource-over; - box position, size and radii are integers, radii within the policy cap;
- the clip stack is absent or rectangular (applied server-side as a picture clip, the way text glyph runs already do);
- strokes additionally need a uniform circular radius, no dashes, a border
no thicker than the corner radius — and the band's ink on pixel
boundaries:
x ± lineWidth/2integral andlineWidthitself integral, which a border of any width drawn the correct way (path inset by half the width) satisfies. The path radius is free to be fractional — nesting a border inside a background corner of radiusRmeans a path radius ofR - lineWidth/2, half-integer at every odd width, and the corner glyph carries that half pixel the same way the polygon route does. A 1px stroke on integer coordinates is a different shape — a genuine two-row 50% band — and keeps the polygon route. strokeRect()and radius-0 strokes lower further, to 4FillRectangleswith no glyphs at all.
Everything else falls through to the two routes above, unchanged. Every
bail-out is counted on the context — ctx.shapeStats is
{ hits, misses: { gradient, transform, 'clip-mask', fractional, dashes, 'radius-cap', … } } — and NTK_DEBUG_SHAPES=1 prints the process-wide
tally at exit, because a silent fall-off from this route is a perf cliff
worth noticing.
Policy, beside rasterPolicy/textPolicy:
app.shapePolicy = { maxRadius: 64, // corners above this fall back
cacheBytes: 256 << 10 }; // server-side corner-bitmap budget
app.shapePolicy = { maxRadius: 0 }; // disable the route entirely
NTK_NO_SHAPE_GLYPHS=1 in the environment disables it too (for A/B
measurement — examples/rounded-boxes.js and
scripts/bench-rounded-boxes.mjs draw the comparison,
scripts/bench-odd-border.mjs sweeps a bordered card wall by border width
alone, and examples/odd-border.js puts both routes side by side with the
corners magnified, for eyeballing parity rather than timing it). The corner
cache is per connection and evicts by resetting the page
when the budget is exceeded, so an adversarial animated-radius load stays
bounded; a real UI's population (a design system's radii × border widths) is
a few dozen tiny bitmaps that never approach it.
Swapping the rasterizer
import { createClient, ScanlineRasterizer, setDefaultRasterizer } from 'ntk';
const app = await createClient({ rasterizer: myRasterizer });
// or per app, at any time:
app.rasterizer = myRasterizer;
app.rasterizer = null; // send every drawing to the server
// or process-wide, before any app is created:
setDefaultRasterizer(myRasterizer);
A rasterizer is any object with one method:
rasterize({ polys, triangles, width, height, rule, dx, dy }) → Uint8Array | Buffer | null
- exactly one of
polys(closed polygons[x0,y0,x1,y1,…], filled byrule,'nonzero'or'evenodd') andtriangles(a stroke's triangle soup, whose overlaps must union rather than cancel — caps, joins and segments overlap constantly); dx/dymust be added to every coordinate. Geometry arrives in device space and the grid covers the drawing's bounding box; the offset maps one to the other. It is passed rather than pre-applied because pre-applying means copying every point of every path on every frame;- return
width * heightbytes of 8-bit coverage, row-major and unpadded, ornullto decline. Declining routes that drawing back to the server, so a partial implementation is safe — a rasterizer that only understands non-zero fills can returnnullfor everything else and stay correct.
Thresholds are tunable the same way: createClient({ rasterPolicy }) or
app.rasterPolicy, merged over DEFAULT_RASTER_POLICY.
Shared memory (MIT-SHM)
On a local connection ntk moves bulk pixel transfers through shared memory
instead of the X socket, which node-x11 provides with no extra dependency (an
unlinked /dev/shm segment handed to the server; see node-x11's
docs/ext/shm.md). It is used automatically for the transfers that are large
enough to benefit and falls back to ordinary PutImage/GetImage everywhere
else — a remote display, an old server, or a transfer too small to matter:
drawImageof anImageandputImageData— the image upload, above ~64 KB.getImageData/readPixels— the readback, above ~16 KB. This is the biggest win: a plainGetImageships the whole region back over the socket and can stall the server for tens of milliseconds; shared memory skips that.
Nothing in your code changes. Disable it with createClient({ shm: false }),
or plug in a zero-copy provider (see node-x11); app.shm is the helper.
Coverage masks deliberately stay on the socket. It is tempting to also send
the a8 fill mask (the PutImage in the local-rasterization route above) through
shared memory, and to then let routeRaster push more drawings local. Measured,
it is not worth it: coverage is one byte per pixel, so every mask the rasterizer
produces stays under the size where shared memory beats the socket (a full
256×256 mask is 64 KB and saves ~0.1 ms; a typical icon mask saves microseconds
lost in the round trip). The masks large enough to benefit are exactly the
simple, large shapes routeRaster already sends to the server, where a handful
of trapezoids still beat uploading the coverage. So enabling shared memory does
not change DEFAULT_RASTER_POLICY; the coverage path is unchanged.
The default ScanlineRasterizer uses signed-area accumulation (the font-rs /
stb_truetype v2 algorithm) — exact analytic antialiasing, no supersampling,
no dependencies, and it works in a browser bundle. CoverageAccumulator is
exported if you want to drive it directly.
Path2D
Path2D is exported from the package root and matches the browser class:
import { Path2D } from 'ntk';
const p = new Path2D('M8 8 H56 V56 H8 Z M24 24 H40 V40 H24 Z');
ctx.fill(p, 'evenodd');
const copy = new Path2D(p); // copy constructor
copy.addPath(p, [2, 0, 0, 2, 0, 0]); // append with an affine transform
- constructors:
new Path2D(),new Path2D(otherPath),new Path2D(svgPathData) - all context path-segment methods (
moveTo…roundRect) plusaddPath(path[, transform])([a,b,c,d,e,f]array or{a..f}object) - SVG path data supports the full grammar —
M L H V C S Q T A Z, relative forms, implicit repeats, compact arc flags; elliptical arcs are converted to cubics. The parser is also exported asparseSvgPath(d) - per the canvas spec, a
Path2Dis transformed by the current transform atfill/stroke/cliptime, while the default path records points as commands are issued
Gradients
const g = ctx.createLinearGradient(0, 0, 200, 0);
g.addColorStop(0, 'red');
g.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = g;
createLinearGradient(x0, y0, x1, y1)createRadialGradient(x0, y0, r0, x1, y1, r1)createConicalGradient(x0, y0, angle)— ntk extension (XRender conical gradient)- The gradient's coordinates are user space, resolved against the transform in force when it is painted — not the one that happened to be current when it was created. A gradient written in a node's own coordinates keeps painting in them after the context is translated to that node's origin, and a scaled context scales the ramp with the shape. A transform that collapses (a zero scale) paints nothing, as the canvas spec says
- Past the outermost stops the gradient clamps to their colours, so a fill wider than the ramp keeps its end colours instead of fading to transparent
- Gradients are uploaded lazily on first use and freed with the context's pictures on GC
Gradients work anywhere a colour does — fillRect, path fills, strokes,
fillText — and go through the clip, globalAlpha and the composite op
like any other style.
Patterns
createPattern(source, repetition) returns a CanvasPattern: a tile the
server repeats across whatever the pattern fills, in the one composite the
fill already costs.
const tile = new Surface(app, { width: 24, height: 24 });
tile.render((c) => {
c.fillStyle = '#d0d4dc';
c.fillRect(0, 0, 1, 1); // one dot per 24x24 cell
});
ctx.fillStyle = ctx.createPattern(tile, 'repeat');
ctx.fillRect(0, 0, ctx.width, ctx.height); // one request, no mask
That is the difference a background grid notices. Drawn as paths — one
subpath per dot, batched or not — the grid rasterizes into an a8 coverage
mask the size of its own bounding box, which for a background is the pane,
then uploads and composites it, every frame. Measured at 1100x700 that grid
cost ~700 KB and ~25 ms of a ~100 ms frame (issue #263); as a pattern it is
a tile-sized picture and one Composite, and the cost stops scaling with
the pane. The same applies to checkerboards under transparency, hatched
chart fills and any texture-shaped background.
sourceis aSurface(pixels the server drew), anImage(pixels uploaded from the client), aPixmapor aWindow. A repeating Picture is created over those pixels, so tiling a surface does not change howdrawImagesamples that same surface. A coverage (a8) surface is refused: it has no colour to paint with —drawImageis what paints coverage in the currentfillStyle. AWindowhas to know its depth and its visual first, because that is where the tile's picture format comes from (see Picture formats):await wnd.getGeometry()establishes the depth on any window, andawait wnd.readyis the wait for both on one adopted by id, which ntk has already asked about — see Adopted windowsrepetitionis'repeat'(the default, and whatnullmeans),'no-repeat', or the two XRender modes the canvas spec has no name for:'pad'(clamp to the edge pixels) and'reflect'(mirror every other tile). The spec's'repeat-x'/'repeat-y'are not supported — XRender repeats a source picture on both axes or on neither — and throw with the equivalent: tile with'repeat'and bound the fill to the one row or column of tiles,ctx.fillRect(x, y, w, tile.height)pattern.setTransform(matrix)positions the tile:[a, b, c, d, e, f]or a DOMMatrix-shaped{a, b, c, d, e, f}mapping pattern space to user space. Translating by the scroll offset is what keeps a grid glued to the content under it; a zoom step re-renders the tile and keeps the composite- The pattern is painted in user space, like the gradients above: the transform in force at fill time applies to the tile as well as to the shape, so a scaled context scales its grid. A transform that collapses (a zero scale) paints nothing, as the canvas spec says
- Whole-pixel tiling samples with the
nearestfilter — the tile's own pixels, exactly — and anything else (a fractional offset, a scale, a rotation) resamples bilinearly - The repeating picture is created on first use.
pattern.destroy()(orSymbol.dispose, or the GC) frees it; the tile it reads is the caller's, and destroying thatSurface/Imagewhile the pattern lives is safe — X keeps pixmap storage alive as long as a picture references it, though the pixels then stop tracking anything drawn afterwards - A pattern belongs to the connection, not to the context that created it:
one grid tile serves every window on the app, and it outlives
ctx.destroy()
Patterns work anywhere a colour does — fillRect, path fills, strokes,
fillText — and go through the clip, globalAlpha and the composite op
like any other style.
Shadows
The four canvas shadow properties, applied to every drawing operation —
fill, stroke, fillText, fillRect, strokeRect, fillRects,
drawImage and drawGlyphs (so a TextLayout shadows exactly as a
fillText does):
ctx.shadowColor = '#05070a';
ctx.shadowBlur = 7;
ctx.shadowOffsetX = ctx.shadowOffsetY = 3;
ctx.fillText('Specimen', x, baseline);
shadowColor— any colour string or premultiplied array. The default,'rgba(0, 0, 0, 0)', is what turns the whole thing off: a transparent shadow colour skips the path entirely, so an app that never sets it pays one array read per drawing operation and nothing elseshadowBlur— the canvas spec's diameter, not a radius: the gaussian it names has σ =shadowBlur / 2, so a value here looks like the same value in a browser.0(the default) is a hard-edged copy of the shapeshadowOffsetX/shadowOffsetY— in device pixels, and deliberately outside the current transform, exactly as the spec has it: a rotated drawing casts an upright shadow, the way a rotated element'sbox-shadowis upright in CSS. Offsets are rounded to whole pixels when the shadow is composited (the drawing's own sub-pixel position is untouched)
A shadow goes through everything the drawing itself does: the clip,
globalAlpha and the composite operation all apply to it, and it is painted
first so the drawing lands on top. With no offset and no blur it sits exactly
under the shape, where it shows through anything translucent — that is the
spec's behaviour, not an oversight.
How it is drawn, and what it costs
A shadow is the drawing's coverage, blurred, offset and painted in one colour. All three steps are server-side:
- the shape is drawn into a padded
a8Surface— white on transparent, so every pixel is its own alpha - two
convolutionfilter passes blur it, horizontal then vertical - the result composites as a mask with
shadowColoras the source
The padding in (1) is why an app should not assemble this by hand: a convolution samples outside the picture, where RepeatNone reads transparent, so a shape drawn flush to the surface edge ends in a straight line where the kernel ran out of pixels. Everything here pads by the blur's full reach.
Step (2) is two 1d passes rather than one 2d kernel because a gaussian is
separable, and that is the difference between a shadow you can animate and
one you cannot: a k-wide 2d kernel costs k² multiplies per pixel where two
passes cost 2k. At shadowBlur: 30 that is 8281 against 182.
Text shadows are cached — keyed by (text, font, blur) on the connection —
because text is the one drawing with a short, stable name. A label redrawn
every frame, or a specimen redrawn on every slider tick, builds its coverage
once and composites it afterwards. Paths, rectangles and images have no such
key and rebuild their coverage per draw, so a large blurred path shadow in a
render loop is the shape to watch for; draw it into a Surface yourself and
drawImage that instead.
Laid-out text is cached too, on the identity of the runs it is made of
rather than on a string: a whole paragraph is one coverage surface, whatever
its line count, keyed by those runs and their positions relative to each
other. So the same words wrapped to two different widths are two shadows —
they are two drawings — while re-drawing one TextLayout, anywhere on the
target, is a lookup and a composite. A caller that hand-builds fresh runs
every frame (a terminal grid, say) has nothing stable to key on and pays for
its coverage each time.
A shadow belongs to a drawing call, here as in a browser: a paragraph
whose spans change colour is drawn as several glyph composites, and each
casts its own shadow, exactly as consecutive fillTexts would.
app.shadowPolicy tunes the ceilings (partial objects merge over the
defaults):
cacheBytes(4 MB) — LRU budget for retained shadow coverage; least-recently-drawn surfaces are freed server-side past itmaxSigma(32) — the widest gaussian actually run. The kernel is 6σ+1 taps, every tap rides the request, and the server multiplies each of them per pixel per pass, so an unboundedshadowBlurwould be an unbounded stall. This is the one place a shadow stops matching a browsermaxPixels(8 M) — the largest coverage surface built for one shadow; past it the shadow is dropped and the drawing is unaffected
Only what could be seen is rendered: a shape's ink is clipped to the part whose shadow can land on the target at all (its own bounds, moved back by the offset and grown by the blur's reach), so a shape mostly off-screen does not allocate a surface the size of its bounding box.
How strong a shadow gets, and how to test one
A blurred shadow reaches shadowColor only where the shape casting it is
wide compared with the blur. That follows from what a blur is — coverage
convolved with a gaussian — but it surprises people looking at pixels, so
here it is in numbers, with σ = shadowBlur / 2:
| what casts it | shadowBlur | peak alpha |
|---|---|---|
| a 60×40 rect | 30 (σ 15) | 0.78 |
| a 60×40 rect | 8 (σ 4) | 1.00 |
| 48px glyph stems | 14 (σ 7) | 0.37 |
A glyph stem five pixels wide against σ 7 keeps about erf(5 / (2√2 · 7))
of its coverage — under a third — and that is what a browser draws too. So
an exact-colour pixel assertion is the wrong test for a shadow: a
"count the pixels within 90 of #ff0000" check finds nothing on a canvas
whose red glyph shadow is plainly visible, because no pixel on it is ever
that red (issue #287).
What to assert instead:
- the shadow's own alpha, on a transparent target. Draw with
fillStyle = 'rgba(0, 0, 0, 0)'so only the shadow paints, and read the alpha channel out ofgetImageData— it is the coverage, with no background mixed into it - a difference between two places, rather than a colour: darker (or more tinted) where the shadow is than where it is not, at an offset the drawing itself does not reach
- the profile, when the blur itself is what is under test: a blurred
straight edge follows the gaussian's CDF, so coverage at ±σ is
0.841 / 0.159 (this is what
test/shadow.test.jsandtest/smoke-canvas.test.jscheck)
None of this changes with the server. Shadows render identically on node-x11's in-process JS X server and on Xorg — same requests, same pixels — and both suites pin the same numbers; see xserver.md.
Text
Text is fully shaped: OpenType kerning and ligatures, contextual forms for
complex scripts (e.g. Arabic), bidi reordering and automatic font fallback
all apply. Glyphs upload to the server once per (face, size); drawing costs
about a byte per glyph afterwards. Very large (>256px by default),
fractional or frame-to-frame-varying sizes render as trapezoids instead —
no per-size server cache — see
text.md; tune via
app.textPolicy.
fillText(text, x, y)— draws with the currentfontandfillStyle, honoringtextAlign/textBaselinemeasureText(text)→ canvas-style TextMetrics:width,actualBoundingBox{Left,Right,Ascent,Descent},fontBoundingBox{Ascent,Descent}textAlign—'start' | 'end' | 'left' | 'right' | 'center'textBaseline—'alphabetic' | 'top' | 'hanging' | 'middle' | 'bottom' | 'ideographic'fontVariationSettings—'"wdth" 87.5'or{ wdth: 87.5 }, for a variable font. Thewghtaxis needs none of this: a numeric weight in thefontshorthand already drives it (ctx.font = '460 40px Inter'). Set either before or afterfont— see fonts.mdlayoutText(content, options)→TextLayout— ntk extension: wrap text (or styled spans) to a target width without drawing, inspect lines and metrics, thenlayout.draw(ctx, x, y)drawGlyphs(op, src, positioned)— ntk extension: composite glyph runs directly, shaped or hand-built. The run shape is public API, for renderers that position glyphs themselves (a terminal grid, a tabular column) — see text.md
Custom font files: app.fonts.load(path), then use the family name in
ctx.font. See text.md for the full text API and
fonts.md for font lookup.