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

pix.gno

11.34 Kb · 427 lines
  1package art
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/moul/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}