1// ___ _____ ____
2// / _ \/ _/ |/_/ /____ ______ _
3// / ___// /_> </ __/ -_) __/ ' \
4// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
5//
6// Copyright 2017 Eliuk Blau
7//
8// This Source Code Form is subject to the terms of the Mozilla Public
9// License, v. 2.0. If a copy of the MPL was not distributed with this
10// file, You can obtain one at http://mozilla.org/MPL/2.0/.
11
12package ansimage
13
14import (
15 "errors"
16 "fmt"
17 "image"
18 "image/color"
19 "image/draw"
20 _ "image/gif" // initialize decoder
21
22 _ "image/jpeg" // initialize decoder
23 _ "image/png" // initialize decoder
24 "io"
25 "net/http"
26 "os"
27 "strings"
28
29 "github.com/disintegration/imaging"
30 "github.com/lucasb-eyer/go-colorful"
31 _ "golang.org/x/image/bmp" // initialize decoder
32 _ "golang.org/x/image/tiff" // initialize decoder
33 _ "golang.org/x/image/webp" // initialize decoder
34)
35
36// Unicode Block Element character used to represent lower pixel in terminal row.
37// INFO: http://en.wikipedia.org/wiki/Block_Elements
38const lowerHalfBlock = "\u2584"
39
40// Unicode Block Element characters used to represent dithering in terminal row.
41// INFO: http://en.wikipedia.org/wiki/Block_Elements
42const fullBlock = "\u2588"
43const darkShadeBlock = "\u2593"
44const mediumShadeBlock = "\u2592"
45const lightShadeBlock = "\u2591"
46
47// ANSImage scale modes:
48// resize (full scaled to area),
49// fill (resize and crop the image with a center anchor point to fill area),
50// fit (resize the image to fit area, preserving the aspect ratio).
51const (
52 ScaleModeResize = ScaleMode(iota)
53 ScaleModeFill
54 ScaleModeFit
55)
56
57// ANSImage dithering modes:
58// no dithering (classic mode: half block based),
59// chars (use characters to represent brightness),
60// blocks (use character blocks to represent brightness).
61const (
62 NoDithering = DitheringMode(iota)
63 DitheringWithBlocks
64 DitheringWithChars
65)
66
67// ANSImage block size in pixels (dithering mode)
68const (
69 BlockSizeY = 8
70 BlockSizeX = 4
71)
72
73var (
74 // ErrImageDownloadFailed occurs in the attempt to download an image and the status code of the response is not "200 OK".
75 ErrImageDownloadFailed = errors.New("ANSImage: image download failed")
76
77 // ErrHeightNonMoT occurs when ANSImage height is not a Multiple of Two value.
78 ErrHeightNonMoT = errors.New("ANSImage: height must be a Multiple of Two value")
79
80 // ErrInvalidBoundsMoT occurs when ANSImage height or width are invalid values (Multiple of Two).
81 ErrInvalidBoundsMoT = errors.New("ANSImage: height or width must be >=2")
82
83 // ErrOutOfBounds occurs when ANSI-pixel coordinates are out of ANSImage bounds.
84 ErrOutOfBounds = errors.New("ANSImage: out of bounds")
85
86 // errUnknownScaleMode occurs when scale mode is invalid.
87 errUnknownScaleMode = errors.New("ANSImage: unknown scale mode")
88
89 // errUnknownDitheringMode occurs when dithering mode is invalid.
90 errUnknownDitheringMode = errors.New("ANSImage: unknown dithering mode")
91)
92
93// ScaleMode type is used for image scale mode constants.
94type ScaleMode uint8
95
96// DitheringMode type is used for image scale dithering mode constants.
97type DitheringMode uint8
98
99// ANSIpixel represents a pixel of an ANSImage.
100type ANSIpixel struct {
101 Brightness uint8
102 R, G, B uint8
103 upper bool
104 source *ANSImage
105}
106
107// ANSImage represents an image encoded in ANSI escape codes.
108type ANSImage struct {
109 h, w int
110 maxprocs int
111 bgR uint8
112 bgG uint8
113 bgB uint8
114 dithering DitheringMode
115 pixmap [][]*ANSIpixel
116}
117
118// Render returns the ANSI-compatible string form of ANSI-pixel.
119func (ap *ANSIpixel) Render() string {
120 // WITHOUT DITHERING
121 if ap.source.dithering == NoDithering {
122 if ap.upper {
123 return fmt.Sprintf(
124 "\033[48;2;%d;%d;%dm",
125 ap.R, ap.G, ap.B,
126 )
127 }
128 return fmt.Sprintf(
129 "\033[38;2;%d;%d;%dm%s",
130 ap.R, ap.G, ap.B,
131 lowerHalfBlock,
132 )
133 }
134
135 // WITH DITHERING
136 block := " "
137 if ap.source.dithering == DitheringWithBlocks {
138 switch bri := ap.Brightness; {
139 case bri > 204:
140 block = fullBlock
141 case bri > 152:
142 block = darkShadeBlock
143 case bri > 100:
144 block = mediumShadeBlock
145 case bri > 48:
146 block = lightShadeBlock
147 }
148 } else if ap.source.dithering == DitheringWithChars {
149 switch bri := ap.Brightness; {
150 case bri > 230:
151 block = "#"
152 case bri > 207:
153 block = "&"
154 case bri > 184:
155 block = "$"
156 case bri > 161:
157 block = "X"
158 case bri > 138:
159 block = "x"
160 case bri > 115:
161 block = "="
162 case bri > 92:
163 block = "+"
164 case bri > 69:
165 block = ";"
166 case bri > 46:
167 block = ":"
168 case bri > 23:
169 block = "."
170 }
171 } else {
172 panic(errUnknownDitheringMode)
173 }
174
175 return fmt.Sprintf(
176 "\033[48;2;%d;%d;%dm\033[38;2;%d;%d;%dm%s",
177 ap.source.bgR, ap.source.bgG, ap.source.bgB,
178 ap.R, ap.G, ap.B,
179 block,
180 )
181}
182
183// Height gets total rows of ANSImage.
184func (ai *ANSImage) Height() int {
185 return ai.h
186}
187
188// Width gets total columns of ANSImage.
189func (ai *ANSImage) Width() int {
190 return ai.w
191}
192
193// DitheringMode gets the dithering mode of ANSImage.
194func (ai *ANSImage) DitheringMode() DitheringMode {
195 return ai.dithering
196}
197
198// SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage
199// (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect).
200func (ai *ANSImage) SetMaxProcs(max int) {
201 ai.maxprocs = max
202}
203
204// GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage.
205func (ai *ANSImage) GetMaxProcs() int {
206 return ai.maxprocs
207}
208
209// SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x).
210func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error {
211 if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
212 ai.pixmap[y][x].R = r
213 ai.pixmap[y][x].G = g
214 ai.pixmap[y][x].B = b
215 ai.pixmap[y][x].Brightness = brightness
216 ai.pixmap[y][x].upper = ((ai.dithering == NoDithering) && (y%2 == 0))
217 return nil
218 }
219 return ErrOutOfBounds
220}
221
222// GetAt gets ANSI-pixel in coordinates (y,x).
223func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) {
224 if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
225 return &ANSIpixel{
226 R: ai.pixmap[y][x].R,
227 G: ai.pixmap[y][x].G,
228 B: ai.pixmap[y][x].B,
229 Brightness: ai.pixmap[y][x].Brightness,
230 upper: ai.pixmap[y][x].upper,
231 source: ai.pixmap[y][x].source,
232 },
233 nil
234 }
235 return nil, ErrOutOfBounds
236}
237
238// Render returns the ANSI-compatible string form of ANSImage.
239// (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728)
240func (ai *ANSImage) Render() string {
241 type renderData struct {
242 row int
243 render string
244 }
245
246 // WITHOUT DITHERING
247 if ai.dithering == NoDithering {
248 rows := make([]string, ai.h/2)
249 for y := 0; y < ai.h; y += ai.maxprocs {
250 ch := make(chan renderData, ai.maxprocs)
251 for n, r := 0, y+1; (n <= ai.maxprocs) && (2*r+1 < ai.h); n, r = n+1, y+n+1 {
252 go func(r, y int) {
253 var str string
254 for x := 0; x < ai.w; x++ {
255 str += ai.pixmap[y][x].Render() // upper pixel
256 str += ai.pixmap[y+1][x].Render() // lower pixel
257 }
258 str += "\033[0m\n" // reset ansi style
259 ch <- renderData{row: r, render: str}
260 }(r, 2*r)
261 // DEBUG:
262 // fmt.Printf("y:%d | n:%d | r:%d | 2*r:%d\n", y, n, r, 2*r)
263 // time.Sleep(time.Millisecond * 100)
264 }
265 for n, r := 0, y+1; (n <= ai.maxprocs) && (2*r+1 < ai.h); n, r = n+1, y+n+1 {
266 data := <-ch
267 rows[data.row] = data.render
268 // DEBUG:
269 // fmt.Printf("data.row:%d\n", data.row)
270 // time.Sleep(time.Millisecond * 100)
271 }
272 }
273 return strings.Join(rows, "")
274 }
275
276 // WITH DITHERING
277 rows := make([]string, ai.h)
278 for y := 0; y < ai.h; y += ai.maxprocs {
279 ch := make(chan renderData, ai.maxprocs)
280 for n, r := 0, y; (n <= ai.maxprocs) && (r+1 < ai.h); n, r = n+1, y+n+1 {
281 go func(y int) {
282 var str string
283 for x := 0; x < ai.w; x++ {
284 str += ai.pixmap[y][x].Render()
285 }
286 str += "\033[0m\n" // reset ansi style
287 ch <- renderData{row: y, render: str}
288 }(r)
289 }
290 for n, r := 0, y; (n <= ai.maxprocs) && (r+1 < ai.h); n, r = n+1, y+n+1 {
291 data := <-ch
292 rows[data.row] = data.render
293 }
294 }
295 return strings.Join(rows, "")
296}
297
298// Draw writes the ANSImage to standard output (terminal).
299func (ai *ANSImage) Draw() {
300 fmt.Print(ai.Render())
301}
302
303// New creates a new empty ANSImage ready to draw on it.
304func New(h, w int, bg color.Color, dm DitheringMode) (*ANSImage, error) {
305 if (dm == NoDithering) && (h%2 != 0) {
306 return nil, ErrHeightNonMoT
307 }
308
309 if h < 2 || w < 2 {
310 return nil, ErrInvalidBoundsMoT
311 }
312
313 r, g, b, _ := bg.RGBA()
314 ansimage := &ANSImage{
315 h: h, w: w,
316 maxprocs: 1,
317 bgR: uint8(r),
318 bgG: uint8(g),
319 bgB: uint8(b),
320 dithering: dm,
321 pixmap: nil,
322 }
323
324 ansimage.pixmap = func() [][]*ANSIpixel {
325 v := make([][]*ANSIpixel, h)
326 for y := 0; y < h; y++ {
327 v[y] = make([]*ANSIpixel, w)
328 for x := 0; x < w; x++ {
329 v[y][x] = &ANSIpixel{
330 R: 0,
331 G: 0,
332 B: 0,
333 Brightness: 0,
334 source: ansimage,
335 upper: ((dm == NoDithering) && (y%2 == 0)),
336 }
337 }
338 }
339 return v
340 }()
341
342 return ansimage, nil
343}
344
345// NewFromReader creates a new ANSImage from an io.Reader.
346// Background color is used to fill when image has transparency or dithering mode is enabled.
347// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
348func NewFromReader(reader io.Reader, bg color.Color, dm DitheringMode) (*ANSImage, error) {
349 image, _, err := image.Decode(reader)
350 if err != nil {
351 return nil, err
352 }
353
354 return createANSImage(image, bg, dm)
355}
356
357// NewScaledFromReader creates a new scaled ANSImage from an io.Reader.
358// Background color is used to fill when image has transparency or dithering mode is enabled.
359// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
360func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color, sm ScaleMode, dm DitheringMode) (*ANSImage, error) {
361 image, _, err := image.Decode(reader)
362 if err != nil {
363 return nil, err
364 }
365
366 switch sm {
367 case ScaleModeResize:
368 image = imaging.Resize(image, x, y, imaging.Lanczos)
369 case ScaleModeFill:
370 image = imaging.Fill(image, x, y, imaging.Center, imaging.Lanczos)
371 case ScaleModeFit:
372 image = imaging.Fit(image, x, y, imaging.Lanczos)
373 default:
374 panic(errUnknownScaleMode)
375 }
376
377 return createANSImage(image, bg, dm)
378}
379
380// NewFromFile creates a new ANSImage from a file.
381// Background color is used to fill when image has transparency or dithering mode is enabled.
382// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
383func NewFromFile(name string, bg color.Color, dm DitheringMode) (*ANSImage, error) {
384 reader, err := os.Open(name)
385 if err != nil {
386 return nil, err
387 }
388 defer reader.Close()
389 return NewFromReader(reader, bg, dm)
390}
391
392// NewFromURL creates a new ANSImage from an image URL.
393// Background color is used to fill when image has transparency or dithering mode is enabled.
394// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
395func NewFromURL(url string, bg color.Color, dm DitheringMode) (*ANSImage, error) {
396 res, err := http.Get(url)
397 if err != nil {
398 return nil, err
399 }
400 if res.StatusCode != http.StatusOK {
401 return nil, ErrImageDownloadFailed
402 }
403 defer res.Body.Close()
404 return NewFromReader(res.Body, bg, dm)
405}
406
407// NewScaledFromFile creates a new scaled ANSImage from a file.
408// Background color is used to fill when image has transparency or dithering mode is enabled.
409// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
410func NewScaledFromFile(name string, y, x int, bg color.Color, sm ScaleMode, dm DitheringMode) (*ANSImage, error) {
411 reader, err := os.Open(name)
412 if err != nil {
413 return nil, err
414 }
415 defer reader.Close()
416 return NewScaledFromReader(reader, y, x, bg, sm, dm)
417}
418
419// NewScaledFromURL creates a new scaled ANSImage from an image URL.
420// Background color is used to fill when image has transparency or dithering mode is enabled.
421// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
422func NewScaledFromURL(url string, y, x int, bg color.Color, sm ScaleMode, dm DitheringMode) (*ANSImage, error) {
423 res, err := http.Get(url)
424 if err != nil {
425 return nil, err
426 }
427 if res.StatusCode != http.StatusOK {
428 return nil, ErrImageDownloadFailed
429 }
430 defer res.Body.Close()
431 return NewScaledFromReader(res.Body, y, x, bg, sm, dm)
432}
433
434// ClearTerminal clears current terminal buffer using ANSI escape code.
435// (Nice info for ANSI escape codes - http://unix.stackexchange.com/questions/124762/how-does-clear-command-work)
436func ClearTerminal() {
437 fmt.Print("\033[H\033[2J")
438}
439
440// createANSImage loads data from an image and returns an ANSImage.
441// Background color is used to fill when image has transparency or dithering mode is enabled.
442// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
443func createANSImage(img image.Image, bg color.Color, dm DitheringMode) (*ANSImage, error) {
444 var rgbaOut *image.RGBA
445 bounds := img.Bounds()
446
447 // do compositing only if background color has no transparency (thank you @disq for the idea!)
448 // (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image)
449 if _, _, _, a := bg.RGBA(); a >= 0xffff {
450 rgbaOut = image.NewRGBA(bounds)
451 draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src)
452 draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over)
453 } else {
454 if v, ok := img.(*image.RGBA); ok {
455 rgbaOut = v
456 } else {
457 rgbaOut = image.NewRGBA(bounds)
458 draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src)
459 }
460 }
461
462 yMin, xMin := bounds.Min.Y, bounds.Min.X
463 yMax, xMax := bounds.Max.Y, bounds.Max.X
464
465 if dm == NoDithering {
466 // always sets an even number of ANSIPixel rows...
467 yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering
468 } else {
469 yMax = yMax / BlockSizeY // always sets 1 ANSIPixel block...
470 xMax = xMax / BlockSizeX // per 8x4 real pixels --> with dithering
471 }
472
473 ansimage, err := New(yMax, xMax, bg, dm)
474 if err != nil {
475 return nil, err
476 }
477
478 if dm == NoDithering {
479 for y := yMin; y < yMax; y++ {
480 for x := xMin; x < xMax; x++ {
481 v := rgbaOut.RGBAAt(x, y)
482 if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil {
483 return nil, err
484 }
485 }
486 }
487 } else {
488 pixelCount := BlockSizeY * BlockSizeX
489
490 for y := yMin; y < yMax; y++ {
491 for x := xMin; x < xMax; x++ {
492
493 var sumR, sumG, sumB, sumBri float64
494 for dy := 0; dy < BlockSizeY; dy++ {
495 py := BlockSizeY*y + dy
496
497 for dx := 0; dx < BlockSizeX; dx++ {
498 px := BlockSizeX*x + dx
499
500 pixel := rgbaOut.At(px, py)
501 _, _, v := colorful.MakeColor(pixel).Hsv()
502 color := colorful.MakeColor(pixel)
503 sumR += color.R
504 sumG += color.G
505 sumB += color.B
506 sumBri += v
507 }
508 }
509
510 r := uint8(sumR/float64(pixelCount)*255.0 + 0.5)
511 g := uint8(sumG/float64(pixelCount)*255.0 + 0.5)
512 b := uint8(sumB/float64(pixelCount)*255.0 + 0.5)
513 brightness := uint8(sumBri/float64(pixelCount)*255.0 + 0.5)
514
515 if err := ansimage.SetAt(y, x, r, g, b, brightness); err != nil {
516 return nil, err
517 }
518 }
519 }
520 }
521
522 return ansimage, nil
523}