前一篇講解了利用gg包來進行圖片旋轉的操作,這一篇我們來看看怎麼在圖片上新增文字。
繪製純色背景
首先,我們先繪製一個純白色的背景,作為新增文字的背景板。
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")
}
輸出圖片如下:
這樣我就得到了一張純青色的背景圖。回顧一下上一篇裡繪製背景圖的步驟:
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()
函式,通過將純色圖片覆蓋到原畫布的方式來實現純色背景的效果的。
// 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)
}
新增文字
背景板已經準備就緒,接下來,我們來新增一些文字。
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!”就出現在了圖片中央。
這裡我們新增了三個步驟,首先是設定了字型顏色為黑色。
dc.SetRGB(0, 0, 0)
然後載入了字型檔案,這裡需要注意的是,通過 LoadFontFace()
方法載入的字型檔案只支援 ttf
字尾的檔案,也就是 true type font
,
if err := dc.LoadFontFace("gilmer-heavy.ttf", 120); err != nil {
panic(err)
}
裡面的實現也比較簡單:
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
磅。
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()
方法傳入的值即可,讓我們來調大一點字型看看效果。
if err := dc.LoadFontFace("gilmer-heavy.ttf", 240); err != nil {
panic(err)
}
這樣就大很多了。不知道聰明的你注意到了沒有,在呼叫dc.DrawString("Hello, world!", 0, S/2)
時,我們設定的座標是 (0, S/2)
,也就是左側邊的正中心點,這個位置剛好是繪製出來的文字的左下角的座標,這是需要注意的一點。
居中顯示
如果想要文字居中顯示怎麼辦呢?比如我們想要這個 Hello,World!
顯示在圖片的正中央,要怎麼處理呢?一個笨辦法當然是通過調整字型位置來實現這個效果,讓我們先來試試:
if err := dc.LoadFontFace("gilmer-heavy.ttf", 120); err != nil {
panic(err)
}
dc.DrawString("Hello, world!", 130, S/2)
通過多次調整,字型大小設定為120
時,x
的位置設定為130
,基本上可以看起來是居中的。但這樣的話每次換文字都得反覆調整位置,顯然不科學。
別慌,有一個方法可以得到文字的寬度,MeasureString()
可以得到在當前字型下指定字串的寬度和高度,這個高度其實就是前面通過 points * 72 / 96
計算得到的,然後我們再將左下角的位置設定為((S-sWidth)/2, (S+sHeight)/2)
即可實現文字居中的效果,注意y軸座標是(S+sHeight)/2
,因為文字的左上頂點位置y軸座標應該是(S-sHeight)/2
,左下頂點座標只需要再加上字型高度即可得出。
s := "Hello, world!"
sWidth, sHeight := dc.MeasureString(s)
dc.DrawString(s, (S-sWidth)/2, (S+sHeight)/2)
這樣看來,居中顯示也不過如此嘛。但別高興的太早,有沒有想過,如果文字過長該怎麼處理?比如我們來調整一下文字內容,再看下生成的效果。
s := "Hello,world! Hello,ByteDancer!"
文字已經超出邊界了,顯然不是理想的效果,這個時候有兩種處理方法,一種是新增省略號,一種是換行。
單行長文字處理
先來說一下新增省略號的處理方案,聽起來好像挺簡單,但實際上處理起來也挺麻煩的。
首先需要確定一個文字展示的最大寬度,因為如果滿打滿算整行都塞滿文字顯然不好看。其次是要逐個字元進行寬度計算,並判斷是否會超過最大寬度,最後擷取並保留剛好小於最大寬度時的字串(需要考慮省略號的寬度)。
我們來逐個處理。首先拍腦袋定一個文字最大寬度為圖片寬度的0.75
倍。
maxTextWidth := S * 0.75
然後來逐個字元計算寬度,直到剛好大於最大寬度為止。
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
}
然後我們調整一下呼叫的地方。
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")
}
這裡我們先計算了省略號的寬度,然後用最大字串寬度減去省略號寬度作為最大寬度傳入,得到最終要展示的字串。生成的效果如下:
看起來好像沒什麼毛病,但如果我們把文字換成中文,情況可能就不一樣了。我們換一箇中文字型,然後把字串設定成中文。
if err := dc.LoadFontFace("方正楷體簡體.ttf", 120); err != nil {
panic(err)
}
s := "如果我們把文字換成中文"
就變成了這個樣子。
發現圖片上只剩下了省略號,原因是中文字串分割不正確導致出現了亂碼,而這個亂碼在字型裡找不到對應的文字,所以無法展示。這時,需要先將字串先轉化為rune
陣列,或者通過直接對字串使用 for range
遍歷,可以避免在中文的情況出現亂碼的情況。
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)
}
這樣文字就能按照我們的預期進行展示了。
多行文字處理
接下來,我們來看看怎麼處理多行文字,即當一行文字展示不下時,把文字切割成多行進行展示。如果我們仍舊使用之前的方法來處理的話,就需要先計算好每行展示的字以及行數,然後再進行展示。
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)
}
這段邏輯其實也很簡單,首先根據行高和行間距計算出當前圖片最多能展示多少行字,然後遍歷需要展示的字串進行逐行擷取,擷取出一行行的文字來。
遍歷時有一個小細節,那就是判斷是否已經到達最後一行,如果到達最後一行,則要考慮是否新增省略號了。
//如果已經是最後一行,則需要判斷剩餘字串是否仍舊超過最大寬度
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,就能得到左上頂點高度了。
lineY := (S - dc.FontHeight()*float64(len(lineTexts)) - lineSpace*float64(len(lineTexts)-1)) / 2
然後開始逐行繪製文字,計算每一行的左下頂點X軸和Y軸座標即可。
lineY += dc.FontHeight()
for _, text := range lineTexts {
sWidth, _ := dc.MeasureString(text)
lineX := (S - sWidth) / 2
dc.DrawString(text, lineX, lineY)
lineY += dc.FontHeight() + lineSpace
}
最後的效果如下圖:
這樣雖然實現了效果,但是顯然有些太過複雜,我們還能再簡化一下這個過程。
在gg庫中,還有兩個方法可以繪製文字,DrawStringAnchored()
和 DrawStringWrapped()
。前者可以在指定一個點為偏移起點。後者則類似於一個文字框的效果,可以指定文字框中心點和文字框寬度,這些將在下一篇中進行介紹。
這裡的處理沒有考慮原文字中有換行符的情況,所以其實還不夠完善,在處理時可以先對文字進行換行符分割,然後再依次進行上述處理。
小結
這一篇中,主要講解了如何在純色背景圖上進行文字的繪製,說明了 DrawString()
方法和 MeasureString()
的使用,並利用它們來實現了文字居中的效果。在下一篇中,將對通過另外幾個方法的講解來了解文字繪製的更多技巧。
如果本篇內容對你有幫助,別忘了點贊關注加收藏~