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

弗蘭克的貓發表於2021-08-01

這一篇將繼續介紹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),所以,當axay設定為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 會設定為不同的值。當最後一個引數分別為 AlignLeftAlignCenterAlignRight時,axay 的組合分別為:(0,1)(0.5,1)(1,1),錨點相對於單行文字的位置分別為左上頂點、上中位置、右上頂點。

然後我們再來看這個 y 的值:

y -= ay * h

y 的初始位置為傳入的 y 值減去 ay (y軸偏移) 乘以整體文字框高度,代表的含義是初始錨點(x,y)相對於文字框的位置,分別傳入00.51時分別代表錨點處於文字框的上邊線、正中線和下邊線上。在迴圈繪製文字時,y 的值也會不斷調整,代表單行文字的錨點位置也在不斷變化。

y += dc.fontHeight * lineSpacing

最後來看下 x 的值,初始值為初始錨點相對於傳入的文字框寬度的相對位置,ax 分別為 00.51 時,分別代表初始錨點位於整體文字框的左邊線、居中豎線和右邊線上。

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")
}

行高的問題

還有一個需要注意的問題,之前在開發時也踩過坑。SetFontFaceLoadFontFace 計算 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庫中關於文字繪製相關的內容,相信對於文字繪製已經有了比較好的掌握。實踐出真知,還是需要多改改多用用才知道是怎麼一回事。在之後的一篇裡,會根據前面的內容進行一個小小的實戰應用,讓我們的知識真正應用起來~

如果本篇內容對你有幫助,別忘了點贊關注加收藏~

相關文章