152 lines
5.2 KiB
Go
152 lines
5.2 KiB
Go
package asciigen
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
"math"
|
||
"strings"
|
||
|
||
"golang.org/x/image/draw"
|
||
)
|
||
|
||
const previewWidth = 60 // ширина превью в символах, независимо от финального размера
|
||
|
||
// ImageRenderer реализует Renderer для картинок.
|
||
type ImageRenderer struct {
|
||
src image.Image
|
||
}
|
||
|
||
func NewImageRenderer(src image.Image) *ImageRenderer {
|
||
return &ImageRenderer{src: src}
|
||
}
|
||
|
||
func (r *ImageRenderer) Previews() ([]Variant, error) {
|
||
presets := defaultImagePresets()
|
||
variants := make([]Variant, 0, len(presets))
|
||
|
||
for id, opts := range presets {
|
||
previewOpts := opts
|
||
previewOpts.Width = previewWidth
|
||
ascii, err := r.render(previewOpts)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("превью %q: %w", id, err)
|
||
}
|
||
variants = append(variants, Variant{
|
||
ID: id,
|
||
Label: labelForImagePreset(id),
|
||
Preview: ascii,
|
||
})
|
||
}
|
||
return variants, nil
|
||
}
|
||
|
||
func (r *ImageRenderer) Full(variantID string) (string, error) {
|
||
presets := defaultImagePresets()
|
||
opts, ok := presets[variantID]
|
||
if !ok {
|
||
return "", fmt.Errorf("неизвестный вариант: %q", variantID)
|
||
}
|
||
// Полный размер — либо оригинальное разрешение картинки в символах,
|
||
// либо разумный кап (напр. 200 символов), чтобы не рендерить гигантские строки.
|
||
opts.Width = fullWidthFor(r.src)
|
||
return r.render(opts)
|
||
}
|
||
|
||
func fullWidthFor(img image.Image) int {
|
||
w := img.Bounds().Dx()
|
||
const cap = 200
|
||
if w > cap {
|
||
return cap
|
||
}
|
||
return w
|
||
}
|
||
|
||
// render — основной конвейер: ресайз -> яркость -> символ палитры -> (опц.) ANSI-цвет.
|
||
func (r *ImageRenderer) render(opts ImageOptions) (string, error) {
|
||
if opts.Width <= 0 {
|
||
return "", fmt.Errorf("render: width должен быть > 0, получено %d", opts.Width)
|
||
}
|
||
if len(opts.Palette) == 0 {
|
||
return "", fmt.Errorf("render: пустая палитра")
|
||
}
|
||
gamma := opts.Gamma
|
||
if gamma <= 0 {
|
||
gamma = 1.0 // 0 трактуем как "не задано", чтобы не делить на ноль
|
||
}
|
||
|
||
resized := resize(r.src, opts.Width, opts.Height)
|
||
bounds := resized.Bounds()
|
||
|
||
var out strings.Builder
|
||
// Небольшой запас под ANSI-коды, чтобы не было лишних реаллокаций.
|
||
out.Grow(bounds.Dy() * bounds.Dx() * 8)
|
||
|
||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||
rr, gg, bb, _ := resized.At(x, y).RGBA()
|
||
// RGBA() отдаёт значения в диапазоне [0, 65535] — приводим к [0, 255].
|
||
r8, g8, b8 := rr>>8, gg>>8, bb>>8
|
||
|
||
ch := brightnessToChar(r8, g8, b8, opts.Palette, gamma, opts.Invert)
|
||
|
||
if opts.Color {
|
||
out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm%c\x1b[0m", r8, g8, b8, ch))
|
||
} else {
|
||
out.WriteRune(ch)
|
||
}
|
||
}
|
||
out.WriteByte('\n')
|
||
}
|
||
|
||
return out.String(), nil
|
||
}
|
||
|
||
// brightnessToChar переводит яркость пикселя в символ палитры с учётом гаммы и инверсии.
|
||
func brightnessToChar(r8, g8, b8 uint32, palette Palette, gamma float64, invert bool) rune {
|
||
// Стандартная формула яркости (luma), учитывает разную чувствительность глаза к цветам.
|
||
luma := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8)
|
||
normalized := luma / 255.0 // теперь в диапазоне [0, 1]
|
||
|
||
// Гамма-коррекция: gamma > 1 делает тени темнее (больше контраста),
|
||
// gamma < 1 — светлее (больше деталей в тенях).
|
||
corrected := math.Pow(normalized, gamma)
|
||
|
||
if invert {
|
||
corrected = 1.0 - corrected
|
||
}
|
||
|
||
chars := []rune(palette)
|
||
// corrected=0 (тёмный пиксель) -> первый символ палитры (обычно самый "плотный", @/#).
|
||
// corrected=1 (светлый пиксель) -> последний символ (обычно пробел).
|
||
idx := int(corrected * float64(len(chars)-1))
|
||
if idx < 0 {
|
||
idx = 0
|
||
}
|
||
if idx >= len(chars) {
|
||
idx = len(chars) - 1
|
||
}
|
||
return chars[idx]
|
||
}
|
||
|
||
// resize — уменьшает картинку с учётом того, что символ терминала выше, чем шире (~2:1).
|
||
func resize(src image.Image, width, height int) image.Image {
|
||
if height == 0 {
|
||
b := src.Bounds()
|
||
aspect := float64(b.Dy()) / float64(b.Dx())
|
||
height = int(float64(width) * aspect * 0.5) // компенсация формы символа
|
||
}
|
||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
|
||
return dst
|
||
}
|
||
|
||
func labelForImagePreset(id string) string {
|
||
labels := map[string]string{
|
||
"blocks-standard": "Блочная палитра, стандартный контраст",
|
||
"blocks-inverted": "Блочная палитра, инверсия",
|
||
"detailed": "Расширенная градация полутонов",
|
||
"high-contrast": "Высокий контраст",
|
||
"minimal": "Минимальная палитра (2 символа)",
|
||
}
|
||
return labels[id]
|
||
}
|