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] }