這一篇將繼續介紹gg庫中繪製文字相關的方法,主要包括:DrawStringAnchored()
、DrawStringWrapped()
、MeasureMultilineString()
、WordWrap()
下面來分別進行介紹。
DrawStringAnchored
如果不細究,可能會覺得這個方法是 DrawString()
方法的一個封裝,但看看裡面的實現就能發現,實際情況正好相反。
// 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
時就是右對齊,此時錨點位置處於文字控右上角。
我們來看下效果:
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")
}
這裡需要注意的就是錨點的位置,當左對齊時,錨點在左下角,所以設定的 (0, dc.FontHeight())
代表的是文字框左下角的位置,同理,當居中對齊時,(S/2, S/2)
代表的是文字框中心點的位置,右對齊時,(S, S-dc.FontHeight())
代表的是文字框右上頂點的位置。
DrawStringWrapped
這個方法可以比較方便的繪製多行文字,還能自動折行,基本上相當於真正文字框的效果。
先看個例子簡單的熟悉一下:
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")
}
繪製的效果如下:
可以看到,不僅自動換行,而且還保持了單詞的完整性,沒有將一個單詞從中間分割開來。
這個方法的引數有點多,一共有8個引數。
第1個引數代表的是要繪製的字串,比如這裡的Hello world! Hello Frank! Hello Alice!
。第6個引數代表文字框的寬度。第7個引數代表行間距。
第2~5和第8個引數共同決定了錨點的位置。這裡的計算比之前稍微複雜一點,讓我們來看看裡面的具體實現:
// 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()
方法來得到根據指定寬度處理過後的每一行需要展示的字串資訊。
lines := dc.WordWrap(s, width)
然後計算行高,這裡計算的時候是用行數乘以字型高度再乘以行間距,得到結果後再減去一個行間距。所以這個 lineSpacing
的含義是行間距相對於字型高度的倍數,當 lineSpacing
設定為1時,也就是行間距為0,設定為1.1時,代表行間距為字型高度的0.1倍。
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
}
可以看到,整體邏輯是先計算好首行文字的錨點位置,然後對處理過的每個字串呼叫 DrawStringAnchored()
方法進行最終文字繪製。我們可以從下往上看,在迴圈繪製之前,先設定了 ay = 1
,也就是說錨點的偏移位置會在每一行的頂部,然後我們來看這個ax
:
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
的值:
y -= ay * h
y
的初始位置為傳入的 y
值減去 ay
(y軸偏移) 乘以整體文字框高度,代表的含義是初始錨點(x,y)
相對於文字框的位置,分別傳入0
、0.5
、1
時分別代表錨點處於文字框的上邊線、正中線和下邊線上。在迴圈繪製文字時,y
的值也會不斷調整,代表單行文字的錨點位置也在不斷變化。
y += dc.fontHeight * lineSpacing
最後來看下 x
的值,初始值為初始錨點相對於傳入的文字框寬度的相對位置,ax
分別為 0
、0.5
、1
時,分別代表初始錨點位於整體文字框的左邊線、居中豎線和右邊線上。
x -= ax * width
根據傳入的最後一個引數的不同,又會對x進行一次調整,這樣調整之後,便能實現文字在文字框中左對齊、居中和右對齊的效果了。
switch align {
case AlignLeft:
ax = 0
case AlignCenter:
ax = 0.5
x += width / 2
case AlignRight:
ax = 1
x += width
}
看起來確實挺好用,不用再操心換行的事情了。但別高興的太早,有一點需要注意。這個方法只會根據空格來分割字串,如果字串沒有空格,就會變成只有一行文字的效果。
dc.DrawStringWrapped("HelloWorld!HelloFrank!HelloAlice!", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter)
你可能會覺得,英文單詞之間都會有空格的嘛,應該不用擔心,但如果是中文呢?
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)
另外,這個方法不會限制文字框整體高度,所以如果文字很長,即使可能正確換行,仍舊會超出圖片範圍。
dc.DrawStringWrapped("比如這是一段很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 很長很長很長很長很長 的文字", S/2, S/2, 0.5, 0.5, S, 1, gg.AlignCenter)
另外,它是按照空格進行詞元素分割的,所以不會從單詞的中間進行拆分,這既是優點,也是缺點。因為如果有長單詞的話,可能會導致提前換行,讓某些行看起來比其它行短很多。所以要想精確控制,還是得用笨辦法。
MeasureMultilineString
MeasureMultilineString()
方法可以測量多行文字的整體高度和寬度,需要傳入用換行符分割好的文字行字串和行間距,裡面的計算邏輯也很簡單。
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()
方法是一樣的:
h := float64(len(lines)) * dc.fontHeight * lineSpacing
h -= (lineSpacing - 1) * dc.fontHeight
寬度則是取這些文字行中寬度最大的那個。
WordWrap
這個方法是用來處理文字的,負責對文字根據指定寬度進行分行,在 DrawStringWrapped()
方法中已經有所呼叫。它內部是呼叫wordWrap()
函式來實現的。
// 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()
函式做的事情便是先將文字按換行符分割,然後對每一個子字串按空格進行分割,再通過一個元素一個元素的拼接來判斷出適合當前行寬的最大字串。
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
字型檔案,則需要換一個姿勢。
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
}
來測試一下:
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")
}
行高的問題
還有一個需要注意的問題,之前在開發時也踩過坑。SetFontFace
與 LoadFontFace
計算 fontHeight
時姿勢不一樣,所以導致設定同樣的字型大小時,最終的字型高度卻不一致。
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
}
可以看到對於行高的計算邏輯有著較大區別,我們可以用一個例子來簡單驗證一下:
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")
}
可以看到,兩行文字大小明顯不一樣。
小結
至此,關於文字繪製的相關內容就說完了。這兩篇講解了gg庫中關於文字繪製相關的內容,相信對於文字繪製已經有了比較好的掌握。實踐出真知,還是需要多改改多用用才知道是怎麼一回事。在之後的一篇裡,會根據前面的內容進行一個小小的實戰應用,讓我們的知識真正應用起來~
如果本篇內容對你有幫助,別忘了點贊關注加收藏~