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}