Framework-agnostic ANSI/ASCII → SVG core. No DOM — everything below is a string from exportSvg(text, options) .
\x1b[30m██\x1b[31m██\x1b[32m██\x1b[33m██\x1b[34m██\x1b[35m██\x1b[36m██\x1b[37m██\x1b[0m \x1b[90m██\x1b[91m██\x1b[92m██\x1b[93m██\x1b[94m██\x …
\x1b[38;5;196m█\x1b[38;5;197m█\x1b[38;5;198m█\x1b[38;5;199m█\x1b[38;5;200m█\x1b[38;5;201m█\x1b[0m\x1b[38;5;46m█\x1b[38;5;47m█\x1b[38;5;48m█\ …
\x1b[38;2;255;0;0m█\x1b[38;2;255;85;0m█\x1b[38;2;255;170;0m█\x1b[38;2;255;255;0m█\x1b[38;2;170;255;0m█\x1b[38;2;85;255;0m█\x1b[38;2;0;255;0m …
\x1b[1mbold\x1b[0m \x1b[2mdim\x1b[0m \x1b[3mitalic\x1b[0m \x1b[4munderline\x1b[0m \x1b[9mstrike\x1b[0m
\x1b[43;30m warn \x1b[0m \x1b[41;97m error \x1b[0m \x1b[7m inverse \x1b[0m \x1b[2;7m dim inverse \x1b[0m
日本語 👍 👨👩👧 é |吾輩は猫である|
┏━┳━┓╔═╦═╗╭─┬─╮ ┃ ┣━┫╠═╬═╣├─┼─┤ ┗━┻━┛╚═╩═╝╰─┴─╯ ▁▂▃▄▅▆▇█ ░▒▓ ▖▚▜
name qty spam 42 eggs 7
docs: \x1b]8;;https://github.com/xl0/svelte-asciiart\x1b\\x1b[94;4msvelte-asciiart\x1b[0m\x1b]8;;\x1b\
\x1b[31mnormal red\x1b[0m \x1b[2;31mdim red\x1b[0m \x1b[2;31;44mdim on blue\x1b[0m
parseAnsi → layout → render → exportSvg — inspect every stage.
Escapes are written as \x1b ( \033 works too); pasted raw ESC bytes are converted.
Convert ASCII/ANSI terminal text into a crisp, styleable SVG character grid. Framework-agnostic and DOM-free — works in the browser, Node, SSR, workers and CI.
npm install lovely-ansi-svg
import { exportSvg } from 'lovely-ansi-svg';
const svg = exportSvg('\x1b[36mHello \x1b[1;33mANSI\x1b[0m world', {
frame: true,
margin: 1,
background: '#1e1e1e'
});
exportSvg(text, options) returns a standalone SVG string.
A fuller example — fixed frame, styled grid and frame via classes, a custom font and theme:
import { exportSvg, defaultTheme } from 'lovely-ansi-svg';
const svg = exportSvg(text, {
rows: 12,
cols: 40,
margin: [1, 2], // [vertical, horizontal] cells
grid: 'grid', // string = CSS class, styled below
frame: 'frame',
fontFamily: '"JetBrains Mono", monospace',
background: '#1e1e1e', // painted rect; also what dim/inverse mix toward
theme: { ...defaultTheme, foreground: '#d4d4d4' },
extraCss: `
.grid { stroke: #333; stroke-width: 0.02 }
.frame { stroke: #888; stroke-width: 0.04 }
`
});
| Option | Type | Default | Description |
|---|---|---|---|
rows |
number |
auto | Frame height in cells (content may overflow, clipped at the viewBox) |
cols |
number |
auto | Frame width in cells (display columns) |
margin |
number | [v, h] | [t,r,b,l] |
0 |
Margin around the frame, in cells |
grid |
boolean | string |
false |
Cell grid lines: true = default faint stroke, string = CSS class |
frame |
boolean | string |
false |
Border around the frame: true = default stroke, string = CSS class |
cellAspect |
number |
0.6 |
Cell width:height ratio (typical monospace) |
baseline |
number |
0.8 |
Baseline position within the cell, 0–1 from the top |
glyphScale |
number |
1 |
Glyph size as a fraction of cell height (1 = box-drawing tiles) |
cellSize |
number |
50 |
Pixels per cell for the intrinsic width/height attributes |
customGlyphs |
boolean |
true |
Draw box/block chars (U+2500–U+259F) as exact-cell shapes, not font glyphs |
fontFamily |
string |
mono stack | font-family for the text (named, not embedded) |
theme |
Theme |
VS Code-ish | 16-color palette + default fg/bg the ANSI colors resolve through |
background |
string |
— | Solid background rect; doubles as theme.background unless that is set |
extraCss |
string |
— | CSS emitted in a <style> block (@font-face, grid/frame classes…) |
16-color, 256-color (38;5;n) and truecolor (38;2;r;g;b) foregrounds and backgrounds; bold, dim, italic, underline, strikethrough, inverse; per-attribute resets (blink is parsed but not rendered). OSC 8 hyperlinks become SVG <a href> anchors — clickable in browsers and most viewers; only web-safe schemes (http/https/ftp/mailto) survive, and linked box-drawing chars render as text so they stay clickable. Style state persists across lines. Unknown codes are consumed; other non-SGR escapes (cursor movement, DCS/sixel payloads — even spanning newlines) are stripped. Tabs expand to 8-column stops. CJK and emoji occupy two cells; grapheme clusters (ZWJ emoji, combining marks) are never torn apart, even by escapes.
Box-drawing and block characters (U+2500–U+259F — lines, corners, doubles, dashes, arcs, diagonals, blocks, shades, quadrants) are drawn as exact-cell rects/paths instead of font glyphs, so they tile seamlessly at any font and cell aspect — font-rendered box characters overshoot or underfill the cell depending on the font. Drawn glyphs always fill the whole cell, ignoring glyphScale. Pass customGlyphs: false to render them as text.
interface Theme {
/** Default text color; unset → `currentColor` inherits from the embedding. */
foreground?: string;
/** Color inverse and dim mix toward; unset → `Canvas`. */
background?: string;
/**
* The 16 base ANSI colors, in standard order: 0–7 normal (black, red,
* green, yellow, blue, magenta, cyan, white), 8–15 their bright variants.
*/
palette: string[];
}
All colors resolve through the theme once, at parse time — the output carries concrete values as inline styles, no CSS classes or custom properties. Re-theming means re-rendering with a different theme. The 16 base colors (and 38;5;n with n < 16) index the palette positionally: SGR 30–37/40–47 → entries 0–7, 90–97/100–107 → 8–15. The extended 256-color palette (the 6×6×6 cube and the grayscale ramp) and truecolor have spec-fixed RGB values and bypass the theme entirely.
theme.foreground sets the SVG root's color; unstyled text fills with currentColor, so an inline-embedded SVG with no foreground simply inherits the page's text color.
Two background knobs, on purpose. theme.background and the background option answer different questions:
theme.background is an assumption: "this is the color behind the art." Inverse-without-foreground glyphs paint in it and dim mixes toward it — the color math is meaningless without knowing the backdrop. It paints nothing.background (option) is an action: paint a solid backdrop rect into the file. Export-only — a standalone file has no page behind it.In practice you set one of them. Painting a backdrop implies it's the backdrop, so exportSvg defaults theme.background to the background option — exportSvg(text, { background: '#1e1e1e' }) paints dark and dims/inverts against dark. Set theme.background alone for the transparent case: an SVG destined for a page that's already dark should compute dim/inverse against that page color without painting over it. Setting both only makes sense when they genuinely disagree, and then the explicit theme.background wins the color math.
Dim is a solid color-mix() of the glyph color toward its backdrop rather than opacity — overlapping full-cell glyphs (box drawing) would double-composite into stripes.
defaultTheme (exported) is a VS Code-ish palette.
exportSvg is a thin serializer over an exposed three-stage pipeline. Each stage can be used on its own — to inspect styled text, to build a custom renderer over the geometry model, or to reuse the width machinery:
import { parseAnsi, layout, render } from 'lovely-ansi-svg';
const parsed = parseAnsi(text); // ParsedRow[] — styled text, no geometry
const laid = layout(parsed); // LayoutRow[] — column-grid runs
const model = render(laid, { frame: true }); // RenderModel — SVG-ready geometry
parseAnsi(text, theme?): ParsedRow[]Parses ANSI SGR escapes into escape-stripped row text plus style breakpoints, with all colors resolved through the theme (default: defaultTheme). No geometry yet — styles are ranges over the text:
interface ParsedRow {
/** The row with escapes stripped. */
text: string;
/** Sorted by offset; empty for unstyled text. */
breaks: { offset: number; style: Style }[];
}
interface Style {
/** Resolved foreground color; unset → `currentColor` (inherits the default text color). Dim is baked in as a `color-mix()` toward the backdrop. */
fill?: string;
/** Resolved background color, painted as a full-cell rect behind the text. */
bgFill?: string;
/** Font attributes; `blink` is parsed but not rendered. */
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
blink?: boolean;
/** OSC 8 hyperlink target (web-safe schemes only). */
link?: string;
}
A break's style applies from its offset (in code units) to the next break or the end of the row:
parseAnsi('\x1b[1;31mred\x1b[0m ok');
// [{ text: 'red ok', breaks: [
// { offset: 0, style: { fill: '#cd3131', bold: true } },
// { offset: 3, style: {} }
// ]}]
Style state persists across rows until reset, like in a terminal. Escapes are matched over the whole input, so control-string payloads (OSC hyperlinks, sixel) containing newlines don't split rows; tabs expand to 8-column stops; other C0 controls are dropped.
layout(rows): LayoutRow[]The grapheme-segmentation pass (per-row: layoutRow), mapping parsed text onto the column grid. Everything is in display-column units — CJK and wide emoji occupy two columns:
interface LayoutRow {
runs: GlyphRun[];
bgs: BgRun[];
/** Total display columns. */
width: number;
}
/** A run of glyphs sharing one foreground style (fill + the Style font flags). */
interface GlyphRun {
fill?: string;
bold?: boolean; // + italic, underline, strike, blink
/** Starting column of each grapheme cluster in `text`. */
cols: number[];
text: string;
}
/** Full-cell background columns `[start, end)` sharing one color. */
interface BgRun {
fill: string;
start: number;
end: number;
}
layout(parseAnsi('\x1b[43;30mwarn\x1b[0m'));
// [{
// runs: [{ fill: '#000000', cols: [0, 1, 2, 3], text: 'warn' }],
// bgs: [{ fill: '#b58900', start: 0, end: 4 }],
// width: 4
// }]
Runs break on foreground style changes; background runs are merged independently, on background color only, so colored blocks stay solid across foreground changes. A multi-code-point cluster (ZWJ emoji, combining marks) always becomes a run of its own with a single cols entry, so a per-glyph x list can never tear it apart.
render(layoutRows, options): RenderModelTurns laid-out rows into SVG geometry. Takes the geometry subset of the options table above (rows, cols, margin, grid, frame, cellAspect, baseline, glyphScale, cellSize). Every number is pre-formatted as an attribute string — the model maps 1:1 onto SVG elements with no further math:
interface RenderModel {
viewBox: string;
/** Intrinsic pixel size (`cellSize` px per cell height). */
width: string;
height: string;
fontSize: string;
rows: RenderedRow[];
/** Present when requested; unclassed ones carry default stroke attributes. */
grid?: { d: string; class?: string; stroke?: string; strokeOpacity?: string; strokeWidth?: string };
frame?: { x: string; y: string; width: string; height: string; class?: string; stroke?: string; strokeWidth?: string };
}
interface RenderedRow {
/** Text baseline y. */
y: string;
runs: RenderedRun[];
bgs: RenderedBg[];
/** Custom-drawn box/block glyphs, one path per style. */
shapes: RenderedShape[];
}
interface RenderedShape {
d: string;
/** Inline CSS: fill or stroke, always explicit. */
style: string;
}
interface RenderedRun {
/** Inline CSS (fill, font-weight, …); absent for default-styled text. */
style?: string;
/** OSC 8 hyperlink target — wrap the tspan in an `<a href>`. */
href?: string;
/** Space-separated x list, one per code point. */
x: string;
text: string;
}
interface RenderedBg {
/** Inline CSS (the background fill). */
style: string;
x: string;
y: string;
width: string;
height: string;
}
render(layout(parseAnsi('\x1b[1;31mred\x1b[0m ok')), { frame: true, margin: 1 });
// {
// viewBox: '0 0 4.8 3', width: '240', height: '150', fontSize: '1',
// rows: [{
// y: '1.8',
// runs: [
// { style: 'fill: #cd3131; font-weight: bold', x: '0.6 1.2 1.8', text: 'red' },
// { x: '2.4 3 3.6', text: ' ok' }
// ],
// bgs: []
// }],
// frame: { x: '0.6', y: '1', width: '3.6', height: '1', stroke: 'currentColor', strokeWidth: '0.02' }
// }
Cell height is 1 viewBox unit and cells are cellAspect units wide; the frame is rows×cols (or the content size), the viewBox is frame + margin, and overflowing content is meant to be clipped with overflow="hidden".
To consume the model, emit one <rect> per background, the grid <path> and frame <rect> if present, one <path style d> per shape, and one <text y font-size xml:space="preserve"> per non-empty row containing one <tspan style x> per run — all styling rides as inline style, so host CSS can't accidentally override it. That is exactly what exportSvg does — and what the svelte-asciiart component template does with the same model, which is what keeps its live render and the exported file identical.
import { clusters, clusterWidth, displayWidth } from 'lovely-ansi-svg';
displayWidth('abc'); // 3
displayWidth('日本'); // 4 — CJK is two columns
clusterWidth('👍'); // 2
[...clusters('a日👍')]; // ['a', '日', '👍']
clusters segments into grapheme clusters (Intl.Segmenter); clusterWidth is a compact wide-character detection (East Asian wide ranges, emoji presentation, variation selectors, flag pairs — deliberately not a full Unicode width table) returning 0 for zero-width clusters, 2 for wide ones.
Also exported: defaultTheme, DEFAULT_FONT_STACK (the fallback monospace stack), and fmt (the shared number → attribute-string formatter, if you build strings that must match the model's).
Fonts are referenced by name, not embedded. To make a file font-standalone (or to rasterize to PNG), see lovely-svg-png. For a Svelte component over this core, see svelte-asciiart.
MIT