diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78d307c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test-data \ No newline at end of file diff --git a/assets/jb.ttf b/assets/jb.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/assets/jb.ttf differ diff --git a/document.go b/document.go new file mode 100644 index 0000000..3b09f2f --- /dev/null +++ b/document.go @@ -0,0 +1,109 @@ +package image_table + +import ( + "image" + "image/color" + "image/draw" + + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +type TableBlock struct { + Title string + Header []string + Rows []any +} + +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) +} + +// ---- Увеличенный шрифт для заголовков ---- + +var globalFaceBig font.Face + +func init() { + f, err := opentype.Parse(jbTTF) + if err != nil { + panic(err) + } + + globalFaceBig, err = opentype.NewFace(f, &opentype.FaceOptions{ + Size: 12, + DPI: 96, + Hinting: font.HintingFull, + }) + if err != nil { + panic(err) + } +} + +func RenderDocument(doc Document) image.Image { + // Сначала считаем общие размеры + + width := 0 + height := 0 + + blockImages := make([]image.Image, len(doc.Blocks)) + + for i, block := range doc.Blocks { + tableImg := DrawTableWarm(block.Header, block.Rows) + blockImages[i] = tableImg + + w := tableImg.Bounds().Dx() + if w > width { + width = w + } + + height += blockTitleHeight + tableImg.Bounds().Dy() + blockSpacing + } + + // Создаём документ + img := image.NewRGBA(image.Rect(0, 0, width+40, height+40)) + + // общий фон + draw.Draw(img, img.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src) + + y := 20 + + // рендерим блоки + for i, block := range doc.Blocks { + + // Заголовок блока + drawBigText(img, 20, 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()), + tableImg, + image.Point{}, + draw.Over, + ) + + y += tableImg.Bounds().Dy() + blockSpacing + } + + return img +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..53eadd2 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.gm6.ru/icewind/image_table + +go 1.25.4 + +require golang.org/x/image v0.34.0 + +require golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..91910a2 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= +golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/image_table.go b/image_table.go new file mode 100644 index 0000000..5e9999b --- /dev/null +++ b/image_table.go @@ -0,0 +1,228 @@ +package image_table + +import ( + _ "embed" + "fmt" + "image" + "image/color" + "image/draw" + "math" + "reflect" + + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +//go:embed assets/jb.ttf +var jbTTF []byte + +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} // тёплый тёмно-серый текст +) + +// ---- Инициализация шрифта ---- + +func init() { + f, err := opentype.Parse(jbTTF) + if err != nil { + panic(err) + } + + globalFace, err = opentype.NewFace(f, &opentype.FaceOptions{ + Size: 10, + DPI: 96, + Hinting: font.HintingFull, + }) + if err != nil { + panic(err) + } +} + +// ---- Утилиты ---- + +func toString(v any) string { + return fmt.Sprintf("%v", v) +} + +func rowToStrings(v any) []string { + val := reflect.ValueOf(v) + + switch val.Kind() { + case reflect.Slice, reflect.Array: + out := make([]string, val.Len()) + for i := 0; i < val.Len(); i++ { + out[i] = toString(val.Index(i).Interface()) + } + return out + + case reflect.Struct: + out := make([]string, val.NumField()) + for i := 0; i < val.NumField(); i++ { + out[i] = toString(val.Field(i).Interface()) + } + return out + } + + return []string{toString(v)} +} + +func drawText(img *image.RGBA, x, y int, text string) { + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(textCol), + Face: globalFace, + Dot: fixed.P(x, y), + } + d.DrawString(text) +} + +func measureText(s string) int { + d := &font.Drawer{Face: globalFace} + return d.MeasureString(s).Round() +} + +func roundedRect(img *image.RGBA, rect image.Rectangle, r int, col color.Color) { + w := rect.Dx() + h := rect.Dy() + + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if (x < r && y < r && dist(x, y, r, r) > float64(r)) || + (x >= w-r && y < r && dist(x, y, w-r-1, r) > float64(r)) || + (x < r && y >= h-r && dist(x, y, r, h-r-1) > float64(r)) || + (x >= w-r && y >= h-r && dist(x, y, w-r-1, h-r-1) > float64(r)) { + continue + } + img.Set(rect.Min.X+x, rect.Min.Y+y, col) + } + } +} + +func dist(x1, y1, x2, y2 int) float64 { + return math.Hypot(float64(x1-x2), float64(y1-y2)) +} + +// ---- Основная функция ---- + +func DrawTableWarm(header []string, rows []any) image.Image { + padX := 8 + padY := 10 + + rowHeight := 26 + headerHeight := 32 + + colCount := len(header) + colWidths := make([]int, colCount) + + // ---- Автоширина ---- + + for i, h := range header { + w := measureText(h) + if w > colWidths[i] { + colWidths[i] = w + } + } + + for _, r := range rows { + cells := rowToStrings(r) + for i := 0; i < len(cells) && i < colCount; i++ { + w := measureText(cells[i]) + if w > colWidths[i] { + colWidths[i] = w + } + } + } + + for i := range colWidths { + colWidths[i] += padX * 2 + } + + imgWidth := 0 + for _, w := range colWidths { + imgWidth += w + } + + imgHeight := headerHeight + len(rows)*rowHeight + + 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) + + // Горизонтальная линия под заголовком + for xx := 0; xx < imgWidth; xx++ { + img.Set(xx, headerHeight-1, dividerCol) + } + + x := 0 + for i, h := range header { + drawText(img, x+padX, headerHeight-padY, h) + x += colWidths[i] + + // Вертикальный разделитель + 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 + } + + // фон строки + 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(row) + + 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) + } + } + + y += rowHeight + } + + // Рамка + for xx := 0; xx < imgWidth; xx++ { + img.Set(xx, 0, borderCol) + img.Set(xx, imgHeight-1, borderCol) + } + for yy := 0; yy < imgHeight; yy++ { + img.Set(0, yy, borderCol) + img.Set(imgWidth-1, yy, borderCol) + } + + return img +} diff --git a/image_table_test.go b/image_table_test.go new file mode 100644 index 0000000..a001da2 --- /dev/null +++ b/image_table_test.go @@ -0,0 +1,72 @@ +package image_table + +import ( + "image/png" + "log" + "os" + "testing" +) + +func TestDrawTable(t *testing.T) { + header := []string{"Имя", "Возраст", "Город"} + + rows := []any{ + []string{"Алиса", "23", "Москва"}, + []string{"Боб", "31", "Казань"}, + struct { + Name string + Age int + City string + }{"Елена", 27, "Минск"}, + []string{"Олег", "44", "Сочи"}, + } + + img := DrawTableWarm(header, rows) + + file, err := os.Create("./test-data/warm_table.png") + if err != nil { + log.Fatal("не могу создать файл:", err) + } + defer file.Close() + + if err := png.Encode(file, img); err != nil { + log.Fatal("ошибка сохранения PNG:", err) + } + + log.Println("Готово! Файл warm_table.png создан.") +} + +func TestDocument(t *testing.T) { + doc := Document{ + Blocks: []TableBlock{ + { + Title: "Пользователи", + Header: []string{"ID", "Name", "Age"}, + Rows: []any{ + []any{1, "Иван", 30}, + []any{2, "Пётр", 25}, + }, + }, + { + Title: "Статистика", + Header: []string{"Метрика", "Значение"}, + Rows: []any{ + []any{"Requests", 12000}, + []any{"Errors", 37}, + }, + }, + }, + } + + img := RenderDocument(doc) + + file, err := os.Create("./test-data/document.png") + if err != nil { + log.Fatal("не могу создать файл:", err) + } + defer file.Close() + + if err := png.Encode(file, img); err != nil { + log.Fatal("ошибка сохранения PNG:", err) + } +}