【Go語言繪圖】圖片新增文字(一)
阿新 • • 發佈:2020-12-20
前一篇講解了利用gg包來進行圖片旋轉的操作,這一篇我們來看看怎麼在圖片上新增文字。
## 繪製純色背景
首先,我們先繪製一個純白色的背景,作為新增文字的背景板。
```go
package main
import "github.com/fogleman/gg"
func main() {
const S = 1024
dc := gg.NewContext(S, S)
dc.SetRGB(0, 1, 1)
dc.Clear()
dc.SavePNG("out.png")
}
```
輸出圖片如下:
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103606900-2129749930.png)
這樣我就得到了一張純青色的背景圖。回顧一下上一篇裡繪製背景圖的步驟:
```go
func TestRotateImage(t *testing.T) {
width := 1000
height := 1000
dc := gg.NewContext(width, height)
dc.DrawRectangle(0, 0, float64(width), float64(width))
dc.SetRGB255(255, 255, 0)
dc.Fill()
dc.SavePNG("test.png")
}
```
我們是通過先繪製跟畫布同樣大小的矩形,然後將它的顏色進行填充來實現純色背景效果的,但實際上使用 `Clear()` 方法便能直接使用當前顏色對畫布進行填充。
檢視一下 `Clear()` 方法便能發現,裡面是通過呼叫 `draw.Draw()` 函式來實現的,這也是go語言自帶的 `image` 包裡很有用的一個函式,後面會有文章來做更詳細的介紹。簡單來說,`Clear()` 方法是通過呼叫`draw.Draw()` 函式,通過將純色圖片覆蓋到原畫布的方式來實現純色背景的效果的。
```go
// Clear fills the entire image with the current color.
func (dc *Context) Clear() {
src := image.NewUniform(dc.color)
draw.Draw(dc.im, dc.im.Bounds(), src, image.ZP, draw.Src)
}
```
## 新增文字
背景板已經準備就緒,接下來,我們來新增一些文字。
```go
package main
import "github.com/fogleman/gg"
func main() {
const S = 1024
dc := gg.NewContext(S, S)
dc.SetRGB(0, 1, 1)
dc.Clear()
dc.SetRGB(0, 0, 0)
if err := dc.LoadFontFace("gilmer-heavy.ttf", 120); err != nil {
panic(err)
}
dc.DrawString("Hello, world!", 0, S/2)
dc.SavePNG("out.png")
}
```
輸出如下,一個碩大、黑色的“Hello, World!”就出現在了圖片中央。
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103619032-791325341.png)
這裡我們添加了三個步驟,首先是設定了字型顏色為黑色。
```go
dc.SetRGB(0, 0, 0)
```
然後載入了字型檔案,這裡需要注意的是,通過 `LoadFontFace()` 方法載入的字型檔案只支援 `ttf` 字尾的檔案,也就是 `true type font`,
```go
if err := dc.LoadFontFace("gilmer-heavy.ttf", 120); err != nil {
panic(err)
}
```
裡面的實現也比較簡單:
```go
func (dc *Context) LoadFontFace(path string, points float64) error {
face, err := LoadFontFace(path, points)
if err == nil {
dc.fontFace = face
dc.fontHeight = points * 72 / 96
}
return err
}
```
內部呼叫了 `LoadFontFace()` 函式,在這個函式內部進行了字型檔案讀取,並用 `freetype` 包裡的`Parse()`函式進行字型的載入,最後在呼叫 `NewFace()` 函式來建立一個 `font.Face` 物件,在外面的`LoadFontFace()`方法裡,將這個物件儲存在 `fontFace` 欄位中,並且根據傳入的`point`大小設定了一下字型高度。
至於為什麼是乘以`72`然後除以`96`,這個查了一下資料,簡單的說,字型的大小單位磅(`points`) 是`1/72`邏輯英寸,螢幕的解析度是`96DPI`(96點每邏輯英寸),那麼螢幕每個點就是`72/96`=`0.75`磅。
```go
func LoadFontFace(path string, points float64) (font.Face, error) {
fontBytes, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
f, err := truetype.Parse(fontBytes)
if err != nil {
return nil, err
}
face := truetype.NewFace(f, &truetype.Options{
Size: points,
// Hinting: font.HintingFull,
})
return face, nil
}
```
### 調整字型大小
如果想調整字型大小,也很簡單,只需要調整`LoadFontFace()` 方法傳入的值即可,讓我們來調大一點字型看看效果。
```go
if err := dc.LoadFontFace("gilmer-heavy.ttf", 240); err != nil {
panic(err)
}
```
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103632597-2095921297.png)
這樣就大很多了。不知道聰明的你注意到了沒有,在呼叫`dc.DrawString("Hello, world!", 0, S/2)`時,我們設定的座標是 `(0, S/2)` ,也就是左側邊的正中心點,**這個位置剛好是繪製出來的文字的左下角的座標**,這是需要注意的一點。
### 居中顯示
如果想要文字居中顯示怎麼辦呢?比如我們想要這個 `Hello,World!` 顯示在圖片的正中央,要怎麼處理呢?一個笨辦法當然是通過調整字型位置來實現這個效果,讓我們先來試試:
```go
if err := dc.LoadFontFace("gilmer-heavy.ttf", 120); err != nil {
panic(err)
}
dc.DrawString("Hello, world!", 130, S/2)
```
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103644790-112950877.png)
通過多次調整,字型大小設定為`120`時,`x`的位置設定為`130`,基本上可以看起來是居中的。但這樣的話每次換文字都得反覆調整位置,顯然不科學。
別慌,有一個方法可以得到文字的寬度,`MeasureString()` 可以得到在當前字型下指定字串的寬度和高度,這個高度其實就是前面通過 `points * 72 / 96` 計算得到的,然後我們再將左下角的位置設定為`((S-sWidth)/2, (S+sHeight)/2)`即可實現文字居中的效果,注意y軸座標是`(S+sHeight)/2`,因為文字的左上頂點位置y軸座標應該是`(S-sHeight)/2`,左下頂點座標只需要再加上字型高度即可得出。
```go
s := "Hello, world!"
sWidth, sHeight := dc.MeasureString(s)
dc.DrawString(s, (S-sWidth)/2, (S+sHeight)/2)
```
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103653841-618179025.png)
這樣看來,居中顯示也不過如此嘛。但別高興的太早,有沒有想過,如果文字過長該怎麼處理?比如我們來調整一下文字內容,再看下生成的效果。
```go
s := "Hello,world! Hello,ByteDancer!"
```
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103702978-1037840116.png)
文字已經超出邊界了,顯然不是理想的效果,這個時候有兩種處理方法,一種是新增省略號,一種是換行。
### 單行長文字處理
先來說一下新增省略號的處理方案,聽起來好像挺簡單,但實際上處理起來也挺麻煩的。
首先需要確定一個文字展示的最大寬度,因為如果滿打滿算整行都塞滿文字顯然不好看。其次是要逐個字元進行寬度計算,並判斷是否會超過最大寬度,最後擷取並保留剛好小於最大寬度時的字串(需要考慮省略號的寬度)。
我們來逐個處理。首先拍腦袋定一個文字最大寬度為圖片寬度的`0.75`倍。
```go
maxTextWidth := S * 0.75
```
然後來逐個字元計算寬度,直到剛好大於最大寬度為止。
```go
func TruncateText(dc *gg.Context, originalText string, maxTextWidth float64) string {
tmpStr := ""
for i := 0; i < len(originalText); i++ {
tmpStr = tmpStr + string(originalText[i])
w, _ := dc.MeasureString(tmpStr)
if w > maxTextWidth {
return tmpStr[0 : i-1]
}
}
return tmpStr
}
```
然後我們調整一下呼叫的地方。
```go
func main() {
const S = 1024
dc := gg.NewContext(S, S)
dc.SetRGB(0, 1, 1)
dc.Clear()
dc.SetRGB(0, 0, 0)
if err := dc.LoadFontFace("gilmer-heavy.ttf", 120); err != nil {
panic(err)
}
s := "Hello,world! Hello,ByteDancer!"
ellipsisWidth, _ := dc.MeasureString("...")
maxTextWidth := S * 0.75
s = TruncateText(dc, s, maxTextWidth - ellipsisWidth) + "..."
fmt.Println(s)
sWidth, sHeight := dc.MeasureString(s)
dc.DrawString(s, (S-sWidth)/2, (S+sHeight)/2)
dc.SavePNG("out.png")
}
```
這裡我們先計算了省略號的寬度,然後用最大字串寬度減去省略號寬度作為最大寬度傳入,得到最終要展示的字串。生成的效果如下:
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103715302-973132336.png)
看起來好像沒什麼毛病,但如果我們把文字換成中文,情況可能就不一樣了。我們換一箇中文字型,然後把字串設定成中文。
```go
if err := dc.LoadFontFace("方正楷體簡體.ttf", 120); err != nil {
panic(err)
}
s := "如果我們把文字換成中文"
```
就變成了這個樣子。
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103725063-1114226624.png)
發現圖片上只剩下了省略號,原因是中文字串分割不正確導致出現了亂碼,而這個亂碼在字型裡找不到對應的文字,所以無法展示。這時,需要先將字串先轉化為`rune`陣列,或者通過直接對字串使用 `for range` 遍歷,可以避免在中文的情況出現亂碼的情況。
```go
func TruncateText(dc *gg.Context, originalText string, maxTextWidth float64) string {
tmpStr := ""
result := make([]rune, 0)
for _, r := range originalText {
tmpStr = tmpStr + string(r)
w, _ := dc.MeasureString(tmpStr)
if w > maxTextWidth {
if len(tmpStr) <= 1 {
return ""
} else {
break
}
} else {
result = append(result, r)
}
}
return string(result)
}
```
這樣文字就能按照我們的預期進行展示了。
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103735177-1026663341.png)
### 多行文字處理
接下來,我們來看看怎麼處理多行文字,即當一行文字展示不下時,把文字切割成多行進行展示。如果我們仍舊使用之前的方法來處理的話,就需要先計算好每行展示的字以及行數,然後再進行展示。
```go
package main
import (
"github.com/fogleman/gg"
"strings"
)
func main() {
const S = 1024
dc := gg.NewContext(S, S)
dc.SetRGB(0, 1, 1)
dc.Clear()
dc.SetRGB(0, 0, 0)
if err := dc.LoadFontFace("/Users/bytedance/Downloads/方正楷體簡體.ttf", 120); err != nil {
panic(err)
}
s := "這是我的一個祕密,再簡單不過的祕密:一個人只有用心去看,才能看到真實。事情的真相只用眼睛是看不見的。 --《小王子》"
ellipsisWidth, _ := dc.MeasureString("...")
maxTextWidth := S * 0.9
lineSpace := 25.0
maxLine := int(S / (dc.FontHeight() + lineSpace))
line := 0
lineTexts := make([]string, 0)
for len(s) > 0 {
line++
if line > maxLine {
break
}
if line == maxLine {
sw, _ := dc.MeasureString(s)
if sw > maxTextWidth {
maxTextWidth -= ellipsisWidth
}
}
lineText := TruncateText(dc, s, maxTextWidth)
if line == maxLine && len(lineText) < len(s) {
lineText += "..."
}
lineTexts = append(lineTexts, lineText)
if len(lineText) >= len(s) {
break
}
s = s[len(lineText):]
}
lineY := (S - dc.FontHeight()*float64(len(lineTexts)) - lineSpace*float64(len(lineTexts)-1)) / 2
lineY += dc.FontHeight()
for _, text := range lineTexts {
sWidth, _ := dc.MeasureString(text)
lineX := (S - sWidth) / 2
dc.DrawString(text, lineX, lineY)
lineY += dc.FontHeight() + lineSpace
}
dc.SavePNG("out.png")
}
func TruncateText(dc *gg.Context, originalText string, maxTextWidth float64) string {
tmpStr := ""
result := make([]rune, 0)
for _, r := range originalText {
tmpStr = tmpStr + string(r)
w, _ := dc.MeasureString(tmpStr)
if w > maxTextWidth {
if len(tmpStr) <= 1 {
return ""
} else {
break
}
} else {
result = append(result, r)
}
}
return string(result)
}
```
這段邏輯其實也很簡單,首先根據行高和行間距計算出當前圖片最多能展示多少行字,然後遍歷需要展示的字串進行逐行擷取,截取出一行行的文字來。
遍歷時有一個小細節,那就是判斷是否已經到達最後一行,如果到達最後一行,則要考慮是否新增省略號了。
```go
//如果已經是最後一行,則需要判斷剩餘字串是否仍舊超過最大寬度
if line == maxLine {
sw, _ := dc.MeasureString(s)
// 如果超過則需要在末尾新增省略號,擷取的最大寬度需要減去省略號的寬度
if sw > maxTextWidth {
maxTextWidth -= ellipsisWidth
}
}
lineText := TruncateText(dc, s, maxTextWidth)
// 如果是最後一行並且文字仍舊是被擷取過,那麼在末尾新增省略號
if line == maxLine && len(lineText) < len(s) {
lineText += "..."
}
```
在繪製文字時,先考慮整個文字框的左上頂點位置,因為需要居中展示,每一行的寬度是變化的,X軸座標是不確定的,但是Y軸座標是可以先計算出來的,因為每一行的高度和行間距我們都已經知道了。整個文字框的高度就是`dc.FontHeight()*float64(len(lineTexts)) - lineSpace*float64(len(lineTexts)-1))` ,用圖片高度減去文字框高度再除以2,就能得到左上頂點高度了。
```go
lineY := (S - dc.FontHeight()*float64(len(lineTexts)) - lineSpace*float64(len(lineTexts)-1)) / 2
```
然後開始逐行繪製文字,計算每一行的左下頂點X軸和Y軸座標即可。
```go
lineY += dc.FontHeight()
for _, text := range lineTexts {
sWidth, _ := dc.MeasureString(text)
lineX := (S - sWidth) / 2
dc.DrawString(text, lineX, lineY)
lineY += dc.FontHeight() + lineSpace
}
```
最後的效果如下圖:
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103751082-1518673996.png)
這樣雖然實現了效果,但是顯然有些太過複雜,我們還能再簡化一下這個過程。
在gg庫中,還有兩個方法可以繪製文字,`DrawStringAnchored()` 和 `DrawStringWrapped()`。前者可以在指定一個點為偏移起點。後者則類似於一個文字框的效果,可以指定文字框中心點和文字框寬度,這些將在下一篇中進行介紹。
這裡的處理沒有考慮原文字中有換行符的情況,所以其實還不夠完善,在處理時可以先對文字進行換行符分割,然後再依次進行上述處理。
## 小結
這一篇中,主要講解了如何在純色背景圖上進行文字的繪製,說明了 `DrawString()` 方法和 `MeasureString()` 的使用,並利用它們來實現了文字居中的效果。在下一篇中,將對通過另外幾個方法的講解來了解文字繪製的更多技巧。
如果本篇內容對你有幫助,別忘了點贊關注加收藏~
![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201220103309803-7832238