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

弗蘭克的貓發表於2020-12-20

前一篇講解了利用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() 的使用,並利用它們來實現了文字居中的效果。在下一篇中,將對通過另外幾個方法的講解來了解文字繪製的更多技巧。

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

相關文章