File: vip/config.go 1 package main 2 3 import ( 4 "flag" 5 "fmt" 6 "strconv" 7 ) 8 9 // config is the parsed cmd-line options given to the app 10 type config struct { 11 Width int 12 Filenames []string 13 } 14 15 const ( 16 widthUsage = "output width in character blocks/rectangles" 17 ) 18 19 // parseFlags is the constructor for type config 20 func parseFlags(usage string) config { 21 flag.Usage = func() { 22 fmt.Fprintf(flag.CommandLine.Output(), "%s\n\nOptions\n\n", usage) 23 flag.PrintDefaults() 24 } 25 26 cfg := config{Width: 80} 27 28 flag.IntVar(&cfg.Width, "w", cfg.Width, "alias for option -width") 29 flag.IntVar(&cfg.Width, "width", cfg.Width, widthUsage) 30 flag.Parse() 31 32 cfg.Filenames = flag.Args() 33 34 // handle a leading number like the width option 35 if len(cfg.Filenames) > 0 { 36 n, err := strconv.Atoi(cfg.Filenames[0]) 37 if err == nil && n > 0 { 38 cfg.Width = n 39 cfg.Filenames = cfg.Filenames[1:] 40 } 41 } 42 43 return cfg 44 } File: vip/go.mod 1 module vip 2 3 go 1.18 4 5 require golang.org/x/image v0.7.0 File: vip/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: vip/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: vip/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: vip/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: vip/mit-license.txt 1 The MIT License (MIT) 2 3 Copyright © 2024 pacman64 4 5 Permission is hereby granted, free of charge, to any person obtaining a copy of 6 this software and associated documentation files (the “Software”), to deal 7 in the Software without restriction, including without limitation the rights to 8 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 of the Software, and to permit persons to whom the Software is furnished to do 10 so, subject to the following conditions: 11 12 The above copyright notice and this permission notice shall be included in all 13 copies or substantial portions of the Software. 14 15 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 SOFTWARE. File: vip/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 }