在 Facebook 上分享 Dcard 文章時,如果沒有指定圖片的話,可能會看到如下的文章摘要:
這張圖片是透過 Go library draw2d 產生的,它有類似 HTML5 canvas 的 API,所以畫一些簡單的圖形都還算小菜一碟,然而在處理文字上就沒這麼得心應手了。
src, _ := loadBaseImageForPost(post)
img := image.NewRGBA(src.Bounds())
gc := draw2dimg.NewGraphicContext(img)
gc.DrawImage(src)
// Draw title
gc.SetFillColor(color.RGBA{0xff, 0xff, 0xff, 0xff})
gc.SetFontSize(26)
gc.FillStringAt(post.Title, 140, 81.5)
// Draw content
gc.SetFillColor(color.RGBA{0x32, 0x32, 0x32, 0xff})
gc.SetFontSize(26)
gc.FillStringAt(post.Content, 60, 190)
// Draw forum
forum := post.ForumName
_, _, right, _ := gc.GetStringBounds(forum)
gc.SetFillColor(color.RGBA{0xff, 0xff, 0xff, 0xff})
gc.SetFontSize(26)
gc.FillStringAt(forum, 1140-right, 81.5)
// Draw time
timeLocation, _ := time.LoadLocation("Asia/Taipei")
time := post.CreatedAt.In(timeLocation).Format("2006/1/2 15:04")
_, _, right, bottom := gc.GetStringBounds(time)
gc.SetFillColor(color.RGBA{0x79, 0x79, 0x79, 0xff})
gc.SetFontSize(26)
gc.FillStringAt(time, 1140-right, 510-bottom)
ctx.Response.Header.Set("Content-Type", "image/png")
png.Encode(ctx, img)
這段程式實際上執行時只會印出背景圖片:
而且會跳出錯誤:
2016/11/04 16:06:16 open ../resource/font/luxisr.ttf: no such file or directory
2016/11/04 16:06:16 open ../resource/font/luxisr.ttf: no such file or directory
2016/11/04 16:06:16 No font set, and no default font available.
這是因為 draw2d 預設依賴 resource/font 資料夾下的字型,所以必須自行指定字型路徑。
draw2d.SetFontFolder("static/fonts")
draw2d.SetFontNamer(func(fontData draw2d.FontData) string {
return "WenQuanYiMicroHei.ttf"
})
設定完成後可以得到以下結果,除了 Emoji 變成方框和內容沒有換行以外,其他元素都放在正常的位置上。
draw2d 只有提供三種有關文字的函數:
func (gc *GraphicContext) FillString(text string) (width float64)
func (gc *GraphicContext) FillStringAt(text string, x, y float64) (width float64)
func (gc *GraphicContext) GetStringBounds(s string) (left, top, right, bottom float64)
沒有任何一個函數可以自動換行或文字排版,所以必須自行處理,我首先想到的做法就是先分行,透過 GetStringBounds
取得該行的高度後,把游標移動到下一行直到超過邊界為止。
func drawLine(gc *draw2dimg.GraphicContext, str string, x float64, y float64) {
gc.FillStringAt(str, x, y)
}
func drawText(gc *draw2dimg.GraphicContext, str string, width float64, height float64, x float64, y float64) {
str = strings.Trim(str, " \n\r\t")
lines := strings.Split(str, "\n")
fontSize := gc.Current.FontSize
offsetY := y
wrapLines := []string{}
runePerLine := int(width / fontSize)
for _, line := range lines {
len := utf8.RuneCountInString(line)
chunks := []string{}
list := []rune(line)
for i := 0; i < len; i += runePerLine {
max := i + runePerLine
if max > len {
chunks = append(chunks, string(list[i:len]))
} else {
chunks = append(chunks, string(list[i:max]))
}
}
wrapLines = append(wrapLines, chunks...)
}
for _, line := range wrapLines {
_, top, _, bottom := gc.GetStringBounds(line)
drawLine(gc, line, x, offsetY)
offsetY += (bottom - top) * lineHeight
if offsetY > y+height {
break
}
}
}
在每行文字長度不要太超過的情況下,這種解法除了行高有些異常之外,得到的結果看起來還可以。
但是這種做法是有缺點的,因為我把所有字元視為寬度相同的方塊字,如果每行文字長度比較長,或是有中英混排,就可能誤判寬度導致排版出錯。
所以我改變了做法,分行之後再切字,計算每個字的實際寬度來避免出錯。
func drawLine(gc *draw2dimg.GraphicContext, str string, maxX float64, maxY float64, x float64, y float64) (bool, float64) {
// Handle empty line
if len(strings.TrimSpace(str)) == 0 {
return false, y + gc.Current.FontSize*lineHeight
}
list := []rune(str)
length := len(list)
offsetX := x
offsetY := y
fontSize := gc.Current.FontSize
lineSize := fontSize * lineHeight
for i := 0; i < length; i++ {
chunk := string(list[i])
textWidth := gc.FillStringAt(chunk, offsetX, offsetY)
offsetX += textWidth
if offsetX >= maxX {
newY := offsetY + lineSize
if newY >= maxY {
return true, offsetY
}
offsetX = x
offsetY = newY
}
}
offsetY += lineSize
return false, offsetY
}
func drawText(gc *draw2dimg.GraphicContext, str string, width float64, height float64, x float64, y float64) {
// Trim newlines
str = strings.Trim(str, "\r\n")
// Split by lines
lines := strings.Split(str, "\n")
// Initial offset
offsetY := y
maxX := width + x
maxY := height + y
for _, line := range lines {
end, newY := drawLine(gc, line, maxX, maxY, x, offsetY)
if end || newY >= maxY {
break
}
offsetY = newY
}
}
排版正常多了,雖然還有一些潛在問題,例如標點符號位置和英文斷字等,但以目前文章大部分都是中文的情況來說還算可以。
最後剩下的就是 Emoji,目前 Emoji 還是方格,我決定利用 EmojiOne 來取代它們。
func drawLine(gc *draw2dimg.GraphicContext, str string, maxX float64, maxY float64, x float64, y float64) (bool, float64) {
// Handle empty line
if len(strings.TrimSpace(str)) == 0 {
return false, y + gc.Current.FontSize*lineHeight
}
list := []rune(str)
length := len(list)
offsetX := x
offsetY := y
fontSize := gc.Current.FontSize
lineSize := fontSize * lineHeight
for i := 0; i < length; i++ {
chunk := string(list[i])
if isEmoji(chunk) {
emojis := []rune{list[i]}
for j := 1; j <= 6 && i+j < length && (isEmoji(string(list[i+j])) || isZWJ(string(list[i+j]))); j++ {
emojis = append(emojis, list[i+j])
}
var img image.Image
var err error
for j := len(emojis); j > 0; j-- {
img, err = loadEmoji(emojis[:j])
if err != nil {
continue
}
i = i + j - 1
break
}
if err != nil {
continue
}
rect := img.Bounds()
dx := float64(rect.Dx())
dy := float64(rect.Dy())
gc.Save()
gc.Translate(offsetX, offsetY-fontSize)
gc.Scale(fontSize/dx, fontSize/dy)
gc.DrawImage(img)
gc.Restore()
offsetX += fontSize
continue
}
textWidth := gc.FillStringAt(chunk, offsetX, offsetY)
offsetX += textWidth
if offsetX >= maxX {
newY := offsetY + lineSize
if newY >= maxY {
return true, offsetY
}
offsetX = x
offsetY = newY
}
}
offsetY += lineSize
return false, offsetY
}
func isRuneBetween(r rune, from rune, to rune) bool {
return r >= from && r <= to
}
func isEmoji(s string) bool {
r, size := utf8.DecodeRuneInString(s)
if size > 3 {
return true
}
return isRuneBetween(r, 0x2700, 0x27bf) || // Dingbats
isRuneBetween(r, 0x2600, 0x26ff) || // Miscellaneous Symbols
isRuneBetween(r, 0x2b00, 0x2bff) || // Miscellaneous Symbols and Arrows
isRuneBetween(r, 0x25a0, 0x25ff) || // Geometric Shapes
isRuneBetween(r, 0x20d0, 0x20ff) // Combining Diacritical Marks for Symbols
}
func isZWJ(s string) bool {
r, _ := utf8.DecodeRuneInString(s)
return isRuneBetween(r, 0x200d, 0x200d)
}
func loadEmoji(emojis []rune) (image.Image, error) {
var codes []string
for _, r := range emojis {
if !isZWJ(string(r)) {
codes = append(codes, strconv.FormatInt(int64(r), 16))
}
}
code := strings.Join(codes, "-")
path := path.Join(emojiDir, code+".png")
return images.Get(path)
}
我稍微修改了 drawLine
函數,讓它在偵測到文字為 Emoji 時把文字取代為 Emoji 的圖片,有些 Emoji 是由好幾個字元組成的(例如 👨👩👧👦 = ["👨", "\u200d", "👩", "\u200d", "👧", "\u200d", "👦"]),中間會以 ZWJ(Zero-width joiner)連接。
以上就是 Web team 在 Dcard Lab 的第一篇文章,之後會為各位帶來更多更有深度的文章,請各位拭目以待!