1
0
Fork 0
mirror of https://github.com/imjasonh/dots synced 2026-07-07 00:22:55 +00:00
dots/braille.go
Jason Hall b3157c7007 add -frame
Signed-off-by: Jason Hall <jason@chainguard.dev>
2025-11-14 14:22:20 -05:00

340 lines
9.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dots
import (
"fmt"
"image"
"image/color"
"os"
"golang.org/x/image/draw"
"golang.org/x/term"
)
// getTerminalSize returns the current terminal dimensions.
// Returns 80x24 as fallback if terminal size cannot be determined.
func getTerminalSize() (int, int) {
// Try to get terminal size from stdout
if fd := int(os.Stdout.Fd()); term.IsTerminal(fd) {
if width, height, err := term.GetSize(fd); err == nil {
return width, height
}
}
// Fallback to reasonable defaults
return 80, 24
}
// Options configures the braille conversion.
type Options struct {
Width int // Width in braille characters
Height int // Height in braille characters
Threshold uint8 // Brightness threshold (0-255), default 20
NoColor bool // Disable ANSI color output
BackgroundColor *uint8 // Background color for ANSI output (nil = no background)
Frame bool // Draw a white ASCII frame around the picture
}
// CalculateDimensions calculates output dimensions maintaining aspect ratio.
// If both width and height are specified, returns them unchanged.
// If only width is specified, calculates height from image aspect ratio.
// If only height is specified, calculates width from image aspect ratio.
// If neither is specified, uses maxWidth and maxHeight as constraints while maintaining aspect ratio.
//
// The calculation accounts for braille characters being 2 pixels wide × 4 pixels tall.
func CalculateDimensions(imgWidth, imgHeight, width, height, maxWidth, maxHeight int) (int, int) {
if width > 0 && height > 0 {
// Both specified, use as-is
return width, height
}
if width > 0 && height == 0 {
// Only width specified, calculate height to maintain aspect ratio
// width chars = width*2 pixels wide
// To maintain aspect: height pixels = width*2 * (imgHeight/imgWidth)
// height chars = height pixels / 4 = width*2*(imgHeight/imgWidth)/4 = width*imgHeight/imgWidth/2
height = int(float64(width) * float64(imgHeight) / float64(imgWidth) / 2.0)
if height == 0 {
height = 1
}
return width, height
}
if height > 0 && width == 0 {
// Only height specified, calculate width to maintain aspect ratio
// height chars = height*4 pixels tall
// To maintain aspect: width pixels = height*4 * (imgWidth/imgHeight)
// width chars = width pixels / 2 = height*4*(imgWidth/imgHeight)/2 = height*imgWidth/imgHeight*2
width = int(float64(height) * float64(imgWidth) / float64(imgHeight) * 2.0)
if width == 0 {
width = 1
}
return width, height
}
// Neither specified - use maxWidth/maxHeight as constraints and maintain aspect ratio
if maxWidth > 0 && maxHeight > 0 {
// Calculate what dimensions would be if we used maxWidth
widthConstrained := maxWidth
heightForWidth := int(float64(widthConstrained) * float64(imgHeight) / float64(imgWidth) / 2.0)
// Calculate what dimensions would be if we used maxHeight
heightConstrained := maxHeight
widthForHeight := int(float64(heightConstrained) * float64(imgWidth) / float64(imgHeight) * 2.0)
// Use whichever fits within both constraints
if heightForWidth <= maxHeight {
// Width-constrained version fits
return widthConstrained, heightForWidth
}
// Height-constrained version fits
return widthForHeight, heightConstrained
}
// No constraints at all
return 0, 0
}
// Convert converts an image to braille representation.
// Returns a slice of strings, one per line of output.
func Convert(img image.Image, opts Options) []string {
// Set defaults
if opts.Threshold == 0 {
opts.Threshold = 20
}
// Respect NO_COLOR environment variable
if os.Getenv("NO_COLOR") != "" {
opts.NoColor = true
}
// Calculate dimensions if not both specified
if opts.Width == 0 || opts.Height == 0 {
bounds := img.Bounds()
imgWidth := bounds.Dx()
imgHeight := bounds.Dy()
// Get terminal dimensions as constraints
termWidth, termHeight := getTerminalSize()
// If frame is enabled, reduce available space by 2 (1 on each side)
if opts.Frame {
termWidth -= 2
termHeight -= 2
}
// CalculateDimensions handles all cases:
// - Both zero: uses terminal as constraint with aspect ratio
// - Only width: calculates height from aspect
// - Only height: calculates width from aspect
opts.Width, opts.Height = CalculateDimensions(imgWidth, imgHeight, opts.Width, opts.Height, termWidth, termHeight)
} else if opts.Frame {
// If dimensions were explicitly specified, reduce them for the frame
opts.Width -= 2
opts.Height -= 2
if opts.Width < 1 {
opts.Width = 1
}
if opts.Height < 1 {
opts.Height = 1
}
}
// Step 1: Spatial quantization - resize to target dimensions
// Each braille char is 2 pixels wide × 4 pixels tall
targetWidth := opts.Width * 2
targetHeight := opts.Height * 4
resized := resize(img, targetWidth, targetHeight)
// Step 2 & 3: Brightness and color quantization
brailleLines := make([]string, opts.Height)
for row := 0; row < opts.Height; row++ {
line := ""
for col := 0; col < opts.Width; col++ {
// Extract 2×4 pixel block
x0, y0 := col*2, row*4
block := extractBlock(resized, x0, y0)
// Brightness quantization: convert to braille character
char := blockToBraille(block, opts.Threshold)
// Color quantization: get ANSI color codes
if !opts.NoColor {
fgColor := blockToANSI(block)
if opts.BackgroundColor != nil {
line += ansiFgBgColor(fgColor, *opts.BackgroundColor) + string(char) + ansiReset()
} else {
line += ansiFgColor(fgColor) + string(char) + ansiReset()
}
} else {
line += string(char)
}
}
brailleLines[row] = line
}
// Add frame if requested
if opts.Frame {
return addFrame(brailleLines, opts.NoColor)
}
return brailleLines
}
// resize scales an image to the target dimensions using high-quality interpolation.
func resize(img image.Image, width, height int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
return dst
}
// extractBlock extracts a 2×4 pixel block from an image at the given position.
func extractBlock(img *image.RGBA, x0, y0 int) [8]color.Color {
var block [8]color.Color
bounds := img.Bounds()
// Standard braille dot numbering:
// 0 3 (pixels at x0, x0+1)
// 1 4 (rows y0, y0+1, y0+2, y0+3)
// 2 5
// 6 7
positions := [][2]int{
{x0, y0}, {x0, y0 + 1}, {x0, y0 + 2}, {x0 + 1, y0},
{x0, y0 + 3}, {x0 + 1, y0 + 1}, {x0 + 1, y0 + 2}, {x0 + 1, y0 + 3},
}
for i, pos := range positions {
x, y := pos[0], pos[1]
if x < bounds.Max.X && y < bounds.Max.Y {
block[i] = img.At(x, y)
} else {
block[i] = color.Black
}
}
return block
}
// blockToBraille converts a 2×4 pixel block to a braille character.
// Each pixel's brightness is compared to the threshold to determine if the dot is on.
func blockToBraille(block [8]color.Color, threshold uint8) rune {
var pattern uint8
for i, c := range block {
// Convert to grayscale using perceived luminance
r, g, b, _ := c.RGBA()
// RGBA() returns values in [0, 65535], convert to [0, 255]
r8, g8, b8 := uint8(r>>8), uint8(g>>8), uint8(b>>8)
luminance := uint8(0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8))
// Apply threshold: bright pixels turn on dots
if luminance > threshold {
pattern |= (1 << i)
}
}
// Unicode braille pattern base is U+2800
return rune(0x2800 + int(pattern))
}
// blockToANSI determines the dominant color of a block and returns the nearest ANSI 256 color code.
func blockToANSI(block [8]color.Color) uint8 {
// Calculate average color of the block
var rSum, gSum, bSum uint32
for _, c := range block {
r, g, b, _ := c.RGBA()
rSum += r
gSum += g
bSum += b
}
// Average and convert to 8-bit
r := uint8((rSum / 8) >> 8)
g := uint8((gSum / 8) >> 8)
b := uint8((bSum / 8) >> 8)
return quantizeRGB(r, g, b)
}
// ansiFgColor returns the ANSI escape sequence to set foreground color.
func ansiFgColor(code uint8) string {
return fmt.Sprintf("\x1b[38;5;%dm", code)
}
// ansiFgBgColor returns the ANSI escape sequence to set both foreground and background colors.
func ansiFgBgColor(fgCode, bgCode uint8) string {
return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", fgCode, bgCode)
}
// ansiReset returns the ANSI escape sequence to reset colors.
func ansiReset() string {
return "\x1b[0m"
}
// addFrame wraps the braille lines with a white ASCII frame.
func addFrame(lines []string, noColor bool) []string {
if len(lines) == 0 {
return lines
}
// Calculate the width of the content (without ANSI codes if present)
width := 0
if !noColor {
// Count visible characters by stripping ANSI codes
width = visibleWidth(lines[0])
} else {
width = len(lines[0])
}
// White color code (for frame)
whiteColor := "\x1b[38;5;15m"
reset := ""
if !noColor {
reset = ansiReset()
} else {
whiteColor = ""
}
// Build the frame
result := make([]string, len(lines)+2)
// Top border
result[0] = whiteColor + "┌" + repeatString("─", width) + "┐" + reset
// Content with side borders
for i, line := range lines {
result[i+1] = whiteColor + "│" + reset + line + whiteColor + "│" + reset
}
// Bottom border
result[len(result)-1] = whiteColor + "└" + repeatString("─", width) + "┘" + reset
return result
}
// visibleWidth counts the visible characters in a string, ignoring ANSI escape codes.
func visibleWidth(s string) int {
width := 0
inEscape := false
for _, r := range s {
if r == '\x1b' {
inEscape = true
} else if inEscape {
if r == 'm' {
inEscape = false
}
} else {
width++
}
}
return width
}
// repeatString repeats a string n times.
func repeatString(s string, n int) string {
result := ""
for range n {
result += s
}
return result
}