const DefaultCellW, DefaultCellH, DefaultFont
Defaults for SVGOpts. 8x16 is the usual terminal cell ratio, so art laid out for a terminal keeps its proportions.
Package art is the primitive layer for ASCII, ANSI and pixel art in a realm.
gno.land/p/moul/art/v0ASCII, ANSI and pixel art primitives for a realm: hold a picture once, emit it as plain text, as ANSI truecolour, or as SVG, and let the caller pick at render time.
Render returns one string and nothing tells the caller what kind of string it
is, so every realm picks markdown and every terminal reader eats the syntax as
noise. This package owns the half of that problem that is about pictures.
| type | is | for |
|---|---|---|
Pix |
a width, a height, one palette index per pixel, a Palette of hex colours |
what a pixel-art NFT actually is |
Canvas |
a grid of Cell, each a rune plus a foreground and background colour |
what a terminal actually is |
Pix.Canvas(mode) goes from one to the other. Canvas.Text(), Canvas.ANSI()
and Canvas.SVG() take it the rest of the way out, and Pix.SVG() skips the
grid entirely for art that was never character-shaped.
pix := &art.Pix{W: 8, H: 6, Idx: idx, Pal: art.Palette{"", "#d64550", "#f7a8b0"}}
plain := pix.Canvas(art.Glyphs).Text() // for a code fence, or a pipe
colour := pix.Canvas(art.HalfBlock).ANSI() // for a terminal
web := pix.SVG(8).Render("a heart") // markdown image, data URI, no host
Glyphs for sprites, Ramp for photosThe obvious conversion, a luminance ramp, produces mush for pixel art. Two
palette entries a sprite might genuinely carry, sand #e8dcc8 and bone
#d9d9d9, sit at luminance 221 and 217 out of 255. A ten-step ramp drops both
in bucket 8 and the shape between them disappears.
Indexed art already carries its own segmentation, in the palette. So the rule
for a sprite is one glyph per palette index, not one glyph per brightness.
Ramp is kept because it is right for a photograph, where there is no
meaningful palette to key on.
Measured on real art rather than on a fixture picked to show it. Settler #25
(r/g17khq…/settlers/nft:token/25, read from mainnet 2026-09-29, 32x32, CC0)
carries 20 palette entries:
| keyed on | colours that survive into the text |
|---|---|
palette index (Glyphs) |
20 of 20 |
luminance (Ramp, ten steps) |
9 of 20 |
Eleven of the artist's twenty colours stop existing, and the shapes between them go with them.
The collisions are not between near-identical shades either, which is the part
that is easy to miss: the orange hat (#f08a2a, luminance 152) and the sky-blue
tunic (#3f8fd1, luminance 130) fall in the same bucket and come out as the
same character. Luminance cannot tell a hue from a hue.
settler_test.gno pins both, and TestRampCollapsesWhatGlyphsKeepsApart pins
the mechanism on a two-colour case small enough to read.
| mode | packs | colour | good for |
|---|---|---|---|
Glyphs |
1 pixel to 1 cell (2 by default, for aspect) | optional | sprites, and anything that has to survive without colour |
Ramp |
same | optional | photographs |
HalfBlock |
2 vertical pixels to 1 cell, with ▀ |
full | the best-looking terminal output |
Quadrant |
2x2 to 1 cell | 2 colours per cell | density, at the cost of two colours per block |
Braille |
2x4 to 1 cell | none | the densest mode; a pixel is on if its index is not 0 |
Index 0 is the background by convention: Braille keys on it directly, the
default glyph set gives it a space, and a tie in Quadrant's two-colour split
goes to the lower index so that ink does not end up in the background.
Canvas.ANSI() emits SGR escapes, which cannot reach gnoweb: p/nt/markdown/sanitize
strips control bytes, and raw ESC in a code fence is garbage in HTML anyway.
That is not a gap to close later. It is the reason the target is a parameter
rather than a decision. When colour has to survive the web, Canvas.SVG()
carries the same two colours per cell through a medium the web can show.
0xRRGGBB ints, or Default (-1) meaning "whatever the
consumer's default is". ParseHex reads #rrggbb and #rgb; #abc expands
by duplication to #aabbcc, not by zero-padding.Render over.Canvas.Text() trims trailing spaces; Canvas.ANSI() does not, because a
trailing cell can carry a background colour and trimming it would punch a hole
in the picture.Pix.SVG() emits one <path> per colour, run-length encoded, in pixel
coordinates with the scale on the viewBox. On Settler #25 that is 4,532 bytes
against 20,625 for one <rect> per run, 4.5x, and scaling up costs 2 bytes
total. A realm pays for those bytes in gas and the reader pays in page weight.Canvas.SVG() emits one <text> per non-blank cell rather than one per run:
a run would have to assume the font's advance width, and a renderer that
disagreed would shear the picture. Dense art belongs in Pix.SVG(), which has
no font to get wrong.p/moul/svg's Text
interpolates its content into the document unescaped.The document model. Turning a whole Render into markdown or plain text
(links as footnotes, tables with padded columns, headings underlined instead of
#-prefixed) is a separate concern and a separate package. This one only knows
about pictures.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Package art is the primitive layer for ASCII, ANSI and pixel art in a realm.
A realm's Render returns one string and nothing tells the caller what kind of string it is, so every realm picks markdown and every terminal reader eats the syntax as noise. This package owns the half of that problem that is about pictures: it holds art in a form that can be emitted as plain text, as ANSI truecolour, or as SVG, and lets the caller choose at render time.
Pix is an indexed bitmap: a width, a height, one palette index per pixel, and a Palette of hex colours. It is what a pixel-art NFT actually is.
Canvas is a grid of Cell, each carrying a rune and a foreground and background colour. It is what a terminal actually is.
The conversion between them is the point of the package. Pix.Canvas takes a Mode and produces a Canvas; Canvas.Text, Canvas.ANSI and Canvas.SVG take it the rest of the way out.
The obvious conversion, a luminance ramp, produces mush for pixel art.
Measured on Settler #25, a 32x32 sprite on mainnet with 20 palette colours: a ten-step luminance ramp resolves those 20 to 9, so eleven of the artist's colours stop existing. The collisions are not between near-identical shades either. The orange hat (#f08a2a, luminance 152) and the sky-blue tunic (#3f8fd1, luminance 130) land in the same bucket and come out as the same character, because luminance cannot tell a hue from a hue.
Indexed art already carries its own segmentation, in the palette. So the right rule for a sprite is one glyph per palette index (Glyphs), not one glyph per brightness (Ramp). Ramp is kept because it is right for a photograph, where there is no meaningful palette to key on.
Canvas.ANSI emits SGR escape sequences. Those cannot reach gnoweb: raw ESC in a code fence is garbage in HTML, and gno.land/p/nt/markdown/sanitize strips control bytes anyway. That is not a gap to close later, it is the reason the target is a parameter instead of a decision. When colour has to survive the web, use Canvas.SVG or Pix.SVG, which carry the same colours through a medium gnoweb can actually show.
A colour is an int holding 0xRRGGBB, or Default (-1) meaning "whatever the consumer's default is": the terminal's default foreground for an FG, and transparent for a background in SVG. ParseHex turns "#rrggbb" or "#rgb" into one.
Defaults for SVGOpts. 8x16 is the usual terminal cell ratio, so art laid out for a terminal keeps its proportions.
Default is the colour that means "let the consumer decide": the terminal's own foreground or background in ANSI, and transparent in SVG.
Escape is the byte that starts every SGR sequence. Exported so a consumer can strip ANSI without hard-coding "\x1b".
1const (
2 // Glyphs gives every palette index its own rune. The right default for
3 // sprites and anything else with a small indexed palette.
4 Glyphs Mode = iota
5
6 // Ramp picks a rune by luminance. Right for a photograph, wrong for a
7 // sprite, and the package doc has the measurement.
8 Ramp
9
10 // HalfBlock packs two vertical pixels into one cell with '▀', foreground
11 // for the top pixel and background for the bottom. Full colour, square
12 // aspect, half the rows. The best-looking mode, and terminal-only.
13 HalfBlock
14
15 // Quadrant packs a 2x2 block into one cell. Twice HalfBlock's density,
16 // but a cell carries only two colours, so a four-colour block loses two.
17 Quadrant
18
19 // Braille packs a 2x4 block into one Braille cell. The densest mode and
20 // the only monochrome one: a pixel is on if its index is not 0.
21 Braille
22)1var (
2 Light = BoxStyle{TL: '┌', TR: '┐', BL: '└', BR: '┘', H: '─', V: '│'}
3 Heavy = BoxStyle{TL: '┏', TR: '┓', BL: '┗', BR: '┛', H: '━', V: '┃'}
4 Double = BoxStyle{TL: '╔', TR: '╗', BL: '╚', BR: '╝', H: '═', V: '║'}
5 Round = BoxStyle{TL: '╭', TR: '╮', BL: '╰', BR: '╯', H: '─', V: '│'}
6 ASCII = BoxStyle{TL: '+', TR: '+', BL: '+', BR: '+', H: '-', V: '|'}
7)The border styles worth having. ASCII is the one that survives a consumer with no Unicode: everything else here is box-drawing, which is fine in a terminal and in a markdown code fence but not in, say, a plain-ASCII log.
Blank is the cell a new canvas is filled with.
1var DefaultGlyphs = []rune{' ', '#', '@', '%', '*', '+', '=', '~', '-', ':', '.', 'o', 'O', 'x', 'X', 'w', 'W', 'm', 'M', '8'}DefaultGlyphs is one visually distinct rune per palette index, ordered so that neighbouring indices stay apart on screen. Index 0 is a space, matching the background convention.
A palette longer than this wraps, which is a real collision: pass your own set through Opts when that matters.
DefaultRamp runs darkest to lightest, for a terminal with a dark background. Invert it for a light one.
1var Quadrants = []rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}Quadrants is indexed by a 4-bit mask: bit 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right.
Hex is the inverse of ParseHex. Default renders as the empty string, because there is no hex spelling of "the consumer's default".
Luminance returns perceived brightness on 0-255, using the Rec. 709 weights (0.2126 R, 0.7152 G, 0.0722 B) in integer arithmetic. Default is treated as black, so an unset colour ramps to the darkest glyph rather than to a midpoint that would read as real content.
This is the function Ramp keys on, and the package doc explains why keying on it is the wrong default for a sprite.
ParseHex reads "#rrggbb", "#rgb", or either without the leading '#', and reports whether it understood the string. An empty string is not an error but it is not a colour either: it reports Default, false, which is how a palette marks a transparent entry.
RGB packs three 0-255 components into the 0xRRGGBB form this package uses. Components outside 0-255 are clamped rather than wrapped, so a computed channel cannot silently alias onto a neighbouring one.
Split returns the three 0-255 components of a packed colour. Default splits to zeroes, so callers must test for Default before calling it rather than after.
NewCanvas returns a w by h canvas of blank cells. Negative dimensions are treated as zero, so a canvas built from arithmetic that underflowed is empty rather than a panic halfway through a Render.
NewPix returns a w by h bitmap with every pixel at index 0.
BoxStyle is the six runes a rectangular border needs.
Canvas is a fixed grid of cells, addressed (x, y) from the top left.
FG and BG are the pen: the colours the drawing helpers ([Write], [HLine], [Box] and the rest) apply to every cell they touch. They start at Default. Set them with Canvas.Pen; a caller that only wants shapes can ignore them entirely.
ANSI emits the canvas with SGR truecolour escapes, one reset at the end of every coloured row. Trailing spaces are NOT trimmed here: a trailing cell may carry a background colour, and trimming it would put a hole in the picture.
This output cannot reach gnoweb. See the package doc.
At returns the cell at (x, y), or Blank if that is off the canvas.
Blit copies src onto c with src's top left at (x, y), colours and all. Cells landing off the edge are dropped.
Box draws a w by h border in the pen's colours, leaving the interior untouched. w or h under 2 draws nothing: there is no border with no inside.
Fill sets every cell on the canvas to cell.
Frame returns a NEW canvas two cells wider and taller than c, with c blitted into the middle and a border around it. A non-empty title is written into the top edge starting two cells in, and is truncated rather than allowed to run over the corner.
It returns a new canvas rather than growing this one because a Canvas has a fixed size by design: every index in this package is computed from W, and a resize in place would invalidate any offset a caller was holding.
HLine draws n runes rightwards from (x, y).
In reports whether (x, y) is on the canvas. Every helper in this package clips silently rather than panicking: art is drawn by arithmetic and a one-off-the-edge line is not worth aborting a Render over.
Pen sets the colours the drawing helpers will apply, and returns the canvas so calls can be chained.
Put writes one rune in the pen's colours.
Rect fills a w by h area with r.
SVG renders the canvas as an SVG document: background colours as run-length rectangles, and one <text> element per non-blank cell, centred in its cell so the grid stays aligned whatever the renderer's idea of a monospace advance width is.
This is how colour reaches gnoweb. Canvas.ANSI cannot: raw ESC bytes are stripped by p/nt/markdown/sanitize and are garbage in HTML regardless. An SVG carries the same two colours per cell through a medium the web can show, which is the whole argument for making the target a parameter.
One element per cell rather than one per run is deliberate: a run of text would have to assume the font's advance width, and a renderer that disagreed would shear the picture. Dense pixel art should go through Pix.SVG instead, which is run-length and has no font to get wrong.
Set writes a whole cell, colours included, ignoring the pen.
String is Canvas.Text, so a canvas can be printed directly.
Text emits the canvas as plain text: runes only, one line per row, trailing spaces trimmed. This is the form that belongs inside a triple-backtick fence, and the form a caller can pipe.
Colours are dropped, which is the whole point: a consumer asking for text has said it cannot show them.
VLine draws n runes downwards from (x, y).
Write draws s left to right from (x, y) in the pen's colours. It does not wrap: anything past the right edge is clipped. A '\n' moves to the next row, back at the starting column, which is what makes a multi-line literal blit the way it reads in source.
Cell is one character cell: a rune and its two colours. FG and BG are packed 0xRRGGBB values, or Default.
1type Opts struct {
2 Mode Mode
3
4 // Glyphs overrides [DefaultGlyphs] for [Glyphs] and [DefaultRamp] for
5 // [Ramp]. Ignored by the block modes.
6 Glyphs []rune
7
8 // Color carries palette colours into the canvas. The block modes need it
9 // and set it by default; the glyph modes default to off, because their
10 // whole job is to be readable without colour.
11 Color bool
12
13 // Wide emits each pixel as two cells side by side. A terminal cell is
14 // about twice as tall as it is wide, so a sprite rendered one cell per
15 // pixel comes out squashed to half height. On by default for the glyph
16 // modes; meaningless for the block modes, which correct aspect by packing.
17 Wide bool
18}Opts is the full form of Pix.Canvas, for callers who want to override a mode's defaults.
Palette maps a pixel's index to a colour, as a hex string ("#e8c39e", "#abc", with or without the '#'). An empty entry is transparent: Pix.SVG emits no rectangle for it and the colour modes give it Default.
By convention index 0 is the background. Braille keys on that convention directly, and the default glyph set gives index 0 a space.
Pix is an indexed bitmap: one palette index per pixel, row-major from the top left. This is the shape pixel art actually has on chain, and the reason Glyphs beats Ramp for it.
At returns the palette index at (x, y), or 0 off the bitmap. Off-bitmap reading as background is what lets the conversions below run past the edge of an odd-sized image without a bounds check at every pixel.
CanvasOpts converts the bitmap with the options spelled out.
Color returns the packed colour at (x, y).
SVG renders the bitmap as one <path> per palette colour, each path a run-length chain of "M<x> <y>h<w>v1h-<w>z" pixel runs. Transparent palette entries emit nothing.
The path lives in pixel coordinates and scale goes on the canvas as a viewBox, so scaling up costs no extra bytes at all.
One path per colour rather than one <rect> per run is worth the loop: measured on Settler #25 (32x32, 20 colours, read from mainnet 2026-09-29), rectangles came to 20,625 bytes against 3,565 for paths, 5.8x. A realm pays for those bytes in gas and the reader pays for them in page weight, and the settlers realm itself emits paths for the same reason.
Use Canvas.Render or Canvas.String from p/moul/svg to get the markdown image or the raw document.
Set writes a palette index, clipping silently off the bitmap.
SVGOpts configures Canvas.SVG. The zero value is usable: every field falls back to the constant beside it.