package main import ( "bufio" "flag" "fmt" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "os" "path/filepath" "slices" "strings" 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") 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 } func imageDimensions(filename string) (width, height int, err error) { f, err := os.Open(filename) if err != nil { return 0, 0, err } defer f.Close() config, _, err := image.DecodeConfig(f) return config.Width, 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") } } type pixelAspectRatio struct { x, y int } func compositeImageRow(imagePaths []string, width, padding int, aspectRatio pixelAspectRatio) (image.Image, error) { resultWidth := width*len(imagePaths) + padding*(len(imagePaths)-1) maxHeight := 0 for _, filename := range imagePaths { imageWidth, imageHeight, err := imageDimensions(filename) if err != nil { return nil, err } maxHeight = max(maxHeight, imageHeight*width/(imageWidth*aspectRatio.y/aspectRatio.x)) } bounds := image.Rect(0, 0, resultWidth, maxHeight) result := image.NewRGBA(bounds) offset := 0 for _, filename := range imagePaths { img, err := load(filename) if err != nil { return nil, err } height := img.Bounds().Dy() * width / (img.Bounds().Dx() * aspectRatio.y / aspectRatio.x) dstBounds := image.Rect(offset, (maxHeight-height)/2, offset+width, (maxHeight-height)/2+height) draw.BiLinear.Scale(result, dstBounds, img, img.Bounds(), draw.Src, nil) offset += width + padding } return result, nil } func thumbnailFiles(w io.Writer, imagePaths []string, width, imagesPerRow, cellX, cellY int) error { imageWidth := width padding := 1 aspectRatio := pixelAspectRatio{1, 1} switch *m { case modeBlock: aspectRatio = pixelAspectRatio{cellX, cellY} case modeBlock24: aspectRatio = pixelAspectRatio{2 * cellX, cellY} case modeSixel: imageWidth = width * cellX padding = cellX } for row := range slices.Chunk(imagePaths, imagesPerRow) { rowImage, err := compositeImageRow(row, imageWidth, padding, aspectRatio) if err != nil { return err } printImg(w, rowImage) names := make([][]string, len(row)) maxLen := 0 for i, filename := range row { names[i] = strings.Split(runewidth.Wrap(filepath.Base(filename), width), "\n") maxLen = max(maxLen, len(names[i])) } for i := 0; i < maxLen; i++ { fmt.Fprintln(w) for imgIdx, name := range names { if imgIdx > 0 { fmt.Fprint(w, " ") } if i < len(name) { fmt.Fprintf(w, "%s", runewidth.FillRight(name[i], width)) } else { fmt.Fprintf(w, "%*s", width, "") } } } 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, imagesPerRow, 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, imagesPerRow, 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) imagesPerRow := (termCols + 1) / (cols + 1) 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, imagesPerRow, 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, imagesPerRow, 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) } }