aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--go.mod9
-rw-r--r--go.sum6
-rw-r--r--thumbnailer.go236
3 files changed, 169 insertions, 82 deletions
diff --git a/go.mod b/go.mod
index 2e358c5..ecbd1d1 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 82bd425..d525430 100644
--- a/go.sum
+++ b/go.sum
@@ -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
}
}