File: ./config.go
   1 package main
   2 
   3 import (
   4     "flag"
   5     "fmt"
   6 )
   7 
   8 // config is the parsed cmd-line options given to the app
   9 type config struct {
  10     Width     int
  11     Filenames []string
  12 }
  13 
  14 const (
  15     widthUsage = "output width in character blocks/rectangles"
  16 )
  17 
  18 // parseFlags is the constructor for type config
  19 func parseFlags(usage string) config {
  20     flag.Usage = func() {
  21         fmt.Fprintf(flag.CommandLine.Output(), "%s\n\nOptions\n\n", usage)
  22         flag.PrintDefaults()
  23     }
  24 
  25     cfg := config{Width: 80}
  26 
  27     flag.IntVar(&cfg.Width, "w", cfg.Width, "alias for option -width")
  28     flag.IntVar(&cfg.Width, "width", cfg.Width, widthUsage)
  29     flag.Parse()
  30 
  31     cfg.Filenames = flag.Args()
  32     return cfg
  33 }

     File: ./go.mod
   1 module vip
   2 
   3 go 1.18
   4 
   5 require golang.org/x/image v0.7.0

     File: ./go.sum
   1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
   2 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
   3 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
   4 golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
   5 golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
   6 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
   7 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
   8 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
   9 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
  10 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
  11 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
  12 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
  13 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
  14 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
  15 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
  16 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
  17 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
  18 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
  19 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
  20 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
  21 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
  22 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
  23 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
  24 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
  25 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
  26 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
  27 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
  28 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
  29 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
  30 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
  31 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
  32 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
  33 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

     File: ./images.go
   1 package main
   2 
   3 import (
   4     "bytes"
   5     "image"
   6 )
   7 
   8 // picture is a true-color picture with size info, and isn't called image
   9 // because this app uses package image from the stdlib, taking away that name
  10 type picture struct {
  11     // Width is the picture's width in pixels
  12     Width uint32
  13 
  14     // Height is the picture's height in pixels
  15     Height uint32
  16 
  17     // Pixels is the repeating RGB byte-sequence of length 3*Width*Height
  18     Pixels []byte
  19 }
  20 
  21 // ensureRGBA turns generic images into RGBA images specifically, since the
  22 // image display logic depends on that specific format
  23 func ensureRGBA(img image.Image) *image.RGBA {
  24     rgba, ok := img.(*image.RGBA)
  25     if ok {
  26         // it's already RGBA
  27         return rgba
  28     }
  29 
  30     bnds := img.Bounds()
  31     rgba = image.NewRGBA(bnds)
  32     for row := bnds.Min.Y; row < bnds.Max.Y; row++ {
  33         for col := bnds.Min.X; col < bnds.Max.X; col++ {
  34             rgba.Set(col, row, img.At(col, row))
  35         }
  36     }
  37     return rgba
  38 }
  39 
  40 // smoothShrink shrinks the picture given by averaging all pixels matching each
  41 // pixel in the shrunk image, resulting in a smooth/low-noise result
  42 func smoothShrink(in picture, w, h int) picture {
  43     if w < 1 || h < 1 {
  44         return picture{}
  45     }
  46     if int(in.Width) <= w && int(in.Height) <= h {
  47         // input pic is smaller than requested output, so there's
  48         // no need to shrink it
  49         return in
  50     }
  51 
  52     // sum all RGB values for each output tile's `catchment region`, as
  53     // well as their hit-counts, to later calculate arithmetic averages
  54 
  55     hits := make([]float64, w*h)
  56     sums := make([]float64, 3*w*h)
  57 
  58     xincr := float64(w) / float64(in.Width)
  59     yincr := float64(h) / float64(in.Height)
  60 
  61     for row := 0; row < int(in.Height); row++ {
  62         for col := 0; col < int(in.Width); col++ {
  63             x := int(xincr * float64(col))
  64             y := int(yincr * float64(row))
  65 
  66             dst := (w*y + x)
  67             // calculate pixel-areas from the source would arguably be more
  68             // efficient, but counting hits is simple and it works; later
  69             // note: that approach leaves a few subtle periodic bands, so
  70             // keep this naive approach for now
  71             hits[dst]++
  72 
  73             dst *= 3
  74             src := 3 * (int(in.Width)*row + col)
  75             // add normalized values (range 0..1) for each color component
  76             sums[dst+0] += float64(in.Pixels[src+0]) / 255
  77             sums[dst+1] += float64(in.Pixels[src+1]) / 255
  78             sums[dst+2] += float64(in.Pixels[src+2]) / 255
  79         }
  80     }
  81 
  82     // shrunk is a picture representing the output tiles, each pixel being
  83     // the average of all source pixels from their corresponding region from
  84     // the original picture
  85     shrunk := picture{
  86         Width:  uint32(w),
  87         Height: uint32(h),
  88         Pixels: make([]byte, 0, 3*w*h),
  89     }
  90     for i := 0; i < 3*w*h; i++ {
  91         avg := 255.0 * sums[i] / hits[i/3]
  92         // avg := 255.0 * sums[i] * (xincr * yincr)
  93         shrunk.Pixels = append(shrunk.Pixels, byte(avg))
  94     }
  95     return shrunk
  96 }
  97 
  98 // noisyShrink is a simpler way to shrink down a pic: the name comes from the
  99 // side-effect of sampling a single pixel from a whole region, resulting in
 100 // high-noise fast-changing pixel colors in the result image
 101 func noisyShrink(in picture, w, h int) picture {
 102     if w < 1 || h < 1 {
 103         return picture{}
 104     }
 105     if int(in.Width) <= w && int(in.Height) <= h {
 106         return in
 107     }
 108 
 109     shrunk := picture{
 110         Width:  uint32(w),
 111         Height: uint32(h),
 112         Pixels: make([]byte, 3*w*h),
 113     }
 114 
 115     xincr := float64(in.Width) / float64(w)
 116     yincr := float64(in.Height) / float64(h)
 117 
 118     for row := 0; row < h; row++ {
 119         for col := 0; col < w; col++ {
 120             x := int(xincr * float64(col))
 121             y := int(yincr * float64(row))
 122 
 123             src := 3 * (int(in.Width)*y + x)
 124             dst := 3 * (w*row + col)
 125             shrunk.Pixels[dst+0] = in.Pixels[src+0]
 126             shrunk.Pixels[dst+1] = in.Pixels[src+1]
 127             shrunk.Pixels[dst+2] = in.Pixels[src+2]
 128         }
 129     }
 130     return shrunk
 131 }
 132 
 133 // guessInputType tries to guess the image format using the first few bytes
 134 // bytes read from its data stream
 135 func guessImageType(start []byte) (string, bool) {
 136     if bytes.HasPrefix(start, []byte{0x89, 'P', 'N', 'G'}) {
 137         return "png", true
 138     }
 139     if bytes.HasPrefix(start, []byte{0xff, 0xd8, 0xff}) {
 140         return "jpeg", true
 141     }
 142     if seemsWEBP(start) {
 143         return "webp", true
 144     }
 145     if bytes.HasPrefix(start, []byte{'B', 'M'}) {
 146         return "bmp", true
 147     }
 148     if bytes.HasPrefix(start, []byte{'I', 'I', '*', 0}) {
 149         return "tiff", true
 150     }
 151     if bytes.HasPrefix(start, []byte{'M', 'M', 0, '*'}) {
 152         return "tiff", true
 153     }
 154     if bytes.HasPrefix(start, []byte{'G', 'I', 'F', '8', '7', 'a'}) {
 155         return "gif", true
 156     }
 157     if bytes.HasPrefix(start, []byte{'G', 'I', 'F', '8', '9', 'a'}) {
 158         return "gif", true
 159     }
 160     return "", false
 161 }
 162 
 163 // seemsWEBP simplifies control-flow for func guessImageType
 164 func seemsWEBP(start []byte) bool {
 165     if !bytes.HasPrefix(start, []byte{'R', 'I', 'F', 'F'}) {
 166         return false
 167     }
 168     if len(start) >= 12 {
 169         return bytes.HasPrefix(start[8:], []byte{'W', 'E', 'B', 'P'})
 170     }
 171     return false
 172 }

     File: ./info.txt
   1 vip [options] [filenames...]
   2 
   3 VIew Pictures on any truecolor terminal emulator / cmd-line: you may want to
   4 pipe its results to `less -RS`, unless pics are small enough to fit in your
   5 current window size.
   6 
   7 Supported input formats
   8 
   9   - PNG (stills only)
  10   - JPEG
  11   - GIF (stills only)
  12   - BMP
  13   - TIFF
  14   - WEBP
  15 
  16 If your terminal emulator supports inline base64-encoded images, run this app
  17 with option -to=direct for best results.

     File: ./main.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "bytes"
   6     "errors"
   7     "fmt"
   8     "image"
   9     "image/gif"
  10     "image/jpeg"
  11     "image/png"
  12     "io"
  13     "math"
  14     "os"
  15 
  16     "golang.org/x/image/bmp"
  17     "golang.org/x/image/tiff"
  18     "golang.org/x/image/webp"
  19 
  20     _ "embed"
  21 )
  22 
  23 // errDoneWriting is a dummy error to signal the app should quit
  24 var errDoneWriting = errors.New("no more output")
  25 
  26 //go:embed info.txt
  27 var usage string
  28 
  29 // decoders dispatches image-format names to decoder funcs
  30 var decoders = map[string]func(io.Reader) (image.Image, error){
  31     "png":  png.Decode,
  32     "jpeg": jpeg.Decode,
  33     "webp": webp.Decode,
  34     "bmp":  bmp.Decode,
  35     "tiff": tiff.Decode,
  36     "gif":  gif.Decode,
  37 }
  38 
  39 func main() {
  40     // avoid annoying unused-func warnings
  41     _ = noisyShrink
  42 
  43     if run() > 0 {
  44         os.Exit(1)
  45     }
  46 }
  47 
  48 // run returns the number of errors occurred
  49 func run() int {
  50     // f, _ := os.Create("vip.prof")
  51     // defer f.Close()
  52     // pprof.StartCPUProfile(f)
  53     // defer pprof.StopCPUProfile()
  54 
  55     cfg := parseFlags(usage)
  56     w := bufio.NewWriter(os.Stdout)
  57     defer w.Flush()
  58 
  59     // performance-profiling shows no real benefit in avoiding
  60     // the likes of fmt.Fprint, since the overwhelming share of
  61     // time is spent decoding inputs, especially when they're
  62     // beyond a few megabytes
  63 
  64     errors := 0
  65     for i, fname := range cfg.Filenames {
  66         if i > 0 {
  67             fmt.Fprint(w, "\n\n")
  68         }
  69         if len(cfg.Filenames) > 1 {
  70             fmt.Fprintf(w, "%s\n\n", fname)
  71         }
  72 
  73         err := handleFile(w, fname, cfg)
  74         if err != nil {
  75             w.Flush()
  76             fmt.Fprintf(os.Stderr, "%s%s\n", fname, err.Error())
  77             errors++
  78         }
  79     }
  80 
  81     if len(cfg.Filenames) == 0 {
  82         err := handleReader(w, os.Stdin, cfg)
  83         if err != nil {
  84             w.Flush()
  85             fmt.Fprintln(os.Stderr, err.Error())
  86             errors++
  87         }
  88     }
  89 
  90     return errors
  91 }
  92 
  93 // handleFile defer-closes files immediately after use for func run
  94 func handleFile(w *bufio.Writer, fname string, cfg config) error {
  95     f, err := os.Open(fname)
  96     if err != nil {
  97         return err
  98     }
  99     defer f.Close()
 100     return handleReader(w, f, cfg)
 101 }
 102 
 103 // handleReader reads the input image, transforms/shrinks it, and show it
 104 // as ANSI-colored blocks on the terminal
 105 func handleReader(w *bufio.Writer, r io.Reader, cfg config) error {
 106     defer w.Flush()
 107 
 108     var start [32]byte
 109     n, err := r.Read(start[:])
 110     if err != nil {
 111         return err
 112     }
 113 
 114     kind, ok := guessImageType(start[:n])
 115     if !ok {
 116         return errors.New("unsupported image data")
 117     }
 118 
 119     dec, ok := decoders[kind]
 120     if !ok {
 121         return errors.New("unsupported image data")
 122     }
 123 
 124     // decode input data
 125     img, err := dec(io.MultiReader(bytes.NewReader(start[:n]), r))
 126     if err != nil {
 127         return err
 128     }
 129 
 130     data := ensureRGBA(img)
 131     pic := picture{
 132         Width:  uint32(img.Bounds().Dx()),
 133         Height: uint32(img.Bounds().Dy()),
 134     }
 135     pic.Pixels = make([]byte, 0, 3*pic.Width*pic.Height)
 136     // copy all pixels, ignoring their alpha values
 137     for i := 0; i < len(data.Pix); i += 4 {
 138         pic.Pixels = append(pic.Pixels, data.Pix[i+0])
 139         pic.Pixels = append(pic.Pixels, data.Pix[i+1])
 140         pic.Pixels = append(pic.Pixels, data.Pix[i+2])
 141     }
 142 
 143     // find output height by keeping the aspect-ratio of the original pic
 144     ar := float64(pic.Width) / float64(pic.Height)
 145     h := int(math.Ceil(float64(cfg.Width) / ar))
 146 
 147     // show a shrunk copy of the pic
 148     err = show(w, smoothShrink(pic, int(cfg.Width), h))
 149     if err == errDoneWriting {
 150         return nil
 151     }
 152     return err
 153 }

     File: ./output.go
   1 package main
   2 
   3 import "bufio"
   4 
   5 // show shows a picture as ANSI-styled full-color terminal graphics
   6 func show(w *bufio.Writer, pic picture) error {
   7     if pic.Width < 1 || pic.Height < 1 {
   8         return nil
   9     }
  10 
  11     rowWidth := 3 * pic.Width
  12     top := make([]byte, rowWidth)
  13     bottom := make([]byte, rowWidth)
  14 
  15     pixels := pic.Pixels
  16     // loop on pairs of data rows
  17     for len(pixels) >= 2*int(rowWidth) {
  18         copy(top, pixels[:rowWidth])
  19         pixels = pixels[rowWidth:]
  20         copy(bottom, pixels[:rowWidth])
  21         pixels = pixels[rowWidth:]
  22 
  23         err := writeLine(w, top, bottom)
  24         if err != nil {
  25             return err
  26         }
  27     }
  28 
  29     // don't forget the last row for pictures with an odd number of rows
  30     if len(pixels) >= int(rowWidth) {
  31         // use top/last row as is
  32         copy(top, pixels[:rowWidth])
  33         // make bottom row all black
  34         for i := 0; i < len(bottom); i++ {
  35             bottom[i] = 0
  36         }
  37         return writeLine(w, top, bottom)
  38     }
  39     return nil
  40 }
  41 
  42 // writeLine handles emitting a row/line as a combination of 2 adjacent
  43 // image rows/lines, combining them pair-wise into ANSI-styled cells
  44 func writeLine(w *bufio.Writer, top, bottom []byte) error {
  45     for len(top) >= 3 && len(bottom) >= 3 {
  46         writeDoublePixel(w,
  47             top[0], top[1], top[2],
  48             bottom[0], bottom[1], bottom[2],
  49         )
  50         top = top[3:]
  51         bottom = bottom[3:]
  52     }
  53 
  54     n, err := w.WriteString("\x1b[0m\n")
  55     if n < 1 || err != nil {
  56         return errDoneWriting
  57     }
  58     return nil
  59 }
  60 
  61 // writeDoublePixel handles combining 2 pixels on top of each other as a
  62 // single cell, using both background and foreground styles to combine them
  63 func writeDoublePixel(w *bufio.Writer, a, b, c, d, e, f byte) {
  64     // fmt.Fprintf(w, "\x1b[48;2;%d;%d;%dm", a, b, c)
  65     // fmt.Fprintf(w, "\x1b[38;2;%d;%d;%dm▄", d, e, f)
  66 
  67     w.WriteString("\x1b[48;2;")
  68     writeByteDigits(w, a)
  69     w.WriteByte(';')
  70     writeByteDigits(w, b)
  71     w.WriteByte(';')
  72     writeByteDigits(w, c)
  73     w.WriteByte('m')
  74 
  75     w.WriteString("\x1b[38;2;")
  76     writeByteDigits(w, d)
  77     w.WriteByte(';')
  78     writeByteDigits(w, e)
  79     w.WriteByte(';')
  80     writeByteDigits(w, f)
  81     w.WriteString("m▄")
  82 }
  83 
  84 // writeByteDigits emits the unpadded decimal digits of the byte given
  85 func writeByteDigits(w *bufio.Writer, b byte) {
  86     if b >= 100 {
  87         w.WriteByte(b/100 + '0')
  88         b %= 100
  89         w.WriteByte(b/10 + '0')
  90         w.WriteByte(b%10 + '0')
  91         return
  92     }
  93 
  94     if b >= 10 {
  95         w.WriteByte(b/10 + '0')
  96         w.WriteByte(b%10 + '0')
  97         return
  98     }
  99 
 100     w.WriteByte(b + '0')
 101 }