alpha
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test-data
|
||||||
BIN
assets/jb.ttf
Normal file
BIN
assets/jb.ttf
Normal file
Binary file not shown.
109
document.go
Normal file
109
document.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -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
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||||
228
image_table.go
Normal file
228
image_table.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
72
image_table_test.go
Normal file
72
image_table_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user