fix: исправление багов, комментарии и документация

- Исправлена двойная обработка ITableRow через reflection
- Исправлен выход за границы изображения при отрисовке последнего разделителя
- Добавлена защита от пустых данных (header, blocks)
- Добавлена compile-time проверка интерфейса ITableRow
- Переименован tablle_block_style.go → table_block_style.go
- Добавлены комментарии на русском ко всем функциям и типам
- Написана документация README.md с примерами использования
- Добавлен CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 20:30:29 +03:00
parent a4c5fc94b7
commit b8e9f20ec3
8 changed files with 347 additions and 89 deletions

34
CLAUDE.md Normal file
View File

@@ -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.

173
README.md
View File

@@ -1,3 +1,174 @@
# image_table # image_table
Создание изображения с таблицами данных 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

View File

@@ -10,31 +10,24 @@ import (
"golang.org/x/image/math/fixed" "golang.org/x/image/math/fixed"
) )
// Document — документ, состоящий из нескольких таблиц с заголовками
type Document struct { type Document struct {
Blocks []TableBlock Blocks []TableBlock
} }
// Цвет заголовка блока
var titleColor = color.RGBA{0x22, 0x22, 0x20, 255} var titleColor = color.RGBA{0x22, 0x22, 0x20, 255}
// Высота заголовка блока // Высота заголовка блока (над таблицей)
const blockTitleHeight = 30 const blockTitleHeight = 30
// Вертикальный отступ между блоками
const blockSpacing = 20 const blockSpacing = 20
// Рисуем текст заголовка // Шрифт для заголовков блоков (12pt)
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)
}
// ---- Увеличенный шрифт для заголовков ----
var globalFaceBig font.Face var globalFaceBig font.Face
// Инициализация увеличенного шрифта для заголовков блоков
func init() { func init() {
f, err := opentype.Parse(jbTTF) f, err := opentype.Parse(jbTTF)
if err != nil { 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 width := 0
height := 0 height := 0
@@ -71,26 +81,28 @@ func RenderDocument(doc Document) image.Image {
height += blockTitleHeight + tableImg.Bounds().Dy() + blockSpacing 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) draw.Draw(img, img.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
y := 20 y := margin
// рендерим блоки // Отрисовка блоков
for i, block := range doc.Blocks { for i, block := range doc.Blocks {
// Заголовок блока // Заголовок блока
drawBigText(img, 20, y+blockTitleHeight-10, block.Title) drawBigText(img, margin, y+blockTitleHeight-10, block.Title)
y += blockTitleHeight y += blockTitleHeight
// Таблица // Таблица блока
tableImg := blockImages[i] tableImg := blockImages[i]
draw.Draw( draw.Draw(
img, 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, tableImg,
image.Point{}, image.Point{},
draw.Over, draw.Over,

View File

@@ -1,3 +1,4 @@
// Пакет image_table — библиотека для рендеринга таблиц данных в PNG-изображения.
package image_table package image_table
import ( import (
@@ -14,23 +15,25 @@ import (
"golang.org/x/image/math/fixed" "golang.org/x/image/math/fixed"
) )
// Встроенный шрифт JetBrains Mono для рендеринга текста
//
//go:embed assets/jb.ttf //go:embed assets/jb.ttf
var jbTTF []byte var jbTTF []byte
// Шрифт для содержимого таблицы (10pt)
var globalFace font.Face var globalFace font.Face
// ---- Цвета ---- // ---- Цвета таблицы ----
var ( var (
headerBg = color.RGBA{0xD8, 0xEC, 0xFF, 255} // голубой фон заголовка headerBg = color.RGBA{0xD8, 0xEC, 0xFF, 255} // голубой фон заголовка
rowBg1 = color.RGBA{0xFF, 0xFF, 0xFF, 255} // белый фон строки 1 rowBg1 = color.RGBA{0xFF, 0xFF, 0xFF, 255} // белый фон нечётных строк
rowBg2 = color.RGBA{0xF7, 0xFA, 0xFC, 255} // мягкий фон строки 2 rowBg2 = color.RGBA{0xF7, 0xFA, 0xFC, 255} // серо-голубой фон чётных строк
borderCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // рамка borderCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // цвет внешней рамки
dividerCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // линии dividerCol = color.RGBA{0xB8, 0xC8, 0xD8, 255} // цвет разделительных линий
textCol = color.RGBA{0x34, 0x34, 0x32, 255} // тёплый тёмно-серый текст textCol = color.RGBA{0x34, 0x34, 0x32, 255} // тёплый тёмно-серый цвет текста
) )
// ---- Инициализация шрифта ---- // Инициализация шрифта при загрузке пакета
func init() { func init() {
f, err := opentype.Parse(jbTTF) f, err := opentype.Parse(jbTTF)
if err != nil { if err != nil {
@@ -47,22 +50,14 @@ func init() {
} }
} }
// ---- Утилиты ---- // toString преобразует любое значение в строку
func toString(v any) string { func toString(v any) string {
return fmt.Sprintf("%v", v) return fmt.Sprintf("%v", v)
} }
// rowToStrings преобразует строку таблицы в массив строк.
// Поддерживает: срезы, массивы, структуры и одиночные значения.
func rowToStrings(v any) []string { 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) val := reflect.ValueOf(v)
switch val.Kind() { switch val.Kind() {
@@ -84,6 +79,16 @@ func rowToStrings(v any) []string {
return []string{toString(v)} 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) { func drawText(img *image.RGBA, x, y int, text string) {
d := &font.Drawer{ d := &font.Drawer{
Dst: img, Dst: img,
@@ -94,11 +99,13 @@ func drawText(img *image.RGBA, x, y int, text string) {
d.DrawString(text) d.DrawString(text)
} }
// measureText возвращает ширину текста в пикселях
func measureText(s string) int { func measureText(s string) int {
d := &font.Drawer{Face: globalFace} d := &font.Drawer{Face: globalFace}
return d.MeasureString(s).Round() return d.MeasureString(s).Round()
} }
// roundedRect рисует прямоугольник со скруглёнными углами
func roundedRect(img *image.RGBA, rect image.Rectangle, r int, col color.Color) { func roundedRect(img *image.RGBA, rect image.Rectangle, r int, col color.Color) {
w := rect.Dx() w := rect.Dx()
h := rect.Dy() 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 { func dist(x1, y1, x2, y2 int) float64 {
return math.Hypot(float64(x1-x2), float64(y1-y2)) 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 { func DrawTableWarm(header []string, rows []any) image.Image {
padX := 8 padX := 8
padY := 10 padY := 10
@@ -130,9 +143,15 @@ func DrawTableWarm(header []string, rows []any) image.Image {
headerHeight := 32 headerHeight := 32
colCount := len(header) colCount := len(header)
// Если нет столбцов — возвращаем пустое изображение 1x1
if colCount == 0 {
return image.NewRGBA(image.Rect(0, 0, 1, 1))
}
colWidths := make([]int, colCount) colWidths := make([]int, colCount)
// ---- Автоширина ---- // ---- Автоподбор ширины столбцов ----
for i, h := range header { for i, h := range header {
w := measureText(h) w := measureText(h)
@@ -142,7 +161,12 @@ func DrawTableWarm(header []string, rows []any) image.Image {
} }
for _, r := range rows { 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++ { for i := 0; i < len(cells) && i < colCount; i++ {
w := measureText(cells[i]) w := measureText(cells[i])
if w > colWidths[i] { if w > colWidths[i] {
@@ -151,10 +175,12 @@ func DrawTableWarm(header []string, rows []any) image.Image {
} }
} }
// Добавляем внутренние отступы к ширине столбцов
for i := range colWidths { for i := range colWidths {
colWidths[i] += padX * 2 colWidths[i] += padX * 2
} }
// Подсчёт общих размеров изображения
imgWidth := 0 imgWidth := 0
for _, w := range colWidths { for _, w := range colWidths {
imgWidth += w imgWidth += w
@@ -164,10 +190,10 @@ func DrawTableWarm(header []string, rows []any) image.Image {
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
// общий фон // Заливка общего фона
draw.Draw(img, img.Bounds(), &image.Uniform{rowBg1}, image.Point{}, draw.Src) draw.Draw(img, img.Bounds(), &image.Uniform{rowBg1}, image.Point{}, draw.Src)
// ---- Заголовок ---- // ---- Отрисовка заголовка ----
roundedRect(img, image.Rect(0, 0, imgWidth, headerHeight), 8, headerBg) 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) drawText(img, x+padX, headerHeight-padY, h)
x += colWidths[i] x += colWidths[i]
// Вертикальный разделитель // Вертикальный разделитель между столбцами (не после последнего)
for yy := 0; yy < headerHeight; yy++ { if i < colCount-1 {
img.Set(x, yy, dividerCol) for yy := 0; yy < headerHeight; yy++ {
img.Set(x, yy, dividerCol)
}
} }
} }
// ---- Строки ---- // ---- Отрисовка строк данных ----
y := headerHeight y := headerHeight
for rowIndex, row := range rows { for rowIndex, row := range rows {
// Чередование фона: нечётные строки — белые, чётные — серо-голубые
bg := rowBg1 bg := rowBg1
if rowIndex%2 == 1 { if rowIndex%2 == 1 {
bg = rowBg2 bg = rowBg2
} }
sCells := row // Получение ячеек и (опционально) пользовательского цвета фона
var cells []string
switch rt := row.(type) { if tr, ok := row.(ITableRow); ok {
case ITableRow: bg = tr.GetBackgroundColor()
bg = rt.GetBackgroundColor() cells = cellsToStrings(tr.GetCells())
sCells = rt.GetCells() } else {
cells = rowToStrings(row)
} }
// фон строки // Заливка фона строки
for xx := 0; xx < imgWidth; xx++ { for xx := 0; xx < imgWidth; xx++ {
for yy := 0; yy < rowHeight; yy++ { for yy := 0; yy < rowHeight; yy++ {
img.Set(xx, y+yy, bg) img.Set(xx, y+yy, bg)
} }
} }
// горизонтальный разделитель ВВЕРХУ строки // Горизонтальный разделитель вверху строки
for xx := 0; xx < imgWidth; xx++ { for xx := 0; xx < imgWidth; xx++ {
img.Set(xx, y, dividerCol) img.Set(xx, y, dividerCol)
} }
cells := rowToStrings(sCells) // Отрисовка текста ячеек
x = 0 x = 0
for i := 0; i < colCount && i < len(cells); i++ { for i := 0; i < colCount && i < len(cells); i++ {
drawText(img, x+padX, y+rowHeight-padY, cells[i]) drawText(img, x+padX, y+rowHeight-padY, cells[i])
x += colWidths[i] x += colWidths[i]
// вертикальная линия // Вертикальный разделитель между столбцами (не после последнего)
for yy := 0; yy < rowHeight; yy++ { if i < colCount-1 {
img.Set(x, y+yy, dividerCol) for yy := 0; yy < rowHeight; yy++ {
img.Set(x, y+yy, dividerCol)
}
} }
} }
y += rowHeight y += rowHeight
} }
// Рамка // ---- Внешняя рамка таблицы ----
for xx := 0; xx < imgWidth; xx++ { for xx := 0; xx < imgWidth; xx++ {
img.Set(xx, 0, borderCol) img.Set(xx, 0, borderCol)
img.Set(xx, imgHeight-1, borderCol) img.Set(xx, imgHeight-1, borderCol)

View File

@@ -2,6 +2,8 @@ package image_table
import "image/color" import "image/color"
// ITableRow — интерфейс для строки таблицы с пользовательским стилем.
// Позволяет задать ячейки и цвет фона строки.
type ITableRow interface { type ITableRow interface {
GetCells() []any GetCells() []any
GetBackgroundColor() color.RGBA GetBackgroundColor() color.RGBA

View File

@@ -1,7 +1,8 @@
package image_table package image_table
// TableBlock — блок документа: именованная таблица с заголовком и строками данных
type TableBlock struct { type TableBlock struct {
Title string Title string // заголовок блока (отображается над таблицей)
Header []string Header []string // заголовки столбцов
Rows []any Rows []any // строки данных ([]string, []any, struct или ITableRow)
} }

24
table_block_style.go Normal file
View File

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

View File

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