Skip to content

SVG rendering

@vyaz/renderer turns a layout result into an <svg> string. The layout engine has already decided where every line, run, and glyph sits — the renderer only chooses how much of that gets written into the markup.

ts
import { layoutTextFrame } from '@vyaz/core'
import { renderResultToSVG } from '@vyaz/renderer'

const result = layoutTextFrame(frame)
const svg = renderResultToSVG(result, { preset: 'browser' })
FunctionFor
renderToSVG(lines, options?)a bare Line[]
renderResultToSVG(result, options?)a layoutTextFrame result (passes dimensions through)
renderParagraphToSVG(lines, width, height, options?)one paragraph at a fixed size

Presets

The preset option is the one setting most callers touch. It picks the SVG structure — from "let the viewer re-measure the text" to "every glyph is nailed to a coordinate". More baked in means the output survives a missing font, at the cost of size.

flatflat · no tspan

Every run is a single flat <text> element — no <tspan>, no nesting. Smallest output. Word origins come from the x on each <text>; the glyphs inside flow on the viewer’s own text engine.

baked in
<text x="0" y="27">Regular </text>
<text x="107" y="27" font-weight="700">Bold</text>

two <text> nodes, nothing nested

browserexpanded · no textLength

One <tspan> per styled run, each carrying its own x and style attributes; whitespace becomes its own <tspan>. Mirrors how a browser walks inline runs — the format to inspect or hand-edit.

baked in
<text xml:space="preserve">
  <tspan x="0">Regular</tspan>
  <tspan x="99.81"> </tspan>   ← the space

x on every run; spaces split out

preserveexpanded · textLength

Same structure as browser, plus textLength on every fragment. The viewer stretches each fragment to the width Vyaz measured, so the line stays put even when the real font is missing.

baked in
<tspan x="0" textLength="99.81">Regular</tspan>
<tspan x="99.81" textLength="7.38"> </tspan>

textLength pins each fragment’s width

glyphglyph · per-char x

One x coordinate per glyph. The whole layout is baked into the markup — it renders identically anywhere, with no shaping at render time. Largest output.

baked in
<tspan x="0 18 34.2 51.2 67.6 74.3 89.9">Regular</tspan>
<tspan x="108 126.5 143.6 151.1">Bold</tspan>

one x value per character

The highlighted attribute in each snippet is what that preset adds over the one above it. All four keep whitespace with xml:space="preserve" and emit space runs as their own <tspan>.

Matching the browser's own width

browser and preserve line up with what a browser paints only if the engine measured the same way the browser shapes. Pass layoutTextFrame(frame, { shaping: true }) for GPOS kerning + liga/clig/calt — see Browser usage. Leave shaping off for glyph (its per-character x is not shaping-aware yet).

Other options

style — how presentation is expressed

ValueOutputUse
xml (default)font-family="Inter" font-size="28" fill="#000"Self-contained; PowerPoint / Inkscape / Illustrator
cssstyle="font-family: 'Inter', sans-serif; font-size: 28px; …"Inlined in HTML, so page CSS can theme it

In xml mode only diff attributes are written on each <tspan> — anything equal to the previous <tspan> in the same <text> is omitted. Space runs don't reset the diff.

fit — the textLength attribute

ValuetextLength onEffect
none (default)text flows naturally
textthe <text>viewer stretches the whole line to the measured width
frageach <tspan>each fragment stretched independently — this is what preset: 'preserve' turns on

frag needs the expanded structure (browser / preserve). It's ignored for glyph; with flat it falls back to text.

sizing — the canvas box

ValueBehaviour
frame (default)use the explicit width / height (both required)
contentcompute the bounding box from the lines; ignore width / height

Pass a per-axis object for mixed control: sizing: { horizontal: 'content', vertical: 'frame' }.

missingGlyph — a code point no font covers

ValueBehaviour
keep (default)Emit the raw character. The viewer paints it from its own fallback stack — a width Vyaz can't predict, so in the glyph preset the next character can overlap it.
boxDrop the character and draw a hollow .notdef rectangle in the slot the layout reserved (MISSING_GLYPH_FACTOR × fontSize wide). Painted width then equals measured width in every viewer — the way a slide editor shows a box for an unavailable glyph.

box is honoured by the glyph preset only (it positions every character, so the box lands exactly). The layout must carry the missing-glyph map: the glyph preset fills it automatically, elsewhere pass layoutTextFrame(frame, { markMissingGlyphs: true }). The real fix for missing glyphs is still to register a font that has them — see Browser usage → Legacy family names.

debug — overlay boxes

Pass { debug: { … } } to draw diagnostic geometry as extra SVG elements. contentPadding adds a margin around the canvas so edge overlays aren't clipped.

FlagDraws
frameBoxthe frame container box (frameWidth × frameHeight)
contentBoxthe actual bounding box of all lines
paragraphBoxone box per paragraph
boxeach line box
baselinethe baseline of each line
ascentDescentascent / descent lines
lineGapfilled rect for each line's lineHeight
runsa rect around each run's glyph box
labelsx / y / w / h / bl coordinate labels
columnBoxcolumn separators (multi-column only)
widthBordernumber — stroke width for all overlay lines (default 1)

Try every preset and overlay live in the Playground.

Released under the MIT License.