V 1.0.0
This commit is contained in:
parent
43e347d530
commit
1ebc404ceb
10 changed files with 663 additions and 0 deletions
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Binaries
|
||||
asciigen
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Credentials
|
||||
*.env
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Config with sensitive data
|
||||
config.yaml
|
||||
config.yml
|
||||
*.local.yaml
|
||||
*.local.yml
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
109
ARCHITECTURE.md
Normal file
109
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Архитектура
|
||||
|
||||
## Идея
|
||||
|
||||
Три независимых слоя, не знающих друг о друге:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
|
||||
│ CLI/main │ --> │ asciigen (core) │ <-- │ tui │
|
||||
└─────────────┘ └──────────────────┘ └─────────────┘
|
||||
знает о обоих не знает не знает
|
||||
ни о CLI, ничего, кроме
|
||||
ни о TUI интерфейса Renderer
|
||||
```
|
||||
|
||||
- **`asciigen` (корневой пакет)** — вся логика рендера. Ничего не знает
|
||||
про терминал, ввод/вывод, флаги. Чистые функции + два типа-рендерера.
|
||||
- **`tui`** — работает только с интерфейсом `asciigen.Renderer`. Не знает,
|
||||
картинка перед ним или текст — поэтому один и тот же экран выбора
|
||||
обслуживает оба сценария без дублирования кода.
|
||||
- **`cmd/asciigen`** — единственное место, которое "склеивает" всё вместе:
|
||||
парсит аргументы, решает какой Renderer создать, передаёт его в TUI.
|
||||
|
||||
Такое разделение даёт две вещи, ради которых это всё затевалось:
|
||||
1. Ядро можно импортировать в другой Go-проект (auspex, rimefrost) как
|
||||
библиотеку, вообще не трогая TUI/CLI.
|
||||
2. TUI не придётся переписывать, если добавится третий вид источника
|
||||
(например, ASCII-арт из ANSI-артов или QR-кодов) — он просто реализует
|
||||
`Renderer`.
|
||||
|
||||
## Поток данных: картинка → ASCII
|
||||
|
||||
```
|
||||
файл (png/jpg)
|
||||
│ image.Decode() [cmd/asciigen/main.go]
|
||||
▼
|
||||
image.Image (декодированная картинка)
|
||||
│ NewImageRenderer(img)
|
||||
▼
|
||||
ImageRenderer.Previews()
|
||||
│ для каждого пресета из defaultImagePresets():
|
||||
│ 1. resize() — уменьшить до previewWidth=60,
|
||||
│ высота домножается на 0.5 (компенсация
|
||||
│ формы символа терминала, он ~2:1)
|
||||
│ 2. render():
|
||||
│ for each pixel:
|
||||
│ a. RGBA() → r8,g8,b8 (0..255)
|
||||
│ b. luma = 0.299R + 0.587G + 0.114B (яркость по глазу)
|
||||
│ c. gamma-коррекция: corrected = luma^gamma
|
||||
│ d. invert (если задан)
|
||||
│ e. индекс в палитре = corrected * (len(palette)-1)
|
||||
│ f. если opts.Color — обернуть символ в ANSI \x1b[38;2;R;G;Bm
|
||||
▼
|
||||
[]Variant{ID, Label, Preview} — 5 готовых превью
|
||||
│ TUI показывает список, пользователь листает ↑/↓
|
||||
▼
|
||||
Enter → ImageRenderer.Full(id)
|
||||
│ те же presets[id], но Width = fullWidthFor(img) (до 200 симв.)
|
||||
▼
|
||||
строка ASCII в полном размере → печатается в stdout
|
||||
```
|
||||
|
||||
## Поток данных: текст → баннер
|
||||
|
||||
```
|
||||
строка текста
|
||||
│ NewTextRenderer(text)
|
||||
▼
|
||||
TextRenderer.Previews()
|
||||
│ для каждого шрифта из defaultFonts():
|
||||
│ go-figure.NewFigure(text, font, true).String()
|
||||
▼
|
||||
[]Variant{ID: имя шрифта, Preview: баннер}
|
||||
│ TUI, тот же экран выбора, что и для картинок
|
||||
▼
|
||||
Enter → TextRenderer.Full(font) — фактически тот же рендер,
|
||||
FIGlet не теряет детализацию на "превью", в отличие от картинки
|
||||
▼
|
||||
готовый баннер → stdout
|
||||
```
|
||||
|
||||
## Почему так, а не иначе
|
||||
|
||||
- **Один и тот же TUI для картинок и текста.** Оба рендерера отдают
|
||||
`[]Variant` одинаковой формы — TUI работает с абстракцией, не зная
|
||||
деталей. Не пришлось бы писать два экрана выбора.
|
||||
- **Превью и полный рендер — один и тот же код (`render()`),
|
||||
разный `Width`.** Не два алгоритма, а один параметризованный —
|
||||
меньше мест для расхождения багов между "как выглядит" и "что получишь".
|
||||
- **Gamma и Invert — это математика над яркостью, а не отдельные ветки
|
||||
кода для каждого пресета.** Пресеты в `options.go` — это просто наборы
|
||||
значений полей `ImageOptions`, а не пять разных функций рендера.
|
||||
- **`resize()` компенсирует форму символа (`* 0.5` по высоте).** Без этого
|
||||
картинка выглядит вытянутой по вертикали — символ терминала выше, чем
|
||||
широкий (~2:1), и это единственное место, где это учитывается,
|
||||
а не размазано по остальному коду.
|
||||
|
||||
## Известные ограничения (сознательно не решались в скелете)
|
||||
|
||||
- `fullWidthFor()` — простой кап в 200 символов по ширине оригинала,
|
||||
без учёта реального размера терминала пользователя (можно прокинуть
|
||||
через `golang.org/x/term.GetSize`, если понадобится).
|
||||
- ANSI-цвет включает "сброс" (`\x1b[0m`) после каждого символа — рабочий,
|
||||
но не самый компактный вариант; для больших картинок можно оптимизировать,
|
||||
сбрасывая только при смене цвета.
|
||||
- Нет кэширования уменьшенной копии картинки между пресетами — каждый
|
||||
пресет по новой делает `resize()` от оригинала. Для маленьких превью
|
||||
(60 символов) это не заметно, но при желании легко вынести resize
|
||||
один раз и переиспользовать для всех превью.
|
||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# asciigen — скелет проекта
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
asciigen/
|
||||
├── go.mod
|
||||
├── variant.go # тип Variant + интерфейс Renderer
|
||||
├── options.go # ImageOptions/TextOptions, палитры, пресеты
|
||||
├── image.go # ImageRenderer — картинка -> ASCII
|
||||
├── text.go # TextRenderer — текст -> FIGlet-баннер
|
||||
├── tui/
|
||||
│ └── model.go # bubbletea-модель: список превью + выбор
|
||||
└── cmd/asciigen/
|
||||
└── main.go # CLI: `asciigen img file.png` / `asciigen text "Hello"`
|
||||
```
|
||||
|
||||
Ядро (`asciigen.Renderer`) не знает о TUI и не знает о CLI — можно
|
||||
подключить как библиотеку в другой Go-проект (auspex, rimefrost и т.д.)
|
||||
и обвязать своим интерфейсом, ничего не переписывая внутри пакета.
|
||||
|
||||
Подробный разбор потока данных и почему всё разложено именно так —
|
||||
см. **[ARCHITECTURE.md](./ARCHITECTURE.md)**.
|
||||
|
||||
## Что уже готово
|
||||
|
||||
- Интерфейс `Renderer` с двумя реализациями (`ImageRenderer`, `TextRenderer`)
|
||||
- Пресеты вариантов (палитры для картинок, шрифты для текста)
|
||||
- TUI на bubbletea: навигация стрелками, превью справа, Enter — полный рендер
|
||||
- CLI-обвязка с двумя подкомандами
|
||||
- `image.go` → `render()` реализован полностью: ресайз, яркость (luma),
|
||||
гамма-коррекция, инверсия, маппинг на палитру, опциональный ANSI-цвет
|
||||
|
||||
## Что нужно доделать
|
||||
|
||||
- `go.sum` — появится сам после `go mod tidy` (нужен доступ к proxy.golang.org,
|
||||
которого нет в этой песочнице, так что тут не собирал и не компилировал)
|
||||
- Реальный запуск и визуальная проверка на конкретных картинках — стоит
|
||||
подобрать `Gamma` под глаз, цифры в пресетах сейчас ориентировочные
|
||||
- См. "Известные ограничения" в ARCHITECTURE.md — сознательно не решённые
|
||||
мелочи (авто-определение ширины терминала, оптимизация ANSI-вывода)
|
||||
|
||||
## Запуск (после `go mod tidy` у себя)
|
||||
|
||||
```
|
||||
go run ./cmd/asciigen img photo.png
|
||||
go run ./cmd/asciigen text "HELLO"
|
||||
```
|
||||
31
go.mod
Normal file
31
go.mod
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
module asciigen
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v0.27.0
|
||||
github.com/charmbracelet/lipgloss v0.13.0
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be
|
||||
golang.org/x/image v0.18.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.1.4 // indirect
|
||||
github.com/charmbracelet/x/input v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.1.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
)
|
||||
49
go.sum
Normal file
49
go.sum
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU=
|
||||
github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y=
|
||||
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
|
||||
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
||||
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
|
||||
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
|
||||
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
|
||||
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
|
||||
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
|
||||
github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
|
||||
github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ=
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
152
image.go
Normal file
152
image.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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]
|
||||
}
|
||||
66
options.go
Normal file
66
options.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package asciigen
|
||||
|
||||
// Palette — набор символов от тёмного к светлому (или наоборот при Invert).
|
||||
type Palette string
|
||||
|
||||
const (
|
||||
// PaletteBlocks — контрастная палитра, хорошо читается на глаз.
|
||||
PaletteBlocks Palette = "@%#*+=-:. "
|
||||
// PaletteMinimal — всего 2 символа, для мелких превью.
|
||||
PaletteMinimal Palette = "#."
|
||||
// PaletteDetailed — длинная градация, больше полутонов.
|
||||
PaletteDetailed Palette = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "
|
||||
)
|
||||
|
||||
// ImageOptions — параметры рендера картинки в ASCII.
|
||||
type ImageOptions struct {
|
||||
Width int // ширина результата в символах
|
||||
Height int // 0 = вычисляется автоматически по аспекту (клетка символа ~2:1)
|
||||
Palette Palette
|
||||
Invert bool // поменять местами тёмное/светлое
|
||||
Color bool // цветной вывод через ANSI-коды (24-bit)
|
||||
Gamma float64 // 1.0 = без изменений; <1 светлее тени, >1 контрастнее
|
||||
}
|
||||
|
||||
// TextOptions — параметры рендера текстового баннера (FIGlet).
|
||||
type TextOptions struct {
|
||||
Font string // имя шрифта FIGlet, напр. "standard", "big", "slant", "block"
|
||||
Width int // ширина переноса строк, 0 = без переноса
|
||||
}
|
||||
|
||||
// defaultImagePresets — набор пресетов для первого экрана выбора.
|
||||
// Каждый пресет — это отдельный Variant, а не отдельный алгоритм:
|
||||
// меняются только Palette/Gamma/Invert поверх одного и того же движка.
|
||||
func defaultImagePresets() map[string]ImageOptions {
|
||||
return map[string]ImageOptions{
|
||||
"blocks-standard": {Palette: PaletteBlocks, Gamma: 1.0},
|
||||
"blocks-inverted": {Palette: PaletteBlocks, Gamma: 1.0, Invert: true},
|
||||
"detailed": {Palette: PaletteDetailed, Gamma: 1.0},
|
||||
"high-contrast": {Palette: PaletteBlocks, Gamma: 1.6},
|
||||
"minimal": {Palette: PaletteMinimal, Gamma: 1.0},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultFonts — пресеты шрифтов для текстового баннера.
|
||||
// Список сверен со встроенными в go-figure шрифтами (пакет их держит
|
||||
// вшитыми через go-bindata, так что это не файлы на диске, а константы
|
||||
// имён). Подобраны по разнообразию стиля, а не просто первые в алфавите —
|
||||
// иначе часть вариантов выглядела бы почти одинаково.
|
||||
func defaultFonts() []string {
|
||||
return []string{
|
||||
"standard", // базовый, читаемый
|
||||
"big", // крупный жирный
|
||||
"slant", // курсив с наклоном
|
||||
"small", // компактный
|
||||
"block", // блочные буквы
|
||||
"shadow", // с тенью
|
||||
"banner", // широкий баннерный
|
||||
"bubble", // "пузырьковые" буквы
|
||||
"digital", // под LCD-индикатор
|
||||
"doom", // угловатый, агрессивный
|
||||
"graffiti", // граффити-стиль
|
||||
"isometric1", // псевдо-3D
|
||||
"larry3d", // объёмный 3D
|
||||
"starwars", // тематический декоративный
|
||||
}
|
||||
}
|
||||
45
text.go
Normal file
45
text.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package asciigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
figure "github.com/common-nighthawk/go-figure"
|
||||
)
|
||||
|
||||
// TextRenderer реализует Renderer для текстовых баннеров.
|
||||
type TextRenderer struct {
|
||||
text string
|
||||
}
|
||||
|
||||
func NewTextRenderer(text string) *TextRenderer {
|
||||
return &TextRenderer{text: text}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) Previews() ([]Variant, error) {
|
||||
fonts := defaultFonts()
|
||||
variants := make([]Variant, 0, len(fonts))
|
||||
|
||||
for _, font := range fonts {
|
||||
ascii, err := r.render(TextOptions{Font: font})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("шрифт %q: %w", font, err)
|
||||
}
|
||||
variants = append(variants, Variant{
|
||||
ID: font,
|
||||
Label: fmt.Sprintf("Шрифт: %s", font),
|
||||
Preview: ascii,
|
||||
})
|
||||
}
|
||||
return variants, nil
|
||||
}
|
||||
|
||||
func (r *TextRenderer) Full(variantID string) (string, error) {
|
||||
// Для текста превью и полная версия обычно совпадают — FIGlet не теряет
|
||||
// детализацию на превью так, как это происходит с картинкой.
|
||||
return r.render(TextOptions{Font: variantID})
|
||||
}
|
||||
|
||||
func (r *TextRenderer) render(opts TextOptions) (string, error) {
|
||||
fig := figure.NewFigure(r.text, opts.Font, true)
|
||||
return fig.String(), nil
|
||||
}
|
||||
97
tui/model.go
Normal file
97
tui/model.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Package tui реализует интерактивный выбор варианта: список превью слева,
|
||||
// полный текущий превью справа, стрелки для навигации, Enter — подтвердить.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"asciigen"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42"))
|
||||
normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
previewBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 2)
|
||||
)
|
||||
|
||||
// Model — состояние TUI. Renderer абстрагирует, картинка перед нами или текст.
|
||||
type Model struct {
|
||||
renderer asciigen.Renderer
|
||||
variants []asciigen.Variant
|
||||
cursor int
|
||||
chosen string // ID выбранного варианта после подтверждения, "" пока не выбрано
|
||||
fullText string // отрендеренная полная версия, заполняется после Enter
|
||||
err error
|
||||
}
|
||||
|
||||
func New(r asciigen.Renderer) (Model, error) {
|
||||
variants, err := r.Previews()
|
||||
if err != nil {
|
||||
return Model{}, err
|
||||
}
|
||||
return Model{renderer: r, variants: variants}, nil
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.variants)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "enter":
|
||||
id := m.variants[m.cursor].ID
|
||||
full, err := m.renderer.Full(id)
|
||||
if err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
m.chosen = id
|
||||
m.fullText = full
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("Ошибка: %v\n", m.err)
|
||||
}
|
||||
|
||||
var list string
|
||||
for i, v := range m.variants {
|
||||
line := v.Label
|
||||
if i == m.cursor {
|
||||
line = selectedStyle.Render("> " + line)
|
||||
} else {
|
||||
line = normalStyle.Render(" " + line)
|
||||
}
|
||||
list += line + "\n"
|
||||
}
|
||||
|
||||
preview := previewBox.Render(m.variants[m.cursor].Preview)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, list, " ", preview) +
|
||||
"\n\n↑/↓ выбор • Enter подтвердить • q выход\n"
|
||||
}
|
||||
|
||||
// Chosen возвращает (ID, полный рендер, ok) — вызывать после того как
|
||||
// tea.NewProgram(...).Run() завершился.
|
||||
func (m Model) Chosen() (string, string, bool) {
|
||||
return m.chosen, m.fullText, m.chosen != ""
|
||||
}
|
||||
20
variant.go
Normal file
20
variant.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package asciigen
|
||||
|
||||
// Variant — один вариант рендера: превью + опции, которыми он получен.
|
||||
// Опции хранятся внутри варианта, чтобы полный рендер запускался
|
||||
// повторным вызовом с теми же параметрами, но в полном разрешении —
|
||||
// без пересчёта пресетов с нуля.
|
||||
type Variant struct {
|
||||
ID string // короткий идентификатор пресета, напр. "blocks-hi-contrast"
|
||||
Label string // человекочитаемое описание для UI/TUI
|
||||
Preview string // уменьшенный ASCII-рендер для быстрого показа
|
||||
}
|
||||
|
||||
// Renderer — общий интерфейс для картиночного и текстового рендера.
|
||||
// TUI работает с этим интерфейсом и не знает, картинка перед ним или текст.
|
||||
type Renderer interface {
|
||||
// Previews возвращает набор вариантов (разные пресеты) в уменьшенном виде.
|
||||
Previews() ([]Variant, error)
|
||||
// Full рендерит выбранный вариант (по ID) в полном размере/детализации.
|
||||
Full(variantID string) (string, error)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue