diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d13ab8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Go library for rendering data tables as PNG images. Provides two APIs: +- `DrawTableWarm()` — renders a single table with header and rows +- `RenderDocument()` — renders a multi-block document with titled table sections + +## Build & Test Commands + +```bash +go test ./... # run all tests +go test -run TestDrawTable # run single test +go test -run TestDocument # run document rendering test +``` + +Tests output PNG files to `test-data/` (gitignored). Verify rendering by inspecting the generated images visually. + +## Architecture + +- **`image_table.go`** — Core rendering engine. `DrawTableWarm()` draws a single table image. Contains font initialization (`init()`), text measurement/drawing utilities, rounded rect helper, and row-to-string conversion via reflection. Embeds `assets/jb.ttf` (JetBrains font) at compile time. +- **`document.go`** — `RenderDocument()` composes multiple `TableBlock`s into a single document image with titles, using a larger font face for block headings. +- **`table_block.go`** — `TableBlock` struct: a titled table with header and rows. +- **`tablle_block_style.go`** — `TableBlockStyle` implements `ITableRow` to allow per-row background colors. +- **`itablerow.go`** — `ITableRow` interface for custom row rendering (cells + background color). + +## Key Patterns + +- Rows accept `any` type: slices, structs (via reflection), or `ITableRow` implementors for custom styling. +- Column widths are auto-calculated from content measurement. +- Two `init()` functions parse the embedded TTF font at different sizes (10pt for table cells, 12pt for document titles). +- Comments and identifiers are in Russian. diff --git a/README.md b/README.md index ce14c7a..b3558c6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,174 @@ # image_table -Создание изображения с таблицами данных \ No newline at end of file +Go-библиотека для рендеринга таблиц данных в PNG-изображения. Подходит для генерации отчётов, уведомлений в мессенджерах и визуализации табличных данных. + +## Установка + +```bash +go get git.gm6.ru/icewind/image_table +``` + +## Возможности + +- Рендеринг одиночной таблицы с автоподбором ширины столбцов +- Рендеринг документа из нескольких именованных таблиц +- Поддержка различных типов данных в строках: срезы, структуры, интерфейс `ITableRow` +- Настройка цвета фона для отдельных строк через `TableBlockStyle` +- Встроенный шрифт JetBrains Mono — не требует внешних зависимостей для шрифтов + +## Примеры + +### Простая таблица + +```go +package main + +import ( + "image/png" + "os" + + "git.gm6.ru/icewind/image_table" +) + +func main() { + header := []string{"Имя", "Возраст", "Город"} + + rows := []any{ + []string{"Алиса", "23", "Москва"}, + []string{"Боб", "31", "Казань"}, + []string{"Олег", "44", "Сочи"}, + } + + img := image_table.DrawTableWarm(header, rows) + + file, _ := os.Create("table.png") + defer file.Close() + png.Encode(file, img) +} +``` + +### Строки из структур + +Поля структуры автоматически преобразуются в ячейки таблицы: + +```go +type User struct { + Name string + Age int + City string +} + +rows := []any{ + User{"Елена", 27, "Минск"}, + User{"Иван", 35, "Киев"}, +} + +img := image_table.DrawTableWarm( + []string{"Имя", "Возраст", "Город"}, + rows, +) +``` + +### Строки с пользовательским фоном + +Используйте `TableBlockStyle` для задания цвета фона строки: + +```go +rows := []any{ + []any{1, "Запрос обработан", "OK"}, + &image_table.TableBlockStyle{ + Cells: []any{2, "Ошибка соединения", "FAIL"}, + BackgroundColor: color.RGBA{R: 255, G: 225, B: 225, A: 255}, // красный фон + }, +} + +img := image_table.DrawTableWarm( + []string{"ID", "Описание", "Статус"}, + rows, +) +``` + +> **Важно:** `TableBlockStyle` нужно передавать как указатель (`&TableBlockStyle{...}`), иначе интерфейс `ITableRow` не будет распознан. + +### Документ из нескольких таблиц + +`Document` позволяет объединить несколько таблиц с заголовками в одно изображение: + +```go +doc := image_table.Document{ + Blocks: []image_table.TableBlock{ + { + Title: "Пользователи", + Header: []string{"ID", "Имя", "Возраст"}, + Rows: []any{ + []any{1, "Иван", 30}, + []any{2, "Мария", 25}, + }, + }, + { + Title: "Статистика", + Header: []string{"Метрика", "Значение"}, + Rows: []any{ + []any{"Запросы", 12000}, + &image_table.TableBlockStyle{ + Cells: []any{"Ошибки", 37}, + BackgroundColor: color.RGBA{R: 255, G: 225, B: 225, A: 255}, + }, + }, + }, + }, +} + +img := image_table.RenderDocument(doc) + +file, _ := os.Create("document.png") +defer file.Close() +png.Encode(file, img) +``` + +### Собственная реализация ITableRow + +Для полного контроля над строкой реализуйте интерфейс `ITableRow`: + +```go +type AlertRow struct { + Level string + Message string +} + +func (r *AlertRow) GetCells() []any { + return []any{r.Level, r.Message} +} + +func (r *AlertRow) GetBackgroundColor() color.RGBA { + if r.Level == "CRITICAL" { + return color.RGBA{R: 255, G: 200, B: 200, A: 255} + } + return color.RGBA{R: 255, G: 255, B: 255, A: 255} +} +``` + +## API + +### `DrawTableWarm(header []string, rows []any) image.Image` + +Рендерит одну таблицу. Строки могут быть `[]string`, `[]any`, структурой или реализацией `ITableRow`. + +### `RenderDocument(doc Document) image.Image` + +Рендерит документ из нескольких таблиц (`Document.Blocks`), каждая с заголовком. + +### `ITableRow` + +```go +type ITableRow interface { + GetCells() []any + GetBackgroundColor() color.RGBA +} +``` + +Интерфейс для строк с пользовательским стилем. Готовая реализация — `TableBlockStyle`. + +## Лицензия + +MIT diff --git a/document.go b/document.go index 4c1ab8b..735de02 100644 --- a/document.go +++ b/document.go @@ -10,31 +10,24 @@ import ( "golang.org/x/image/math/fixed" ) +// Document — документ, состоящий из нескольких таблиц с заголовками type Document struct { Blocks []TableBlock } +// Цвет заголовка блока var titleColor = color.RGBA{0x22, 0x22, 0x20, 255} -// Высота заголовка блока +// Высота заголовка блока (над таблицей) const blockTitleHeight = 30 + +// Вертикальный отступ между блоками const blockSpacing = 20 -// Рисуем текст заголовка -func drawBigText(img *image.RGBA, x, y int, text string) { - d := &font.Drawer{ - Dst: img, - Src: image.NewUniform(titleColor), - Face: globalFaceBig, // увеличенный шрифт - Dot: fixed.P(x, y), - } - d.DrawString(text) -} - -// ---- Увеличенный шрифт для заголовков ---- - +// Шрифт для заголовков блоков (12pt) var globalFaceBig font.Face +// Инициализация увеличенного шрифта для заголовков блоков func init() { f, err := opentype.Parse(jbTTF) if err != nil { @@ -51,9 +44,26 @@ func init() { } } -func RenderDocument(doc Document) image.Image { - // Сначала считаем общие размеры +// drawBigText рисует текст увеличенным шрифтом (для заголовков блоков) +func drawBigText(img *image.RGBA, x, y int, text string) { + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(titleColor), + Face: globalFaceBig, + Dot: fixed.P(x, y), + } + d.DrawString(text) +} +// RenderDocument рендерит документ — несколько именованных таблиц на одном изображении. +// Каждый блок содержит заголовок и таблицу. Блоки располагаются вертикально с отступами. +func RenderDocument(doc Document) image.Image { + // Если нет блоков — возвращаем пустое изображение + if len(doc.Blocks) == 0 { + return image.NewRGBA(image.Rect(0, 0, 1, 1)) + } + + // Рендерим каждую таблицу отдельно и считаем общие размеры width := 0 height := 0 @@ -71,26 +81,28 @@ func RenderDocument(doc Document) image.Image { height += blockTitleHeight + tableImg.Bounds().Dy() + blockSpacing } - // Создаём документ - img := image.NewRGBA(image.Rect(0, 0, width+40, height+40)) + // Внешние отступы документа + margin := 20 - // общий фон + // Создаём итоговое изображение + img := image.NewRGBA(image.Rect(0, 0, width+margin*2, height+margin*2)) + + // Белый фон документа draw.Draw(img, img.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) - y := 20 + y := margin - // рендерим блоки + // Отрисовка блоков for i, block := range doc.Blocks { - // Заголовок блока - drawBigText(img, 20, y+blockTitleHeight-10, block.Title) + drawBigText(img, margin, y+blockTitleHeight-10, block.Title) y += blockTitleHeight - // Таблица + // Таблица блока tableImg := blockImages[i] draw.Draw( img, - image.Rect(20, y, 20+tableImg.Bounds().Dx(), y+tableImg.Bounds().Dy()), + image.Rect(margin, y, margin+tableImg.Bounds().Dx(), y+tableImg.Bounds().Dy()), tableImg, image.Point{}, draw.Over, diff --git a/image_table.go b/image_table.go index e31fe54..59cd39f 100644 --- a/image_table.go +++ b/image_table.go @@ -1,3 +1,4 @@ +// Пакет image_table — библиотека для рендеринга таблиц данных в PNG-изображения. package image_table import ( @@ -14,23 +15,25 @@ import ( "golang.org/x/image/math/fixed" ) +// Встроенный шрифт JetBrains Mono для рендеринга текста +// //go:embed assets/jb.ttf var jbTTF []byte +// Шрифт для содержимого таблицы (10pt) var globalFace font.Face -// ---- Цвета ---- +// ---- Цвета таблицы ---- var ( headerBg = color.RGBA{0xD8, 0xEC, 0xFF, 255} // голубой фон заголовка - rowBg1 = color.RGBA{0xFF, 0xFF, 0xFF, 255} // белый фон строки 1 - rowBg2 = color.RGBA{0xF7, 0xFA, 0xFC, 255} // мягкий фон строки 2 - borderCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // рамка - dividerCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // линии - textCol = color.RGBA{0x34, 0x34, 0x32, 255} // тёплый тёмно-серый текст + rowBg1 = color.RGBA{0xFF, 0xFF, 0xFF, 255} // белый фон нечётных строк + rowBg2 = color.RGBA{0xF7, 0xFA, 0xFC, 255} // серо-голубой фон чётных строк + borderCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // цвет внешней рамки + dividerCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // цвет разделительных линий + textCol = color.RGBA{0x34, 0x34, 0x32, 255} // тёплый тёмно-серый цвет текста ) -// ---- Инициализация шрифта ---- - +// Инициализация шрифта при загрузке пакета func init() { f, err := opentype.Parse(jbTTF) if err != nil { @@ -47,22 +50,14 @@ func init() { } } -// ---- Утилиты ---- - +// toString преобразует любое значение в строку func toString(v any) string { return fmt.Sprintf("%v", v) } +// rowToStrings преобразует строку таблицы в массив строк. +// Поддерживает: срезы, массивы, структуры и одиночные значения. func rowToStrings(v any) []string { - if r, ok := v.(ITableRow); ok { - cells := r.GetCells() - out := make([]string, len(cells)) - for i, c := range cells { - out[i] = toString(c) - } - return out - } - val := reflect.ValueOf(v) switch val.Kind() { @@ -84,6 +79,16 @@ func rowToStrings(v any) []string { return []string{toString(v)} } +// cellsToStrings преобразует []any ячейки ITableRow в []string +func cellsToStrings(cells []any) []string { + out := make([]string, len(cells)) + for i, c := range cells { + out[i] = toString(c) + } + return out +} + +// drawText рисует текст на изображении в указанной позиции (x, y — базовая линия) func drawText(img *image.RGBA, x, y int, text string) { d := &font.Drawer{ Dst: img, @@ -94,11 +99,13 @@ func drawText(img *image.RGBA, x, y int, text string) { d.DrawString(text) } +// measureText возвращает ширину текста в пикселях func measureText(s string) int { d := &font.Drawer{Face: globalFace} return d.MeasureString(s).Round() } +// roundedRect рисует прямоугольник со скруглёнными углами func roundedRect(img *image.RGBA, rect image.Rectangle, r int, col color.Color) { w := rect.Dx() h := rect.Dy() @@ -116,12 +123,18 @@ func roundedRect(img *image.RGBA, rect image.Rectangle, r int, col color.Color) } } +// dist вычисляет евклидово расстояние между двумя точками func dist(x1, y1, x2, y2 int) float64 { return math.Hypot(float64(x1-x2), float64(y1-y2)) } -// ---- Основная функция ---- - +// DrawTableWarm рендерит таблицу в изображение. +// +// header — заголовки столбцов. +// rows — строки данных. Каждый элемент может быть: +// - []string, []any — обычный срез значений +// - struct — поля структуры станут ячейками +// - ITableRow (например *TableBlockStyle) — для задания фона строки func DrawTableWarm(header []string, rows []any) image.Image { padX := 8 padY := 10 @@ -130,9 +143,15 @@ func DrawTableWarm(header []string, rows []any) image.Image { headerHeight := 32 colCount := len(header) + + // Если нет столбцов — возвращаем пустое изображение 1x1 + if colCount == 0 { + return image.NewRGBA(image.Rect(0, 0, 1, 1)) + } + colWidths := make([]int, colCount) - // ---- Автоширина ---- + // ---- Автоподбор ширины столбцов ---- for i, h := range header { w := measureText(h) @@ -142,7 +161,12 @@ func DrawTableWarm(header []string, rows []any) image.Image { } for _, r := range rows { - cells := rowToStrings(r) + var cells []string + if tr, ok := r.(ITableRow); ok { + cells = cellsToStrings(tr.GetCells()) + } else { + cells = rowToStrings(r) + } for i := 0; i < len(cells) && i < colCount; i++ { w := measureText(cells[i]) if w > colWidths[i] { @@ -151,10 +175,12 @@ func DrawTableWarm(header []string, rows []any) image.Image { } } + // Добавляем внутренние отступы к ширине столбцов for i := range colWidths { colWidths[i] += padX * 2 } + // Подсчёт общих размеров изображения imgWidth := 0 for _, w := range colWidths { imgWidth += w @@ -164,10 +190,10 @@ func DrawTableWarm(header []string, rows []any) image.Image { img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) - // общий фон + // Заливка общего фона draw.Draw(img, img.Bounds(), &image.Uniform{rowBg1}, image.Point{}, draw.Src) - // ---- Заголовок ---- + // ---- Отрисовка заголовка ---- roundedRect(img, image.Rect(0, 0, imgWidth, headerHeight), 8, headerBg) // Горизонтальная линия под заголовком @@ -180,59 +206,63 @@ func DrawTableWarm(header []string, rows []any) image.Image { drawText(img, x+padX, headerHeight-padY, h) x += colWidths[i] - // Вертикальный разделитель - for yy := 0; yy < headerHeight; yy++ { - img.Set(x, yy, dividerCol) + // Вертикальный разделитель между столбцами (не после последнего) + if i < colCount-1 { + for yy := 0; yy < headerHeight; yy++ { + img.Set(x, yy, dividerCol) + } } } - // ---- Строки ---- + // ---- Отрисовка строк данных ---- y := headerHeight for rowIndex, row := range rows { + // Чередование фона: нечётные строки — белые, чётные — серо-голубые bg := rowBg1 - if rowIndex%2 == 1 { bg = rowBg2 } - sCells := row - - switch rt := row.(type) { - case ITableRow: - bg = rt.GetBackgroundColor() - sCells = rt.GetCells() + // Получение ячеек и (опционально) пользовательского цвета фона + var cells []string + if tr, ok := row.(ITableRow); ok { + bg = tr.GetBackgroundColor() + cells = cellsToStrings(tr.GetCells()) + } else { + cells = rowToStrings(row) } - // фон строки + // Заливка фона строки for xx := 0; xx < imgWidth; xx++ { for yy := 0; yy < rowHeight; yy++ { img.Set(xx, y+yy, bg) } } - // горизонтальный разделитель ВВЕРХУ строки + // Горизонтальный разделитель вверху строки for xx := 0; xx < imgWidth; xx++ { img.Set(xx, y, dividerCol) } - cells := rowToStrings(sCells) - + // Отрисовка текста ячеек x = 0 for i := 0; i < colCount && i < len(cells); i++ { drawText(img, x+padX, y+rowHeight-padY, cells[i]) x += colWidths[i] - // вертикальная линия - for yy := 0; yy < rowHeight; yy++ { - img.Set(x, y+yy, dividerCol) + // Вертикальный разделитель между столбцами (не после последнего) + if i < colCount-1 { + for yy := 0; yy < rowHeight; yy++ { + img.Set(x, y+yy, dividerCol) + } } } y += rowHeight } - // Рамка + // ---- Внешняя рамка таблицы ---- for xx := 0; xx < imgWidth; xx++ { img.Set(xx, 0, borderCol) img.Set(xx, imgHeight-1, borderCol) diff --git a/itablerow.go b/itablerow.go index f31cef1..982fafc 100644 --- a/itablerow.go +++ b/itablerow.go @@ -2,6 +2,8 @@ package image_table import "image/color" +// ITableRow — интерфейс для строки таблицы с пользовательским стилем. +// Позволяет задать ячейки и цвет фона строки. type ITableRow interface { GetCells() []any GetBackgroundColor() color.RGBA diff --git a/table_block.go b/table_block.go index 29c0ea5..3c1eca7 100644 --- a/table_block.go +++ b/table_block.go @@ -1,7 +1,8 @@ package image_table +// TableBlock — блок документа: именованная таблица с заголовком и строками данных type TableBlock struct { - Title string - Header []string - Rows []any + Title string // заголовок блока (отображается над таблицей) + Header []string // заголовки столбцов + Rows []any // строки данных ([]string, []any, struct или ITableRow) } diff --git a/table_block_style.go b/table_block_style.go new file mode 100644 index 0000000..e1ee8ae --- /dev/null +++ b/table_block_style.go @@ -0,0 +1,24 @@ +package image_table + +import "image/color" + +// Проверка реализации интерфейса на этапе компиляции +var _ ITableRow = (*TableBlockStyle)(nil) + +// TableBlockStyle — строка таблицы с пользовательским цветом фона. +// Реализует интерфейс ITableRow. +// Важно: передавать в rows как указатель (*TableBlockStyle), иначе интерфейс не будет распознан. +type TableBlockStyle struct { + Cells []any // ячейки строки + BackgroundColor color.RGBA // цвет фона строки +} + +// GetCells возвращает ячейки строки +func (s *TableBlockStyle) GetCells() []any { + return s.Cells +} + +// GetBackgroundColor возвращает цвет фона строки +func (s *TableBlockStyle) GetBackgroundColor() color.RGBA { + return s.BackgroundColor +} diff --git a/tablle_block_style.go b/tablle_block_style.go deleted file mode 100644 index 22a2136..0000000 --- a/tablle_block_style.go +++ /dev/null @@ -1,16 +0,0 @@ -package image_table - -import "image/color" - -type TableBlockStyle struct { - Cells []any - BackgroundColor color.RGBA -} - -func (s *TableBlockStyle) GetCells() []any { - return s.Cells -} - -func (s *TableBlockStyle) GetBackgroundColor() color.RGBA { - return s.BackgroundColor -}