Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

pix.gno

11.35 Kb · 427 lines
  1package art
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/nym-moultest123/svg/v0"
  7)
  8
  9// Palette maps a pixel's index to a colour, as a hex string ("#e8c39e", "#abc",
 10// with or without the '#'). An empty entry is transparent: [Pix.SVG] emits no
 11// rectangle for it and the colour modes give it [Default].
 12//
 13// By convention index 0 is the background. [Braille] keys on that convention
 14// directly, and the default glyph set gives index 0 a space.
 15type Palette []string
 16
 17// Color returns the packed colour of a palette index, or [Default] if the index
 18// is out of range or the entry is empty or unparseable.
 19func (p Palette) Color(i uint8) int {
 20	if int(i) >= len(p) {
 21		return Default
 22	}
 23	c, ok := ParseHex(p[i])
 24	if !ok {
 25		return Default
 26	}
 27	return c
 28}
 29
 30// Pix is an indexed bitmap: one palette index per pixel, row-major from the top
 31// left. This is the shape pixel art actually has on chain, and the reason
 32// [Glyphs] beats [Ramp] for it.
 33type Pix struct {
 34	W, H int
 35	Idx  []uint8
 36	Pal  Palette
 37}
 38
 39// NewPix returns a w by h bitmap with every pixel at index 0.
 40func NewPix(w, h int, pal Palette) *Pix {
 41	if w < 0 {
 42		w = 0
 43	}
 44	if h < 0 {
 45		h = 0
 46	}
 47	return &Pix{W: w, H: h, Idx: make([]uint8, w*h), Pal: pal}
 48}
 49
 50// At returns the palette index at (x, y), or 0 off the bitmap. Off-bitmap
 51// reading as background is what lets the conversions below run past the edge of
 52// an odd-sized image without a bounds check at every pixel.
 53func (p *Pix) At(x, y int) uint8 {
 54	if x < 0 || y < 0 || x >= p.W || y >= p.H {
 55		return 0
 56	}
 57	return p.Idx[y*p.W+x]
 58}
 59
 60// Set writes a palette index, clipping silently off the bitmap.
 61func (p *Pix) Set(x, y int, i uint8) {
 62	if x < 0 || y < 0 || x >= p.W || y >= p.H {
 63		return
 64	}
 65	p.Idx[y*p.W+x] = i
 66}
 67
 68// Color returns the packed colour at (x, y).
 69func (p *Pix) Color(x, y int) int { return p.Pal.Color(p.At(x, y)) }
 70
 71// Mode is how a [Pix] becomes a [Canvas]. They are not cosmetic variants of
 72// each other: they trade resolution, colour and consumer against one another,
 73// and the package doc says which to reach for.
 74type Mode int
 75
 76const (
 77	// Glyphs gives every palette index its own rune. The right default for
 78	// sprites and anything else with a small indexed palette.
 79	Glyphs Mode = iota
 80
 81	// Ramp picks a rune by luminance. Right for a photograph, wrong for a
 82	// sprite, and the package doc has the measurement.
 83	Ramp
 84
 85	// HalfBlock packs two vertical pixels into one cell with '▀', foreground
 86	// for the top pixel and background for the bottom. Full colour, square
 87	// aspect, half the rows. The best-looking mode, and terminal-only.
 88	HalfBlock
 89
 90	// Quadrant packs a 2x2 block into one cell. Twice HalfBlock's density,
 91	// but a cell carries only two colours, so a four-colour block loses two.
 92	Quadrant
 93
 94	// Braille packs a 2x4 block into one Braille cell. The densest mode and
 95	// the only monochrome one: a pixel is on if its index is not 0.
 96	Braille
 97)
 98
 99// DefaultGlyphs is one visually distinct rune per palette index, ordered so
100// that neighbouring indices stay apart on screen. Index 0 is a space, matching
101// the background convention.
102//
103// A palette longer than this wraps, which is a real collision: pass your own
104// set through [Opts] when that matters.
105var DefaultGlyphs = []rune{' ', '#', '@', '%', '*', '+', '=', '~', '-', ':', '.', 'o', 'O', 'x', 'X', 'w', 'W', 'm', 'M', '8'}
106
107// DefaultRamp runs darkest to lightest, for a terminal with a dark background.
108// Invert it for a light one.
109var DefaultRamp = []rune{' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'}
110
111// Quadrants is indexed by a 4-bit mask: bit 0 top-left, 1 top-right, 2
112// bottom-left, 3 bottom-right.
113var Quadrants = []rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
114
115// Opts is the full form of [Pix.Canvas], for callers who want to override a
116// mode's defaults.
117type Opts struct {
118	Mode Mode
119
120	// Glyphs overrides [DefaultGlyphs] for [Glyphs] and [DefaultRamp] for
121	// [Ramp]. Ignored by the block modes.
122	Glyphs []rune
123
124	// Color carries palette colours into the canvas. The block modes need it
125	// and set it by default; the glyph modes default to off, because their
126	// whole job is to be readable without colour.
127	Color bool
128
129	// Wide emits each pixel as two cells side by side. A terminal cell is
130	// about twice as tall as it is wide, so a sprite rendered one cell per
131	// pixel comes out squashed to half height. On by default for the glyph
132	// modes; meaningless for the block modes, which correct aspect by packing.
133	Wide bool
134}
135
136// Canvas converts the bitmap using a mode's defaults: [Glyphs] and [Ramp] come
137// out wide and monochrome, the block modes come out coloured.
138func (p *Pix) Canvas(m Mode) *Canvas {
139	o := Opts{Mode: m}
140	switch m {
141	case Glyphs, Ramp:
142		o.Wide = true
143	default:
144		o.Color = true
145	}
146	return p.CanvasOpts(o)
147}
148
149// CanvasOpts converts the bitmap with the options spelled out.
150func (p *Pix) CanvasOpts(o Opts) *Canvas {
151	switch o.Mode {
152	case HalfBlock:
153		return p.halfBlock(o)
154	case Quadrant:
155		return p.quadrant(o)
156	case Braille:
157		return p.braille(o)
158	case Ramp:
159		return p.glyphGrid(o, pickRamp(o.Glyphs))
160	default:
161		return p.glyphGrid(o, pickGlyphs(o.Glyphs))
162	}
163}
164
165func pickGlyphs(g []rune) []rune {
166	if len(g) == 0 {
167		return DefaultGlyphs
168	}
169	return g
170}
171
172func pickRamp(g []rune) []rune {
173	if len(g) == 0 {
174		return DefaultRamp
175	}
176	return g
177}
178
179// glyphGrid covers both Glyphs and Ramp: they differ only in what they key the
180// rune lookup on, which is exactly the finding the package doc records.
181func (p *Pix) glyphGrid(o Opts, set []rune) *Canvas {
182	step := 1
183	if o.Wide {
184		step = 2
185	}
186	out := NewCanvas(p.W*step, p.H)
187	for y := 0; y < p.H; y++ {
188		for x := 0; x < p.W; x++ {
189			idx := p.At(x, y)
190			col := p.Pal.Color(idx)
191
192			var r rune
193			if o.Mode == Ramp {
194				r = set[Luminance(col)*len(set)/256]
195			} else {
196				r = set[int(idx)%len(set)]
197			}
198
199			cell := Cell{R: r, FG: Default, BG: Default}
200			if o.Color {
201				cell.FG = col
202			}
203			for k := 0; k < step; k++ {
204				out.Set(x*step+k, y, cell)
205			}
206		}
207	}
208	return out
209}
210
211func (p *Pix) halfBlock(o Opts) *Canvas {
212	out := NewCanvas(p.W, (p.H+1)/2)
213	for y := 0; y < out.H; y++ {
214		for x := 0; x < p.W; x++ {
215			top := p.Color(x, y*2)
216			bot := Default
217			if y*2+1 < p.H {
218				bot = p.Color(x, y*2+1)
219			}
220			if !o.Color {
221				// Without colour the upper half block says nothing, so fall
222				// back to "is there ink here": full, half, or empty.
223				out.Set(x, y, Cell{R: monoHalf(p.At(x, y*2), p.At(x, y*2+1)), FG: Default, BG: Default})
224				continue
225			}
226			out.Set(x, y, Cell{R: '▀', FG: top, BG: bot})
227		}
228	}
229	return out
230}
231
232func monoHalf(top, bot uint8) rune {
233	switch {
234	case top != 0 && bot != 0:
235		return '█'
236	case top != 0:
237		return '▀'
238	case bot != 0:
239		return '▄'
240	}
241	return ' '
242}
243
244func (p *Pix) quadrant(o Opts) *Canvas {
245	out := NewCanvas((p.W+1)/2, (p.H+1)/2)
246	for y := 0; y < out.H; y++ {
247		for x := 0; x < out.W; x++ {
248			var idx [4]uint8
249			idx[0] = p.At(x*2, y*2)
250			idx[1] = p.At(x*2+1, y*2)
251			idx[2] = p.At(x*2, y*2+1)
252			idx[3] = p.At(x*2+1, y*2+1)
253
254			if !o.Color {
255				mask := 0
256				for k := 0; k < 4; k++ {
257					if idx[k] != 0 {
258						mask |= 1 << uint(k)
259					}
260				}
261				out.Set(x, y, Cell{R: Quadrants[mask], FG: Default, BG: Default})
262				continue
263			}
264
265			bgIdx, fgIdx, split := twoWaySplit(idx)
266			mask := 0
267			if split {
268				for k := 0; k < 4; k++ {
269					if idx[k] == fgIdx {
270						mask |= 1 << uint(k)
271					}
272				}
273			}
274			fg := Default
275			if split {
276				fg = p.Pal.Color(fgIdx)
277			}
278			out.Set(x, y, Cell{R: Quadrants[mask], FG: fg, BG: p.Pal.Color(bgIdx)})
279		}
280	}
281	return out
282}
283
284// twoWaySplit picks the two palette indices a 2x2 block is drawn with: the most
285// common becomes the background, the most common of the rest the foreground.
286// It reports false when every pixel agrees, in which case there is no
287// foreground and the cell is a solid background.
288//
289// A tie goes to the LOWER palette index, which is what makes index 0 behave as
290// the background the package documents it to be. Tie-breaking on pixel order
291// instead put the ink in the background half the time: a two-colour block split
292// down the middle came out as the mirror image of itself, because whichever
293// side happened to be scanned first won.
294func twoWaySplit(idx [4]uint8) (bg, fg uint8, split bool) {
295	bg = mostCommon(idx, false, 0)
296	fg = mostCommon(idx, true, bg)
297	if fg == bg {
298		return bg, bg, false
299	}
300	return bg, fg, true
301}
302
303func mostCommon(idx [4]uint8, skip bool, skipped uint8) uint8 {
304	best, bestN := uint8(0), 0
305	for k := 0; k < 4; k++ {
306		if skip && idx[k] == skipped {
307			continue
308		}
309		n := 0
310		for j := 0; j < 4; j++ {
311			if idx[j] == idx[k] {
312				n++
313			}
314		}
315		if n > bestN || (n == bestN && bestN > 0 && idx[k] < best) {
316			best, bestN = idx[k], n
317		}
318	}
319	if bestN == 0 {
320		return skipped
321	}
322	return best
323}
324
325// brailleBits maps (col, row) inside a 2x4 block to its bit in U+2800. The
326// layout is not sequential: the fourth row was added to the standard late and
327// took the two high bits.
328var brailleBits = [2][4]uint{
329	{0, 1, 2, 6},
330	{3, 4, 5, 7},
331}
332
333func (p *Pix) braille(o Opts) *Canvas {
334	out := NewCanvas((p.W+1)/2, (p.H+3)/4)
335	for y := 0; y < out.H; y++ {
336		for x := 0; x < out.W; x++ {
337			bits := 0
338			fg := Default
339			for col := 0; col < 2; col++ {
340				for row := 0; row < 4; row++ {
341					if p.At(x*2+col, y*4+row) == 0 {
342						continue
343					}
344					bits |= 1 << brailleBits[col][row]
345					if o.Color && fg == Default {
346						fg = p.Color(x*2+col, y*4+row)
347					}
348				}
349			}
350			out.Set(x, y, Cell{R: rune(0x2800 + bits), FG: fg, BG: Default})
351		}
352	}
353	return out
354}
355
356// SVG renders the bitmap as one <path> per palette colour, each path a
357// run-length chain of "M<x> <y>h<w>v1h-<w>z" pixel runs. Transparent palette
358// entries emit nothing.
359//
360// The path lives in pixel coordinates and scale goes on the canvas as a viewBox,
361// so scaling up costs no extra bytes at all.
362//
363// One path per colour rather than one <rect> per run is worth the loop:
364// measured on Settler #25 (32x32, 20 colours, read from mainnet 2026-09-29),
365// rectangles came to 20,625 bytes against 3,565 for paths, 5.8x. A realm pays
366// for those bytes in gas and the reader pays for them in page weight, and the
367// settlers realm itself emits paths for the same reason.
368//
369// Use Canvas.Render or Canvas.String from p/moul/svg to get the markdown image
370// or the raw document.
371func (p *Pix) SVG(scale int) *svg.Canvas {
372	if scale < 1 {
373		scale = 1
374	}
375	out := svg.NewCanvas(p.W*scale, p.H*scale)
376	out.WithViewBox(0, 0, p.W, p.H)
377	out.AddStyle("path", "shape-rendering:crispEdges")
378
379	for i := 0; i < len(p.Pal); i++ {
380		hex := p.Pal.hex(uint8(i))
381		if hex == "" {
382			continue
383		}
384		var d strings.Builder
385		for y := 0; y < p.H; y++ {
386			x := 0
387			for x < p.W {
388				if p.At(x, y) != uint8(i) {
389					x++
390					continue
391				}
392				run := 1
393				for x+run < p.W && p.At(x+run, y) == uint8(i) {
394					run++
395				}
396				d.WriteByte('M')
397				d.WriteString(itoa(x))
398				d.WriteByte(' ')
399				d.WriteString(itoa(y))
400				d.WriteByte('h')
401				d.WriteString(itoa(run))
402				d.WriteString("v1h-")
403				d.WriteString(itoa(run))
404				d.WriteByte('z')
405				x += run
406			}
407		}
408		if d.Len() > 0 {
409			out.Append(svg.NewPath(d.String(), hex))
410		}
411	}
412	return out
413}
414
415// hex returns a palette entry normalised to "#rrggbb", or "" for a transparent
416// or unparseable one. Normalising rather than passing the raw string through is
417// what keeps an entry a caller typed out of the SVG document unescaped.
418func (p Palette) hex(i uint8) string {
419	if int(i) >= len(p) {
420		return ""
421	}
422	c, ok := ParseHex(p[i])
423	if !ok {
424		return ""
425	}
426	return Hex(c)
427}