diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aa640c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +test.png +face.png +.vscode/launch.json diff --git a/README.md b/README.md index 99a5124..f80c81a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,189 @@ # imagedd -golang绘图库 \ No newline at end of file +golang绘图库 + +## 功能概述 + +imagedd是一个Go语言绘图库,提供了精灵图(Sprite)管理、2D图形绘制、图像渲染等功能。 + +## 主要组件 + +### 精灵图 (Sprite) + +精灵图是基本的渲染单元,包含图像数据和位置信息。 + +```go +type Sprite struct { + Name string // 精灵名称 + Position image.Point // 精灵位置 + Image image.Image // 精灵图像 + Index int // 用于排序的索引 +} +``` + +#### 精灵图操作 + +```go +// 移动精灵 +sprite.Move(x, y int) + +// 旋转精灵 +sprite.Rotate(angle float64) + +// 投影变换 +sprite.Project(projectMatrix *ProjectMatrix) + +// 在精灵上绘制线条 +sprite.DrawLine(line *Line) +``` + +### 精灵板 (NamedSpriteBoard) + +精灵板用于管理多个精灵,提供高效的添加、查找、排序功能。 + +```go +// 创建新的精灵板 +board := sprite.NewNamedSpriteBoard() + +// 添加精灵 +board.AddSprite(sprite) + +// 按名称查找精灵 +foundSprite := board.GetSpriteByName("精灵名称") + +// 获取特定索引的所有精灵 +sprites := board.GetSpritesByIndex(1) + +// 按名称删除精灵 +board.RemoveSpriteByName("精灵名称") + +// 更新精灵的索引 +board.UpdateSpriteIndex("精灵名称", 2) + +// 更新精灵的名称 +board.UpdateSpriteName("旧名称", "新名称") + +// 获取所有精灵(按Index主排序,Name次排序) +allSprites := board.GetAllSprites() + +// 渲染精灵板为图像 +img := board.RenderToImage() + +// 保存为PNG文件 +board.SaveToPng("output.png") +``` + +### 2D图形组件 + +#### 线条 (Line) + +```go +// 创建线条 +line := &sprite.Line{ + Start: image.Point{X: 10, Y: 10}, + End: image.Point{X: 100, Y: 100}, + Width: 2, + Color: color.RGBA{255, 0, 0, 255}, // 红色 +} + +// 添加到精灵 +line.AddToSprite(sprite) +``` + +#### 圆形 (Circle) + +```go +// 创建圆形 +circle := &sprite.Circle{ + Center: image.Point{X: 50, Y: 50}, + Radius: 30, + Color: color.RGBA{0, 0, 255, 255}, // 蓝色 +} + +// 添加到精灵 +circle.AddToSprite(sprite) +``` + +#### 投影矩阵 (ProjectMatrix) + +```go +// 创建投影矩阵 +matrix := &sprite.ProjectMatrix{ + Matrix: [2][2]float64{ + {1.0, 0.5}, + {0.0, 1.0}, + }, +} + +// 投影点 +newPoint := matrix.ProjectPoint(image.Point{X: 10, Y: 20}) + +// 投影线条 +newLine := matrix.ProjectLine(line) +``` + +## 渲染特性 + +- 支持精灵的层级渲染(通过Index控制) +- 支持精灵的名称索引(O(1)时间复杂度查找) +- 支持负坐标位置的精灵 +- 自动计算画布大小以适应所有精灵 +- 处理超出边界的精灵(裁剪) + +## 使用示例 + +```go +package main + +import ( + "image" + "image/color" + + "git.lxtend.com/lixiangwuxian/imagedd/sprite" +) + +func main() { + // 创建精灵板 + board := sprite.NewNamedSpriteBoard() + + // 创建精灵 + sprite1 := &sprite.Sprite{ + Name: "背景", + Position: image.Point{0, 0}, + Index: 0, + } + + // 创建圆形并添加到精灵 + circle := &sprite.Circle{ + Center: image.Point{50, 50}, + Radius: 30, + Color: color.RGBA{0, 0, 255, 255}, + } + circle.AddToSprite(sprite1) + + // 添加到精灵板 + board.AddSprite(sprite1) + + // 创建第二个精灵 + sprite2 := &sprite.Sprite{ + Name: "线条", + Position: image.Point{20, 20}, + Index: 1, + } + + // 创建线条并添加到精灵 + line := &sprite.Line{ + Start: image.Point{0, 0}, + End: image.Point{100, 100}, + Width: 2, + Color: color.RGBA{255, 0, 0, 255}, + } + line.AddToSprite(sprite2) + + // 添加到精灵板 + board.AddSprite(sprite2) + + // 渲染并保存 + board.SaveToPng("output.png") +} +``` \ No newline at end of file diff --git a/font2img/NotoEmoji-VariableFont_wght.svg b/font2img/NotoEmoji-VariableFont_wght.svg new file mode 100644 index 0000000..4d2e969 --- /dev/null +++ b/font2img/NotoEmoji-VariableFont_wght.svg @@ -0,0 +1,13046 @@ + + + + +Created by FontForge 20200511 at Fri Aug 9 17:23:25 2024 + By convertio +Copyright 2013 Google LLC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/font2img/fusion-pixel-12px-monospaced.ttf b/font2img/fusion-pixel-12px-monospaced.ttf new file mode 100644 index 0000000..ea0c426 Binary files /dev/null and b/font2img/fusion-pixel-12px-monospaced.ttf differ diff --git a/font2img/parse_font.go b/font2img/parse_font.go new file mode 100644 index 0000000..d6efba2 --- /dev/null +++ b/font2img/parse_font.go @@ -0,0 +1,886 @@ +package font2img + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "os" + "strings" + "sync" + "unicode" + + "github.com/golang/freetype" + "github.com/golang/freetype/truetype" + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" + + _ "embed" +) + +//go:embed NotoEmoji-VariableFont_wght.svg +var svgEmojiBytes []byte + +//go:embed fusion-pixel-12px-monospaced.ttf +var ttfBytes []byte + +// 全局emoji字体和相关变量 +var ( + svgEmojiPath string + svgEmojiLock sync.RWMutex + + // 添加SVG缓存,避免重复解析相同的SVG文件 + svgCache map[rune][]byte + svgCacheLock sync.RWMutex + + // 保存已加载的SVG字体文件 + svgFontData []byte + svgFontLock sync.RWMutex + svgFontCache *SVG +) + +func init() { + // 初始化SVG缓存 + svgCache = make(map[rune][]byte) + + // 初始化嵌入的SVG字体 + var svg SVG + if err := xml.Unmarshal(svgEmojiBytes, &svg); err == nil { + svgFontLock.Lock() + svgFontData = svgEmojiBytes + svgFontCache = &svg + svgFontLock.Unlock() + } +} + +// SVG表示一个简单的SVG解析结构 +type SVG struct { + Width int `xml:"width,attr"` + Height int `xml:"height,attr"` + ViewBox string `xml:"viewBox,attr"` + Defs struct { + Font struct { + Glyphs []struct { + GlyphName string `xml:"glyph-name,attr"` + Unicode string `xml:"unicode,attr"` + VertAdvY string `xml:"vert-adv-y,attr"` + Path string `xml:"d,attr"` + } `xml:"glyph"` + } `xml:"font"` + } `xml:"defs"` +} + +// SetSVGEmojiPath 设置SVG格式emoji的字体文件路径 +func SetSVGEmojiPath(path string) error { + // 检查文件是否存在 + if _, err := os.Stat(path); os.IsNotExist(err) { + return errors.New("SVG emoji字体文件不存在") + } + + svgEmojiLock.Lock() + defer svgEmojiLock.Unlock() + + // 清除之前的缓存 + svgFontLock.Lock() + svgFontData = nil + svgFontCache = nil + svgFontLock.Unlock() + + svgCacheLock.Lock() + svgCache = make(map[rune][]byte) + svgCacheLock.Unlock() + + svgEmojiPath = path + return nil +} + +// loadSVGEmoji 加载特定码点的SVG Emoji +func loadSVGEmoji(codePoint rune) ([]byte, error) { + // 先从缓存中查找 + svgCacheLock.RLock() + if data, ok := svgCache[codePoint]; ok { + svgCacheLock.RUnlock() + return data, nil + } + svgCacheLock.RUnlock() + + // 缓存中没有,从SVG字体文件加载 + svgFontLock.RLock() + fontData := svgFontData + fontCache := svgFontCache + svgFontLock.RUnlock() + + // 如果没有字体数据,且有指定路径,则从文件加载 + if len(fontData) == 0 { + svgEmojiLock.RLock() + path := svgEmojiPath + svgEmojiLock.RUnlock() + + if path != "" { + // 读取SVG字体文件 + var err error + fontData, err = os.ReadFile(path) + if err != nil { + return nil, err + } + + // 解析SVG字体 + var svg SVG + if err := xml.Unmarshal(fontData, &svg); err != nil { + return nil, err + } + + // 保存到缓存 + svgFontLock.Lock() + svgFontData = fontData + svgFontCache = &svg + fontCache = &svg + svgFontLock.Unlock() + } else { + // 没有外部路径,直接使用嵌入的字体数据 + fontData = svgEmojiBytes + + // 如果未初始化,则解析嵌入的字体 + if fontCache == nil { + var svg SVG + if err := xml.Unmarshal(fontData, &svg); err != nil { + return nil, err + } + + svgFontLock.Lock() + svgFontCache = &svg + fontCache = &svg + svgFontLock.Unlock() + } + } + } + + // 在字体中查找匹配的字形 + targetChar := string(codePoint) + + if fontCache != nil { + for _, glyph := range fontCache.Defs.Font.Glyphs { + if glyph.Unicode == targetChar { + // 创建包含该字形的SVG数据 + glyphSVG := createGlyphSVG(glyph, fontCache) + + // 添加到缓存 + svgCacheLock.Lock() + svgCache[codePoint] = []byte(glyphSVG) + svgCacheLock.Unlock() + + return []byte(glyphSVG), nil + } + } + } + + return nil, errors.New("未找到匹配的SVG字形") +} + +// createGlyphSVG 从字形数据创建独立的SVG +func createGlyphSVG(glyph struct { + GlyphName string `xml:"glyph-name,attr"` + Unicode string `xml:"unicode,attr"` + VertAdvY string `xml:"vert-adv-y,attr"` + Path string `xml:"d,attr"` +}, fontInfo *SVG) string { + // 获取视口尺寸 + viewBox := fontInfo.ViewBox + if viewBox == "" { + viewBox = "0 0 1000 1000" // 默认视口 + } + + // 创建独立的SVG标记 + svgTemplate := ` + +` + + return fmt.Sprintf(svgTemplate, viewBox, glyph.Path) +} + +// renderSVGEmojiToImage 将SVG转换为图像 +func renderSVGEmojiToImage(svgData []byte, size float64, col color.Color) (*image.RGBA, error) { + // 解析SVG以获取尺寸信息 + var svg SVG + if err := xml.Unmarshal(svgData, &svg); err != nil { + return nil, err + } + + // 准备输入数据 + r, g, b, _ := col.RGBA() + hexColor := rgbaToHex(uint8(r>>8), uint8(g>>8), uint8(b>>8)) + + // 替换currentColor为指定的颜色 + svgStr := string(svgData) + svgStr = strings.ReplaceAll(svgStr, "currentColor", hexColor) + + // 创建输出图像 + imgSize := int(size) + if imgSize < 10 { + imgSize = 10 + } + + // 创建一个透明背景的RGBA图像 + rgba := image.NewRGBA(image.Rect(0, 0, imgSize, imgSize)) + draw.Draw(rgba, rgba.Bounds(), image.Transparent, image.Point{}, draw.Src) + + // 直接渲染SVG数据到图像 + err := renderSVGDataToImage([]byte(svgStr), rgba) + if err != nil { + return nil, err + } + + return rgba, nil +} + +// renderSVGDataToImage 使用oksvg库渲染SVG数据到图像 +func renderSVGDataToImage(svgData []byte, dst *image.RGBA) error { + // 从字节数据创建SVG图标 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData), oksvg.StrictErrorMode) + if err != nil { + return err + } + + // 设置图标大小适应目标图像 + bounds := dst.Bounds() + width, height := float64(bounds.Dx()), float64(bounds.Dy()) + + // 创建转换后的图像(先渲染到这里,然后再处理翻转) + tempImg := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) + + // 适应目标大小 + icon.SetTarget(0, width/8, width/2.7, height/2.7) + + // 创建光栅化器 + scanner := rasterx.NewScannerGV(bounds.Dx(), bounds.Dy(), tempImg, tempImg.Bounds()) + raster := rasterx.NewDasher(bounds.Dx(), bounds.Dy(), scanner) + + // 渲染图标 + icon.Draw(raster, 1.0) + + // 只垂直翻转图像(处理上下颠倒问题) + for y := 0; y < bounds.Dy(); y++ { + for x := 0; x < bounds.Dx(); x++ { + // 计算垂直翻转后的坐标 + flippedY := bounds.Dy() - y - 1 + + // 将翻转后的像素复制到目标图像 + dst.Set(x, y, tempImg.At(x, flippedY)) + } + } + + return nil +} + +// rgbaToHex 将RGB颜色分量转换为十六进制字符串 +func rgbaToHex(r, g, b uint8) string { + return "#" + colorToHex(r) + colorToHex(g) + colorToHex(b) +} + +// colorToHex 将单个颜色分量转换为十六进制 +func colorToHex(c uint8) string { + hex := "0123456789ABCDEF" + return string(hex[c>>4]) + string(hex[c&0x0F]) +} + +// IsEmoji 检测一个字符是否是emoji +func IsEmoji(r rune) bool { + // 简单检测一些常见的emoji范围 + return unicode.Is(unicode.So, r) || // Symbol, Other + // 表情符号范围 + (r >= 0x1F600 && r <= 0x1F64F) || // Emoticons + (r >= 0x1F300 && r <= 0x1F5FF) || // Misc Symbols and Pictographs + (r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map + (r >= 0x1F700 && r <= 0x1F77F) || // Alchemical Symbols + (r >= 0x1F780 && r <= 0x1F7FF) || // Geometric Shapes + (r >= 0x1F800 && r <= 0x1F8FF) || // Supplemental Arrows-C + (r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs + (r >= 0x1FA00 && r <= 0x1FA6F) || // Chess Symbols + (r >= 0x1FA70 && r <= 0x1FAFF) || // Symbols and Pictographs Extended-A + // 国旗符号 + (r >= 0x1F1E6 && r <= 0x1F1FF) +} + +// HasGlyph 检查字体是否包含该字符的字形 +func HasGlyph(fontContent *truetype.Font, char rune) bool { + if fontContent == nil { + return false + } + // 尝试获取字符索引 + index := fontContent.Index(char) + return index != 0 // 索引0通常是notdef字形 +} + +// SafeGlyphAdvance 安全获取字符宽度,避免崩溃 +func SafeGlyphAdvance(face font.Face, char rune) (fixed.Int26_6, bool) { + defer func() { + if r := recover(); r != nil { + // 发生panic,忽略 + } + }() + + return face.GlyphAdvance(char) +} + +// SafeGlyphBounds 安全获取字符边界,避免崩溃 +func SafeGlyphBounds(face font.Face, char rune) (fixed.Rectangle26_6, fixed.Int26_6, bool) { + defer func() { + if r := recover(); r != nil { + // 发生panic,忽略 + } + }() + + return face.GlyphBounds(char) +} + +// SafeDrawString 安全绘制字符串,避免崩溃 +func SafeDrawString(ctx *freetype.Context, text string, pt fixed.Point26_6) (fixed.Point26_6, error) { + defer func() { + if r := recover(); r != nil { + // 发生panic,忽略 + } + }() + + return ctx.DrawString(text, pt) +} + +// ParseFont 解析字体文件或使用内嵌字体 +func ParseFont(fontPath string) (*truetype.Font, error) { + // 如果提供了路径,尝试从文件加载 + if fontPath != "" { + fontBytes, err := os.ReadFile(fontPath) + if err != nil { + return nil, err + } + fontCache, err := truetype.Parse(fontBytes) + return fontCache, err + } + + // 没有提供路径,使用嵌入的TTF字体 + fontCache, err := truetype.Parse(ttfBytes) + return fontCache, err +} + +// RenderCharToImage 将单个字符渲染为图像 +// fontSize: 字体大小(点数) +// textColor: 字体颜色 +// 返回字符的图像 +func RenderCharToImage(fontContent *truetype.Font, char rune, fontSize float64, textColor color.Color) (*image.RGBA, error) { + // 确保字体已加载 + if fontContent == nil { + // 尝试使用嵌入的字体 + var err error + fontContent, err = truetype.Parse(ttfBytes) + if err != nil { + return nil, ErrFontNotLoaded + } + } + + // 检查是否是emoji,且应该使用SVG格式 + if IsEmoji(char) { + // 尝试加载SVG emoji + svgData, err := loadSVGEmoji(char) + if err == nil { + // 成功加载SVG,渲染为图像 + return renderSVGEmojiToImage(svgData, fontSize, textColor) + } + // 如果加载失败,回退到使用普通emoji字体 + return createPlaceholderImage(fontSize, textColor), nil + } + + // 创建字体上下文 + c := freetype.NewContext() + c.SetDPI(72) // 标准DPI + c.SetFont(fontContent) + c.SetFontSize(fontSize) + c.SetHinting(font.HintingFull) + + // 计算字符尺寸 + opts := truetype.Options{ + Size: fontSize, + DPI: 72, + Hinting: font.HintingFull, + } + face := truetype.NewFace(fontContent, &opts) + + // 测量单个字符宽度 + charWidth, ok := SafeGlyphAdvance(face, char) + if !ok { + // 无法获取字符宽度,使用替代图像 + return createPlaceholderImage(fontSize, textColor), nil + } + widthInPixels := charWidth.Ceil() + if widthInPixels <= 0 { + widthInPixels = int(fontSize / 2) + } + + // 计算字体高度和基线位置 + bounds, _, ok := SafeGlyphBounds(face, char) + if !ok { + // 无法获取字符边界,使用替代图像 + return createPlaceholderImage(fontSize, textColor), nil + } + height := (bounds.Max.Y - bounds.Min.Y).Ceil() + if height <= 0 { + height = int(fontSize) + } + + // 添加少量额外空间确保字符完全显示 + heightInPixels := height + int(fontSize/8) + + // 创建图像,确保有足够空间 + rgba := image.NewRGBA(image.Rect(0, 0, widthInPixels, heightInPixels)) + draw.Draw(rgba, rgba.Bounds(), image.Transparent, image.Point{}, draw.Src) + + // 设置绘图属性 + c.SetDst(rgba) + c.SetClip(rgba.Bounds()) + c.SetSrc(image.NewUniform(textColor)) + + // 计算正确的基线位置 + // 基线应该在字符下降部分的上方 + baseline := (-bounds.Min.Y).Ceil() + + // 绘制字符,Y坐标需要考虑一个偏移以确保完整显示 + pt := fixed.Point26_6{ + X: fixed.Int26_6(0 << 6), + Y: fixed.Int26_6(baseline << 6), + } + _, err := SafeDrawString(c, string(char), pt) + if err != nil { + return nil, err + } + + // 裁剪图像,去除多余的空白 + trimmedRGBA := trimImage(rgba) + return trimmedRGBA, nil +} + +// trimImage 裁剪图像,去除底部、顶部、左侧和右侧的空白 +func trimImage(img *image.RGBA) *image.RGBA { + bounds := img.Bounds() + min, max := bounds.Min, bounds.Max + + // 找到字符的实际底部(最后一个非透明像素的行) + bottom := min.Y + for y := max.Y - 1; y >= min.Y; y-- { + isEmpty := true + for x := min.X; x < max.X; x++ { + _, _, _, a := img.At(x, y).RGBA() + if a > 0 { + isEmpty = false + break + } + } + if !isEmpty { + bottom = y + 1 // 找到最后一个非空行 + break + } + } + + // 找到字符的实际顶部(第一个非透明像素的行) + top := max.Y - 1 + for y := min.Y; y < max.Y; y++ { + isEmpty := true + for x := min.X; x < max.X; x++ { + _, _, _, a := img.At(x, y).RGBA() + if a > 0 { + isEmpty = false + break + } + } + if !isEmpty { + top = y // 找到第一个非空行 + break + } + } + + // 找到字符的实际左侧边缘(第一个非透明像素的列) + left := max.X - 1 + for x := min.X; x < max.X; x++ { + isEmpty := true + for y := min.Y; y < max.Y; y++ { + _, _, _, a := img.At(x, y).RGBA() + if a > 0 { + isEmpty = false + break + } + } + if !isEmpty { + left = x // 找到第一个非空列 + break + } + } + + // 找到字符的实际右侧边缘(最后一个非透明像素的列) + right := min.X + for x := max.X - 1; x >= min.X; x-- { + isEmpty := true + for y := min.Y; y < max.Y; y++ { + _, _, _, a := img.At(x, y).RGBA() + if a > 0 { + isEmpty = false + break + } + } + if !isEmpty { + right = x + 1 // 找到最后一个非空列 + break + } + } + + // 防止空图像情况 + if top >= bottom || left >= right { + return img // 返回原图 + } + + // 创建新图像并复制内容 + newWidth := right - left + newHeight := bottom - top + newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + for y := 0; y < newHeight; y++ { + for x := 0; x < newWidth; x++ { + newImg.Set(x, y, img.At(x+left, y+top)) + } + } + + return newImg +} + +// RenderTextToImage 将一段文本渲染为图像 +// text: 要渲染的文本,支持换行符 +// fontSize: 字体大小(点数) +// textColor: 字体颜色 +// charSpacing: 字符间距(像素) +// lineSpacing: 行间距(像素) +// 返回渲染后的图像 +func RenderTextToImage(fontContent *truetype.Font, text string, fontSize float64, textColor color.Color, charSpacing, lineSpacing int) (*image.RGBA, error) { + // 确保字体已加载 + if fontContent == nil { + // 尝试使用嵌入的字体 + var err error + fontContent, err = truetype.Parse(ttfBytes) + if err != nil { + return nil, ErrFontNotLoaded + } + } + + // 检查是否有SVG emoji数据 + hasSVGData := false + svgFontLock.RLock() + hasSVGData = svgFontCache != nil + svgFontLock.RUnlock() + + // 首先需要计算整个文本的尺寸 + lines := splitTextIntoLines(text) + if len(lines) == 0 { + return image.NewRGBA(image.Rect(0, 0, 1, 1)), nil // 返回1x1空图像 + } + + // 创建字体配置 + opts := truetype.Options{ + Size: fontSize, + DPI: 72, + Hinting: font.HintingFull, + } + face := truetype.NewFace(fontContent, &opts) + + // 计算每行的宽度和整体高度 + var maxWidth int + lineHeights := make([]int, len(lines)) + lineBounds := make([]fixed.Rectangle26_6, len(lines)) + + for i, line := range lines { + var lineWidth int + // 计算一行的宽度 + for _, char := range line { + // 决定使用哪个字体 + currentFace := face + + // 处理SVG emoji + if hasSVGData && IsEmoji(char) { + // 对于SVG emoji,使用固定宽度 + lineWidth += int(fontSize) + charSpacing + continue + } + + advance, ok := SafeGlyphAdvance(currentFace, char) + if !ok { + // 如果无法获取宽度,使用默认宽度 + lineWidth += int(fontSize/2) + charSpacing + continue + } + lineWidth += advance.Ceil() + charSpacing + } + + // 如果一行有字符,减去最后一个字符后多加的间距 + if len(line) > 0 { + lineWidth -= charSpacing + } + + // 计算行的边界,用于高度计算 + lineBound := fixed.Rectangle26_6{ + Min: fixed.Point26_6{X: 0, Y: fixed.Int26_6(-int(fontSize) << 6)}, + Max: fixed.Point26_6{X: 0, Y: fixed.Int26_6(int(fontSize) << 6)}, + } + + for _, char := range line { + // 如果是SVG emoji,跳过边界计算 + if hasSVGData && IsEmoji(char) { + continue + } + + // 决定使用哪个字体 + currentFace := face + + bound, _, ok := SafeGlyphBounds(currentFace, char) + if !ok { + continue + } + if bound.Min.Y < lineBound.Min.Y { + lineBound.Min.Y = bound.Min.Y + } + if bound.Max.Y > lineBound.Max.Y { + lineBound.Max.Y = bound.Max.Y + } + } + + lineBounds[i] = lineBound + lineHeight := int(fontSize) + lineHeights[i] = lineHeight + + if lineWidth > maxWidth { + maxWidth = lineWidth + } + } + + // 防止创建太小的图像 + if maxWidth <= 0 { + maxWidth = int(fontSize) + } + + // 计算总高度(每行高度+行间距) + totalHeight := 0 + for _, height := range lineHeights { + totalHeight += height + lineSpacing + 1 + } + // 减去最后一行多加的行间距 + if len(lineHeights) > 0 { + totalHeight -= lineSpacing + } + + // 防止创建太小的图像 + if totalHeight <= 0 { + totalHeight = int(fontSize) + } + + // 创建图像 + rgba := image.NewRGBA(image.Rect(0, 0, maxWidth, totalHeight)) + draw.Draw(rgba, rgba.Bounds(), image.Transparent, image.Point{}, draw.Src) + + // 设置字体上下文 + c := freetype.NewContext() + c.SetDPI(72) + c.SetFontSize(fontSize) + c.SetHinting(font.HintingFull) + c.SetDst(rgba) + c.SetClip(rgba.Bounds()) + c.SetSrc(image.NewUniform(textColor)) + + // 渲染每一行文本 + y := 0 + for i, line := range lines { + if len(line) == 0 { + y += lineHeights[i] + lineSpacing + continue + } + + // 计算基线位置 + baseline := y + (-lineBounds[i].Min.Y).Ceil() + + // 绘制每个字符 + x := 0 + for _, char := range line { + // 如果是SVG格式的emoji,单独处理 + if hasSVGData && IsEmoji(char) { + svgData, err := loadSVGEmoji(char) + if err == nil { + // 渲染SVG emoji + emojiImg, err := renderSVGEmojiToImage(svgData, fontSize, textColor) + if err == nil { + emojiBounds := emojiImg.Bounds() + draw.Draw(rgba, + image.Rect(x, baseline-int(fontSize), x+emojiBounds.Dx(), baseline), + emojiImg, + image.Point{}, + draw.Over) + x += emojiBounds.Dx() + charSpacing + continue + } + } + // 如果加载或渲染SVG失败,回退到使用普通方式 + } + + // 决定使用哪个字体 + currentFont := fontContent + currentFace := face + useCurrentFont := true + + if useCurrentFont { + // 设置当前字符的字体 + c.SetFont(currentFont) + + // 绘制字符 + pt := fixed.Point26_6{ + X: fixed.Int26_6(x << 6), + Y: fixed.Int26_6(baseline << 6), + } + + width, ok := SafeGlyphAdvance(currentFace, char) + if !ok { + // 无法获取宽度,使用默认宽度 + x += int(fontSize/2) + charSpacing + continue + } + + _, err := SafeDrawString(c, string(char), pt) + if err != nil { + // 绘制失败,使用替代方法 + drawPlaceholder(rgba, x, baseline, int(fontSize), textColor) + x += int(fontSize/2) + charSpacing + continue + } + + // 移动到下一个字符位置 + x += width.Ceil() + charSpacing + } else { + // 不能使用当前字体,绘制占位符 + drawPlaceholder(rgba, x, baseline, int(fontSize/2), textColor) + x += int(fontSize/2) + charSpacing + } + } + + // 移动到下一行 + y += lineHeights[i] + lineSpacing + } + + return rgba, nil +} + +// RenderTextToTrimmedImage 将一段文本渲染为图像并裁剪多余空白 +// 与RenderTextToImage功能相同,但会自动裁剪图像边缘的空白 +func RenderTextToTrimmedImage(fontContent *truetype.Font, text string, fontSize float64, textColor color.Color, charSpacing, lineSpacing int) (*image.RGBA, error) { + // 如果字体为nil,尝试使用嵌入的字体 + if fontContent == nil { + var err error + fontContent, err = truetype.Parse(ttfBytes) + if err != nil { + return nil, ErrFontNotLoaded + } + } + + img, err := RenderTextToImage(fontContent, text, fontSize, textColor, charSpacing, lineSpacing) + if err != nil { + return nil, err + } + + // 裁剪图像四周的空白 + return trimImage(img), nil +} + +// splitTextIntoLines 将文本按换行符分割成行 +func splitTextIntoLines(text string) [][]rune { + lines := [][]rune{} + currentLine := []rune{} + + for _, char := range text { + if char == '\n' { + lines = append(lines, currentLine) + currentLine = []rune{} + } else { + currentLine = append(currentLine, char) + } + } + + // 添加最后一行 + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + + return lines +} + +// ErrFontNotLoaded 表示字体未加载错误 +var ErrFontNotLoaded = ErrorMessage("字体未加载,请先调用ParseFont加载字体") + +// ErrorMessage 是一个自定义错误类型 +type ErrorMessage string + +func (e ErrorMessage) Error() string { + return string(e) +} + +// createPlaceholderImage 创建替代图像,用于不支持的字符 +func createPlaceholderImage(fontSize float64, textColor color.Color) *image.RGBA { + size := int(fontSize) + if size < 10 { + size = 10 + } + + // 创建正方形图像 + img := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src) + + // 绘制边框 + for x := 0; x < size; x++ { + img.Set(x, 0, textColor) + img.Set(x, size-1, textColor) + } + for y := 0; y < size; y++ { + img.Set(0, y, textColor) + img.Set(size-1, y, textColor) + } + + // 绘制对角线 + for i := 0; i < size; i++ { + if i < size { + img.Set(i, i, textColor) + img.Set(i, size-i-1, textColor) + } + } + + return img +} + +// drawPlaceholder 在图像上绘制占位符 +func drawPlaceholder(img *image.RGBA, x, y, size int, c color.Color) { + halfSize := size / 2 + + // 绘制一个简单的方框 + for i := 0; i < size; i++ { + img.Set(x+i, y-halfSize, c) // 顶边 + img.Set(x+i, y+halfSize, c) // 底边 + } + + for i := -halfSize; i <= halfSize; i++ { + img.Set(x, y+i, c) // 左边 + img.Set(x+size-1, y+i, c) // 右边 + } + + // 绘制对角线 + for i := 0; i < size; i++ { + y1 := y - halfSize + i + y2 := y + halfSize - i + + if y1 >= 0 && y1 < img.Bounds().Max.Y && + y2 >= 0 && y2 < img.Bounds().Max.Y { + img.Set(x+i, y1, c) + img.Set(x+i, y2, c) + } + } +} diff --git a/go.mod b/go.mod index 259920e..c5c3a9b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module git.lxtend.com/lixiangwuxian/imagedd go 1.24.2 + +require github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 + +require golang.org/x/image v0.27.0 + +require ( + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect + golang.org/x/text v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..57d4894 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0= +golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..37b131a --- /dev/null +++ b/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "image" + "image/color" + "log" + + "git.lxtend.com/lixiangwuxian/imagedd/font2img" + "git.lxtend.com/lixiangwuxian/imagedd/sprite" +) + +func main() { + board := sprite.NewNamedSpriteBoard() + { + whiteBackground := image.NewRGBA(image.Rect(0, 0, 200, 200)) + for i := range whiteBackground.Bounds().Dx() { + for j := range whiteBackground.Bounds().Dy() { + whiteBackground.Set(i, j, color.White) + } + } + board.AddSprite(&sprite.Sprite{ + Name: "background", + Image: whiteBackground, + Index: 0, + Position: image.Point{X: 0, Y: 0}, + }) + } + // { + // wordSprite := &sprite.Sprite{ + // Name: "word", + // Index: 5, + // Position: image.Point{X: 0, Y: 0}, + // } + // img, err := font2img.RenderCharToImage(font, '🤣', 90, color.Black) + // if err != nil { + // log.Fatal(err) + // } + // wordSprite.Image = img + // board.AddSprite(wordSprite) + // } + { + textSprite := &sprite.Sprite{ + Name: "text", + Index: 5, + Position: image.Point{X: 10, Y: 12}, + } + img, err := font2img.RenderTextToTrimmedImage(nil, "usss测试\n测试📧测✌试测\n试🥳🧁🍰\n🎁🎂🎈🎺🎉🎊\n📧🧿🌶无焊无缝🔋😂❤😍🤣😊🥺\n🙏💕😭😘👍\n😅👏测试测试", 12, color.Black, 0, 0) + if err != nil { + log.Fatal(err) + } + textSprite.Image = img + board.AddSprite(textSprite) + } + // { + // faceBytes, err := os.ReadFile("face.png") + // if err != nil { + // log.Fatal(err) + // } + // faceImage, err := png.Decode(bytes.NewReader(faceBytes)) + // if err != nil { + // log.Fatal(err) + // } + // faceSprite := &sprite.Sprite{ + // Name: "face", + // Image: faceImage, + // Index: 1, + // Position: image.Point{X: -100, Y: -100}, + // } + // faceSprite.Rotate(math.Pi / 2) + // board.AddSprite(faceSprite) + // } + // { + // rect := image.NewRGBA(image.Rect(0, 0, 101, 101)) + // rectSprite := &sprite.Sprite{ + // Name: "rect", + // Image: rect, + // Index: 2, + // Position: image.Point{X: 20, Y: 20}, + // } + // lineL := &sprite.Line{ + // Start: image.Point{X: 0, Y: 100}, + // End: image.Point{X: 50, Y: 0}, + // Width: 1, + // Color: color.RGBA{0, 0, 0, 255}, + // } + // lineL.AddToSprite(rectSprite) + // lineR := &sprite.Line{ + // Start: image.Point{X: 100, Y: 100}, + // End: image.Point{X: 50, Y: 0}, + // Width: 1, + // Color: color.RGBA{0, 0, 0, 255}, + // } + // lineR.AddToSprite(rectSprite) + // lineB := &sprite.Line{ + // Start: image.Point{X: 0, Y: 100}, + // End: image.Point{X: 100, Y: 100}, + // Width: 1, + // Color: color.RGBA{0, 0, 0, 255}, + // } + // lineB.AddToSprite(rectSprite) + // board.AddSprite(rectSprite) + // } + // { + // circleSprite := &sprite.Sprite{ + // Name: "circle", + // Image: image.NewRGBA(image.Rect(0, 0, 101, 101)), + // Index: 3, + // Position: image.Point{X: 30, Y: 20}, + // } + // for r := 15; r < 45; r++ { + // circle := &sprite.Circle{ + // Center: image.Point{X: 50, Y: 50}, + // Radius: r, + // Color: color.RGBA{0, 0, 255, 255}, + // } + // circle.AddToSprite(circleSprite) + // } + // board.AddSprite(circleSprite) + // } + board.SaveToPng("test.png") +}