aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md12
-rw-r--r--go.mod13
-rw-r--r--go.sum12
-rw-r--r--icat/bench_test.go20
-rw-r--r--icat/icat.go324
-rw-r--r--ils/ils.go459
7 files changed, 836 insertions, 5 deletions
diff --git a/.gitignore b/.gitignore
index 896533f..be55b8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
+/icat
/convert
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..df587a8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+# Image utilities for the command line
+
+Included utilities:
+
+ - ils: like ls but for images
+ - icat: display images in the terminal
+ - convert: convert between formats or resize images
+ - itest: check image properties
+
+```
+go install roseh.moe/cmd/imgutil/...@latest
+```
diff --git a/go.mod b/go.mod
index 368083f..3693705 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,12 @@
-module roseh.moe/cmd/convert
+module roseh.moe/cmd/imgutil
-go 1.24.1
+go 1.26.3
-require golang.org/x/image v0.32.0
+require (
+ github.com/mattn/go-runewidth v0.0.24
+ golang.org/x/image v0.42.0
+ golang.org/x/sys v0.46.0
+ roseh.moe/pkg/sixel v0.2.1
+)
+
+require github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
diff --git a/go.sum b/go.sum
index 2a81e87..c8be9b3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,10 @@
-golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
-golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
+golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+roseh.moe/pkg/sixel v0.2.1 h1:Njzw2wWOdyrIn+SEJ8Gir6qD8uzgJ7UuHL4em0k5Q3A=
+roseh.moe/pkg/sixel v0.2.1/go.mod h1:DX/c+l3VYm+yXoACY+HXl2gLV5o5iAG6gkHeDddTSrQ=
diff --git a/icat/bench_test.go b/icat/bench_test.go
new file mode 100644
index 0000000..1e79a10
--- /dev/null
+++ b/icat/bench_test.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+ "cmp"
+ "math/rand/v2"
+ "testing"
+)
+
+func BenchmarkQuickSelect(b *testing.B) {
+ rng := rand.New(rand.NewPCG(0, 0))
+ myTestCase := make([]int, 3840*2160)
+ for b.Loop() {
+ b.StopTimer()
+ for i := range myTestCase {
+ myTestCase[i] = rng.Int()
+ }
+ b.StartTimer()
+ quickSelect(myTestCase, len(myTestCase)/2, cmp.Compare)
+ }
+}
diff --git a/icat/icat.go b/icat/icat.go
new file mode 100644
index 0000000..2b69170
--- /dev/null
+++ b/icat/icat.go
@@ -0,0 +1,324 @@
+// The icat command displays an image to the terminal using block characters.
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "image"
+ "image/color"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "math"
+ "os"
+ "slices"
+
+ _ "golang.org/x/image/bmp"
+ "golang.org/x/image/draw"
+ _ "golang.org/x/image/tiff"
+ _ "golang.org/x/image/webp"
+ "golang.org/x/sys/unix"
+ "roseh.moe/pkg/sixel"
+)
+
+var (
+ x = flag.Int("x", 0, "set image width in columns")
+ y = flag.Int("y", 0, "set image height in rows")
+ m = flagPrintMode(flag.CommandLine, "m", modeBlock24, "one of 'block', 'block24', or 'sixel'")
+)
+
+type printMode int
+
+const (
+ modeInvalid printMode = iota
+ modeBlock
+ modeBlock24
+ modeSixel
+)
+
+type printModeValue printMode
+
+func (m *printModeValue) String() string {
+ switch printMode(*m) {
+ case modeBlock:
+ return "block"
+ case modeBlock24:
+ return "block24"
+ case modeSixel:
+ return "sixel"
+ default:
+ return "invalid"
+ }
+}
+
+func (m *printModeValue) Set(s string) error {
+ var mode printMode
+ switch s {
+ case "block":
+ mode = modeBlock
+ case "block24":
+ mode = modeBlock24
+ case "sixel":
+ mode = modeSixel
+ default:
+ return fmt.Errorf("bad mode type %q, should be one of 'block', 'block24', or 'sixel'", s)
+ }
+ *m = printModeValue(mode)
+ return nil
+}
+
+func flagPrintMode(fs *flag.FlagSet, name string, value printMode, usage string) *printMode {
+ fs.Var((*printModeValue)(&value), name, usage)
+ return &value
+}
+
+func divRound[N ~int64 | ~int](n, d N) N {
+ return (n + d/2) / d
+}
+
+func load(filename string) (image.Image, error) {
+ file := os.Stdin
+ if filename != "-" {
+ var err error
+ file, err = os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ }
+
+ img, _, err := image.Decode(file)
+ if err != nil {
+ return nil, fmt.Errorf("decode %q: %s", filename, err)
+ }
+
+ return img, nil
+}
+
+func sign(n int) float64 {
+ if n < 0 {
+ return -1
+ }
+ if n > 0 {
+ return 1
+ }
+ return 0
+}
+
+func floydRivest[S ~[]E, E any](array S, left, right, k int, cmp func(E, E) int) {
+ for right > left {
+ if right-left > 600 {
+ n := right - left + 1
+ i := k - left + 1
+ z := math.Log(float64(n))
+ s := .5 * math.Exp(2*z/3)
+ sd := .5 * math.Sqrt(z*s*(float64(n)-s)/float64(n)) * sign(i-n/2)
+ newLeft := max(left, int(float64(k)-float64(i)*s/float64(n)+sd))
+ newRight := min(right, int(float64(k)+float64(n-i)*s/float64(n)+sd))
+ floydRivest(array, newLeft, newRight, k, cmp)
+ }
+ t := array[k]
+ i := left
+ j := right
+ array[left], array[k] = array[k], array[left]
+ if cmp(array[right], t) > 0 {
+ array[right], array[left] = array[left], array[right]
+ }
+ for i < j {
+ array[i], array[j] = array[j], array[i]
+ i++
+ j--
+ for ; cmp(array[i], t) < 0; i++ {
+ }
+ for ; cmp(array[j], t) > 0; j-- {
+ }
+ }
+ if cmp(array[left], t) == 0 {
+ array[left], array[j] = array[j], array[left]
+ } else {
+ j++
+ array[j], array[right] = array[right], array[j]
+ }
+ if j <= k {
+ left = j + 1
+ }
+ if k <= j {
+ right = j - 1
+ }
+ }
+}
+
+func quickSelect[S ~[]E, E any](list S, k int, cmp func(E, E) int) {
+ floydRivest(list, 0, len(list)-1, k, cmp)
+}
+
+func bucketRange(colors []color.RGBA) color.RGBA {
+ if len(colors) == 0 {
+ return color.RGBA{}
+ }
+ var minR, minG, minB uint8 = math.MaxUint8, math.MaxUint8, math.MaxUint8
+ var maxR, maxG, maxB uint8
+ for _, c := range colors {
+ minR, maxR = min(minR, c.R), max(maxR, c.R)
+ minG, maxG = min(minG, c.G), max(maxG, c.G)
+ minB, maxB = min(minB, c.B), max(maxB, c.B)
+ }
+ return color.RGBA{R: maxR - minR, G: maxG - minG, B: maxB - minB}
+}
+
+func cutOnce(colors []color.RGBA, bucketRange color.RGBA) [2][]color.RGBA {
+ if len(colors) == 0 {
+ return [...][]color.RGBA{colors, colors}
+ }
+ rRange, gRange, bRange := bucketRange.R, bucketRange.G, bucketRange.B
+ if rRange >= gRange && rRange >= bRange {
+ quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.R) - int(y.R) })
+ } else if gRange >= rRange && gRange >= bRange {
+ quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.G) - int(y.G) })
+ } else {
+ quickSelect(colors, len(colors)/2, func(x, y color.RGBA) int { return int(x.B) - int(y.B) })
+ }
+ return [...][]color.RGBA{colors[:len(colors)/2], colors[len(colors)/2:]}
+}
+
+func colorAvg(colors []color.RGBA) color.RGBA {
+ var r, g, b int64
+ for _, c := range colors {
+ r += int64(c.R)
+ g += int64(c.G)
+ b += int64(c.B)
+ }
+ n := int64(len(colors))
+ return color.RGBA{R: uint8(divRound(r, n)), G: uint8(divRound(g, n)), B: uint8(divRound(b, n)), A: 0xff}
+}
+
+func medianCut(img image.Image) color.Palette {
+ var colors []color.RGBA
+ for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
+ for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
+ r, g, b, a := img.At(x, y).RGBA()
+ if a > 0 {
+ colors = append(colors, color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 0xff})
+ }
+ }
+ }
+ buckets := [][]color.RGBA{colors}
+ bucketRanges := []color.RGBA{{}}
+ for {
+ var bestRange uint8
+ var bestIdx int
+ for i, rng := range bucketRanges {
+ r := max(rng.R, rng.G, rng.B)
+ if r >= bestRange {
+ bestRange = r
+ bestIdx = i
+ }
+ }
+ split := cutOnce(buckets[bestIdx], bucketRanges[bestIdx])
+ buckets = slices.Replace(buckets, bestIdx, bestIdx+1, split[:]...)
+ if len(buckets) == 255 {
+ break
+ }
+ bucketRanges = slices.Replace(bucketRanges, bestIdx, bestIdx+1, bucketRange(split[0]), bucketRange(split[1]))
+ }
+ palette := color.Palette{color.Transparent}
+ for _, b := range buckets {
+ if len(b) > 0 {
+ palette = append(palette, colorAvg(b))
+ }
+ }
+ return palette
+}
+
+func printImg(img image.Image, maxX, maxY, pixelX, pixelY int) error {
+ x := min(img.Bounds().Dx(), maxX)
+ y := min(img.Bounds().Dy(), maxY)
+
+ // Try not to stretch the image.
+ if y == 0 || x != 0 && img.Bounds().Dy()*x*pixelX <= img.Bounds().Dx()*y*pixelY {
+ y = divRound(img.Bounds().Dy()*x*pixelX, img.Bounds().Dx()*pixelY)
+ } else {
+ x = divRound(img.Bounds().Dx()*y*pixelY, pixelX*img.Bounds().Dy())
+ }
+
+ if x != img.Bounds().Dx() || y != img.Bounds().Dy() {
+ dst := image.NewRGBA(image.Rect(0, 0, x, y))
+ draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
+ img = dst
+ }
+
+ switch *m {
+ case modeBlock:
+ sixel.PrintXTerm16(os.Stdout, img)
+ case modeBlock24:
+ sixel.PrintBlock(os.Stdout, img)
+ case modeSixel:
+ sixel.Print(os.Stdout, img, medianCut(img))
+ }
+ fmt.Println()
+ return nil
+}
+
+func icat(args []string) error {
+ if len(args) == 0 {
+ return errors.New("missing positional argument")
+ }
+ if len(args) > 1 {
+ return errors.New("too many positional arguments")
+ }
+ file := args[0]
+
+ cols := *x
+ lines := *y
+ pixelX := 2
+ pixelY := 5
+ if cols == 0 && lines == 0 {
+ ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
+ if err != nil {
+ return err
+ }
+ cols = int(ws.Col)
+ lines = int(ws.Row)
+ lines-- // Leave a line for the status bar.
+ cellX, cellY := int(ws.Xpixel)/int(ws.Col), int(ws.Ypixel)/int(ws.Row)
+ if cellX == 0 && cellY == 0 {
+ cellX, cellY = 10, 20
+ }
+ if *m == modeSixel {
+ cols *= cellX
+ lines *= cellY
+ } else {
+ pixelX, pixelY = cellX, cellY
+ }
+ }
+ switch *m {
+ case modeSixel:
+ pixelX, pixelY = 1, 1
+ case modeBlock24:
+ lines *= 2
+ pixelX *= 2
+ }
+
+ img, err := load(file)
+ if err != nil {
+ return err
+ }
+
+ if err := printImg(img, cols, lines, pixelX, pixelY); err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: icat FILE\n")
+ }
+ flag.Parse()
+
+ if err := icat(flag.Args()); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed: %s\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/ils/ils.go b/ils/ils.go
new file mode 100644
index 0000000..f2301dd
--- /dev/null
+++ b/ils/ils.go
@@ -0,0 +1,459 @@
+package main
+
+import (
+ "bufio"
+ "flag"
+ "fmt"
+ "image"
+ "image/color"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+
+ runewidth "github.com/mattn/go-runewidth"
+ _ "golang.org/x/image/bmp"
+ "golang.org/x/image/draw"
+ _ "golang.org/x/image/tiff"
+ _ "golang.org/x/image/webp"
+ "golang.org/x/sys/unix"
+ "roseh.moe/pkg/sixel"
+)
+
+var (
+ x = flag.Int("x", 15, "set image width in columns")
+ l = flag.Bool("l", false, "print image attributes")
+ m = flagPrintMode(flag.CommandLine, "m", modeSixel, "one of 'block', 'block24', or 'sixel'")
+)
+
+type printMode int
+
+const (
+ modeInvalid printMode = iota
+ modeBlock
+ modeBlock24
+ modeSixel
+)
+
+type printModeValue printMode
+
+func (m *printModeValue) String() string {
+ switch printMode(*m) {
+ case modeBlock:
+ return "block"
+ case modeBlock24:
+ return "block24"
+ case modeSixel:
+ return "sixel"
+ default:
+ return "invalid"
+ }
+}
+
+func (m *printModeValue) Set(s string) error {
+ var mode printMode
+ switch s {
+ case "block":
+ mode = modeBlock
+ case "block24":
+ mode = modeBlock24
+ case "sixel":
+ mode = modeSixel
+ default:
+ return fmt.Errorf("bad mode type %q, should be one of 'block', 'block24', or 'sixel'", s)
+ }
+ *m = printModeValue(mode)
+ return nil
+}
+
+func flagPrintMode(fs *flag.FlagSet, name string, value printMode, usage string) *printMode {
+ fs.Var((*printModeValue)(&value), name, usage)
+ return &value
+}
+
+func isDir(filename string) bool {
+ stat, err := os.Stat(filename)
+ return err == nil && stat.IsDir()
+}
+
+func isImage(filename string) bool {
+ f, err := os.Open(filename)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+ _, _, err = image.DecodeConfig(f)
+ return err == nil
+}
+
+type imageMetadata struct {
+ format, colorModel string
+ width, height int
+}
+
+func (m *imageMetadata) String() string {
+ return fmt.Sprintf("%dx%d %s %s", m.width, m.height, m.format, m.colorModel)
+}
+
+func colorModelName(m color.Model) string {
+ switch m {
+ case color.RGBAModel:
+ return "rgba"
+ case color.RGBA64Model:
+ return "rgba 64 bit"
+ case color.NRGBAModel:
+ return "rgba not alpha premultiplied"
+ case color.NRGBA64Model:
+ return "rgba 64 bit not alpha premultiplied"
+ case color.AlphaModel:
+ return "alpha"
+ case color.Alpha16Model:
+ return "alpha 16 bit"
+ case color.GrayModel:
+ return "grayscale"
+ case color.Gray16Model:
+ return "grayscale 16 bit"
+ case color.CMYKModel:
+ return "cmyk"
+ case color.NYCbCrAModel:
+ return "Y'CbCr with alpha"
+ case color.YCbCrModel:
+ return "Y'CbCr"
+ }
+ if palette, ok := m.(color.Palette); ok {
+ return fmt.Sprintf("palettized %d colors", len(palette))
+ }
+ return fmt.Sprintf("unknown %v", m)
+}
+
+func readMetadata(filename string) (imageMetadata, error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return imageMetadata{}, err
+ }
+ defer f.Close()
+ config, format, err := image.DecodeConfig(f)
+ if err != nil {
+ return imageMetadata{}, err
+ }
+ return imageMetadata{format: format, colorModel: colorModelName(config.ColorModel), width: config.Width, height: config.Height}, nil
+}
+
+func load(filename string) (image.Image, error) {
+ f, err := os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ img, _, err := image.Decode(f)
+ if err != nil {
+ return nil, fmt.Errorf("decode %q: %s", filename, err)
+ }
+
+ return img, nil
+}
+
+func printImg(w io.Writer, img image.Image) {
+ switch *m {
+ case modeBlock:
+ sixel.PrintXTerm16(w, img)
+ case modeBlock24:
+ sixel.PrintBlock(w, img)
+ case modeSixel:
+ sixel.Print(w, img, nil)
+ default:
+ panic("invalid mode")
+ }
+}
+
+func escapeString(s string) string {
+ for _, r := range s {
+ if !strconv.IsGraphic(r) {
+ return strconv.QuoteToGraphic(s)
+ }
+ }
+ return s
+}
+
+func divRound(n, d int) int {
+ return (n + d/2) / d
+}
+
+type pixelAspectRatio struct {
+ x, y int
+}
+
+func layoutRow(imagePaths []string, metadata []imageMetadata, width, rowWidth, cellX int) []int {
+ filenameWidths := make([]int, len(imagePaths))
+ for i, path := range imagePaths {
+ filenameWidths[i] = cellX * runewidth.StringWidth(escapeString(filepath.Base(path)))
+ if *l {
+ filenameWidths[i] = max(filenameWidths[i], cellX*runewidth.StringWidth(metadata[i].String()))
+ }
+ }
+ var logicalWidth int
+ for i, filenameWidth := range filenameWidths {
+ if i > 0 {
+ logicalWidth += 2 * cellX
+ }
+ logicalWidth += max(width, filenameWidth)
+ }
+ layout := make([]int, len(imagePaths))
+ var offset int
+ for i, filenameWidth := range filenameWidths {
+ imageWidth := max(width, filenameWidth)
+ layout[i] = divRound(offset*rowWidth, logicalWidth)
+ offset += imageWidth + 2*cellX
+ }
+ return layout
+}
+
+func compositeImageRow(imagePaths []string, metadata []imageMetadata, width, rowWidth, cellX int, aspectRatio pixelAspectRatio) (image.Image, error) {
+ if len(metadata) == 0 {
+ metadata = make([]imageMetadata, len(imagePaths))
+ for i, filename := range imagePaths {
+ var err error
+ if metadata[i], err = readMetadata(filename); err != nil {
+ return nil, err
+ }
+ }
+ }
+ var maxHeight int
+ for _, fileMetadata := range metadata {
+ maxHeight = max(maxHeight, divRound(fileMetadata.height*width*aspectRatio.x, fileMetadata.width*aspectRatio.y))
+ }
+ layout := layoutRow(imagePaths, metadata, width, rowWidth, cellX)
+ bounds := image.Rect(0, 0, rowWidth, maxHeight)
+ result := image.NewRGBA(bounds)
+ for i, filename := range imagePaths {
+ img, err := load(filename)
+ if err != nil {
+ return nil, err
+ }
+ start := layout[i]
+ end := rowWidth
+ if i+1 < len(imagePaths) {
+ end = layout[i+1]
+ }
+ height := divRound(img.Bounds().Dy()*width*aspectRatio.x, img.Bounds().Dx()*aspectRatio.y)
+ offsetStart := start + (end-start-width)/2
+ dstBounds := image.Rect(offsetStart, (maxHeight-height)/2, offsetStart+width, (maxHeight-height)/2+height)
+ draw.BiLinear.Scale(result, dstBounds, img, img.Bounds(), draw.Src, nil)
+ }
+ return result, nil
+}
+
+func splitRows(imagePaths []string, metadata []imageMetadata, width, rowWidth int) [][]string {
+ var rows [][]string
+ for len(imagePaths) > 0 {
+ row := imagePaths[:0]
+ thisRowWidth := 0
+ for ; len(row) < len(imagePaths); row = row[:len(row)+1] {
+ nextW := max(width, runewidth.StringWidth(escapeString(filepath.Base(imagePaths[len(row)]))))
+ if *l {
+ nextW = max(nextW, len(metadata[len(row)].String()))
+ }
+ if len(row) > 0 {
+ nextW += 2
+ }
+ if thisRowWidth > 0 && thisRowWidth+nextW > rowWidth {
+ break
+ }
+ thisRowWidth += nextW
+ }
+ imagePaths = imagePaths[len(row):]
+ if *l {
+ metadata = metadata[len(row):]
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
+func thumbnailFiles(w io.Writer, imagePaths []string, width, rowWidth, cellX, cellY int) error {
+ padding := 2
+ aspectRatio := pixelAspectRatio{1, 1}
+ switch *m {
+ case modeBlock:
+ aspectRatio = pixelAspectRatio{cellX, cellY}
+ cellX = 1
+ case modeBlock24:
+ aspectRatio = pixelAspectRatio{2 * cellX, cellY}
+ cellX = 1
+ case modeSixel:
+ padding *= cellX
+ }
+ var metadata []imageMetadata
+ if *l {
+ metadata = make([]imageMetadata, len(imagePaths))
+ for i, filename := range imagePaths {
+ var err error
+ if metadata[i], err = readMetadata(filename); err != nil {
+ return err
+ }
+ }
+ }
+ rows := splitRows(imagePaths, metadata, width, rowWidth)
+ rowMetadata := make([][]imageMetadata, len(rows))
+ if *l {
+ for i, row := range rows {
+ rowMetadata[i] = metadata[:len(row)]
+ metadata = metadata[len(row):]
+ }
+ }
+ rowImages := make([]chan image.Image, len(rows))
+ for i := range rowImages {
+ rowImages[i] = make(chan image.Image, 1)
+ }
+ errCh := make(chan error, 1)
+ go func() {
+ sem := make(chan struct{}, 20)
+ for i, row := range rows {
+ sem <- struct{}{}
+ go func() {
+ defer func() { <-sem }()
+ img, err := compositeImageRow(row, rowMetadata[i], width*cellX, rowWidth*cellX, cellX, aspectRatio)
+ if err != nil {
+ select {
+ case errCh <- err:
+ default:
+ }
+ return
+ }
+ rowImages[i] <- img
+ }()
+ }
+ }()
+ for i, row := range rows {
+ var rowImage image.Image
+ select {
+ case err := <-errCh:
+ return err
+ case rowImage = <-rowImages[i]:
+ }
+ printImg(w, rowImage)
+ fmt.Fprintln(w)
+ layouts := layoutRow(row, rowMetadata[i], width*cellX, rowWidth*cellX, cellX)
+ realOffset := 0
+ for j, filename := range row {
+ start := divRound(layouts[j], cellX)
+ end := rowWidth
+ if j+1 < len(row) {
+ end = divRound(layouts[j+1], cellX)
+ }
+ fmt.Fprintf(w, "%*s", start-realOffset, "")
+ realOffset = start
+ fileNameWidth := runewidth.StringWidth(escapeString(filepath.Base(filename)))
+ padding := max((end-start-fileNameWidth)/2, 0)
+ fmt.Fprintf(w, "%*s%s", padding, "", escapeString(filepath.Base(filename)))
+ realOffset += padding + fileNameWidth
+ }
+ fmt.Fprintln(w)
+ if *l {
+ realOffset = 0
+ for j, metadata := range rowMetadata[i] {
+ start := divRound(layouts[j], cellX)
+ end := rowWidth
+ if j+1 < len(row) {
+ end = divRound(layouts[j+1], cellX)
+ }
+ fmt.Fprintf(w, "%*s", start-realOffset, "")
+ realOffset = start
+ details := metadata.String()
+ padding := max((end-start-len(details))/2, 0)
+ fmt.Fprintf(w, "%*s%s", padding, "", details)
+ realOffset += padding + len(details)
+ }
+ fmt.Fprintln(w)
+ }
+ }
+ return nil
+}
+
+func readDir(d string) ([]string, error) {
+ files, err := os.ReadDir(d)
+ if err != nil {
+ return nil, err
+ }
+ filenames := make([]string, len(files))
+ for i, f := range files {
+ filenames[i] = filepath.Join(d, f.Name())
+ }
+ return filenames, nil
+}
+
+func thumbnailDir(w io.Writer, dir string, width, rowWidth, cellX, cellY int) error {
+ files, err := readDir(dir)
+ if err != nil {
+ return err
+ }
+ files = slices.DeleteFunc(files, func(filename string) bool { return !isImage(filename) })
+ return thumbnailFiles(w, files, width, rowWidth, cellX, cellY)
+}
+
+func thumbnailer(args []string) error {
+ cols := *x
+ if *x <= 0 {
+ return fmt.Errorf("-x must be greater than 0")
+ }
+ ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
+ if err != nil {
+ return fmt.Errorf("terminal size: %s", err)
+ }
+ termCols := int(ws.Col)
+ cellX, cellY := int(ws.Xpixel)/int(ws.Col), int(ws.Ypixel)/int(ws.Row)
+
+ cols = min(cols, termCols)
+
+ var files, dirs []string
+ if len(args) == 0 {
+ dirs = []string{"."}
+ } else {
+ for _, arg := range args {
+ if isDir(arg) {
+ dirs = append(dirs, arg)
+ } else {
+ files = append(files, arg)
+ }
+ }
+ }
+ w := bufio.NewWriter(os.Stdout)
+ defer w.Flush()
+ if err := thumbnailFiles(w, files, cols, termCols, cellX, cellY); err != nil {
+ return err
+ }
+ if len(files) > 0 && len(dirs) > 0 {
+ fmt.Fprintln(w)
+ }
+ for i, d := range dirs {
+ if i > 0 {
+ fmt.Fprintln(w)
+ }
+ if len(dirs) > 1 {
+ fmt.Fprintf(w, "%s:\n", d)
+ }
+ if err := thumbnailDir(w, d, cols, termCols, cellX, cellY); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintf(os.Stderr, "Usage: thumbnailer [OPTION]... [FILE]...\n")
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ if err := thumbnailer(flag.Args()); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed: %s\n", err)
+ os.Exit(1)
+ }
+}