From 70003486b8e85fe97ec01ba04c143bb35ee10770 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 14 Oct 2026 10:40:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BD=92=E5=B9=B6=E6=96=87=E4=BB=B6):=20?= =?UTF-8?q?=E5=BD=92=E5=B9=B6=E6=96=87=E4=BB=B6=E4=B8=8B=E6=B2=89=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/base/font/match.go | 742 ++++++++++++++++++ internal/base/geom/clip.go | 129 +++ internal/base/image/metadata.go | 188 +++++ .../base/image/scale.go | 66 +- internal/codec/pdf/color.go | 188 +++++ ofdgo_action.go | 92 ++- ofdgo_action_destination.go | 109 --- ofdgo_compression_raster.go | 80 +- ofdgo_compression_tiff.go | 174 ---- ofdgo_editor_action.go | 162 ++++ ofdgo_editor_action_query.go | 181 ----- ofdgo_editor_composite_ops.go | 78 +- ofdgo_editor_composite_style.go | 93 --- ofdgo_editor_font_binding.go | 4 +- ofdgo_font.go | 36 + ofdgo_font_fs.go | 405 +--------- ofdgo_font_info.go | 4 +- ofdgo_font_match.go | 435 ---------- ofdgo_font_source.go | 60 +- ofdgo_pdf.go | 132 ++++ ofdgo_pdf_buffer.go | 151 ---- ofdgo_pdf_composite.go | 29 +- ofdgo_pdf_composite_draw.go | 172 +--- ofdgo_pdf_composite_pattern.go | 32 +- ofdgo_pdf_composite_shading.go | 20 +- ofdgo_pdf_transfer.go | 44 +- ofdgo_raster_image.go | 73 +- ofdgo_render_text.go | 3 +- ofdgo_renderer_cache_canvas.go | 70 -- ofdgo_renderer_clip_canvas.go | 112 +++ ofdgo_renderer_clip_convex_canvas.go | 222 ------ ofdgo_renderer_state_canvas.go | 50 ++ ofdgo_renderer_text_canvas.go | 3 +- ofdgo_text.go | 103 +++ ofdgo_text_xml.go | 123 --- ofdgo_version_extract.go | 175 ----- ofdgo_version_reader.go | 155 ++++ 37 files changed, 2386 insertions(+), 2509 deletions(-) create mode 100644 internal/base/geom/clip.go rename ofdgo_raster_image_scale.go => internal/base/image/scale.go (69%) delete mode 100644 ofdgo_action_destination.go delete mode 100644 ofdgo_compression_tiff.go delete mode 100644 ofdgo_editor_action_query.go delete mode 100644 ofdgo_editor_composite_style.go delete mode 100644 ofdgo_font_match.go delete mode 100644 ofdgo_pdf_buffer.go delete mode 100644 ofdgo_renderer_cache_canvas.go delete mode 100644 ofdgo_renderer_clip_convex_canvas.go delete mode 100644 ofdgo_text_xml.go delete mode 100644 ofdgo_version_extract.go diff --git a/internal/base/font/match.go b/internal/base/font/match.go index 39cd1ea..9a9c986 100644 --- a/internal/base/font/match.go +++ b/internal/base/font/match.go @@ -15,13 +15,147 @@ 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 规范化后的字体名称 @@ -62,3 +196,611 @@ func AppendFontName(names []string, seen map[string]bool, name string) []string 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 + } +} diff --git a/internal/base/geom/clip.go b/internal/base/geom/clip.go new file mode 100644 index 0000000..dd3117a --- /dev/null +++ b/internal/base/geom/clip.go @@ -0,0 +1,129 @@ +// 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 geom + +import "math" + +// polygonPoint 允许同结构的后端顶点直接参与裁剪,避免整片坐标转换 +type polygonPoint interface { + ~struct{ X, Y float64 } +} + +// ClipPolygon 按非退化凸窗口逐边裁剪多边形,保留原顶点绕向,不修改输入 +// 入参: a 绘制顶点, b 已通过ConvexPolygon检查的裁剪顶点,均不含重复闭合点 +// 返回: []P 交集顶点,少于三个顶点时不构成区域, bool 是否满足数值精度 +func ClipPolygon[P polygonPoint](a, b []P) ([]P, bool) { + for i, start := range b { + origin, end := Point(start), Point(b[(i+1)%len(b)]) + dx, dy := end.X-origin.X, end.Y-origin.Y + length := math.Hypot(dx, dy) + if length == 0 { + continue + } + dx, dy = dx/length, dy/length + distance := func(p P) float64 { return convexDistance(origin, Point(p), dx, dy) } + sign := 1.0 + for _, p := range b { + if d := distance(p); d != 0 { + sign = math.Copysign(1, d) + break + } + } + if len(a) == 0 { + break + } + var clipped []P + appendPoint := func(p P) { + if len(clipped) == 0 || p != clipped[len(clipped)-1] { + clipped = append(clipped, p) + } + } + previous := a[len(a)-1] + previousDistance := sign * distance(previous) + if !finite(previousDistance) { + return nil, false + } + for _, p := range a { + d := sign * distance(p) + if !finite(d) { + return nil, false + } + if (d >= 0) != (previousDistance >= 0) { + scale := math.Max(math.Abs(d), math.Abs(previousDistance)) + fraction := (previousDistance / scale) / (previousDistance/scale - d/scale) + intersection := Lerp(Point(previous), Point(p), fraction) + if dx == 0 { + intersection.X = origin.X + } + if dy == 0 { + intersection.Y = origin.Y + } + appendPoint(P(intersection)) + } + if d >= 0 { + appendPoint(p) + } + previous, previousDistance = p, d + } + if len(clipped) > 1 && clipped[0] == clipped[len(clipped)-1] { + clipped = clipped[:len(clipped)-1] + } + a = clipped + } + return a, true +} + +// ConvexPolygon 检查所有顶点位于各边的同一侧,不修改输入 +// 入参: points 不含重复闭合点的独立顶点 +// 返回: []P 原顶点,非有限距离或异侧顶点返回nil, bool 是否为非退化凸多边形 +func ConvexPolygon[P polygonPoint](points []P) ([]P, bool) { + positive := false + for i, start := range points { + origin, end := Point(start), Point(points[(i+1)%len(points)]) + dx, dy := end.X-origin.X, end.Y-origin.Y + length := math.Hypot(dx, dy) + if !finite(length) { + return nil, false + } + if length == 0 { + continue + } + dx, dy = dx/length, dy/length + sign := 0.0 + for _, p := range points { + d := convexDistance(origin, Point(p), dx, dy) + if !finite(d) || sign*d < 0 { + return nil, false + } + if d != 0 { + sign, positive = math.Copysign(1, d), true + } + } + } + return points, positive +} + +// convexDistance 按浮点乘法误差界识别共线点,不使用固定坐标容差 +// 入参: start 边起点, point 待测顶点, dx 横向单位分量, dy 纵向单位分量 +// 返回: float64 顶点到边的有向距离 +func convexDistance(start, point Point, dx, dy float64) float64 { + x, y := point.X-start.X, point.Y-start.Y + distance := math.FMA(dx, y, -dy*x) + errorBound := (math.Abs(dx*y) + math.Abs(dy*x)) * 0x1p-49 + if finite(errorBound) && math.Abs(distance) <= errorBound { + return 0 + } + return distance +} diff --git a/internal/base/image/metadata.go b/internal/base/image/metadata.go index 6d04707..420c135 100644 --- a/internal/base/image/metadata.go +++ b/internal/base/image/metadata.go @@ -20,8 +20,20 @@ import ( "encoding/binary" "fmt" "hash/crc32" + "image" + "math" + "math/big" ) +// PNGDensityChunkSize 为PNG像素密度块占用的字节数 +const PNGDensityChunkSize = 21 + +// Density 保存横纵像素密度,Unit为0表示比例、1表示每米像素数 +type Density struct { + X, Y float64 + Unit byte +} + // PageInfo 图片方向与像素密度,不改变原始编码 type PageInfo struct { Orientation int @@ -232,3 +244,179 @@ func imageExifMetadata(data []byte, dpi float64, directory uint32) (int, float64 } return orientation, x, y, nil } + +// BMPDensity 提取已校验BMP头中的每米像素数 +// 入参: data 含至少40字节信息头的BMP编码 +// 返回: Density 像素密度, bool 密度是否可保留 +func BMPDensity(data []byte) (Density, bool) { + xdpi, ydpi := int32(binary.LittleEndian.Uint32(data[38:42])), int32(binary.LittleEndian.Uint32(data[42:46])) + if xdpi < 0 || ydpi < 0 { + return Density{}, false + } + return Density{X: float64(xdpi), Y: float64(ydpi), Unit: 1}, true +} + +// TIFFConversionDensity 校验可转换的TIFF字段并提取规范化后的像素密度 +// 色彩配置、预乘透明度、扩展字段及其他目录需保留原始编码 +// 入参: data TIFF编码, layout 已校验的主图布局 +// 返回: Density PNG像素密度, bool 是否可完整转换 +func TIFFConversionDensity(data []byte, layout TIFFLayout) (Density, bool) { + var density Density + var order binary.ByteOrder = binary.LittleEndian + if data[0] == 'M' { + order = binary.BigEndian + } + if layout.Next != 0 || layout.Offset != order.Uint32(data[4:]) || layout.Reduced { + return density, false + } + offset := int(layout.Offset) + count := int(order.Uint16(data[offset:])) + unit := uint32(2) + var resolutions [2][2]uint32 + for i := range count { + entry := data[offset+2+i*12 : offset+14+i*12] + tag, kind, number := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:]) + value := order.Uint32(entry[8:]) + if kind == 3 && number == 1 { + value = uint32(order.Uint16(entry[8:])) + } + switch tag { + case 256, 257, 258, 259, 273, 274, 277, 278, 279, 320, 322, 323, 324, 325: + case 262: + if value == 5 { + return density, false + } + case 254: + if value != 0 { + return density, false + } + case 255: + if value != 1 { + return density, false + } + case 266: + if kind != 3 || number != 1 || value != 1 && value != 2 { + return density, false + } + case 284: + if kind != 3 || number != 1 || value != 1 && value != 2 { + return density, false + } + case 292, 293: + mask := uint32(7) + if tag == 293 { + mask = 2 + } + if kind != 4 || number != 1 || value & ^mask != 0 { + return density, false + } + case 317: + if kind != 3 || number != 1 || value != 1 && value != 2 { + return density, false + } + case 338: + if kind != 3 || number != 1 || value != 2 { + return density, false + } + case 339: + if kind != 3 || number == 0 || number > 4 { + return density, false + } + values := entry[8:] + if number > 2 { + if uint64(value)+uint64(number)*2 > uint64(len(data)) { + return density, false + } + values = data[value:] + } + for j := uint32(0); j < number; j++ { + if order.Uint16(values[j*2:]) != 1 { + return density, false + } + } + case 282, 283: + if kind != 5 || number != 1 || uint64(value)+8 > uint64(len(data)) { + return density, false + } + resolutions[tag-282] = [2]uint32{order.Uint32(data[value:]), order.Uint32(data[value+4:])} + case 296: + if kind != 3 || number != 1 || value < 1 || value > 3 { + return density, false + } + unit = value + default: + return density, false + } + } + if resolutions == [2][2]uint32{} { + return density, true + } + x, y := resolutions[0], resolutions[1] + if x[0] == 0 || x[1] == 0 || y[0] == 0 || y[1] == 0 { + return density, false + } + if unit == 1 { + a, b := uint64(x[0])*uint64(y[1]), uint64(y[0])*uint64(x[1]) + divisor, next := a, b + for next != 0 { + divisor, next = next, divisor%next + } + density.X, density.Y = float64(a/divisor), float64(b/divisor) + } else { + factor := 1 / 0.0254 + if unit == 3 { + factor = 100 + } + density = Density{X: float64(x[0]) / float64(x[1]) * factor, Y: float64(y[0]) / float64(y[1]) * factor, Unit: 1} + } + if math.Round(density.X) < 1 || math.Round(density.Y) < 1 || math.Round(density.X) > math.MaxInt32 || math.Round(density.Y) > math.MaxInt32 { + return density, false + } + if layout.Orientation >= 5 { + density.X, density.Y = density.Y, density.X + } + return density, true +} + +// Scale 按最终像素尺寸保留绝对密度或精确相对比例 +// 入参: before 原始像素尺寸, after 最终像素尺寸 +// 返回: Density 缩放后密度, bool 相对比例是否可完整保留 +func (density Density) Scale(before, after image.Point) (Density, bool) { + hasDensity := density.X != 0 || density.Y != 0 + if hasDensity && before != after { + if density.Unit == 0 { + ratio := new(big.Rat).SetFrac64(int64(density.X)*int64(after.X), int64(density.Y)*int64(after.Y)) + ratio.Mul(ratio, new(big.Rat).SetFrac64(int64(before.Y), int64(before.X))) + if ratio.Num().BitLen() > 31 || ratio.Denom().BitLen() > 31 { + return density, false + } + density.X, density.Y = float64(ratio.Num().Int64()), float64(ratio.Denom().Int64()) + } else { + density.X *= float64(after.X) / float64(before.X) + density.Y *= float64(after.Y) / float64(before.Y) + } + } + return density, true +} + +// WithPNGDensity 在无密度块的PNG头后插入pHYs,保留源编码不变 +// 入参: data 完整PNG编码, density 像素密度 +// 返回: []byte 带密度的编码, bool 密度是否可完整保留 +func WithPNGDensity(data []byte, density Density) ([]byte, bool) { + x, y := math.Round(density.X), math.Round(density.Y) + if x > math.MaxInt32 || y > math.MaxInt32 || density.X > 0 && x < 1 || density.Y > 0 && y < 1 { + return nil, false + } + chunk := make([]byte, 21) + binary.BigEndian.PutUint32(chunk, 9) + copy(chunk[4:], "pHYs") + binary.BigEndian.PutUint32(chunk[8:], uint32(x)) + binary.BigEndian.PutUint32(chunk[12:], uint32(y)) + chunk[16] = density.Unit + binary.BigEndian.PutUint32(chunk[17:], crc32.ChecksumIEEE(chunk[4:17])) + output := make([]byte, 0, len(data)+PNGDensityChunkSize) + output = append(output, data[:33]...) + output = append(output, chunk...) + output = append(output, data[33:]...) + return output, true +} diff --git a/ofdgo_raster_image_scale.go b/internal/base/image/scale.go similarity index 69% rename from ofdgo_raster_image_scale.go rename to internal/base/image/scale.go index 7002be9..79d6f73 100644 --- a/ofdgo_raster_image_scale.go +++ b/internal/base/image/scale.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package ofdgo +package image import ( "image" @@ -21,8 +21,12 @@ import ( "slices" "golang.org/x/image/draw" + "golang.org/x/image/math/f64" ) +// rasterScaleBufferLimit 限制整像素缩放器的浮点中间缓冲,超出时使用逐行采样 +const rasterScaleBufferLimit = 8 << 20 + // rasterAxisSample 保存目标像素及其归一化源采样权重 type rasterAxisSample struct { pixel int @@ -55,7 +59,7 @@ func (s rasterImageSource) RGBA64At(x, y int) color.RGBA64 { // drawRasterAxisImage 按像素中心进行轴向缩小,逐行复用横向采样并保留镜像与小数坐标 // 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作 // 返回: bool 是否使用轴向采样 -func drawRasterAxisImage(dst draw.Image, src image.Image, m RasterMatrix, op draw.Op) bool { +func drawRasterAxisImage(dst draw.Image, src image.Image, m [6]float64, op draw.Op) bool { sx, sy := math.Abs(m[0]), math.Abs(m[3]) if m[1] != 0 || m[2] != 0 || sx == 0 || sy == 0 || sx > 1 || sy > 1 || sx == 1 && sy == 1 { return false @@ -69,7 +73,7 @@ func drawRasterAxisImage(dst draw.Image, src image.Image, m RasterMatrix, op dra if b.Empty() { return true } - r := rasterImageBounds(b, m, dst.Bounds()) + r := TransformBounds(b, m, dst.Bounds()) if r.Empty() { return true } @@ -223,3 +227,59 @@ func rasterImageWriteRow(dst draw.Image, output *image.RGBA64, values []rasterIm } draw.Draw(dst, image.Rect(x, y, x+len(values), y+1), output, image.Point{}, op) } + +// DrawPixels 对轴向缩小使用可分离采样,其余变换保留通用路径 +// 入参: dst 目标图像, src 源图像, m 按a、b、c、d、e、f排列的像素变换, op 合成操作 +func DrawPixels(dst draw.Image, src image.Image, m [6]float64, op draw.Op) { + b := src.Bounds() + if dr, ok := rasterScaleBounds(b, m); ok { + if dr.Intersect(dst.Bounds()).Empty() { + return + } + draw.CatmullRom.Scale(dst, dr, src, b, op, nil) + return + } + if drawRasterAxisImage(dst, src, m, op) { + return + } + draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, src, b, op, nil) +} + +// rasterScaleBounds 检查变换是否可无几何取整地使用缩放器,并限制其中间缓冲 +// 入参: b 源边界, m 像素变换 +// 返回: image.Rectangle 缩放边界, bool 是否适用 +func rasterScaleBounds(b image.Rectangle, m [6]float64) (image.Rectangle, bool) { + if b.Empty() || m[1] != 0 || m[2] != 0 || m[0] <= 0 || m[0] > 1 || m[3] <= 0 || m[3] > 1 || m[0] == 1 && m[3] == 1 { + return image.Rectangle{}, false + } + x0, y0 := float64(m[0]*float64(b.Min.X))+m[4], float64(m[3]*float64(b.Min.Y))+m[5] + x1, y1 := float64(m[0]*float64(b.Max.X))+m[4], float64(m[3]*float64(b.Max.Y))+m[5] + for _, v := range [4]float64{x0, y0, x1, y1} { + if v != math.Floor(v) || v < math.MinInt32 || v > math.MaxInt32 { + return image.Rectangle{}, false + } + } + if x1 <= x0 || y1 <= y0 || b.Dx() > math.MaxInt32 || b.Dy() > math.MaxInt32 || (x1-x0)*float64(b.Dy()) > rasterScaleBufferLimit/32 { + return image.Rectangle{}, false + } + return image.Rect(int(x0), int(y0), int(x1), int(y1)), true +} + +// TransformBounds 计算采样器可能写入的像素范围,并在整数转换前裁至目标边界 +// 入参: source 原始像素边界, m 像素变换, target 目标边界 +// 返回: image.Rectangle 受影响的目标像素区域 +func TransformBounds(source image.Rectangle, m [6]float64, target image.Rectangle) image.Rectangle { + minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) + for _, p := range [4]image.Point{source.Min, {X: source.Max.X, Y: source.Min.Y}, {X: source.Min.X, Y: source.Max.Y}, source.Max} { + x := math.Floor(float64(m[0]*float64(p.X)) + float64(m[2]*float64(p.Y)) + m[4]) + y := math.Floor(float64(m[1]*float64(p.X)) + float64(m[3]*float64(p.Y)) + m[5]) + minX, minY = math.Min(minX, x), math.Min(minY, y) + maxX, maxY = math.Max(maxX, x+1), math.Max(maxY, y+1) + } + minX, minY = math.Max(minX, float64(target.Min.X)), math.Max(minY, float64(target.Min.Y)) + maxX, maxY = math.Min(maxX, float64(target.Max.X)), math.Min(maxY, float64(target.Max.Y)) + if minX >= maxX || minY >= maxY { + return image.Rectangle{} + } + return image.Rect(int(minX), int(minY), int(maxX), int(maxY)) +} diff --git a/internal/codec/pdf/color.go b/internal/codec/pdf/color.go index 76fc718..4059e31 100644 --- a/internal/codec/pdf/color.go +++ b/internal/codec/pdf/color.go @@ -15,11 +15,63 @@ package pdf import ( + "context" "fmt" + "image" "github.com/xiaoqidun/pdfgo" ) +// compositeCancelInterval 保持像素合成的取消检查间隔 +const compositeCancelInterval = 256 + +// CompositePixel 保存非预乘源空间分量及累计透明度 +type CompositePixel struct { + Values [4]float64 + Alpha float64 + Effect float64 + Shape float64 +} + +// transferKey 区分图形状态、网屏覆盖函数及输出设备 +type transferKey struct { + transfer *pdfgo.TransferFunction + halftone *pdfgo.Halftone + model pdfgo.Name +} + +// TransferCache 在页面内复用设备传递函数,零值可用 +// 缓存与所属阅读器同生命周期,不用于并发解析 +type TransferCache struct { + functions map[transferKey]*pdfgo.TransferFunction +} + +// Resolve 在页面内复用输出设备的有效函数,不修改原始图形状态 +// 入参: reader 原始阅读器, style 原始图形状态, model 原生设备颜色空间 +// 返回: *pdfgo.TransferFunction 有效函数, error 网屏传递函数错误 +func (c *TransferCache) Resolve(reader *pdfgo.Reader, style pdfgo.Style, model pdfgo.Name) (*pdfgo.TransferFunction, error) { + if style.Halftone == nil && style.Transfer == nil { + return nil, nil + } + if model == "" { + model = "DeviceRGB" + } + if c.functions == nil { + c.functions = make(map[transferKey]*pdfgo.TransferFunction) + } + key := transferKey{style.Transfer, style.Halftone, model} + transfer, found := c.functions[key] + if !found { + var err error + transfer, err = reader.DeviceTransfer(style.Transfer, style.Halftone, model) + if err != nil { + return nil, err + } + c.functions[key] = transfer + } + return transfer, nil +} + // OutputColor 将合成结果映射到OFD输出,与保留的设备四色对象使用相同显示换算 // 入参: space 合成空间, values 非预乘分量 // 返回: [3]float64 显示颜色, error 校准颜色转换错误 @@ -147,3 +199,139 @@ func CompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorSpac values, err := space.ConvertWith(values[:source.Components()], source, intent, conversion) return values, true, err } + +// CompositeInterpolate 按形状覆盖混合预乘颜色及累计透明度 +// 入参: target 原像素与输出, source 替换像素, shape 覆盖率 +// 返回: error 像素或覆盖率错误 +func CompositeInterpolate(target *CompositePixel, source CompositePixel, shape float64) error { + pixel := pdfgo.ColorantPixel{Values: target.Values[:], Alpha: target.Alpha, Shape: target.Shape, Effect: target.Effect} + if err := pixel.Interpolate(pdfgo.ColorantPixel{Values: source.Values[:], Alpha: source.Alpha, Shape: source.Shape, Effect: source.Effect}, shape); err != nil { + return err + } + target.Alpha, target.Effect = pixel.Alpha, pixel.Effect + return nil +} + +// CompositeKnockout 移除未覆盖区域的初始背景贡献,保留形状与不透明度差异 +// 入参: target 累计组结果, initial 初始背景, source 单个对象合成结果 +// 返回: error 像素错误 +func CompositeKnockout(target *CompositePixel, initial, source CompositePixel) error { + pixel := pdfgo.ColorantPixel{Values: target.Values[:], Alpha: target.Alpha, Shape: target.Shape, Effect: target.Effect} + if err := pixel.Knockout( + pdfgo.ColorantPixel{Values: initial.Values[:], Alpha: initial.Alpha, Shape: initial.Shape, Effect: initial.Effect}, + pdfgo.ColorantPixel{Values: source.Values[:], Alpha: source.Alpha, Shape: source.Shape, Effect: source.Effect}, + ); err != nil { + return err + } + target.Alpha, target.Effect, target.Shape = pixel.Alpha, pixel.Effect, pixel.Shape + return nil +} + +// CompositeGroupRegion 在组覆盖区域合成像素,保留原栅格及形状贡献 +// 入参: ctx 取消上下文, pixels 父组输出, initial 初始背景, result 组结果, stride 行跨度, region 覆盖区域, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式, intent 渲染意图, conversion 设备转换函数 +// 返回: error 缓冲、区域、颜色或混合错误 +func CompositeGroupRegion(ctx context.Context, pixels, initial, result []CompositePixel, stride int, region image.Rectangle, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode, intent pdfgo.Name, conversion pdfgo.ColorConversion) error { + if err := ctx.Err(); err != nil { + return err + } + if len(pixels) != len(initial) || len(pixels) != len(result) || mask != nil && len(mask) != len(pixels) { + return fmt.Errorf("invalid PDF transparency group buffers") + } + if stride <= 0 || region.Min.X < 0 || region.Min.Y < 0 || region.Max.X > stride || !region.Empty() && region.Max.Y > len(pixels)/stride { + return fmt.Errorf("invalid PDF transparency group region") + } + group, destination := pdfgo.ColorantGroup{Space: space}, pdfgo.ColorantGroup{Space: parent} + compositor, err := destination.PrepareGroup(&group, mode, intent, conversion) + if err != nil { + return err + } + if region.Empty() { + return nil + } + components, parentComponents := space.Components(), parent.Components() + var target pdfgo.ColorantPixel + for y := region.Min.Y; y < region.Max.Y; y++ { + if err := ctx.Err(); err != nil { + return err + } + for i, end := y*stride+region.Min.X, y*stride+region.Max.X; i < end; i++ { + if i%compositeCancelInterval == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + alpha := opacity + if mask != nil { + alpha *= mask[i] + } + pixel := &result[i] + target.Values = pixels[i].Values[:parentComponents] + target.Alpha = pixels[i].Alpha + target.Shape = pixels[i].Shape + target.Effect = pixels[i].Effect + if err := compositor.Composite(&target, + pdfgo.ColorantPixel{Values: initial[i].Values[:components], Alpha: initial[i].Alpha, Shape: initial[i].Shape, Effect: initial[i].Effect}, + pdfgo.ColorantPixel{Values: pixel.Values[:components], Alpha: pixel.Alpha, Shape: pixel.Shape, Effect: pixel.Effect}, + alpha, + ); err != nil { + return err + } + pixels[i].Alpha, pixels[i].Effect, pixels[i].Shape = target.Alpha, target.Effect, target.Shape + } + } + return nil +} + +// CompositePaintOver 以兼容叠印计算零色料分量,再应用对象混合模式 +// 入参: backdrop 背景及输出, source 源分量, alpha 源透明度, space 混合空间, mode 混合模式, compatible 是否保留零色料背景, marked 原生过程通道,nil使用普通空间 +// 返回: error 未支持的混合模式 +func CompositePaintOver(backdrop *CompositePixel, source [4]float64, alpha float64, space *pdfgo.ColorSpace, mode pdfgo.Name, compatible bool, marked *[4]bool) error { + if !compatible && marked == nil { + return CompositeOver(backdrop, source, alpha, space, mode) + } + if alpha == 0 { + return nil + } + mask := PaintChannels(source, compatible, marked) + values, combined, err := space.CompositeOverprint(backdrop.Values[:space.Components()], source[:space.Components()], backdrop.Alpha, alpha, mode, mask[:space.Components()]) + if err != nil { + return err + } + copy(backdrop.Values[:space.Components()], values[:space.Components()]) + backdrop.Alpha = combined + backdrop.Effect = alpha + backdrop.Effect*(1-alpha) + return nil +} + +// CompositeOver 按PDF透明模型合成源颜色,四色混合使用分量补色 +// 入参: backdrop 背景及输出, source 源分量, alpha 源透明度, space 混合空间, mode 混合模式 +// 返回: error 未支持的混合模式 +func CompositeOver(backdrop *CompositePixel, source [4]float64, alpha float64, space *pdfgo.ColorSpace, mode pdfgo.Name) error { + if alpha == 0 { + return nil + } + values, combined, err := space.Composite(backdrop.Values[:space.Components()], source[:space.Components()], backdrop.Alpha, alpha, mode) + if err != nil { + return err + } + copy(backdrop.Values[:space.Components()], values[:space.Components()]) + backdrop.Alpha = combined + backdrop.Effect = alpha + backdrop.Effect*(1-alpha) + return nil +} + +// PaintChannels 选择兼容叠印实际使用源颜色的通道 +// 入参: values 源四色分量, compatible 是否保留零分量背景, marked 指定通道,nil表示全部 +// 返回: [4]bool 各通道是否取源颜色 +func PaintChannels(values [4]float64, compatible bool, marked *[4]bool) [4]bool { + channels := [4]bool{true, true, true, true} + if marked != nil { + channels = *marked + } + if compatible { + for i := range channels { + channels[i] = channels[i] && values[i] != 0 + } + } + return channels +} diff --git a/ofdgo_action.go b/ofdgo_action.go index cdae52d..24e3f71 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -17,9 +17,10 @@ package ofdgo import ( "encoding/xml" "fmt" - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" "net/url" "strconv" + + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" ) // Action 动作 @@ -548,3 +549,92 @@ func resolveActionURI(action URI) string { } return base.ResolveReference(target).String() } + +// MarshalXML 按标准属性写入目标,书签和动作采用相同格式 +// 入参: encoder XML编码器, start 起始节点 +// 返回: error 参数或编码错误 +func (dest Dest) MarshalXML(encoder *xml.Encoder, start xml.StartElement) error { + attrs, err := dest.attributes() + if err != nil { + return err + } + start.Attr = attrs + if err := encoder.EncodeToken(start); err != nil { + return err + } + return encoder.EncodeToken(start.End()) +} + +// effective 应用省略属性的默认值,不改变原始声明 +// 返回: Dest 生效的目标参数 +func (dest Dest) effective() Dest { + if dest.OmitLeft { + dest.Left = 0 + } + if dest.OmitTop { + dest.Top = 0 + } + if dest.OmitZoom { + dest.Zoom = 0 + } + return dest +} + +// attributes 校验并生成目标属性,缩放为0表示沿用当前比例 +// 返回: ofdAttrs 标准属性, error 模式或参数错误 +func (dest Dest) attributes() (ofdAttrs, error) { + dest = dest.effective() + attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}} + if dest.PageID == "" { + return nil, fmt.Errorf("missing destination page") + } + add := func(name string, value float64, omit bool) error { + if !finite(value) { + return fmt.Errorf("invalid destination %s", name) + } + if !omit { + attrs.add(name, ofdNumber(value)) + } + return nil + } + switch dest.Type { + case "XYZ": + if dest.Zoom != 0 && (dest.Zoom < 0.1 || dest.Zoom > 64) { + return nil, fmt.Errorf("destination zoom must be zero or between 0.1 and 64") + } + if err := add("Zoom", dest.Zoom, dest.OmitZoom); err != nil { + return nil, err + } + fallthrough + case "FitR": + if err := add("Left", dest.Left, dest.OmitLeft); err != nil { + return nil, err + } + if err := add("Top", dest.Top, dest.OmitTop); err != nil { + return nil, err + } + if dest.Type == "FitR" { + if dest.Right <= dest.Left || dest.Bottom <= dest.Top { + return nil, fmt.Errorf("empty destination rectangle") + } + if err := add("Right", dest.Right, false); err != nil { + return nil, err + } + if err := add("Bottom", dest.Bottom, false); err != nil { + return nil, err + } + } + case "FitH": + if err := add("Top", dest.Top, dest.OmitTop); err != nil { + return nil, err + } + case "FitV": + if err := add("Left", dest.Left, dest.OmitLeft); err != nil { + return nil, err + } + case "Fit": + default: + return nil, fmt.Errorf("invalid destination type %q", dest.Type) + } + return attrs, nil +} diff --git a/ofdgo_action_destination.go b/ofdgo_action_destination.go deleted file mode 100644 index d3cfdab..0000000 --- a/ofdgo_action_destination.go +++ /dev/null @@ -1,109 +0,0 @@ -// 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 ofdgo - -import ( - "encoding/xml" - "fmt" -) - -// MarshalXML 按标准属性写入目标,书签和动作采用相同格式 -// 入参: encoder XML编码器, start 起始节点 -// 返回: error 参数或编码错误 -func (dest Dest) MarshalXML(encoder *xml.Encoder, start xml.StartElement) error { - attrs, err := dest.attributes() - if err != nil { - return err - } - start.Attr = attrs - if err := encoder.EncodeToken(start); err != nil { - return err - } - return encoder.EncodeToken(start.End()) -} - -// effective 应用省略属性的默认值,不改变原始声明 -// 返回: Dest 生效的目标参数 -func (dest Dest) effective() Dest { - if dest.OmitLeft { - dest.Left = 0 - } - if dest.OmitTop { - dest.Top = 0 - } - if dest.OmitZoom { - dest.Zoom = 0 - } - return dest -} - -// attributes 校验并生成目标属性,缩放为0表示沿用当前比例 -// 返回: ofdAttrs 标准属性, error 模式或参数错误 -func (dest Dest) attributes() (ofdAttrs, error) { - dest = dest.effective() - attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}} - if dest.PageID == "" { - return nil, fmt.Errorf("missing destination page") - } - add := func(name string, value float64, omit bool) error { - if !finite(value) { - return fmt.Errorf("invalid destination %s", name) - } - if !omit { - attrs.add(name, ofdNumber(value)) - } - return nil - } - switch dest.Type { - case "XYZ": - if dest.Zoom != 0 && (dest.Zoom < 0.1 || dest.Zoom > 64) { - return nil, fmt.Errorf("destination zoom must be zero or between 0.1 and 64") - } - if err := add("Zoom", dest.Zoom, dest.OmitZoom); err != nil { - return nil, err - } - fallthrough - case "FitR": - if err := add("Left", dest.Left, dest.OmitLeft); err != nil { - return nil, err - } - if err := add("Top", dest.Top, dest.OmitTop); err != nil { - return nil, err - } - if dest.Type == "FitR" { - if dest.Right <= dest.Left || dest.Bottom <= dest.Top { - return nil, fmt.Errorf("empty destination rectangle") - } - if err := add("Right", dest.Right, false); err != nil { - return nil, err - } - if err := add("Bottom", dest.Bottom, false); err != nil { - return nil, err - } - } - case "FitH": - if err := add("Top", dest.Top, dest.OmitTop); err != nil { - return nil, err - } - case "FitV": - if err := add("Left", dest.Left, dest.OmitLeft); err != nil { - return nil, err - } - case "Fit": - default: - return nil, fmt.Errorf("invalid destination type %q", dest.Type) - } - return attrs, nil -} diff --git a/ofdgo_compression_raster.go b/ofdgo_compression_raster.go index d7bd8a9..1215758 100644 --- a/ofdgo_compression_raster.go +++ b/ofdgo_compression_raster.go @@ -19,22 +19,13 @@ import ( "context" "encoding/binary" "errors" - "hash/crc32" "image" "io" - "math" - "math/big" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" "github.com/xiaoqidun/pdfgo" ) -// rasterDensity 保存横纵像素密度,unit为0表示比例、1表示每米像素数 -type rasterDensity struct { - x, y float64 - unit byte -} - // outputImageFormat 识别可由资源压缩生成的图片格式 // 入参: data 图片编码 // 返回: string 标准资源格式,未识别时为空 @@ -96,8 +87,8 @@ func optimizeBMPResource(ctx context.Context, data []byte, options CompressionOp if int64(offset)+stride*int64(height) != int64(len(data)) { return data, nil } - xdpi, ydpi := int32(binary.LittleEndian.Uint32(data[38:42])), int32(binary.LittleEndian.Uint32(data[42:46])) - if xdpi < 0 || ydpi < 0 { + density, ok := baseimage.BMPDensity(data) + if !ok { return data, nil } if depth == 16 || depth == 32 { @@ -122,7 +113,7 @@ func optimizeBMPResource(ctx context.Context, data []byte, options CompressionOp if err != nil { return nil, err } - return optimizeRasterResource(ctx, data, pixels, options, size, rasterDensity{x: float64(xdpi), y: float64(ydpi), unit: 1}) + return optimizeRasterResource(ctx, data, pixels, options, size, density) } // optimizeRasterResource 编码已解码的资源图片,同步降采样后的像素密度,仅采用更小结果 @@ -130,8 +121,8 @@ func optimizeBMPResource(ctx context.Context, data []byte, options CompressionOp // 有密度时保留PNG,未知密度按最终像素选择编码格式 // 入参: ctx 取消上下文, data 原编码, pixels 像素, options 压缩选项, size 像素需求, density 像素密度 // 返回: []byte 图片编码, error 编码或取消错误 -func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image, options CompressionOptions, size image.Point, density rasterDensity) ([]byte, error) { - hasDensity := density.x != 0 || density.y != 0 +func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image, options CompressionOptions, size image.Point, density baseimage.Density) ([]byte, error) { + hasDensity := density.X != 0 || density.Y != 0 before := pixels.Bounds().Size() if options.Mode == CompressionLossy { var err error @@ -140,18 +131,10 @@ func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image return nil, err } } - if after := pixels.Bounds().Size(); hasDensity && before != after { - if density.unit == 0 { - ratio := new(big.Rat).SetFrac64(int64(density.x)*int64(after.X), int64(density.y)*int64(after.Y)) - ratio.Mul(ratio, new(big.Rat).SetFrac64(int64(before.Y), int64(before.X))) - if ratio.Num().BitLen() > 31 || ratio.Denom().BitLen() > 31 { - return data, ctx.Err() - } - density.x, density.y = float64(ratio.Num().Int64()), float64(ratio.Denom().Int64()) - } else { - density.x *= float64(after.X) / float64(before.X) - density.y *= float64(after.Y) / float64(before.Y) - } + var ok bool + density, ok = density.Scale(before, pixels.Bounds().Size()) + if !ok { + return data, ctx.Err() } var candidate []byte var err error @@ -169,23 +152,13 @@ func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image return nil, err } if hasDensity { - x, y := math.Round(density.x), math.Round(density.y) - if x > math.MaxInt32 || y > math.MaxInt32 || density.x > 0 && x < 1 || density.y > 0 && y < 1 || len(candidate) > outputOptimizationBufferLimit-21 { + if len(candidate) > outputOptimizationBufferLimit-baseimage.PNGDensityChunkSize { return data, ctx.Err() } - chunk := make([]byte, 21) - binary.BigEndian.PutUint32(chunk, 9) - copy(chunk[4:], "pHYs") - binary.BigEndian.PutUint32(chunk[8:], uint32(x)) - binary.BigEndian.PutUint32(chunk[12:], uint32(y)) - chunk[16] = density.unit - binary.BigEndian.PutUint32(chunk[17:], crc32.ChecksumIEEE(chunk[4:17])) - var output bytes.Buffer - writer := imageMetadataWriter{writer: &output, skip: 33, data: chunk} - if _, err := writer.Write(candidate); err != nil { - return nil, err + candidate, ok = baseimage.WithPNGDensity(candidate, density) + if !ok { + return data, ctx.Err() } - candidate = output.Bytes() } if err := ctx.Err(); err != nil { return nil, err @@ -195,3 +168,30 @@ func optimizeRasterResource(ctx context.Context, data []byte, pixels image.Image } return data, nil } + +// optimizeTIFFResource 压缩单目录TIFF,保留方向、透明度、采样精度及像素密度 +// 主图或单个瓦片的预计缓冲超限时保留原编码 +// 入参: ctx 取消上下文, data TIFF编码, options 压缩选项, size 显示像素需求 +// 返回: []byte 图片编码,不可完整转换时保留原文, error 编码或取消错误 +func optimizeTIFFResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) { + layout, err := baseimage.ReadTIFFLayoutContext(ctx, bytes.NewReader(data)) + if err != nil { + return data, ctx.Err() + } + pixelSize := int64(layout.DecodedPixelBytes()) + if int64(layout.Width) > outputOptimizationBufferLimit/pixelSize/int64(layout.Height) { + return data, ctx.Err() + } + if layout.TileWidth != 0 && int64(layout.TileWidth) > outputOptimizationBufferLimit/pixelSize/int64(layout.TileHeight) { + return data, ctx.Err() + } + density, ok := baseimage.TIFFConversionDensity(data, layout) + if !ok { + return data, ctx.Err() + } + pixels, err := baseimage.DecodeTIFF(ctx, data, layout) + if err != nil { + return data, ctx.Err() + } + return optimizeRasterResource(ctx, data, pixels, options, size, density) +} diff --git a/ofdgo_compression_tiff.go b/ofdgo_compression_tiff.go deleted file mode 100644 index 8ad509e..0000000 --- a/ofdgo_compression_tiff.go +++ /dev/null @@ -1,174 +0,0 @@ -// 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 ofdgo - -import ( - "bytes" - "context" - "encoding/binary" - "image" - "math" - - baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" -) - -// optimizeTIFFResource 压缩单目录TIFF,保留方向、透明度、采样精度及像素密度 -// 主图或单个瓦片的预计缓冲超限时保留原编码 -// 入参: ctx 取消上下文, data TIFF编码, options 压缩选项, size 显示像素需求 -// 返回: []byte 图片编码,不可完整转换时保留原文, error 编码或取消错误 -func optimizeTIFFResource(ctx context.Context, data []byte, options CompressionOptions, size image.Point) ([]byte, error) { - layout, err := baseimage.ReadTIFFLayoutContext(ctx, bytes.NewReader(data)) - if err != nil { - return data, ctx.Err() - } - pixelSize := int64(layout.DecodedPixelBytes()) - if int64(layout.Width) > outputOptimizationBufferLimit/pixelSize/int64(layout.Height) { - return data, ctx.Err() - } - if layout.TileWidth != 0 && int64(layout.TileWidth) > outputOptimizationBufferLimit/pixelSize/int64(layout.TileHeight) { - return data, ctx.Err() - } - density, ok := tiffCompressionDensity(data, layout) - if !ok { - return data, ctx.Err() - } - pixels, err := baseimage.DecodeTIFF(ctx, data, layout) - if err != nil { - return data, ctx.Err() - } - return optimizeRasterResource(ctx, data, pixels, options, size, density) -} - -// tiffCompressionDensity 校验可转换的TIFF字段并提取规范化后的像素密度 -// 色彩配置、预乘透明度、扩展字段及其他目录需保留原始编码 -// 入参: data TIFF编码, layout 已校验的主图布局 -// 返回: rasterDensity PNG像素密度, bool 是否可完整转换 -func tiffCompressionDensity(data []byte, layout baseimage.TIFFLayout) (rasterDensity, bool) { - var density rasterDensity - var order binary.ByteOrder = binary.LittleEndian - if data[0] == 'M' { - order = binary.BigEndian - } - if layout.Next != 0 || layout.Offset != order.Uint32(data[4:]) || layout.Reduced { - return density, false - } - offset := int(layout.Offset) - count := int(order.Uint16(data[offset:])) - unit := uint32(2) - var resolutions [2][2]uint32 - for i := range count { - entry := data[offset+2+i*12 : offset+14+i*12] - tag, kind, number := order.Uint16(entry), order.Uint16(entry[2:]), order.Uint32(entry[4:]) - value := order.Uint32(entry[8:]) - if kind == 3 && number == 1 { - value = uint32(order.Uint16(entry[8:])) - } - switch tag { - case 256, 257, 258, 259, 273, 274, 277, 278, 279, 320, 322, 323, 324, 325: - case 262: - if value == 5 { - return density, false - } - case 254: - if value != 0 { - return density, false - } - case 255: - if value != 1 { - return density, false - } - case 266: - if kind != 3 || number != 1 || value != 1 && value != 2 { - return density, false - } - case 284: - if kind != 3 || number != 1 || value != 1 && value != 2 { - return density, false - } - case 292, 293: - mask := uint32(7) - if tag == 293 { - mask = 2 - } - if kind != 4 || number != 1 || value & ^mask != 0 { - return density, false - } - case 317: - if kind != 3 || number != 1 || value != 1 && value != 2 { - return density, false - } - case 338: - if kind != 3 || number != 1 || value != 2 { - return density, false - } - case 339: - if kind != 3 || number == 0 || number > 4 { - return density, false - } - values := entry[8:] - if number > 2 { - if uint64(value)+uint64(number)*2 > uint64(len(data)) { - return density, false - } - values = data[value:] - } - for j := uint32(0); j < number; j++ { - if order.Uint16(values[j*2:]) != 1 { - return density, false - } - } - case 282, 283: - if kind != 5 || number != 1 || uint64(value)+8 > uint64(len(data)) { - return density, false - } - resolutions[tag-282] = [2]uint32{order.Uint32(data[value:]), order.Uint32(data[value+4:])} - case 296: - if kind != 3 || number != 1 || value < 1 || value > 3 { - return density, false - } - unit = value - default: - return density, false - } - } - if resolutions == [2][2]uint32{} { - return density, true - } - x, y := resolutions[0], resolutions[1] - if x[0] == 0 || x[1] == 0 || y[0] == 0 || y[1] == 0 { - return density, false - } - if unit == 1 { - a, b := uint64(x[0])*uint64(y[1]), uint64(y[0])*uint64(x[1]) - divisor, next := a, b - for next != 0 { - divisor, next = next, divisor%next - } - density.x, density.y = float64(a/divisor), float64(b/divisor) - } else { - factor := 1 / 0.0254 - if unit == 3 { - factor = 100 - } - density = rasterDensity{x: float64(x[0]) / float64(x[1]) * factor, y: float64(y[0]) / float64(y[1]) * factor, unit: 1} - } - if math.Round(density.x) < 1 || math.Round(density.y) < 1 || math.Round(density.x) > math.MaxInt32 || math.Round(density.y) > math.MaxInt32 { - return density, false - } - if layout.Orientation >= 5 { - density.x, density.y = density.y, density.x - } - return density, true -} diff --git a/ofdgo_editor_action.go b/ofdgo_editor_action.go index 9757c75..0ef4841 100644 --- a/ofdgo_editor_action.go +++ b/ofdgo_editor_action.go @@ -15,6 +15,9 @@ package ofdgo import ( + "bytes" + "context" + "encoding/xml" "fmt" "reflect" "slices" @@ -258,3 +261,162 @@ func editorActionsXML(actions []Action) ([]byte, error) { node := root.child("Actions") return editorXMLStandalone(data[node.start:node.end], node) } + +// PageActions 获取当前文档及版本中页面自身的完整动作独立副本,不合并模板或执行动作 +// 入参: page 从0开始的页面索引 +// 返回: []Action 原顺序动作,未设置时为nil, error 页面或解析错误 +func (e *Editor) PageActions(page int) ([]Action, error) { + content, err := e.page(page) + if err != nil { + return nil, err + } + return cloneEditorData(content.Actions), nil +} + +// ObjectActions 获取普通页面图元自身的完整动作独立副本,不展开共享复合资源或执行动作 +// 入参: page 从0开始的页面索引, id 与SetObjectActions相同的对象标识 +// 返回: []Action 原顺序动作,未设置时为nil, error 页面、对象或类型错误 +func (e *Editor) ObjectActions(page int, id string) ([]Action, error) { + layer, index, err := e.findObject(page, id) + if err != nil { + return nil, err + } + actions, err := resourceObjectActions(layer.Objects[index]) + if err != nil { + return nil, err + } + return cloneEditorData(actions), nil +} + +// OutlineActions 获取当前目录节点自身的完整动作独立副本,不含子目录或执行动作 +// 入参: path 从0开始的非空层级索引 +// 返回: []Action 原顺序动作,未设置时为nil, error 路径或解析错误 +func (e *Editor) OutlineActions(path []int) ([]Action, error) { + if len(path) == 0 { + return nil, fmt.Errorf("outline path must not be empty") + } + data, err := e.outlineXML() + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + node, err := editorOutlineNode(root, path) + if err != nil { + return nil, err + } + node = node.child("Actions") + if node == nil { + return nil, nil + } + data, err = editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + decoder := xml.NewDecoder(bytes.NewReader(data)) + start, err := decoder.Token() + if err != nil { + return nil, err + } + var actions []Action + if err := decodeOFDActions(decoder, start.(xml.StartElement), &actions); err != nil { + return nil, err + } + return actions, nil +} + +// ValidateActionTargets 校验动作结构及当前文档、版本内的页面、书签、附件和音视频引用 +// 可用于提交前检查,不改变现有Set方法允许悬空目标的语义;不检查事件归属或访问外部URI +// 已注册但未绑定的媒体也可引用;仅检查资源声明及类型,不解码媒体或执行动作 +// 入参: ctx 取消上下文, actions 待校验动作列表 +// 返回: error 首个错误及从0开始的动作索引,取消错误支持errors.Is +func (e *Editor) ValidateActionTargets(ctx context.Context, actions []Action) error { + if err := ctx.Err(); err != nil { + return err + } + var resources []ResourceInfo + resourcesLoaded := false + for index, action := range actions { + if err := ctx.Err(); err != nil { + return err + } + if err := validateActions([]Action{action}); err != nil { + return fmt.Errorf("action %d: %w", index, err) + } + if action.Goto != nil { + dest := action.Goto.Dest + if bookmark := action.Goto.Bookmark; bookmark != nil { + if e.source != nil { + for _, entry := range e.source.document.Bookmarks.Bookmark { + if err := ctx.Err(); err != nil { + return err + } + if entry.Name == bookmark.Name { + value := entry.Dest + dest = &value + } + } + } + if dest == nil { + return fmt.Errorf("action %d: bookmark %q not found", index, bookmark.Name) + } + } + if _, err := dest.attributes(); err != nil { + return fmt.Errorf("action %d: %w", index, err) + } + found := false + for _, page := range e.pages { + if err := ctx.Err(); err != nil { + return err + } + if sameResourceID(page.ID, dest.PageID) { + found = true + break + } + } + if !found { + return fmt.Errorf("action %d: destination page %q not found", index, dest.PageID) + } + } + var kind, id string + switch { + case action.GotoA != nil: + kind, id = "Attachment", action.GotoA.AttachID + case action.Sound != nil: + kind, id = "Audio", action.Sound.ResourceID + case action.Movie != nil: + kind, id = "Video", action.Movie.ResourceID + default: + continue + } + registered := false + if kind != "Attachment" { + for _, resource := range e.resources { + if err := ctx.Err(); err != nil { + return err + } + if media := resource.image; media != nil && media.Type == kind && sameResourceID(media.ID, id) { + registered = true + break + } + } + } + if registered { + continue + } + if !resourcesLoaded { + var err error + resources, err = e.Resources(ctx) + if err != nil { + return fmt.Errorf("action %d: %w", index, err) + } + resourcesLoaded = true + } + if _, err := findResource(resources, kind, id); err != nil { + return fmt.Errorf("action %d: %w", index, err) + } + } + return ctx.Err() +} diff --git a/ofdgo_editor_action_query.go b/ofdgo_editor_action_query.go deleted file mode 100644 index 8b2aef7..0000000 --- a/ofdgo_editor_action_query.go +++ /dev/null @@ -1,181 +0,0 @@ -// 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 ofdgo - -import ( - "bytes" - "context" - "encoding/xml" - "fmt" -) - -// PageActions 获取当前文档及版本中页面自身的完整动作独立副本,不合并模板或执行动作 -// 入参: page 从0开始的页面索引 -// 返回: []Action 原顺序动作,未设置时为nil, error 页面或解析错误 -func (e *Editor) PageActions(page int) ([]Action, error) { - content, err := e.page(page) - if err != nil { - return nil, err - } - return cloneEditorData(content.Actions), nil -} - -// ObjectActions 获取普通页面图元自身的完整动作独立副本,不展开共享复合资源或执行动作 -// 入参: page 从0开始的页面索引, id 与SetObjectActions相同的对象标识 -// 返回: []Action 原顺序动作,未设置时为nil, error 页面、对象或类型错误 -func (e *Editor) ObjectActions(page int, id string) ([]Action, error) { - layer, index, err := e.findObject(page, id) - if err != nil { - return nil, err - } - actions, err := resourceObjectActions(layer.Objects[index]) - if err != nil { - return nil, err - } - return cloneEditorData(actions), nil -} - -// OutlineActions 获取当前目录节点自身的完整动作独立副本,不含子目录或执行动作 -// 入参: path 从0开始的非空层级索引 -// 返回: []Action 原顺序动作,未设置时为nil, error 路径或解析错误 -func (e *Editor) OutlineActions(path []int) ([]Action, error) { - if len(path) == 0 { - return nil, fmt.Errorf("outline path must not be empty") - } - data, err := e.outlineXML() - if err != nil { - return nil, err - } - root, err := parseEditorXML(data) - if err != nil { - return nil, err - } - node, err := editorOutlineNode(root, path) - if err != nil { - return nil, err - } - node = node.child("Actions") - if node == nil { - return nil, nil - } - data, err = editorXMLStandalone(data[node.start:node.end], node) - if err != nil { - return nil, err - } - decoder := xml.NewDecoder(bytes.NewReader(data)) - start, err := decoder.Token() - if err != nil { - return nil, err - } - var actions []Action - if err := decodeOFDActions(decoder, start.(xml.StartElement), &actions); err != nil { - return nil, err - } - return actions, nil -} - -// ValidateActionTargets 校验动作结构及当前文档、版本内的页面、书签、附件和音视频引用 -// 可用于提交前检查,不改变现有Set方法允许悬空目标的语义;不检查事件归属或访问外部URI -// 已注册但未绑定的媒体也可引用;仅检查资源声明及类型,不解码媒体或执行动作 -// 入参: ctx 取消上下文, actions 待校验动作列表 -// 返回: error 首个错误及从0开始的动作索引,取消错误支持errors.Is -func (e *Editor) ValidateActionTargets(ctx context.Context, actions []Action) error { - if err := ctx.Err(); err != nil { - return err - } - var resources []ResourceInfo - resourcesLoaded := false - for index, action := range actions { - if err := ctx.Err(); err != nil { - return err - } - if err := validateActions([]Action{action}); err != nil { - return fmt.Errorf("action %d: %w", index, err) - } - if action.Goto != nil { - dest := action.Goto.Dest - if bookmark := action.Goto.Bookmark; bookmark != nil { - if e.source != nil { - for _, entry := range e.source.document.Bookmarks.Bookmark { - if err := ctx.Err(); err != nil { - return err - } - if entry.Name == bookmark.Name { - value := entry.Dest - dest = &value - } - } - } - if dest == nil { - return fmt.Errorf("action %d: bookmark %q not found", index, bookmark.Name) - } - } - if _, err := dest.attributes(); err != nil { - return fmt.Errorf("action %d: %w", index, err) - } - found := false - for _, page := range e.pages { - if err := ctx.Err(); err != nil { - return err - } - if sameResourceID(page.ID, dest.PageID) { - found = true - break - } - } - if !found { - return fmt.Errorf("action %d: destination page %q not found", index, dest.PageID) - } - } - var kind, id string - switch { - case action.GotoA != nil: - kind, id = "Attachment", action.GotoA.AttachID - case action.Sound != nil: - kind, id = "Audio", action.Sound.ResourceID - case action.Movie != nil: - kind, id = "Video", action.Movie.ResourceID - default: - continue - } - registered := false - if kind != "Attachment" { - for _, resource := range e.resources { - if err := ctx.Err(); err != nil { - return err - } - if media := resource.image; media != nil && media.Type == kind && sameResourceID(media.ID, id) { - registered = true - break - } - } - } - if registered { - continue - } - if !resourcesLoaded { - var err error - resources, err = e.Resources(ctx) - if err != nil { - return fmt.Errorf("action %d: %w", index, err) - } - resourcesLoaded = true - } - if _, err := findResource(resources, kind, id); err != nil { - return fmt.Errorf("action %d: %w", index, err) - } - } - return ctx.Err() -} diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 822d271..359daef 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -18,10 +18,10 @@ import ( "bytes" "context" "fmt" - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" "maps" "slices" + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" ) @@ -366,3 +366,79 @@ func (e *Editor) collectCompositeIDs(node *editorXML, ids map[string]string) err } return nil } + +// Style 获取内部成员的独立样式快照,文字和路径CTM只表达页面描边倍率 +// 可传入CopyStyle或CopyCompositeStyle,不作为几何复制快照 +// 返回: GraphicObject 有效样式来源 +func (m CompositeMember) Style() GraphicObject { + object := cloneEditorData(m.Object) + if object.Type == "PathObject" { + object.PathObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() + } else if object.Type == "TextObject" { + object.TextObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() + } + return object +} + +// CopyCompositeStyle 将同类型样式复制到内部选区,保留内容、位置、裁剪及其他实例 +// 描边复制页面实际线宽和虚线,文字沿用目标段落和基线,全部成功后提交一次撤销 +// 入参: page 页面索引, path 父路径, indexes 成员序号, source Object或CompositeMember.Style取得的样式快照 +// 返回: error 错误信息 +func (e *Editor) CopyCompositeStyle(page int, path ObjectPath, indexes []int, source GraphicObject) error { + if err := e.validateCopyStyle(source); err != nil { + return err + } + return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + if len(nodes) == 0 { + return nil + } + var err error + source, err = e.explicitDefaultPaint(source) + if err != nil { + return err + } + for i, node := range nodes { + member := members[i] + if node.object.Type != source.Type || !member.Capabilities.Transform || source.Type != "ImageObject" && !member.Capabilities.Paint { + return fmt.Errorf("style requires editable members of the same type") + } + object := cloneEditorData(member.Object) + switch source.Type { + case "TextObject": + if err := e.updateCompositeText(node, member, nil, TextStyle{Font: source.TextObject.Font, Size: source.TextObject.Size}, nil, nil); err != nil { + return err + } + var err error + object, err = e.compositeMemberStyle(node) + if err != nil { + return err + } + style := copyEditorPathStyle(object.TextObject.pathStyle(), source.TextObject.pathStyle(), editorStrokeScale(source.TextObject.CTM)/member.StrokeScale) + if err := validateEditorStroke(style); err != nil { + return err + } + object.TextObject = object.TextObject.withPathStyle(style) + object.TextObject.DrawParam, err = e.neutralDrawParam() + if err != nil { + return err + } + case "PathObject": + object.PathObject = copyEditorPathStyle(object.PathObject, source.PathObject, editorStrokeScale(source.PathObject.CTM)/member.StrokeScale) + if err := validateEditorStroke(object.PathObject); err != nil { + return err + } + var err error + object.PathObject.DrawParam, err = e.neutralDrawParam() + if err != nil { + return err + } + case "ImageObject": + object.ImageObject.Alpha = cloneEditorData(source.ImageObject.Alpha) + } + if err := node.update(object); err != nil { + return err + } + } + return nil + }) +} diff --git a/ofdgo_editor_composite_style.go b/ofdgo_editor_composite_style.go deleted file mode 100644 index c399202..0000000 --- a/ofdgo_editor_composite_style.go +++ /dev/null @@ -1,93 +0,0 @@ -// 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 ofdgo - -import "fmt" - -// Style 获取内部成员的独立样式快照,文字和路径CTM只表达页面描边倍率 -// 可传入CopyStyle或CopyCompositeStyle,不作为几何复制快照 -// 返回: GraphicObject 有效样式来源 -func (m CompositeMember) Style() GraphicObject { - object := cloneEditorData(m.Object) - if object.Type == "PathObject" { - object.PathObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() - } else if object.Type == "TextObject" { - object.TextObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() - } - return object -} - -// CopyCompositeStyle 将同类型样式复制到内部选区,保留内容、位置、裁剪及其他实例 -// 描边复制页面实际线宽和虚线,文字沿用目标段落和基线,全部成功后提交一次撤销 -// 入参: page 页面索引, path 父路径, indexes 成员序号, source Object或CompositeMember.Style取得的样式快照 -// 返回: error 错误信息 -func (e *Editor) CopyCompositeStyle(page int, path ObjectPath, indexes []int, source GraphicObject) error { - if err := e.validateCopyStyle(source); err != nil { - return err - } - return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { - if len(nodes) == 0 { - return nil - } - var err error - source, err = e.explicitDefaultPaint(source) - if err != nil { - return err - } - for i, node := range nodes { - member := members[i] - if node.object.Type != source.Type || !member.Capabilities.Transform || source.Type != "ImageObject" && !member.Capabilities.Paint { - return fmt.Errorf("style requires editable members of the same type") - } - object := cloneEditorData(member.Object) - switch source.Type { - case "TextObject": - if err := e.updateCompositeText(node, member, nil, TextStyle{Font: source.TextObject.Font, Size: source.TextObject.Size}, nil, nil); err != nil { - return err - } - var err error - object, err = e.compositeMemberStyle(node) - if err != nil { - return err - } - style := copyEditorPathStyle(object.TextObject.pathStyle(), source.TextObject.pathStyle(), editorStrokeScale(source.TextObject.CTM)/member.StrokeScale) - if err := validateEditorStroke(style); err != nil { - return err - } - object.TextObject = object.TextObject.withPathStyle(style) - object.TextObject.DrawParam, err = e.neutralDrawParam() - if err != nil { - return err - } - case "PathObject": - object.PathObject = copyEditorPathStyle(object.PathObject, source.PathObject, editorStrokeScale(source.PathObject.CTM)/member.StrokeScale) - if err := validateEditorStroke(object.PathObject); err != nil { - return err - } - var err error - object.PathObject.DrawParam, err = e.neutralDrawParam() - if err != nil { - return err - } - case "ImageObject": - object.ImageObject.Alpha = cloneEditorData(source.ImageObject.Alpha) - } - if err := node.update(object); err != nil { - return err - } - } - return nil - }) -} diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go index 0e1ee02..4d32cbc 100644 --- a/ofdgo_editor_font_binding.go +++ b/ofdgo_editor_font_binding.go @@ -275,7 +275,7 @@ func (e *Editor) prepareFontBinding(ctx context.Context, scan *editorFontDocumen } file = FontFile{Data: data} if bytes.HasPrefix(data, []byte("ttcf")) { - index = fontCollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic) + index = basefont.CollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic) } } else if len(file.Data) == 0 { file, err = e.resolveBindingFont(ctx, scan, declaration) @@ -415,7 +415,7 @@ func originalBindingFont(ctx context.Context, scan *editorFontDocument, declarat } index := 0 if bytes.HasPrefix(data, []byte("ttcf")) { - index = fontCollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic) + index = basefont.CollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic) } data, err = (FontFile{Data: data}).Face(index) if cancelErr := ctx.Err(); cancelErr != nil { diff --git a/ofdgo_font.go b/ofdgo_font.go index c4dc71f..5de6c6a 100644 --- a/ofdgo_font.go +++ b/ofdgo_font.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "path" "unicode/utf8" basefont "github.com/xiaoqidun/ofdgo/internal/base/font" @@ -67,3 +68,38 @@ func (f FontFile) MissingGlyphs(index int, text string) (string, error) { } return basefont.MissingGlyphs(data, text) } + +// FontNormalizeName 规范化字体名称 +// 入参: name 字体名称 +// 返回: string 规范化后的字体名称 +func FontNormalizeName(name string) string { + return basefont.NormalizeName(name) +} + +// FontCandidateNames 获取字体候选名称 +// 入参: names 字体名称列表 +// 返回: []string 字体候选名称列表 +func FontCandidateNames(names ...string) []string { + return basefont.CandidateNames(names...) +} + +// FontFilePatterns 获取字体文件匹配模式 +// 入参: names 字体名称列表 +// 返回: []string 字体文件匹配模式 +func FontFilePatterns(names ...string) []string { + return basefont.FilePatterns(names...) +} + +// FontSystemNames 获取系统字体名称 +// 入参: names 字体名称列表 +// 返回: []string 系统字体名称 +func FontSystemNames(names ...string) []string { + return basefont.SystemNames(names...) +} + +// FontFileMatches 匹配指定样式的字体文件名称 +// 入参: files 可用字体文件名, bold 是否粗体, italic 是否斜体, names 字体名称,留空匹配默认回退字体 +// 返回: []string 按优先级排列的匹配文件名 +func FontFileMatches(files []string, bold, italic bool, names ...string) []string { + return basefont.FileMatchNames(basefont.FileMatches(basefont.FileCandidates(files, path.Base), names, bold, italic)) +} diff --git a/ofdgo_font_fs.go b/ofdgo_font_fs.go index 02b955f..4347786 100644 --- a/ofdgo_font_fs.go +++ b/ofdgo_font_fs.go @@ -48,44 +48,7 @@ type FontFS struct { files map[string][]byte loaders map[string]func() ([]byte, error) names []string - candidates []fontFileCandidate -} - -// fontFileCandidate 字体文件候选 -type fontFileCandidate struct { - name string - face int - base string - lowerBase string - filename string - normalized string - names []string - fold string -} - -// fontPatternMatcher 字体文件匹配器 -type fontPatternMatcher struct { - pattern string - lowerPattern string - stem string - aliases []string - priority int -} - -// fontFileMatch 字体文件匹配结果 -type fontFileMatch struct { - name string - face int - priority int - rank int - styleRank int - sortName string -} - -// fontFileKey 字体文件与集合索引的去重键 -type fontFileKey struct { - name string - face int + candidates []basefont.FileCandidate } // fontMemFile 内存字体文件 @@ -130,9 +93,9 @@ func NewFontFS(fonts []FontFile) *FontFS { fsys.files[name] = append([]byte(nil), font.Data...) } sort.Strings(fsys.names) - fsys.candidates = fontFileCandidates(fsys.names, path.Base) + fsys.candidates = basefont.FileCandidates(fsys.names, path.Base) for i, file := range fsys.candidates { - fsys.candidates = appendFontFileNames(fsys.candidates, i, basefont.FileNames(bytes.NewReader(fsys.files[file.name]))) + fsys.candidates = basefont.AppendFileNames(fsys.candidates, i, basefont.FileNames(bytes.NewReader(fsys.files[file.Name]))) } return fsys } @@ -159,7 +122,7 @@ func NewLazyFontFS(names []string, load func(string) ([]byte, error)) *FontFS { }) } sort.Strings(fsys.names) - fsys.candidates = fontFileCandidates(fsys.names, path.Base) + fsys.candidates = basefont.FileCandidates(fsys.names, path.Base) return fsys } @@ -238,8 +201,8 @@ func (fsys *FontFS) Match(names ...string) (string, bool) { // 返回: string 匹配字体文件, bool 是否为名称匹配 func (fsys *FontFS) MatchStyle(bold, italic bool, names ...string) (string, bool) { if len(names) > 0 { - if matches := fsys.matchStyle(names, bold, italic); len(matches) > 0 { - return matches[0].name, true + if matches := basefont.FileMatches(fsys.candidates, names, bold, italic); len(matches) > 0 { + return matches[0].Name, true } } if matches := fsys.fallbackFonts(bold, italic); len(matches) > 0 { @@ -248,13 +211,6 @@ func (fsys *FontFS) MatchStyle(bold, italic bool, names ...string) (string, bool return "", false } -// FontFileMatches 匹配指定样式的字体文件名称 -// 入参: files 可用字体文件名, bold 是否粗体, italic 是否斜体, names 字体名称,留空匹配默认回退字体 -// 返回: []string 按优先级排列的匹配文件名 -func FontFileMatches(files []string, bold, italic bool, names ...string) []string { - return fontFileMatchNames(fontFileMatches(fontFileCandidates(files, path.Base), names, bold, italic)) -} - // MatchFontFiles 按文档字体名称和样式选择首选文件,未声明的使用编号按默认规则回退 // 入参: files 可用字体文件名, ids 已发现的使用编号,省略时仅匹配声明字体 // 返回: []string 所需字体文件名, error 错误信息 @@ -268,19 +224,19 @@ func (r *Reader) MatchFontFiles(files []string, ids ...string) ([]string, error) fonts = append(fonts, Font{ID: id}) } } - candidates := fontFileCandidates(files, path.Base) + candidates := basefont.FileCandidates(files, path.Base) names := make([]string, 0, len(fonts)) seen := make(map[string]bool) for _, font := range fonts { if font.FontFile != "" { continue } - matches := fontFileMatches(candidates, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic) + matches := basefont.FileMatches(candidates, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic) if len(matches) == 0 { - matches = fontFileMatches(candidates, nil, font.Bold, font.Italic) + matches = basefont.FileMatches(candidates, nil, font.Bold, font.Italic) } if len(matches) > 0 { - name := matches[0].name + name := matches[0].Name if !seen[name] { names = append(names, name) seen[name] = true @@ -405,68 +361,12 @@ func (i fontFileInfo) Sys() any { return nil } -// matchStyle 匹配指定样式的字体名称 -// 入参: names 字体名称列表, bold 是否粗体, italic 是否斜体 -// 返回: []fontFileMatch 字体匹配列表 -func (fsys *FontFS) matchStyle(names []string, bold, italic bool) []fontFileMatch { - return fontFileMatches(fsys.candidates, names, bold, italic) -} - -// fontFileMatches 匹配字体文件候选 -// 入参: candidates 字体候选, names 字体名称, bold 是否粗体, italic 是否斜体 -// 返回: []fontFileMatch 按优先级排列的字体匹配 -func fontFileMatches(candidates []fontFileCandidate, names []string, bold, italic bool) []fontFileMatch { - matches := make([]fontFileMatch, 0, len(candidates)) - seen := make(map[fontFileKey]int, len(candidates)) - if len(names) == 0 { - for _, name := range fontDefaultSystemNames() { - matches = append(matches, fontFileMatches(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 := fontFileKey{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}, fontQualifiedNames(name)...) { - matcher := fontPatternMatcher{stem: basefont.NormalizeName(alias), priority: (i-len(names))*2 + min(j, 1)} - if matcher.stem == "" { - continue - } - for _, file := range candidates { - if rank := matcher.rankCandidate(file); rank == fontMatchExact { - appendFontFileMatch(&matches, seen, matcher, file, rank, bold, italic) - } - } - } - } - for _, matcher := range newFontPatternMatchers(fontFilePatterns(names...)) { - for _, file := range candidates { - rank := matcher.rankCandidate(file) - appendFontFileMatch(&matches, seen, matcher, file, rank, bold, italic) - } - } - sortFontFileMatches(matches) - return matches -} - // fallbackFonts 获取回退字体文件 // 入参: bold 是否粗体, italic 是否斜体 // 返回: []string 字体文件列表 func (fsys *FontFS) fallbackFonts(bold, italic bool) []string { - if matches := fsys.matchStyle(nil, bold, italic); len(matches) > 0 { - return fontFileMatchNames(matches) + if matches := basefont.FileMatches(fsys.candidates, nil, bold, italic); len(matches) > 0 { + return basefont.FileMatchNames(matches) } if len(fsys.names) > 0 { return []string{fsys.names[0]} @@ -502,284 +402,3 @@ func cleanFontName(name string) string { } return strings.ToLower(name) } - -// fontFileCandidates 筛选字体文件候选 -// 入参: names 文件名列表, base 文件名提取函数 -// 返回: []fontFileCandidate 字体文件候选列表 -func fontFileCandidates(names []string, base func(string) string) []fontFileCandidate { - files := make([]fontFileCandidate, 0, len(names)) - for _, name := range names { - if basefont.IsFontFileName(name) { - baseName := base(name) - normalized := basefont.NormalizeName(strings.TrimSuffix(baseName, path.Ext(baseName))) - files = append(files, fontFileCandidate{ - name: name, - base: baseName, - lowerBase: strings.ToLower(baseName), - filename: normalized, - normalized: normalized, - fold: strings.ToLower(name), - }) - } - } - return files -} - -// newFontPatternMatcher 创建字体文件匹配器 -// 入参: pattern 匹配模式 -// 返回: fontPatternMatcher 字体文件匹配器 -func newFontPatternMatcher(pattern string) fontPatternMatcher { - matcher := fontPatternMatcher{ - pattern: pattern, - lowerPattern: strings.ToLower(pattern), - stem: fontPatternStem(pattern), - } - if matcher.stem != "" { - for _, alias := range fontExactCandidateNames(matcher.stem) { - alias = basefont.NormalizeName(alias) - if alias != "" { - matcher.aliases = append(matcher.aliases, alias) - } - } - } - return matcher -} - -// newFontPatternMatchers 创建字体文件匹配器列表 -// 入参: patterns 匹配模式列表 -// 返回: []fontPatternMatcher 字体文件匹配器列表 -func newFontPatternMatchers(patterns []string) []fontPatternMatcher { - matchers := make([]fontPatternMatcher, 0, len(patterns)) - for priority, pattern := range patterns { - matcher := newFontPatternMatcher(pattern) - matcher.priority = priority - matchers = append(matchers, matcher) - } - return matchers -} - -// rank 获取字体文件匹配等级 -// 入参: name 字体文件名 -// 返回: int 匹配等级 -func (m fontPatternMatcher) rank(name string) int { - return m.rankCandidate(fontFileCandidate{ - base: name, - lowerBase: strings.ToLower(name), - normalized: basefont.NormalizeName(name), - }) -} - -// rankCandidate 获取字体文件候选匹配等级 -// 入参: file 字体文件候选 -// 返回: int 匹配等级 -func (m fontPatternMatcher) rankCandidate(file fontFileCandidate) int { - exactPath, _ := path.Match(m.pattern, file.base) - lowerPath, _ := path.Match(m.lowerPattern, file.lowerBase) - if m.stem == "" { - if exactPath || lowerPath { - return fontMatchFuzzy - } - return fontMatchNone - } - name := m.candidateName(file) - if name == m.stem { - return fontMatchExact - } - for _, alias := range m.aliases { - if name == alias { - return fontMatchExact - } - } - if fontFileKnownStyleSuffix(m.styleSuffixNormalized(name)) { - return fontMatchExact - } - if strings.HasPrefix(name, m.stem) { - return fontMatchPartial - } - for _, alias := range m.aliases { - if strings.HasPrefix(name, alias) { - return fontMatchPartial - } - } - if exactPath || lowerPath || strings.Contains(name, m.stem) { - return fontMatchFuzzy - } - for _, alias := range m.aliases { - if strings.Contains(name, alias) { - return fontMatchFuzzy - } - } - return fontMatchNone -} - -// candidateName 保留同族内部名称的区分,只有无关联时使用调用方文件别名 -// 入参: file 字体文件候选 -// 返回: string 用于统一匹配名称与样式的规范名称 -func (m fontPatternMatcher) candidateName(file fontFileCandidate) 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 fontPatternMatcher) 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 "" -} - -// appendFontFileMatch 追加字体文件匹配结果 -// 入参: matches 匹配结果, seen 已匹配文件, matcher 匹配器, file 字体文件候选, rank 匹配等级, bold 是否粗体, italic 是否斜体 -func appendFontFileMatch(matches *[]fontFileMatch, seen map[fontFileKey]int, matcher fontPatternMatcher, file fontFileCandidate, rank int, bold, italic bool) { - if rank == fontMatchNone { - return - } - suffix := matcher.styleSuffixNormalized(matcher.candidateName(file)) - if rank == fontMatchFuzzy { - suffix = matcher.styleSuffixNormalized(basefont.NormalizeName(file.base)) - } - next := fontFileMatch{ - name: file.name, - face: file.face, - priority: matcher.priority, - rank: rank, - styleRank: fontFileStyleRank(suffix, bold, italic), - sortName: file.lowerBase, - } - key := fontFileKey{file.fold, file.face} - if index, ok := seen[key]; ok { - if fontFileMatchLess(next, (*matches)[index]) { - (*matches)[index] = next - } - return - } - seen[key] = len(*matches) - *matches = append(*matches, next) -} - -// sortFontFileMatches 排序字体文件匹配结果 -// 入参: matches 匹配结果 -func sortFontFileMatches(matches []fontFileMatch) { - sort.SliceStable(matches, func(i, j int) bool { - return fontFileMatchLess(matches[i], matches[j]) - }) -} - -// fontFileMatchLess 判断字体文件匹配结果优先级 -// 入参: left 左侧匹配结果, right 右侧匹配结果 -// 返回: bool 左侧是否优先 -func fontFileMatchLess(left, right fontFileMatch) 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 -} - -// fontFileMatchNames 获取字体文件匹配名称 -// 入参: matches 匹配结果 -// 返回: []string 字体文件列表 -func fontFileMatchNames(matches []fontFileMatch) []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 -} - -// fontFileStyleRank 获取字体文件样式匹配等级 -// 入参: suffix 样式后缀, bold 是否粗体, italic 是否斜体 -// 返回: int 样式匹配等级 -func fontFileStyleRank(suffix string, bold, italic bool) int { - fileBold, fileItalic := fontFileStyleFromSuffix(suffix) - rank := 0 - if fileBold != bold { - rank += 2 - } - if fileItalic != italic { - rank += 2 - } - if !bold && !italic && !fileBold && !fileItalic && suffix != "" && !fontFileRegularSuffix(suffix) { - rank++ - } - return rank -} - -// fontFileStyleFromSuffix 获取样式后缀对应的字体样式 -// 入参: suffix 样式后缀 -// 返回: bool 是否粗体, bool 是否斜体 -func fontFileStyleFromSuffix(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 -} - -// fontFileRegularSuffix 判断是否为常规样式后缀 -// 入参: suffix 样式后缀 -// 返回: bool 是否为常规样式后缀 -func fontFileRegularSuffix(suffix string) bool { - switch suffix { - case "r", "regular", "normal", "常规": - return true - default: - return false - } -} - -// fontFileKnownStyleSuffix 判断是否为已知样式后缀 -// 入参: suffix 样式后缀 -// 返回: bool 是否为已知样式后缀 -func fontFileKnownStyleSuffix(suffix string) bool { - switch suffix { - case "r", "regular", "normal", "常规", - "b", "bd", "bold", - "i", "it", "italic", "oblique", - "bi", "bolditalic", "boldoblique": - return true - default: - return false - } -} diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index d74415c..9d5dc33 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -129,7 +129,7 @@ func (r *Reader) fontData(id string, readonly bool) ([]byte, error) { return nil, err } if bytes.HasPrefix(data, []byte("ttcf")) { - index := fontCollectionIndex(data, []string{f.FontName, f.FamilyName}, f.Bold, f.Italic) + index := basefont.CollectionIndex(data, []string{f.FontName, f.FamilyName}, f.Bold, f.Italic) return basefont.ExtractCollectionFont(data, index) } return data, nil @@ -268,7 +268,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) { names[index] = face.Names } } - index := fontNameIndex(names, []string{of.FontName, of.FamilyName}, of.Bold, of.Italic) + index := basefont.NameIndex(names, []string{of.FontName, of.FamilyName}, of.Bold, of.Italic) if faces[index] == nil { return nil, nil } diff --git a/ofdgo_font_match.go b/ofdgo_font_match.go deleted file mode 100644 index 5c15ca1..0000000 --- a/ofdgo_font_match.go +++ /dev/null @@ -1,435 +0,0 @@ -// 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 ofdgo - -import ( - "bytes" - "path" - "slices" - "strings" - - basefont "github.com/xiaoqidun/ofdgo/internal/base/font" -) - -// 字体匹配按精确、部分和模糊匹配排列,零值表示未匹配 -const ( - fontMatchNone = iota - fontMatchExact - fontMatchPartial - fontMatchFuzzy -) - -// fontMatchRules 保存常用字体的名称、文件名及系统字体匹配规则 -var fontMatchRules = normalizeFontMatchRules([]fontMatchRule{ - { - 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", - }, -}) - -// fontMatchRule 字体匹配规则 -type fontMatchRule struct { - Keys []string - Names []string - Files []string - System string - NoSyntheticBold bool -} - -// FontNormalizeName 规范化字体名称 -// 入参: name 字体名称 -// 返回: string 规范化后的字体名称 -func FontNormalizeName(name string) string { - return basefont.NormalizeName(name) -} - -// FontCandidateNames 获取字体候选名称 -// 入参: names 字体名称列表 -// 返回: []string 字体候选名称列表 -func FontCandidateNames(names ...string) []string { - return fontCandidateNames(names...) -} - -// FontFilePatterns 获取字体文件匹配模式 -// 入参: names 字体名称列表 -// 返回: []string 字体文件匹配模式 -func FontFilePatterns(names ...string) []string { - return fontFilePatterns(names...) -} - -// FontSystemNames 获取系统字体名称 -// 入参: names 字体名称列表 -// 返回: []string 系统字体名称 -func FontSystemNames(names ...string) []string { - return fontSystemNames(names...) -} - -// normalizeFontMatchRules 预处理固定字体规则的匹配名称,保留候选名称及顺序 -// 入参: rules 字体匹配规则 -// 返回: []fontMatchRule 预处理后的规则 -func normalizeFontMatchRules(rules []fontMatchRule) []fontMatchRule { - for i := range rules { - rule := &rules[i] - keys := append(append(rule.Keys, rule.Names...), rule.System) - rule.Keys = nil - for _, key := range keys { - key = basefont.NormalizeName(key) - if key != "" && !slices.Contains(rule.Keys, key) { - rule.Keys = append(rule.Keys, key) - } - } - } - return rules -} - -// fontCandidateNames 获取字体候选名称 -// 入参: names 字体名称列表 -// 返回: []string 字体候选名称 -func fontCandidateNames(names ...string) []string { - return fontCandidateNamesByLevel(fontMatchFuzzy, names...) -} - -// fontExactCandidateNames 获取精确字体候选名称 -// 入参: names 字体名称列表 -// 返回: []string 精确字体候选名称 -func fontExactCandidateNames(names ...string) []string { - return fontCandidateNamesByLevel(fontMatchExact, names...) -} - -// fontQualifiedNames 获取保留名称后缀的字体候选 -// 入参: name 字体名称 -// 返回: []string 保留后缀的候选名称 -func fontQualifiedNames(name string) []string { - name = basefont.NormalizeName(name) - key := basefont.NormalizeName(name) - var names []string - seen := make(map[string]bool) - for _, rule := range fontMatchRules { - if fontRuleMatchLevel(rule, key) != fontMatchPartial { - 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 = basefont.AppendFontName(names, seen, basefont.NormalizeName(alias)+suffix) - } - } - return names -} - -// fontCandidateNamesByLevel 获取指定等级内的字体候选名称 -// 入参: maxLevel 最大匹配等级, names 字体名称列表 -// 返回: []string 字体候选名称 -func fontCandidateNamesByLevel(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 = basefont.AppendFontName(result, seen, name) - bases = append(bases, name) - } - levels := fontMatchLevels(bases) - for level := fontMatchExact; level <= maxLevel; level++ { - for i := range bases { - for j, rule := range fontMatchRules { - if levels[i*len(fontMatchRules)+j] == level { - result = basefont.AppendFontName(result, seen, rule.System) - for _, item := range rule.Names { - result = basefont.AppendFontName(result, seen, item) - } - } - } - } - } - return result -} - -// fontPatternStem 获取字体匹配模式主干 -// 入参: pattern 字体文件匹配模式 -// 返回: string 字体匹配模式主干 -func fontPatternStem(pattern string) string { - stem := strings.TrimSuffix(pattern, "*") - if basefont.IsFontFileName(stem) { - stem = strings.TrimSuffix(stem, path.Ext(stem)) - } - if index := strings.IndexAny(stem, "*?["); index >= 0 { - stem = stem[:index] - } - return basefont.NormalizeName(stem) -} - -// fontFilePatterns 获取字体文件匹配模式 -// 入参: names 字体名称列表 -// 返回: []string 字体文件匹配模式 -func fontFilePatterns(names ...string) []string { - var result []string - seen := make(map[string]bool) - levels := fontMatchLevels(names) - for level := fontMatchExact; level <= fontMatchFuzzy; level++ { - for _, name := range fontCandidateNamesAtLevel(level, names, levels) { - result = appendFontPattern(result, seen, name+"*") - } - for i := range names { - for j, rule := range fontMatchRules { - if levels[i*len(fontMatchRules)+j] == level { - for _, item := range rule.Files { - result = appendFontPattern(result, seen, item) - } - } - } - } - } - return result -} - -// fontSystemNames 获取系统字体名称 -// 入参: names 字体名称列表 -// 返回: []string 系统字体名称 -func fontSystemNames(names ...string) []string { - var result []string - seen := make(map[string]bool) - for _, name := range names { - for _, item := range fontCandidateNamesByLevel(fontMatchPartial, name) { - result = basefont.AppendFontName(result, seen, item) - } - } - return result -} - -// fontCandidateNamesAtLevel 获取指定等级的字体候选名称 -// 入参: level 匹配等级, names 字体名称列表, levels 名称与规则的匹配等级 -// 返回: []string 字体候选名称 -func fontCandidateNamesAtLevel(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 == fontMatchExact { - result = basefont.AppendFontName(result, seen, name) - } - for j, rule := range fontMatchRules { - if levels[i*len(fontMatchRules)+j] == level { - result = basefont.AppendFontName(result, seen, rule.System) - for _, item := range rule.Names { - result = basefont.AppendFontName(result, seen, item) - } - } - } - } - return result -} - -// fontDefaultSystemNames 获取默认系统字体名称 -// 返回: []string 默认系统字体名称 -func fontDefaultSystemNames() []string { - return []string{ - "SimSun", "Microsoft YaHei", "SimHei", "KaiTi", "FangSong", - "Noto Sans CJK SC", "Source Han Sans SC", "Arial", "Segoe UI", "Times New Roman", - } -} - -// fontNoSyntheticBold 判断字体是否禁用合成粗体 -// 入参: names 字体名称列表 -// 返回: bool 是否禁用合成粗体 -func fontNoSyntheticBold(names ...string) bool { - for _, name := range names { - name = basefont.NormalizeName(name) - for _, rule := range fontMatchRules { - if !rule.NoSyntheticBold { - continue - } - level := fontRuleMatchLevel(rule, name) - if level >= fontMatchExact && level <= fontMatchPartial { - return true - } - } - } - return false -} - -// fontMatchLevels 按名称和规则顺序计算匹配等级 -// 入参: names 字体名称列表 -// 返回: []int 名称与规则的匹配等级 -func fontMatchLevels(names []string) []int { - levels := make([]int, len(names)*len(fontMatchRules)) - for i, name := range names { - name = basefont.NormalizeName(name) - for j, rule := range fontMatchRules { - levels[i*len(fontMatchRules)+j] = fontRuleMatchLevel(rule, name) - } - } - return levels -} - -// fontRuleMatchLevel 获取字体规则匹配等级 -// 入参: rule 字体匹配规则, name 规范化后的字体名称 -// 返回: int 匹配等级 -func fontRuleMatchLevel(rule fontMatchRule, name string) int { - if name == "" { - return fontMatchNone - } - level := fontMatchNone - for _, item := range rule.Keys { - if name == item { - return fontMatchExact - } - if strings.HasPrefix(name, item) { - level = fontMatchPartial - } else if level == fontMatchNone && strings.Contains(name, item) { - level = fontMatchFuzzy - } - } - return level -} - -// appendFontPattern 追加字体文件匹配模式 -// 入参: patterns 匹配模式列表, seen 去重映射, pattern 匹配模式 -// 返回: []string 匹配模式列表 -func appendFontPattern(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) -} - -// appendFontFileNames 建立各字体的内部名称与文件别名匹配候选 -// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称 -// 返回: []fontFileCandidate 字体候选列表 -func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate { - 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] = basefont.NormalizeName(name) - } - for _, name := range file.names { - file.normalized = name - if !replaced { - candidates[index] = file - replaced = true - } else { - candidates = append(candidates, file) - } - } - } - return candidates -} - -// fontCollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项 -// 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体 -// 返回: int 零起始字体索引 -func fontCollectionIndex(data []byte, names []string, bold, italic bool) int { - return fontNameIndex(basefont.FileNames(bytes.NewReader(data)), names, bold, italic) -} - -// fontNameIndex 按名称与样式选择字体索引,未匹配时保留首项 -// 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体 -// 返回: int 零起始字体索引 -func fontNameIndex(faces [][]string, names []string, bold, italic bool) int { - candidates := appendFontFileNames([]fontFileCandidate{{}}, 0, faces) - if matches := fontFileMatches(candidates, names, bold, italic); len(matches) > 0 { - return matches[0].face - } - return 0 -} diff --git a/ofdgo_font_source.go b/ofdgo_font_source.go index d2dd8e5..e4aa12d 100644 --- a/ofdgo_font_source.go +++ b/ofdgo_font_source.go @@ -64,8 +64,8 @@ type fontSourceKey struct { // fontSourceCache 保存字体候选及独立字体数据,原始集合按字节预算淘汰,不包含绘图库对象 type fontSourceCache struct { candidates map[string][]fontSource - directories map[string][]fontFileCandidate - filesystems map[int][]fontFileCandidate + directories map[string][]basefont.FileCandidate + filesystems map[int][]basefont.FileCandidate data map[fontSourceKey]fontSourceData collections *renderCache[fontSourceKey, []byte] fallback ResolvedFont @@ -81,7 +81,7 @@ type fontSourceData struct { // newFontSourceCache 创建独立字体来源缓存 // 返回: *fontSourceCache 字体来源缓存 func newFontSourceCache() *fontSourceCache { - return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]fontFileCandidate), filesystems: make(map[int][]fontFileCandidate), data: make(map[fontSourceKey]fontSourceData), collections: &renderCache[fontSourceKey, []byte]{limit: 32 << 20}} + return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]basefont.FileCandidate), filesystems: make(map[int][]basefont.FileCandidate), data: make(map[fontSourceKey]fontSourceData), collections: &renderCache[fontSourceKey, []byte]{limit: 32 << 20}} } // child 复用相同来源的文件索引与数据,匹配和回退状态保持独立 @@ -99,11 +99,11 @@ func (c *fontSourceCache) child() *fontSourceCache { func (r *Renderer) readFontSource(source fontSource, definition *Font) ([]byte, error) { key := fontSourceKey{kind: source.kind, index: source.index, face: source.face, name: source.name} if source.kind == fontSourceSystem { - matches := fontFileMatches(systemFontIndex(), []string{source.name}, definition.Bold, definition.Italic) + matches := basefont.FileMatches(systemFontIndex(), []string{source.name}, definition.Bold, definition.Italic) if len(matches) == 0 { return nil, fmt.Errorf("font %q not found", source.name) } - key = fontSourceKey{kind: fontSourceFile, name: matches[0].name, face: matches[0].face} + key = fontSourceKey{kind: fontSourceFile, name: matches[0].Name, face: matches[0].Face} } if cached, ok := r.fontSourcesCache.data[key]; ok { return cached.data, cached.err @@ -192,7 +192,7 @@ func (r *Renderer) resolveFontSource(backend FontBackend, id string, exact bool) if !r.fontSourcesCache.fallbackRead { r.fontSourcesCache.fallbackRead = true if canLoadSystemFonts() { - for _, name := range fontDefaultSystemNames() { + for _, name := range basefont.DefaultSystemNames() { data, err := r.readFontSource(fontSource{kind: fontSourceSystem, name: name}, &Font{}) if err != nil { continue @@ -221,12 +221,12 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource { seen := make(map[fontSourceKey]bool) for _, dir := range r.fontDirs { for _, match := range r.matchFontFiles(dir, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: match.rank == fontMatchExact, priority: min(match.priority, 0)}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.Name, face: match.Face, exact: match.Rank == basefont.MatchExact, priority: min(match.Priority, 0)}) } } for index := range r.fontFS { for _, match := range r.matchFontFS(index, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face, exact: match.rank == fontMatchExact, priority: min(match.priority, 0)}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.Name, face: match.Face, exact: match.Rank == basefont.MatchExact, priority: min(match.Priority, 0)}) } } sortFontSources(sources) @@ -234,10 +234,10 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource { if canLoadSystemFonts() { for _, dir := range systemFontDirs() { for _, match := range r.matchFontFiles(dir, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: match.rank == fontMatchExact, priority: min(match.priority, 0)}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.Name, face: match.Face, exact: match.Rank == basefont.MatchExact, priority: min(match.Priority, 0)}) } } - for _, name := range fontSystemNames(names...) { + for _, name := range basefont.SystemNames(names...) { source := fontSource{kind: fontSourceSystem, name: name, exact: true} for i, requested := range names { if basefont.NormalizeName(name) == basefont.NormalizeName(requested) { @@ -251,12 +251,12 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource { sortFontSources(sources[systemStart:]) for _, dir := range r.fontDirs { for _, match := range r.matchFontFiles(dir, nil, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.Name, face: match.Face}) } } for index := range r.fontFS { for _, match := range r.matchFontFS(index, nil, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.Name, face: match.Face}) } } for index, fsys := range r.fontFS { @@ -294,39 +294,39 @@ func appendFontSource(sources []fontSource, seen map[fontSourceKey]bool, source // matchFontFiles 查找字体文件 // 入参: dir 字体目录, names 字体名称, bold 是否粗体, italic 是否斜体 -// 返回: []fontFileMatch 字体匹配列表 -func (r *Renderer) matchFontFiles(dir string, names []string, bold, italic bool) []fontFileMatch { +// 返回: []basefont.FileMatch 字体匹配列表 +func (r *Renderer) matchFontFiles(dir string, names []string, bold, italic bool) []basefont.FileMatch { candidates, ok := r.fontSourcesCache.directories[dir] if !ok { files, _ := filepath.Glob(filepath.Join(dir, "*")) - candidates = fontFileCandidates(files, filepath.Base) + candidates = basefont.FileCandidates(files, filepath.Base) for i, candidate := range candidates { - file, err := os.Open(candidate.name) + file, err := os.Open(candidate.Name) if err != nil { continue } - candidates = appendFontFileNames(candidates, i, basefont.FileNames(file)) + candidates = basefont.AppendFileNames(candidates, i, basefont.FileNames(file)) file.Close() } r.fontSourcesCache.directories[dir] = candidates } - return fontFileMatches(candidates, names, bold, italic) + return basefont.FileMatches(candidates, names, bold, italic) } // matchFontFS 匹配字体文件系统中的字体名称 // 入参: index 字体文件系统索引, names 字体名称, bold 是否粗体, italic 是否斜体 -// 返回: []fontFileMatch 字体匹配列表 -func (r *Renderer) matchFontFS(index int, names []string, bold, italic bool) []fontFileMatch { +// 返回: []basefont.FileMatch 字体匹配列表 +func (r *Renderer) matchFontFS(index int, names []string, bold, italic bool) []basefont.FileMatch { fsys := r.fontFS[index] if fonts, ok := fsys.(*FontFS); ok { - return fonts.matchStyle(names, bold, italic) + return basefont.FileMatches(fonts.candidates, names, bold, italic) } candidates, ok := r.fontSourcesCache.filesystems[index] if !ok { files, _ := fs.Glob(fsys, "*") - candidates = fontFileCandidates(files, path.Base) + candidates = basefont.FileCandidates(files, path.Base) for i, candidate := range candidates { - file, err := fsys.Open(candidate.name) + file, err := fsys.Open(candidate.Name) if err != nil { continue } @@ -335,12 +335,12 @@ func (r *Renderer) matchFontFS(index int, names []string, bold, italic bool) []f data, _ := io.ReadAll(file) reader = bytes.NewReader(data) } - candidates = appendFontFileNames(candidates, i, basefont.FileNames(reader)) + candidates = basefont.AppendFileNames(candidates, i, basefont.FileNames(reader)) file.Close() } r.fontSourcesCache.filesystems[index] = candidates } - return fontFileMatches(candidates, names, bold, italic) + return basefont.FileMatches(candidates, names, bold, italic) } // systemFontDirs 获取系统字体目录 @@ -378,8 +378,8 @@ func systemFontDirs() []string { } // indexSystemFonts 递归读取系统字体名称,保留集合索引 -// 返回: []fontFileCandidate 字体候选 -func indexSystemFonts() []fontFileCandidate { +// 返回: []basefont.FileCandidate 字体候选 +func indexSystemFonts() []basefont.FileCandidate { var files []string seen := make(map[string]bool) for _, dir := range systemFontDirs() { @@ -391,13 +391,13 @@ func indexSystemFonts() []fontFileCandidate { return nil }) } - candidates := fontFileCandidates(files, filepath.Base) + candidates := basefont.FileCandidates(files, filepath.Base) for i, candidate := range candidates { - file, err := os.Open(candidate.name) + file, err := os.Open(candidate.Name) if err != nil { continue } - candidates = appendFontFileNames(candidates, i, basefont.FileNames(file)) + candidates = basefont.AppendFileNames(candidates, i, basefont.FileNames(file)) file.Close() } return candidates diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 5bba546..dadcb31 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -23,6 +23,9 @@ import ( "github.com/xiaoqidun/pdfgo" ) +// pdfObjectChunkLimit 限制导入暂存片段的容量,不限制页面对象数量 +const pdfObjectChunkLimit = 256 + // ErrPDFPassword 表示PDF需要正确的用户或所有者密码 var ErrPDFPassword = pdfgo.ErrPassword @@ -118,6 +121,12 @@ type pdfImporter struct { compression CompressionOptions } +// pdfObjectBuffer 分块收集导入对象,提交前只整理一次连续数据 +type pdfObjectBuffer struct { + parts [][]GraphicObject + size int +} + // ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果 // 入参: ctx 取消上下文, source PDF数据, size 字节数, options 转换选项 // 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息 @@ -321,3 +330,126 @@ func (p *pdfImporter) referencePage(ctx context.Context, source *pdfgo.Reader, r } return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning) } + +// objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器 +// 返回: *editorValidation 当前编辑器的构建校验会话 +func (p *pdfImporter) objectValidation() *editorValidation { + if p.validation == nil || p.validation.Editor != p.editor { + p.validation = &editorValidation{Editor: p.editor} + } + return p.validation +} + +// appendObject 追加本次导入对象,按需建立独立暂存缓冲 +// 入参: object 独立导入对象 +func (p *pdfImporter) appendObject(object GraphicObject) { + if p.objects == nil { + p.objects = &pdfObjectBuffer{} + } + p.objects.append(object) +} + +// append 追加对象,小批量连续存储,超过阈值后不复制前序片段 +// 入参: object 独立对象 +func (b *pdfObjectBuffer) append(object GraphicObject) { + index := len(b.parts) - 1 + if index == 0 && len(b.parts[0]) == cap(b.parts[0]) && cap(b.parts[0]) < pdfObjectChunkLimit { + part := b.parts[0] + next := make([]GraphicObject, len(part), min(pdfObjectChunkLimit, max(1, cap(part)*2))) + copy(next, part) + b.parts[0] = next + } + if index < 0 || len(b.parts[index]) == cap(b.parts[index]) { + capacity := pdfObjectChunkLimit + if index < 0 { + capacity = 1 + } + b.parts = append(b.parts, make([]GraphicObject, 0, capacity)) + index++ + } + b.parts[index] = append(b.parts[index], object) + b.size++ +} + +// len 获取暂存对象数量,空缓冲返回零 +// 返回: int 对象数量 +func (b *pdfObjectBuffer) len() int { + if b == nil { + return 0 + } + return b.size +} + +// truncate 回滚到已有对象位置,清理丢弃对象及片段引用 +// 入参: size 保留对象数量 +func (b *pdfObjectBuffer) truncate(size int) { + if size < 0 || size > b.len() { + panic("invalid PDF object buffer size") + } + if size == b.len() { + return + } + remaining := size + for index, part := range b.parts { + if remaining <= len(part) { + clear(part[remaining:]) + b.parts[index] = part[:remaining] + clear(b.parts[index+1:]) + b.parts = b.parts[:index+1] + b.size = size + return + } + remaining -= len(part) + } +} + +// data 按绘制顺序整理独立对象数组,取消时保留原片段供重试 +// 入参: ctx 取消上下文 +// 返回: []GraphicObject 暂存数据, error 取消错误 +func (b *pdfObjectBuffer) data(ctx context.Context) ([]GraphicObject, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if b.len() == 0 { + return nil, nil + } + if len(b.parts) == 1 { + return b.parts[0][:b.size:b.size], nil + } + objects := make([]GraphicObject, b.size) + offset := 0 + for _, part := range b.parts { + if err := ctx.Err(); err != nil { + return nil, err + } + offset += copy(objects[offset:], part) + } + if err := ctx.Err(); err != nil { + return nil, err + } + b.parts[0] = objects + clear(b.parts[1:]) + b.parts = b.parts[:1] + return objects, nil +} + +// commitObjects 将当前页面待转换对象写入编辑文档 +// 返回: error 对象写入错误 +func (p *pdfImporter) commitObjects() error { + if p.objects.len() == 0 { + return nil + } + objects, err := p.objects.data(p.ctx) + if err != nil { + return err + } + objects, err = p.sharedClipObjects(objects) + if err != nil { + return err + } + if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil { + return err + } + p.objects = nil + return nil +} diff --git a/ofdgo_pdf_buffer.go b/ofdgo_pdf_buffer.go deleted file mode 100644 index 7563d17..0000000 --- a/ofdgo_pdf_buffer.go +++ /dev/null @@ -1,151 +0,0 @@ -// 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 ofdgo - -import ( - "context" -) - -// pdfObjectChunkLimit 限制导入暂存片段的容量,不限制页面对象数量 -const pdfObjectChunkLimit = 256 - -// pdfObjectBuffer 分块收集导入对象,提交前只整理一次连续数据 -type pdfObjectBuffer struct { - parts [][]GraphicObject - size int -} - -// objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器 -// 返回: *editorValidation 当前编辑器的构建校验会话 -func (p *pdfImporter) objectValidation() *editorValidation { - if p.validation == nil || p.validation.Editor != p.editor { - p.validation = &editorValidation{Editor: p.editor} - } - return p.validation -} - -// appendObject 追加本次导入对象,按需建立独立暂存缓冲 -// 入参: object 独立导入对象 -func (p *pdfImporter) appendObject(object GraphicObject) { - if p.objects == nil { - p.objects = &pdfObjectBuffer{} - } - p.objects.append(object) -} - -// append 追加对象,小批量连续存储,超过阈值后不复制前序片段 -// 入参: object 独立对象 -func (b *pdfObjectBuffer) append(object GraphicObject) { - index := len(b.parts) - 1 - if index == 0 && len(b.parts[0]) == cap(b.parts[0]) && cap(b.parts[0]) < pdfObjectChunkLimit { - part := b.parts[0] - next := make([]GraphicObject, len(part), min(pdfObjectChunkLimit, max(1, cap(part)*2))) - copy(next, part) - b.parts[0] = next - } - if index < 0 || len(b.parts[index]) == cap(b.parts[index]) { - capacity := pdfObjectChunkLimit - if index < 0 { - capacity = 1 - } - b.parts = append(b.parts, make([]GraphicObject, 0, capacity)) - index++ - } - b.parts[index] = append(b.parts[index], object) - b.size++ -} - -// len 获取暂存对象数量,空缓冲返回零 -// 返回: int 对象数量 -func (b *pdfObjectBuffer) len() int { - if b == nil { - return 0 - } - return b.size -} - -// truncate 回滚到已有对象位置,清理丢弃对象及片段引用 -// 入参: size 保留对象数量 -func (b *pdfObjectBuffer) truncate(size int) { - if size < 0 || size > b.len() { - panic("invalid PDF object buffer size") - } - if size == b.len() { - return - } - remaining := size - for index, part := range b.parts { - if remaining <= len(part) { - clear(part[remaining:]) - b.parts[index] = part[:remaining] - clear(b.parts[index+1:]) - b.parts = b.parts[:index+1] - b.size = size - return - } - remaining -= len(part) - } -} - -// data 按绘制顺序整理独立对象数组,取消时保留原片段供重试 -// 入参: ctx 取消上下文 -// 返回: []GraphicObject 暂存数据, error 取消错误 -func (b *pdfObjectBuffer) data(ctx context.Context) ([]GraphicObject, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - if b.len() == 0 { - return nil, nil - } - if len(b.parts) == 1 { - return b.parts[0][:b.size:b.size], nil - } - objects := make([]GraphicObject, b.size) - offset := 0 - for _, part := range b.parts { - if err := ctx.Err(); err != nil { - return nil, err - } - offset += copy(objects[offset:], part) - } - if err := ctx.Err(); err != nil { - return nil, err - } - b.parts[0] = objects - clear(b.parts[1:]) - b.parts = b.parts[:1] - return objects, nil -} - -// commitObjects 将当前页面待转换对象写入编辑文档 -// 返回: error 对象写入错误 -func (p *pdfImporter) commitObjects() error { - if p.objects.len() == 0 { - return nil - } - objects, err := p.objects.data(p.ctx) - if err != nil { - return err - } - objects, err = p.sharedClipObjects(objects) - if err != nil { - return err - } - if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil { - return err - } - p.objects = nil - return nil -} diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 1e83fc9..4cd2ebf 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -38,13 +38,8 @@ type pdfCompositeNode struct { formCount int } -// pdfCompositePixel 保存非预乘源空间分量及累计透明度 -type pdfCompositePixel struct { - values [4]float64 - alpha float64 - effect float64 - shape float64 -} +// pdfCompositePixel 复用内部PDF像素布局 +type pdfCompositePixel = codecpdf.CompositePixel // pdfCompositor 在指定局部区域中按源颜色空间合成PDF图元 type pdfCompositor struct { @@ -121,7 +116,7 @@ type pdfCompositeCache struct { shadingPatterns map[*pdfgo.ShadingPattern][]pdfCompositeNode shadingSources map[pdfShadingSourceKey][]pdfCompositeNode shadings map[pdfGradientKey]pdfCompositeGeometry - transfers map[pdfTransferKey]*pdfgo.TransferFunction + transfers codecpdf.TransferCache textDisjoint map[*pdfgo.GroupMark]bool groups map[*pdfgo.GroupMark]Box } @@ -420,7 +415,7 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error, widg } name := codecpdf.EmbeddedFontName(mark.Font.Name) if !externalNames[basefont.NormalizeName(name)] { - for _, candidate := range fontExactCandidateNames(name) { + for _, candidate := range basefont.ExactCandidateNames(name) { externalNames[basefont.NormalizeName(candidate)] = true } } @@ -907,7 +902,7 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo return err } for i := range pixels { - pixels[i].effect = 0 + pixels[i].Effect = 0 } copy(previousTransfers, c.transfers) if err := c.draw([]pdfCompositeNode{node}, pixels, space); err != nil { @@ -919,16 +914,16 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo return err } } - if pixel.effect == 0 && (c.transfers == nil || c.transfers[i] == previousTransfers[i]) { + if pixel.Effect == 0 && (c.transfers == nil || c.transfers[i] == previousTransfers[i]) { continue } var rgb [3]float64 var err error if c.transfers != nil && device == "DeviceCMYK" { - values := pixel.values + values := pixel.Values if flatten { for j := range values { - values[j] *= pixel.alpha + values[j] *= pixel.Alpha } } values, err = c.transfers[i].Apply(values[:], device, "") @@ -936,18 +931,18 @@ func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompo rgb, err = codecpdf.OutputColor(space, values) } } else if c.transfers != nil { - rgb, err = space.RGB(pixel.values[:space.Components()], "RelativeColorimetric") + rgb, err = space.RGB(pixel.Values[:space.Components()], "RelativeColorimetric") } else { - rgb, err = codecpdf.OutputColor(space, pixel.values) + rgb, err = codecpdf.OutputColor(space, pixel.Values) } if err != nil { return err } - alpha := pixel.alpha + alpha := pixel.Alpha if flatten { if c.transfers == nil || device != "DeviceCMYK" { for j := range rgb { - rgb[j] = pixel.alpha*rgb[j] + 1 - pixel.alpha + rgb[j] = pixel.Alpha*rgb[j] + 1 - pixel.Alpha } } alpha = 1 diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index 5cc3580..611e59f 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -15,9 +15,7 @@ package ofdgo import ( - "context" "fmt" - "image" "math" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" @@ -84,8 +82,8 @@ func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePix defer c.releasePixels(result) copy(result, pixels) for i := range result { - result[i].effect = 0 - result[i].shape = 0 + result[i].Effect = 0 + result[i].Shape = 0 } } } @@ -109,7 +107,7 @@ func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePix } } if grouped { - return pdfCompositeGroupRegion(c.importer.ctx, pixels, initial, result, c.width, region, space, space, style.Fill.Alpha, mask, style.BlendMode, style.RenderingIntent, style.ColorConversion) + return codecpdf.CompositeGroupRegion(c.importer.ctx, pixels, initial, result, c.width, region, space, space, style.Fill.Alpha, mask, style.BlendMode, style.RenderingIntent, style.ColorConversion) } return nil } @@ -266,14 +264,14 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf if patternChannels != nil && patternChannels[index]&pdfTransferShape == 0 { selectionShape = 0 } - values = sample.values + values = sample.Values if paint.Shading != nil { - shape = sample.shape + shape = sample.Shape } else { - shape *= sample.shape + shape *= sample.Shape } - if sample.shape != 0 { - opacity *= sample.alpha / sample.shape + if sample.Shape != 0 { + opacity *= sample.Alpha / sample.Shape } if node.image != nil && paint.Shading == nil { point := c.inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)*step, Y: c.box.Y + (float64(y)+.5)*step}) @@ -359,22 +357,22 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf } if initial != nil { pixel := initial[index] - if err := pdfCompositePaintOver(&pixel, values, opacity, space, mode, compatible, marked); err != nil { + if err := codecpdf.CompositePaintOver(&pixel, values, opacity, space, mode, compatible, marked); err != nil { return err } - if err := pdfCompositeInterpolate(&pixels[index], pixel, shape); err != nil { + if err := codecpdf.CompositeInterpolate(&pixels[index], pixel, shape); err != nil { return err } } else { - if err := pdfCompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible, marked); err != nil { + if err := codecpdf.CompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible, marked); err != nil { return err } } - pixels[index].shape += shape * (1 - pixels[index].shape) + pixels[index].Shape += shape * (1 - pixels[index].Shape) bits := pdfTransferChannels if (c.channels != nil || transfer != nil && c.transferModel == "DeviceCMYK") && (compatible || marked != nil || patternChannels != nil) { bits = 0 - for j, selected := range pdfPaintChannels(values, compatible, marked) { + for j, selected := range codecpdf.PaintChannels(values, compatible, marked) { if selected { bits |= 1 << j } @@ -409,18 +407,6 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf return nil } -// pdfCompositeInterpolate 按形状覆盖混合预乘颜色及累计透明度 -// 入参: target 原像素与输出, source 替换像素, shape 覆盖率 -// 返回: error 像素或覆盖率错误 -func pdfCompositeInterpolate(target *pdfCompositePixel, source pdfCompositePixel, shape float64) error { - pixel := pdfgo.ColorantPixel{Values: target.values[:], Alpha: target.alpha, Shape: target.shape, Effect: target.effect} - if err := pixel.Interpolate(pdfgo.ColorantPixel{Values: source.values[:], Alpha: source.alpha, Shape: source.shape, Effect: source.effect}, shape); err != nil { - return err - } - target.alpha, target.effect = pixel.Alpha, pixel.Effect - return nil -} - // drawGroup 按隔离标志保留初始背景,移除背景贡献后应用组透明度 // 非隔离同空间的非挖空组借用父组背景,子组只写入独立结果缓冲 // 入参: node 透明组, pixels 父组像素, parent 父混合空间 @@ -481,7 +467,7 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi } } pixel := &pixels[i] - values := pixel.values + values := pixel.Values if !sameSpace { var err error values, err = space.Convert(values[:components], parent, "RelativeColorimetric") @@ -489,7 +475,7 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi return err } } - background[i] = pdfCompositePixel{values: values, alpha: pixel.alpha} + background[i] = pdfCompositePixel{Values: values, Alpha: pixel.Alpha} } } if !sharedInitial { @@ -526,13 +512,13 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi return err } } - result[i].shape *= g.Alpha + result[i].Shape *= g.Alpha if mask != nil { - result[i].shape *= mask[i] + result[i].Shape *= mask[i] } } } - if err := pdfCompositeGroupRegion(c.importer.ctx, pixels, initial, result, c.width, region, space, parent, g.Alpha, mask, g.BlendMode, g.RenderingIntent, g.ColorConversion); err != nil { + if err := codecpdf.CompositeGroupRegion(c.importer.ctx, pixels, initial, result, c.width, region, space, parent, g.Alpha, mask, g.BlendMode, g.RenderingIntent, g.ColorConversion); err != nil { return err } if c.channels != nil { @@ -542,7 +528,7 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi return err } for i, end := y*c.width+region.Min.X, y*c.width+region.Max.X; i < end; i++ { - if result[i].shape != 0 && local.channels[i]&pdfTransferShape != 0 { + if result[i].Shape != 0 && local.channels[i]&pdfTransferShape != 0 { c.channels[i] = local.channels[i] if independent { c.channels[i] |= pdfTransferChannels @@ -605,10 +591,10 @@ func (c *pdfCompositor) drawKnockout(nodes []pdfCompositeNode, initial, pixels [ for y := region.Min.Y; y < region.Max.Y; y++ { for x := region.Min.X; x < region.Max.X; x++ { i := y*c.width + x - if err := pdfCompositeKnockout(&pixels[i], initial[i], candidate[i]); err != nil { + if err := codecpdf.CompositeKnockout(&pixels[i], initial[i], candidate[i]); err != nil { return err } - if c.channels != nil && candidate[i].shape != 0 { + if c.channels != nil && candidate[i].Shape != 0 { c.channels[i] = local.channels[i] } } @@ -618,76 +604,6 @@ func (c *pdfCompositor) drawKnockout(nodes []pdfCompositeNode, initial, pixels [ return nil } -// pdfCompositeKnockout 移除未覆盖区域的初始背景贡献,保留形状与不透明度差异 -// 入参: target 累计组结果, initial 初始背景, source 单个对象合成结果 -// 返回: error 像素错误 -func pdfCompositeKnockout(target *pdfCompositePixel, initial, source pdfCompositePixel) error { - pixel := pdfgo.ColorantPixel{Values: target.values[:], Alpha: target.alpha, Shape: target.shape, Effect: target.effect} - if err := pixel.Knockout( - pdfgo.ColorantPixel{Values: initial.values[:], Alpha: initial.alpha, Shape: initial.shape, Effect: initial.effect}, - pdfgo.ColorantPixel{Values: source.values[:], Alpha: source.alpha, Shape: source.shape, Effect: source.effect}, - ); err != nil { - return err - } - target.alpha, target.effect, target.shape = pixel.Alpha, pixel.Effect, pixel.Shape - return nil -} - -// pdfCompositeGroupRegion 在组覆盖区域合成像素,保留原栅格及形状贡献 -// 入参: ctx 取消上下文, pixels 父组输出, initial 初始背景, result 组结果, stride 行跨度, region 覆盖区域, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式, intent 渲染意图, conversion 设备转换函数 -// 返回: error 缓冲、区域、颜色或混合错误 -func pdfCompositeGroupRegion(ctx context.Context, pixels, initial, result []pdfCompositePixel, stride int, region image.Rectangle, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode, intent pdfgo.Name, conversion pdfgo.ColorConversion) error { - if err := ctx.Err(); err != nil { - return err - } - if len(pixels) != len(initial) || len(pixels) != len(result) || mask != nil && len(mask) != len(pixels) { - return fmt.Errorf("invalid PDF transparency group buffers") - } - if stride <= 0 || region.Min.X < 0 || region.Min.Y < 0 || region.Max.X > stride || !region.Empty() && region.Max.Y > len(pixels)/stride { - return fmt.Errorf("invalid PDF transparency group region") - } - group, destination := pdfgo.ColorantGroup{Space: space}, pdfgo.ColorantGroup{Space: parent} - compositor, err := destination.PrepareGroup(&group, mode, intent, conversion) - if err != nil { - return err - } - if region.Empty() { - return nil - } - components, parentComponents := space.Components(), parent.Components() - var target pdfgo.ColorantPixel - for y := region.Min.Y; y < region.Max.Y; y++ { - if err := ctx.Err(); err != nil { - return err - } - for i, end := y*stride+region.Min.X, y*stride+region.Max.X; i < end; i++ { - if i%pdfCompositeTileSize == 0 { - if err := ctx.Err(); err != nil { - return err - } - } - alpha := opacity - if mask != nil { - alpha *= mask[i] - } - pixel := &result[i] - target.Values = pixels[i].values[:parentComponents] - target.Alpha = pixels[i].alpha - target.Shape = pixels[i].shape - target.Effect = pixels[i].effect - if err := compositor.Composite(&target, - pdfgo.ColorantPixel{Values: initial[i].values[:components], Alpha: initial[i].alpha, Shape: initial[i].shape, Effect: initial[i].effect}, - pdfgo.ColorantPixel{Values: pixel.values[:components], Alpha: pixel.alpha, Shape: pixel.shape, Effect: pixel.effect}, - alpha, - ); err != nil { - return err - } - pixels[i].alpha, pixels[i].effect, pixels[i].shape = target.Alpha, target.Effect, target.Shape - } - } - return nil -} - // mask 在组颜色空间计算蒙版,保留背景色、组隔离和亮度传递 // 入参: mask 蒙版或空值, inherited 未指定蒙版空间时继承的空间 // 返回: []float64 蒙版透明度或空值, error 蒙版错误 @@ -724,8 +640,8 @@ func (c *pdfCompositor) mask(mask *pdfgo.SoftMask, inherited *pdfgo.ColorSpace) return nil, err } } - copy(pixels[i].values[:], mask.Backdrop) - pixels[i].alpha = 1 + copy(pixels[i].Values[:], mask.Backdrop) + pixels[i].Alpha = 1 } } local := *c @@ -748,9 +664,9 @@ func (c *pdfCompositor) mask(mask *pdfgo.SoftMask, inherited *pdfgo.ColorSpace) return nil, err } } - v := pixel.alpha + v := pixel.Alpha if mask.Subtype == "Luminosity" { - v, err = space.Luminosity(pixel.values[:space.Components()], "RelativeColorimetric") + v, err = space.Luminosity(pixel.Values[:space.Components()], "RelativeColorimetric") if err != nil { return nil, err } @@ -917,41 +833,3 @@ func (s *pdfCompositeColorSampler) color(point pdfgo.Point) ([4]float64, bool, e } return values, visible, err } - -// pdfCompositePaintOver 以兼容叠印计算零色料分量,再应用对象混合模式 -// 入参: backdrop 背景及输出, source 源分量, alpha 源透明度, space 混合空间, mode 混合模式, compatible 是否保留零色料背景, marked 原生过程通道,nil使用普通空间 -// 返回: error 未支持的混合模式 -func pdfCompositePaintOver(backdrop *pdfCompositePixel, source [4]float64, alpha float64, space *pdfgo.ColorSpace, mode pdfgo.Name, compatible bool, marked *[4]bool) error { - if !compatible && marked == nil { - return pdfCompositeOver(backdrop, source, alpha, space, mode) - } - if alpha == 0 { - return nil - } - mask := pdfPaintChannels(source, compatible, marked) - values, combined, err := space.CompositeOverprint(backdrop.values[:space.Components()], source[:space.Components()], backdrop.alpha, alpha, mode, mask[:space.Components()]) - if err != nil { - return err - } - copy(backdrop.values[:space.Components()], values[:space.Components()]) - backdrop.alpha = combined - backdrop.effect = alpha + backdrop.effect*(1-alpha) - return nil -} - -// pdfCompositeOver 按PDF透明模型合成源颜色,四色混合使用分量补色 -// 入参: backdrop 背景及输出, source 源分量, alpha 源透明度, space 混合空间, mode 混合模式 -// 返回: error 未支持的混合模式 -func pdfCompositeOver(backdrop *pdfCompositePixel, source [4]float64, alpha float64, space *pdfgo.ColorSpace, mode pdfgo.Name) error { - if alpha == 0 { - return nil - } - values, combined, err := space.Composite(backdrop.values[:space.Components()], source[:space.Components()], backdrop.alpha, alpha, mode) - if err != nil { - return err - } - copy(backdrop.values[:space.Components()], values[:space.Components()]) - backdrop.alpha = combined - backdrop.effect = alpha + backdrop.effect*(1-alpha) - return nil -} diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index 01bd571..16b5ae7 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -71,7 +71,7 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, return nil, err } } - result[i] = pdfCompositePixel{values: pixel.values, alpha: pixel.alpha} + result[i] = pdfCompositePixel{Values: pixel.Values, Alpha: pixel.Alpha} } candidate := c.acquirePixels(len(backdrop)) defer c.releasePixels(candidate) @@ -113,7 +113,7 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, if disjoint { initial = backdrop[index] } - pixels[y*width+x] = pdfCompositePixel{values: initial.values, alpha: initial.alpha} + pixels[y*width+x] = pdfCompositePixel{Values: initial.Values, Alpha: initial.Alpha} if cellChannels != nil { cellChannels[y*width+x] = 0 if !disjoint { @@ -136,7 +136,7 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, for x := range width { index := (top+y)*c.width + left + x pdfCompositePatternCell(&result[index], backdrop[index], pixels[y*width+x], disjoint) - if channels != nil && pixels[y*width+x].shape != 0 { + if channels != nil && pixels[y*width+x].Shape != 0 { if disjoint { channels[index] |= cellChannels[y*width+x] } else { @@ -154,12 +154,12 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, } } pixel := &result[i] - if pixel.effect != 0 { + if pixel.Effect != 0 { for j := 0; j < space.Components(); j++ { - pixel.values[j] = math.Max(0, math.Min(1, (pixel.values[j]*pixel.alpha-backdrop[i].values[j]*backdrop[i].alpha*(1-pixel.effect))/pixel.effect)) + pixel.Values[j] = math.Max(0, math.Min(1, (pixel.Values[j]*pixel.Alpha-backdrop[i].Values[j]*backdrop[i].Alpha*(1-pixel.Effect))/pixel.Effect)) } } - pixel.alpha = pixel.effect + pixel.Alpha = pixel.Effect } complete = true return result, nil @@ -168,23 +168,23 @@ func (c *pdfCompositor) tiling(paint pdfgo.Paint, backdrop []pdfCompositePixel, // pdfCompositePatternCell 合并互不重叠单元的覆盖贡献,避免相邻抗锯齿边缘重复混合 // 入参: target 图案累计结果, initial 原始背景, source 单元结果, disjoint 单元边界是否互不重叠 func pdfCompositePatternCell(target *pdfCompositePixel, initial, source pdfCompositePixel, disjoint bool) { - if source.shape == 0 { + if source.Shape == 0 { return } if disjoint { - alpha := math.Max(0, math.Min(1, target.alpha+source.alpha-initial.alpha)) + alpha := math.Max(0, math.Min(1, target.Alpha+source.Alpha-initial.Alpha)) if alpha != 0 { - for i := range target.values { - target.values[i] = (target.values[i]*target.alpha + source.values[i]*source.alpha - initial.values[i]*initial.alpha) / alpha + for i := range target.Values { + target.Values[i] = (target.Values[i]*target.Alpha + source.Values[i]*source.Alpha - initial.Values[i]*initial.Alpha) / alpha } } - target.alpha = alpha - target.effect = math.Min(1, target.effect+source.effect) - target.shape = math.Min(1, target.shape+source.shape) + target.Alpha = alpha + target.Effect = math.Min(1, target.Effect+source.Effect) + target.Shape = math.Min(1, target.Shape+source.Shape) } else { - target.values, target.alpha = source.values, source.alpha - target.effect += source.effect * (1 - target.effect) - target.shape += source.shape * (1 - target.shape) + target.Values, target.Alpha = source.Values, source.Alpha + target.Effect += source.Effect * (1 - target.Effect) + target.Shape += source.Shape * (1 - target.Shape) } } diff --git a/ofdgo_pdf_composite_shading.go b/ofdgo_pdf_composite_shading.go index 1eab560..1ab28af 100644 --- a/ofdgo_pdf_composite_shading.go +++ b/ofdgo_pdf_composite_shading.go @@ -114,17 +114,17 @@ func (c *pdfCompositor) shadingPattern(pattern *pdfgo.ShadingPattern, node pdfCo } remaining := 1.0 if shape != 0 { - remaining -= math.Min(1, result[i].shape/shape) + remaining -= math.Min(1, result[i].Shape/shape) } - alpha := result[i].alpha + background[i].alpha*remaining + alpha := result[i].Alpha + background[i].Alpha*remaining if alpha != 0 { for j := 0; j < space.Components(); j++ { - result[i].values[j] = (result[i].values[j]*result[i].alpha + background[i].values[j]*background[i].alpha*remaining) / alpha + result[i].Values[j] = (result[i].Values[j]*result[i].Alpha + background[i].Values[j]*background[i].Alpha*remaining) / alpha } } - result[i].alpha, result[i].effect = alpha, alpha - result[i].shape += background[i].shape * remaining - if channels != nil && background[i].shape*remaining > 0 { + result[i].Alpha, result[i].Effect = alpha, alpha + result[i].Shape += background[i].Shape * remaining + if channels != nil && background[i].Shape*remaining > 0 { channels[i] |= backgroundChannels[i] } } @@ -190,7 +190,7 @@ func (c *pdfCompositor) shadingSourcePixels(node pdfCompositeNode, backdrop []pd } }() for i, pixel := range backdrop { - result[i] = pdfCompositePixel{values: pixel.values, alpha: pixel.alpha} + result[i] = pdfCompositePixel{Values: pixel.Values, Alpha: pixel.Alpha} } if err := c.drawMark(node, result, space); err != nil { return nil, err @@ -202,12 +202,12 @@ func (c *pdfCompositor) shadingSourcePixels(node pdfCompositeNode, backdrop []pd } } pixel := &result[i] - if pixel.effect != 0 { + if pixel.Effect != 0 { for j := 0; j < space.Components(); j++ { - pixel.values[j] = math.Max(0, math.Min(1, (pixel.values[j]*pixel.alpha-backdrop[i].values[j]*backdrop[i].alpha*(1-pixel.effect))/pixel.effect)) + pixel.Values[j] = math.Max(0, math.Min(1, (pixel.Values[j]*pixel.Alpha-backdrop[i].Values[j]*backdrop[i].Alpha*(1-pixel.Effect))/pixel.Effect)) } } - pixel.alpha = pixel.effect + pixel.Alpha = pixel.Effect } complete = true return result, nil diff --git a/ofdgo_pdf_transfer.go b/ofdgo_pdf_transfer.go index caaf32b..089bee6 100644 --- a/ofdgo_pdf_transfer.go +++ b/ofdgo_pdf_transfer.go @@ -25,38 +25,14 @@ const ( pdfTransferShape uint8 = 16 ) -// pdfTransferKey 区分图形状态、网屏覆盖函数及输出设备 -type pdfTransferKey struct { - transfer *pdfgo.TransferFunction - halftone *pdfgo.Halftone - model pdfgo.Name -} - -// resolveTransfer 在页面内复用输出设备的有效函数,不修改原始图形状态 +// resolveTransfer 无设备函数时保留无缓存快速路径,按需绑定单页解析缓存 // 入参: style 原始图形状态, model 原生设备颜色空间 // 返回: *pdfgo.TransferFunction 有效函数, error 网屏传递函数错误 func (p *pdfImporter) resolveTransfer(style pdfgo.Style, model pdfgo.Name) (*pdfgo.TransferFunction, error) { if style.Halftone == nil && style.Transfer == nil { return nil, nil } - if model == "" { - model = "DeviceRGB" - } - cache := p.compositingCache() - if cache.transfers == nil { - cache.transfers = make(map[pdfTransferKey]*pdfgo.TransferFunction) - } - key := pdfTransferKey{style.Transfer, style.Halftone, model} - transfer, found := cache.transfers[key] - if !found { - var err error - transfer, err = p.reader.DeviceTransfer(style.Transfer, style.Halftone, model) - if err != nil { - return nil, err - } - cache.transfers[key] = transfer - } - return transfer, nil + return p.compositingCache().transfers.Resolve(p.reader, style, model) } // compositeHasTransfer 检查最终设备使用的通道,不将备用通道误判为有效函数 @@ -134,22 +110,6 @@ func (c *pdfCompositor) selectTransfer(node pdfCompositeNode, outline bool) (*pd return transfer, nil } -// pdfPaintChannels 选择兼容叠印实际使用源颜色的通道 -// 入参: values 源四色分量, compatible 是否保留零分量背景, marked 指定通道,nil表示全部 -// 返回: [4]bool 各通道是否取源颜色 -func pdfPaintChannels(values [4]float64, compatible bool, marked *[4]bool) [4]bool { - channels := [4]bool{true, true, true, true} - if marked != nil { - channels = *marked - } - if compatible { - for i := range channels { - channels[i] = channels[i] && values[i] != 0 - } - } - return channels -} - // opaqueTransfer 复用图案不透明性检查,图案内部函数不提前作用于源颜色 // 返回: bool 是否允许使用外层设备函数, error 单元解析错误 func (p *pdfCompositePattern) opaqueTransfer() (bool, error) { diff --git a/ofdgo_raster_image.go b/ofdgo_raster_image.go index 09def0a..204f495 100644 --- a/ofdgo_raster_image.go +++ b/ofdgo_raster_image.go @@ -16,16 +16,11 @@ package ofdgo import ( "image" - "math" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" "golang.org/x/image/draw" - "golang.org/x/image/math/f64" ) -// rasterScaleBufferLimit 限制整像素缩放器的浮点中间缓冲,超出时使用逐行采样 -const rasterScaleBufferLimit = 8 << 20 - // PixelTransform 将页面毫米变换转换为像素变换,保留尺寸取整后的原点位置 // 入参: m 局部到页面变换, height 目标像素高度 // 返回: RasterMatrix 局部到像素变换 @@ -38,56 +33,13 @@ func (p *RasterPage) PixelTransform(m RasterMatrix, height int) RasterMatrix { return m } -// drawRasterImage 按页面变换对图像执行高质量采样 -// 入参: dst 目标图像, src 源图像, m 像素坐标变换 -func drawRasterImage(dst draw.Image, src image.Image, m RasterMatrix) { - drawRasterImagePixels(dst, src, m, draw.Over) -} - -// drawRasterImagePixels 对轴向缩小使用可分离采样,其余变换保留通用路径 -// 入参: dst 目标图像, src 源图像, m 像素变换, op 合成操作 -func drawRasterImagePixels(dst draw.Image, src image.Image, m RasterMatrix, op draw.Op) { - b := src.Bounds() - if dr, ok := rasterScaleBounds(b, m); ok { - if dr.Intersect(dst.Bounds()).Empty() { - return - } - draw.CatmullRom.Scale(dst, dr, src, b, op, nil) - return - } - if drawRasterAxisImage(dst, src, m, op) { - return - } - draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, src, b, op, nil) -} - -// rasterScaleBounds 检查变换是否可无几何取整地使用缩放器,并限制其中间缓冲 -// 入参: b 源边界, m 像素变换 -// 返回: image.Rectangle 缩放边界, bool 是否适用 -func rasterScaleBounds(b image.Rectangle, m RasterMatrix) (image.Rectangle, bool) { - if b.Empty() || m[1] != 0 || m[2] != 0 || m[0] <= 0 || m[0] > 1 || m[3] <= 0 || m[3] > 1 || m[0] == 1 && m[3] == 1 { - return image.Rectangle{}, false - } - x0, y0 := float64(m[0]*float64(b.Min.X))+m[4], float64(m[3]*float64(b.Min.Y))+m[5] - x1, y1 := float64(m[0]*float64(b.Max.X))+m[4], float64(m[3]*float64(b.Max.Y))+m[5] - for _, v := range [4]float64{x0, y0, x1, y1} { - if v != math.Floor(v) || v < math.MinInt32 || v > math.MaxInt32 { - return image.Rectangle{}, false - } - } - if x1 <= x0 || y1 <= y0 || b.Dx() > math.MaxInt32 || b.Dy() > math.MaxInt32 || (x1-x0)*float64(b.Dy()) > rasterScaleBufferLimit/32 { - return image.Rectangle{}, false - } - return image.Rect(int(x0), int(y0), int(x1), int(y1)), true -} - // drawRasterImageCommand 使用当前后端生成裁剪蒙版,共用高质量图像采样 // 入参: backend 当前后端, page 页面, dst 目标图像, src 原始图像, command 图像指令, cache 蒙版缓存 // 返回: error 裁剪绘制错误 func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Image, src image.Image, command RasterCommand, cache *renderCache[[32]byte, *image.Alpha]) error { m := page.PixelTransform(command.Transform, dst.Bounds().Dy()) if command.Clip == nil { - drawRasterImage(dst, src, m) + baseimage.DrawPixels(dst, src, m, draw.Over) return nil } mask, err := rasterClipMask(backend, page, command.Clip, cache) @@ -97,31 +49,12 @@ func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Im if mask.Rect.Empty() { return nil } - bounds := mask.Rect.Intersect(dst.Bounds()).Intersect(rasterImageBounds(src.Bounds(), m, dst.Bounds())) + bounds := mask.Rect.Intersect(dst.Bounds()).Intersect(baseimage.TransformBounds(src.Bounds(), m, dst.Bounds())) if bounds.Empty() { return nil } layer := image.NewRGBA64(bounds) - drawRasterImagePixels(layer, baseimage.PixelSource(src), m, draw.Src) + baseimage.DrawPixels(layer, baseimage.PixelSource(src), m, draw.Src) draw.DrawMask(dst, bounds, layer, bounds.Min, mask, bounds.Min, draw.Over) return nil } - -// rasterImageBounds 计算采样器可能写入的像素范围,并在整数转换前裁至目标边界 -// 入参: source 原始像素边界, m 像素变换, target 目标边界 -// 返回: image.Rectangle 受影响的目标像素区域 -func rasterImageBounds(source image.Rectangle, m RasterMatrix, target image.Rectangle) image.Rectangle { - minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1) - for _, p := range [4]image.Point{source.Min, {X: source.Max.X, Y: source.Min.Y}, {X: source.Min.X, Y: source.Max.Y}, source.Max} { - x := math.Floor(float64(m[0]*float64(p.X)) + float64(m[2]*float64(p.Y)) + m[4]) - y := math.Floor(float64(m[1]*float64(p.X)) + float64(m[3]*float64(p.Y)) + m[5]) - minX, minY = math.Min(minX, x), math.Min(minY, y) - maxX, maxY = math.Max(maxX, x+1), math.Max(maxY, y+1) - } - minX, minY = math.Max(minX, float64(target.Min.X)), math.Max(minY, float64(target.Min.Y)) - maxX, maxY = math.Min(maxX, float64(target.Max.X)), math.Min(maxY, float64(target.Max.Y)) - if minX >= maxX || minY >= maxY { - return image.Rectangle{} - } - return image.Rect(int(minX), int(minY), int(maxX), int(maxY)) -} diff --git a/ofdgo_render_text.go b/ofdgo_render_text.go index 850e169..2a8daa4 100644 --- a/ofdgo_render_text.go +++ b/ofdgo_render_text.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + basefont "github.com/xiaoqidun/ofdgo/internal/base/font" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" "math" "strings" @@ -133,7 +134,7 @@ func (r *Renderer) positionText(object TextObject, state RenderState, contentOnl bold := weight >= 700 if definition := r.Reader.fontDefinition(id); definition != nil { bold = bold && !definition.Bold - if definition.FontFile == "" && fontNoSyntheticBold(definition.FontName, definition.FamilyName) { + if definition.FontFile == "" && basefont.NoSyntheticBold(definition.FontName, definition.FamilyName) { bold = false } italic = italic && !definition.Italic diff --git a/ofdgo_renderer_cache_canvas.go b/ofdgo_renderer_cache_canvas.go deleted file mode 100644 index 023878f..0000000 --- a/ofdgo_renderer_cache_canvas.go +++ /dev/null @@ -1,70 +0,0 @@ -// 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 ofdgo - -import ( - "crypto/sha256" - "encoding/binary" - "math" - - "github.com/tdewolff/canvas" -) - -// canvasGeometryDigest 按完整坐标、路径顺序及几何容差计算摘要 -// 入参: paths 非空路径序列 -// 返回: [32]byte 几何摘要 -func canvasGeometryDigest(paths ...*canvas.Path) [32]byte { - hash := sha256.New() - var data [512]byte - binary.LittleEndian.PutUint64(data[:8], math.Float64bits(canvas.Tolerance)) - binary.LittleEndian.PutUint64(data[8:16], math.Float64bits(canvas.Epsilon)) - binary.LittleEndian.PutUint64(data[16:24], math.Float64bits(canvas.BentleyOttmannEpsilon)) - hash.Write(data[:24]) - for _, path := range paths { - values := path.Data() - binary.LittleEndian.PutUint64(data[:8], uint64(len(values))) - hash.Write(data[:8]) - for len(values) > 0 { - count := min(len(values), len(data)/8) - for i, value := range values[:count] { - binary.LittleEndian.PutUint64(data[i*8:], math.Float64bits(value)) - } - hash.Write(data[:count*8]) - values = values[count:] - } - } - var key [32]byte - hash.Sum(key[:0]) - return key -} - -// settleCanvasPath 复用复杂路径的填充规范化结果,返回独立副本 -// 入参: path 页面坐标路径, rule 填充规则 -// 返回: *canvas.Path 规范化填充轮廓 -func (r *Renderer) settleCanvasPath(path *canvas.Path, rule canvas.FillRule) *canvas.Path { - if len(path.Data()) < 256 { - return path.Settle(rule) - } - key := canvasFillKey{digest: canvasGeometryDigest(path), rule: rule} - cache := r.canvasSettledPaths() - if result, ok := cache.get(key); ok { - return result.Copy() - } - result := path.Settle(rule) - if cost := len(result.Data())*8 + 256; cost <= cache.limit { - cache.put(key, result.Copy(), cost) - } - return result -} diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index 1b5a280..c76f8df 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -20,6 +20,7 @@ import ( "slices" "github.com/tdewolff/canvas" + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" ) // clipRenderer 收集图形和文字的绘制轮廓 @@ -262,6 +263,117 @@ func intersectClipPath(parent, current *canvas.Path) *canvas.Path { return parent.And(current) } +// intersectConvexCanvasPaths 逐边裁剪单个凸多边形,避免仿射矩形共边进入曲线扫线 +// 入参: left 左侧路径, right 右侧路径 +// 返回: *canvas.Path 交集路径, bool 是否适用凸多边形裁剪 +func intersectConvexCanvasPaths(left, right *canvas.Path) (*canvas.Path, bool) { + a, ok := convexCanvasPolygon(left) + if !ok { + return nil, false + } + b, ok := convexCanvasPolygon(right) + if !ok { + return nil, false + } + return intersectCanvasPolygons(a, b) +} + +// intersectLinearCanvasContours 精确裁剪直线轮廓,不展开曲线或改变填充绕数 +// 入参: path 绘制路径, clip 单个凸裁剪窗口 +// 返回: *canvas.Path 交集路径, bool 是否适用直线轮廓裁剪 +func intersectLinearCanvasContours(path, clip *canvas.Path) (*canvas.Path, bool) { + if path == nil { + return nil, false + } + data := path.Data() + for i := 0; i < len(data); i += 4 { + if i+4 > len(data) || data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd { + return nil, false + } + } + return intersectCanvasContours(path, clip) +} + +// intersectCanvasContours 按凸窗口裁剪闭合轮廓,保留绕向、孔洞和重叠透明度 +// 入参: path 绘制路径, clip 单个凸裁剪路径 +// 返回: *canvas.Path 交集路径, bool 是否适用分段裁剪 +func intersectCanvasContours(path, clip *canvas.Path) (*canvas.Path, bool) { + if path == nil { + return nil, false + } + b, ok := convexCanvasPolygon(clip) + if !ok { + return nil, false + } + parts := path.Split() + polygons := make([][]canvas.Point, 0, len(parts)) + for _, part := range parts { + part = part.Flatten(canvas.Tolerance) + if !part.Closed() { + return nil, false + } + a := part.Coords() + if len(a) < 4 || a[0] != a[len(a)-1] { + continue + } + for _, point := range a { + if !finite(point.X) || !finite(point.Y) { + return nil, false + } + } + polygons = append(polygons, a[:len(a)-1]) + } + result := &canvas.Path{} + for _, polygon := range polygons { + part, ok := intersectCanvasPolygons(polygon, b) + if !ok { + return nil, false + } + result = result.Append(part) + } + return result, true +} + +// intersectCanvasPolygons 按凸窗口逐边裁剪多边形,保留原顶点绕向 +// 入参: a 绘制顶点, b 裁剪顶点 +// 返回: *canvas.Path 交集路径, bool 是否满足数值精度 +func intersectCanvasPolygons(a, b []canvas.Point) (*canvas.Path, bool) { + a, ok := basegeom.ClipPolygon(a, b) + if !ok { + return nil, false + } + result := &canvas.Path{} + if len(a) >= 3 { + result.MoveTo(a[0].X, a[0].Y) + for _, p := range a[1:] { + result.LineTo(p.X, p.Y) + } + result.Close() + } + return result, true +} + +// convexCanvasPolygon 检查单个闭合直线轮廓的所有顶点位于各边的同一侧 +// 入参: path 路径 +// 返回: []canvas.Point 独立顶点, bool 是否为非退化凸多边形 +func convexCanvasPolygon(path *canvas.Path) ([]canvas.Point, bool) { + if path == nil || path.HasSubpaths() || !path.Closed() { + return nil, false + } + data := path.Data() + for i := 0; i < len(data); i += 4 { + if i+4 > len(data) || data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd { + return nil, false + } + } + points := path.Coords() + if len(points) < 4 || points[0] != points[len(points)-1] { + return nil, false + } + points = points[:len(points)-1] + return basegeom.ConvexPolygon(points) +} + // unionClipPath 合并裁剪区域 // 入参: left 左侧裁剪路径, right 右侧裁剪路径 // 返回: *canvas.Path 合并后的裁剪路径 diff --git a/ofdgo_renderer_clip_convex_canvas.go b/ofdgo_renderer_clip_convex_canvas.go deleted file mode 100644 index b5411d0..0000000 --- a/ofdgo_renderer_clip_convex_canvas.go +++ /dev/null @@ -1,222 +0,0 @@ -// 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 ofdgo - -import ( - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" - "math" - - "github.com/tdewolff/canvas" -) - -// intersectConvexCanvasPaths 逐边裁剪单个凸多边形,避免仿射矩形共边进入曲线扫线 -// 入参: left 左侧路径, right 右侧路径 -// 返回: *canvas.Path 交集路径, bool 是否适用凸多边形裁剪 -func intersectConvexCanvasPaths(left, right *canvas.Path) (*canvas.Path, bool) { - a, ok := convexCanvasPolygon(left) - if !ok { - return nil, false - } - b, ok := convexCanvasPolygon(right) - if !ok { - return nil, false - } - return intersectCanvasPolygons(a, b) -} - -// intersectLinearCanvasContours 精确裁剪直线轮廓,不展开曲线或改变填充绕数 -// 入参: path 绘制路径, clip 单个凸裁剪窗口 -// 返回: *canvas.Path 交集路径, bool 是否适用直线轮廓裁剪 -func intersectLinearCanvasContours(path, clip *canvas.Path) (*canvas.Path, bool) { - if path == nil { - return nil, false - } - data := path.Data() - for i := 0; i < len(data); i += 4 { - if i+4 > len(data) || data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd { - return nil, false - } - } - return intersectCanvasContours(path, clip) -} - -// intersectCanvasContours 按凸窗口裁剪闭合轮廓,保留绕向、孔洞和重叠透明度 -// 入参: path 绘制路径, clip 单个凸裁剪路径 -// 返回: *canvas.Path 交集路径, bool 是否适用分段裁剪 -func intersectCanvasContours(path, clip *canvas.Path) (*canvas.Path, bool) { - if path == nil { - return nil, false - } - b, ok := convexCanvasPolygon(clip) - if !ok { - return nil, false - } - parts := path.Split() - polygons := make([][]canvas.Point, 0, len(parts)) - for _, part := range parts { - part = part.Flatten(canvas.Tolerance) - if !part.Closed() { - return nil, false - } - a := part.Coords() - if len(a) < 4 || a[0] != a[len(a)-1] { - continue - } - for _, point := range a { - if !finite(point.X) || !finite(point.Y) { - return nil, false - } - } - polygons = append(polygons, a[:len(a)-1]) - } - result := &canvas.Path{} - for _, polygon := range polygons { - part, ok := intersectCanvasPolygons(polygon, b) - if !ok { - return nil, false - } - result = result.Append(part) - } - return result, true -} - -// intersectCanvasPolygons 按凸窗口逐边裁剪多边形,保留原顶点绕向 -// 入参: a 绘制顶点, b 裁剪顶点 -// 返回: *canvas.Path 交集路径, bool 是否满足数值精度 -func intersectCanvasPolygons(a, b []canvas.Point) (*canvas.Path, bool) { - for i, start := range b { - end := b[(i+1)%len(b)] - dx, dy := end.X-start.X, end.Y-start.Y - length := math.Hypot(dx, dy) - if length == 0 { - continue - } - dx, dy = dx/length, dy/length - distance := func(p canvas.Point) float64 { return convexCanvasDistance(start, p, dx, dy) } - sign := 1.0 - for _, p := range b { - if d := distance(p); d != 0 { - sign = math.Copysign(1, d) - break - } - } - if len(a) == 0 { - break - } - var clipped []canvas.Point - appendPoint := func(p canvas.Point) { - if len(clipped) == 0 || p != clipped[len(clipped)-1] { - clipped = append(clipped, p) - } - } - previous := a[len(a)-1] - previousDistance := sign * distance(previous) - if !finite(previousDistance) { - return nil, false - } - for _, p := range a { - d := sign * distance(p) - if !finite(d) { - return nil, false - } - if (d >= 0) != (previousDistance >= 0) { - scale := math.Max(math.Abs(d), math.Abs(previousDistance)) - fraction := (previousDistance / scale) / (previousDistance/scale - d/scale) - intersection := basegeom.Lerp(Point{X: previous.X, Y: previous.Y}, Point{X: p.X, Y: p.Y}, fraction) - if dx == 0 { - intersection.X = start.X - } - if dy == 0 { - intersection.Y = start.Y - } - appendPoint(canvas.Point{X: intersection.X, Y: intersection.Y}) - } - if d >= 0 { - appendPoint(p) - } - previous, previousDistance = p, d - } - if len(clipped) > 1 && clipped[0] == clipped[len(clipped)-1] { - clipped = clipped[:len(clipped)-1] - } - a = clipped - } - result := &canvas.Path{} - if len(a) >= 3 { - result.MoveTo(a[0].X, a[0].Y) - for _, p := range a[1:] { - result.LineTo(p.X, p.Y) - } - result.Close() - } - return result, true -} - -// convexCanvasPolygon 检查单个闭合直线轮廓的所有顶点位于各边的同一侧 -// 入参: path 路径 -// 返回: []canvas.Point 独立顶点, bool 是否为非退化凸多边形 -func convexCanvasPolygon(path *canvas.Path) ([]canvas.Point, bool) { - if path == nil || path.HasSubpaths() || !path.Closed() { - return nil, false - } - data := path.Data() - for i := 0; i < len(data); i += 4 { - if i+4 > len(data) || data[i] != canvas.MoveToCmd && data[i] != canvas.LineToCmd && data[i] != canvas.CloseCmd { - return nil, false - } - } - points := path.Coords() - if len(points) < 4 || points[0] != points[len(points)-1] { - return nil, false - } - points = points[:len(points)-1] - positive := false - for i, start := range points { - end := points[(i+1)%len(points)] - dx, dy := end.X-start.X, end.Y-start.Y - length := math.Hypot(dx, dy) - if !finite(length) { - return nil, false - } - if length == 0 { - continue - } - dx, dy = dx/length, dy/length - sign := 0.0 - for _, p := range points { - d := convexCanvasDistance(start, p, dx, dy) - if !finite(d) || sign*d < 0 { - return nil, false - } - if d != 0 { - sign, positive = math.Copysign(1, d), true - } - } - } - return points, positive -} - -// convexCanvasDistance 按浮点乘法误差界识别共线点,不使用固定坐标容差 -// 入参: start 边起点, point 待测顶点, dx 横向单位分量, dy 纵向单位分量 -// 返回: float64 顶点到边的有向距离 -func convexCanvasDistance(start, point canvas.Point, dx, dy float64) float64 { - x, y := point.X-start.X, point.Y-start.Y - distance := math.FMA(dx, y, -dy*x) - errorBound := (math.Abs(dx*y) + math.Abs(dy*x)) * 0x1p-49 - if finite(errorBound) && math.Abs(distance) <= errorBound { - return 0 - } - return distance -} diff --git a/ofdgo_renderer_state_canvas.go b/ofdgo_renderer_state_canvas.go index 6d57d6b..f9a1202 100644 --- a/ofdgo_renderer_state_canvas.go +++ b/ofdgo_renderer_state_canvas.go @@ -15,7 +15,10 @@ package ofdgo import ( + "crypto/sha256" + "encoding/binary" "image" + "math" "github.com/tdewolff/canvas" basefont "github.com/xiaoqidun/ofdgo/internal/base/font" @@ -152,3 +155,50 @@ func (s *canvasBackendState) resetFonts() { s.textGlyphPathCache = renderCache[textGlyphPathCacheKey, textGlyphPathCacheValue]{limit: 16 << 20} s.fontOutlines = make(map[*canvas.Font]*basefont.Outliner) } + +// canvasGeometryDigest 按完整坐标、路径顺序及几何容差计算摘要 +// 入参: paths 非空路径序列 +// 返回: [32]byte 几何摘要 +func canvasGeometryDigest(paths ...*canvas.Path) [32]byte { + hash := sha256.New() + var data [512]byte + binary.LittleEndian.PutUint64(data[:8], math.Float64bits(canvas.Tolerance)) + binary.LittleEndian.PutUint64(data[8:16], math.Float64bits(canvas.Epsilon)) + binary.LittleEndian.PutUint64(data[16:24], math.Float64bits(canvas.BentleyOttmannEpsilon)) + hash.Write(data[:24]) + for _, path := range paths { + values := path.Data() + binary.LittleEndian.PutUint64(data[:8], uint64(len(values))) + hash.Write(data[:8]) + for len(values) > 0 { + count := min(len(values), len(data)/8) + for i, value := range values[:count] { + binary.LittleEndian.PutUint64(data[i*8:], math.Float64bits(value)) + } + hash.Write(data[:count*8]) + values = values[count:] + } + } + var key [32]byte + hash.Sum(key[:0]) + return key +} + +// settleCanvasPath 复用复杂路径的填充规范化结果,返回独立副本 +// 入参: path 页面坐标路径, rule 填充规则 +// 返回: *canvas.Path 规范化填充轮廓 +func (r *Renderer) settleCanvasPath(path *canvas.Path, rule canvas.FillRule) *canvas.Path { + if len(path.Data()) < 256 { + return path.Settle(rule) + } + key := canvasFillKey{digest: canvasGeometryDigest(path), rule: rule} + cache := r.canvasSettledPaths() + if result, ok := cache.get(key); ok { + return result.Copy() + } + result := path.Settle(rule) + if cost := len(result.Data())*8 + 256; cost <= cache.limit { + cache.put(key, result.Copy(), cost) + } + return result +} diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index 208c411..fe2b896 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/tdewolff/canvas" + basefont "github.com/xiaoqidun/ofdgo/internal/base/font" codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf" ) @@ -138,7 +139,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 embeddedFont := false if of := r.Reader.fontDefinition(fontID); of != nil { embeddedFont = of.FontFile != "" - if syntheticBold && !embeddedFont && fontNoSyntheticBold(of.FontName, of.FamilyName) { + if syntheticBold && !embeddedFont && basefont.NoSyntheticBold(of.FontName, of.FamilyName) { syntheticBold = false } if of.Bold { diff --git a/ofdgo_text.go b/ofdgo_text.go index 884d3da..91081b3 100644 --- a/ofdgo_text.go +++ b/ofdgo_text.go @@ -16,6 +16,7 @@ package ofdgo import ( "context" + "encoding/xml" "math" "strconv" "strings" @@ -340,3 +341,105 @@ func textCodeRunesContext(ctx context.Context, value string) ([]rune, error) { } return runes[:written], nil } + +// UnmarshalXML 解析字形变换,省略的字符数和字形数均取1 +// 入参: decoder XML解码器, start 变换起始节点 +// 返回: error 解码错误 +func (transform *CGTransform) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { + *transform = CGTransform{CodeCount: 1, GlyphCount: 1} + for _, attr := range start.Attr { + if attr.Name.Space != "" { + continue + } + var target *int + switch attr.Name.Local { + case "CodePosition": + target = &transform.CodePosition + case "CodeCount": + target = &transform.CodeCount + case "GlyphCount": + target = &transform.GlyphCount + } + if target != nil { + value, err := strconv.Atoi(strings.TrimSpace(attr.Value)) + if err != nil { + return err + } + *target = value + } + } + return decodeOFDChildren(decoder, start, func(d *xml.Decoder, node xml.StartElement) error { + if node.Name.Local == "Glyphs" { + return d.DecodeElement(&transform.Glyphs, &node) + } + return d.Skip() + }) +} + +// UnmarshalXML 解析文字定位及原文,不使用带外部命名空间的定位属性 +// 入参: decoder XML解码器, start 文字节点 +// 返回: error 解码错误 +func (code *TextCode) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { + type plain TextCode + var value plain + start = ofdXMLStart(start) + if err := decoder.DecodeElement(&value, &start); err != nil { + return err + } + *code = TextCode(value) + return nil +} + +// UnmarshalXML 解析标准文字节点,将各TextCode前的局部字形位置统一为对象内索引 +// 入参: decoder XML解码器, start 对象起始节点 +// 返回: error 解码错误 +func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { + type plain TextObject + var value plain + if err := decodeGraphicAttributes(start, &value); err != nil { + return err + } + offset, pending := 0, 0 + if err := decodeOFDChildren(decoder, start, func(d *xml.Decoder, node xml.StartElement) error { + switch node.Name.Local { + case "FillColor": + return d.DecodeElement(&value.FillColor, &node) + case "StrokeColor": + return d.DecodeElement(&value.StrokeColor, &node) + case "Clips": + return d.DecodeElement(&value.Clips, &node) + case "Actions": + return decodeOFDActions(d, node, &value.Actions) + case "CGTransform": + return d.DecodeElement(&value.CGTransform, &node) + case "TextCode": + var code TextCode + if err := d.DecodeElement(&code, &node); err != nil { + return err + } + for i := pending; i < len(value.CGTransform); i++ { + value.CGTransform[i].CodePosition += offset + } + pending = len(value.CGTransform) + offset += len(textCodeRunes(code.Value)) + value.TextCode = append(value.TextCode, code) + return nil + } + return d.Skip() + }); err != nil { + return err + } + *obj = TextObject(value) + for _, attr := range ofdXMLStart(start).Attr { + if attr.Name.Local == "LineWidth" { + obj.LineWidthSet = obj.LineWidth == 0 + } + if attr.Name.Local == "MiterLimit" { + obj.MiterLimitSet = obj.MiterLimit == 0 + } + if attr.Name.Local == "DashPattern" { + obj.dashPatternSet = obj.DashPattern == "" + } + } + return nil +} diff --git a/ofdgo_text_xml.go b/ofdgo_text_xml.go deleted file mode 100644 index 682e789..0000000 --- a/ofdgo_text_xml.go +++ /dev/null @@ -1,123 +0,0 @@ -// 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 ofdgo - -import ( - "encoding/xml" - "strconv" - "strings" -) - -// UnmarshalXML 解析字形变换,省略的字符数和字形数均取1 -// 入参: decoder XML解码器, start 变换起始节点 -// 返回: error 解码错误 -func (transform *CGTransform) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { - *transform = CGTransform{CodeCount: 1, GlyphCount: 1} - for _, attr := range start.Attr { - if attr.Name.Space != "" { - continue - } - var target *int - switch attr.Name.Local { - case "CodePosition": - target = &transform.CodePosition - case "CodeCount": - target = &transform.CodeCount - case "GlyphCount": - target = &transform.GlyphCount - } - if target != nil { - value, err := strconv.Atoi(strings.TrimSpace(attr.Value)) - if err != nil { - return err - } - *target = value - } - } - return decodeOFDChildren(decoder, start, func(d *xml.Decoder, node xml.StartElement) error { - if node.Name.Local == "Glyphs" { - return d.DecodeElement(&transform.Glyphs, &node) - } - return d.Skip() - }) -} - -// UnmarshalXML 解析文字定位及原文,不使用带外部命名空间的定位属性 -// 入参: decoder XML解码器, start 文字节点 -// 返回: error 解码错误 -func (code *TextCode) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { - type plain TextCode - var value plain - start = ofdXMLStart(start) - if err := decoder.DecodeElement(&value, &start); err != nil { - return err - } - *code = TextCode(value) - return nil -} - -// UnmarshalXML 解析标准文字节点,将各TextCode前的局部字形位置统一为对象内索引 -// 入参: decoder XML解码器, start 对象起始节点 -// 返回: error 解码错误 -func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement) error { - type plain TextObject - var value plain - if err := decodeGraphicAttributes(start, &value); err != nil { - return err - } - offset, pending := 0, 0 - if err := decodeOFDChildren(decoder, start, func(d *xml.Decoder, node xml.StartElement) error { - switch node.Name.Local { - case "FillColor": - return d.DecodeElement(&value.FillColor, &node) - case "StrokeColor": - return d.DecodeElement(&value.StrokeColor, &node) - case "Clips": - return d.DecodeElement(&value.Clips, &node) - case "Actions": - return decodeOFDActions(d, node, &value.Actions) - case "CGTransform": - return d.DecodeElement(&value.CGTransform, &node) - case "TextCode": - var code TextCode - if err := d.DecodeElement(&code, &node); err != nil { - return err - } - for i := pending; i < len(value.CGTransform); i++ { - value.CGTransform[i].CodePosition += offset - } - pending = len(value.CGTransform) - offset += len(textCodeRunes(code.Value)) - value.TextCode = append(value.TextCode, code) - return nil - } - return d.Skip() - }); err != nil { - return err - } - *obj = TextObject(value) - for _, attr := range ofdXMLStart(start).Attr { - if attr.Name.Local == "LineWidth" { - obj.LineWidthSet = obj.LineWidth == 0 - } - if attr.Name.Local == "MiterLimit" { - obj.MiterLimitSet = obj.MiterLimit == 0 - } - if attr.Name.Local == "DashPattern" { - obj.dashPatternSet = obj.DashPattern == "" - } - } - return nil -} diff --git a/ofdgo_version_extract.go b/ofdgo_version_extract.go deleted file mode 100644 index c2bc338..0000000 --- a/ofdgo_version_extract.go +++ /dev/null @@ -1,175 +0,0 @@ -// 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 ofdgo - -import ( - "archive/zip" - "context" - "fmt" -) - -// ExtractVersion 将当前文档的指定版本提取为独立阅读器,可继续编辑或另存,不改变原包 -// 仅保留版本文件清单中的文件并重建单文档入口,不保留版本列表;原签名可能因入口变化而失效 -// 文件内容按需读取,使用期间不得关闭持有输入文件的原阅读器;返回值不关闭输入文件 -// 入参: id 版本标识,空值选择唯一的Current版本,没有默认版本或存在歧义时返回错误 -// 返回: *Reader 版本阅读器, error 选择、读取或文件缺失错误 -func (r *Reader) ExtractVersion(id string) (*Reader, error) { - return r.ExtractVersionContext(context.Background(), id) -} - -// ExtractVersionContext 将指定版本提取为独立阅读器,扫描、读取和解析时响应取消 -// 文件保留范围、入口选择和输入文件生命周期与ExtractVersion相同,取消或失败不改变原包 -// 入参: ctx 取消上下文, id 版本标识,空值选择唯一的Current版本 -// 返回: *Reader 版本阅读器, error 选择、读取或取消错误 -func (r *Reader) ExtractVersionContext(ctx context.Context, id string) (*Reader, error) { - entries, err := r.documentVersionEntriesContext(ctx) - if err != nil { - return nil, err - } - var selected *DocumentVersionInfo - for i := range entries { - if err := ctx.Err(); err != nil { - return nil, err - } - entry := &entries[i] - if id != "" && entry.ID == id || id == "" && entry.Current { - if selected != nil { - return nil, fmt.Errorf("multiple current document versions") - } - selected = entry - } - } - if selected == nil { - if id == "" { - return nil, fmt.Errorf("no current document version") - } - return nil, fmt.Errorf("document version not found: %s", id) - } - version, err := r.readDocumentVersionContext(ctx, selected.ID, selected.Location) - if err != nil { - return nil, fmt.Errorf("version %s: %w", selected.ID, err) - } - files := make(map[string]bool, len(version.Files)) - for _, file := range version.Files { - if err := ctx.Err(); err != nil { - return nil, err - } - if !r.fileNames[file.Location] { - return nil, fmt.Errorf("version %s file not found: %s", selected.ID, file.Location) - } - files[file.Location] = true - } - root, err := r.extractedVersionRoot(ctx, version, files) - if err != nil { - return nil, err - } - next := &Reader{Path: r.Path, files: map[string][]byte{"OFD.xml": root}, encryption: r.encryption} - for name := range files { - if err := ctx.Err(); err != nil { - return nil, err - } - if name != "OFD.xml" { - if data, ok := r.files[name]; ok { - next.files[name] = data - } - } - } - if r.Zip != nil { - next.Zip = &zip.Reader{Comment: r.Zip.Comment} - for _, file := range r.Zip.File { - if err := ctx.Err(); err != nil { - return nil, err - } - name := cleanPackagePath(file.Name) - if name != "OFD.xml" && files[name] && !file.FileInfo().IsDir() { - next.Zip.File = append(next.Zip.File, file) - } - } - } - if err := next.initRootContext(ctx); err != nil { - return nil, err - } - if _, err := next.docStructureContext(ctx); err != nil { - return nil, err - } - return next, nil -} - -// extractedVersionRoot 重建版本副本入口,保留元数据及命名空间,移除其他文档与版本列表 -// 入参: ctx 取消上下文, version 版本描述, files 版本包含的文件 -// 返回: []byte 主入口XML, error 读取、结构或取消错误 -func (r *Reader) extractedVersionRoot(ctx context.Context, version DocumentVersionInfo, files map[string]bool) ([]byte, error) { - data, err := r.readFileViewContext(ctx, "OFD.xml") - if err != nil { - return nil, err - } - root, err := parseEditorXMLContext(ctx, data) - if err != nil { - return nil, err - } - body := root.childAt("DocBody", r.documentIndex) - if body == nil { - return nil, fmt.Errorf("document entry not found: %d", r.documentIndex) - } - var patches []editorXMLPatch - for _, child := range root.children { - if err := ctx.Err(); err != nil { - return nil, err - } - if child.matchesOFD("DocBody") && child != body { - patches = append(patches, editorXMLPatch{start: child.start, end: child.end}) - } - } - entry := body.child("DocRoot") - if entry != nil { - patches = append(patches, editorXMLContent(data, entry, editorFontXMLText(version.DocRoot))) - } - if versions := body.child("Versions"); versions != nil { - var replacement []byte - if entry == nil { - replacement = body.textXML("DocRoot", version.DocRoot) - } - patches = append(patches, editorXMLPatch{versions.start, versions.end, replacement}) - } else if entry == nil { - return nil, fmt.Errorf("document entry not found: %d", r.documentIndex) - } - references := []*editorXML{body.child("Signatures")} - if info := body.child("DocInfo"); info != nil { - references = append(references, info.child("Cover")) - } - for _, node := range references { - if node == nil { - continue - } - name := resolveResourcePath("OFD.xml", "", editorImportText(data, node)) - if files[name] { - patches = append(patches, editorXMLContent(data, node, editorFontXMLText("/"+name))) - } else { - patches = append(patches, editorXMLPatch{start: node.start, end: node.end}) - } - } - data = editorPatchXML(data, patches) - if version.Version != "" { - root, err = parseEditorXMLContext(ctx, data) - if err != nil { - return nil, err - } - data, err = editorXMLAttribute(data, root, "Version", version.Version) - } - if err == nil { - err = ctx.Err() - } - return data, err -} diff --git a/ofdgo_version_reader.go b/ofdgo_version_reader.go index 43aa1c5..a423b33 100644 --- a/ofdgo_version_reader.go +++ b/ofdgo_version_reader.go @@ -15,6 +15,7 @@ package ofdgo import ( + "archive/zip" "context" "fmt" ) @@ -153,3 +154,157 @@ func (r *Reader) versionSignatures(location string) string { } return "" } + +// ExtractVersion 将当前文档的指定版本提取为独立阅读器,可继续编辑或另存,不改变原包 +// 仅保留版本文件清单中的文件并重建单文档入口,不保留版本列表;原签名可能因入口变化而失效 +// 文件内容按需读取,使用期间不得关闭持有输入文件的原阅读器;返回值不关闭输入文件 +// 入参: id 版本标识,空值选择唯一的Current版本,没有默认版本或存在歧义时返回错误 +// 返回: *Reader 版本阅读器, error 选择、读取或文件缺失错误 +func (r *Reader) ExtractVersion(id string) (*Reader, error) { + return r.ExtractVersionContext(context.Background(), id) +} + +// ExtractVersionContext 将指定版本提取为独立阅读器,扫描、读取和解析时响应取消 +// 文件保留范围、入口选择和输入文件生命周期与ExtractVersion相同,取消或失败不改变原包 +// 入参: ctx 取消上下文, id 版本标识,空值选择唯一的Current版本 +// 返回: *Reader 版本阅读器, error 选择、读取或取消错误 +func (r *Reader) ExtractVersionContext(ctx context.Context, id string) (*Reader, error) { + entries, err := r.documentVersionEntriesContext(ctx) + if err != nil { + return nil, err + } + var selected *DocumentVersionInfo + for i := range entries { + if err := ctx.Err(); err != nil { + return nil, err + } + entry := &entries[i] + if id != "" && entry.ID == id || id == "" && entry.Current { + if selected != nil { + return nil, fmt.Errorf("multiple current document versions") + } + selected = entry + } + } + if selected == nil { + if id == "" { + return nil, fmt.Errorf("no current document version") + } + return nil, fmt.Errorf("document version not found: %s", id) + } + version, err := r.readDocumentVersionContext(ctx, selected.ID, selected.Location) + if err != nil { + return nil, fmt.Errorf("version %s: %w", selected.ID, err) + } + files := make(map[string]bool, len(version.Files)) + for _, file := range version.Files { + if err := ctx.Err(); err != nil { + return nil, err + } + if !r.fileNames[file.Location] { + return nil, fmt.Errorf("version %s file not found: %s", selected.ID, file.Location) + } + files[file.Location] = true + } + root, err := r.extractedVersionRoot(ctx, version, files) + if err != nil { + return nil, err + } + next := &Reader{Path: r.Path, files: map[string][]byte{"OFD.xml": root}, encryption: r.encryption} + for name := range files { + if err := ctx.Err(); err != nil { + return nil, err + } + if name != "OFD.xml" { + if data, ok := r.files[name]; ok { + next.files[name] = data + } + } + } + if r.Zip != nil { + next.Zip = &zip.Reader{Comment: r.Zip.Comment} + for _, file := range r.Zip.File { + if err := ctx.Err(); err != nil { + return nil, err + } + name := cleanPackagePath(file.Name) + if name != "OFD.xml" && files[name] && !file.FileInfo().IsDir() { + next.Zip.File = append(next.Zip.File, file) + } + } + } + if err := next.initRootContext(ctx); err != nil { + return nil, err + } + if _, err := next.docStructureContext(ctx); err != nil { + return nil, err + } + return next, nil +} + +// extractedVersionRoot 重建版本副本入口,保留元数据及命名空间,移除其他文档与版本列表 +// 入参: ctx 取消上下文, version 版本描述, files 版本包含的文件 +// 返回: []byte 主入口XML, error 读取、结构或取消错误 +func (r *Reader) extractedVersionRoot(ctx context.Context, version DocumentVersionInfo, files map[string]bool) ([]byte, error) { + data, err := r.readFileViewContext(ctx, "OFD.xml") + if err != nil { + return nil, err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return nil, err + } + body := root.childAt("DocBody", r.documentIndex) + if body == nil { + return nil, fmt.Errorf("document entry not found: %d", r.documentIndex) + } + var patches []editorXMLPatch + for _, child := range root.children { + if err := ctx.Err(); err != nil { + return nil, err + } + if child.matchesOFD("DocBody") && child != body { + patches = append(patches, editorXMLPatch{start: child.start, end: child.end}) + } + } + entry := body.child("DocRoot") + if entry != nil { + patches = append(patches, editorXMLContent(data, entry, editorFontXMLText(version.DocRoot))) + } + if versions := body.child("Versions"); versions != nil { + var replacement []byte + if entry == nil { + replacement = body.textXML("DocRoot", version.DocRoot) + } + patches = append(patches, editorXMLPatch{versions.start, versions.end, replacement}) + } else if entry == nil { + return nil, fmt.Errorf("document entry not found: %d", r.documentIndex) + } + references := []*editorXML{body.child("Signatures")} + if info := body.child("DocInfo"); info != nil { + references = append(references, info.child("Cover")) + } + for _, node := range references { + if node == nil { + continue + } + name := resolveResourcePath("OFD.xml", "", editorImportText(data, node)) + if files[name] { + patches = append(patches, editorXMLContent(data, node, editorFontXMLText("/"+name))) + } else { + patches = append(patches, editorXMLPatch{start: node.start, end: node.end}) + } + } + data = editorPatchXML(data, patches) + if version.Version != "" { + root, err = parseEditorXMLContext(ctx, data) + if err != nil { + return nil, err + } + data, err = editorXMLAttribute(data, root, "Version", version.Version) + } + if err == nil { + err = ctx.Err() + } + return data, err +}