Tables
TableFrame is a grid layout sibling of TextFrame: rows of cells, each cell's content a full recursive TextFrame (or another TableFrame — see Nested tables). Column widths and row heights are measured from cell content, the same way paragraph width is measured from runs — you don't hand-compute a grid, you describe cells and the engine sizes them.
import { layoutTableFrame } from '@vyaz/core'
import { renderTableToSVG } from '@vyaz/renderer'
import type { TableFrame } from '@vyaz/core'
const cell = (text: string) => ({
content: {
wrap: true,
paragraphs: [{
style: { alignment: 'left', lineHeight: 1.3, spaceBefore: 0, spaceAfter: 0 },
children: [{ type: 'text', text, fontFamily: 'Inter', fontSize: 14, fontWeight: 'normal', fontStyle: 'normal', color: '#111' }],
}],
},
})
const table: TableFrame = {
defaultCellStyle: { paddings: 8, borderWidths: 1, borderColors: '#ccc' },
rows: [
{ style: { bgColor: '#eee' }, cells: [cell('Name'), cell('Qty')] },
{ cells: [cell('Widget'), cell('3')] },
],
}
const result = layoutTableFrame(table)
const svg = renderTableToSVG(result, { preset: 'browser' })layoutTableFrame does the sizing and positioning (TableLayoutResult); renderTableToSVG paints it. They're separate the same way layoutTextFrame and renderToSVG are — you can inspect/transform the layout result before rendering.
Sizing
By default, column widths follow the widest natural (unwrapped) cell in that column, and row heights follow the tallest laid-out cell in that row. Override either explicitly:
const table: TableFrame = {
columnWidths: [100, 200], // px, per column — skips measurement
rowHeights: [40, 60], // px, per row
rows: [ /* … */ ],
}Or give the table a total target and let columns/rows scale proportionally to fit:
const table: TableFrame = {
width: 500, // narrower than natural: columns shrink, text wraps to absorb it
height: 300, // narrower than natural: rows shrink — text can't "wrap" to
// absorb it the way width does, so short of a certain point
// content overflows the row (see allowOverflow below)
rows: [ /* … */ ],
}width/height apply after whichever source produced the base sizes — measured, or an explicit columnWidths/rowHeights override.
colSpan / rowSpan
{ cells: [{ ...cell('Header'), colSpan: 2 }] }A spanning cell's own natural width/height only ever widens the columns or rows it covers — it never shrinks them below what a non-spanning cell in that column/row already needs.
The last cell of a row can also take colSpan: 'auto' to stretch across every remaining column — handy for a footer/total row that shouldn't leave a gap next to a wider table above it:
{ cells: [{ ...cell('Total: $23.50'), colSpan: 'auto' }] }This is opt-in: a genuinely short last cell keeps colSpan: 1 unless you ask for 'auto'. It's a no-op anywhere but a row's last cell.
Styling
TableStyle (the table's own outer border/background), TableRowStyle, and TableCellStyle all share the same border surface — set once via defaultCellStyle/defaultRowStyle, override per row/cell:
const table: TableFrame = {
style: { margins: 8, colGaps: 4, rowGaps: 4, borderWidths: 2, borderColors: '#333', rx: 12, ry: 12 },
defaultCellStyle: { paddings: 8, borderWidths: 1, borderColors: '#ddd', bgColor: '#fff' },
rows: [ /* … */ ],
}paddings/borderWidths/borderColors— CSS shorthand: a single number/color for all sides,[topBottom, leftRight], or[top, right, bottom, left].rx/ry— a uniform corner radius (all four corners). One given, the other defaults to it.borderPatterns— per-side SVGstroke-dasharray:[6, 3]for one pattern on every side, or[[topBottom], [leftRight]]/ one array per side ([t, r, b, l]) for different sides. An empty array ([]) or an absent side is solid.borderShapes— per-sidestroke-linecap('butt'default,'round','square'), same shorthand shape asborderPatterns.
{ borderWidths: 2, borderColors: '#0b3d91', borderPatterns: [10, 5], borderShapes: 'round' }A border whose four sides don't all share one width and color and pattern and linecap can't be drawn as a single stroked rect (SVG has one stroke-dasharray per shape) — the renderer falls back to four <line>s, each with its own dasharray/linecap.
Cell content
verticalAlign('top'default,'middle','bottom') — positions content within the cell's full height (only visible when the row is taller than the content).cx/cy— a px nudge applied on top of padding/alignment, for fine-tuning a single cell's content position independent of everything else.allowOverflow(falsedefault) — a cell's content is a nested<svg>that clips at the padding box by default;allowOverflow: truesetsoverflow: visibleso content wider/taller than the cell paints past its edges instead.
{ ...cell('note'), style: { verticalAlign: 'middle', cy: -2, allowOverflow: true } }before / after decorative slots
Independent of the cell's own content and its alignment, before/after anchor a second TextFrame to the left/right edge, vertically centered in the cell's full height — a status dot before a label, a checkmark after it:
{
content: /* … main text … */,
before: { wrap: true, paragraphs: [{ /* … */, children: [{ type: 'text', text: '●', color: '#22863a', /* … */ }] }] },
after: { wrap: true, paragraphs: [{ /* … */, children: [{ type: 'text', text: '✓', color: '#22863a', /* … */ }] }] },
}They're laid out unwrapped at their own natural size and don't affect column-width measurement — a narrow column can make them overlap content, the same tradeoff allowOverflow accepts.
Nested tables
A cell's content can be a TableFrame instead of a TextFrame:
{
content: {
defaultCellStyle: { paddings: 6, borderWidths: 1, borderColors: '#ccd' },
rows: [
{ style: { bgColor: '#eef' }, cells: [cell('City'), cell('Pop.')] },
{ cells: [cell('Paris'), cell('2.1M')] },
],
},
}The nested table is laid out at the outer cell's own content width and rendered as a <g> fragment spliced directly into the outer SVG (no nested <svg> inside <svg> inside <svg>). Nesting depth is unbounded in the type system, but layoutTableFrame throws past 50 levels as a guard against a cyclic/pathological structure rather than hanging.
Rendering options
renderTableToSVG(result, {
preset: 'browser', // forwarded to every cell's own text render — see @vyaz/renderer's presets
style: 'xml', // how cell text style properties are expressed
className: 'my-table',
fragment: true, // emit a bare <g> instead of <svg xmlns… viewBox…>,
// for splicing into a document that already has one
})More examples
The full style surface above is exercised by the renderer's golden-corpus tests — every case renders both the input.json and its expected SVG side by side: packages/renderers/tests/table-cases/.
