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

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

這一篇將繼續介紹gg庫中繪製文字相關的方法,主要包括:`DrawStringAnchored()`、`DrawStringWrapped()`、`MeasureMultilineString()`、`WordWrap()`下面來分別進行介紹。 ## DrawStringAnchored 如果不細究,可能會覺得這個方法是 `DrawString()` 方法的一個封裝,但看看裡面的實現就能發現,實際情況正好相反。 ```go // DrawString draws the specified text at the specified point. func (dc *Context) DrawString(s string, x, y float64) { dc.DrawStringAnchored(s, x, y, 0, 0) } // DrawStringAnchored draws the specified text at the specified anchor point. // The anchor point is x - w * ax, y - h * ay, where w, h is the size of the // text. Use ax=0.5, ay=0.5 to center the text at the specified point. func (dc *Context) DrawStringAnchored(s string, x, y, ax, ay float64) { w, h := dc.MeasureString(s) x -= ax * w y += ay * h if dc.mask == nil { dc.drawString(dc.im, s, x, y) } else { im := image.NewRGBA(image.Rect(0, 0, dc.width, dc.height)) dc.drawString(im, s, x, y) draw.DrawMask(dc.im, dc.im.Bounds(), im, image.ZP, dc.mask, image.ZP, draw.Over) } } ``` `DrawStringAnchored()` 方法主要有5個引數,第一個引數是要繪製的字串,後面四個引數共同決定了錨點的位置,具體計算邏輯是`(x - w * ax, y - h * ay)`,所以,當`ax`、`ay`設定為`0`時就是左對齊,此時錨點位置處於文字框左下角;設定為`0.5`時就是居中,此時錨點位置處於文字框正中央;設定為`1`時就是右對齊,此時錨點位置處於文字控右上角。 我們來看下效果: ```go func TestDrawStringAnchored(t *testing.T){ const S = 1024 dc := gg.NewContext(S, S) dc.SetRGB(1, 1, 1) dc.Clear() dc.SetRGB(0, 0, 0) if err := dc.LoadFontFace("gilmer-heavy.ttf", 96); err != nil { panic(err) } dc.DrawStringAnchored("Hello, world!", 0, dc.FontHeight(), 0, 0) dc.DrawStringAnchored("Hello, world!", S/2, S/2, 0.5, 0.5) dc.DrawStringAnchored("Hello, world!", S, S-dc.FontHeight(), 1, 1) dc.SavePNG("out.png") } ``` ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202108494-771144130.png) 這裡需要注意的就是錨點的位置,當左對齊時,錨點在左下角,所以設定的 `(0, dc.FontHeight())` 代表的是文字框左下角的位置,同理,當居中對齊時,`(S/2, S/2)` 代表的是文字框中心點的位置,右對齊時,`(S, S-dc.FontHeight())` 代表的是文字框右上頂點的位置。 ## DrawStringWrapped 這個方法可以比較方便的繪製多行文字,還能自動折行,基本上相當於真正文字框的效果。 先看個例子簡單的熟悉一下: ```go func TestDrawStringWrapped(t *testing.T){ const S = 1024 dc := gg.NewContext(S, S) dc.SetRGB(1, 1, 1) dc.Clear() dc.SetRGB(0, 0, 0) if err := dc.LoadFontFace("gilmer-heavy.ttf", 96); err != nil { panic(err) } dc.DrawStringWrapped("Hello world! Hello Frank! Hello Alice!", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter) dc.SavePNG("out.png") } ``` 繪製的效果如下: ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202120979-184744200.png) 可以看到,不僅自動換行,而且還保持了單詞的完整性,沒有將一個單詞從中間分割開來。 這個方法的引數有點多,一共有8個引數。 第1個引數代表的是要繪製的字串,比如這裡的`Hello world! Hello Frank! Hello Alice!`。第6個引數代表文字框的寬度。第7個引數代表行間距。 第2~5和第8個引數共同決定了錨點的位置。這裡的計算比之前稍微複雜一點,讓我們來看看裡面的具體實現: ```go // DrawStringWrapped word-wraps the specified string to the given max width // and then draws it at the specified anchor point using the given line // spacing and text alignment. func (dc *Context) DrawStringWrapped(s string, x, y, ax, ay, width, lineSpacing float64, align Align) { lines := dc.WordWrap(s, width) // sync h formula with MeasureMultilineString h := float64(len(lines)) * dc.fontHeight * lineSpacing h -= (lineSpacing - 1) * dc.fontHeight x -= ax * width y -= ay * h switch align { case AlignLeft: ax = 0 case AlignCenter: ax = 0.5 x += width / 2 case AlignRight: ax = 1 x += width } ay = 1 for _, line := range lines { dc.DrawStringAnchored(line, x, y, ax, ay) y += dc.fontHeight * lineSpacing } } ``` 首先通過 `WordWrap()` 方法來得到根據指定寬度處理過後的每一行需要展示的字串資訊。 ```go lines := dc.WordWrap(s, width) ``` 然後計算行高,這裡計算的時候是用行數乘以字型高度再乘以行間距,得到結果後再減去一個行間距。所以這個 `lineSpacing` 的含義是行間距相對於字型高度的倍數,當 `lineSpacing` 設定為1時,也就是行間距為0,設定為1.1時,代表行間距為字型高度的0.1倍。 ```go h := float64(len(lines)) * dc.fontHeight * lineSpacing h -= (lineSpacing - 1) * dc.fontHeight ``` 然後是有點繞的計算。 ```go x -= ax * width y -= ay * h switch align { case AlignLeft: ax = 0 case AlignCenter: ax = 0.5 x += width / 2 case AlignRight: ax = 1 x += width } ay = 1 for _, line := range lines { dc.DrawStringAnchored(line, x, y, ax, ay) y += dc.fontHeight * lineSpacing } ``` 可以看到,整體邏輯是先計算好首行文字的錨點位置,然後對處理過的每個字串呼叫 `DrawStringAnchored()` 方法進行最終文字繪製。我們可以從下往上看,在迴圈繪製之前,先設定了 `ay = 1`,也就是說錨點的偏移位置會在每一行的頂部,然後我們來看這個`ax`: ```go switch align { case AlignLeft: ax = 0 case AlignCenter: ax = 0.5 x += width / 2 case AlignRight: ax = 1 x += width } ``` 根據傳入的最後一個引數的不同值,`ax` 會設定為不同的值。當最後一個引數分別為 `AlignLeft`、`AlignCenter`、`AlignRight`時,`ax` 和 `ay` 的組合分別為:`(0,1)`、`(0.5,1)`、`(1,1)`,錨點相對於單行文字的位置分別為左上頂點、上中位置、右上頂點。 然後我們再來看這個 `y` 的值: ```go y -= ay * h ``` `y` 的初始位置為傳入的 `y` 值減去 `ay` (y軸偏移) 乘以整體文字框高度,代表的含義是初始錨點`(x,y)`相對於文字框的位置,分別傳入`0`、`0.5`、`1`時分別代表錨點處於文字框的上邊線、正中線和下邊線上。在迴圈繪製文字時,`y` 的值也會不斷調整,代表單行文字的錨點位置也在不斷變化。 ```go y += dc.fontHeight * lineSpacing ``` 最後來看下 `x` 的值,初始值為初始錨點相對於傳入的文字框寬度的相對位置,`ax` 分別為 `0`、`0.5`、`1` 時,分別代表初始錨點位於整體文字框的左邊線、居中豎線和右邊線上。 ```go x -= ax * width ``` 根據傳入的最後一個引數的不同,又會對x進行一次調整,這樣調整之後,便能實現文字在文字框中左對齊、居中和右對齊的效果了。 ```go switch align { case AlignLeft: ax = 0 case AlignCenter: ax = 0.5 x += width / 2 case AlignRight: ax = 1 x += width } ``` 看起來確實挺好用,不用再操心換行的事情了。但別高興的太早,有一點需要注意。這個方法只會根據空格來分割字串,如果字串沒有空格,就會變成只有一行文字的效果。 ```go dc.DrawStringWrapped("HelloWorld!HelloFrank!HelloAlice!", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter) ``` ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202138637-781638039.png) 你可能會覺得,英文單詞之間都會有空格的嘛,應該不用擔心,但如果是中文呢? ```go if err := dc.LoadFontFace("/Users/bytedance/Downloads/font/方正楷體簡體.ttf", 96); err != nil { panic(err) } dc.DrawStringWrapped("如果我們把文字換成中文效果就沒那麼好了", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter) ``` ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202156176-619260488.png) 另外,這個方法不會限制文字框整體高度,所以如果文字很長,即使可能正確換行,仍舊會超出圖片範圍。 ```go dc.DrawStringWrapped("比如這是一段很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 的文字", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter) ``` ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202206823-780973399.png) 另外,它是按照空格進行詞元素分割的,所以不會從單詞的中間進行拆分,這既是優點,也是缺點。因為如果有長單詞的話,可能會導致提前換行,讓某些行看起來比其它行短很多。所以要想精確控制,還是得用笨辦法。 ### MeasureMultilineString `MeasureMultilineString()` 方法可以測量多行文字的整體高度和寬度,需要傳入用換行符分割好的文字行字串和行間距,裡面的計算邏輯也很簡單。 ```go func (dc *Context) MeasureMultilineString(s string, lineSpacing float64) (width, height float64) { lines := strings.Split(s, "\n") // sync h formula with DrawStringWrapped height = float64(len(lines)) * dc.fontHeight * lineSpacing height -= (lineSpacing - 1) * dc.fontHeight d := &font.Drawer{ Face: dc.fontFace, } // max width from lines for _, line := range lines { adv := d.MeasureString(line) currentWidth := float64(adv >> 6) // from gg.Context.MeasureString if currentWidth > width { width = currentWidth } } return width, height } ``` 行高的計算跟上面`DrawStringWrapped()`方法是一樣的: ```go h := float64(len(lines)) * dc.fontHeight * lineSpacing h -= (lineSpacing - 1) * dc.fontHeight ``` 寬度則是取這些文字行中寬度最大的那個。 ### WordWrap 這個方法是用來處理文字的,負責對文字根據指定寬度進行分行,在 `DrawStringWrapped()` 方法中已經有所呼叫。它內部是呼叫`wordWrap()`函式來實現的。 ```go // WordWrap wraps the specified string to the given max width and current // font face. func (dc *Context) WordWrap(s string, w float64) []string { return wordWrap(dc, s, w) } ``` `wordWrap()` 函式做的事情便是先將文字按換行符分割,然後對每一個子字串按空格進行分割,再通過一個元素一個元素的拼接來判斷出適合當前行寬的最大字串。 ```go func wordWrap(m measureStringer, s string, width float64) []string { var result []string for _, line := range strings.Split(s, "\n") { fields := splitOnSpace(line) if len(fields)%2 == 1 { fields = append(fields, "") } x := "" for i := 0; i < len(fields); i += 2 { w, _ := m.MeasureString(x + fields[i]) if w > width { if x == "" { result = append(result, fields[i]) x = "" continue } else { result = append(result, x) x = "" } } x += fields[i] + fields[i+1] } if x != "" { result = append(result, x) } } for i, line := range result { result[i] = strings.TrimSpace(line) } return result } ``` ## 需要注意的點 ### otf 字型檔案載入 前面的內容中,載入字型檔案都使用的是 `LoadFontFace()` 方法進行的,但需要注意的是,這個方法只能載入 `ttf` 字型檔案,也就是 `true type font`,無法載入 `otf` 字型檔案,也就是 `open type font`。 所以如果需要載入 `otf` 字型檔案,則需要換一個姿勢。 ```go 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 } ``` 來測試一下: ```go func TestUseOtfFile(t *testing.T){ filePath := "SourceHanSansCN-Bold-2.otf" fontFace, err := getOpenTypeFontFace(filePath, 100, 82) if err != nil { panic(err) } const S = 1024 dc := gg.NewContext(S, S) dc.SetRGB(0, 1, 1) dc.Clear() dc.SetRGB(0, 0, 0) dc.SetFontFace(*fontFace) dc.DrawStringWrapped("比如這是一段 很長很長很長 很長很長很長 的文字", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter) dc.SavePNG("out.png") } ``` ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202224948-1197689956.png) ### 行高的問題 還有一個需要注意的問題,之前在開發時也踩過坑。`SetFontFace` 與 `LoadFontFace` 計算 `fontHeight` 時姿勢不一樣,所以導致設定同樣的字型大小時,最終的字型高度卻不一致。 ```go func (dc *Context) SetFontFace(fontFace font.Face) { dc.fontFace = fontFace dc.fontHeight = float64(fontFace.Metrics().Height) / 64 } 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 } ``` 可以看到對於行高的計算邏輯有著較大區別,我們可以用一個例子來簡單驗證一下: ```go func TestUseOtfFile(t *testing.T){ filePath := "/Users/bytedance/Downloads/font/方正楷體簡體.ttf" fontFace1, err := getOpenTypeFontFace(filePath, 100, 82) if err != nil { panic(err) } const S = 1024 dc := gg.NewContext(S, S) dc.SetRGB(0, 1, 1) dc.Clear() dc.SetRGB(0, 0, 0) dc.SetFontFace(*fontFace1) dc.DrawStringWrapped("比如這是一段文字", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter) if err := dc.LoadFontFace("/Users/bytedance/Downloads/font/方正楷體簡體.ttf", 100); err != nil { panic(err) } dc.DrawStringWrapped("比如這是一段文字", S/2, S/2 + 100, 0.5, 0.5, S, 1, gg.AlignCenter) dc.SavePNG("out.png") } ``` ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202234611-1761091271.png) 可以看到,兩行文字大小明顯不一樣。 ## 小結 至此,關於文字繪製的相關內容就說完了。這兩篇講解了gg庫中關於文字繪製相關的內容,相信對於文字繪製已經有了比較好的掌握。實踐出真知,還是需要多改改多用用才知道是怎麼一回事。在之後的一篇裡,會根據前面的內容進行一個小小的實戰應用,讓我們的知識真正應用起來~ 如果本篇內容對你有幫助,別忘了點贊關注加收藏~ ![](https://img2020.cnblogs.com/blog/1043143/202012/1043143-20201222202314085-10997218