diff options
| author | Rose Hogenson <rosehogenson@posteo.net> | 2026-05-28 10:57:37 -0700 |
|---|---|---|
| committer | Rose Hogenson <rosehogenson@posteo.net> | 2026-05-28 10:57:37 -0700 |
| commit | b19ccbb3d2c3567444618b1aa8653868f74e5686 (patch) | |
| tree | 2ee9788c3dff4c9972ae45c5ffd89dbaa83330b8 | |
| parent | 93868f0af0db41c723edd03aae35f7957ddeadf0 (diff) | |
| download | imgutil-b19ccbb3d2c3567444618b1aa8653868f74e5686.tar.zst | |
Support sixel
| -rw-r--r-- | go.mod | 9 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | thumbnailer.go | 236 |
3 files changed, 169 insertions, 82 deletions
@@ -1,14 +1,11 @@ module roseh.moe/cmd/thumbnailer -go 1.24.3 +go 1.25.0 require ( github.com/mattn/go-runewidth v0.0.23 golang.org/x/image v0.28.0 - golang.org/x/term v0.32.0 + golang.org/x/sys v0.45.0 ) -require ( - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - golang.org/x/sys v0.33.0 // indirect -) +require github.com/clipperhouse/uax29/v2 v2.2.0 // indirect @@ -4,7 +4,5 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/thumbnailer.go b/thumbnailer.go index 78d393d..9045f94 100644 --- a/thumbnailer.go +++ b/thumbnailer.go @@ -7,6 +7,7 @@ import ( "fmt" "image" "image/color" + "image/color/palette" _ "image/gif" _ "image/jpeg" _ "image/png" @@ -22,12 +23,11 @@ import ( "golang.org/x/image/draw" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" - "golang.org/x/term" + "golang.org/x/sys/unix" ) var ( x = flag.Int("x", 15, "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'") ) @@ -186,94 +186,187 @@ func xtermColorCode(c color.Color) int { } } -func printImgXterm(img image.Image, cols, lines int) (string, int) { - scaled := image.NewRGBA(image.Rect(0, 0, cols, 2*lines)) - draw.BiLinear.Scale(scaled, scaled.Bounds(), img, img.Bounds(), draw.Over, nil) - paletted := image.NewPaletted(scaled.Bounds(), xtermPalette) - draw.FloydSteinberg.Draw(paletted, paletted.Bounds(), scaled, image.Point{}) - - s := new(strings.Builder) - for y := 0; y < 2*lines; y += 2 { +func printImgXterm(w io.Writer, img image.Image) { + for y := 0; y < img.Bounds().Dy(); y += 2 { if y > 0 { - fmt.Fprintln(s) + fmt.Fprintln(w) } - for x := 0; x < cols; x++ { - hi := xtermColorCode(paletted.At(x, y)) - lo := xtermColorCode(paletted.At(x, y+1)) - fmt.Fprintf(s, "\033[%dm\033[%dm▀", 30+hi, 40+lo) + for x := 0; x < img.Bounds().Dx(); x++ { + hi := img.At(x, y) + if hi == color.Transparent { + if x > 0 && img.At(x-1, y) != color.Transparent { + fmt.Fprint(w, "\033[49m") + } + fmt.Fprint(w, " ") + } else { + lo := 9 + if y+1 < img.Bounds().Dy() && img.At(x, y+1) != color.Transparent { + lo = xtermColorCode(img.At(x, y+1)) + } + fmt.Fprintf(w, "\033[%dm\033[%dm▀", 30+xtermColorCode(hi), 40+lo) + } } - fmt.Fprint(s, "\033[39m\033[49m") + fmt.Fprint(w, "\033[39m\033[49m") } - return s.String(), cols } -func printImg(img image.Image, cols, lines int) (string, int) { - // Try not to stretch the image. - if lines <= 0 || img.Bounds().Dy()*cols <= img.Bounds().Dx()*lines*5/2 { - lines = img.Bounds().Dy() * cols / (img.Bounds().Dx() * 5 / 2) - } else { - cols = img.Bounds().Dx() * lines * 5 / 2 / img.Bounds().Dy() - } - if lines == 0 || cols == 0 { - return "", 0 +func printImgBlock24(w io.Writer, img image.Image) { + for y := 0; y < img.Bounds().Dy(); y += 2 { + if y > 0 { + fmt.Fprintln(w) + } + for x := 0; x < img.Bounds().Dx(); x++ { + hi := img.At(x, y) + if hi == color.Transparent { + if x > 0 && img.At(x-1, y) != color.Transparent { + fmt.Fprint(w, "\033[49m") + } + fmt.Fprint(w, " ") + } else { + if y+1 < img.Bounds().Dy() && img.At(x, y+1) != color.Transparent { + r, g, b, _ := img.At(x, y+1).RGBA() + fmt.Fprintf(w, "\033[48;2;%d;%d;%dm", r>>8, g>>8, b>>8) + } else { + fmt.Fprint(w, "\033[49m") + } + r, g, b, _ := hi.RGBA() + fmt.Fprintf(w, "\033[38;2;%d;%d;%dm▀", r>>8, g>>8, b>>8) + } + } + fmt.Fprint(w, "\033[39m\033[49m") } +} - if *m == modeBlock { - return printImgXterm(img, cols, lines) +func makePlan9ColorMap() map[color.Color]int { + m := make(map[color.Color]int) + for i, c := range palette.Plan9 { + m[c] = i } + return m +} - dst := image.NewRGBA(image.Rect(0, 0, cols, 2*lines)) - draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) +var plan9ColorMap = makePlan9ColorMap() - s := new(strings.Builder) - for y := 0; y < 2*lines; y += 2 { +func scale100(c uint32) int { + return int(c) * 100 / 0xffff +} + +func printImgSixel(w io.Writer, img image.Image) { + fmt.Fprint(w, "\033P7;1q") + for i, c := range palette.Plan9 { + r, g, b, _ := c.RGBA() + fmt.Fprintf(w, "#%d;2;%d;%d;%d", i, scale100(r), scale100(g), scale100(b)) + } + for y := 0; y < img.Bounds().Dy(); y++ { if y > 0 { - fmt.Fprintln(s) + if y%6 == 0 { + fmt.Fprint(w, "-") + } else { + fmt.Fprint(w, "$") + } } - for x := 0; x < cols; x++ { - hiR, hiG, hiB, _ := dst.At(x, y).RGBA() - loR, loG, loB, _ := dst.At(x, y+1).RGBA() - fmt.Fprintf(s, "\033[38;2;%d;%d;%dm\033[48;2;%d;%d;%dm▀", - hiR>>8, hiG>>8, hiB>>8, - loR>>8, loG>>8, loB>>8) + for x := 0; x < img.Bounds().Dx(); x++ { + if img.At(x, y) == color.Transparent { + fmt.Fprint(w, "?") + } else { + char := '?' + 1<<(y%6) + fmt.Fprintf(w, "#%d%c", plan9ColorMap[img.At(x, y)], char) + } } - fmt.Fprint(s, "\033[39m\033[49m") } - return s.String(), cols + fmt.Fprint(w, "\033\\") +} + +func printImg(w io.Writer, img image.Image) { + switch *m { + case modeBlock: + printImgXterm(w, img) + case modeBlock24: + printImgBlock24(w, img) + case modeSixel: + printImgSixel(w, img) + default: + panic("invalid mode") + } +} + +type pixelAspectRatio struct { + x, y int +} + +func compositeImageRow(images []image.Image, width, padding int, aspectRatio pixelAspectRatio, palette color.Palette) image.Image { + resultWidth := width*len(images) + padding*(len(images)-1) + maxHeight := 0 + for _, img := range images { + maxHeight = max(maxHeight, img.Bounds().Dy()*width/(img.Bounds().Dx()*aspectRatio.y/aspectRatio.x)) + } + bounds := image.Rect(0, 0, resultWidth, maxHeight) + var result draw.Image + if len(palette) > 0 { + result = image.NewPaletted(bounds, append([]color.Color{color.Transparent}, palette...)) + } else { + result = image.NewRGBA(bounds) + } + offset := 0 + for _, img := range images { + 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) + if len(palette) > 0 { + tmp := image.NewRGBA(image.Rect(0, 0, width, height)) + draw.BiLinear.Scale(tmp, tmp.Bounds(), img, img.Bounds(), draw.Src, nil) + draw.FloydSteinberg.Draw(result, dstBounds, tmp, image.Point{}) + } else { + draw.BiLinear.Scale(result, dstBounds, img, img.Bounds(), draw.Src, nil) + } + offset += width + padding + } + return result } type img struct { - s string - width int + i image.Image filename string } -func compositeImages(w io.Writer, images []img, imageWidth, imagesPerRow int) { +func compositeImages(w io.Writer, images []img, width, imagesPerRow, cellX, cellY int) { + imageWidth := width + padding := 1 + var colorPalette color.Palette + aspectRatio := pixelAspectRatio{1, 1} + switch *m { + case modeBlock: + colorPalette = xtermPalette + aspectRatio = pixelAspectRatio{2 * cellX, cellY} + case modeBlock24: + aspectRatio = pixelAspectRatio{2 * cellX, cellY} + case modeSixel: + imageWidth = width * cellX + padding = cellX + colorPalette = palette.Plan9 + } for row := range slices.Chunk(images, imagesPerRow) { - rowLines := make([][]string, len(row)) - names := make([][]string, len(row)) + images := make([]image.Image, len(row)) for i, img := range row { - rowLines[i] = strings.Split(img.s, "\n") - names[i] = strings.Split(runewidth.Wrap(img.filename, imageWidth), "\n") + images[i] = img.i } + rowImage := compositeImageRow(images, imageWidth, padding, aspectRatio, colorPalette) + printImg(w, rowImage) + names := make([][]string, len(row)) maxLen := 0 - for imgIdx, img := range rowLines { - maxLen = max(maxLen, len(img)+len(names[imgIdx])) + for i, img := range row { + names[i] = strings.Split(runewidth.Wrap(img.filename, width), "\n") + maxLen = max(maxLen, len(names[i])) } for i := 0; i < maxLen; i++ { - if i > 0 { - fmt.Fprintln(w) - } - for imgIdx, img := range rowLines { + fmt.Fprintln(w) + for imgIdx, name := range names { if imgIdx > 0 { fmt.Fprint(w, " ") } - if i < len(img) { - fmt.Fprintf(w, "%s%*s", img[i], max(imageWidth-row[imgIdx].width, 0), "") - } else if i < len(img)+len(names[imgIdx]) { - fmt.Fprintf(w, "%s", runewidth.FillRight(names[imgIdx][i-len(img)], imageWidth)) + if i < len(name) { + fmt.Fprintf(w, "%s", runewidth.FillRight(name[i], width)) } else { - fmt.Fprintf(w, "%*s", imageWidth, "") + fmt.Fprintf(w, "%*s", width, "") } } } @@ -281,17 +374,16 @@ func compositeImages(w io.Writer, images []img, imageWidth, imagesPerRow int) { } } -func thumbnailFiles(w io.Writer, cols, lines, imagesPerRow int, args []string) error { +func thumbnailFiles(w io.Writer, args []string, width, imagesPerRow, cellX, cellY int) error { images := make([]img, len(args)) for i, arg := range args { image, err := load(arg) if err != nil { return err } - imageString, width := printImg(image, cols, lines) - images[i] = img{imageString, width, filepath.Base(arg)} + images[i] = img{image, filepath.Base(arg)} } - compositeImages(w, images, cols, imagesPerRow) + compositeImages(w, images, width, imagesPerRow, cellX, cellY) return nil } @@ -307,7 +399,7 @@ func readDir(d string) ([]string, error) { return filenames, nil } -func thumbnailDir(w io.Writer, cols, lines, imagesPerRow int, dir string) error { +func thumbnailDir(w io.Writer, dir string, width, imagesPerRow, cellX, cellY int) error { files, err := readDir(dir) if err != nil { return err @@ -321,23 +413,23 @@ func thumbnailDir(w io.Writer, cols, lines, imagesPerRow int, dir string) error } return err } - imageString, width := printImg(image, cols, lines) - images = append(images, img{imageString, width, filepath.Base(file)}) + images = append(images, img{image, filepath.Base(file)}) } - compositeImages(w, images, cols, imagesPerRow) + compositeImages(w, images, width, imagesPerRow, cellX, cellY) return nil } func thumbnailer(args []string) error { cols := *x - lines := *y - if cols <= 0 { + if *x <= 0 { return fmt.Errorf("-x must be greater than 0") } - termCols, _, err := term.GetSize(1) + 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) imagesPerRow := termCols / (cols + 1) @@ -359,7 +451,7 @@ func thumbnailer(args []string) error { } w := bufio.NewWriter(os.Stdout) defer w.Flush() - if err := thumbnailFiles(w, cols, lines, imagesPerRow, files); err != nil { + if err := thumbnailFiles(w, files, cols, imagesPerRow, cellX, cellY); err != nil { return err } if len(files) > 0 && len(dirs) > 0 { @@ -372,7 +464,7 @@ func thumbnailer(args []string) error { if len(dirs) > 1 { fmt.Fprintf(w, "%s:\n", d) } - if err := thumbnailDir(w, cols, lines, imagesPerRow, d); err != nil { + if err := thumbnailDir(w, d, cols, imagesPerRow, cellX, cellY); err != nil { return err } } |
