Files
tamakyi e807dea218 feat(share-card): 字体自动发现 + 启动探测,缺字体不再等到第一次请求才 500
- 系统候选补上 glyf 轮廓的 CJK 字体(文泉驿正黑/微米黑、DroidSansFallback、
  AR PL UMing),并把 Noto Sans CJK 压到最后:它是 CFF/OTTO,freetype 必定解析失败,
  排前面只会白白多读十几 MB
- static/fonts 同时按当前工作目录与可执行文件目录查找,systemd 没配
  WorkingDirectory 时也能找到(原先只按 CWD,文件在却报「没有可用字体」)
- 启动时探一次字体:命中打 Info 带路径,缺失打 WARN 并给出补救命令
  (apt install fonts-wqy-zenhei / 放 static/fonts/ / 配 font_path)
- .ttc 先提取首个字面再用(仅对 glyf 轮廓的集合有效,如文泉驿);
  失败结果缓存 1 分钟,避免公开端点被「每请求重新解析十几 MB」放大

实测:本地实例 apt install fonts-wqy-zenhei 后无需任何配置,启动即打出
font=/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc,/share-card 与单题分享图
从 500 变为 200 image/jpeg,中文与二维码渲染正常。
2026-10-27 12:37:48 +08:00

147 lines
4.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package question
import (
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
)
// buildTTC 拼一个最小的 TrueType Collection:两个字面共用一套表,
// 表数据刻意按「与目录相反」的顺序摆放,用来证明提取逻辑是按目录里的 offset
// 搬数据,而不是简单地顺序切片。
func buildTTC(t *testing.T) []byte {
t.Helper()
// 表内容
headData := []byte{0xDE, 0xAD, 0xBE, 0xEF}
cmapData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}
const ttcHeaderLen = 16
const sfntHeaderLen = 12
const recLen = 16
const numTables = 2
sfntStart := ttcHeaderLen
dirStart := sfntStart + sfntHeaderLen
dataStart := dirStart + recLen*numTables
// 先放 cmap 再放 head(与目录顺序相反)
cmapOff := dataStart
headOff := cmapOff + len(cmapData)
buf := make([]byte, headOff+len(headData))
copy(buf[0:4], "ttcf")
binary.BigEndian.PutUint32(buf[4:8], 0x00010000) // version
binary.BigEndian.PutUint32(buf[8:12], 1) // numFonts
binary.BigEndian.PutUint32(buf[12:16], uint32(sfntStart))
binary.BigEndian.PutUint32(buf[sfntStart:sfntStart+4], 0x00010000) // sfntVersion
binary.BigEndian.PutUint16(buf[sfntStart+4:sfntStart+6], numTables)
binary.BigEndian.PutUint16(buf[sfntStart+6:sfntStart+8], 16) // searchRange(随便填,提取时应重算)
binary.BigEndian.PutUint16(buf[sfntStart+8:sfntStart+10], 0)
binary.BigEndian.PutUint16(buf[sfntStart+10:sfntStart+12], 0)
// 第 1 条记录:head → 数据在末尾
p := dirStart
copy(buf[p:p+4], "head")
binary.BigEndian.PutUint32(buf[p+4:p+8], 0x11111111) // checksum
binary.BigEndian.PutUint32(buf[p+8:p+12], uint32(headOff))
binary.BigEndian.PutUint32(buf[p+12:p+16], uint32(len(headData)))
// 第 2 条记录:cmap → 数据在前
p = dirStart + recLen
copy(buf[p:p+4], "cmap")
binary.BigEndian.PutUint32(buf[p+4:p+8], 0x22222222)
binary.BigEndian.PutUint32(buf[p+8:p+12], uint32(cmapOff))
binary.BigEndian.PutUint32(buf[p+12:p+16], uint32(len(cmapData)))
copy(buf[cmapOff:], cmapData)
copy(buf[headOff:], headData)
return buf
}
func TestTTCFirstFont(t *testing.T) {
ttc := buildTTC(t)
out, err := ttcFirstFont(ttc)
require.NoError(t, err)
// sfnt header
require.Equal(t, uint32(0x00010000), binary.BigEndian.Uint32(out[0:4]))
require.Equal(t, uint16(2), binary.BigEndian.Uint16(out[4:6]))
// searchRange = 最大 2 的幂 × 16 = 2 × 16 = 32;entrySelector = 1;rangeShift = 32-32 = 0
require.Equal(t, uint16(32), binary.BigEndian.Uint16(out[6:8]))
require.Equal(t, uint16(1), binary.BigEndian.Uint16(out[8:10]))
require.Equal(t, uint16(0), binary.BigEndian.Uint16(out[10:12]))
// 目录:tag / checksum / length 原样保留,offset 指向新位置
type rec struct {
tag string
checksum uint32
off, ln uint32
}
var recs []rec
for i := 0; i < 2; i++ {
p := 12 + 16*i
recs = append(recs, rec{
tag: string(out[p : p+4]),
checksum: binary.BigEndian.Uint32(out[p+4 : p+8]),
off: binary.BigEndian.Uint32(out[p+8 : p+12]),
ln: binary.BigEndian.Uint32(out[p+12 : p+16]),
})
}
require.Equal(t, "head", recs[0].tag)
require.Equal(t, "cmap", recs[1].tag)
require.Equal(t, uint32(0x11111111), recs[0].checksum)
require.Equal(t, uint32(0x22222222), recs[1].checksum)
// 表数据按新 offset 读出来必须与原始一致
require.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF},
out[recs[0].off:recs[0].off+recs[0].ln])
require.Equal(t, []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
out[recs[1].off:recs[1].off+recs[1].ln])
// 每张表在新文件里都要 4 字节对齐(sfnt 规范要求)
require.Zero(t, recs[0].off%4)
require.Zero(t, recs[1].off%4)
}
func TestTTCFirstFontRejects(t *testing.T) {
cases := map[string][]byte{
"空数据": nil,
"不是 TTC": []byte("not a font file at all"),
"头部被截断": []byte("ttcf\x00\x01\x00\x00"),
"numFonts 为 0": func() []byte {
b := make([]byte, 16)
copy(b, "ttcf")
binary.BigEndian.PutUint32(b[4:8], 0x00010000)
return b
}(),
"子字体偏移越界": func() []byte {
b := make([]byte, 16)
copy(b, "ttcf")
binary.BigEndian.PutUint32(b[4:8], 0x00010000)
binary.BigEndian.PutUint32(b[8:12], 1)
binary.BigEndian.PutUint32(b[12:16], 4096)
return b
}(),
}
for name, data := range cases {
t.Run(name, func(t *testing.T) {
_, err := ttcFirstFont(data)
require.Error(t, err)
})
}
}
// TestTTCFirstFontRejectsBadTableOffset 表目录里的 offset 指向文件外时必须报错,
// 而不是把越界切片交给上层(否则就是一次 panic 而不是 500)。
func TestTTCFirstFontRejectsBadTableOffset(t *testing.T) {
ttc := buildTTC(t)
// 第 1 条记录位于 ttcHeader(16) + sfntHeader(12),其 length 字段在记录内第 12 字节
p := 16 + 12 + 12
binary.BigEndian.PutUint32(ttc[p:p+4], 1<<20)
_, err := ttcFirstFont(ttc)
require.Error(t, err)
}