// Copyright 2025-2026 肖其顿 (XIAO QI DUN) // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package font import ( "bytes" "path" "slices" "sort" "strings" ) // 字体匹配按精确、部分和模糊匹配排列,零值表示未匹配 const ( MatchNone = iota MatchExact MatchPartial MatchFuzzy ) // matchRules 保存常用字体的名称、文件名及系统字体匹配规则 var matchRules = normalizeMatchRules([]matchRule{ { Keys: []string{"小标宋", "方正小标宋", "xiaobiaosong", "fzxiaobiaosong", "fzxbs"}, Names: []string{"小标宋体", "方正小标宋简体", "FZXiaoBiaoSong-B05", "FZXiaoBiaoSong-B05S"}, Files: []string{"FZXBSJW.TTF", "fzxbs*.ttf", "fzxiaobiaosong*.ttf", "xiaobiaosong*.ttf"}, System: "FZXiaoBiaoSong-B05S", NoSyntheticBold: true, }, { Keys: []string{"宋体", "新宋体", "simsun", "nsimsun", "simsunextb", "song", "songti", "stsong", "书宋", "方正书宋"}, Names: []string{"宋体", "新宋体", "SimSun", "NSimSun", "SongTi", "STSong"}, Files: []string{"simsun.ttc", "simsun.ttf", "nsimsun.ttf", "STSONG.TTF"}, System: "SimSun", }, { Keys: []string{"黑体", "hei", "simhei", "heiti", "sthei", "方正黑体"}, Names: []string{"黑体", "SimHei", "HeiTi", "STHeiti", "方正黑体简体"}, Files: []string{"simhei.ttf", "STXIHEI.TTF", "FZHT_GB18030.TTF", "fzht*.ttf"}, System: "SimHei", }, { Keys: []string{"楷体", "kai", "kaiti", "simkai", "stkaiti", "方正楷体"}, Names: []string{"楷体", "KaiTi", "SimKai", "STKaiti", "方正楷体简体"}, Files: []string{"simkai.ttf", "kaiti.ttf", "STKAITI.TTF", "FZKTJ.TTF", "fzkai*.ttf", "fzkt*.ttf"}, System: "KaiTi", }, { Keys: []string{"仿宋", "fang", "fangsong", "simfang", "stfangsong", "方正仿宋"}, Names: []string{"仿宋", "FangSong", "SimFang", "STFangsong", "方正仿宋简体"}, Files: []string{"simfang.ttf", "fangsong.ttf", "STFANGSO.TTF", "FZFSJ.TTF", "fzfs*.ttf"}, System: "FangSong", }, { Keys: []string{"微软雅黑", "microsoftyahei", "yahei", "msyh"}, Names: []string{"微软雅黑", "Microsoft YaHei", "YaHei", "MSYH"}, Files: []string{"msyh.ttc", "msyh.ttf"}, System: "Microsoft YaHei", }, { Keys: []string{"arialblack"}, Names: []string{"Arial Black", "ArialBlack"}, Files: []string{"ariblk.ttf", "arial black*.ttf", "arialblack*.ttf"}, System: "Arial Black", }, { Keys: []string{"arial"}, Names: []string{"Arial"}, Files: []string{"arial.ttf", "arial*.ttf"}, System: "Arial", }, { Keys: []string{"helvetica"}, Names: []string{"Helvetica", "Arial"}, Files: []string{"arial.ttf", "arial*.ttf"}, System: "Arial", }, { Keys: []string{"couriernew", "courier"}, Names: []string{"Courier New", "CourierNew", "Courier"}, Files: []string{"cour.ttf", "courier new*.ttf", "courier*.ttf"}, System: "Courier New", }, { Keys: []string{"segoeui"}, Names: []string{"Segoe UI", "SegoeUI"}, Files: []string{"segoeui.ttf", "segoe*.ttf"}, System: "Segoe UI", }, { Keys: []string{"timesnewroman", "times"}, Names: []string{"Times New Roman", "TimesNewRoman"}, Files: []string{"times.ttf", "times new roman*.ttf", "times*.ttf"}, System: "Times New Roman", }, }) // fontNameReplacer 移除字体名称匹配时忽略的分隔符 var fontNameReplacer = strings.NewReplacer(" ", "", "-", "", "_", "", "(", "", ")", "", "(", "", ")", "") // matchRule 字体匹配规则 type matchRule struct { Keys []string Names []string Files []string System string NoSyntheticBold bool } // FileCandidate 字体文件候选 type FileCandidate struct { Name string Face int base string lowerBase string filename string normalized string names []string fold string } // patternMatcher 字体文件匹配器 type patternMatcher struct { pattern string lowerPattern string stem string aliases []string priority int } // FileMatch 字体文件匹配结果 type FileMatch struct { Name string Face int Priority int Rank int styleRank int sortName string } // fileKey 字体文件与集合索引的去重键 type fileKey struct { Name string Face int } // NormalizeName 规范化字体名称 // 入参: name 字体名称 // 返回: string 规范化后的字体名称 func NormalizeName(name string) string { name = strings.ReplaceAll(name, "\\", "/") name = path.Base(strings.TrimSpace(name)) if IsFontFileName(name) { name = strings.TrimSuffix(name, path.Ext(name)) } name = strings.ToLower(strings.TrimSpace(name)) return fontNameReplacer.Replace(name) } // IsFontFileName 判断是否为字体文件名 // 入参: name 文件名 // 返回: bool 是否为字体文件 func IsFontFileName(name string) bool { switch strings.ToLower(path.Ext(name)) { case ".ttf", ".otf", ".ttc", ".otc": return true default: return false } } // AppendFontName 追加字体名称 // 入参: names 字体名称列表, seen 去重映射, name 字体名称 // 返回: []string 字体名称列表 func AppendFontName(names []string, seen map[string]bool, name string) []string { name = strings.TrimSpace(name) if name == "" { return names } key := NormalizeName(name) if key == "" || seen[key] { return names } seen[key] = true return append(names, name) } // normalizeMatchRules 预处理固定字体规则的匹配名称,保留候选名称及顺序 // 入参: rules 字体匹配规则 // 返回: []matchRule 预处理后的规则 func normalizeMatchRules(rules []matchRule) []matchRule { for i := range rules { rule := &rules[i] keys := append(append(rule.Keys, rule.Names...), rule.System) rule.Keys = nil for _, key := range keys { key = NormalizeName(key) if key != "" && !slices.Contains(rule.Keys, key) { rule.Keys = append(rule.Keys, key) } } } return rules } // CandidateNames 获取字体候选名称 // 入参: names 字体名称列表 // 返回: []string 字体候选名称 func CandidateNames(names ...string) []string { return candidateNamesByLevel(MatchFuzzy, names...) } // ExactCandidateNames 获取精确字体候选名称 // 入参: names 字体名称列表 // 返回: []string 精确字体候选名称 func ExactCandidateNames(names ...string) []string { return candidateNamesByLevel(MatchExact, names...) } // qualifiedNames 获取保留名称后缀的字体候选 // 入参: name 字体名称 // 返回: []string 保留后缀的候选名称 func qualifiedNames(name string) []string { name = NormalizeName(name) key := NormalizeName(name) var names []string seen := make(map[string]bool) for _, rule := range matchRules { if ruleMatchLevel(rule, key) != MatchPartial { continue } prefix := "" for _, alias := range rule.Keys { if len(alias) > len(prefix) && strings.HasPrefix(name, alias) { prefix = alias } } suffix := strings.TrimPrefix(name, prefix) for _, alias := range append([]string{rule.System}, rule.Names...) { names = AppendFontName(names, seen, NormalizeName(alias)+suffix) } } return names } // candidateNamesByLevel 获取指定等级内的字体候选名称 // 入参: maxLevel 最大匹配等级, names 字体名称列表 // 返回: []string 字体候选名称 func candidateNamesByLevel(maxLevel int, names ...string) []string { var result []string seen := make(map[string]bool) var bases []string for _, name := range names { name = strings.TrimSpace(name) if name == "" { continue } result = AppendFontName(result, seen, name) bases = append(bases, name) } levels := matchLevels(bases) for level := MatchExact; level <= maxLevel; level++ { for i := range bases { for j, rule := range matchRules { if levels[i*len(matchRules)+j] == level { result = AppendFontName(result, seen, rule.System) for _, item := range rule.Names { result = AppendFontName(result, seen, item) } } } } } return result } // patternStem 获取字体匹配模式主干 // 入参: pattern 字体文件匹配模式 // 返回: string 字体匹配模式主干 func patternStem(pattern string) string { stem := strings.TrimSuffix(pattern, "*") if IsFontFileName(stem) { stem = strings.TrimSuffix(stem, path.Ext(stem)) } if index := strings.IndexAny(stem, "*?["); index >= 0 { stem = stem[:index] } return NormalizeName(stem) } // FilePatterns 获取字体文件匹配模式 // 入参: names 字体名称列表 // 返回: []string 字体文件匹配模式 func FilePatterns(names ...string) []string { var result []string seen := make(map[string]bool) levels := matchLevels(names) for level := MatchExact; level <= MatchFuzzy; level++ { for _, name := range candidateNamesAtLevel(level, names, levels) { result = appendPattern(result, seen, name+"*") } for i := range names { for j, rule := range matchRules { if levels[i*len(matchRules)+j] == level { for _, item := range rule.Files { result = appendPattern(result, seen, item) } } } } } return result } // SystemNames 获取系统字体名称 // 入参: names 字体名称列表 // 返回: []string 系统字体名称 func SystemNames(names ...string) []string { var result []string seen := make(map[string]bool) for _, name := range names { for _, item := range candidateNamesByLevel(MatchPartial, name) { result = AppendFontName(result, seen, item) } } return result } // candidateNamesAtLevel 获取指定等级的字体候选名称 // 入参: level 匹配等级, names 字体名称列表, levels 名称与规则的匹配等级 // 返回: []string 字体候选名称 func candidateNamesAtLevel(level int, names []string, levels []int) []string { var result []string seen := make(map[string]bool) for i, name := range names { name = strings.TrimSpace(name) if name == "" { continue } if level == MatchExact { result = AppendFontName(result, seen, name) } for j, rule := range matchRules { if levels[i*len(matchRules)+j] == level { result = AppendFontName(result, seen, rule.System) for _, item := range rule.Names { result = AppendFontName(result, seen, item) } } } } return result } // DefaultSystemNames 获取默认系统字体名称 // 返回: []string 默认系统字体名称 func DefaultSystemNames() []string { return []string{ "SimSun", "Microsoft YaHei", "SimHei", "KaiTi", "FangSong", "Noto Sans CJK SC", "Source Han Sans SC", "Arial", "Segoe UI", "Times New Roman", } } // NoSyntheticBold 判断字体是否禁用合成粗体 // 入参: names 字体名称列表 // 返回: bool 是否禁用合成粗体 func NoSyntheticBold(names ...string) bool { for _, name := range names { name = NormalizeName(name) for _, rule := range matchRules { if !rule.NoSyntheticBold { continue } level := ruleMatchLevel(rule, name) if level >= MatchExact && level <= MatchPartial { return true } } } return false } // matchLevels 按名称和规则顺序计算匹配等级 // 入参: names 字体名称列表 // 返回: []int 名称与规则的匹配等级 func matchLevels(names []string) []int { levels := make([]int, len(names)*len(matchRules)) for i, name := range names { name = NormalizeName(name) for j, rule := range matchRules { levels[i*len(matchRules)+j] = ruleMatchLevel(rule, name) } } return levels } // ruleMatchLevel 获取字体规则匹配等级 // 入参: rule 字体匹配规则, name 规范化后的字体名称 // 返回: int 匹配等级 func ruleMatchLevel(rule matchRule, name string) int { if name == "" { return MatchNone } level := MatchNone for _, item := range rule.Keys { if name == item { return MatchExact } if strings.HasPrefix(name, item) { level = MatchPartial } else if level == MatchNone && strings.Contains(name, item) { level = MatchFuzzy } } return level } // appendPattern 追加字体文件匹配模式 // 入参: patterns 匹配模式列表, seen 去重映射, pattern 匹配模式 // 返回: []string 匹配模式列表 func appendPattern(patterns []string, seen map[string]bool, pattern string) []string { pattern = strings.TrimSpace(pattern) if pattern == "" { return patterns } key := strings.ToLower(pattern) if seen[key] { return patterns } seen[key] = true return append(patterns, pattern) } // AppendFileNames 建立各字体的内部名称与文件别名匹配候选 // 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称 // 返回: []FileCandidate 字体候选列表 func AppendFileNames(candidates []FileCandidate, index int, faces [][]string) []FileCandidate { file := candidates[index] replaced := false for face, names := range faces { file.Face = face file.names = make([]string, len(names)) for i, name := range names { file.names[i] = NormalizeName(name) } for _, name := range file.names { file.normalized = name if !replaced { candidates[index] = file replaced = true } else { candidates = append(candidates, file) } } } return candidates } // CollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项 // 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体 // 返回: int 零起始字体索引 func CollectionIndex(data []byte, names []string, bold, italic bool) int { return NameIndex(FileNames(bytes.NewReader(data)), names, bold, italic) } // NameIndex 按名称与样式选择字体索引,未匹配时保留首项 // 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体 // 返回: int 零起始字体索引 func NameIndex(faces [][]string, names []string, bold, italic bool) int { candidates := AppendFileNames([]FileCandidate{{}}, 0, faces) if matches := FileMatches(candidates, names, bold, italic); len(matches) > 0 { return matches[0].Face } return 0 } // FileMatches 匹配字体文件候选 // 入参: candidates 字体候选, names 字体名称, bold 是否粗体, italic 是否斜体 // 返回: []FileMatch 按优先级排列的字体匹配 func FileMatches(candidates []FileCandidate, names []string, bold, italic bool) []FileMatch { matches := make([]FileMatch, 0, len(candidates)) seen := make(map[fileKey]int, len(candidates)) if len(names) == 0 { for _, name := range DefaultSystemNames() { matches = append(matches, FileMatches(candidates, []string{name}, bold, italic)...) } sort.SliceStable(matches, func(i, j int) bool { return matches[i].Rank < matches[j].Rank }) unique := matches[:0] for _, match := range matches { key := fileKey{strings.ToLower(match.Name), match.Face} if _, ok := seen[key]; !ok { seen[key] = len(unique) unique = append(unique, match) } } return unique } for i, name := range names { if name == "" { continue } for j, alias := range append([]string{name}, qualifiedNames(name)...) { matcher := patternMatcher{stem: NormalizeName(alias), priority: (i-len(names))*2 + min(j, 1)} if matcher.stem == "" { continue } for _, file := range candidates { if rank := matcher.rankCandidate(file); rank == MatchExact { appendFileMatch(&matches, seen, matcher, file, rank, bold, italic) } } } } for _, matcher := range newPatternMatchers(FilePatterns(names...)) { for _, file := range candidates { rank := matcher.rankCandidate(file) appendFileMatch(&matches, seen, matcher, file, rank, bold, italic) } } sortFileMatches(matches) return matches } // FileCandidates 筛选字体文件候选 // 入参: names 文件名列表, base 文件名提取函数 // 返回: []FileCandidate 字体文件候选列表 func FileCandidates(names []string, base func(string) string) []FileCandidate { files := make([]FileCandidate, 0, len(names)) for _, name := range names { if IsFontFileName(name) { baseName := base(name) normalized := NormalizeName(strings.TrimSuffix(baseName, path.Ext(baseName))) files = append(files, FileCandidate{ Name: name, base: baseName, lowerBase: strings.ToLower(baseName), filename: normalized, normalized: normalized, fold: strings.ToLower(name), }) } } return files } // newPatternMatcher 创建字体文件匹配器 // 入参: pattern 匹配模式 // 返回: patternMatcher 字体文件匹配器 func newPatternMatcher(pattern string) patternMatcher { matcher := patternMatcher{ pattern: pattern, lowerPattern: strings.ToLower(pattern), stem: patternStem(pattern), } if matcher.stem != "" { for _, alias := range ExactCandidateNames(matcher.stem) { alias = NormalizeName(alias) if alias != "" { matcher.aliases = append(matcher.aliases, alias) } } } return matcher } // newPatternMatchers 创建字体文件匹配器列表 // 入参: patterns 匹配模式列表 // 返回: []patternMatcher 字体文件匹配器列表 func newPatternMatchers(patterns []string) []patternMatcher { matchers := make([]patternMatcher, 0, len(patterns)) for priority, pattern := range patterns { matcher := newPatternMatcher(pattern) matcher.priority = priority matchers = append(matchers, matcher) } return matchers } // rankCandidate 获取字体文件候选匹配等级 // 入参: file 字体文件候选 // 返回: int 匹配等级 func (m patternMatcher) rankCandidate(file FileCandidate) int { exactPath, _ := path.Match(m.pattern, file.base) lowerPath, _ := path.Match(m.lowerPattern, file.lowerBase) if m.stem == "" { if exactPath || lowerPath { return MatchFuzzy } return MatchNone } name := m.candidateName(file) if name == m.stem { return MatchExact } for _, alias := range m.aliases { if name == alias { return MatchExact } } if fileKnownStyleSuffix(m.styleSuffixNormalized(name)) { return MatchExact } if strings.HasPrefix(name, m.stem) { return MatchPartial } for _, alias := range m.aliases { if strings.HasPrefix(name, alias) { return MatchPartial } } if exactPath || lowerPath || strings.Contains(name, m.stem) { return MatchFuzzy } for _, alias := range m.aliases { if strings.Contains(name, alias) { return MatchFuzzy } } return MatchNone } // candidateName 保留同族内部名称的区分,只有无关联时使用调用方文件别名 // 入参: file 字体文件候选 // 返回: string 用于统一匹配名称与样式的规范名称 func (m patternMatcher) candidateName(file FileCandidate) string { if len(file.names) == 0 || file.base == "" || m.stem == "" { return file.normalized } for _, name := range file.names { if strings.HasPrefix(name, m.stem) { return file.normalized } for _, alias := range m.aliases { if strings.HasPrefix(name, alias) { return file.normalized } } } return file.filename } // styleSuffixNormalized 获取规范字体名称的样式后缀 // 入参: name 规范字体名称 // 返回: string 样式后缀 func (m patternMatcher) styleSuffixNormalized(name string) string { if m.stem == "" || name == "" { return "" } if strings.HasPrefix(name, m.stem) { return strings.TrimPrefix(name, m.stem) } for _, alias := range m.aliases { if strings.HasPrefix(name, alias) { return strings.TrimPrefix(name, alias) } } return "" } // appendFileMatch 追加字体文件匹配结果 // 入参: matches 匹配结果, seen 已匹配文件, matcher 匹配器, file 字体文件候选, rank 匹配等级, bold 是否粗体, italic 是否斜体 func appendFileMatch(matches *[]FileMatch, seen map[fileKey]int, matcher patternMatcher, file FileCandidate, rank int, bold, italic bool) { if rank == MatchNone { return } suffix := matcher.styleSuffixNormalized(matcher.candidateName(file)) if rank == MatchFuzzy { suffix = matcher.styleSuffixNormalized(NormalizeName(file.base)) } next := FileMatch{ Name: file.Name, Face: file.Face, Priority: matcher.priority, Rank: rank, styleRank: fileStyleRank(suffix, bold, italic), sortName: file.lowerBase, } key := fileKey{file.fold, file.Face} if index, ok := seen[key]; ok { if fileMatchLess(next, (*matches)[index]) { (*matches)[index] = next } return } seen[key] = len(*matches) *matches = append(*matches, next) } // sortFileMatches 排序字体文件匹配结果 // 入参: matches 匹配结果 func sortFileMatches(matches []FileMatch) { sort.SliceStable(matches, func(i, j int) bool { return fileMatchLess(matches[i], matches[j]) }) } // fileMatchLess 判断字体文件匹配结果优先级 // 入参: left 左侧匹配结果, right 右侧匹配结果 // 返回: bool 左侧是否优先 func fileMatchLess(left, right FileMatch) bool { if left.Priority != right.Priority { return left.Priority < right.Priority } if left.Rank != right.Rank { return left.Rank < right.Rank } if left.styleRank != right.styleRank { return left.styleRank < right.styleRank } return left.sortName < right.sortName } // FileMatchNames 获取字体文件匹配名称 // 入参: matches 匹配结果 // 返回: []string 字体文件列表 func FileMatchNames(matches []FileMatch) []string { names := make([]string, 0, len(matches)) seen := make(map[string]bool, len(matches)) for _, match := range matches { key := strings.ToLower(match.Name) if !seen[key] { seen[key] = true names = append(names, match.Name) } } return names } // fileStyleRank 获取字体文件样式匹配等级 // 入参: suffix 样式后缀, bold 是否粗体, italic 是否斜体 // 返回: int 样式匹配等级 func fileStyleRank(suffix string, bold, italic bool) int { fileBold, fileItalic := fileStyleFromSuffix(suffix) rank := 0 if fileBold != bold { rank += 2 } if fileItalic != italic { rank += 2 } if !bold && !italic && !fileBold && !fileItalic && suffix != "" && !fileRegularSuffix(suffix) { rank++ } return rank } // fileStyleFromSuffix 获取样式后缀对应的字体样式 // 入参: suffix 样式后缀 // 返回: bool 是否粗体, bool 是否斜体 func fileStyleFromSuffix(suffix string) (bool, bool) { bold := suffix == "b" || suffix == "bd" || suffix == "bold" || suffix == "bi" || suffix == "bolditalic" || suffix == "boldoblique" italic := suffix == "i" || suffix == "it" || suffix == "italic" || suffix == "oblique" || suffix == "bi" || suffix == "bolditalic" || suffix == "boldoblique" return bold, italic } // fileRegularSuffix 判断是否为常规样式后缀 // 入参: suffix 样式后缀 // 返回: bool 是否为常规样式后缀 func fileRegularSuffix(suffix string) bool { switch suffix { case "r", "regular", "normal", "常规": return true default: return false } } // fileKnownStyleSuffix 判断是否为已知样式后缀 // 入参: suffix 样式后缀 // 返回: bool 是否为已知样式后缀 func fileKnownStyleSuffix(suffix string) bool { switch suffix { case "r", "regular", "normal", "常规", "b", "bd", "bold", "i", "it", "italic", "oblique", "bi", "bolditalic", "boldoblique": return true default: return false } }