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:
+107
-17
@@ -123,20 +123,51 @@ var (
|
||||
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
|
||||
)
|
||||
|
||||
// 全局字体缓存
|
||||
// 全局字体缓存。
|
||||
//
|
||||
// fontFail* 记录"上一次加载失败"与时间:失败结果要短期缓存,不能每次请求都重来。
|
||||
// 缺字体时单次尝试的代价是遍历候选路径 + 读进 19 MB 的 .ttc 再解析失败(实测 0.7–4 秒纯 CPU),
|
||||
// 而分享卡是**公开端点**——不缓存失败就等于给任何人一个"发一个 GET 烧几秒 CPU"的放大器。
|
||||
// 仍保留 1 分钟重试窗口:字体文件是部署产物,补上后无需重启即可恢复。
|
||||
var (
|
||||
fontMu sync.Mutex
|
||||
fontCache *truetype.Font
|
||||
fontMu sync.Mutex
|
||||
fontCache *truetype.Font
|
||||
fontFailAt time.Time
|
||||
fontFailErr error
|
||||
)
|
||||
|
||||
// getFont 返回绘图字体。失败结果不缓存:字体文件属于部署产物、不在仓库里,
|
||||
// 新环境最常见的就是漏放;补上文件后无需重启即可恢复分享卡。
|
||||
// fontFailRetry 是加载失败后的重试间隔。
|
||||
const fontFailRetry = time.Minute
|
||||
|
||||
// getFont 返回绘图字体。成功结果永久缓存;失败结果缓存 fontFailRetry 后重试。
|
||||
func getFont() (*truetype.Font, error) {
|
||||
fontMu.Lock()
|
||||
defer fontMu.Unlock()
|
||||
if 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
|
||||
for _, path := range fontCandidates() {
|
||||
tried = append(tried, path)
|
||||
@@ -144,38 +175,97 @@ func getFont() (*truetype.Font, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ft, err := freetype.ParseFont(data)
|
||||
if err != nil {
|
||||
continue
|
||||
if ft, err := freetype.ParseFont(data); err == nil {
|
||||
return ft, path, nil
|
||||
}
|
||||
// .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 {
|
||||
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 字体候选路径:配置项优先,其次是部署目录里的字体文件
|
||||
// (换过文件名也能用上),最后是常见系统 CJK 字体路径。
|
||||
// (换过文件名也能用上),最后是系统里能用的 CJK 字体。
|
||||
//
|
||||
// 顺序是有意的:
|
||||
// - static/fonts 是部署产物,最可能就是「运维补的那个」;
|
||||
// - 系统候选把 glyf 轮廓的放前面,Noto Sans CJK 压到最后 —— 后者是 CFF/OTTO,
|
||||
// freetype 一定解析失败,只是偶尔会有 glyf 变体才留着当兜底。
|
||||
//
|
||||
// 相对路径按「当前工作目录」与「可执行文件所在目录」各查一次:systemd 单元
|
||||
// 未必配 WorkingDirectory,只按 CWD 找会出现「文件明明在却找不到」。
|
||||
func fontCandidates() []string {
|
||||
var paths []string
|
||||
if p := strings.TrimSpace(conf.App.FontPath); p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
for _, pattern := range []string{"static/fonts/*.otf", "static/fonts/*.ttf", "static/fonts/*.ttc"} {
|
||||
if matches, err := filepath.Glob(pattern); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
for _, dir := range staticFontDirs() {
|
||||
for _, pattern := range []string{"*.ttf", "*.otf", "*.ttc"} {
|
||||
if matches, err := filepath.Glob(filepath.Join(dir, pattern)); err == nil {
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
}
|
||||
}
|
||||
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/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 均非内网,
|
||||
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
|
||||
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
|
||||
|
||||
Reference in New Issue
Block a user