1. 程式人生 > >【Go語言繪圖】圖片新增文字(一)

【Go語言繪圖】圖片新增文字(一)

前一篇講解了利用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