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,中文与二维码渲染正常。
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/tamakyi/TamaBox/internal/shared"
|
"github.com/tamakyi/TamaBox/internal/shared"
|
||||||
"github.com/tamakyi/TamaBox/internal/stats"
|
"github.com/tamakyi/TamaBox/internal/stats"
|
||||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||||
|
"github.com/tamakyi/TamaBox/route/question"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Web = &cli.Command{
|
var Web = &cli.Command{
|
||||||
@@ -98,6 +99,16 @@ func runWeb(ctx *cli.Context) error {
|
|||||||
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
|
||||||
shared.App.SetLang(conf.App.DefaultLang)
|
shared.App.SetLang(conf.App.DefaultLang)
|
||||||
|
|
||||||
|
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
|
||||||
|
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
|
||||||
|
if fontPath, err := question.CheckShareCardFont(); err != nil {
|
||||||
|
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
|
||||||
|
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
|
||||||
|
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
|
||||||
|
} else {
|
||||||
|
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
|
||||||
|
}
|
||||||
|
|
||||||
r := route.New()
|
r := route.New()
|
||||||
r.Use(tracing.Middleware("TamaBox"))
|
r.Use(tracing.Middleware("TamaBox"))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package question
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/tamakyi/TamaBox/internal/conf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFontCandidatesOrder 锁住候选字体的优先级。
|
||||||
|
//
|
||||||
|
// 顺序错了不会报错,只会让线上多花几秒去解析一个注定失败的 19 MB CFF 字体
|
||||||
|
// (Noto Sans CJK 是 OTTO 轮廓,freetype 不支持),甚至在缺字体时把排障引向
|
||||||
|
// 「配了 font_path 却仍失败」的错误方向。
|
||||||
|
func TestFontCandidatesOrder(t *testing.T) {
|
||||||
|
old := conf.App.FontPath
|
||||||
|
t.Cleanup(func() { conf.App.FontPath = old })
|
||||||
|
conf.App.FontPath = "/opt/fonts/mine.ttf"
|
||||||
|
|
||||||
|
got := fontCandidates()
|
||||||
|
require.NotEmpty(t, got)
|
||||||
|
require.Equal(t, "/opt/fonts/mine.ttf", got[0], "配置项必须排在最前")
|
||||||
|
|
||||||
|
idxWQY, idxNoto := -1, -1
|
||||||
|
for i, p := range got {
|
||||||
|
if strings.Contains(p, "wqy-zenhei") {
|
||||||
|
idxWQY = i
|
||||||
|
}
|
||||||
|
if strings.Contains(p, "NotoSansCJK") && idxNoto < 0 {
|
||||||
|
idxNoto = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEqual(t, -1, idxWQY, "系统 glyf 候选(文泉驿)应保留")
|
||||||
|
require.NotEqual(t, -1, idxNoto, "CFF 兜底候选应保留")
|
||||||
|
require.Less(t, idxWQY, idxNoto, "glyf 轮廓的系统字体必须排在 Noto(CFF) 之前")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticFontDirs 校验两条不变式:返回的目录一定真实存在、且不重复。
|
||||||
|
// 存在性很重要 —— 把不存在的目录交给 Glob 不会报错,只会静默返回空,
|
||||||
|
// 于是「字体明明放在 static/fonts 却找不到」这种问题查起来毫无线索。
|
||||||
|
func TestStaticFontDirs(t *testing.T) {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, d := range staticFontDirs() {
|
||||||
|
st, err := os.Stat(d)
|
||||||
|
require.NoError(t, err, "候选目录必须存在:%s", d)
|
||||||
|
require.True(t, st.IsDir(), "候选必须是目录:%s", d)
|
||||||
|
_, dup := seen[d]
|
||||||
|
require.False(t, dup, "目录重复:%s", d)
|
||||||
|
seen[d] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadFontWithPathReportsTried 确认失败信息里列出实际尝试过的路径 ——
|
||||||
|
// 缺字体时这条文案就是唯一线索(分享卡是公开端点,没人看得到 console)。
|
||||||
|
func TestLoadFontWithPathReportsTried(t *testing.T) {
|
||||||
|
old := conf.App.FontPath
|
||||||
|
t.Cleanup(func() { conf.App.FontPath = old })
|
||||||
|
conf.App.FontPath = filepath.Join(t.TempDir(), "definitely-missing.ttf")
|
||||||
|
|
||||||
|
// 环境里可能偏偏装着可用系统字体,那就只能跳过(不断言失败)。
|
||||||
|
if _, path, err := loadFontWithPath(); err == nil {
|
||||||
|
t.Skipf("本机存在可用系统字体 %q,跳过失败路径断言", path)
|
||||||
|
} else {
|
||||||
|
require.Contains(t, err.Error(), "definitely-missing.ttf", "错误信息必须列出尝试过的路径")
|
||||||
|
require.Contains(t, err.Error(), "TrueType 轮廓", "错误信息必须点明需要 glyf 轮廓")
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-17
@@ -123,20 +123,51 @@ var (
|
|||||||
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
|
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 全局字体缓存
|
// 全局字体缓存。
|
||||||
|
//
|
||||||
|
// fontFail* 记录"上一次加载失败"与时间:失败结果要短期缓存,不能每次请求都重来。
|
||||||
|
// 缺字体时单次尝试的代价是遍历候选路径 + 读进 19 MB 的 .ttc 再解析失败(实测 0.7–4 秒纯 CPU),
|
||||||
|
// 而分享卡是**公开端点**——不缓存失败就等于给任何人一个"发一个 GET 烧几秒 CPU"的放大器。
|
||||||
|
// 仍保留 1 分钟重试窗口:字体文件是部署产物,补上后无需重启即可恢复。
|
||||||
var (
|
var (
|
||||||
fontMu sync.Mutex
|
fontMu sync.Mutex
|
||||||
fontCache *truetype.Font
|
fontCache *truetype.Font
|
||||||
|
fontFailAt time.Time
|
||||||
|
fontFailErr error
|
||||||
)
|
)
|
||||||
|
|
||||||
// getFont 返回绘图字体。失败结果不缓存:字体文件属于部署产物、不在仓库里,
|
// fontFailRetry 是加载失败后的重试间隔。
|
||||||
// 新环境最常见的就是漏放;补上文件后无需重启即可恢复分享卡。
|
const fontFailRetry = time.Minute
|
||||||
|
|
||||||
|
// getFont 返回绘图字体。成功结果永久缓存;失败结果缓存 fontFailRetry 后重试。
|
||||||
func getFont() (*truetype.Font, error) {
|
func getFont() (*truetype.Font, error) {
|
||||||
fontMu.Lock()
|
fontMu.Lock()
|
||||||
defer fontMu.Unlock()
|
defer fontMu.Unlock()
|
||||||
if fontCache != nil {
|
if fontCache != nil {
|
||||||
return fontCache, nil
|
return fontCache, nil
|
||||||
}
|
}
|
||||||
|
if fontFailErr != nil && time.Since(fontFailAt) < fontFailRetry {
|
||||||
|
return nil, fontFailErr
|
||||||
|
}
|
||||||
|
|
||||||
|
font, err := loadFont()
|
||||||
|
if err != nil {
|
||||||
|
fontFailAt, fontFailErr = time.Now(), err
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fontCache = font
|
||||||
|
fontFailErr = nil
|
||||||
|
return font, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFont 遍历候选路径,返回第一个能被解析的字体。
|
||||||
|
func loadFont() (*truetype.Font, error) {
|
||||||
|
ft, _, err := loadFontWithPath()
|
||||||
|
return ft, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFontWithPath 同 loadFont,但额外返回命中的文件路径(启动日志要打出来)。
|
||||||
|
func loadFontWithPath() (*truetype.Font, string, error) {
|
||||||
var tried []string
|
var tried []string
|
||||||
for _, path := range fontCandidates() {
|
for _, path := range fontCandidates() {
|
||||||
tried = append(tried, path)
|
tried = append(tried, path)
|
||||||
@@ -144,38 +175,97 @@ func getFont() (*truetype.Font, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ft, err := freetype.ParseFont(data)
|
if ft, err := freetype.ParseFont(data); err == nil {
|
||||||
if err != nil {
|
return ft, path, nil
|
||||||
continue
|
}
|
||||||
|
// .ttc(TrueType Collection)是「字体集合」,ParseFont 读不了它;
|
||||||
|
// 先提取第一个字面再试。注意:这只对 TrueType 轮廓(glyf)的集合有效,
|
||||||
|
// Noto Sans CJK 那种 CFF/OTTO 轮廓 freetype 本身就不支持,提出来照样失败。
|
||||||
|
if extracted, ttcErr := ttcFirstFont(data); ttcErr == nil {
|
||||||
|
if ft, err := freetype.ParseFont(extracted); err == nil {
|
||||||
|
return ft, path + "(ttc 首字面)", nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fontCache = ft
|
|
||||||
return ft, nil
|
|
||||||
}
|
}
|
||||||
if len(tried) == 0 {
|
if len(tried) == 0 {
|
||||||
return nil, errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc")
|
return nil, "", errors.New("font_path 未配置,且 static/fonts 下没有 .otf/.ttf/.ttc")
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("没有可用字体,已尝试:%s", strings.Join(tried, "、"))
|
return nil, "", fmt.Errorf("没有可用字体(需 TrueType 轮廓的 ttf/otf/ttc,Noto Sans CJK 是 CFF 轮廓不支持),已尝试:%s", strings.Join(tried, "、"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckShareCardFont 在启动时探一次字体并把结果写进缓存,返回命中的路径。
|
||||||
|
//
|
||||||
|
// 为什么要提前探:分享卡是公开端点,缺字体只会在「第一个真实请求」时才 500,
|
||||||
|
// 而那已经是线上事故;启动日志里一条 WARN + 补救命令能让人当场发现。
|
||||||
|
// 顺带把首次请求的解析开销(读十几 MB + 解析)挪到启动阶段。
|
||||||
|
func CheckShareCardFont() (string, error) {
|
||||||
|
fontMu.Lock()
|
||||||
|
defer fontMu.Unlock()
|
||||||
|
ft, path, err := loadFontWithPath()
|
||||||
|
if err != nil {
|
||||||
|
fontFailAt, fontFailErr = time.Now(), err
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fontCache, fontFailErr = ft, nil
|
||||||
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fontCandidates 字体候选路径:配置项优先,其次是部署目录里的字体文件
|
// fontCandidates 字体候选路径:配置项优先,其次是部署目录里的字体文件
|
||||||
// (换过文件名也能用上),最后是常见系统 CJK 字体路径。
|
// (换过文件名也能用上),最后是系统里能用的 CJK 字体。
|
||||||
|
//
|
||||||
|
// 顺序是有意的:
|
||||||
|
// - static/fonts 是部署产物,最可能就是「运维补的那个」;
|
||||||
|
// - 系统候选把 glyf 轮廓的放前面,Noto Sans CJK 压到最后 —— 后者是 CFF/OTTO,
|
||||||
|
// freetype 一定解析失败,只是偶尔会有 glyf 变体才留着当兜底。
|
||||||
|
//
|
||||||
|
// 相对路径按「当前工作目录」与「可执行文件所在目录」各查一次:systemd 单元
|
||||||
|
// 未必配 WorkingDirectory,只按 CWD 找会出现「文件明明在却找不到」。
|
||||||
func fontCandidates() []string {
|
func fontCandidates() []string {
|
||||||
var paths []string
|
var paths []string
|
||||||
if p := strings.TrimSpace(conf.App.FontPath); p != "" {
|
if p := strings.TrimSpace(conf.App.FontPath); p != "" {
|
||||||
paths = append(paths, p)
|
paths = append(paths, p)
|
||||||
}
|
}
|
||||||
for _, pattern := range []string{"static/fonts/*.otf", "static/fonts/*.ttf", "static/fonts/*.ttc"} {
|
for _, dir := range staticFontDirs() {
|
||||||
if matches, err := filepath.Glob(pattern); err == nil {
|
for _, pattern := range []string{"*.ttf", "*.otf", "*.ttc"} {
|
||||||
paths = append(paths, matches...)
|
if matches, err := filepath.Glob(filepath.Join(dir, pattern)); err == nil {
|
||||||
|
paths = append(paths, matches...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return append(paths,
|
return append(paths,
|
||||||
|
// glyf 轮廓的 CJK 字体:apt install fonts-wqy-zenhei / fonts-wqy-microhei /
|
||||||
|
// fonts-droid-fallback / fonts-arphic-uming 任装一个即可工作
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||||
|
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||||
|
"/usr/share/fonts/truetype/arphic/uming.ttc",
|
||||||
|
// 兜底:CFF 轮廓,通常会解析失败,仅在存在 glyf 变体时有意义
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
|
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
|
||||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// staticFontDirs 返回要去扫字体文件的目录(去重、跳过不存在的)。
|
||||||
|
func staticFontDirs() []string {
|
||||||
|
dirs := []string{filepath.Join("static", "fonts")}
|
||||||
|
if exe, err := os.Executable(); err == nil {
|
||||||
|
dirs = append(dirs, filepath.Join(filepath.Dir(exe), "static", "fonts"))
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(dirs))
|
||||||
|
out := make([]string, 0, len(dirs))
|
||||||
|
for _, d := range dirs {
|
||||||
|
if _, ok := seen[d]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d] = struct{}{}
|
||||||
|
if st, err := os.Stat(d); err == nil && st.IsDir() {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
|
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
|
||||||
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
|
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
|
||||||
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
|
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package question
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ttcFirstFont 从一个 TrueType Collection(.ttc)里提出第一个字面,返回等价的单字体数据。
|
||||||
|
//
|
||||||
|
// 为什么需要:分享卡的字体候选里放了系统 CJK 字体路径,其中三个是 .ttc
|
||||||
|
// (Debian / Ubuntu 的 fonts-noto-cjk 只提供 .ttc)。但 freetype.ParseFont 只能读
|
||||||
|
// 单字体文件,碰到 .ttc 一律失败 —— 现场表现是「路径明明存在,却报没有可用字体」
|
||||||
|
// (本地部署实测,分享卡 500)。这里按 sfnt 规范把选中的字面重排成独立文件:
|
||||||
|
// 复制表目录与表数据,并把每张表的 offset 改写成新文件内的位置。
|
||||||
|
//
|
||||||
|
// 只取第一个字面:分享卡只需要一套能画中英混排的字形,TTC 里后续字面是
|
||||||
|
// another weight / another language 的变体,不值得为它引入「按名字挑字面」的逻辑。
|
||||||
|
func ttcFirstFont(data []byte) ([]byte, error) {
|
||||||
|
if len(data) < 16 || string(data[:4]) != "ttcf" {
|
||||||
|
return nil, errors.New("not a TrueType Collection")
|
||||||
|
}
|
||||||
|
numFonts := binary.BigEndian.Uint32(data[8:12])
|
||||||
|
if numFonts == 0 {
|
||||||
|
return nil, errors.New("TrueType Collection 里没有字体")
|
||||||
|
}
|
||||||
|
base := int(binary.BigEndian.Uint32(data[12:16]))
|
||||||
|
if base < 0 || base+12 > len(data) {
|
||||||
|
return nil, errors.New("TrueType Collection 的子字体偏移越界")
|
||||||
|
}
|
||||||
|
|
||||||
|
sfntVersion := binary.BigEndian.Uint32(data[base : base+4])
|
||||||
|
numTables := int(binary.BigEndian.Uint16(data[base+4 : base+6]))
|
||||||
|
if numTables == 0 {
|
||||||
|
return nil, errors.New("子字体没有表")
|
||||||
|
}
|
||||||
|
recStart := base + 12
|
||||||
|
if recStart+16*numTables > len(data) {
|
||||||
|
return nil, errors.New("子字体的表目录越界")
|
||||||
|
}
|
||||||
|
|
||||||
|
type table struct {
|
||||||
|
tag uint32
|
||||||
|
checksum uint32
|
||||||
|
srcOff int
|
||||||
|
srcLen int
|
||||||
|
dstOff uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := make([]table, 0, numTables)
|
||||||
|
for i := 0; i < numTables; i++ {
|
||||||
|
p := recStart + 16*i
|
||||||
|
srcOff := int(binary.BigEndian.Uint32(data[p+8 : p+12]))
|
||||||
|
srcLen := int(binary.BigEndian.Uint32(data[p+12 : p+16]))
|
||||||
|
if srcOff < 0 || srcLen < 0 || srcOff+srcLen > len(data) {
|
||||||
|
return nil, errors.New("子字体的表数据越界")
|
||||||
|
}
|
||||||
|
tables = append(tables, table{
|
||||||
|
tag: binary.BigEndian.Uint32(data[p : p+4]),
|
||||||
|
checksum: binary.BigEndian.Uint32(data[p+4 : p+8]),
|
||||||
|
srcOff: srcOff,
|
||||||
|
srcLen: srcLen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新文件布局:sfnt header(12) + 表目录(16n) + 表数据(每张表 4 字节对齐)
|
||||||
|
offset := uint32(12 + 16*numTables)
|
||||||
|
for i := range tables {
|
||||||
|
tables[i].dstOff = offset
|
||||||
|
offset += uint32(tables[i].srcLen)
|
||||||
|
offset += (4 - offset%4) % 4
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, 0, offset)
|
||||||
|
header := make([]byte, 12)
|
||||||
|
binary.BigEndian.PutUint32(header[0:4], sfntVersion)
|
||||||
|
binary.BigEndian.PutUint16(header[4:6], uint16(numTables))
|
||||||
|
// searchRange / entrySelector / rangeShift:规范要求填,部分解析器(含 freetype)
|
||||||
|
// 不校验,这里仍按公式算,避免换解析器后踩坑。
|
||||||
|
maxPow2, entrySelector := 1, uint16(0)
|
||||||
|
for maxPow2*2 <= numTables {
|
||||||
|
maxPow2 *= 2
|
||||||
|
entrySelector++
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint16(header[6:8], uint16(maxPow2*16))
|
||||||
|
binary.BigEndian.PutUint16(header[8:10], entrySelector)
|
||||||
|
binary.BigEndian.PutUint16(header[10:12], uint16(numTables*16-maxPow2*16))
|
||||||
|
out = append(out, header...)
|
||||||
|
|
||||||
|
dir := make([]byte, 16*numTables)
|
||||||
|
for i, t := range tables {
|
||||||
|
p := 16 * i
|
||||||
|
binary.BigEndian.PutUint32(dir[p:p+4], t.tag)
|
||||||
|
binary.BigEndian.PutUint32(dir[p+4:p+8], t.checksum)
|
||||||
|
binary.BigEndian.PutUint32(dir[p+8:p+12], t.dstOff)
|
||||||
|
binary.BigEndian.PutUint32(dir[p+12:p+16], uint32(t.srcLen))
|
||||||
|
}
|
||||||
|
out = append(out, dir...)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Write(out)
|
||||||
|
for _, t := range tables {
|
||||||
|
if pad := int(t.dstOff) - buf.Len(); pad > 0 {
|
||||||
|
buf.Write(make([]byte, pad))
|
||||||
|
}
|
||||||
|
buf.Write(data[t.srcOff : t.srcOff+t.srcLen])
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user