commit ca1c87a822f3f7b138b8b7dd8df4200e478de77f
parent 8f5eaf7d60618af21903edca4a4a78383b94895c
Author: Damian Myrda <damian@prime8.dev>
Date: Thu, 22 Oct 2026 22:16:00 +0000
web: half-block truecolor portrait in the terminal
Diffstat:
2 files changed, 55 insertions(+), 15 deletions(-)
diff --git a/go.mod b/go.mod
@@ -17,6 +17,8 @@ require (
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894
github.com/charmbracelet/wish v1.4.7
github.com/charmbracelet/x/ansi v0.10.1
+ github.com/disintegration/imaging v1.6.2
+ github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75
github.com/evanw/esbuild v0.27.4
github.com/joho/godotenv v1.5.1
github.com/muesli/termenv v0.16.0
@@ -41,9 +43,7 @@ require (
github.com/charmbracelet/x/windows v0.2.0 // indirect
github.com/creack/pty v1.1.21 // indirect
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
- github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
- github.com/eliukblau/pixterm/pkg/ansimage v0.0.0-20191210081756-9fb6cf8c2f75 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.9.0 // indirect
github.com/fogleman/gg v1.3.0 // indirect
diff --git a/internal/utils/logo.go b/internal/utils/logo.go
@@ -1,35 +1,75 @@
package utils
import (
+ "fmt"
+ "image"
"path/filepath"
"strings"
"sync"
aic "github.com/TheZoraiz/ascii-image-converter/aic_package"
+ "github.com/disintegration/imaging"
)
var logoMu sync.Mutex
-// LogoASCII renders static/me.jpg as ascii art. aic refuses color when the
-// server's env lacks COLORTERM, so it falls back to plain ascii.
-func LogoASCII(width int, colored bool) string {
- flags := aic.DefaultFlags()
- flags.Width = width
- flags.Colored = colored
- flags.Dither = true
- flags.Threshold = 160
+const logoPath = "static/me.jpg"
+// logoCrop frames the face: fractions of the source image (x0, y0, x1, y1).
+var logoCrop = [4]float64{0.12, 0.08, 0.88, 0.84}
+
+// LogoASCII renders static/me.jpg for terminals. Colored output uses
+// half-block cells (upper half = foreground, lower half = background), so each
+// cell carries two truecolor pixels and the portrait reads as a photo. Plain
+// output falls back to ascii art.
+func LogoASCII(width int, colored bool) string {
logoMu.Lock()
defer logoMu.Unlock()
- path := filepath.Join("static", "me.jpg")
- ascii, err := aic.Convert(path, flags)
- if err != nil && colored {
- flags.Colored = false
- ascii, err = aic.Convert(path, flags)
+ if colored {
+ if s, err := halfBlocks(logoPath, width); err == nil {
+ return s
+ }
}
+ flags := aic.DefaultFlags()
+ flags.Width = width
+ flags.Dither = true
+ flags.Threshold = 160
+ ascii, err := aic.Convert(filepath.FromSlash(logoPath), flags)
if err != nil {
return ""
}
return strings.TrimRight(ascii, "\n")
}
+
+func halfBlocks(path string, width int) (string, error) {
+ src, err := imaging.Open(path, imaging.AutoOrientation(true))
+ if err != nil {
+ return "", err
+ }
+ b := src.Bounds()
+ w, h := float64(b.Dx()), float64(b.Dy())
+ rect := image.Rect(
+ b.Min.X+int(logoCrop[0]*w), b.Min.Y+int(logoCrop[1]*h),
+ b.Min.X+int(logoCrop[2]*w), b.Min.Y+int(logoCrop[3]*h),
+ )
+ crop := imaging.Crop(src, rect)
+ // one cell is roughly twice as tall as wide, so two pixel rows per cell
+ // keep the pixels square
+ rows := int(float64(width)*float64(rect.Dy())/float64(rect.Dx())+0.5) &^ 1
+ img := imaging.Resize(crop, width, rows, imaging.Lanczos)
+
+ var sb strings.Builder
+ for y := 0; y < rows; y += 2 {
+ if y > 0 {
+ sb.WriteByte('\n')
+ }
+ for x := 0; x < width; x++ {
+ t := img.NRGBAAt(x, y)
+ u := img.NRGBAAt(x, y+1)
+ fmt.Fprintf(&sb, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dmâ–€", t.R, t.G, t.B, u.R, u.G, u.B)
+ }
+ sb.WriteString("\x1b[0m")
+ }
+ return sb.String(), nil
+}