feat(归并文件): 归并文件下沉通用能力
build.yaml / build (push) Successful in 53s

This commit is contained in:
xiaoqidun committed 2026-10-14 10:40:08 +08:00
1 parent d07972346b
commit 70003486b8
37 files changed
+2386 -2509

No files matched your search

+742
View File
@@ -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
}
}
+129
View File
@@ -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
}
+188
View File
@@ -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
}
@@ -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))
}
+188
View File
@@ -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
}
+91 -1
View File
@@ -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
}
-109
View File
@@ -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
}
+40 -40
View File
@@ -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)
}
-174
View File
@@ -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
}
+162
View File
@@ -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()
}
-181
View File
@@ -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()
}
+77 -1
View File
@@ -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
})
}
-93
View File
@@ -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
})
}
+2 -2
View File
@@ -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 {
+36
View File
@@ -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))
}
+12 -393
View File
@@ -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
}
}
+2 -2
View File
@@ -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
}
-435
View File
@@ -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
}
+30 -30
View File
@@ -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
+132
View File
@@ -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
}
-151
View File
@@ -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
}
+12 -17
View File
@@ -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
+25 -147
View File
@@ -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
}
+16 -16
View File
@@ -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)
}
}
+10 -10
View File
@@ -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
+2 -42
View File
@@ -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) {
+3 -70
View File
@@ -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))
}
+2 -1
View File
@@ -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
-70
View File
@@ -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
}
+112
View File
@@ -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 合并后的裁剪路径
-222
View File
@@ -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
}
+50
View File
@@ -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
}
+2 -1
View File
@@ -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 {
+103
View File
@@ -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
}
-123
View File
@@ -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
}
-175
View File
@@ -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
}
+155
View File
@@ -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
}