feat(归并文件): 归并根包文件,保持接口不变
build.yaml / build (push) Successful in 43s

This commit is contained in:
xiaoqidun committed 2026-10-19 09:43:06 +08:00
1 parent aab5d40f93
commit 84808cda61
22 files changed
+1066 -1306

No files matched your search

-110
View File
@@ -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()
}
-136
View File
@@ -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
}
+203
View File
@@ -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
}
+184
View File
@@ -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))
}
-160
View File
@@ -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))
}
+81 -14
View File
@@ -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 &copy
}
// 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))
}
-86
View File
@@ -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))
}
+93
View File
@@ -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() }
-114
View File
@@ -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() }
-64
View File
@@ -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")
}
-120
View File
@@ -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)
}
+73
View File
@@ -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)
}
+100
View File
@@ -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)
}
-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 (
"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)
}
+104
View File
@@ -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
}
-122
View File
@@ -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
}
+39
View File
@@ -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
}
-60
View File
@@ -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
}
+81 -1
View File
@@ -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
}
-128
View File
@@ -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, `<svg xmlns="`+svgNamespace+`" xmlns:xlink="`+XLinkNamespace+`" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><image width="%g" height="%g" preserveAspectRatio="none" xlink:href="%s"/></svg>`, 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, `<path transform="translate(%g %g) scale(%g)" d="%s"/>`, 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, `<svg xmlns="`+svgNamespace+`" width="%d" height="%d" viewBox="0 0 %d %d">%s</svg>`, width, height, width, height, paths.Bytes())
return err
}
+108
View File
@@ -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, `<svg xmlns="`+svgNamespace+`" xmlns:xlink="`+XLinkNamespace+`" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><image width="%g" height="%g" preserveAspectRatio="none" xlink:href="%s"/></svg>`, 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, `<path transform="translate(%g %g) scale(%g)" d="%s"/>`, 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, `<svg xmlns="`+svgNamespace+`" width="%d" height="%d" viewBox="0 0 %d %d">%s</svg>`, width, height, width, height, paths.Bytes())
return err
}
-98
View File
@@ -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
}