Files
ofdgo/internal/base/font/match.go
T
2026-10-14 10:40:08 +08:00

807 lines
23 KiB
Go
Raw 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.
// 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":
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
}
}