feat: add text-to-image conversion utility with Markdown and HTML support

This commit is contained in:
lixiangwuxian 2025-03-10 17:44:21 +08:00
parent d464f18fbd
commit da77357401
3 changed files with 205 additions and 0 deletions

2
go.mod
View File

@ -45,6 +45,7 @@ require (
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@ -62,6 +63,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
go.opentelemetry.io/otel v1.31.0 // indirect go.opentelemetry.io/otel v1.31.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect

4
go.sum
View File

@ -73,6 +73,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e h1:ESHlT0RVZphh4JGBz49I5R6nTdC8Qyc08vU25GQHzzQ=
github.com/gomarkdown/markdown v0.0.0-20250207164621-7a1f277a159e/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -163,6 +165,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=

199
util/text_to_pic.go Normal file
View File

@ -0,0 +1,199 @@
package util
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"github.com/fogleman/gg"
"github.com/yuin/goldmark"
"golang.org/x/net/html"
)
const (
defaultWidth = 800
defaultMargin = 20
lineHeight = 1.5
fontSize = 14
maxImageWidth = 600
maxImageHeight = 400
)
type ContentBlock struct {
Type string // "text" or "image"
Content string // text content or image path
ImgWidth float64 // image width after resize
ImgHeight float64 // image height after resize
Image image.Image // loaded image
}
// TextToPicture 将文本转换为图片
func TextToPicture(text string, isMarkdown bool) (image.Image, error) {
var htmlContent string
if isMarkdown {
// 将Markdown转换为HTML
md := goldmark.New()
var buf bytes.Buffer
if err := md.Convert([]byte(text), &buf); err != nil {
return nil, err
}
htmlContent = buf.String()
} else {
htmlContent = text
}
// 解析HTML并提取内容块
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return nil, err
}
var blocks []ContentBlock
var extractContent func(*html.Node)
extractContent = func(n *html.Node) {
if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" {
blocks = append(blocks, ContentBlock{
Type: "text",
Content: text,
})
}
} else if n.Type == html.ElementNode && n.Data == "img" {
// 处理图片节点
var src string
for _, attr := range n.Attr {
if attr.Key == "src" {
src = attr.Val
break
}
}
if src != "" && !strings.HasPrefix(src, "http") {
// 处理本地图片
img, w, h, err := loadAndResizeImage(src)
if err == nil {
blocks = append(blocks, ContentBlock{
Type: "image",
Content: src,
Image: img,
ImgWidth: w,
ImgHeight: h,
})
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
extractContent(c)
}
}
extractContent(doc)
// 计算总高度
totalHeight := calculateTotalHeight(blocks)
// 创建图片上下文
dc := gg.NewContext(defaultWidth, totalHeight)
dc.SetRGB(1, 1, 1) // 设置白色背景
dc.Clear()
// 设置字体和颜色
if err := dc.LoadFontFace("./resource/fonts/SourceHanSansCN-Regular.otf", fontSize); err != nil {
return nil, err
}
dc.SetColor(color.Black)
// 绘制内容
y := float64(defaultMargin)
for _, block := range blocks {
if block.Type == "text" {
wrapped := dc.WordWrap(block.Content, float64(defaultWidth-2*defaultMargin))
for _, wrappedLine := range wrapped {
dc.DrawString(wrappedLine, float64(defaultMargin), y+fontSize)
y += fontSize * lineHeight
}
y += fontSize // 段落间额外间距
} else if block.Type == "image" {
// 居中绘制图片
x := (float64(defaultWidth) - block.ImgWidth) / 2
dc.DrawImage(block.Image, int(x), int(y))
y += block.ImgHeight + float64(defaultMargin) // 图片后添加间距
}
}
return dc.Image(), nil
}
// loadAndResizeImage 加载并调整图片大小
func loadAndResizeImage(path string) (image.Image, float64, float64, error) {
// 确保路径是绝对路径
if !filepath.IsAbs(path) {
path = filepath.Join(".", path)
}
file, err := os.Open(path)
if err != nil {
return nil, 0, 0, err
}
defer file.Close()
// 解码图片
var img image.Image
if strings.HasSuffix(strings.ToLower(path), ".png") {
img, err = png.Decode(file)
} else {
img, err = jpeg.Decode(file)
}
if err != nil {
return nil, 0, 0, err
}
// 计算调整后的尺寸
bounds := img.Bounds()
origWidth := float64(bounds.Dx())
origHeight := float64(bounds.Dy())
var newWidth, newHeight float64
if origWidth > maxImageWidth {
ratio := maxImageWidth / origWidth
newWidth = maxImageWidth
newHeight = origHeight * ratio
} else {
newWidth = origWidth
newHeight = origHeight
}
if newHeight > maxImageHeight {
ratio := maxImageHeight / newHeight
newHeight = maxImageHeight
newWidth = newWidth * ratio
}
// 创建新的图片上下文并调整大小
dc := gg.NewContext(int(newWidth), int(newHeight))
dc.DrawImage(img, 0, 0)
return dc.Image(), newWidth, newHeight, nil
}
// calculateTotalHeight 计算总高度
func calculateTotalHeight(blocks []ContentBlock) int {
var totalHeight float64 = float64(2 * defaultMargin) // 上下边距
for _, block := range blocks {
if block.Type == "text" {
// 估算文本高度
estimatedLines := (len(block.Content)*fontSize)/(defaultWidth-2*defaultMargin) + 1
totalHeight += float64(estimatedLines) * fontSize * lineHeight
totalHeight += fontSize // 段落间距
} else if block.Type == "image" {
totalHeight += block.ImgHeight + float64(defaultMargin) // 图片高度加间距
}
}
return int(totalHeight)
}