Skip to content

Instantly share code, notes, and snippets.

@tommy351
Created November 7, 2016 06:25
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tommy351/144f5e7161d50212f9d509c79ac113b4 to your computer and use it in GitHub Desktop.
Save tommy351/144f5e7161d50212f9d509c79ac113b4 to your computer and use it in GitHub Desktop.

在 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,目前 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 的第一篇文章,之後會為各位帶來更多更有深度的文章,請各位拭目以待!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment