SVG widget
SvgView renders static SVG documents through the 2d context:
geometry becomes Path2D objects, <g transform="…"> becomes context
transforms, paint servers become canvas gradients — so everything is
composited server-side by XRender like any other 2d drawing.
import { createClient, SvgView } from 'ntk';
const app = await createClient();
const wnd = app.createWindow({ width: 480, height: 360, title: 'svg' });
const view = new SvgView(wnd);
view.setSvg(await readFile('drawing.svg', 'utf8'));
wnd.map();
Standalone (windowless) use draws into any 2d context (a window, a pixmap):
const view = new SvgView(null);
view.setSvg(svgText);
view.draw(ctx, x, y, width, height);
See examples/svg-viewer.js for a small viewer
(node svg-viewer.js file.svg).
A document renderer built on ntk uses this widget for the SVG inside its
documents — react-x11's <svg> element is the worked example, feeding it
either a parsed DOM or a markup string.
API
new SvgView(window[, opts])—windowmay benullfor standalone use. Options:theme.background— window-mode background fill (default'white')fit— window-mode fitting:'contain'(default; fitted + centered, preserving aspect ratio) or'fill'(stretch)color— whatcurrentColorresolves to (default'#000'); see Taking colour from the callerlanguages— the languagessystemLanguageis matched against, most preferred first, likenavigator.languages; see Conditional processing
view.setSvg(svgText)— parse and adopt a document (a string containing an<svg>element). Re-renders in window modeview.setSvgDom(element)— adopt an already-parsed htmlparser2<svg>element. For inline SVG inside a host document; tolerates HTML-mode parses (lowercased tag/attribute names likeviewbox,lineargradient)view.draw(ctx, x, y[, w, h][, opts])— draw into any 2d context;w/hdefault to the natural size. TheviewBox(when present) is scaled to the target box. Options, each for this draw only:color— whatcurrentColorresolves to, overriding the view's owncolorfont—{ family, size, weight, style }, any of them, the size in user units: what the document's text inherits where it names no font of its own. A host document hands an inline<svg>its font this way, as a browser does; without it text starts from 16pxsans-serifsurface(width, height)— makes the offscreen surface a masked element is drawn on: something withgetContext('2d')anddestroy()thatctx.drawImagetakes, or null. A context of ntk's own needs none — it usesapp.createSurfaceon its app — but one that is not ntk's, react-x11's macOS context, has to be handed one
view.paintKind/view.soloPaint— how many colours the document commits to, from the parse; see Taking colour from the callerview.render()— window mode: clear the background and draw fitted; called automatically onexposeview.naturalWidth/view.naturalHeight— from thewidth/heightattributes, falling back to theviewBoxsizeview.viewBox—[minX, minY, width, height]ornullview.languages— thelanguagesoption, lowercased, or the default worked out from the runtime. Read when a document is adopted, forpaintKind, and on every draw
The widget is static and safe by construction: no scripting, no network or filesystem access — documents are strings and nothing external is ever fetched.
Supported SVG subset
Elements:
- shapes:
path(full path-data grammar, arcs included),rect(+rx/ry),circle,ellipse,line,polyline,polygon - structure:
svg(viewBox,width/height, presentation attributes),g,defs,use(href/xlink:hrefto a local#id,x/yoffset — the last of its transforms, so itsclip-pathand itsmaskmove with it —symboltargets),a(rendered, not clickable),switch(draws its first child whose conditions hold, through its owntransformandopacityas agwould) - a nested
svgis a new viewport, not a group:widthbyheightatx,y, each a length or a percentage of the viewport around it, and all of that viewport where unset. ItsviewBoxis fitted in aspreserveAspectRatiosays — any alignment,meet(the default),sliceornone— and what it draws is clipped to the viewport unless itsoverflowisvisibleorauto. A zero or negativewidthorheight, or a zero-sizedviewBox, draws nothing - paint servers:
linearGradient,radialGradientwithstop(offset,stop-color,stop-opacity),gradientUnitsofobjectBoundingBox(default) oruserSpaceOnUse,gradientTransform, andhref/xlink:hrefto another gradient, whose attributes it takes where it sets none and whose stops it takes where it has none, through any number of them. A radial gradient under agradientTransformthat stretches it stays a circle, of the same area text, withtspan(andaandtextPath, read as atspan) — see Textmask— see MasksclipPath— see Clip paths
Presentation attributes (also inside inline style="…", which wins):
fill,stroke— colors,none,currentColor,url(#gradient)fill-rule(nonzero/evenodd),fill-opacity,stroke-opacity,opacity(multiplies down the tree) — each a number or a percentagedisplay: none: the element and everything in it are left out, whateverdisplaya child names — which is how an editor exports a hidden layer. What is only ever drawn by reference (a gradient, asymbol) is still there to reference, and auseof an element that isdisplay: nonedraws nothingvisibility: hiddenorcollapse: the shapes and text it reaches are not drawn. It is inherited, and a shape inside a hidden group that saysvisibility="visible"is drawn againstroke-width,stroke-linecap,stroke-linejoin,stroke-miterlimittransform—matrix,translate,scale,rotate(incl. the 3-argument center form),skewX,skewY, in any list combinationcolor(forcurrentColor)
These apply on the root <svg> too, and inherit from there like they do
from a <g> — which is how every mainstream icon set is written:
<!-- lucide, feather, heroicons, tabler, Material Symbols all look like this -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
Nothing on the shapes names a paint, so dropping the root's attributes would
leave them at SVG's initial values — fill: #000, stroke: none — and an
outline icon would fill black, or paint nothing at all if its strokes enclose
no area.
Not supported (skipped silently): CSS stylesheets/<style>, a
clip-path that is a CSS shape rather than a url(), filter, pattern, animation/SMIL, foreignObject,
external references, preserveAspectRatio on the root svg (whose
viewBox is stretched to the box draw is given), a symbol's viewBox,
stroke dashing, and of text: a path to set it along, rotate,
textLength, baseline-shift, decorations, and stroked text.
Text
A <text> is laid out as SVG 2's text layout has it, short of what a
browser does for vertical and right-to-left writing:
- Spans.
tspans nest, and each sets its own paint,opacity, font and spacing for what it holds. Atspanthat isdisplay: nonetakes its text out; one that isvisibility: hiddenkeeps its place and draws nothing, and one that saysvisibleinside a hiddentextis drawn. - White space collapses as CSS's
white-space: normalcollapses it: a line break and a tab are a space, a run of spaces is one, across span boundaries, and none is left at either end of thetext.xml:space= "preserve", or awhite-spaceofpre,pre-wraporbreak-spaces, keeps each. - Positions.
x,y,dxanddyare lists, one value a character, in user units,em,ex, the absolute units or a percentage of the viewport; a character takes each from the innermost element that gives it one. Adyof1.15emon atspanis of that span's own font size. - Chunks and anchoring. A character with an absolute
xorystarts a text chunk, and each chunk is moved as its first character'stext-anchorsays — measured, so the twotspans of a badge are each centred on their ownx, whatever size each is set at. Nothing is left to the context'stextAlign, which a context that draws through CoreText or DirectWrite does not have. - Fonts.
font-family,font-size(lengths, percentages and keywords),font-weight(bolderandlighterincluded) andfont-style. A family that leans on a custom property,var(--sans), is the one inherited: there are no custom properties here. - Spacing and case.
letter-spacingandword-spacing, a length inemcoming to the element's own size and inherited as that length, andtext-transform. - Baselines.
dominant-baseline, inherited, andalignment-baselineon a span:central,middle,hanging,mathematicaland the text edges, from the font's ascent and descent where the context'smeasureTextsays them and from an em's proportions where it does not. - Size. Glyphs are set at the size they are drawn. On ntk's own context,
whose glyphs are rasterized at the size they are shaped at and do not
scale with the transform, the transform's scale goes into the font size;
on a context with
scalesText, the font is the size the document says and the context scales it.
Masks
An element with a mask (attribute or style) naming a <mask> is drawn
as CSS Masking 1 has it:
- the element on an offscreen surface, and the mask's content on a second, through the same transform — device pixels, the part of the mask's region the drawing can show
- the second cuts the first with
destination-in, and the first is composited in the element's place at itsopacity, which applies to what the mask leaves of it as one group mask-type: alphatakes the mask's alpha as it is; the default,luminance, its luminance times its alpha. The content of a luminance mask is drawn as the value it makes: each mark first erases its alpha from what is under it (destination-out) and then adds its luminance times its alpha (lighter), so a black shape over a white one hides what is under it, as the colours composited would. A context with neither op adds the value alonemaskUnits(objectBoundingBox, the default, oruserSpaceOnUse) and the region'sx,y,widthandheight(-10%, -10%, 120% and 120% where unset);maskContentUnits. An element whose bounding box has no area is not drawn where either isobjectBoundingBox, and a region of no area shows nothing- what is in the mask inherits from the mask's ancestors, not from what it masks, and a mask that reaches itself draws what it holds once
The surfaces come from opts.surface, or from app.createSurface on a
context of ntk's own. Where there is none, or the context cannot
destination-in, the element is drawn as it is, cut to the mask's region.
A mask that is not there, or that names something else, is no mask.
Clip paths
An element with a clip-path (attribute or style) naming a <clipPath>
is cut to it as CSS Masking 1, 6 has it, in the user space the element
draws in — after its transform, a use's x and y, and for a nested
svg its viewBox:
- what cuts is the union of the clipPath's shapes and texts, and of the
shape or text each
usein it names, through their transforms, each as it is filled, whatever its fill, stroke or opacity: by itsclip-rule(nonzeroorevenodd), which is inherited from the clipPath and what it is in, and not by itsfill-rule. A text cuts by its glyphs - anything else in it — a group, a
useof a group or of asymbol— counts for nothing, as in Chrome and WebKit; and so does what isdisplay: none, not visible, or whose conditions do not hold. A clipPath with nothing in it that counts lets nothing show clipPathUnits:userSpaceOnUse, the default, orobjectBoundingBox, in the bounding box of what it cuts. An element whose box has no area is not drawn in the second. The clipPath'stransformgoes outside that- a
clip-pathon what is in the clipPath cuts that, in its own user space, and one on the clipPath cuts the whole, in the user space of what it cuts — as Chrome and Firefox set it. A clip path that leads back to itself is left out, as Chrome and WebKit leave it, and the clip that named it kept
A clip that is one shape, and what cuts it one shape at a time, is the
context's own clip(), on any context. More than one is drawn as a mask is
drawn: the element on a surface, and the clip's coverage on a second, from
opts.surface or app.createSurface — see Masks. Where there is
none, the element is cut to the outlines of all its shapes as one path,
which is their union wherever they do not overlap, and to a text's box.
A clip-path naming something that is not there, or not a clipPath, cuts
nothing, and so does one naming a clipPath that is display: none itself,
as Chrome, Firefox and WebKit all have it; one inside something that is
display: none cuts as any other. The root svg's own clip-path is the
caller's, as its opacity and mask are.
Markers
A path, line, polyline or polygon draws the <marker>s its
marker-start, marker-mid and marker-end name — attributes, or in its
style, where the marker shorthand sets all three — as SVG 2, 11.6 has
them, over its fill and its stroke:
- at its vertices: the first vertex of the path takes
marker-start, the lastmarker-end, and every one betweenmarker-mid— where each subpath starts, where each of its segments ends, and where a closed one comes back to its start. An arc is one segment, so the curves it is drawn as make no vertices inside it - each in a viewport of its own,
markerWidthbymarkerHeight(3 by 3 where unset), in stroke widths unlessmarkerUnitsisuserSpaceOnUse, with the marker'sviewBoxfitted in as itspreserveAspectRatiosays and the pointrefX,refYof it — a number, orleft/center/rightandtop/center/bottomof the box — on the vertex - turned by
orient: an angle (degrees unless it namesrad,gradorturn), or the direction the path runs at the vertex forauto— the bisector of the way it comes in and the way it goes out, at a vertex with both, and a closed subpath comes into its start by its closing side — and forauto-start-reversethe start's turned the other way, which is how an arrow is put on both ends of a line with one marker - clipped to the viewport unless the marker's
overflowisvisibleorauto
What is in a marker inherits from the marker's ancestors, not from the
shape it is on, and is faded by the shape's opacity. A marker inside
something that is display: none is not drawn, as Chrome, Firefox and
WebKit all leave it; its own display is not asked, since the property
does not apply to a marker, and Chrome and WebKit draw one that says
none. A marker that
reaches itself draws what it holds once. Markers count for
paintKind: a marker of
another colour makes a drawing more than one.
Conditional processing
An element whose requiredExtensions, requiredFeatures or
systemLanguage does not hold is not drawn, nor anything in it; one that
carries none of them holds. A switch draws the first of its child elements
that holds and none of the rest — that one as it says, so one that is
display: none is chosen and draws nothing.
requiredExtensionsnever holds:SvgViewsupports no extension. That is how an Illustrator export draws here, its own data first in aforeignObjectbehind Adobe's extension and the drawing after it.requiredFeaturesholds unless it names an SVG 1.1 featureSvgViewdraws nothing of —#Extensibility(foreignObject),#Image,#Clip,#Filter,#Pattern,#Marker,#Font,#Script,#Animationand the like. SVG 2 dropped the attribute and browsers hold every one; here a feature it lacks picks the author's fallback, which is how a draw.io export's labels draw: as thetextit writes after eachforeignObjectfor renderers without one.systemLanguageholds where one of its comma-separated tags is one of the view'slanguages, or one of them narrowed or widened by a subtag —enmatchesen-AUanden-AUmatchesen. An empty one never holds. By default those arenavigator.languages, or else the runtime's locale, each followed by its language alone:['en-au', 'en']. A host that knows its UI's language passes it:
const view = new SvgView(null, { languages: ['de-CH', 'de'] });
Taking colour from the caller
An icon set is written without colours: every shape says
fill="currentColor" or stroke="currentColor", and the surrounding UI
decides what that means. One parsed document then serves a normal row, a
hovered row and a disabled row.
const icon = new SvgView(null).setSvg(iconMarkup);
icon.draw(ctx, x, y, 20, 20, { color: theme.fg });
icon.draw(ctx, x, y + 24, 20, 20, { color: theme.accent }); // same document
opts.color applies to that draw only. A default for every draw goes on the
view, and window mode uses it too, since render() takes no options:
const view = new SvgView(wnd, { color: '#0984e3' });
Both fall back to the CSS initial value, black. Note that only currentColor
follows this: the initial fill is black, not currentColor, so a document
that names no paint at all still fills black — as it does in a browser.
paintKind: which documents can be recoloured
Parsing also records how many distinct paints the drawing actually commits to, which is what a caller caching rendered output needs in order to decide whether the colour belongs in its cache key:
view.paintKind === 'mono'— every fill and stroke that reaches a shape isnoneor the same paint. The drawing is a coverage mask plus a colour, so one rendered copy can be recoloured for every use.view.soloPaintis that paint: a colour, or the literal'currentColor'when the document defers to its caller.view.paintKind === 'multi'— a second distinct paint, or a gradient/pattern reference. Those colours belong to the drawing rather than to the UI, so a rendered copy is only good for the colours baked into it, andsoloPaintisnull.
Opacity does not enter into it: opacity, fill-opacity and
stroke-opacity scale coverage, which a mask carries perfectly well. What
is not drawn at all — under display: none, a shape whose visibility is
hidden, a switch child it does not choose — commits to no colour.
SVG path data elsewhere
The path-data parser is shared with Path2D and exported directly:
import { Path2D, parseSvgPath } from 'ntk';
ctx.fill(new Path2D('M10 10 A 20 20 0 0 1 50 10 Z'));
const commands = parseSvgPath('M0 0 Q 5 5 10 0'); // [{type:'M',…}, {type:'Q',…}]
parseSvgPath returns normalized M/L/C/Q/Z commands (arcs are converted
to cubics) — the same shape consumed by lib/rasterize.js and the TeX
widget.