diff --git a/ofdgo_compression_fonts.go b/ofdgo_compression_fonts.go deleted file mode 100644 index 64a9ba8..0000000 --- a/ofdgo_compression_fonts.go +++ /dev/null @@ -1,110 +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" - "encoding/xml" - "strings" -) - -// compressionFontUsage 按文档合并共享字体用字,版本编辑仅统计选中入口 -// 入参: ctx 取消上下文, documents 文档入口, resources 资源索引, references 各文件引用, read 输出文件读取方法 -// 返回: map[string]*editorFontUsage 各字体文件的用字,无法确定作用域时为空, error 读取或取消错误 -func (r *Reader) compressionFontUsage(ctx context.Context, documents map[string]bool, resources []string, references map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) { - selected := "" - if r.OFD.DocBody[r.documentIndex].versioned { - var err error - selected, err = r.DocumentRootContext(ctx) - if err != nil { - return nil, err - } - } - scoped, err := resourceReferenceScopes(ctx, documents, resources, references, selected) - if err != nil || scoped == nil { - return nil, err - } - return resourceFontUsage(ctx, resources, scoped, read) -} - -// resourceFontUsage 按已确认的文档作用域合并各字体文件的字符和字形引用 -// 入参: ctx 取消上下文, resources 资源索引, scoped 各索引的用字范围, read 输出文件读取方法 -// 返回: map[string]*editorFontUsage 各字体文件的用字, error 读取或取消错误 -func resourceFontUsage(ctx context.Context, resources []string, scoped map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) { - result := make(map[string]*editorFontUsage) - protected := make(map[string]bool) - for _, name := range resources { - if err := ctx.Err(); err != nil { - return nil, err - } - refs := scoped[name] - if refs == nil { - continue - } - data, err := read(name) - if err != nil { - return nil, err - } - root, err := parseEditorXMLContext(ctx, data) - if err != nil { - return nil, err - } - for font := range editorResourceNodes(root, "Fonts", "Font") { - if err := ctx.Err(); err != nil { - return nil, err - } - file := font.child("FontFile") - if file == nil { - continue - } - var value string - if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil { - return nil, err - } - location := editorResourceLocation(name, root.attr("BaseLoc"), value) - alternate := cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value)) - id, charset := editorResourceID(font.attr("ID")), font.attr("Charset") - unsafe := id == "" || charset != "" && !strings.EqualFold(charset, "unicode") || location != alternate - for i, path := range [2]string{location, alternate} { - if i == 1 && path == location { - continue - } - if result[path] == nil { - result[path] = newEditorFontUsage() - } - result[path].merge(refs.fonts[id], unsafe) - } - } - for media := range editorResourceNodes(root, "MultiMedias", "MultiMedia") { - file := media.child("MediaFile") - if file == nil { - continue - } - var value string - if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil { - return nil, err - } - for _, location := range []string{editorResourceLocation(name, root.attr("BaseLoc"), value), cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))} { - protected[location] = true - } - } - } - for location := range protected { - if usage := result[location]; usage != nil { - usage.unsafe = true - } - } - return result, ctx.Err() -} diff --git a/ofdgo_compression_profiles.go b/ofdgo_compression_profiles.go deleted file mode 100644 index cf5edf3..0000000 --- a/ofdgo_compression_profiles.go +++ /dev/null @@ -1,136 +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" - "fmt" - "maps" - "slices" -) - -// editorProfileReference 记录颜色配置文件所在的资源索引及颜色空间标识 -type editorProfileReference struct { - index string - id string -} - -// compactResourceProfiles 合并相同颜色配置文件,保留颜色空间声明和独立文件引用 -// 入参: ctx 取消上下文, reader 原包,可为空, parts 输出改动, profiles 配置文件引用, protected 不可替换的文件, removed 待移除文件 -// 返回: error 读取、改写或取消错误 -func compactResourceProfiles(ctx context.Context, reader *Reader, parts map[string][]byte, profiles map[string][]editorProfileReference, protected, removed map[string]bool) error { - if len(profiles) < 2 { - return ctx.Err() - } - seen := make(map[[32]byte]string) - aliases := make(map[string]string) - indexes := make(map[string]map[string]string) - ordered := slices.Sorted(maps.Keys(profiles)) - sizes := make(map[string]uint64, len(profiles)) - counts := make(map[uint64]int) - for _, name := range ordered { - if err := ctx.Err(); err != nil { - return err - } - if protected[name] { - continue - } - size, err := outputResourceSize(reader, parts, name) - if err != nil { - return err - } - sizes[name] = size - counts[size]++ - } - var buffer []byte - for _, name := range ordered { - if err := ctx.Err(); err != nil { - return err - } - if protected[name] || counts[sizes[name]] < 2 { - continue - } - if buffer == nil { - buffer = make([]byte, 32*1024) - } - key, err := outputResourceDigest(ctx, reader, parts, name, buffer) - if err != nil { - return err - } - first, found := seen[key] - if !found { - seen[key] = name - continue - } - aliases[name] = first - for _, reference := range profiles[name] { - if indexes[reference.index] == nil { - indexes[reference.index] = make(map[string]string) - } - indexes[reference.index][reference.id] = first - } - } - updates := make(map[string][]byte, len(indexes)) - for _, name := range slices.Sorted(maps.Keys(indexes)) { - if err := ctx.Err(); err != nil { - return err - } - data, ok := parts[name] - if !ok { - if reader == nil { - return fmt.Errorf("file not found: %s", name) - } - var err error - data, err = reader.readFileViewContext(ctx, name) - if err != nil { - return err - } - } - root, err := parseEditorXMLContext(ctx, data) - if err != nil { - return err - } - var patches []editorXMLPatch - for _, group := range root.children { - if !group.matchesOFD("ColorSpaces") { - continue - } - for _, node := range group.children { - if err := ctx.Err(); err != nil { - return err - } - location := indexes[name][editorResourceID(node.attr("ID"))] - if !node.matchesOFD("ColorSpace") || location == "" { - continue - } - patch, err := editorXMLAttributePatch(data, node, "Profile", "/"+location) - if err != nil { - return err - } - patches = append(patches, patch) - } - } - updates[name] = editorPatchXML(data, patches) - } - if err := ctx.Err(); err != nil { - return err - } - maps.Copy(parts, updates) - for name := range aliases { - removed[name] = true - delete(parts, name) - } - return nil -} diff --git a/ofdgo_compression_resources.go b/ofdgo_compression_resources.go index 560ef5f..b982c2c 100644 --- a/ofdgo_compression_resources.go +++ b/ofdgo_compression_resources.go @@ -35,6 +35,12 @@ type outputImageKey struct { size image.Point } +// editorProfileReference 记录颜色配置文件所在的资源索引及颜色空间标识 +type editorProfileReference struct { + index string + id string +} + // compressResourceParts 优化输出图片并合并相同资源文件,保留资源标识及各自属性 // 入参: parts 输出改动, reader 原包,可为空, removed 已移除条目 // 返回: error 读取或取消错误 @@ -470,3 +476,200 @@ func outputResourceDigest(ctx context.Context, reader *Reader, parts map[string] } return [32]byte(hash.Sum(nil)), ctx.Err() } + +// compressionFontUsage 按文档合并共享字体用字,版本编辑仅统计选中入口 +// 入参: ctx 取消上下文, documents 文档入口, resources 资源索引, references 各文件引用, read 输出文件读取方法 +// 返回: map[string]*editorFontUsage 各字体文件的用字,无法确定作用域时为空, error 读取或取消错误 +func (r *Reader) compressionFontUsage(ctx context.Context, documents map[string]bool, resources []string, references map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) { + selected := "" + if r.OFD.DocBody[r.documentIndex].versioned { + var err error + selected, err = r.DocumentRootContext(ctx) + if err != nil { + return nil, err + } + } + scoped, err := resourceReferenceScopes(ctx, documents, resources, references, selected) + if err != nil || scoped == nil { + return nil, err + } + return resourceFontUsage(ctx, resources, scoped, read) +} + +// resourceFontUsage 按已确认的文档作用域合并各字体文件的字符和字形引用 +// 入参: ctx 取消上下文, resources 资源索引, scoped 各索引的用字范围, read 输出文件读取方法 +// 返回: map[string]*editorFontUsage 各字体文件的用字, error 读取或取消错误 +func resourceFontUsage(ctx context.Context, resources []string, scoped map[string]*editorResourceRefs, read func(string) ([]byte, error)) (map[string]*editorFontUsage, error) { + result := make(map[string]*editorFontUsage) + protected := make(map[string]bool) + for _, name := range resources { + if err := ctx.Err(); err != nil { + return nil, err + } + refs := scoped[name] + if refs == nil { + continue + } + data, err := read(name) + if err != nil { + return nil, err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return nil, err + } + for font := range editorResourceNodes(root, "Fonts", "Font") { + if err := ctx.Err(); err != nil { + return nil, err + } + file := font.child("FontFile") + if file == nil { + continue + } + var value string + if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil { + return nil, err + } + location := editorResourceLocation(name, root.attr("BaseLoc"), value) + alternate := cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value)) + id, charset := editorResourceID(font.attr("ID")), font.attr("Charset") + unsafe := id == "" || charset != "" && !strings.EqualFold(charset, "unicode") || location != alternate + for i, path := range [2]string{location, alternate} { + if i == 1 && path == location { + continue + } + if result[path] == nil { + result[path] = newEditorFontUsage() + } + result[path].merge(refs.fonts[id], unsafe) + } + } + for media := range editorResourceNodes(root, "MultiMedias", "MultiMedia") { + file := media.child("MediaFile") + if file == nil { + continue + } + var value string + if err := xml.Unmarshal(data[file.start:file.end], &value); err != nil { + return nil, err + } + for _, location := range []string{editorResourceLocation(name, root.attr("BaseLoc"), value), cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))} { + protected[location] = true + } + } + } + for location := range protected { + if usage := result[location]; usage != nil { + usage.unsafe = true + } + } + return result, ctx.Err() +} + +// compactResourceProfiles 合并相同颜色配置文件,保留颜色空间声明和独立文件引用 +// 入参: ctx 取消上下文, reader 原包,可为空, parts 输出改动, profiles 配置文件引用, protected 不可替换的文件, removed 待移除文件 +// 返回: error 读取、改写或取消错误 +func compactResourceProfiles(ctx context.Context, reader *Reader, parts map[string][]byte, profiles map[string][]editorProfileReference, protected, removed map[string]bool) error { + if len(profiles) < 2 { + return ctx.Err() + } + seen := make(map[[32]byte]string) + aliases := make(map[string]string) + indexes := make(map[string]map[string]string) + ordered := slices.Sorted(maps.Keys(profiles)) + sizes := make(map[string]uint64, len(profiles)) + counts := make(map[uint64]int) + for _, name := range ordered { + if err := ctx.Err(); err != nil { + return err + } + if protected[name] { + continue + } + size, err := outputResourceSize(reader, parts, name) + if err != nil { + return err + } + sizes[name] = size + counts[size]++ + } + var buffer []byte + for _, name := range ordered { + if err := ctx.Err(); err != nil { + return err + } + if protected[name] || counts[sizes[name]] < 2 { + continue + } + if buffer == nil { + buffer = make([]byte, 32*1024) + } + key, err := outputResourceDigest(ctx, reader, parts, name, buffer) + if err != nil { + return err + } + first, found := seen[key] + if !found { + seen[key] = name + continue + } + aliases[name] = first + for _, reference := range profiles[name] { + if indexes[reference.index] == nil { + indexes[reference.index] = make(map[string]string) + } + indexes[reference.index][reference.id] = first + } + } + updates := make(map[string][]byte, len(indexes)) + for _, name := range slices.Sorted(maps.Keys(indexes)) { + if err := ctx.Err(); err != nil { + return err + } + data, ok := parts[name] + if !ok { + if reader == nil { + return fmt.Errorf("file not found: %s", name) + } + var err error + data, err = reader.readFileViewContext(ctx, name) + if err != nil { + return err + } + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return err + } + var patches []editorXMLPatch + for _, group := range root.children { + if !group.matchesOFD("ColorSpaces") { + continue + } + for _, node := range group.children { + if err := ctx.Err(); err != nil { + return err + } + location := indexes[name][editorResourceID(node.attr("ID"))] + if !node.matchesOFD("ColorSpace") || location == "" { + continue + } + patch, err := editorXMLAttributePatch(data, node, "Profile", "/"+location) + if err != nil { + return err + } + patches = append(patches, patch) + } + } + updates[name] = editorPatchXML(data, patches) + } + if err := ctx.Err(); err != nil { + return err + } + maps.Copy(parts, updates) + for name := range aliases { + removed[name] = true + delete(parts, name) + } + return nil +} diff --git a/ofdgo_convert.go b/ofdgo_convert.go index f1e0fc5..54a102d 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -15,11 +15,17 @@ package ofdgo import ( + "archive/zip" + "bytes" "context" + "errors" "fmt" "io" + "runtime" "slices" "strings" + "sync" + "time" "github.com/xiaoqidun/pdfgo" ) @@ -82,6 +88,26 @@ type ConvertReport struct { Fonts []FontChange `json:"fonts,omitempty"` } +// ConversionPoolOptions 设置共享转换并行度及图像工作集预算 +// Parallelism为0时使用GOMAXPROCS,MemoryLimit为0时不指定固定预算 +// AvailableMemory返回可用于新增任务的字节数,nil表示不探测,回调须支持并发调用 +// 内存预算用于调度,不限制解析器及运行时的实际分配 +type ConversionPoolOptions struct { + Parallelism int + MemoryLimit int64 + AvailableMemory func() int64 +} + +// ConversionPool 协调独立转换和图像任务,配置在创建后不变,可跨会话复用 +type ConversionPool struct { + options ConversionPoolOptions + mu sync.Mutex + changed chan struct{} + active int + images int + memory int64 +} + // convertReader 将取消上下文传递到随机读取 type convertReader struct { ctx context.Context @@ -95,6 +121,9 @@ type convertWriter struct { count *int64 } +// conversionPoolKey 标识单次转换使用的共享预算 +type conversionPoolKey struct{} + // OutputFormats 返回独立的输出格式列表,不依赖WebUI或具体渲染后端 // 返回: []OutputFormat 输出格式列表 func OutputFormats() []OutputFormat { @@ -412,3 +441,158 @@ func (w convertWriter) WriteString(data string) (int, error) { } return n, err } + +// DetectFormat 按文件内容识别PDF或OFD,不读取压缩内容或关闭输入 +// 识别结果不代表文档完整有效,仍需通过对应阅读器解析 +// 入参: input 输入源 +// 返回: string 格式标识pdf或ofd, error 读取错误或不支持的格式 +func DetectFormat(input *Input) (string, error) { + if input == nil || input.Size() <= 0 { + return "", fmt.Errorf("empty document input") + } + size := input.Size() + var header [1024]byte + data := header[:min(size, int64(len(header)))] + n, err := input.ReadAt(data, 0) + if err != nil && err != io.EOF { + return "", err + } + if n != len(data) { + return "", io.ErrUnexpectedEOF + } + if bytes.HasPrefix(data, []byte("%PDF-")) { + return "pdf", nil + } + archive, err := zip.NewReader(input, size) + if err == nil { + reader := &Reader{Zip: archive} + if err := reader.indexPackage(); err != nil { + return "", err + } + if reader.fileNames["OFD.xml"] || reader.fileNames["Encryptions.xml"] { + return "ofd", nil + } + } else { + if !errors.Is(err, zip.ErrFormat) { + return "", err + } + if bytes.Contains(data, []byte("%PDF-")) { + return "pdf", nil + } + } + return "", fmt.Errorf("unsupported input format") +} + +// NewConversionPool 创建共享转换调度器,不启动后台任务 +// 入参: options 并行及内存选项 +// 返回: *ConversionPool 调度器, error 配置错误 +func NewConversionPool(options ConversionPoolOptions) (*ConversionPool, error) { + if options.Parallelism < 0 || options.MemoryLimit < 0 { + return nil, fmt.Errorf("invalid conversion pool options") + } + if options.Parallelism == 0 { + options.Parallelism = runtime.GOMAXPROCS(0) + } + return &ConversionPool{options: options, changed: make(chan struct{})}, nil +} + +// Parallelism 返回同时运行的转换数量上限 +// 返回: int 并行上限 +func (p *ConversionPool) Parallelism() int { return p.options.Parallelism } + +// memoryLimit 读取当前可分配的图像工作量预算 +// 返回: int64 可用字节数,负值表示未设置预算 +func (p *ConversionPool) memoryLimit() int64 { + limit := int64(-1) + if p.options.MemoryLimit > 0 { + limit = p.options.MemoryLimit + } + if p.options.AvailableMemory != nil { + available := max(0, p.options.AvailableMemory()) + if limit < 0 || available < limit { + limit = available + } + } + return limit +} + +// acquire 等待转换或图像预算,超预算图像可独占运行 +// 入参: ctx 取消上下文, image 是否为图像任务, size 图像工作量 +// 返回: error 取消错误 +func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) error { + var timer *time.Timer + defer func() { + if timer != nil { + timer.Stop() + } + }() + for { + if err := ctx.Err(); err != nil { + return err + } + limit := p.memoryLimit() + p.mu.Lock() + allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0) + if image { + allowed = p.images < runtime.GOMAXPROCS(0) && (p.images == 0 || limit < 0 || size <= limit-p.memory) + } + if allowed { + if image { + p.images++ + p.memory += size + } else { + p.active++ + } + p.mu.Unlock() + return nil + } + changed := p.changed + p.mu.Unlock() + var tick <-chan time.Time + if p.options.AvailableMemory != nil { + if timer == nil { + timer = time.NewTimer(250 * time.Millisecond) + } else { + timer.Reset(250 * time.Millisecond) + } + tick = timer.C + } + select { + case <-ctx.Done(): + case <-changed: + case <-tick: + } + if timer != nil { + timer.Stop() + } + } +} + +// release 归还任务预算并唤醒等待者 +// 入参: image 是否为图像任务, size 图像工作量 +func (p *ConversionPool) release(image bool, size int64) { + p.mu.Lock() + defer p.mu.Unlock() + if image { + p.images-- + p.memory -= size + } else { + p.active-- + } + close(p.changed) + p.changed = make(chan struct{}) +} + +// conversionWorkers 按当前文件数量分配内部计算任务 +// 入参: ctx 转换上下文, fallback 非共享模式并行上限 +// 返回: int 内部并行数 +func conversionWorkers(ctx context.Context, fallback int) int { + p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool) + if p == nil { + return min(fallback, runtime.GOMAXPROCS(0)) + } + p.mu.Lock() + active := p.active + p.mu.Unlock() + return max(1, runtime.GOMAXPROCS(0)/max(1, active)) +} diff --git a/ofdgo_convert_pool.go b/ofdgo_convert_pool.go deleted file mode 100644 index 3ebcb41..0000000 --- a/ofdgo_convert_pool.go +++ /dev/null @@ -1,160 +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" - "fmt" - "runtime" - "sync" - "time" -) - -// ConversionPoolOptions 设置共享转换并行度及图像工作集预算 -// Parallelism为0时使用GOMAXPROCS,MemoryLimit为0时不指定固定预算 -// AvailableMemory返回可用于新增任务的字节数,nil表示不探测,回调须支持并发调用 -// 内存预算用于调度,不限制解析器及运行时的实际分配 -type ConversionPoolOptions struct { - Parallelism int - MemoryLimit int64 - AvailableMemory func() int64 -} - -// ConversionPool 协调独立转换和图像任务,配置在创建后不变,可跨会话复用 -type ConversionPool struct { - options ConversionPoolOptions - mu sync.Mutex - changed chan struct{} - active int - images int - memory int64 -} - -// conversionPoolKey 标识单次转换使用的共享预算 -type conversionPoolKey struct{} - -// NewConversionPool 创建共享转换调度器,不启动后台任务 -// 入参: options 并行及内存选项 -// 返回: *ConversionPool 调度器, error 配置错误 -func NewConversionPool(options ConversionPoolOptions) (*ConversionPool, error) { - if options.Parallelism < 0 || options.MemoryLimit < 0 { - return nil, fmt.Errorf("invalid conversion pool options") - } - if options.Parallelism == 0 { - options.Parallelism = runtime.GOMAXPROCS(0) - } - return &ConversionPool{options: options, changed: make(chan struct{})}, nil -} - -// Parallelism 返回同时运行的转换数量上限 -// 返回: int 并行上限 -func (p *ConversionPool) Parallelism() int { return p.options.Parallelism } - -// memoryLimit 读取当前可分配的图像工作量预算 -// 返回: int64 可用字节数,负值表示未设置预算 -func (p *ConversionPool) memoryLimit() int64 { - limit := int64(-1) - if p.options.MemoryLimit > 0 { - limit = p.options.MemoryLimit - } - if p.options.AvailableMemory != nil { - available := max(0, p.options.AvailableMemory()) - if limit < 0 || available < limit { - limit = available - } - } - return limit -} - -// acquire 等待转换或图像预算,超预算图像可独占运行 -// 入参: ctx 取消上下文, image 是否为图像任务, size 图像工作量 -// 返回: error 取消错误 -func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) error { - var timer *time.Timer - defer func() { - if timer != nil { - timer.Stop() - } - }() - for { - if err := ctx.Err(); err != nil { - return err - } - limit := p.memoryLimit() - p.mu.Lock() - allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0) - if image { - allowed = p.images < runtime.GOMAXPROCS(0) && (p.images == 0 || limit < 0 || size <= limit-p.memory) - } - if allowed { - if image { - p.images++ - p.memory += size - } else { - p.active++ - } - p.mu.Unlock() - return nil - } - changed := p.changed - p.mu.Unlock() - var tick <-chan time.Time - if p.options.AvailableMemory != nil { - if timer == nil { - timer = time.NewTimer(250 * time.Millisecond) - } else { - timer.Reset(250 * time.Millisecond) - } - tick = timer.C - } - select { - case <-ctx.Done(): - case <-changed: - case <-tick: - } - if timer != nil { - timer.Stop() - } - } -} - -// release 归还任务预算并唤醒等待者 -// 入参: image 是否为图像任务, size 图像工作量 -func (p *ConversionPool) release(image bool, size int64) { - p.mu.Lock() - defer p.mu.Unlock() - if image { - p.images-- - p.memory -= size - } else { - p.active-- - } - close(p.changed) - p.changed = make(chan struct{}) -} - -// conversionWorkers 按当前文件数量分配内部计算任务 -// 入参: ctx 转换上下文, fallback 非共享模式并行上限 -// 返回: int 内部并行数 -func conversionWorkers(ctx context.Context, fallback int) int { - p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool) - if p == nil { - return min(fallback, runtime.GOMAXPROCS(0)) - } - p.mu.Lock() - active := p.active - p.mu.Unlock() - return max(1, runtime.GOMAXPROCS(0)/max(1, active)) -} diff --git a/ofdgo_crypto.go b/ofdgo_crypto.go index 87c51cb..37746a3 100644 --- a/ofdgo_crypto.go +++ b/ofdgo_crypto.go @@ -18,14 +18,7 @@ import ( "bytes" "crypto" "errors" -) - -// 加密错误可通过errors.Is识别,不包含口令或私钥 -var ( - ErrCredentialsRequired = errors.New("document credentials required") - ErrInvalidCredentials = errors.New("invalid credentials or damaged encrypted document") - ErrUnsupportedEncryption = errors.New("unsupported document encryption") - ErrEncryptionPolicyRequired = errors.New("explicit encryption policy required") + "io" ) // Credentials 解密凭据,UserName用于筛选接收者,留空自动匹配,不参与口令派生;证书采用DER编码 @@ -51,12 +44,6 @@ type CryptoProvider interface { // ReaderOption 阅读器的凭据与密码提供者选项 type ReaderOption func(*readerOptions) -// readerOptions 保存打开文档时的会话参数 -type readerOptions struct { - credentials []Credentials - provider CryptoProvider -} - // EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码 type EncryptionRecipient struct { UserName string @@ -85,12 +72,26 @@ type EncryptionInfo struct { PolicyRequired bool } +// readerOptions 保存打开文档时的会话参数 +type readerOptions struct { + credentials []Credentials + provider CryptoProvider +} + // encryptionState 保存解密来源与可继承的输出策略 type encryptionState struct { info EncryptionInfo options *EncryptionOptions } +// 加密错误可通过errors.Is识别,不包含口令或私钥 +var ( + ErrCredentialsRequired = errors.New("document credentials required") + ErrInvalidCredentials = errors.New("invalid credentials or damaged encrypted document") + ErrUnsupportedEncryption = errors.New("unsupported document encryption") + ErrEncryptionPolicyRequired = errors.New("explicit encryption policy required") +) + // WithCredentials 添加解密凭据,多次调用可解开多层加密 // 入参: credentials 解密凭据 // 返回: ReaderOption 阅读选项 @@ -145,3 +146,69 @@ func cloneEncryptionOptions(options *EncryptionOptions) *EncryptionOptions { } return © } + +// Encryption 获取当前保存策略,不暴露口令或私钥 +// 返回: EncryptionInfo 加密状态 +func (e *Editor) Encryption() EncryptionInfo { + return e.encryption.summary() +} + +// SetEncryption 设置后续保存的加密策略,nil显式选择明文保存 +// 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略 +// 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史 +// 入参: options 加密策略 +// 返回: error 错误信息 +func (e *Editor) SetEncryption(options *EncryptionOptions) error { + if options == nil { + e.encryption = nil + return nil + } + if err := validateEncryptionOptions(*options); err != nil { + return err + } + info := EncryptionInfo{Encrypted: true, Method: "1.1.1", Layers: 1} + if len(options.Password) != 0 { + info.Users = []string{options.UserName} + } else { + info.Method = "1.1.2" + for _, recipient := range options.Recipients { + info.Users = append(info.Users, recipient.UserName) + } + } + e.encryption = &encryptionState{info: info, options: cloneEncryptionOptions(options)} + return nil +} + +// writeEncrypted 在全部准备及加密成功后写出结果,取消不产生输出 +// 输出流自身写入失败时调用方仍需丢弃本次输出 +// 入参: writer 输出流 +// 返回: int64 写入字节数, error 错误信息 +func (e *Editor) writeEncrypted(writer io.Writer) (int64, error) { + if e.encryption.options == nil { + return 0, ErrEncryptionPolicyRequired + } + var plaintext bytes.Buffer + if _, err := e.writePlaintext(&plaintext); err != nil { + return 0, err + } + defer clear(plaintext.Bytes()) + r, err := NewReader(&Input{reader: bytes.NewReader(plaintext.Bytes()), size: int64(plaintext.Len())}) + if err != nil { + return 0, err + } + defer r.Close() + parts, err := r.packageData() + if err != nil { + return 0, err + } + defer func() { + for _, data := range parts { + clear(data) + } + }() + data, err := encryptPackageParts(parts, *e.encryption.options, editorProgress(e.OnWriteProgress)) + if err != nil { + return 0, err + } + return io.Copy(writer, bytes.NewReader(data)) +} diff --git a/ofdgo_crypto_editor.go b/ofdgo_crypto_editor.go deleted file mode 100644 index cb4f34e..0000000 --- a/ofdgo_crypto_editor.go +++ /dev/null @@ -1,86 +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" - "io" -) - -// Encryption 获取当前保存策略,不暴露口令或私钥 -// 返回: EncryptionInfo 加密状态 -func (e *Editor) Encryption() EncryptionInfo { - return e.encryption.summary() -} - -// SetEncryption 设置后续保存的加密策略,nil显式选择明文保存 -// 打开单用户口令或证书加密文档时默认继承原策略;多人口令和多层加密需显式选择输出策略 -// 新的加密密钥与初始向量在每次保存时独立生成,设置不改变文档内容或撤销历史 -// 入参: options 加密策略 -// 返回: error 错误信息 -func (e *Editor) SetEncryption(options *EncryptionOptions) error { - if options == nil { - e.encryption = nil - return nil - } - if err := validateEncryptionOptions(*options); err != nil { - return err - } - info := EncryptionInfo{Encrypted: true, Method: "1.1.1", Layers: 1} - if len(options.Password) != 0 { - info.Users = []string{options.UserName} - } else { - info.Method = "1.1.2" - for _, recipient := range options.Recipients { - info.Users = append(info.Users, recipient.UserName) - } - } - e.encryption = &encryptionState{info: info, options: cloneEncryptionOptions(options)} - return nil -} - -// writeEncrypted 在全部准备及加密成功后写出结果,取消不产生输出 -// 输出流自身写入失败时调用方仍需丢弃本次输出 -// 入参: writer 输出流 -// 返回: int64 写入字节数, error 错误信息 -func (e *Editor) writeEncrypted(writer io.Writer) (int64, error) { - if e.encryption.options == nil { - return 0, ErrEncryptionPolicyRequired - } - var plaintext bytes.Buffer - if _, err := e.writePlaintext(&plaintext); err != nil { - return 0, err - } - defer clear(plaintext.Bytes()) - r, err := NewReader(&Input{reader: bytes.NewReader(plaintext.Bytes()), size: int64(plaintext.Len())}) - if err != nil { - return 0, err - } - defer r.Close() - parts, err := r.packageData() - if err != nil { - return 0, err - } - defer func() { - for _, data := range parts { - clear(data) - } - }() - data, err := encryptPackageParts(parts, *e.encryption.options, editorProgress(e.OnWriteProgress)) - if err != nil { - return 0, err - } - return io.Copy(writer, bytes.NewReader(data)) -} diff --git a/ofdgo_font_metrics.go b/ofdgo_font_metrics.go index eee4164..2614472 100644 --- a/ofdgo_font_metrics.go +++ b/ofdgo_font_metrics.go @@ -15,6 +15,7 @@ package ofdgo import ( + "encoding/binary" "fmt" "sync" @@ -24,6 +25,34 @@ import ( // sfntGlyphBoundsLimit 限制单个字体保留的字形与字号边界数量 const sfntGlyphBoundsLimit = 4096 +// FontResource 保存独立字体数据、标准元信息与只读度量,不包含绘图库类型 +type FontResource struct { + FontMetrics + Data []byte + Font Font + Extension string + CanSubset bool +} + +// FontUsage 描述全包已确认的字体引用,Unsafe禁止裁剪 +type FontUsage struct { + Characters []rune + Glyphs []uint16 + Unsafe bool +} + +// FontResourceBackend 解析和裁剪保存资源,独立于显示字体后端 +// SubsetFont的preserve为true时不得改变字形编号,SubsetSourceFont无安全收益时返回nil +type FontResourceBackend interface { + Backend + OpenFontResource(file FontFile, index int) (*FontResource, error) + SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) + SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) +} + +// SFNTBackend 提供独立的OpenType字体度量、轮廓和资源处理 +type SFNTBackend struct{} + // sfntFontMetrics 保留默认字体度量,写出时不更新原始时间 type sfntFontMetrics struct { *basefont.SFNT @@ -47,6 +76,9 @@ type sfntGlyphSize struct { // glyphGeometry 将字体轮廓转换为纵轴向下的几何路径 type glyphGeometry struct{ path GeometryPath } +// sfntResourceMetrics 隔离资源解析器的度量实现 +type sfntResourceMetrics struct{ *basefont.SFNT } + // MoveTo 开始子路径 // 入参: x 横坐标, y 纵坐标 func (p *glyphGeometry) MoveTo(x, y float64) { @@ -180,3 +212,64 @@ func (f sfntFontMetrics) GlyphOutlines(glyphs []uint16, size float64) ([]Geometr } return result, nil } + +// Name 返回资源后端标识 +// 返回: string 后端标识 +func (SFNTBackend) Name() string { return "sfnt" } + +// OpenFontResource 提取独立字体并读取名称、样式和裁剪能力 +// CFF2使用默认实例的静态轮廓,不改变原字体文件 +// 入参: file 字体文件, index 集合索引 +// 返回: *FontResource 字体资源, error 解析错误 +func (SFNTBackend) OpenFontResource(file FontFile, index int) (*FontResource, error) { + data, err := file.Face(index) + if err != nil { + return nil, err + } + data, err = basefont.DefaultCFF2Font(data) + if err != nil { + return nil, err + } + sfnt, err := basefont.ParseSFNT(data) + if err != nil { + return nil, err + } + name := basefont.SFNTName(sfnt, 6, 4, 1) + if name == "" { + return nil, fmt.Errorf("font has no name") + } + extension := ".ttf" + if sfnt.IsCFF { + extension = ".otf" + } + return &FontResource{ + FontMetrics: sfntResourceMetrics{sfnt}, Data: data, Extension: extension, + Font: Font{FontName: name, FamilyName: basefont.SFNTName(sfnt, 16, 1), Charset: "unicode", Bold: binary.BigEndian.Uint16(sfnt.Tables["head"][44:])&1 != 0, Italic: binary.BigEndian.Uint16(sfnt.Tables["head"][44:])&2 != 0, FixedWidth: len(sfnt.Tables["post"]) >= 16 && binary.BigEndian.Uint32(sfnt.Tables["post"][12:]) != 0}, + CanSubset: basefont.Subsettable(sfnt), + }, nil +} + +// SubsetFont 裁剪新建字体并按要求保留显式字形编号 +// 入参: data 字体数据, glyphs 用字, preserve 是否保留编号 +// 返回: []byte 子集, error 裁剪错误 +func (SFNTBackend) SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) { + return basefont.SubsetSFNT(data, glyphs, preserve) +} + +// SubsetSourceFont 按全包引用裁剪原字体,不确定时保留原资源 +// 入参: data 字体数据, usage 用字信息 +// 返回: []byte 子集, error 裁剪错误 +func (SFNTBackend) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) { + u := &basefont.Usage{Characters: make(map[rune]bool), Glyphs: make(map[uint16]bool), Unsafe: usage.Unsafe} + for _, char := range usage.Characters { + u.Characters[char] = true + } + for _, glyph := range usage.Glyphs { + u.Glyphs[glyph] = true + } + return basefont.SubsetSourceSFNT(data, u), nil +} + +// Write 返回未改动元信息的字体数据 +// 返回: []byte 字体数据 +func (f sfntResourceMetrics) Write() []byte { return f.SFNT.Write() } diff --git a/ofdgo_font_resource.go b/ofdgo_font_resource.go deleted file mode 100644 index 22d6149..0000000 --- a/ofdgo_font_resource.go +++ /dev/null @@ -1,114 +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/binary" - "fmt" - - basefont "github.com/xiaoqidun/ofdgo/internal/base/font" -) - -// FontResource 保存独立字体数据、标准元信息与只读度量,不包含绘图库类型 -type FontResource struct { - FontMetrics - Data []byte - Font Font - Extension string - CanSubset bool -} - -// FontUsage 描述全包已确认的字体引用,Unsafe禁止裁剪 -type FontUsage struct { - Characters []rune - Glyphs []uint16 - Unsafe bool -} - -// FontResourceBackend 解析和裁剪保存资源,独立于显示字体后端 -// SubsetFont的preserve为true时不得改变字形编号,SubsetSourceFont无安全收益时返回nil -type FontResourceBackend interface { - Backend - OpenFontResource(file FontFile, index int) (*FontResource, error) - SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) - SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) -} - -// SFNTBackend 提供独立的OpenType字体度量、轮廓和资源处理 -type SFNTBackend struct{} - -// sfntResourceMetrics 隔离资源解析器的度量实现 -type sfntResourceMetrics struct{ *basefont.SFNT } - -// Name 返回资源后端标识 -// 返回: string 后端标识 -func (SFNTBackend) Name() string { return "sfnt" } - -// OpenFontResource 提取独立字体并读取名称、样式和裁剪能力 -// CFF2使用默认实例的静态轮廓,不改变原字体文件 -// 入参: file 字体文件, index 集合索引 -// 返回: *FontResource 字体资源, error 解析错误 -func (SFNTBackend) OpenFontResource(file FontFile, index int) (*FontResource, error) { - data, err := file.Face(index) - if err != nil { - return nil, err - } - data, err = basefont.DefaultCFF2Font(data) - if err != nil { - return nil, err - } - sfnt, err := basefont.ParseSFNT(data) - if err != nil { - return nil, err - } - name := basefont.SFNTName(sfnt, 6, 4, 1) - if name == "" { - return nil, fmt.Errorf("font has no name") - } - extension := ".ttf" - if sfnt.IsCFF { - extension = ".otf" - } - return &FontResource{ - FontMetrics: sfntResourceMetrics{sfnt}, Data: data, Extension: extension, - Font: Font{FontName: name, FamilyName: basefont.SFNTName(sfnt, 16, 1), Charset: "unicode", Bold: binary.BigEndian.Uint16(sfnt.Tables["head"][44:])&1 != 0, Italic: binary.BigEndian.Uint16(sfnt.Tables["head"][44:])&2 != 0, FixedWidth: len(sfnt.Tables["post"]) >= 16 && binary.BigEndian.Uint32(sfnt.Tables["post"][12:]) != 0}, - CanSubset: basefont.Subsettable(sfnt), - }, nil -} - -// SubsetFont 裁剪新建字体并按要求保留显式字形编号 -// 入参: data 字体数据, glyphs 用字, preserve 是否保留编号 -// 返回: []byte 子集, error 裁剪错误 -func (SFNTBackend) SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) { - return basefont.SubsetSFNT(data, glyphs, preserve) -} - -// SubsetSourceFont 按全包引用裁剪原字体,不确定时保留原资源 -// 入参: data 字体数据, usage 用字信息 -// 返回: []byte 子集, error 裁剪错误 -func (SFNTBackend) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) { - u := &basefont.Usage{Characters: make(map[rune]bool), Glyphs: make(map[uint16]bool), Unsafe: usage.Unsafe} - for _, char := range usage.Characters { - u.Characters[char] = true - } - for _, glyph := range usage.Glyphs { - u.Glyphs[glyph] = true - } - return basefont.SubsetSourceSFNT(data, u), nil -} - -// Write 返回未改动元信息的字体数据 -// 返回: []byte 字体数据 -func (f sfntResourceMetrics) Write() []byte { return f.SFNT.Write() } diff --git a/ofdgo_format.go b/ofdgo_format.go deleted file mode 100644 index 5a9378b..0000000 --- a/ofdgo_format.go +++ /dev/null @@ -1,64 +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" - "bytes" - "errors" - "fmt" - "io" -) - -// DetectFormat 按文件内容识别PDF或OFD,不读取压缩内容或关闭输入 -// 识别结果不代表文档完整有效,仍需通过对应阅读器解析 -// 入参: input 输入源 -// 返回: string 格式标识pdf或ofd, error 读取错误或不支持的格式 -func DetectFormat(input *Input) (string, error) { - if input == nil || input.Size() <= 0 { - return "", fmt.Errorf("empty document input") - } - size := input.Size() - var header [1024]byte - data := header[:min(size, int64(len(header)))] - n, err := input.ReadAt(data, 0) - if err != nil && err != io.EOF { - return "", err - } - if n != len(data) { - return "", io.ErrUnexpectedEOF - } - if bytes.HasPrefix(data, []byte("%PDF-")) { - return "pdf", nil - } - archive, err := zip.NewReader(input, size) - if err == nil { - reader := &Reader{Zip: archive} - if err := reader.indexPackage(); err != nil { - return "", err - } - if reader.fileNames["OFD.xml"] || reader.fileNames["Encryptions.xml"] { - return "ofd", nil - } - } else { - if !errors.Is(err, zip.ErrFormat) { - return "", err - } - if bytes.Contains(data, []byte("%PDF-")) { - return "pdf", nil - } - } - return "", fmt.Errorf("unsupported input format") -} diff --git a/ofdgo_mesh_sampler.go b/ofdgo_mesh_sampler.go deleted file mode 100644 index fe22d68..0000000 --- a/ofdgo_mesh_sampler.go +++ /dev/null @@ -1,120 +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 ( - "image/color" - "math" - "slices" -) - -// MeshSampler 保存独立网格快照和空间索引,支持并发采样 -type MeshSampler struct { - mesh MeshShading - nodes []meshSampleNode -} - -// meshSampleNode 保存连续三角形组的包围盒及右子树位置 -type meshSampleNode struct { - minX, minY, maxX, maxY float64 - right int -} - -// NewSampler 创建网格快照,后续修改原网格不影响采样结果 -// 返回: *MeshSampler 只读采样器 -func (m *MeshShading) NewSampler() *MeshSampler { - s := &MeshSampler{} - if m != nil { - s.mesh = *m - s.mesh.Triangles = slices.Clone(m.Triangles) - if len(m.Triangles) > 8 { - s.build(0, len(m.Triangles)) - } - } - return s -} - -// At 采样快照中的预乘颜色,后绘制的三角形优先 -// 入参: x 横坐标, y 纵坐标 -// 返回: color.RGBA 预乘颜色 -func (s *MeshSampler) At(x, y float64) color.RGBA { - if len(s.nodes) == 0 || !finite(x) || !finite(y) { - return s.mesh.At(x, y) - } - if value, ok := s.sample(0, 0, len(s.mesh.Triangles), x, y); ok { - return value - } - return s.mesh.Background -} - -// build 按绘制顺序构建包围盒层级,不重排原三角形 -// 入参: first 起始三角形, end 结束位置 -// 返回: int 节点位置 -func (s *MeshSampler) build(first, end int) int { - index := len(s.nodes) - s.nodes = append(s.nodes, meshSampleNode{}) - if end-first <= 8 { - node := meshSampleNode{minX: math.Inf(1), minY: math.Inf(1), maxX: math.Inf(-1), maxY: math.Inf(-1)} - for _, triangle := range s.mesh.Triangles[first:end] { - a, b, c := triangle[0].Point, triangle[1].Point, triangle[2].Point - d := (b.Y-c.Y)*(a.X-c.X) + (c.X-b.X)*(a.Y-c.Y) - if !finite(d) { - node = meshSampleNode{minX: math.Inf(-1), minY: math.Inf(-1), maxX: math.Inf(1), maxY: math.Inf(1)} - break - } - for _, vertex := range triangle { - node.minX, node.minY = math.Min(node.minX, vertex.Point.X), math.Min(node.minY, vertex.Point.Y) - node.maxX, node.maxY = math.Max(node.maxX, vertex.Point.X), math.Max(node.maxY, vertex.Point.Y) - } - } - if finite(node.minX) && finite(node.minY) && finite(node.maxX) && finite(node.maxY) { - dx := 2e-12*(node.maxX-node.minX) + 1e-14*math.Max(math.Abs(node.minX), math.Abs(node.maxX)) - dy := 2e-12*(node.maxY-node.minY) + 1e-14*math.Max(math.Abs(node.minY), math.Abs(node.maxY)) - node.minX, node.maxX = math.Nextafter(node.minX-dx, math.Inf(-1)), math.Nextafter(node.maxX+dx, math.Inf(1)) - node.minY, node.maxY = math.Nextafter(node.minY-dy, math.Inf(-1)), math.Nextafter(node.maxY+dy, math.Inf(1)) - } - s.nodes[index] = node - return index - } - middle := first + (end-first)/2 - left := s.build(first, middle) - right := s.build(middle, end) - a, b := s.nodes[left], s.nodes[right] - s.nodes[index] = meshSampleNode{minX: math.Min(a.minX, b.minX), minY: math.Min(a.minY, b.minY), maxX: math.Max(a.maxX, b.maxX), maxY: math.Max(a.maxY, b.maxY), right: right} - return index -} - -// sample 优先查询后绘制的区域,包围盒外跳过整组三角形 -// 入参: index 节点位置, first 起始三角形, end 结束位置, x 横坐标, y 纵坐标 -// 返回: color.RGBA 采样颜色, bool 是否命中 -func (s *MeshSampler) sample(index, first, end int, x, y float64) (color.RGBA, bool) { - node := s.nodes[index] - if x < node.minX || x > node.maxX || y < node.minY || y > node.maxY { - return color.RGBA{}, false - } - if end-first <= 8 { - for i := end - 1; i >= first; i-- { - if value, ok := meshTriangleColor(&s.mesh.Triangles[i], x, y); ok { - return value, true - } - } - return color.RGBA{}, false - } - middle := first + (end-first)/2 - if value, ok := s.sample(node.right, middle, end, x, y); ok { - return value, true - } - return s.sample(index+1, first, middle, x, y) -} diff --git a/ofdgo_paint.go b/ofdgo_paint.go index 63fb69c..d790e2e 100644 --- a/ofdgo_paint.go +++ b/ofdgo_paint.go @@ -30,6 +30,12 @@ type PatternPaint struct { Alpha *int } +// profileGradient 保存独立色标和只读配置,延伸及重复由几何采样器处理 +type profileGradient struct { + positions []float64 + colors []MeshVertex +} + // ResolveColor 按文档颜色空间和调色板解析颜色,返回预乘透明度的RGBA // 入参: value 颜色分量, index 调色板索引, space 颜色空间标识, alpha 透明度 // 返回: color.RGBA 标准颜色, error 颜色定义或配置错误 @@ -394,3 +400,70 @@ func colorToRGBA(c color.Color) color.RGBA { r, g, b, a := c.RGBA() return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} } + +// profileGradient 准备ICC渐变的源分量采样,不用转换后的端点近似非线性色彩变换 +// 入参: segments 原色标, alpha 外层透明度 +// 返回: func(float64) color.RGBA 单位区间采样器,无ICC时为nil, error 配置错误 +func (r *Renderer) profileGradient(segments []ShdSegment, alpha *int) (func(float64) color.RGBA, error) { + profiled := false + for _, segment := range segments { + if space := r.colorDefinition(segment.Color.ColorSpace); space != nil && space.Profile != "" { + profiled = true + break + } + } + if !profiled { + return nil, nil + } + gradient := &profileGradient{positions: gradientPositions(segments), colors: make([]MeshVertex, len(segments))} + for i, segment := range segments { + kind, values, err := r.colorComponents(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace) + if err != nil { + return nil, err + } + profile, err := r.Reader.colorProfile(r.colorDefinition(segment.Color.ColorSpace)) + if err != nil { + return nil, err + } + opacity := 1.0 + if value := mergeAlpha(segment.Color.Alpha, alpha); value != nil { + opacity = float64(clampColor(*value)) / 255 + } + gradient.colors[i] = MeshVertex{Space: kind, Values: values, Alpha: opacity, profile: profile} + } + return gradient.At, nil +} + +// At 在相同源空间内插值,不同空间先转换为sRGB,保留重复位置的右侧色标 +// 入参: t 单位区间位置 +// 返回: color.RGBA 预乘采样颜色 +func (g *profileGradient) At(t float64) color.RGBA { + if len(g.colors) == 0 || math.IsNaN(t) { + return color.RGBA{} + } + first := g.colors[0] + if t < g.positions[0] { + r, green, b := first.rgb(first.Values) + return profileColor([3]float64{r, green, b}, first.Alpha) + } + for i := 1; i < len(g.colors); i++ { + if t < g.positions[i] { + before, after := g.colors[i-1], g.colors[i] + x := (t - g.positions[i-1]) / (g.positions[i] - g.positions[i-1]) + if before.sameSpace(after) { + var values [4]float64 + for j := range values { + values[j] = before.Values[j]*(1-x) + after.Values[j]*x + } + r, green, b := before.rgb(values) + return profileColor([3]float64{r, green, b}, before.Alpha*(1-x)+after.Alpha*x) + } + r0, g0, b0 := before.rgb(before.Values) + r1, g1, b1 := after.rgb(after.Values) + return color.RGBA{meshByte(r0*before.Alpha*(1-x) + r1*after.Alpha*x), meshByte(g0*before.Alpha*(1-x) + g1*after.Alpha*x), meshByte(b0*before.Alpha*(1-x) + b1*after.Alpha*x), meshByte(before.Alpha*(1-x) + after.Alpha*x)} + } + } + last := g.colors[len(g.colors)-1] + r, green, b := last.rgb(last.Values) + return profileColor([3]float64{r, green, b}, last.Alpha) +} diff --git a/ofdgo_paint_mesh.go b/ofdgo_paint_mesh.go index f2f64f7..ec83455 100644 --- a/ofdgo_paint_mesh.go +++ b/ofdgo_paint_mesh.go @@ -19,6 +19,7 @@ import ( "fmt" "image/color" "math" + "slices" "strconv" "strings" ) @@ -61,6 +62,18 @@ type MeshShading struct { Background color.RGBA } +// MeshSampler 保存独立网格快照和空间索引,支持并发采样 +type MeshSampler struct { + mesh MeshShading + nodes []meshSampleNode +} + +// meshSampleNode 保存连续三角形组的包围盒及右子树位置 +type meshSampleNode struct { + minX, minY, maxX, maxY float64 + right int +} + // UnmarshalXML 解析高洛德渐变的控制点和背景色 // 入参: d XML解码器, start 起始节点 // 返回: error 解码错误 @@ -355,3 +368,90 @@ func meshRGB(space string, values [4]float64) (float64, float64, float64) { // 入参: value 分量 // 返回: uint8 量化结果 func meshByte(value float64) uint8 { return uint8(math.Round(math.Max(0, math.Min(1, value)) * 255)) } + +// NewSampler 创建网格快照,后续修改原网格不影响采样结果 +// 返回: *MeshSampler 只读采样器 +func (m *MeshShading) NewSampler() *MeshSampler { + s := &MeshSampler{} + if m != nil { + s.mesh = *m + s.mesh.Triangles = slices.Clone(m.Triangles) + if len(m.Triangles) > 8 { + s.build(0, len(m.Triangles)) + } + } + return s +} + +// At 采样快照中的预乘颜色,后绘制的三角形优先 +// 入参: x 横坐标, y 纵坐标 +// 返回: color.RGBA 预乘颜色 +func (s *MeshSampler) At(x, y float64) color.RGBA { + if len(s.nodes) == 0 || !finite(x) || !finite(y) { + return s.mesh.At(x, y) + } + if value, ok := s.sample(0, 0, len(s.mesh.Triangles), x, y); ok { + return value + } + return s.mesh.Background +} + +// build 按绘制顺序构建包围盒层级,不重排原三角形 +// 入参: first 起始三角形, end 结束位置 +// 返回: int 节点位置 +func (s *MeshSampler) build(first, end int) int { + index := len(s.nodes) + s.nodes = append(s.nodes, meshSampleNode{}) + if end-first <= 8 { + node := meshSampleNode{minX: math.Inf(1), minY: math.Inf(1), maxX: math.Inf(-1), maxY: math.Inf(-1)} + for _, triangle := range s.mesh.Triangles[first:end] { + a, b, c := triangle[0].Point, triangle[1].Point, triangle[2].Point + d := (b.Y-c.Y)*(a.X-c.X) + (c.X-b.X)*(a.Y-c.Y) + if !finite(d) { + node = meshSampleNode{minX: math.Inf(-1), minY: math.Inf(-1), maxX: math.Inf(1), maxY: math.Inf(1)} + break + } + for _, vertex := range triangle { + node.minX, node.minY = math.Min(node.minX, vertex.Point.X), math.Min(node.minY, vertex.Point.Y) + node.maxX, node.maxY = math.Max(node.maxX, vertex.Point.X), math.Max(node.maxY, vertex.Point.Y) + } + } + if finite(node.minX) && finite(node.minY) && finite(node.maxX) && finite(node.maxY) { + dx := 2e-12*(node.maxX-node.minX) + 1e-14*math.Max(math.Abs(node.minX), math.Abs(node.maxX)) + dy := 2e-12*(node.maxY-node.minY) + 1e-14*math.Max(math.Abs(node.minY), math.Abs(node.maxY)) + node.minX, node.maxX = math.Nextafter(node.minX-dx, math.Inf(-1)), math.Nextafter(node.maxX+dx, math.Inf(1)) + node.minY, node.maxY = math.Nextafter(node.minY-dy, math.Inf(-1)), math.Nextafter(node.maxY+dy, math.Inf(1)) + } + s.nodes[index] = node + return index + } + middle := first + (end-first)/2 + left := s.build(first, middle) + right := s.build(middle, end) + a, b := s.nodes[left], s.nodes[right] + s.nodes[index] = meshSampleNode{minX: math.Min(a.minX, b.minX), minY: math.Min(a.minY, b.minY), maxX: math.Max(a.maxX, b.maxX), maxY: math.Max(a.maxY, b.maxY), right: right} + return index +} + +// sample 优先查询后绘制的区域,包围盒外跳过整组三角形 +// 入参: index 节点位置, first 起始三角形, end 结束位置, x 横坐标, y 纵坐标 +// 返回: color.RGBA 采样颜色, bool 是否命中 +func (s *MeshSampler) sample(index, first, end int, x, y float64) (color.RGBA, bool) { + node := s.nodes[index] + if x < node.minX || x > node.maxX || y < node.minY || y > node.maxY { + return color.RGBA{}, false + } + if end-first <= 8 { + for i := end - 1; i >= first; i-- { + if value, ok := meshTriangleColor(&s.mesh.Triangles[i], x, y); ok { + return value, true + } + } + return color.RGBA{}, false + } + middle := first + (end-first)/2 + if value, ok := s.sample(node.right, middle, end, x, y); ok { + return value, true + } + return s.sample(index+1, first, middle, x, y) +} diff --git a/ofdgo_paint_profile.go b/ofdgo_paint_profile.go deleted file mode 100644 index 5ab4fff..0000000 --- a/ofdgo_paint_profile.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "image/color" - "math" -) - -// profileGradient 保存独立色标和只读配置,延伸及重复由几何采样器处理 -type profileGradient struct { - positions []float64 - colors []MeshVertex -} - -// profileGradient 准备ICC渐变的源分量采样,不用转换后的端点近似非线性色彩变换 -// 入参: segments 原色标, alpha 外层透明度 -// 返回: func(float64) color.RGBA 单位区间采样器,无ICC时为nil, error 配置错误 -func (r *Renderer) profileGradient(segments []ShdSegment, alpha *int) (func(float64) color.RGBA, error) { - profiled := false - for _, segment := range segments { - if space := r.colorDefinition(segment.Color.ColorSpace); space != nil && space.Profile != "" { - profiled = true - break - } - } - if !profiled { - return nil, nil - } - gradient := &profileGradient{positions: gradientPositions(segments), colors: make([]MeshVertex, len(segments))} - for i, segment := range segments { - kind, values, err := r.colorComponents(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace) - if err != nil { - return nil, err - } - profile, err := r.Reader.colorProfile(r.colorDefinition(segment.Color.ColorSpace)) - if err != nil { - return nil, err - } - opacity := 1.0 - if value := mergeAlpha(segment.Color.Alpha, alpha); value != nil { - opacity = float64(clampColor(*value)) / 255 - } - gradient.colors[i] = MeshVertex{Space: kind, Values: values, Alpha: opacity, profile: profile} - } - return gradient.At, nil -} - -// At 在相同源空间内插值,不同空间先转换为sRGB,保留重复位置的右侧色标 -// 入参: t 单位区间位置 -// 返回: color.RGBA 预乘采样颜色 -func (g *profileGradient) At(t float64) color.RGBA { - if len(g.colors) == 0 || math.IsNaN(t) { - return color.RGBA{} - } - first := g.colors[0] - if t < g.positions[0] { - r, green, b := first.rgb(first.Values) - return profileColor([3]float64{r, green, b}, first.Alpha) - } - for i := 1; i < len(g.colors); i++ { - if t < g.positions[i] { - before, after := g.colors[i-1], g.colors[i] - x := (t - g.positions[i-1]) / (g.positions[i] - g.positions[i-1]) - if before.sameSpace(after) { - var values [4]float64 - for j := range values { - values[j] = before.Values[j]*(1-x) + after.Values[j]*x - } - r, green, b := before.rgb(values) - return profileColor([3]float64{r, green, b}, before.Alpha*(1-x)+after.Alpha*x) - } - r0, g0, b0 := before.rgb(before.Values) - r1, g1, b1 := after.rgb(after.Values) - return color.RGBA{meshByte(r0*before.Alpha*(1-x) + r1*after.Alpha*x), meshByte(g0*before.Alpha*(1-x) + g1*after.Alpha*x), meshByte(b0*before.Alpha*(1-x) + b1*after.Alpha*x), meshByte(before.Alpha*(1-x) + after.Alpha*x)} - } - } - last := g.colors[len(g.colors)-1] - r, green, b := last.rgb(last.Values) - return profileColor([3]float64{r, green, b}, last.Alpha) -} diff --git a/ofdgo_raster.go b/ofdgo_raster.go index 67b0f24..9422f36 100644 --- a/ofdgo_raster.go +++ b/ofdgo_raster.go @@ -15,10 +15,13 @@ package ofdgo import ( + "context" "fmt" "image" "image/color" "math" + + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" ) // 光栅后端接收的路径指令 @@ -278,3 +281,104 @@ func (g *RasterGradient) colorAt(t float64) color.RGBA { } return g.Stops[len(g.Stops)-1].Color } + +// Bounds 度量已编译指令的页面坐标范围,不重新定位文字或读取图片像素 +// 包含描边与指令裁剪,不应用页面边界,不排除图片透明像素或渐变透明区域 +// 入参: geometry 几何后端 +// 返回: Box 毫米坐标范围, error 几何或指令错误 +func (p *RasterPage) Bounds(geometry GeometryBackend) (Box, error) { + return p.BoundsContext(context.Background(), geometry) +} + +// BoundsContext 按上下文度量只读绘制指令,取消或失败时不返回部分范围 +// 入参: ctx 取消上下文, geometry 几何后端 +// 返回: Box 毫米坐标范围, error 取消、几何或指令错误 +func (p *RasterPage) BoundsContext(ctx context.Context, geometry GeometryBackend) (Box, error) { + if err := ctx.Err(); err != nil { + return Box{}, err + } + if p == nil { + return Box{}, fmt.Errorf("invalid raster page") + } + if geometry == nil { + return Box{}, fmt.Errorf("raster bounds: %w", ErrBackendUnavailable) + } + var box Box + for index, command := range p.Commands { + if err := ctx.Err(); err != nil { + return Box{}, err + } + path, err := rasterCommandGeometry(geometry, command) + if err == nil { + var bounds Box + bounds, err = geometry.Bounds(path) + box = unionTextBox(box, bounds) + } + if err != nil { + return Box{}, fmt.Errorf("raster command %d bounds: %w", index, err) + } + } + if err := ctx.Err(); err != nil { + return Box{}, err + } + return box, nil +} + +// rasterCommandGeometry 共用指令的页面变换、描边与裁剪,不修改路径或样式 +// 入参: geometry 几何后端, command 绘制指令 +// 返回: GeometryPath 页面轮廓, error 几何或指令错误 +func rasterCommandGeometry(geometry GeometryBackend, command RasterCommand) (GeometryPath, error) { + if command.Clip != nil && len(command.Clip) == 0 { + return nil, nil + } + var path GeometryPath + if command.Image != nil { + bounds := command.Image.Bounds() + if bounds.Empty() { + return nil, nil + } + path = GeometryPath(basegeom.Rectangle(Box{X: float64(bounds.Min.X), Y: float64(bounds.Min.Y), W: float64(bounds.Dx()), H: float64(bounds.Dy())})) + } else { + if len(command.Path) == 0 || command.Paint.Gradient == nil && command.Paint.Color.A == 0 { + return nil, nil + } + for _, segment := range command.Path { + if segment.Verb > RasterClose { + return nil, fmt.Errorf("unsupported raster path verb %d", segment.Verb) + } + } + path = geometryFromRaster(command.Path) + } + matrix := MatrixFromValues([6]float64(command.Transform)) + path, err := geometry.Transform(path, matrix) + if err != nil { + return nil, err + } + if command.Image == nil && command.Stroke != nil { + if err := basegeom.ValidateStroke(*command.Stroke); err != nil { + return nil, err + } + path, err = geometry.Stroke(path, *command.Stroke) + if err != nil { + return nil, err + } + } else if command.EvenOdd && command.Clip != nil { + path, err = geometry.Normalize(path, true) + if err != nil { + return nil, err + } + } + if command.Clip != nil { + for _, segment := range command.Clip { + if segment.Verb > RasterClose { + return nil, fmt.Errorf("unsupported raster clip verb %d", segment.Verb) + } + } + clip := geometryFromRaster(command.Clip) + path, err = clipGeometry(geometry, path, &clip) + if err != nil { + return nil, err + } + } + return path, nil +} diff --git a/ofdgo_raster_bounds.go b/ofdgo_raster_bounds.go deleted file mode 100644 index 62600a8..0000000 --- a/ofdgo_raster_bounds.go +++ /dev/null @@ -1,122 +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" - "fmt" - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" -) - -// Bounds 度量已编译指令的页面坐标范围,不重新定位文字或读取图片像素 -// 包含描边与指令裁剪,不应用页面边界,不排除图片透明像素或渐变透明区域 -// 入参: geometry 几何后端 -// 返回: Box 毫米坐标范围, error 几何或指令错误 -func (p *RasterPage) Bounds(geometry GeometryBackend) (Box, error) { - return p.BoundsContext(context.Background(), geometry) -} - -// BoundsContext 按上下文度量只读绘制指令,取消或失败时不返回部分范围 -// 入参: ctx 取消上下文, geometry 几何后端 -// 返回: Box 毫米坐标范围, error 取消、几何或指令错误 -func (p *RasterPage) BoundsContext(ctx context.Context, geometry GeometryBackend) (Box, error) { - if err := ctx.Err(); err != nil { - return Box{}, err - } - if p == nil { - return Box{}, fmt.Errorf("invalid raster page") - } - if geometry == nil { - return Box{}, fmt.Errorf("raster bounds: %w", ErrBackendUnavailable) - } - var box Box - for index, command := range p.Commands { - if err := ctx.Err(); err != nil { - return Box{}, err - } - path, err := rasterCommandGeometry(geometry, command) - if err == nil { - var bounds Box - bounds, err = geometry.Bounds(path) - box = unionTextBox(box, bounds) - } - if err != nil { - return Box{}, fmt.Errorf("raster command %d bounds: %w", index, err) - } - } - if err := ctx.Err(); err != nil { - return Box{}, err - } - return box, nil -} - -// rasterCommandGeometry 共用指令的页面变换、描边与裁剪,不修改路径或样式 -// 入参: geometry 几何后端, command 绘制指令 -// 返回: GeometryPath 页面轮廓, error 几何或指令错误 -func rasterCommandGeometry(geometry GeometryBackend, command RasterCommand) (GeometryPath, error) { - if command.Clip != nil && len(command.Clip) == 0 { - return nil, nil - } - var path GeometryPath - if command.Image != nil { - bounds := command.Image.Bounds() - if bounds.Empty() { - return nil, nil - } - path = GeometryPath(basegeom.Rectangle(Box{X: float64(bounds.Min.X), Y: float64(bounds.Min.Y), W: float64(bounds.Dx()), H: float64(bounds.Dy())})) - } else { - if len(command.Path) == 0 || command.Paint.Gradient == nil && command.Paint.Color.A == 0 { - return nil, nil - } - for _, segment := range command.Path { - if segment.Verb > RasterClose { - return nil, fmt.Errorf("unsupported raster path verb %d", segment.Verb) - } - } - path = geometryFromRaster(command.Path) - } - matrix := MatrixFromValues([6]float64(command.Transform)) - path, err := geometry.Transform(path, matrix) - if err != nil { - return nil, err - } - if command.Image == nil && command.Stroke != nil { - if err := basegeom.ValidateStroke(*command.Stroke); err != nil { - return nil, err - } - path, err = geometry.Stroke(path, *command.Stroke) - if err != nil { - return nil, err - } - } else if command.EvenOdd && command.Clip != nil { - path, err = geometry.Normalize(path, true) - if err != nil { - return nil, err - } - } - if command.Clip != nil { - for _, segment := range command.Clip { - if segment.Verb > RasterClose { - return nil, fmt.Errorf("unsupported raster clip verb %d", segment.Verb) - } - } - clip := geometryFromRaster(command.Clip) - path, err = clipGeometry(geometry, path, &clip) - if err != nil { - return nil, err - } - } - return path, nil -} diff --git a/ofdgo_raster_clip.go b/ofdgo_raster_clip.go index 965a932..1322119 100644 --- a/ofdgo_raster_clip.go +++ b/ofdgo_raster_clip.go @@ -23,6 +23,7 @@ import ( "math" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" + "golang.org/x/image/draw" ) // rasterClipKey 区分页面尺寸、分辨率和裁剪路径 @@ -114,3 +115,41 @@ func rasterClipMask(backend RasterBackend, page *RasterPage, path []RasterSegmen cache.put(key, mask, len(mask.Pix)+256) return mask, nil } + +// PixelTransform 将页面毫米变换转换为像素变换,保留尺寸取整后的原点位置 +// 入参: m 局部到页面变换, height 目标像素高度 +// 返回: RasterMatrix 局部到像素变换 +func (p *RasterPage) PixelTransform(m RasterMatrix, height int) RasterMatrix { + scale := p.DPI / 25.4 + for i := range m { + m[i] *= scale + } + m[5] += float64(height) - p.Height*scale + return m +} + +// 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 { + baseimage.DrawPixels(dst, src, m, draw.Over) + return nil + } + mask, err := rasterClipMask(backend, page, command.Clip, cache) + if err != nil { + return err + } + if mask.Rect.Empty() { + return nil + } + bounds := mask.Rect.Intersect(dst.Bounds()).Intersect(baseimage.TransformBounds(src.Bounds(), m, dst.Bounds())) + if bounds.Empty() { + return nil + } + layer := image.NewRGBA64(bounds) + baseimage.DrawPixels(layer, baseimage.PixelSource(src), m, draw.Src) + draw.DrawMask(dst, bounds, layer, bounds.Min, mask, bounds.Min, draw.Over) + return nil +} diff --git a/ofdgo_raster_image.go b/ofdgo_raster_image.go deleted file mode 100644 index 204f495..0000000 --- a/ofdgo_raster_image.go +++ /dev/null @@ -1,60 +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 ( - "image" - - baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" - "golang.org/x/image/draw" -) - -// PixelTransform 将页面毫米变换转换为像素变换,保留尺寸取整后的原点位置 -// 入参: m 局部到页面变换, height 目标像素高度 -// 返回: RasterMatrix 局部到像素变换 -func (p *RasterPage) PixelTransform(m RasterMatrix, height int) RasterMatrix { - scale := p.DPI / 25.4 - for i := range m { - m[i] *= scale - } - m[5] += float64(height) - p.Height*scale - return m -} - -// 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 { - baseimage.DrawPixels(dst, src, m, draw.Over) - return nil - } - mask, err := rasterClipMask(backend, page, command.Clip, cache) - if err != nil { - return err - } - if mask.Rect.Empty() { - return nil - } - bounds := mask.Rect.Intersect(dst.Bounds()).Intersect(baseimage.TransformBounds(src.Bounds(), m, dst.Bounds())) - if bounds.Empty() { - return nil - } - layer := image.NewRGBA64(bounds) - baseimage.DrawPixels(layer, baseimage.PixelSource(src), m, draw.Src) - draw.DrawMask(dst, bounds, layer, bounds.Min, mask, bounds.Min, draw.Over) - return nil -} diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index 0c89d35..8ed1e40 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -16,11 +16,16 @@ package ofdgo import ( "fmt" - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" + "image/color" "math" "strconv" + + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" ) +// OFDCompiler 使用库自有页面语义,字体和几何操作交给当前后端配置 +type OFDCompiler struct{} + // semanticCompiler 将公共页面语义编译为路径和原始图片,绘制、搜索和度量共享定位 type semanticCompiler struct { renderer *Renderer @@ -498,3 +503,78 @@ func (c *semanticCompiler) fillPattern(clip GeometryPath, pattern *PatternPaint, } return nil } + +// Name 返回页面编译器标识 +// 返回: string 编译器标识 +func (OFDCompiler) Name() string { return "ofd" } + +// CompilePage 使用公共OFD语义和配置的字形、几何能力编译页面 +// 入参: r 渲染器, page 页面 +// 返回: *RasterPage 绘制页面, error 编译错误 +func (OFDCompiler) CompilePage(r *Renderer, page *PageContent) (*RasterPage, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + c, err := newSemanticCompiler(r) + if err != nil { + return nil, err + } + c.page = &RasterPage{Width: box.W, Height: box.H, DPI: r.DPI} + if _, _, err := c.page.PixelSize(); err != nil { + return nil, err + } + if !r.TransparentBackground { + if err := c.fill(GeometryPath(basegeom.Rectangle(Box{W: box.W, H: box.H})), Paint{Kind: PaintSolid, Color: color.RGBA{255, 255, 255, 255}}, false, nil, IdentityMatrix, IdentityMatrix); err != nil { + return nil, err + } + } + if err := r.WalkPage(page, c); err != nil { + return nil, err + } + if r.OnPageText != nil { + r.OnPageText(page, &c.text) + } + return c.page, nil +} + +// PageText 提取与页面绘制相同定位的原文,不读取图片像素 +// 入参: r 渲染器, page 页面 +// 返回: *PageText 页面文字, error 定位错误 +func (OFDCompiler) PageText(r *Renderer, page *PageContent) (*PageText, error) { + if _, err := r.GetPageBox(page); err != nil { + return nil, err + } + c, err := newSemanticCompiler(r) + if err != nil { + return nil, err + } + c.textOnly = true + if err := r.WalkPage(page, c); err != nil { + return nil, err + } + return &c.text, nil +} + +// MeasureObject 复用文字和图形编译规则收集范围,不分配页面像素 +// 入参: r 渲染器, object 对象, options 度量上下文 +// 返回: ObjectMeasurement 对象范围与轮廓, error 度量错误 +func (OFDCompiler) MeasureObject(r *Renderer, object GraphicObject, options MeasureOptions) (ObjectMeasurement, error) { + c, err := newSemanticCompiler(r) + if err != nil { + return ObjectMeasurement{}, err + } + boundary, ctm := editorGeometry(object) + if _, err := creationBox(boundary); err != nil { + return ObjectMeasurement{}, err + } + if ctm != "" { + if _, err := creationNumbers(ctm, 6); err != nil { + return ObjectMeasurement{}, err + } + } + c.measure = true + c.contours = options.Contours + err = r.WalkObject(&object, RenderState{Defaults: options.Defaults, Parent: options.Parent, BoundaryInCTM: options.BoundaryInCTM, Clip: options.Clip}, c) + return c.measurement, err +} diff --git a/ofdgo_render_preview.go b/ofdgo_render_preview.go deleted file mode 100644 index 77355a0..0000000 --- a/ofdgo_render_preview.go +++ /dev/null @@ -1,128 +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" - "crypto/sha256" - "fmt" - "io" -) - -// svgNamespace 表示SVG预览文档命名空间 -const svgNamespace = "http://www.w3.org/2000/svg" - -// PreviewOptions 指定页面显示方式,光栅预览不提供SVG对象分组 -type PreviewOptions struct { - Raster bool - Objects bool -} - -// RenderPreview 生成统一SVG容器及独立资源,原生程序与WebUI使用相同绘制入口 -// 入参: page 页面内容, writer 输出流, options 显示方式 -// 返回: SVGResources 需由调用方映射地址的资源, error 错误信息 -func (r *Renderer) RenderPreview(page *PageContent, writer io.Writer, options PreviewOptions) (SVGResources, error) { - if !options.Raster { - if options.Objects { - return r.RenderToSVGWithObjects(page, writer) - } - return r.RenderToSVGWithResources(page, writer) - } - if options.Objects { - return SVGResources{}, fmt.Errorf("raster preview does not provide SVG object groups") - } - box, err := r.GetPageBox(page) - if err != nil { - return SVGResources{}, err - } - var pixels bytes.Buffer - if err := r.RenderToPNG(page, &pixels); err != nil { - return SVGResources{}, err - } - name := fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(pixels.Bytes())) - _, err = fmt.Fprintf(writer, ``, box.W, box.H, box.W, box.H, box.W, box.H, name) - if err != nil { - return SVGResources{}, err - } - return SVGResources{Images: []SVGImage{{Name: name, MIME: "image/png", Data: pixels.Bytes()}}}, nil -} - -// RenderFontPreview 将指定字体的前32个可见字形绘制为独立SVG,不依赖字符映射或浏览器字体 -// 排除缺字字形,外部字体须精确匹配,不修改原始资源或使用无关回退字体 -// 入参: ctx 取消上下文, id 字体资源标识, writer 输出流 -// 返回: error 字体、轮廓、取消或写入错误 -func (r *Renderer) RenderFontPreview(ctx context.Context, id string, writer io.Writer) error { - if err := ctx.Err(); err != nil { - return err - } - view := *r - view.pageContext = ctx - resolved, err := view.ResolveFont(id, true) - if err != nil { - return err - } - font := &PreparedFont{ResolvedFont: resolved} - if definition := view.Reader.fontDefinition(id); definition != nil && definition.FontFile != "" { - font, err = view.PrepareFont(id) - if err != nil { - return err - } - } - metrics, err := view.preparedMetrics(font) - if err != nil { - return err - } - outlines, ok := metrics.(FontOutlines) - if !ok { - return fmt.Errorf("font outlines: %w", ErrBackendUnavailable) - } - var paths bytes.Buffer - count := 0 - for glyph := uint16(1); glyph < metrics.NumGlyphs() && count < 32; glyph++ { - if err := ctx.Err(); err != nil { - return err - } - outline, err := outlines.GlyphOutline(glyph, 24) - if err != nil { - return err - } - box, err := outline.Bounds() - if err != nil { - return err - } - if box.W <= 0 || box.H <= 0 { - continue - } - path, err := outline.SVG() - if err != nil { - return err - } - scale := min(1.0, 28/box.W, 32/box.H) - x := float64(count%8)*36 + (36-box.W*scale)/2 - box.X*scale - y := float64(count/8)*40 + (40-box.H*scale)/2 - box.Y*scale - fmt.Fprintf(&paths, ``, x, y, scale, path) - count++ - } - if err := ctx.Err(); err != nil { - return err - } - if count == 0 { - return fmt.Errorf("font has no visible glyphs") - } - width, height := min(count, 8)*36, (count+7)/8*40 - _, err = fmt.Fprintf(writer, `%s`, width, height, width, height, paths.Bytes()) - return err -} diff --git a/ofdgo_render_svg.go b/ofdgo_render_svg.go index 8bfa701..a87c641 100644 --- a/ofdgo_render_svg.go +++ b/ofdgo_render_svg.go @@ -15,10 +15,16 @@ package ofdgo import ( + "bytes" + "context" + "crypto/sha256" "fmt" "io" ) +// svgNamespace 表示SVG预览文档命名空间 +const svgNamespace = "http://www.w3.org/2000/svg" + // SVGFont SVG引用的字体资源,Name为CSS字体族标识,Data为只读字体数据 type SVGFont struct { Name string @@ -40,6 +46,12 @@ type SVGResources struct { Images []SVGImage } +// PreviewOptions 指定页面显示方式,光栅预览不提供SVG对象分组 +type PreviewOptions struct { + Raster bool + Objects bool +} + // RenderToSVGWithFonts 渲染为SVG并返回页面引用的字体资源,不在SVG中嵌入字体 // 入参: page 页面内容, writer 输出流 // 返回: []SVGFont 字体资源,可按Name复用并按Weight、Style注册,error 错误信息 @@ -76,3 +88,99 @@ func (r *Renderer) renderSVG(page *PageContent, writer io.Writer, mode SVGMode) writer = convertWriter{ctx: r.outputContext(), writer: writer, count: &written} return r.backends.SVG.RenderSVG(r, page, writer, mode) } + +// RenderPreview 生成统一SVG容器及独立资源,原生程序与WebUI使用相同绘制入口 +// 入参: page 页面内容, writer 输出流, options 显示方式 +// 返回: SVGResources 需由调用方映射地址的资源, error 错误信息 +func (r *Renderer) RenderPreview(page *PageContent, writer io.Writer, options PreviewOptions) (SVGResources, error) { + if !options.Raster { + if options.Objects { + return r.RenderToSVGWithObjects(page, writer) + } + return r.RenderToSVGWithResources(page, writer) + } + if options.Objects { + return SVGResources{}, fmt.Errorf("raster preview does not provide SVG object groups") + } + box, err := r.GetPageBox(page) + if err != nil { + return SVGResources{}, err + } + var pixels bytes.Buffer + if err := r.RenderToPNG(page, &pixels); err != nil { + return SVGResources{}, err + } + name := fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(pixels.Bytes())) + _, err = fmt.Fprintf(writer, ``, box.W, box.H, box.W, box.H, box.W, box.H, name) + if err != nil { + return SVGResources{}, err + } + return SVGResources{Images: []SVGImage{{Name: name, MIME: "image/png", Data: pixels.Bytes()}}}, nil +} + +// RenderFontPreview 将指定字体的前32个可见字形绘制为独立SVG,不依赖字符映射或浏览器字体 +// 排除缺字字形,外部字体须精确匹配,不修改原始资源或使用无关回退字体 +// 入参: ctx 取消上下文, id 字体资源标识, writer 输出流 +// 返回: error 字体、轮廓、取消或写入错误 +func (r *Renderer) RenderFontPreview(ctx context.Context, id string, writer io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + view := *r + view.pageContext = ctx + resolved, err := view.ResolveFont(id, true) + if err != nil { + return err + } + font := &PreparedFont{ResolvedFont: resolved} + if definition := view.Reader.fontDefinition(id); definition != nil && definition.FontFile != "" { + font, err = view.PrepareFont(id) + if err != nil { + return err + } + } + metrics, err := view.preparedMetrics(font) + if err != nil { + return err + } + outlines, ok := metrics.(FontOutlines) + if !ok { + return fmt.Errorf("font outlines: %w", ErrBackendUnavailable) + } + var paths bytes.Buffer + count := 0 + for glyph := uint16(1); glyph < metrics.NumGlyphs() && count < 32; glyph++ { + if err := ctx.Err(); err != nil { + return err + } + outline, err := outlines.GlyphOutline(glyph, 24) + if err != nil { + return err + } + box, err := outline.Bounds() + if err != nil { + return err + } + if box.W <= 0 || box.H <= 0 { + continue + } + path, err := outline.SVG() + if err != nil { + return err + } + scale := min(1.0, 28/box.W, 32/box.H) + x := float64(count%8)*36 + (36-box.W*scale)/2 - box.X*scale + y := float64(count/8)*40 + (40-box.H*scale)/2 - box.Y*scale + fmt.Fprintf(&paths, ``, x, y, scale, path) + count++ + } + if err := ctx.Err(); err != nil { + return err + } + if count == 0 { + return fmt.Errorf("font has no visible glyphs") + } + width, height := min(count, 8)*36, (count+7)/8*40 + _, err = fmt.Fprintf(writer, `%s`, width, height, width, height, paths.Bytes()) + return err +} diff --git a/ofdgo_renderer_compile.go b/ofdgo_renderer_compile.go deleted file mode 100644 index 7be5bf9..0000000 --- a/ofdgo_renderer_compile.go +++ /dev/null @@ -1,98 +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" - "image/color" -) - -// OFDCompiler 使用库自有页面语义,字体和几何操作交给当前后端配置 -type OFDCompiler struct{} - -// Name 返回页面编译器标识 -// 返回: string 编译器标识 -func (OFDCompiler) Name() string { return "ofd" } - -// CompilePage 使用公共OFD语义和配置的字形、几何能力编译页面 -// 入参: r 渲染器, page 页面 -// 返回: *RasterPage 绘制页面, error 编译错误 -func (OFDCompiler) CompilePage(r *Renderer, page *PageContent) (*RasterPage, error) { - box, err := r.GetPageBox(page) - if err != nil { - return nil, err - } - c, err := newSemanticCompiler(r) - if err != nil { - return nil, err - } - c.page = &RasterPage{Width: box.W, Height: box.H, DPI: r.DPI} - if _, _, err := c.page.PixelSize(); err != nil { - return nil, err - } - if !r.TransparentBackground { - if err := c.fill(GeometryPath(basegeom.Rectangle(Box{W: box.W, H: box.H})), Paint{Kind: PaintSolid, Color: color.RGBA{255, 255, 255, 255}}, false, nil, IdentityMatrix, IdentityMatrix); err != nil { - return nil, err - } - } - if err := r.WalkPage(page, c); err != nil { - return nil, err - } - if r.OnPageText != nil { - r.OnPageText(page, &c.text) - } - return c.page, nil -} - -// PageText 提取与页面绘制相同定位的原文,不读取图片像素 -// 入参: r 渲染器, page 页面 -// 返回: *PageText 页面文字, error 定位错误 -func (OFDCompiler) PageText(r *Renderer, page *PageContent) (*PageText, error) { - if _, err := r.GetPageBox(page); err != nil { - return nil, err - } - c, err := newSemanticCompiler(r) - if err != nil { - return nil, err - } - c.textOnly = true - if err := r.WalkPage(page, c); err != nil { - return nil, err - } - return &c.text, nil -} - -// MeasureObject 复用文字和图形编译规则收集范围,不分配页面像素 -// 入参: r 渲染器, object 对象, options 度量上下文 -// 返回: ObjectMeasurement 对象范围与轮廓, error 度量错误 -func (OFDCompiler) MeasureObject(r *Renderer, object GraphicObject, options MeasureOptions) (ObjectMeasurement, error) { - c, err := newSemanticCompiler(r) - if err != nil { - return ObjectMeasurement{}, err - } - boundary, ctm := editorGeometry(object) - if _, err := creationBox(boundary); err != nil { - return ObjectMeasurement{}, err - } - if ctm != "" { - if _, err := creationNumbers(ctm, 6); err != nil { - return ObjectMeasurement{}, err - } - } - c.measure = true - c.contours = options.Contours - err = r.WalkObject(&object, RenderState{Defaults: options.Defaults, Parent: options.Parent, BoundaryInCTM: options.BoundaryInCTM, Clip: options.Clip}, c) - return c.measurement, err -}