在一次需求中使用Go做图片处理,简单来说,就是生成一张图片带有水印+文字。使用的, 特定写下此文,介绍一些在Go中处理图片的方式和一些第三库的使用。
对于图片的处理,后端通常来说有两种方式,前端处理的方式我就不列了,因为我也不熟悉。
gg 是一个用于在纯 Go 实现的用于渲染 2D 图形的库,包装的是标准库中的图片处理库。
对于图片处理,常见的需求生产带水印图片,图片裁剪,压缩图片大小。
我们来看看 gg 这个库的简单入门怎么使用
package main
import (
"fmt"
"image/jpeg"
"os"
"github.com/fogleman/gg"
)
func main(){
// 加载图片
waterImage, err := gg.LoadPNG("test_pic.png")
if err != nil {
fmt.Println(err.Error())
return
}
// 通过图片实例化gg
dc := gg.NewContextForImage(waterImage)
dc.SetRGBA(1, 1, 1, 0)
dc.SetRGB(0, 0, 0)
// 加载字体,设置大小
if err := dc.LoadFontFace("robotoLight.ttf", 16); err != nil {
fmt.Println(err.Error())
return
}
// 给图片添加文字,位置在(x, y) 处
s := "hello worldhello"
dc.DrawStringWrapped(s, 10, 25, 0, 0, float64(dc.Width())*0.9, 1.5, gg.AlignLeft)
name := "/Users/yichuan.deng/Pictures/test_pic_1.png"
newfile, err := os.Create(name)
if err != nil {
fmt.Println(err.Error())
return
}
defer newfile.Close()
// 将文件保存输出,并设置压缩比
err = jpeg.Encode(newfile, dc.Image(), &jpeg.Options{Quality: 60})
if err != nil {
fmt.Println(err.Error())
return
}
return
}
上面的例子,是一个最简单的例子,做的事情很简单,就是往一张图片的坐标(10,25)处添加文字,在实际的开发过程中,我们还会遇到一个最典型的问题:能不能自动换行?
在 DrawString
这个方法中,并不能实现自动换行,超出图片宽度的数据就直接没了。。
那么需要自动换行,文字对齐,我们应该怎么做呢?我们可以使用 DrawStringWrapped 方法,这个方法可以自动换行和对齐方式
s := "hello worldhello worldhello worldhello worldhello worldhello worldhello worldhello worldhello world"
// 每个参数的含义:(字符串,x,y,ax,ay,最大多宽开始换行,换行之后怎么对齐)
dc.DrawStringWrapped(s, 10, 25, 0, 0, float64(dc.Width())*0.9, 1.5, gg.AlignLeft)
// x,y,ax,ay 这四个参数有什么用?,DrawStringWrapped 这个函数会结合这四个参数来最终确认字符串应该写在什么坐标,具体为(x-w*ax,y-h*ay),w 为图片的宽度,h为图片的高度
// DrawStringWrapped 实现对齐的方式也是通过设置 (align Align)这个参数来改变 ax,ay的值来实现是左对齐或中间对齐或右对齐
type Align int
const (
AlignLeft Align = iota // 左对齐
AlignCenter // 中间对齐
AlignRight // 右对齐
)
其实这个函数:DrawStringWrapped 底层换行使用了他自己写的一个换行函数:wordWrap:先根据"\n"切分字符串,在根据“ ” 切分字符,保证按照单词切分,最后也是一个一个单词计算有没有超过最大宽度。
那么问题又来了,无论你的业务是在PC上,还是在APP上,你可以展示的图片大小一般是固定的,文字可以换行,但是又不能让他无限换行是吧?我们想设置让他只换多少行怎么设置呢?答案是没有!!!我们只有自己撸一个出来
// truncateText 根据参数切分字符串
// 入参:
// dc: gg的一个实例
// originalText:原始的字符串
// maxTextWidth: 最多宽换行
// align:偏移量,有的字符串是从中间开始写的,所以这个align是针对从中间写的情况
// 返回值:切分之后的字符串
func truncateText(dc *gg.Context, originalText string, maxTextWidth float64, align float64) []string {
result := make([]string, 0)
lineNumber := 0
// 最多只能换几行
maxLine := 2
w, _ := dc.MeasureString(originalText)
// 增加2个像素是为了冗余
if int(w) < int(maxTextWidth)+2 {
return []string{originalText}
}
head := 0
tail := 1
add := false
// 这个for循环就是为了一个一个字符技术,看在哪里可以切分
for tail, _ = range originalText {
s := originalText[head:tail]
// 计算字符串的像素
w, _ = dc.MeasureString(s)
if w+align > maxTextWidth {
add = true
result = append(result, originalText[head:tail])
lineNumber++
head = tail
// 计算是不是剩下的字符串不需再切分了,不需要则直接break循环
w, _ = dc.MeasureString(originalText[tail:])
if w < maxTextWidth {
result = append(result, originalText[tail:])
break
}
}
// 到了最大的行数,则不在切分,直接追加到最后,这有可能最后一的一行如果超出了图片宽度,就会丢失数据
if lineNumber >= maxLine {
result = append(result, originalText[tail:])
break
}
}
// 完全没有切分过,把整个字符串返回
if !add {
result = append(result, originalText)
}
return result
}
我们自己撸的这个函数:truncateText 并没有完全参考源码中的:wordWrap,其实我们可以优化得再好一点,按照单词去切分和换行。
对图片自己水印是非常常见的需求,比如下方的图片,希望水印有透明而且旋转。但是对于gg这个库,我试过了没有太好的方式,所以,针对如果需要实现带水印的图片,可以事先生成好底图,然后再地图上增加文字。可以到达比较好的效果;
设置字体我们使用 gg 的 LoadFontFace 方法,不过这方法每次都会去open这个字体文件,如果频繁生成图片,IO会对生成图片的性能产生较大的影响。
if err := dc.LoadFontFace("robotoLight.ttf", 16); err != nil {
fmt.Println(err.Error())
return
}
这个时候我们要避免重复打开这字体文件,我们需要为这个文件字体生成一个单例,只load一次这个字图文件就好了。具体做法如下
// 声明一个单例
var fontFace font.Face
// init 实例化一次
func init(){
fontFace, err = gg.LoadFontFace("metadata/NotoSansCJKkr-Bold.ttf", points)
if err != nil {
panic(err)
}
}
// 业务逻辑
func bizFunc(){
// .......业务逻辑
// 为gg实例dc设置字体
dc.SetFontFace(fontFace)
// .......业务逻辑
}
前面的内容中,加载字体文件都使用的是 LoadFontFace() 方法进行的,但需要注意的是,这个方法只能加载 ttf 字体文件,也就是 true type font,无法加载 otf 字体文件,也就是 open type font。 所以如果需要加载 otf 字体文件,则需要换一个姿势。
func getOpenTypeFontFace(fontFilePath string, fontSize, dpi float64)(*font.Face, error){
fontData, fontFileReadErr := ioutil.ReadFile(fontFilePath)
if fontFileReadErr != nil {
return nil, fontFileReadErr
}
otfFont, parseErr := opentype.Parse(fontData)
if parseErr != nil {
return nil, parseErr
}
otfFace, newFaceErr := opentype.NewFace(otfFont, &opentype.FaceOptions{
Size: fontSize,
DPI: dpi,
})
if newFaceErr != nil {
return nil, newFaceErr
}
return &otfFace, nil
}
我们都知道,对于图片的处理,肯定是前端会更好处理,但是如果图片需要后端生成时,我们能不能通过直接用代码的方式生成图片,而是通过渲染HTML+CSS之后再浏览器上截图一样生成图片呢?
第三方库:chromedp 就提我们做了这样的事情,帮我买渲染出HTML并且截图保存为文件。图片就会变成使用了 第三方库:chromedp 来渲染HTML,这也有一个缺点,就是后端需要写HTML+CSS了。
package main
import (
"context"
"io/ioutil"
"log"
"github.com/chromedp/chromedp"
)
func main() {
// 生成 chrome 实例
ctx, cancel := chromedp.NewContext(
context.Background(),
chromedp.WithDebugf(log.Printf),
)
defer cancel()
// 设置截图某一个元素
var buf []byte
if err := chromedp.Run(ctx, elementScreenshot(`https://pkg.go.dev/`, `img.Homepage-logo`, &buf)); err != nil {
log.Fatal(err)
}
if err := ioutil.WriteFile("/Users/yichuan.deng/Pictures/elementScreenshot.png", buf, 0o644); err != nil {
log.Fatal(err)
}
// 全部截取,设置压缩比为90%
if err := chromedp.Run(ctx, fullScreenshot(`https://brank.as/`, 90, &buf)); err != nil {
log.Fatal(err)
}
if err := ioutil.WriteFile("/Users/yichuan.deng/Pictures/fullScreenshot.png", buf, 0o644); err != nil {
log.Fatal(err)
}
log.Printf("wrote elementScreenshot.png and fullScreenshot.png")
}
// elementScreenshot 截图页面某一个元素
func elementScreenshot(urlstr, sel string, res *[]byte) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(urlstr),
chromedp.Screenshot(sel, res, chromedp.NodeVisible),
}
}
// fullScreenshot 截图整个屏幕
func fullScreenshot(urlstr string, quality int, res *[]byte) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(urlstr),
chromedp.FullScreenshot(res, quality),
}
}
整体使用感觉来看,gg这个库还是差强人意,能够满足一些基本需求,再深入的话可能就得自己去实现了,毕竟Go只是通用语言,不可能把一些图片处理算法都给你实现好。
要实现复杂的图片处理需求,还是需要通过HTML+CSS+chromedp截图的方式来处理才是比较好的方案。而且这个库不紧紧可以将页面截图为图片,还可以变为PDF,更多的例子可以见:chromedp/examples