feat(归并文件): 归并根包文件

This commit is contained in:
xiaoqidun committed 2026-10-19 10:17:59 +08:00
1 parent ec24b6a17d
commit 1a8de5a3f1
23 files changed
+1724 -1981

No files matched your search

+101
View File
@@ -17,8 +17,18 @@ package ofdgo
import ( import (
"bytes" "bytes"
"crypto" "crypto"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"errors" "errors"
"fmt"
"io" "io"
"github.com/emmansun/gmsm/padding"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
"github.com/emmansun/gmsm/sm4"
"github.com/emmansun/gmsm/smx509"
) )
// Credentials 解密凭据,UserName用于筛选接收者,留空自动匹配,不参与口令派生;证书采用DER编码 // Credentials 解密凭据,UserName用于筛选接收者,留空自动匹配,不参与口令派生;证书采用DER编码
@@ -84,6 +94,9 @@ type encryptionState struct {
options *EncryptionOptions options *EncryptionOptions
} }
// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用
type GMCryptoProvider struct{}
// 加密错误可通过errors.Is识别,不包含口令或私钥 // 加密错误可通过errors.Is识别,不包含口令或私钥
var ( var (
ErrCredentialsRequired = errors.New("document credentials required") ErrCredentialsRequired = errors.New("document credentials required")
@@ -212,3 +225,91 @@ func (e *Editor) writeEncrypted(writer io.Writer) (int64, error) {
} }
return io.Copy(writer, bytes.NewReader(data)) return io.Copy(writer, bytes.NewReader(data))
} }
// EncryptSM4 使用SM4-CBC及PKCS#7填充加密
// 入参: key 密钥, iv 初始向量, plaintext 明文
// 返回: []byte 密文, error 错误信息
func (GMCryptoProvider) EncryptSM4(key, iv, plaintext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() {
return nil, fmt.Errorf("invalid SM4 IV length")
}
out := padding.NewPKCS7Padding(sm4.BlockSize).Pad(bytes.Clone(plaintext))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, out)
return out, nil
}
// DecryptSM4 解密SM4-CBC并校验PKCS#7填充
// 入参: key 密钥, iv 初始向量, ciphertext 密文
// 返回: []byte 明文, error 错误信息
func (GMCryptoProvider) DecryptSM4(key, iv, ciphertext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() || len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return nil, ErrInvalidCredentials
}
out := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ciphertext)
plain, err := padding.NewPKCS7Padding(sm4.BlockSize).ConstantTimeUnpad(out)
if err != nil {
clear(out)
return nil, ErrInvalidCredentials
}
return plain, nil
}
// PasswordKey 按GB/T32918派生16字节口令密钥
// 入参: password UTF-8口令
// 返回: []byte 密钥, error 错误信息
func (GMCryptoProvider) PasswordKey(password []byte) ([]byte, error) {
if len(password) == 0 {
return nil, ErrCredentialsRequired
}
return sm3.Kdf(password, sm4.BlockSize), nil
}
// EncryptKey 使用SM2公钥证书封装文件密钥
// 入参: certificate DER证书, key 文件密钥
// 返回: []byte ASN.1密文, error 错误信息
func (GMCryptoProvider) EncryptKey(certificate, key []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return sm2.EncryptASN1(rand.Reader, cert.PublicKey.(*ecdsa.PublicKey), key)
}
// ParseEncryptionCertificate 解析单张SM2加密证书并返回DER副本
// 仅检查格式、算法和密钥用途,接收者身份及信任应由调用方确认
// 入参: certificate PEM或DER证书
// 返回: []byte DER证书, error 错误信息
func ParseEncryptionCertificate(certificate []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return bytes.Clone(cert.Raw), nil
}
// parseEncryptionCertificate 解析并检查加密证书
// 入参: certificate PEM或DER证书
// 返回: *smx509.Certificate 证书, error 错误信息
func parseEncryptionCertificate(certificate []byte) (*smx509.Certificate, error) {
cert, err := parseSignatureCertificate(certificate)
if err != nil {
return nil, err
}
public, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || !sm2.IsSM2PublicKey(public) {
return nil, fmt.Errorf("SM2 encryption certificate required")
}
if cert.KeyUsage != 0 && cert.KeyUsage&(smx509.KeyUsageKeyEncipherment|smx509.KeyUsageDataEncipherment|smx509.KeyUsageKeyAgreement) == 0 {
return nil, fmt.Errorf("certificate does not permit encryption")
}
return cert, nil
}
-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 (
"bytes"
"crypto/cipher"
"crypto/ecdsa"
"crypto/rand"
"fmt"
"github.com/emmansun/gmsm/padding"
"github.com/emmansun/gmsm/sm2"
"github.com/emmansun/gmsm/sm3"
"github.com/emmansun/gmsm/sm4"
"github.com/emmansun/gmsm/smx509"
)
// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用
type GMCryptoProvider struct{}
// EncryptSM4 使用SM4-CBC及PKCS#7填充加密
// 入参: key 密钥, iv 初始向量, plaintext 明文
// 返回: []byte 密文, error 错误信息
func (GMCryptoProvider) EncryptSM4(key, iv, plaintext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() {
return nil, fmt.Errorf("invalid SM4 IV length")
}
out := padding.NewPKCS7Padding(sm4.BlockSize).Pad(bytes.Clone(plaintext))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, out)
return out, nil
}
// DecryptSM4 解密SM4-CBC并校验PKCS#7填充
// 入参: key 密钥, iv 初始向量, ciphertext 密文
// 返回: []byte 明文, error 错误信息
func (GMCryptoProvider) DecryptSM4(key, iv, ciphertext []byte) ([]byte, error) {
block, err := sm4.NewCipher(key)
if err != nil {
return nil, err
}
if len(iv) != block.BlockSize() || len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
return nil, ErrInvalidCredentials
}
out := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ciphertext)
plain, err := padding.NewPKCS7Padding(sm4.BlockSize).ConstantTimeUnpad(out)
if err != nil {
clear(out)
return nil, ErrInvalidCredentials
}
return plain, nil
}
// PasswordKey 按GB/T32918派生16字节口令密钥
// 入参: password UTF-8口令
// 返回: []byte 密钥, error 错误信息
func (GMCryptoProvider) PasswordKey(password []byte) ([]byte, error) {
if len(password) == 0 {
return nil, ErrCredentialsRequired
}
return sm3.Kdf(password, sm4.BlockSize), nil
}
// EncryptKey 使用SM2公钥证书封装文件密钥
// 入参: certificate DER证书, key 文件密钥
// 返回: []byte ASN.1密文, error 错误信息
func (GMCryptoProvider) EncryptKey(certificate, key []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return sm2.EncryptASN1(rand.Reader, cert.PublicKey.(*ecdsa.PublicKey), key)
}
// ParseEncryptionCertificate 解析单张SM2加密证书并返回DER副本
// 仅检查格式、算法和密钥用途,接收者身份及信任应由调用方确认
// 入参: certificate PEM或DER证书
// 返回: []byte DER证书, error 错误信息
func ParseEncryptionCertificate(certificate []byte) ([]byte, error) {
cert, err := parseEncryptionCertificate(certificate)
if err != nil {
return nil, err
}
return bytes.Clone(cert.Raw), nil
}
// parseEncryptionCertificate 解析并检查加密证书
// 入参: certificate PEM或DER证书
// 返回: *smx509.Certificate 证书, error 错误信息
func parseEncryptionCertificate(certificate []byte) (*smx509.Certificate, error) {
cert, err := parseSignatureCertificate(certificate)
if err != nil {
return nil, err
}
public, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || !sm2.IsSM2PublicKey(public) {
return nil, fmt.Errorf("SM2 encryption certificate required")
}
if cert.KeyUsage != 0 && cert.KeyUsage&(smx509.KeyUsageKeyEncipherment|smx509.KeyUsageDataEncipherment|smx509.KeyUsageKeyAgreement) == 0 {
return nil, fmt.Errorf("certificate does not permit encryption")
}
return cert, nil
}
+281
View File
@@ -18,11 +18,22 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"maps"
"path" "path"
"strings" "strings"
"time" "time"
) )
// AnnotationSelection 保存当前编辑器内可重复粘贴的注解原文及资源上下文
// 来源修改或删除不影响快照,不用于跨文档传输
type AnnotationSelection struct {
editor *Editor
document editorDocumentIdentity
data [][]byte
resources []string
states map[string]map[string]editorCompositeState
}
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立 // Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
// 入参: index 页面索引 // 入参: index 页面索引
// 返回: []AnnotationInfo 注解信息, error 错误信息 // 返回: []AnnotationInfo 注解信息, error 错误信息
@@ -297,3 +308,273 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi
} }
return e.commitAnnotationParts(base, parts) return e.commitAnnotationParts(base, parts)
} }
// CaptureAnnotations 捕获注解选区,保留未知字段、外观及动作
// 入参: page 页面索引, ids 不重复的注解标识
// 返回: *AnnotationSelection 独立快照, error 错误信息
func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelection, error) {
content, err := e.page(page)
if err != nil {
return nil, err
}
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState)}
seen := make(map[string]bool)
for _, id := range ids {
key := editorResourceID(id)
if seen[key] {
return nil, fmt.Errorf("duplicate annotation ID %q", id)
}
seen[key] = true
data, err := e.annotationXML(page, id)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
if !editorXMLCopyable(root) {
return nil, fmt.Errorf("annotation contains unsupported identifiers")
}
selection.data = append(selection.data, data)
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
}
if e.originalPage(page) {
origin := e.source.pages[e.pages[page].ID]
for _, name := range content.PageRes {
selection.resources = append(selection.resources, resolveResourcePath(e.source.reader.ResPath(origin.ref.BaseLoc), "", name))
}
}
return selection, nil
}
// PasteAnnotations 将注解快照粘贴到指定页并平移,保留动作目标和原有外观
// 全部标识重新分配,页级资源提升为文档资源,一次操作计入一条撤销记录
// 入参: page 目标页面索引, selection 当前编辑器快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米
// 返回: []string 新注解标识, error 错误信息
func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx, dy float64) ([]string, error) {
if selection == nil || selection.editor != e || !e.documentIdentity().matches(selection.document) || !finite(dx) || !finite(dy) {
return nil, fmt.Errorf("invalid annotation selection or offset")
}
var result []string
err := e.Transaction(func(edit *Editor) error {
if page < 0 || page >= len(edit.pages) {
return fmt.Errorf("page index %d out of range", page)
}
if len(selection.data) == 0 {
return nil
}
if err := edit.prepareSourceIDs(); err != nil {
return err
}
var content []byte
var states map[string]map[string]editorCompositeState
if edit.source != nil {
states = maps.Clone(edit.source.annotationStates)
}
if states == nil {
states = make(map[string]map[string]editorCompositeState)
}
for _, data := range selection.data {
root, err := parseEditorXML(data)
if err != nil {
return err
}
ids := make(map[string]string)
if err := edit.collectCompositeIDs(root, ids); err != nil {
return err
}
id := ids[editorResourceID(root.attr("ID"))]
states[edit.pages[page].ID+"/"+id] = remapCompositeStates(selection.states[editorResourceID(root.attr("ID"))], ids)
data, err = editorXMLRemapIDs(data, root, ids)
if err != nil {
return err
}
root, err = parseEditorXML(data)
if err != nil {
return err
}
appearance := root.child("Appearance")
if appearance == nil {
return fmt.Errorf("annotation has no appearance")
}
box, err := ParseBox(appearance.attr("Boundary"))
if err != nil || !finite(box.X+dx) || !finite(box.Y+dy) {
return fmt.Errorf("invalid annotation boundary")
}
box.X, box.Y = box.X+dx, box.Y+dy
fragment, err := editorXMLAttribute(data, appearance, "Boundary", editorBoxString(box))
if err != nil {
return err
}
data, err = editorAnnotationDate(editorPatchXML(data, []editorXMLPatch{{appearance.start, appearance.end, fragment}}))
if err != nil {
return err
}
content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...)
result = append(result, id)
}
if err := edit.appendAnnotations(page, content, false); err != nil {
return err
}
edit.source.annotationStates = states
if len(selection.resources) != 0 {
reader := edit.source.reader
name := reader.documentRoot
data, err := reader.readFile(name)
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
common := root.child("CommonData")
registered := make(map[string]bool)
for _, name := range edit.source.document.CommonData.PublicRes {
registered[reader.ResPath(name)] = true
}
for _, name := range edit.source.document.CommonData.DocumentRes {
registered[reader.ResPath(name)] = true
}
var added []byte
for _, name := range selection.resources {
if !registered[name] {
added = append(added, common.textXML("DocumentRes", "/"+name)...)
registered[name] = true
}
}
if len(added) != 0 {
position := common.close
for _, node := range common.children {
if node.name.Local == "TemplatePage" || node.name.Local == "DefaultCS" {
position = node.start
break
}
}
return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})})
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// TransformAnnotations 对注解选区施加页面仿射变换,保持内部图元与外观边界同步
// 入参: page 页面索引, ids 注解标识, matrix 可逆页面变换
// 返回: error 错误信息
func (e *Editor) TransformAnnotations(page int, ids []string, matrix Matrix) error {
if _, ok := matrix.Invert(); !ok {
return fmt.Errorf("annotation transform must be finite and invertible")
}
return e.Transaction(func(edit *Editor) error {
seen := make(map[string]bool)
for _, id := range ids {
if seen[id] {
return fmt.Errorf("duplicate annotation ID %q", id)
}
seen[id] = true
annotation, err := edit.Annotation(page, id)
if err != nil {
return err
}
if matrix == IdentityMatrix {
continue
}
box, err := ParseBox(annotation.Appearance.Boundary)
if err != nil {
return err
}
bounds := matrix.TransformBox(box)
if !finite(bounds.X) || !finite(bounds.Y) || !finite(bounds.W) || !finite(bounds.H) || bounds.W < 0 || bounds.H < 0 {
return fmt.Errorf("annotation transform exceeds finite range")
}
path := ObjectPath{Annotation: id}
reader, _, _, members, err := edit.compositeScope(page, path)
if err != nil {
return err
}
reader.Close()
indexes := make([]int, len(members))
for i := range indexes {
indexes[i] = i
}
local := TranslationMatrix(box.X-bounds.X, box.Y-bounds.Y).Multiply(matrix)
if err := edit.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return local }); err != nil {
return err
}
if err := edit.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) {
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
if err != nil {
return nil, err
}
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
appearance := root.child("Appearance")
updated, err := editorXMLAttribute(fragment, appearance, "Boundary", editorBoxString(bounds))
if err != nil {
return nil, err
}
return editorAnnotationDate(editorPatchXML(fragment, []editorXMLPatch{{appearance.start, appearance.end, updated}}))
}); err != nil {
return err
}
}
return nil
})
}
// ReplaceAnnotationText 修改注解内部匹配的普通文字,保留各成员位置、旋转及未修改属性
// 入参: page 页面索引, id 注解标识, old 原文字, value 新文字, style 文字样式增量
// 返回: int 修改数量, error 错误信息
func (e *Editor) ReplaceAnnotationText(page int, id, old, value string, style TextStyle) (int, error) {
count := 0
err := e.Transaction(func(edit *Editor) error {
return edit.editCompositeScope(page, ObjectPath{Annotation: id}, func(renderer *Renderer, _ *editorCompositeNode, nodes []*editorCompositeNode) error {
visiting := make(map[string]bool)
var replace func([]*editorCompositeNode) error
replace = func(nodes []*editorCompositeNode) error {
members, err := edit.measureCompositeMembers(renderer, nodes)
if err != nil {
return err
}
for i, member := range members {
if member.Object.Type == "TextObject" && member.Object.TextObject.Text() == old {
if err := edit.updateCompositeText(nodes[i], member, &value, style, nil, nil); err != nil {
return err
}
count++
} else if member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit" {
resource := member.Object.CompositeGraphicUnit.ResourceID
if id := editorResourceID(resource); id != "" {
resource = id
}
if resource != "" && visiting[resource] {
return fmt.Errorf("cyclic composite resource %q", resource)
}
children, err := edit.compositeMembers(nodes[i], renderer.Reader, renderer, visiting)
if err != nil {
return err
}
visiting[resource] = true
if err := replace(children); err != nil {
return err
}
delete(visiting, resource)
}
}
return nil
}
return replace(nodes)
})
})
if err != nil {
return 0, err
}
return count, nil
}
-302
View File
@@ -1,302 +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"
"encoding/xml"
"fmt"
"maps"
)
// AnnotationSelection 保存当前编辑器内可重复粘贴的注解原文及资源上下文
// 来源修改或删除不影响快照,不用于跨文档传输
type AnnotationSelection struct {
editor *Editor
document editorDocumentIdentity
data [][]byte
resources []string
states map[string]map[string]editorCompositeState
}
// CaptureAnnotations 捕获注解选区,保留未知字段、外观及动作
// 入参: page 页面索引, ids 不重复的注解标识
// 返回: *AnnotationSelection 独立快照, error 错误信息
func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelection, error) {
content, err := e.page(page)
if err != nil {
return nil, err
}
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState)}
seen := make(map[string]bool)
for _, id := range ids {
key := editorResourceID(id)
if seen[key] {
return nil, fmt.Errorf("duplicate annotation ID %q", id)
}
seen[key] = true
data, err := e.annotationXML(page, id)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
if !editorXMLCopyable(root) {
return nil, fmt.Errorf("annotation contains unsupported identifiers")
}
selection.data = append(selection.data, data)
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
}
if e.originalPage(page) {
origin := e.source.pages[e.pages[page].ID]
for _, name := range content.PageRes {
selection.resources = append(selection.resources, resolveResourcePath(e.source.reader.ResPath(origin.ref.BaseLoc), "", name))
}
}
return selection, nil
}
// PasteAnnotations 将注解快照粘贴到指定页并平移,保留动作目标和原有外观
// 全部标识重新分配,页级资源提升为文档资源,一次操作计入一条撤销记录
// 入参: page 目标页面索引, selection 当前编辑器快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米
// 返回: []string 新注解标识, error 错误信息
func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx, dy float64) ([]string, error) {
if selection == nil || selection.editor != e || !e.documentIdentity().matches(selection.document) || !finite(dx) || !finite(dy) {
return nil, fmt.Errorf("invalid annotation selection or offset")
}
var result []string
err := e.Transaction(func(edit *Editor) error {
if page < 0 || page >= len(edit.pages) {
return fmt.Errorf("page index %d out of range", page)
}
if len(selection.data) == 0 {
return nil
}
if err := edit.prepareSourceIDs(); err != nil {
return err
}
var content []byte
var states map[string]map[string]editorCompositeState
if edit.source != nil {
states = maps.Clone(edit.source.annotationStates)
}
if states == nil {
states = make(map[string]map[string]editorCompositeState)
}
for _, data := range selection.data {
root, err := parseEditorXML(data)
if err != nil {
return err
}
ids := make(map[string]string)
if err := edit.collectCompositeIDs(root, ids); err != nil {
return err
}
id := ids[editorResourceID(root.attr("ID"))]
states[edit.pages[page].ID+"/"+id] = remapCompositeStates(selection.states[editorResourceID(root.attr("ID"))], ids)
data, err = editorXMLRemapIDs(data, root, ids)
if err != nil {
return err
}
root, err = parseEditorXML(data)
if err != nil {
return err
}
appearance := root.child("Appearance")
if appearance == nil {
return fmt.Errorf("annotation has no appearance")
}
box, err := ParseBox(appearance.attr("Boundary"))
if err != nil || !finite(box.X+dx) || !finite(box.Y+dy) {
return fmt.Errorf("invalid annotation boundary")
}
box.X, box.Y = box.X+dx, box.Y+dy
fragment, err := editorXMLAttribute(data, appearance, "Boundary", editorBoxString(box))
if err != nil {
return err
}
data, err = editorAnnotationDate(editorPatchXML(data, []editorXMLPatch{{appearance.start, appearance.end, fragment}}))
if err != nil {
return err
}
content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...)
result = append(result, id)
}
if err := edit.appendAnnotations(page, content, false); err != nil {
return err
}
edit.source.annotationStates = states
if len(selection.resources) != 0 {
reader := edit.source.reader
name := reader.documentRoot
data, err := reader.readFile(name)
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
common := root.child("CommonData")
registered := make(map[string]bool)
for _, name := range edit.source.document.CommonData.PublicRes {
registered[reader.ResPath(name)] = true
}
for _, name := range edit.source.document.CommonData.DocumentRes {
registered[reader.ResPath(name)] = true
}
var added []byte
for _, name := range selection.resources {
if !registered[name] {
added = append(added, common.textXML("DocumentRes", "/"+name)...)
registered[name] = true
}
}
if len(added) != 0 {
position := common.close
for _, node := range common.children {
if node.name.Local == "TemplatePage" || node.name.Local == "DefaultCS" {
position = node.start
break
}
}
return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})})
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// TransformAnnotations 对注解选区施加页面仿射变换,保持内部图元与外观边界同步
// 入参: page 页面索引, ids 注解标识, matrix 可逆页面变换
// 返回: error 错误信息
func (e *Editor) TransformAnnotations(page int, ids []string, matrix Matrix) error {
if _, ok := matrix.Invert(); !ok {
return fmt.Errorf("annotation transform must be finite and invertible")
}
return e.Transaction(func(edit *Editor) error {
seen := make(map[string]bool)
for _, id := range ids {
if seen[id] {
return fmt.Errorf("duplicate annotation ID %q", id)
}
seen[id] = true
annotation, err := edit.Annotation(page, id)
if err != nil {
return err
}
if matrix == IdentityMatrix {
continue
}
box, err := ParseBox(annotation.Appearance.Boundary)
if err != nil {
return err
}
bounds := matrix.TransformBox(box)
if !finite(bounds.X) || !finite(bounds.Y) || !finite(bounds.W) || !finite(bounds.H) || bounds.W < 0 || bounds.H < 0 {
return fmt.Errorf("annotation transform exceeds finite range")
}
path := ObjectPath{Annotation: id}
reader, _, _, members, err := edit.compositeScope(page, path)
if err != nil {
return err
}
reader.Close()
indexes := make([]int, len(members))
for i := range indexes {
indexes[i] = i
}
local := TranslationMatrix(box.X-bounds.X, box.Y-bounds.Y).Multiply(matrix)
if err := edit.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return local }); err != nil {
return err
}
if err := edit.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) {
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
if err != nil {
return nil, err
}
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
appearance := root.child("Appearance")
updated, err := editorXMLAttribute(fragment, appearance, "Boundary", editorBoxString(bounds))
if err != nil {
return nil, err
}
return editorAnnotationDate(editorPatchXML(fragment, []editorXMLPatch{{appearance.start, appearance.end, updated}}))
}); err != nil {
return err
}
}
return nil
})
}
// ReplaceAnnotationText 修改注解内部匹配的普通文字,保留各成员位置、旋转及未修改属性
// 入参: page 页面索引, id 注解标识, old 原文字, value 新文字, style 文字样式增量
// 返回: int 修改数量, error 错误信息
func (e *Editor) ReplaceAnnotationText(page int, id, old, value string, style TextStyle) (int, error) {
count := 0
err := e.Transaction(func(edit *Editor) error {
return edit.editCompositeScope(page, ObjectPath{Annotation: id}, func(renderer *Renderer, _ *editorCompositeNode, nodes []*editorCompositeNode) error {
visiting := make(map[string]bool)
var replace func([]*editorCompositeNode) error
replace = func(nodes []*editorCompositeNode) error {
members, err := edit.measureCompositeMembers(renderer, nodes)
if err != nil {
return err
}
for i, member := range members {
if member.Object.Type == "TextObject" && member.Object.TextObject.Text() == old {
if err := edit.updateCompositeText(nodes[i], member, &value, style, nil, nil); err != nil {
return err
}
count++
} else if member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit" {
resource := member.Object.CompositeGraphicUnit.ResourceID
if id := editorResourceID(resource); id != "" {
resource = id
}
if resource != "" && visiting[resource] {
return fmt.Errorf("cyclic composite resource %q", resource)
}
children, err := edit.compositeMembers(nodes[i], renderer.Reader, renderer, visiting)
if err != nil {
return err
}
visiting[resource] = true
if err := replace(children); err != nil {
return err
}
delete(visiting, resource)
}
}
return nil
}
return replace(nodes)
})
})
if err != nil {
return 0, err
}
return count, nil
}
-123
View File
@@ -1,123 +0,0 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"reflect"
"sync"
)
// editorClonePlans 仅共享类型的可变字段索引,不持有文档对象或资源
var editorClonePlans sync.Map
// cloneEditorData 深复制编辑快照,未导出的排版及原文来源保持不可变共享
// 入参: value 原值
// 返回: T 独立副本
func cloneEditorData[T any](value T) T {
source := reflect.ValueOf(value)
if !source.IsValid() {
return value
}
return cloneEditorValue(source).Interface().(T)
}
// cloneEditorValue 复制公开指针、切片及数组,纯值数据不创建额外结构
// 入参: value 原值
// 返回: reflect.Value 独立副本
func cloneEditorValue(value reflect.Value) reflect.Value {
if !value.IsValid() || !editorCloneMutable(value.Type()) {
return value
}
switch value.Kind() {
case reflect.Pointer:
if value.IsNil() {
return value
}
result := reflect.New(value.Type().Elem())
cloneEditorInto(result.Elem(), value.Elem())
return result
case reflect.Slice:
if value.IsNil() {
return value
}
result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
if !editorCloneMutable(value.Type().Elem()) {
reflect.Copy(result, value)
} else {
for i := range value.Len() {
cloneEditorInto(result.Index(i), value.Index(i))
}
}
return result
default:
result := reflect.New(value.Type()).Elem()
cloneEditorInto(result, value)
return result
}
}
// cloneEditorInto 直接填充目标结构,避免指针及切片成员的二次结构分配
// 入参: target 可写目标, source 原值
func cloneEditorInto(target, source reflect.Value) {
switch source.Kind() {
case reflect.Struct:
target.Set(source)
for _, i := range editorCloneFields(source.Type()) {
cloneEditorInto(target.Field(i), source.Field(i))
}
case reflect.Array:
target.Set(source)
if editorCloneMutable(source.Type().Elem()) {
for i := range source.Len() {
cloneEditorInto(target.Index(i), source.Index(i))
}
}
default:
target.Set(cloneEditorValue(source))
}
}
// editorCloneMutable 判断类型是否含有需要隔离的公开可变成员
// 入参: kind 数据类型
// 返回: bool 是否需要独立复制
func editorCloneMutable(kind reflect.Type) bool {
switch kind.Kind() {
case reflect.Pointer, reflect.Slice:
return true
case reflect.Array:
return editorCloneMutable(kind.Elem())
case reflect.Struct:
return len(editorCloneFields(kind)) != 0
}
return false
}
// editorCloneFields 按类型复用公开可变字段,索引发布后保持只读
// 入参: kind 结构类型
// 返回: []int 可变字段索引
func editorCloneFields(kind reflect.Type) []int {
if cached, ok := editorClonePlans.Load(kind); ok {
return cached.([]int)
}
var fields []int
for i := range kind.NumField() {
field := kind.Field(i)
if field.IsExported() && editorCloneMutable(field.Type) {
fields = append(fields, i)
}
}
actual, _ := editorClonePlans.LoadOrStore(kind, fields)
return actual.([]int)
}
+105
View File
@@ -18,9 +18,14 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"reflect"
"strconv" "strconv"
"sync"
) )
// editorClonePlans 仅共享类型的可变字段索引,不持有文档对象或资源
var editorClonePlans sync.Map
// copiedObjectStyle 保留复杂路径的原始颜色引用,普通对象沿用有效样式快照 // copiedObjectStyle 保留复杂路径的原始颜色引用,普通对象沿用有效样式快照
// 入参: object 原对象, layer 图层绘制参数 // 入参: object 原对象, layer 图层绘制参数
// 返回: GraphicObject 复制快照, error 错误信息 // 返回: GraphicObject 复制快照, error 错误信息
@@ -231,3 +236,103 @@ func (e *Editor) copiedLayerStyle(object GraphicObject) string {
} }
return "" return ""
} }
// cloneEditorData 深复制编辑快照,未导出的排版及原文来源保持不可变共享
// 入参: value 原值
// 返回: T 独立副本
func cloneEditorData[T any](value T) T {
source := reflect.ValueOf(value)
if !source.IsValid() {
return value
}
return cloneEditorValue(source).Interface().(T)
}
// cloneEditorValue 复制公开指针、切片及数组,纯值数据不创建额外结构
// 入参: value 原值
// 返回: reflect.Value 独立副本
func cloneEditorValue(value reflect.Value) reflect.Value {
if !value.IsValid() || !editorCloneMutable(value.Type()) {
return value
}
switch value.Kind() {
case reflect.Pointer:
if value.IsNil() {
return value
}
result := reflect.New(value.Type().Elem())
cloneEditorInto(result.Elem(), value.Elem())
return result
case reflect.Slice:
if value.IsNil() {
return value
}
result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
if !editorCloneMutable(value.Type().Elem()) {
reflect.Copy(result, value)
} else {
for i := range value.Len() {
cloneEditorInto(result.Index(i), value.Index(i))
}
}
return result
default:
result := reflect.New(value.Type()).Elem()
cloneEditorInto(result, value)
return result
}
}
// cloneEditorInto 直接填充目标结构,避免指针及切片成员的二次结构分配
// 入参: target 可写目标, source 原值
func cloneEditorInto(target, source reflect.Value) {
switch source.Kind() {
case reflect.Struct:
target.Set(source)
for _, i := range editorCloneFields(source.Type()) {
cloneEditorInto(target.Field(i), source.Field(i))
}
case reflect.Array:
target.Set(source)
if editorCloneMutable(source.Type().Elem()) {
for i := range source.Len() {
cloneEditorInto(target.Index(i), source.Index(i))
}
}
default:
target.Set(cloneEditorValue(source))
}
}
// editorCloneMutable 判断类型是否含有需要隔离的公开可变成员
// 入参: kind 数据类型
// 返回: bool 是否需要独立复制
func editorCloneMutable(kind reflect.Type) bool {
switch kind.Kind() {
case reflect.Pointer, reflect.Slice:
return true
case reflect.Array:
return editorCloneMutable(kind.Elem())
case reflect.Struct:
return len(editorCloneFields(kind)) != 0
}
return false
}
// editorCloneFields 按类型复用公开可变字段,索引发布后保持只读
// 入参: kind 结构类型
// 返回: []int 可变字段索引
func editorCloneFields(kind reflect.Type) []int {
if cached, ok := editorClonePlans.Load(kind); ok {
return cached.([]int)
}
var fields []int
for i := range kind.NumField() {
field := kind.Field(i)
if field.IsExported() && editorCloneMutable(field.Type) {
fields = append(fields, i)
}
}
actual, _ := editorClonePlans.LoadOrStore(kind, fields)
return actual.([]int)
}
-82
View File
@@ -1,82 +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"
"crypto/sha256"
"fmt"
"strings"
)
// AddMedia 注册音频或视频资源,保持原始编码,资源被动作引用后写入文档
// 本方法不转码或验证媒体编解码器,播放能力由调用方提供
// 相同类型、格式及内容复用标识;需要独立控制时可通过CopyMedia复制资源
// 入参: kind 为Audio或Video, format 媒体格式,可为空, data 完整媒体文件
// 返回: string 资源标识, error 无效类型或数据
func (e *Editor) AddMedia(kind, format string, data []byte) (string, error) {
if kind != "Audio" && kind != "Video" {
return "", fmt.Errorf("media type must be Audio or Video")
}
if len(data) == 0 {
return "", fmt.Errorf("empty media data")
}
format = strings.ToUpper(strings.TrimSpace(format))
key := editorResourceKey{checksum: sha256.Sum256(data), index: -1, kind: kind + "/" + format}
if id, ok := e.resourceID[key]; ok {
return id, nil
}
if err := e.prepareSourceIDs(); err != nil {
return "", err
}
id := e.nextID()
name := e.packageName("Res/Media/Media_" + id)
e.resources = append(e.resources, editorResource{name: name, data: bytes.Clone(data), image: &MultiMedia{ID: id, Type: kind, Format: format, MediaFile: "/" + name}})
e.resourceID[key] = id
return id, nil
}
// CopyMedia 复制音视频资源定义,共用原始文件,以新标识区分播放控制
// 原页面资源须先通过Page读取;未被动作引用的副本不写入文档
// 入参: id 音视频资源标识
// 返回: string 新资源标识, error 资源缺失、类型或包结构错误
func (e *Editor) CopyMedia(id string) (string, error) {
var copy editorResource
for _, resource := range e.resources {
if resource.image != nil && sameResourceID(resource.image.ID, id) {
copy = resource
break
}
}
if copy.image == nil && e.source != nil {
media, err := e.source.reader.Media(id)
if err != nil {
return "", err
}
media.MediaFile = "/" + cleanPackagePath(e.source.reader.ResPath(media.MediaFile))
copy.image = &media
}
if copy.image == nil || copy.image.Type != "Audio" && copy.image.Type != "Video" {
return "", fmt.Errorf("audio or video resource %q is unavailable", id)
}
if err := e.prepareSourceIDs(); err != nil {
return "", err
}
media := *copy.image
media.ID = e.nextID()
copy.image = &media
e.resources = append(e.resources, copy)
return media.ID, nil
}
+111
View File
@@ -17,9 +17,11 @@ package ofdgo
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"compress/flate"
"context" "context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"hash/crc32"
"io" "io"
"maps" "maps"
"reflect" "reflect"
@@ -28,6 +30,25 @@ import (
"strings" "strings"
) )
// editorStagedPage 保存单次写出的压缩页面及校验值,不持有未压缩XML
type editorStagedPage struct {
data []byte
size uint64
checksum uint32
}
// editorPageStager 复用单次保存的页面压缩器,不跨文档共享状态
type editorPageStager struct {
compressor *flate.Writer
}
// editorPageEncoding 同步统计编码字节及CRC,保留实际写入错误
type editorPageEncoding struct {
writer io.Writer
size uint64
checksum uint32
}
// sourceParts 生成编辑快照的XML和新增资源,同步清理已删除页面及附件的引用 // sourceParts 生成编辑快照的XML和新增资源,同步清理已删除页面及附件的引用
// 入参: ctx 取消上下文, progress 保存进度回调,预览时为nil // 入参: ctx 取消上下文, progress 保存进度回调,预览时为nil
// 返回: map[string][]byte 替换及新增条目, error 错误信息 // 返回: map[string][]byte 替换及新增条目, error 错误信息
@@ -810,3 +831,93 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re
} }
return reader, nil return reader, nil
} }
// Write 写入页面字节并统计实际接收的部分
// 入参: data 未压缩编码数据
// 返回: int 实际字节数, error 写入或短写错误
func (w *editorPageEncoding) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
w.size += uint64(n)
w.checksum = crc32.Update(w.checksum, crc32.IEEETable, data[:n])
if n != len(data) && err == nil {
err = io.ErrShortWrite
}
return n, err
}
// encode 边编码边压缩自产页面,同步收集引用及用字
// 入参: editor 保存快照, page 页面内容, references 本次引用扫描
// 返回: *editorStagedPage 压缩页面, error 编码或取消错误
func (s *editorPageStager) encode(editor *Editor, page PageContent, references *editorReferenceScan) (*editorStagedPage, error) {
var data bytes.Buffer
if s.compressor == nil {
level := flate.DefaultCompression
if editor.output != nil && editor.output.options.Mode != CompressionUnchanged {
level = flate.BestCompression
}
var err error
s.compressor, err = flate.NewWriter(&data, level)
if err != nil {
return nil, err
}
} else {
s.compressor.Reset(&data)
}
output := editorPageEncoding{writer: s.compressor}
x := &ofdXML{encoder: xml.NewEncoder(&output), references: references}
if editor.output != nil {
x.ctx = editor.output.ctx
}
x.page(page)
if err := x.finish(); err != nil {
return nil, err
}
if err := s.compressor.Close(); err != nil {
return nil, err
}
if !x.checkContext() {
return nil, x.err
}
return &editorStagedPage{data: data.Bytes(), size: output.size, checksum: output.checksum}, nil
}
// open 流式读取暂存页面,仅用于无法完整收集编码引用的分支
// 返回: io.ReadCloser 页面解压流
func (p *editorStagedPage) open() io.ReadCloser {
return flate.NewReader(bytes.NewReader(p.data))
}
// writeStagedPage 直接写入已经压缩的自产页面,保持ZIP条目属性和取消语义
// 入参: archive 输出归档, header 条目属性, page 暂存页面
// 返回: error 写入、短写或取消错误
func (e *Editor) writeStagedPage(archive *zip.Writer, header zip.FileHeader, page *editorStagedPage) error {
if e.output != nil && e.output.options.Mode != CompressionUnchanged {
if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil {
return err
}
}
prepareOutputHeader(&header, zip.Deflate, page.size, uint64(len(page.data)), page.checksum)
entry, err := archive.CreateRaw(&header)
if err != nil {
return err
}
for offset := 0; offset < len(page.data); {
if e.output != nil {
if err := e.output.ctx.Err(); err != nil {
return err
}
}
end := min(offset+64<<10, len(page.data))
if n, err := entry.Write(page.data[offset:end]); err != nil {
return err
} else if n != end-offset {
return io.ErrShortWrite
}
offset = end
}
if e.output != nil && e.output.options.Mode != CompressionUnchanged {
e.output.completed++
return editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0)
}
return nil
}
+61
View File
@@ -17,6 +17,7 @@ package ofdgo
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"fmt" "fmt"
"image" "image"
"path" "path"
@@ -662,3 +663,63 @@ func resourceActionMatches(action Action, kind, id string) bool {
return false return false
} }
} }
// AddMedia 注册音频或视频资源,保持原始编码,资源被动作引用后写入文档
// 本方法不转码或验证媒体编解码器,播放能力由调用方提供
// 相同类型、格式及内容复用标识;需要独立控制时可通过CopyMedia复制资源
// 入参: kind 为Audio或Video, format 媒体格式,可为空, data 完整媒体文件
// 返回: string 资源标识, error 无效类型或数据
func (e *Editor) AddMedia(kind, format string, data []byte) (string, error) {
if kind != "Audio" && kind != "Video" {
return "", fmt.Errorf("media type must be Audio or Video")
}
if len(data) == 0 {
return "", fmt.Errorf("empty media data")
}
format = strings.ToUpper(strings.TrimSpace(format))
key := editorResourceKey{checksum: sha256.Sum256(data), index: -1, kind: kind + "/" + format}
if id, ok := e.resourceID[key]; ok {
return id, nil
}
if err := e.prepareSourceIDs(); err != nil {
return "", err
}
id := e.nextID()
name := e.packageName("Res/Media/Media_" + id)
e.resources = append(e.resources, editorResource{name: name, data: bytes.Clone(data), image: &MultiMedia{ID: id, Type: kind, Format: format, MediaFile: "/" + name}})
e.resourceID[key] = id
return id, nil
}
// CopyMedia 复制音视频资源定义,共用原始文件,以新标识区分播放控制
// 原页面资源须先通过Page读取;未被动作引用的副本不写入文档
// 入参: id 音视频资源标识
// 返回: string 新资源标识, error 资源缺失、类型或包结构错误
func (e *Editor) CopyMedia(id string) (string, error) {
var copy editorResource
for _, resource := range e.resources {
if resource.image != nil && sameResourceID(resource.image.ID, id) {
copy = resource
break
}
}
if copy.image == nil && e.source != nil {
media, err := e.source.reader.Media(id)
if err != nil {
return "", err
}
media.MediaFile = "/" + cleanPackagePath(e.source.reader.ResPath(media.MediaFile))
copy.image = &media
}
if copy.image == nil || copy.image.Type != "Audio" && copy.image.Type != "Video" {
return "", fmt.Errorf("audio or video resource %q is unavailable", id)
}
if err := e.prepareSourceIDs(); err != nil {
return "", err
}
media := *copy.image
media.ID = e.nextID()
copy.image = &media
e.resources = append(e.resources, copy)
return media.ID, nil
}
-133
View File
@@ -1,133 +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"
"compress/flate"
"encoding/xml"
"hash/crc32"
"io"
)
// editorStagedPage 保存单次写出的压缩页面及校验值,不持有未压缩XML
type editorStagedPage struct {
data []byte
size uint64
checksum uint32
}
// editorPageStager 复用单次保存的页面压缩器,不跨文档共享状态
type editorPageStager struct {
compressor *flate.Writer
}
// editorPageEncoding 同步统计编码字节及CRC,保留实际写入错误
type editorPageEncoding struct {
writer io.Writer
size uint64
checksum uint32
}
// Write 写入页面字节并统计实际接收的部分
// 入参: data 未压缩编码数据
// 返回: int 实际字节数, error 写入或短写错误
func (w *editorPageEncoding) Write(data []byte) (int, error) {
n, err := w.writer.Write(data)
w.size += uint64(n)
w.checksum = crc32.Update(w.checksum, crc32.IEEETable, data[:n])
if n != len(data) && err == nil {
err = io.ErrShortWrite
}
return n, err
}
// encode 边编码边压缩自产页面,同步收集引用及用字
// 入参: editor 保存快照, page 页面内容, references 本次引用扫描
// 返回: *editorStagedPage 压缩页面, error 编码或取消错误
func (s *editorPageStager) encode(editor *Editor, page PageContent, references *editorReferenceScan) (*editorStagedPage, error) {
var data bytes.Buffer
if s.compressor == nil {
level := flate.DefaultCompression
if editor.output != nil && editor.output.options.Mode != CompressionUnchanged {
level = flate.BestCompression
}
var err error
s.compressor, err = flate.NewWriter(&data, level)
if err != nil {
return nil, err
}
} else {
s.compressor.Reset(&data)
}
output := editorPageEncoding{writer: s.compressor}
x := &ofdXML{encoder: xml.NewEncoder(&output), references: references}
if editor.output != nil {
x.ctx = editor.output.ctx
}
x.page(page)
if err := x.finish(); err != nil {
return nil, err
}
if err := s.compressor.Close(); err != nil {
return nil, err
}
if !x.checkContext() {
return nil, x.err
}
return &editorStagedPage{data: data.Bytes(), size: output.size, checksum: output.checksum}, nil
}
// open 流式读取暂存页面,仅用于无法完整收集编码引用的分支
// 返回: io.ReadCloser 页面解压流
func (p *editorStagedPage) open() io.ReadCloser {
return flate.NewReader(bytes.NewReader(p.data))
}
// writeStagedPage 直接写入已经压缩的自产页面,保持ZIP条目属性和取消语义
// 入参: archive 输出归档, header 条目属性, page 暂存页面
// 返回: error 写入、短写或取消错误
func (e *Editor) writeStagedPage(archive *zip.Writer, header zip.FileHeader, page *editorStagedPage) error {
if e.output != nil && e.output.options.Mode != CompressionUnchanged {
if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil {
return err
}
}
prepareOutputHeader(&header, zip.Deflate, page.size, uint64(len(page.data)), page.checksum)
entry, err := archive.CreateRaw(&header)
if err != nil {
return err
}
for offset := 0; offset < len(page.data); {
if e.output != nil {
if err := e.output.ctx.Err(); err != nil {
return err
}
}
end := min(offset+64<<10, len(page.data))
if n, err := entry.Write(page.data[offset:end]); err != nil {
return err
} else if n != end-offset {
return io.ErrShortWrite
}
offset = end
}
if e.output != nil && e.output.options.Mode != CompressionUnchanged {
e.output.completed++
return editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0)
}
return nil
}
+199
View File
@@ -18,10 +18,25 @@ import (
"context" "context"
"fmt" "fmt"
"image" "image"
"image/color"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
) )
// imageCacheLimit 限制图片缓存的编码数据及预计解码内存
const imageCacheLimit = 64 << 20
// EncodedImage 保留PNG或JPEG原始编码,首次读取像素时解码
// Bytes返回只读数据,Image返回共享只读像素,解码错误由Image返回
type EncodedImage = baseimage.EncodedImage
// resampledImageMask 按替换图尺寸读取已校验的二值蒙版,不分配缩放像素
type resampledImageMask struct {
source image.Image
original image.Rectangle
bounds image.Rectangle
}
// imageWithEffects 合并同尺寸二值蒙版和对象透明度,保留原图采样精度及只读像素 // imageWithEffects 合并同尺寸二值蒙版和对象透明度,保留原图采样精度及只读像素
// 入参: ctx 取消上下文,nil使用后台上下文, img 原图, mask 二值蒙版,可为空, alpha 对象透明度,可为空 // 入参: ctx 取消上下文,nil使用后台上下文, img 原图, mask 二值蒙版,可为空, alpha 对象透明度,可为空
// 返回: image.Image 处理后的图片, error 蒙版、解码或取消错误 // 返回: image.Image 处理后的图片, error 蒙版、解码或取消错误
@@ -167,3 +182,187 @@ func validateImageMask(ctx context.Context, mask image.Image, size image.Point)
} }
return ctx.Err() return ctx.Err()
} }
// ImageResource 按资源标识读取只读图片,PNG和JPEG保留原始编码并惰性解码
// 应先读取源页面以加载页面资源,返回EncodedImage时通过Image检查像素解码错误
// 入参: id 图片资源标识
// 返回: image.Image 图片资源, error 读取错误
func (r *Renderer) ImageResource(id string) (image.Image, error) {
path, ok := resourceValue(r.Reader.ResMap, id)
if !ok {
return nil, fmt.Errorf("image resource %q not found", id)
}
return r.cachedImageResource(path)
}
// imageObjectResource 读取主图,无法读取或解码时使用对象声明的替换图
// 入参: object 图像对象
// 返回: image.Image 图片资源, error 读取或解码错误
func (r *Renderer) imageObjectResource(object ImageObject) (image.Image, error) {
read := func(id string) (image.Image, error) {
img, err := r.ImageResource(id)
if err == nil && (r.decodeImages || object.Substitution != "") {
pixels, decodeErr := baseimage.PixelDataContext(r.pageContext, img)
err = decodeErr
if r.decodeImages {
img = pixels
}
}
return img, err
}
img, err := read(object.ResourceID)
if err == nil || object.Substitution == "" || sameResourceID(object.Substitution, object.ResourceID) {
return img, err
}
if r.pageContext != nil && r.pageContext.Err() != nil {
return nil, r.pageContext.Err()
}
if r.OutputContext != nil && r.OutputContext.Err() != nil {
return nil, r.OutputContext.Err()
}
img, substituteErr := read(object.Substitution)
if substituteErr != nil {
return nil, fmt.Errorf("image resource %q: %w; substitution %q: %w", object.ResourceID, err, object.Substitution, substituteErr)
}
return img, nil
}
// imageObjectMask 校验原图蒙版,替换图分辨率不同时按像素中心进行最近邻采样
// 入参: object 图片对象, target 实际绘制图像
// 返回: image.Image 二值蒙版,无蒙版时为nil, error 资源或蒙版错误
func (r *Renderer) imageObjectMask(object ImageObject, target image.Image) (image.Image, error) {
if object.ImageMask == "" {
return nil, nil
}
mask, err := r.ImageResource(object.ImageMask)
if err != nil {
return nil, err
}
size := target.Bounds().Size()
if object.Substitution == "" {
if mask.Bounds().Size() == size {
return mask, nil
}
return nil, fmt.Errorf("image mask dimensions differ from source")
}
original, err := r.ImageResource(object.ResourceID)
if err != nil {
if mask.Bounds().Size() == size {
return mask, nil
}
return nil, fmt.Errorf("cannot verify image mask dimensions: %w", err)
}
if mask.Bounds().Size() != original.Bounds().Size() {
return nil, fmt.Errorf("image mask dimensions differ from source")
}
if mask.Bounds().Size() == size {
return mask, nil
}
ctx := r.pageContext
if ctx == nil {
ctx = context.Background()
}
if err := validateImageMask(ctx, mask, original.Bounds().Size()); err != nil {
return nil, err
}
mask, err = baseimage.PixelDataContext(ctx, mask)
if err != nil {
return nil, err
}
return &resampledImageMask{source: mask, original: mask.Bounds(), bounds: image.Rectangle{Max: size}}, ctx.Err()
}
// Bounds 返回替换图对应的蒙版范围
// 返回: image.Rectangle 像素区域
func (s *resampledImageMask) Bounds() image.Rectangle { return s.bounds }
// ColorModel 返回二值蒙版的灰度颜色模型
// 返回: color.Model 灰度模型
func (s *resampledImageMask) ColorModel() color.Model { return color.GrayModel }
// GrayAt 按像素中心读取原蒙版,区域外返回黑色
// 入参: x 横坐标, y 纵坐标
// 返回: color.Gray 二值灰度
func (s *resampledImageMask) GrayAt(x, y int) color.Gray {
if !image.Pt(x, y).In(s.bounds) {
return color.Gray{}
}
bounds, size := s.original, s.bounds.Size()
sx := bounds.Min.X + int((uint64(x-s.bounds.Min.X)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X))
sy := bounds.Min.Y + int((uint64(y-s.bounds.Min.Y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y))
return baseimage.GrayAt(s.source, sx, sy)
}
// At 返回采样后的二值灰度
// 入参: x 横坐标, y 纵坐标
// 返回: color.Color 像素颜色
func (s *resampledImageMask) At(x, y int) color.Color { return s.GrayAt(x, y) }
// BackingImage 返回共享只读蒙版,供图像缓存计算底层存储
// 返回: image.Image 共享只读蒙版
func (s *resampledImageMask) BackingImage() image.Image { return s.source }
// RGBA64At 返回不透明的十六位灰度颜色,供蒙版合成直接读取
// 入参: x 横坐标, y 纵坐标
// 返回: color.RGBA64 像素颜色
func (s *resampledImageMask) RGBA64At(x, y int) color.RGBA64 {
value := uint16(s.GrayAt(x, y).Y) * 257
return color.RGBA64{R: value, G: value, B: value, A: 65535}
}
// cachedImageResource 复用原始图片资源,不依赖输出后端
// 入参: resPath 图片资源路径
// 返回: image.Image 图片对象, error 读取错误
func (r *Renderer) cachedImageResource(resPath string) (image.Image, error) {
if r.pageContext != nil {
if err := r.pageContext.Err(); err != nil {
return nil, err
}
}
resPath = cleanPackagePath(r.Reader.ResPath(resPath))
img, ok := r.imageCache.get(resPath)
if !ok {
var err error
img, err = r.readImageResource(resPath)
if err != nil {
return nil, err
}
r.imageCache.put(resPath, img, baseimage.StorageCost(img, r.imageCache.limit))
}
return img, nil
}
// decodeImageResource 读取或复用图片资源,按输出需要解码像素
// 入参: resPath 图片资源路径
// 返回: image.Image 图片对象, error 读取或解码错误
func (r *Renderer) decodeImageResource(resPath string) (image.Image, error) {
img, err := r.cachedImageResource(resPath)
if err != nil {
return nil, err
}
if r.decodeImages {
return baseimage.PixelDataContext(r.pageContext, img)
}
return img, nil
}
// readImageResource 按页面上下文读取图片,借用已加载的只读编码
// 入参: resPath 图片资源路径
// 返回: image.Image 图片对象, error 读取或解码错误
func (r *Renderer) readImageResource(resPath string) (image.Image, error) {
ctx := r.pageContext
if ctx == nil {
ctx = context.Background()
}
data, err := r.Reader.readFileViewContext(ctx, resPath)
if err != nil {
return nil, err
}
img, _, err := baseimage.DecodeContext(ctx, data)
return img, err
}
// NewEncodedImage 复制输入数据并读取PNG或JPEG头信息,不解码像素
// 入参: data 原始编码
// 返回: *EncodedImage 惰性图片, error 头信息或格式错误
func NewEncodedImage(data []byte) (*EncodedImage, error) { return baseimage.NewEncodedImage(data) }
-222
View File
@@ -1,222 +0,0 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"context"
"fmt"
"image"
"image/color"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
)
// imageCacheLimit 限制图片缓存的编码数据及预计解码内存
const imageCacheLimit = 64 << 20
// EncodedImage 保留PNG或JPEG原始编码,首次读取像素时解码
// Bytes返回只读数据,Image返回共享只读像素,解码错误由Image返回
type EncodedImage = baseimage.EncodedImage
// resampledImageMask 按替换图尺寸读取已校验的二值蒙版,不分配缩放像素
type resampledImageMask struct {
source image.Image
original image.Rectangle
bounds image.Rectangle
}
// ImageResource 按资源标识读取只读图片,PNG和JPEG保留原始编码并惰性解码
// 应先读取源页面以加载页面资源,返回EncodedImage时通过Image检查像素解码错误
// 入参: id 图片资源标识
// 返回: image.Image 图片资源, error 读取错误
func (r *Renderer) ImageResource(id string) (image.Image, error) {
path, ok := resourceValue(r.Reader.ResMap, id)
if !ok {
return nil, fmt.Errorf("image resource %q not found", id)
}
return r.cachedImageResource(path)
}
// imageObjectResource 读取主图,无法读取或解码时使用对象声明的替换图
// 入参: object 图像对象
// 返回: image.Image 图片资源, error 读取或解码错误
func (r *Renderer) imageObjectResource(object ImageObject) (image.Image, error) {
read := func(id string) (image.Image, error) {
img, err := r.ImageResource(id)
if err == nil && (r.decodeImages || object.Substitution != "") {
pixels, decodeErr := baseimage.PixelDataContext(r.pageContext, img)
err = decodeErr
if r.decodeImages {
img = pixels
}
}
return img, err
}
img, err := read(object.ResourceID)
if err == nil || object.Substitution == "" || sameResourceID(object.Substitution, object.ResourceID) {
return img, err
}
if r.pageContext != nil && r.pageContext.Err() != nil {
return nil, r.pageContext.Err()
}
if r.OutputContext != nil && r.OutputContext.Err() != nil {
return nil, r.OutputContext.Err()
}
img, substituteErr := read(object.Substitution)
if substituteErr != nil {
return nil, fmt.Errorf("image resource %q: %w; substitution %q: %w", object.ResourceID, err, object.Substitution, substituteErr)
}
return img, nil
}
// imageObjectMask 校验原图蒙版,替换图分辨率不同时按像素中心进行最近邻采样
// 入参: object 图片对象, target 实际绘制图像
// 返回: image.Image 二值蒙版,无蒙版时为nil, error 资源或蒙版错误
func (r *Renderer) imageObjectMask(object ImageObject, target image.Image) (image.Image, error) {
if object.ImageMask == "" {
return nil, nil
}
mask, err := r.ImageResource(object.ImageMask)
if err != nil {
return nil, err
}
size := target.Bounds().Size()
if object.Substitution == "" {
if mask.Bounds().Size() == size {
return mask, nil
}
return nil, fmt.Errorf("image mask dimensions differ from source")
}
original, err := r.ImageResource(object.ResourceID)
if err != nil {
if mask.Bounds().Size() == size {
return mask, nil
}
return nil, fmt.Errorf("cannot verify image mask dimensions: %w", err)
}
if mask.Bounds().Size() != original.Bounds().Size() {
return nil, fmt.Errorf("image mask dimensions differ from source")
}
if mask.Bounds().Size() == size {
return mask, nil
}
ctx := r.pageContext
if ctx == nil {
ctx = context.Background()
}
if err := validateImageMask(ctx, mask, original.Bounds().Size()); err != nil {
return nil, err
}
mask, err = baseimage.PixelDataContext(ctx, mask)
if err != nil {
return nil, err
}
return &resampledImageMask{source: mask, original: mask.Bounds(), bounds: image.Rectangle{Max: size}}, ctx.Err()
}
// Bounds 返回替换图对应的蒙版范围
// 返回: image.Rectangle 像素区域
func (s *resampledImageMask) Bounds() image.Rectangle { return s.bounds }
// ColorModel 返回二值蒙版的灰度颜色模型
// 返回: color.Model 灰度模型
func (s *resampledImageMask) ColorModel() color.Model { return color.GrayModel }
// GrayAt 按像素中心读取原蒙版,区域外返回黑色
// 入参: x 横坐标, y 纵坐标
// 返回: color.Gray 二值灰度
func (s *resampledImageMask) GrayAt(x, y int) color.Gray {
if !image.Pt(x, y).In(s.bounds) {
return color.Gray{}
}
bounds, size := s.original, s.bounds.Size()
sx := bounds.Min.X + int((uint64(x-s.bounds.Min.X)*uint64(bounds.Dx())+uint64(bounds.Dx()/2))/uint64(size.X))
sy := bounds.Min.Y + int((uint64(y-s.bounds.Min.Y)*uint64(bounds.Dy())+uint64(bounds.Dy()/2))/uint64(size.Y))
return baseimage.GrayAt(s.source, sx, sy)
}
// At 返回采样后的二值灰度
// 入参: x 横坐标, y 纵坐标
// 返回: color.Color 像素颜色
func (s *resampledImageMask) At(x, y int) color.Color { return s.GrayAt(x, y) }
// BackingImage 返回共享只读蒙版,供图像缓存计算底层存储
// 返回: image.Image 共享只读蒙版
func (s *resampledImageMask) BackingImage() image.Image { return s.source }
// RGBA64At 返回不透明的十六位灰度颜色,供蒙版合成直接读取
// 入参: x 横坐标, y 纵坐标
// 返回: color.RGBA64 像素颜色
func (s *resampledImageMask) RGBA64At(x, y int) color.RGBA64 {
value := uint16(s.GrayAt(x, y).Y) * 257
return color.RGBA64{R: value, G: value, B: value, A: 65535}
}
// cachedImageResource 复用原始图片资源,不依赖输出后端
// 入参: resPath 图片资源路径
// 返回: image.Image 图片对象, error 读取错误
func (r *Renderer) cachedImageResource(resPath string) (image.Image, error) {
if r.pageContext != nil {
if err := r.pageContext.Err(); err != nil {
return nil, err
}
}
resPath = cleanPackagePath(r.Reader.ResPath(resPath))
img, ok := r.imageCache.get(resPath)
if !ok {
var err error
img, err = r.readImageResource(resPath)
if err != nil {
return nil, err
}
r.imageCache.put(resPath, img, baseimage.StorageCost(img, r.imageCache.limit))
}
return img, nil
}
// decodeImageResource 读取或复用图片资源,按输出需要解码像素
// 入参: resPath 图片资源路径
// 返回: image.Image 图片对象, error 读取或解码错误
func (r *Renderer) decodeImageResource(resPath string) (image.Image, error) {
img, err := r.cachedImageResource(resPath)
if err != nil {
return nil, err
}
if r.decodeImages {
return baseimage.PixelDataContext(r.pageContext, img)
}
return img, nil
}
// readImageResource 按页面上下文读取图片,借用已加载的只读编码
// 入参: resPath 图片资源路径
// 返回: image.Image 图片对象, error 读取或解码错误
func (r *Renderer) readImageResource(resPath string) (image.Image, error) {
ctx := r.pageContext
if ctx == nil {
ctx = context.Background()
}
data, err := r.Reader.readFileViewContext(ctx, resPath)
if err != nil {
return nil, err
}
img, _, err := baseimage.DecodeContext(ctx, data)
return img, err
}
// NewEncodedImage 复制输入数据并读取PNG或JPEG头信息,不解码像素
// 入参: data 原始编码
// 返回: *EncodedImage 惰性图片, error 头信息或格式错误
func NewEncodedImage(data []byte) (*EncodedImage, error) { return baseimage.NewEncodedImage(data) }
-46
View File
@@ -1,46 +0,0 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"fmt"
"io"
)
// Media 获取已加载的音视频资源,页面资源须先读取所属页面
// 不加载注解或签名
// 入参: id 资源标识
// 返回: MultiMedia 资源信息, error 资源缺失或类型错误
func (r *Reader) Media(id string) (MultiMedia, error) {
if _, err := r.docStructure(); err != nil {
return MultiMedia{}, err
}
media, ok := resourceValue(r.mediaCache, id)
if !ok || media.Type != "Audio" && media.Type != "Video" {
return MultiMedia{}, fmt.Errorf("audio or video resource %q is unavailable", id)
}
return media, nil
}
// OpenMedia 打开文档内音视频原始数据,不解码或执行播放
// 入参: id 资源标识
// 返回: io.ReadCloser 数据流,由调用方关闭, error 资源或文件读取错误
func (r *Reader) OpenMedia(id string) (io.ReadCloser, error) {
media, err := r.Media(id)
if err != nil {
return nil, err
}
return r.openFile(r.ResPath(media.MediaFile))
}
+132
View File
@@ -16,12 +16,16 @@ package ofdgo
import ( import (
"context" "context"
"crypto/sha256"
"encoding/binary"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
"math" "math"
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
"golang.org/x/image/draw"
) )
// 光栅后端接收的路径指令 // 光栅后端接收的路径指令
@@ -382,3 +386,131 @@ func rasterCommandGeometry(geometry GeometryBackend, command RasterCommand) (Geo
} }
return path, nil return path, nil
} }
// rasterClipKey 区分页面尺寸、分辨率和裁剪路径
// 入参: page 页面, path 页面坐标路径
// 返回: [32]byte 缓存键
func rasterClipKey(page *RasterPage, path []RasterSegment) [32]byte {
hash := sha256.New()
var data [8]byte
number := func(v float64) {
binary.LittleEndian.PutUint64(data[:], math.Float64bits(v))
hash.Write(data[:])
}
number(page.Width)
number(page.Height)
number(page.DPI)
for _, s := range path {
hash.Write([]byte{byte(s.Verb)})
for _, p := range []RasterPoint{s.End, s.Control1, s.Control2} {
number(p.X)
number(p.Y)
}
}
var key [32]byte
copy(key[:], hash.Sum(nil))
return key
}
// rasterClipMask 使用所选后端生成区域化透明度蒙版,同页相同裁剪可复用
// 入参: backend 光栅后端, page 页面, path 页面坐标裁剪, cache 蒙版缓存
// 返回: *image.Alpha 页面像素坐标蒙版, error 绘制错误
func rasterClipMask(backend RasterBackend, page *RasterPage, path []RasterSegment, cache *renderCache[[32]byte, *image.Alpha]) (*image.Alpha, error) {
key := rasterClipKey(page, path)
if mask, ok := cache.get(key); ok {
return mask, nil
}
w, h, err := page.PixelSize()
if err != nil {
return nil, err
}
matrix := page.PixelTransform(RasterMatrix{1, 0, 0, 1, 0, 0}, h)
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
include := func(p RasterPoint) {
p = matrix.Apply(p)
minX = math.Min(minX, p.X)
minY = math.Min(minY, p.Y)
maxX = math.Max(maxX, p.X)
maxY = math.Max(maxY, p.Y)
}
for _, s := range path {
switch s.Verb {
case RasterMove, RasterLine:
include(s.End)
case RasterQuad:
include(s.End)
include(s.Control1)
case RasterCubic:
include(s.End)
include(s.Control1)
include(s.Control2)
case RasterClose:
default:
return nil, fmt.Errorf("invalid clip path command %d", s.Verb)
}
}
if len(path) == 0 || minX > maxX || minY > maxY {
return image.NewAlpha(image.Rectangle{}), nil
}
if !finite(minX) || !finite(minY) || !finite(maxX) || !finite(maxY) {
return nil, fmt.Errorf("invalid clip coordinates")
}
bounds := image.Rect(int(math.Max(0, math.Min(float64(w), math.Floor(minX)-1))), int(math.Max(0, math.Min(float64(h), math.Floor(minY)-1))), int(math.Max(0, math.Min(float64(w), math.Ceil(maxX)+1))), int(math.Max(0, math.Min(float64(h), math.Ceil(maxY)+1))))
mask := image.NewAlpha(bounds)
if !bounds.Empty() {
scale := page.DPI / 25.4
local := &RasterPage{Width: float64(bounds.Dx()) / scale, Height: float64(bounds.Dy()) / scale, DPI: page.DPI}
local.Commands = []RasterCommand{{Path: path, Paint: RasterPaint{Color: color.RGBA{255, 255, 255, 255}}, Transform: RasterMatrix{1, 0, 0, 1, -float64(bounds.Min.X) / scale, (matrix[5] - float64(bounds.Min.Y)) / scale}}}
pixels, err := backend.Render(local)
if err != nil {
return nil, err
}
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
start := mask.PixOffset(bounds.Min.X, y)
row := mask.Pix[start : start+bounds.Dx()]
for x := range row {
row[x] = uint8(baseimage.AlphaAt(pixels, x, y-bounds.Min.Y) >> 8)
}
}
}
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
}
-155
View File
@@ -1,155 +0,0 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"image"
"image/color"
"math"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
"golang.org/x/image/draw"
)
// rasterClipKey 区分页面尺寸、分辨率和裁剪路径
// 入参: page 页面, path 页面坐标路径
// 返回: [32]byte 缓存键
func rasterClipKey(page *RasterPage, path []RasterSegment) [32]byte {
hash := sha256.New()
var data [8]byte
number := func(v float64) {
binary.LittleEndian.PutUint64(data[:], math.Float64bits(v))
hash.Write(data[:])
}
number(page.Width)
number(page.Height)
number(page.DPI)
for _, s := range path {
hash.Write([]byte{byte(s.Verb)})
for _, p := range []RasterPoint{s.End, s.Control1, s.Control2} {
number(p.X)
number(p.Y)
}
}
var key [32]byte
copy(key[:], hash.Sum(nil))
return key
}
// rasterClipMask 使用所选后端生成区域化透明度蒙版,同页相同裁剪可复用
// 入参: backend 光栅后端, page 页面, path 页面坐标裁剪, cache 蒙版缓存
// 返回: *image.Alpha 页面像素坐标蒙版, error 绘制错误
func rasterClipMask(backend RasterBackend, page *RasterPage, path []RasterSegment, cache *renderCache[[32]byte, *image.Alpha]) (*image.Alpha, error) {
key := rasterClipKey(page, path)
if mask, ok := cache.get(key); ok {
return mask, nil
}
w, h, err := page.PixelSize()
if err != nil {
return nil, err
}
matrix := page.PixelTransform(RasterMatrix{1, 0, 0, 1, 0, 0}, h)
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
include := func(p RasterPoint) {
p = matrix.Apply(p)
minX = math.Min(minX, p.X)
minY = math.Min(minY, p.Y)
maxX = math.Max(maxX, p.X)
maxY = math.Max(maxY, p.Y)
}
for _, s := range path {
switch s.Verb {
case RasterMove, RasterLine:
include(s.End)
case RasterQuad:
include(s.End)
include(s.Control1)
case RasterCubic:
include(s.End)
include(s.Control1)
include(s.Control2)
case RasterClose:
default:
return nil, fmt.Errorf("invalid clip path command %d", s.Verb)
}
}
if len(path) == 0 || minX > maxX || minY > maxY {
return image.NewAlpha(image.Rectangle{}), nil
}
if !finite(minX) || !finite(minY) || !finite(maxX) || !finite(maxY) {
return nil, fmt.Errorf("invalid clip coordinates")
}
bounds := image.Rect(int(math.Max(0, math.Min(float64(w), math.Floor(minX)-1))), int(math.Max(0, math.Min(float64(h), math.Floor(minY)-1))), int(math.Max(0, math.Min(float64(w), math.Ceil(maxX)+1))), int(math.Max(0, math.Min(float64(h), math.Ceil(maxY)+1))))
mask := image.NewAlpha(bounds)
if !bounds.Empty() {
scale := page.DPI / 25.4
local := &RasterPage{Width: float64(bounds.Dx()) / scale, Height: float64(bounds.Dy()) / scale, DPI: page.DPI}
local.Commands = []RasterCommand{{Path: path, Paint: RasterPaint{Color: color.RGBA{255, 255, 255, 255}}, Transform: RasterMatrix{1, 0, 0, 1, -float64(bounds.Min.X) / scale, (matrix[5] - float64(bounds.Min.Y)) / scale}}}
pixels, err := backend.Render(local)
if err != nil {
return nil, err
}
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
start := mask.PixOffset(bounds.Min.X, y)
row := mask.Pix[start : start+bounds.Dx()]
for x := range row {
row[x] = uint8(baseimage.AlphaAt(pixels, x, y-bounds.Min.Y) >> 8)
}
}
}
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
}
-138
View File
@@ -1,138 +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"
"compress/zlib"
"context"
"encoding/ascii85"
"fmt"
"image"
"image/jpeg"
"io"
"math"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/ps"
"github.com/xiaoqidun/pdfgo"
)
// epsImageRenderer 在EPS输出端优化图片,不修改页面及渲染缓存
type epsImageRenderer struct {
*ps.PS
writer io.Writer
options CompressionOptions
ctx context.Context
err error
}
// RenderImage 写出Flate或更小的JPEG图片,保留显示尺寸与变换
// 入参: img 图片, matrix 绘制变换
func (r *epsImageRenderer) RenderImage(img image.Image, matrix canvas.Matrix) {
if r.err != nil {
return
}
if dpi := r.options.ImageDPI(); dpi > 0 {
original := img.Bounds().Size()
target := compressionImageSize(math.Hypot(matrix[0][0], matrix[1][0])*float64(original.X), math.Hypot(matrix[0][1], matrix[1][1])*float64(original.Y), dpi)
img, r.err = pdfgo.ResizeImage(r.ctx, img, target)
if r.err != nil {
return
}
size := img.Bounds().Size()
if size.X > 0 && size.Y > 0 {
matrix = matrix.Scale(float64(original.X)/float64(size.X), float64(original.Y)/float64(size.Y))
}
}
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
pixels := make([]byte, width*3)
var encoded bytes.Buffer
compressor, _ := zlib.NewWriterLevel(&encoded, zlib.BestCompression)
opaque := true
native, _ := img.(image.RGBA64Image)
for y := 0; y < height; y++ {
if r.err = r.ctx.Err(); r.err != nil {
return
}
clear(pixels)
for x := 0; x < width; x++ {
if x&4095 == 0 {
if r.err = r.ctx.Err(); r.err != nil {
return
}
}
var red, green, blue, alpha uint32
if native != nil {
pixel := native.RGBA64At(bounds.Min.X+x, bounds.Min.Y+y)
red, green, blue, alpha = uint32(pixel.R), uint32(pixel.G), uint32(pixel.B), uint32(pixel.A)
} else {
red, green, blue, alpha = img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
}
if alpha != 65535 {
opaque = false
}
if alpha == 65535 {
offset := x * 3
pixels[offset], pixels[offset+1], pixels[offset+2] = byte(red>>8), byte(green>>8), byte(blue>>8)
} else if alpha != 0 {
offset := x * 3
pixels[offset], pixels[offset+1], pixels[offset+2] = byte(red*65535/alpha>>8), byte(green*65535/alpha>>8), byte(blue*65535/alpha>>8)
}
}
if r.err = r.ctx.Err(); r.err != nil {
return
}
if _, r.err = compressor.Write(pixels); r.err != nil {
return
}
}
if r.err = compressor.Close(); r.err != nil {
return
}
data := encoded.Bytes()
filter := "FlateDecode"
if opaque && r.options.Mode == CompressionLossy {
var candidate bytes.Buffer
if r.err = jpeg.Encode(&candidate, img, &jpeg.Options{Quality: r.options.ImageQuality()}); r.err != nil {
return
}
optimized, err := pdfgo.OptimizeJPEG(r.ctx, candidate.Bytes())
if err != nil {
r.err = err
return
}
if len(optimized) < len(data) {
data, filter = optimized, "DCTDecode"
}
}
if r.err = r.ctx.Err(); r.err != nil {
return
}
m := matrix.Scale(float64(width), float64(height))
_, r.err = fmt.Fprintf(r.writer, " gsave /DeviceRGB setcolorspace [%s %s %s %s %s %s] concat <</ImageType 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /Interpolate true /Width %d /Height %d /ImageMatrix [%d 0 0 -%d 0 %d] /DataSource currentfile /ASCII85Decode filter /%s filter>>image\n", ofdNumber(m[0][0]), ofdNumber(m[1][0]), ofdNumber(m[0][1]), ofdNumber(m[1][1]), ofdNumber(m[0][2]), ofdNumber(m[1][2]), width, height, width, height, height, filter)
if r.err != nil {
return
}
writer := ascii85.NewEncoder(r.writer)
if _, r.err = writer.Write(data); r.err != nil {
return
}
if r.err = writer.Close(); r.err != nil {
return
}
_, r.err = io.WriteString(r.writer, "~>\n grestore")
}
+112
View File
@@ -17,9 +17,14 @@ package ofdgo
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"compress/zlib"
"context"
"encoding/ascii85"
"fmt" "fmt"
"image" "image"
"image/jpeg"
"io" "io"
"math"
"strconv" "strconv"
"github.com/tdewolff/canvas" "github.com/tdewolff/canvas"
@@ -35,6 +40,15 @@ type canvasEPSWriter struct {
io.Writer io.Writer
} }
// epsImageRenderer 在EPS输出端优化图片,不修改页面及渲染缓存
type epsImageRenderer struct {
*ps.PS
writer io.Writer
options CompressionOptions
ctx context.Context
err error
}
// RenderSVG 使用canvas输出SVG,按模式保留资源和对象分组 // RenderSVG 使用canvas输出SVG,按模式保留资源和对象分组
// 入参: r 渲染器, page 页面内容, writer 输出流, mode 资源模式 // 入参: r 渲染器, page 页面内容, writer 输出流, mode 资源模式
// 返回: SVGResources 外部资源, error 错误信息 // 返回: SVGResources 外部资源, error 错误信息
@@ -234,3 +248,101 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
} }
return renderer.writePDF(r.outputContext(), data, writer, options) return renderer.writePDF(r.outputContext(), data, writer, options)
} }
// RenderImage 写出Flate或更小的JPEG图片,保留显示尺寸与变换
// 入参: img 图片, matrix 绘制变换
func (r *epsImageRenderer) RenderImage(img image.Image, matrix canvas.Matrix) {
if r.err != nil {
return
}
if dpi := r.options.ImageDPI(); dpi > 0 {
original := img.Bounds().Size()
target := compressionImageSize(math.Hypot(matrix[0][0], matrix[1][0])*float64(original.X), math.Hypot(matrix[0][1], matrix[1][1])*float64(original.Y), dpi)
img, r.err = pdfgo.ResizeImage(r.ctx, img, target)
if r.err != nil {
return
}
size := img.Bounds().Size()
if size.X > 0 && size.Y > 0 {
matrix = matrix.Scale(float64(original.X)/float64(size.X), float64(original.Y)/float64(size.Y))
}
}
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
pixels := make([]byte, width*3)
var encoded bytes.Buffer
compressor, _ := zlib.NewWriterLevel(&encoded, zlib.BestCompression)
opaque := true
native, _ := img.(image.RGBA64Image)
for y := 0; y < height; y++ {
if r.err = r.ctx.Err(); r.err != nil {
return
}
clear(pixels)
for x := 0; x < width; x++ {
if x&4095 == 0 {
if r.err = r.ctx.Err(); r.err != nil {
return
}
}
var red, green, blue, alpha uint32
if native != nil {
pixel := native.RGBA64At(bounds.Min.X+x, bounds.Min.Y+y)
red, green, blue, alpha = uint32(pixel.R), uint32(pixel.G), uint32(pixel.B), uint32(pixel.A)
} else {
red, green, blue, alpha = img.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
}
if alpha != 65535 {
opaque = false
}
if alpha == 65535 {
offset := x * 3
pixels[offset], pixels[offset+1], pixels[offset+2] = byte(red>>8), byte(green>>8), byte(blue>>8)
} else if alpha != 0 {
offset := x * 3
pixels[offset], pixels[offset+1], pixels[offset+2] = byte(red*65535/alpha>>8), byte(green*65535/alpha>>8), byte(blue*65535/alpha>>8)
}
}
if r.err = r.ctx.Err(); r.err != nil {
return
}
if _, r.err = compressor.Write(pixels); r.err != nil {
return
}
}
if r.err = compressor.Close(); r.err != nil {
return
}
data := encoded.Bytes()
filter := "FlateDecode"
if opaque && r.options.Mode == CompressionLossy {
var candidate bytes.Buffer
if r.err = jpeg.Encode(&candidate, img, &jpeg.Options{Quality: r.options.ImageQuality()}); r.err != nil {
return
}
optimized, err := pdfgo.OptimizeJPEG(r.ctx, candidate.Bytes())
if err != nil {
r.err = err
return
}
if len(optimized) < len(data) {
data, filter = optimized, "DCTDecode"
}
}
if r.err = r.ctx.Err(); r.err != nil {
return
}
m := matrix.Scale(float64(width), float64(height))
_, r.err = fmt.Fprintf(r.writer, " gsave /DeviceRGB setcolorspace [%s %s %s %s %s %s] concat <</ImageType 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /Interpolate true /Width %d /Height %d /ImageMatrix [%d 0 0 -%d 0 %d] /DataSource currentfile /ASCII85Decode filter /%s filter>>image\n", ofdNumber(m[0][0]), ofdNumber(m[1][0]), ofdNumber(m[0][1]), ofdNumber(m[1][1]), ofdNumber(m[0][2]), ofdNumber(m[1][2]), width, height, width, height, height, filter)
if r.err != nil {
return
}
writer := ascii85.NewEncoder(r.writer)
if _, r.err = writer.Write(data); r.err != nil {
return
}
if r.err = writer.Close(); r.err != nil {
return
}
_, r.err = io.WriteString(r.writer, "~>\n grestore")
}
+192
View File
@@ -20,6 +20,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"maps"
"path" "path"
"slices" "slices"
"strings" "strings"
@@ -74,6 +75,14 @@ type resourcePageFrame struct {
link bool link bool
} }
// ResourceLocation 保存资源引用的页面实例范围,Page从1开始,Bounds单位为毫米
// Object为承载引用的图元标识;隐藏或完全裁剪的图元不产生定位结果
type ResourceLocation struct {
Page int `json:"page"`
Object string `json:"object"`
Bounds Box `json:"bounds"`
}
// Resources 列出当前文档及所选版本的资源,标记缺失文件,不读取二进制内容或执行媒体动作 // Resources 列出当前文档及所选版本的资源,标记缺失文件,不读取二进制内容或执行媒体动作
// 重复查询复用已完成的扫描,返回值及引用位置与阅读器相互独立 // 重复查询复用已完成的扫描,返回值及引用位置与阅读器相互独立
// 入参: ctx 取消上下文 // 入参: ctx 取消上下文
@@ -485,3 +494,186 @@ func findResource(items []ResourceInfo, kind, id string) (ResourceInfo, error) {
} }
return ResourceInfo{}, fmt.Errorf("resource not found: %s/%s", kind, id) return ResourceInfo{}, fmt.Errorf("resource not found: %s/%s", kind, id)
} }
// ResourceLocations 定位当前文档及版本内的一处资源引用,返回各页面上的可见使用范围
// 模板、图案和复合资源沿引用关系定位承载图元,不将资源声明位置当作页面位置
// 入参: ctx 取消上下文, kind 资源类型, id 资源标识, reference 为Resources返回的引用索引
// 返回: []ResourceLocation 页面位置,无可见图元时为空, error 引用、度量或取消错误
func (r *Renderer) ResourceLocations(ctx context.Context, kind, id string, reference int) ([]ResourceLocation, error) {
catalog, err := r.Reader.resourceCatalog(ctx)
if err != nil {
return nil, err
}
item, err := findResource(catalog.items, kind, id)
if err != nil {
return nil, err
}
if reference < 0 || reference >= len(item.References) {
return nil, fmt.Errorf("resource reference not found")
}
ref := item.References[reference]
doc, err := r.Reader.DocContext(ctx)
if err != nil {
return nil, err
}
wanted := make(map[string]bool)
users := make(map[string][]string)
for _, file := range catalog.scan.files {
root, err := parseEditorXMLContext(ctx, file.data)
if err != nil {
return nil, err
}
var visit func(*editorXML, string) error
visit = func(node *editorXML, object string) error {
if err := ctx.Err(); err != nil {
return err
}
if !node.matchesOFD(node.name.Local) || node.matchesOFD("Attachments") {
return nil
}
if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject"}, node.name.Local) {
object = node.attr("ID")
}
owner := resourceReferenceOwner(node)
if owner != "" && resourceColorNode(node) && node.attr("ColorSpace") == "" && doc.CommonData.DefaultCS != 0 {
dependency := fmt.Sprint(doc.CommonData.DefaultCS)
users[dependency] = append(users[dependency], owner)
if ref.Role == "DefaultCS" && sameResourceID(dependency, item.ID) {
wanted[owner] = true
}
}
add := func(role, value string) {
if owner == "" {
return
}
if role != "AttachID" {
if dependency := editorResourceID(value); dependency != "" && dependency != owner {
users[dependency] = append(users[dependency], owner)
}
}
match := value == item.ID
if kind != "Attachment" {
match = sameResourceID(value, item.ID)
}
if file.name == ref.File && object == ref.Object && role == ref.Role && match {
wanted[owner] = true
}
}
for _, attr := range node.attrs {
if attr.Name.Space == "" && (editorResourceReferenceKind(attr.Name.Local, root.matchesOFD("Res")) == editorReferenceID || attr.Name.Local == "AttachID") {
add(attr.Name.Local, attr.Value)
}
}
if slices.Contains([]string{"Thumbnail", "Substitution"}, node.name.Local) {
add(node.name.Local, strings.TrimSpace(editorImportText(file.data, node)))
}
parent := node.parent
for parent != nil && parent.matchesOFD("PageBlock") {
parent = parent.parent
}
if owner != "" && parent != nil && parent.matchesOFD("Layer") {
if layer := editorResourceID(parent.attr("ID")); layer != "" && layer != owner {
users[layer] = append(users[layer], owner)
}
}
for _, child := range node.children {
if err := visit(child, object); err != nil {
return err
}
}
return nil
}
if err := visit(root, ""); err != nil {
return nil, err
}
}
queue := slices.Collect(maps.Keys(wanted))
for len(queue) != 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
id := queue[0]
queue = queue[1:]
for _, user := range users[id] {
if !wanted[user] {
wanted[user] = true
queue = append(queue, user)
}
}
}
view := *r
view.RenderAnnotations, view.pageContext = true, ctx
var result []ResourceLocation
for page := range doc.Pages.Page {
if ref.Page >= 0 && ref.Page != page || len(wanted) == 0 {
continue
}
content, err := r.Reader.PageContentByIndexContext(ctx, page)
if err != nil {
return nil, err
}
targets := make(map[string]*StructureTarget, len(wanted))
for id := range wanted {
targets[id] = &StructureTarget{ID: id}
}
visitor := &structureVisitor{ctx: ctx, renderer: &view, targets: targets, boundsOnly: true}
if err := view.WalkPage(content, visitor); err != nil {
return nil, err
}
for _, id := range slices.Sorted(maps.Keys(targets)) {
box := targets[id].Bounds
if box.W > 0 && box.H > 0 {
result = append(result, ResourceLocation{Page: page + 1, Object: id, Bounds: box})
}
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
return result, nil
}
// resourceReferenceOwner 查找承载引用的资源定义或最外层图元,图层只承接其自身的绘制参数
// 入参: node 引用节点
// 返回: string 图元或资源标识,无法映射时为空
func resourceReferenceOwner(node *editorXML) string {
id := ""
for current := node; current != nil; current = current.parent {
if !current.matchesOFD(current.name.Local) {
return ""
}
if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit"}, current.name.Local) || current.parent != nil && current.name.Local == editorResourceElement(current.parent.name.Local) {
id = editorResourceID(current.attr("ID"))
}
if id == "" && current.matchesOFD("Layer") {
id = editorResourceID(current.attr("ID"))
}
}
return id
}
// Media 获取已加载的音视频资源,页面资源须先读取所属页面
// 不加载注解或签名
// 入参: id 资源标识
// 返回: MultiMedia 资源信息, error 资源缺失或类型错误
func (r *Reader) Media(id string) (MultiMedia, error) {
if _, err := r.docStructure(); err != nil {
return MultiMedia{}, err
}
media, ok := resourceValue(r.mediaCache, id)
if !ok || media.Type != "Audio" && media.Type != "Video" {
return MultiMedia{}, fmt.Errorf("audio or video resource %q is unavailable", id)
}
return media, nil
}
// OpenMedia 打开文档内音视频原始数据,不解码或执行播放
// 入参: id 资源标识
// 返回: io.ReadCloser 数据流,由调用方关闭, error 资源或文件读取错误
func (r *Reader) OpenMedia(id string) (io.ReadCloser, error) {
media, err := r.Media(id)
if err != nil {
return nil, err
}
return r.openFile(r.ResPath(media.MediaFile))
}
-188
View File
@@ -1,188 +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"
"strings"
)
// ResourceLocation 保存资源引用的页面实例范围,Page从1开始,Bounds单位为毫米
// Object为承载引用的图元标识;隐藏或完全裁剪的图元不产生定位结果
type ResourceLocation struct {
Page int `json:"page"`
Object string `json:"object"`
Bounds Box `json:"bounds"`
}
// ResourceLocations 定位当前文档及版本内的一处资源引用,返回各页面上的可见使用范围
// 模板、图案和复合资源沿引用关系定位承载图元,不将资源声明位置当作页面位置
// 入参: ctx 取消上下文, kind 资源类型, id 资源标识, reference 为Resources返回的引用索引
// 返回: []ResourceLocation 页面位置,无可见图元时为空, error 引用、度量或取消错误
func (r *Renderer) ResourceLocations(ctx context.Context, kind, id string, reference int) ([]ResourceLocation, error) {
catalog, err := r.Reader.resourceCatalog(ctx)
if err != nil {
return nil, err
}
item, err := findResource(catalog.items, kind, id)
if err != nil {
return nil, err
}
if reference < 0 || reference >= len(item.References) {
return nil, fmt.Errorf("resource reference not found")
}
ref := item.References[reference]
doc, err := r.Reader.DocContext(ctx)
if err != nil {
return nil, err
}
wanted := make(map[string]bool)
users := make(map[string][]string)
for _, file := range catalog.scan.files {
root, err := parseEditorXMLContext(ctx, file.data)
if err != nil {
return nil, err
}
var visit func(*editorXML, string) error
visit = func(node *editorXML, object string) error {
if err := ctx.Err(); err != nil {
return err
}
if !node.matchesOFD(node.name.Local) || node.matchesOFD("Attachments") {
return nil
}
if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject"}, node.name.Local) {
object = node.attr("ID")
}
owner := resourceReferenceOwner(node)
if owner != "" && resourceColorNode(node) && node.attr("ColorSpace") == "" && doc.CommonData.DefaultCS != 0 {
dependency := fmt.Sprint(doc.CommonData.DefaultCS)
users[dependency] = append(users[dependency], owner)
if ref.Role == "DefaultCS" && sameResourceID(dependency, item.ID) {
wanted[owner] = true
}
}
add := func(role, value string) {
if owner == "" {
return
}
if role != "AttachID" {
if dependency := editorResourceID(value); dependency != "" && dependency != owner {
users[dependency] = append(users[dependency], owner)
}
}
match := value == item.ID
if kind != "Attachment" {
match = sameResourceID(value, item.ID)
}
if file.name == ref.File && object == ref.Object && role == ref.Role && match {
wanted[owner] = true
}
}
for _, attr := range node.attrs {
if attr.Name.Space == "" && (editorResourceReferenceKind(attr.Name.Local, root.matchesOFD("Res")) == editorReferenceID || attr.Name.Local == "AttachID") {
add(attr.Name.Local, attr.Value)
}
}
if slices.Contains([]string{"Thumbnail", "Substitution"}, node.name.Local) {
add(node.name.Local, strings.TrimSpace(editorImportText(file.data, node)))
}
parent := node.parent
for parent != nil && parent.matchesOFD("PageBlock") {
parent = parent.parent
}
if owner != "" && parent != nil && parent.matchesOFD("Layer") {
if layer := editorResourceID(parent.attr("ID")); layer != "" && layer != owner {
users[layer] = append(users[layer], owner)
}
}
for _, child := range node.children {
if err := visit(child, object); err != nil {
return err
}
}
return nil
}
if err := visit(root, ""); err != nil {
return nil, err
}
}
queue := slices.Collect(maps.Keys(wanted))
for len(queue) != 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
id := queue[0]
queue = queue[1:]
for _, user := range users[id] {
if !wanted[user] {
wanted[user] = true
queue = append(queue, user)
}
}
}
view := *r
view.RenderAnnotations, view.pageContext = true, ctx
var result []ResourceLocation
for page := range doc.Pages.Page {
if ref.Page >= 0 && ref.Page != page || len(wanted) == 0 {
continue
}
content, err := r.Reader.PageContentByIndexContext(ctx, page)
if err != nil {
return nil, err
}
targets := make(map[string]*StructureTarget, len(wanted))
for id := range wanted {
targets[id] = &StructureTarget{ID: id}
}
visitor := &structureVisitor{ctx: ctx, renderer: &view, targets: targets, boundsOnly: true}
if err := view.WalkPage(content, visitor); err != nil {
return nil, err
}
for _, id := range slices.Sorted(maps.Keys(targets)) {
box := targets[id].Bounds
if box.W > 0 && box.H > 0 {
result = append(result, ResourceLocation{Page: page + 1, Object: id, Bounds: box})
}
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
return result, nil
}
// resourceReferenceOwner 查找承载引用的资源定义或最外层图元,图层只承接其自身的绘制参数
// 入参: node 引用节点
// 返回: string 图元或资源标识,无法映射时为空
func resourceReferenceOwner(node *editorXML) string {
id := ""
for current := node; current != nil; current = current.parent {
if !current.matchesOFD(current.name.Local) {
return ""
}
if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit"}, current.name.Local) || current.parent != nil && current.name.Local == editorResourceElement(current.parent.name.Local) {
id = editorResourceID(current.attr("ID"))
}
if id == "" && current.matchesOFD("Layer") {
id = editorResourceID(current.attr("ID"))
}
}
return id
}
+299
View File
@@ -18,12 +18,20 @@ import (
"fmt" "fmt"
"math" "math"
"slices" "slices"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"github.com/go-text/typesetting/segmenter"
"golang.org/x/text/unicode/bidi" "golang.org/x/text/unicode/bidi"
) )
// configuredTextShaper 将会话选项传递给每次换行后的塑形
type configuredTextShaper struct {
FontShaperOptions
options TextShapeOptions
}
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形 // LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位 // 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量 // 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
@@ -141,3 +149,294 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
} }
return nil return nil
} }
// ShapeText 使用固定选项塑形单行原文
// 入参: value 原文, size 毫米字号
// 返回: []ShapedGlyph 字形, error 塑形错误
func (s configuredTextShaper) ShapeText(value string, size float64) ([]ShapedGlyph, error) {
return s.ShapeTextWithOptions(value, size, s.options)
}
// layoutShapedText 将显式塑形结果保存为标准文字定位,成功前不修改对象
// 入参: obj 文字对象, value 原文, options 排版选项, metrics 字体度量, width 行宽, lineHeight 行高, hScale 横向比例, baseline 首行基线
// 返回: error 塑形或排版错误
func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics FontMetrics, width, lineHeight, hScale, baseline float64) error {
shaper, ok := metrics.(FontShaper)
if !ok {
return fmt.Errorf("font shaping: %w", ErrBackendUnavailable)
}
if options.Shaping != (TextShapeOptions{}) {
configured, ok := metrics.(FontShaperOptions)
if !ok {
return fmt.Errorf("font shaping options: %w", ErrBackendUnavailable)
}
shaper = configuredTextShaper{FontShaperOptions: configured, options: options.Shaping}
}
var codes []TextCode
var transforms []CGTransform
offset := 0
for _, paragraph := range strings.Split(value, "\n") {
runes := []rune(paragraph)
paragraphGlyphs, advances, err := shapeTextLine(shaper, runes, obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing, options.FirstLineIndent)
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := 0.0
if i == 0 {
indent = options.FirstLineIndent
}
available := width - indent
glyphs, lineAdvances := paragraphGlyphs, advances
if line[0] != 0 || line[1] != len(runes) {
glyphs, lineAdvances, err = shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
}
for options.Wrap && shapedLineWidth(runes[line[0]:line[1]], lineAdvances, options.LetterSpacing) > available {
var seg segmenter.Segmenter
seg.Init(runes[line[0]:line[1]])
previous := 0
for it := seg.GraphemeIterator(); it.Next(); {
previous = it.Grapheme().Offset
}
if previous == 0 {
break
}
oldEnd := line[1]
line[1] = line[0] + previous
if i+1 < len(lines) {
lines[i+1][0] = line[1]
} else {
lines = append(lines, [2]int{line[1], oldEnd})
}
lines[i] = line
glyphs, lineAdvances, err = shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
}
chars := runes[line[0]:line[1]]
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing)
if options.Shaping.Direction == "rtl" {
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
x -= lineAdvances[j]
}
}
x += options.LeftIndent + indent
y := baseline + float64(len(codes))*lineHeight
if !finite(x) || !finite(y) {
return fmt.Errorf("text position exceeds finite range")
}
adjusted := parseFloats(delta)
extra := make([]float64, len(chars)+1)
raw := make([]float64, len(chars))
for _, glyph := range glyphs {
raw[glyph.Cluster] += glyph.Advance * hScale
}
for j := range chars {
advance := lineAdvances[j]
if j < len(adjusted) {
advance = adjusted[j]
}
extra[j+1] = extra[j] + advance - raw[j]
}
code := TextCode{X: ofdNumber(x), Y: ofdNumber(y), Value: escapeOFDText(string(chars))}
var dx, dy []string
var lastX, lastY float64
var visualShifts map[int]float64
if options.Shaping.Bidi {
visualShifts = bidiClusterShifts(glyphs, extra)
}
for j, glyph := range glyphs {
shift := extra[glyph.Cluster]
if options.Shaping.Bidi {
shift = visualShifts[glyph.Cluster]
} else if options.Shaping.Direction == "rtl" {
shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing
}
gx, gy := x+glyph.X*hScale+shift, y+glyph.Y
if !finite(gx) || !finite(gy) {
return fmt.Errorf("shaped position exceeds finite range")
}
if j == 0 {
code.X, code.Y = ofdNumber(gx), ofdNumber(gy)
} else {
dx, dy = append(dx, ofdNumber(gx-lastX)), append(dy, ofdNumber(gy-lastY))
}
lastX, lastY = gx, gy
}
code.DeltaX, code.DeltaY = strings.Join(dx, " "), strings.Join(dy, " ")
for j := 0; j < len(glyphs); {
end := j + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[j].Cluster {
end++
}
next := len(chars)
if end < len(glyphs) {
next = glyphs[end].Cluster
}
ids := make([]string, end-j)
for k := j; k < end; k++ {
ids[k-j] = strconv.Itoa(int(glyphs[k].Glyph))
}
transforms = append(transforms, CGTransform{CodePosition: offset + glyphs[j].Cluster, CodeCount: next - glyphs[j].Cluster, GlyphCount: end - j, Glyphs: strings.Join(ids, " ")})
j = end
}
codes = append(codes, code)
offset += len(chars)
}
}
obj.TextCode, obj.CGTransform = codes, transforms
obj.layout = &textLayout{value: value, options: options}
return nil
}
// bidiClusterShifts 按视觉簇顺序累计字距和两端对齐产生的偏移
// 入参: glyphs 逻辑顺序字形, extra 逻辑字符累计偏移
// 返回: map[int]float64 各簇的视觉偏移
func bidiClusterShifts(glyphs []ShapedGlyph, extra []float64) map[int]float64 {
type cluster struct {
index, order int
extra float64
}
var clusters []cluster
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
end++
}
next := len(extra) - 1
if end < len(glyphs) {
next = glyphs[end].Cluster
}
clusters = append(clusters, cluster{glyphs[i].Cluster, glyphs[i].VisualOrder, extra[next] - extra[glyphs[i].Cluster]})
i = end
}
slices.SortFunc(clusters, func(a, b cluster) int { return a.order - b.order })
shifts := make(map[int]float64, len(clusters))
var offset float64
for _, cluster := range clusters {
shifts[cluster.index] = offset
offset += cluster.extra
}
return shifts
}
// shapeTextLine 检查可选后端的簇映射,按原文字符分配塑形步进
// 入参: shaper 字体塑形器, runes 单行字符, font 字体标识, size 字号, scale 横向比例, spacing 字距
// 返回: []ShapedGlyph 字形, []float64 含字距的字符步进, error 塑形错误
func shapeTextLine(shaper FontShaper, runes []rune, font string, size, scale, spacing float64) ([]ShapedGlyph, []float64, error) {
glyphs, err := shaper.ShapeText(string(runes), size)
if err != nil {
return nil, nil, err
}
if len(runes) > 0 && (len(glyphs) == 0 || glyphs[0].Cluster != 0) {
return nil, nil, fmt.Errorf("shaper omitted source text")
}
advances := make([]float64, len(runes))
var missing []rune
for i, glyph := range glyphs {
if glyph.Cluster < 0 || glyph.Cluster >= len(runes) || i > 0 && glyph.Cluster < glyphs[i-1].Cluster || glyph.Glyph >= shaper.NumGlyphs() || !finite(glyph.X) || !finite(glyph.Y) || !finite(glyph.Advance) {
return nil, nil, fmt.Errorf("invalid shaped glyph")
}
advances[glyph.Cluster] += glyph.Advance * scale
}
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
end++
}
next := len(runes)
if end < len(glyphs) {
next = glyphs[end].Cluster
}
start := glyphs[i].Cluster
if slices.ContainsFunc(glyphs[i:end], func(glyph ShapedGlyph) bool { return glyph.Glyph == 0 }) {
before := len(missing)
for _, char := range runes[start:next] {
if shaper.GlyphIndex(char) == 0 {
missing = append(missing, char)
}
}
if len(missing) == before {
missing = append(missing, runes[start:next]...)
}
}
advance := advances[start] / float64(next-start)
for j := start; j < next; j++ {
advances[j] = advance
}
i = end
}
if err := missingGlyphError(font, missing); err != nil {
return nil, nil, err
}
spaceTextAdvances(runes, advances, spacing)
if slices.ContainsFunc(advances, func(value float64) bool { return !finite(value) }) {
return nil, nil, fmt.Errorf("shaped advance exceeds finite range")
}
return glyphs, advances, nil
}
// shapedLineWidth 计算不含尾随空格和末尾字距的行宽
// 入参: runes 行内字符, advances 字符步进, spacing 字距
// 返回: float64 行宽
func shapedLineWidth(runes []rune, advances []float64, spacing float64) float64 {
end := len(runes)
for end > 0 && runes[end-1] == ' ' {
end--
}
width := 0.0
for _, advance := range advances[:end] {
width += advance
}
if end > 0 {
width -= spacing
}
return width
}
// validateTextGlyphs 校验显式字形编号及原文区间,不允许跨TextCode或重叠映射
// 入参: obj 文字对象, glyphCount 字体字形数量
// 返回: error 无效映射
func validateTextGlyphs(obj TextObject, glyphCount int) error {
if len(obj.CGTransform) == 0 {
return nil
}
var ends []int
total := 0
for _, code := range obj.TextCode {
total += len(textCodeRunes(code.Value))
ends = append(ends, total)
}
previous, boundary := 0, 0
for _, transform := range obj.CGTransform {
start, count := transform.CodePosition, transform.CodeCount
if start < previous || count <= 0 || start >= total || count > total-start {
return fmt.Errorf("invalid glyph character range")
}
end := start + count
for ends[boundary] <= start {
boundary++
}
if end > ends[boundary] {
return fmt.Errorf("glyph mapping crosses text codes")
}
ids := strings.Fields(transform.Glyphs)
if len(ids) == 0 || len(ids) != transform.GlyphCount {
return fmt.Errorf("invalid glyph count")
}
for _, value := range ids {
id, err := strconv.ParseUint(value, 10, 16)
if err != nil || id >= uint64(glyphCount) {
return fmt.Errorf("invalid glyph index %q", value)
}
}
previous = end
}
return nil
}
-151
View File
@@ -1,151 +0,0 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"math"
"strconv"
"strings"
)
// textLineScanner 顺序判断文本段换行,复用继承坐标和字符到字形的变换
type textLineScanner struct {
size float64
enabled bool
started bool
firstX string
previous TextCode
offset int
pending []CGTransform
transforms map[int][2]int
}
// newTextLineScanner 创建独立的换行扫描器,不修改文字对象
// 入参: obj 定位文字
// 返回: textLineScanner 扫描状态
func newTextLineScanner(obj TextObject) textLineScanner {
return textLineScanner{size: obj.Size, enabled: len(obj.TextCode) > 1 && obj.ReadDirection == 0 && obj.CharDirection == 0, previous: TextCode{X: "0", Y: "0"}, pending: obj.CGTransform}
}
// glyphCount 首次需要字形范围时解析变换,后续文本段复用结果
// 入参: code 文本段, offset 对象内字符起点
// 返回: int 字形数量
func (s *textLineScanner) glyphCount(code TextCode, offset int) int {
if len(s.pending) != 0 {
s.transforms = make(map[int][2]int, len(s.pending))
}
for _, transform := range s.pending {
if transform.CodePosition < 0 {
continue
}
glyphs := len(parseInts(transform.Glyphs))
if transform.GlyphCount > 0 {
glyphs = min(glyphs, transform.GlyphCount)
}
if glyphs > 0 {
s.transforms[transform.CodePosition] = [2]int{max(1, transform.CodeCount), glyphs}
}
}
s.pending = nil
return textCodeGlyphCount(code, offset, s.transforms)
}
// next 继承文本段原点并根据实际字形范围判断是否换行
// 入参: code 按文档顺序传入的文本段
// 返回: bool 当前段前是否换行
func (s *textLineScanner) next(code TextCode) bool {
if !s.enabled {
return false
}
previous, offset := s.previous, s.offset
if s.started && (len(s.pending) != 0 || len(s.transforms) != 0) {
s.offset += textCodeRuneCount(previous.Value)
}
if code.X == "" {
code.X = previous.X
}
if code.Y == "" {
code.Y = previous.Y
}
s.previous = code
if !s.started {
s.started, s.firstX = true, code.X
return false
}
x, ex := strconv.ParseFloat(code.X, 64)
start, es := strconv.ParseFloat(s.firstX, 64)
y, ey := strconv.ParseFloat(code.Y, 64)
prev, ep := strconv.ParseFloat(previous.Y, 64)
if ex != nil || es != nil || ey != nil || ep != nil {
return false
}
if x == start && y != prev {
return true
}
left, err := strconv.ParseFloat(previous.X, 64)
if err != nil || s.size <= 0 || y-prev < s.size/2 || previous.DeltaY != "" || code.DeltaY != "" {
return false
}
count := s.glyphCount(previous, offset)
if count == 0 {
return false
}
right := left + s.size
positioner := NewTextPositioner(previous)
for range count {
right = math.Max(right, positioner.Next().X+s.size)
}
return x <= right
}
// textCodeGlyphCount 按文本段的字符起点统计实际字形数量
// 入参: code 文本段, offset 对象内字符起点, transforms 已解析的字符到字形变换
// 返回: int 字形数量
func textCodeGlyphCount(code TextCode, offset int, transforms map[int][2]int) int {
if code.Index != "" {
count := 0
for _, field := range strings.Fields(code.Index) {
first, last, interval := strings.Cut(field, "-")
start, err := strconv.ParseUint(first, 10, 16)
if err != nil {
continue
}
if !interval {
count++
continue
}
end, err := strconv.ParseUint(last, 10, 16)
if err == nil && end >= start {
count += int(end-start) + 1
}
}
return count
}
count := textCodeRuneCount(code.Value)
if len(transforms) == 0 || count == 0 {
return count
}
glyphs := 0
for i := 0; i < count; {
if transform, ok := transforms[offset+i]; ok && transform[0] <= count-i {
i += transform[0]
glyphs += transform[1]
} else {
i++
glyphs++
}
}
return glyphs
}
+131
View File
@@ -15,6 +15,7 @@
package ofdgo package ofdgo
import ( import (
"math"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -37,6 +38,18 @@ type textPositionValues struct {
present bool present bool
} }
// textLineScanner 顺序判断文本段换行,复用继承坐标和字符到字形的变换
type textLineScanner struct {
size float64
enabled bool
started bool
firstX string
previous TextCode
offset int
pending []CGTransform
transforms map[int][2]int
}
// textCodeOrigins 按标准继承文本段原点,首段缺失坐标时以零横坐标和字体上升高度容错恢复 // textCodeOrigins 按标准继承文本段原点,首段缺失坐标时以零横坐标和字体上升高度容错恢复
// 入参: codes 文本段, ascent 对象坐标系下的字体上升高度 // 入参: codes 文本段, ascent 对象坐标系下的字体上升高度
// 返回: []TextCode 定位文本段,不修改原始数据 // 返回: []TextCode 定位文本段,不修改原始数据
@@ -175,3 +188,121 @@ func (v *textPositionValues) field() string {
v.data = "" v.data = ""
return field return field
} }
// newTextLineScanner 创建独立的换行扫描器,不修改文字对象
// 入参: obj 定位文字
// 返回: textLineScanner 扫描状态
func newTextLineScanner(obj TextObject) textLineScanner {
return textLineScanner{size: obj.Size, enabled: len(obj.TextCode) > 1 && obj.ReadDirection == 0 && obj.CharDirection == 0, previous: TextCode{X: "0", Y: "0"}, pending: obj.CGTransform}
}
// glyphCount 首次需要字形范围时解析变换,后续文本段复用结果
// 入参: code 文本段, offset 对象内字符起点
// 返回: int 字形数量
func (s *textLineScanner) glyphCount(code TextCode, offset int) int {
if len(s.pending) != 0 {
s.transforms = make(map[int][2]int, len(s.pending))
}
for _, transform := range s.pending {
if transform.CodePosition < 0 {
continue
}
glyphs := len(parseInts(transform.Glyphs))
if transform.GlyphCount > 0 {
glyphs = min(glyphs, transform.GlyphCount)
}
if glyphs > 0 {
s.transforms[transform.CodePosition] = [2]int{max(1, transform.CodeCount), glyphs}
}
}
s.pending = nil
return textCodeGlyphCount(code, offset, s.transforms)
}
// next 继承文本段原点并根据实际字形范围判断是否换行
// 入参: code 按文档顺序传入的文本段
// 返回: bool 当前段前是否换行
func (s *textLineScanner) next(code TextCode) bool {
if !s.enabled {
return false
}
previous, offset := s.previous, s.offset
if s.started && (len(s.pending) != 0 || len(s.transforms) != 0) {
s.offset += textCodeRuneCount(previous.Value)
}
if code.X == "" {
code.X = previous.X
}
if code.Y == "" {
code.Y = previous.Y
}
s.previous = code
if !s.started {
s.started, s.firstX = true, code.X
return false
}
x, ex := strconv.ParseFloat(code.X, 64)
start, es := strconv.ParseFloat(s.firstX, 64)
y, ey := strconv.ParseFloat(code.Y, 64)
prev, ep := strconv.ParseFloat(previous.Y, 64)
if ex != nil || es != nil || ey != nil || ep != nil {
return false
}
if x == start && y != prev {
return true
}
left, err := strconv.ParseFloat(previous.X, 64)
if err != nil || s.size <= 0 || y-prev < s.size/2 || previous.DeltaY != "" || code.DeltaY != "" {
return false
}
count := s.glyphCount(previous, offset)
if count == 0 {
return false
}
right := left + s.size
positioner := NewTextPositioner(previous)
for range count {
right = math.Max(right, positioner.Next().X+s.size)
}
return x <= right
}
// textCodeGlyphCount 按文本段的字符起点统计实际字形数量
// 入参: code 文本段, offset 对象内字符起点, transforms 已解析的字符到字形变换
// 返回: int 字形数量
func textCodeGlyphCount(code TextCode, offset int, transforms map[int][2]int) int {
if code.Index != "" {
count := 0
for _, field := range strings.Fields(code.Index) {
first, last, interval := strings.Cut(field, "-")
start, err := strconv.ParseUint(first, 10, 16)
if err != nil {
continue
}
if !interval {
count++
continue
}
end, err := strconv.ParseUint(last, 10, 16)
if err == nil && end >= start {
count += int(end-start) + 1
}
}
return count
}
count := textCodeRuneCount(code.Value)
if len(transforms) == 0 || count == 0 {
return count
}
glyphs := 0
for i := 0; i < count; {
if transform, ok := transforms[offset+i]; ok && transform[0] <= count-i {
i += transform[0]
glyphs += transform[1]
} else {
i++
glyphs++
}
}
return glyphs
}
-321
View File
@@ -1,321 +0,0 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"fmt"
"slices"
"strconv"
"strings"
"github.com/go-text/typesetting/segmenter"
)
// configuredTextShaper 将会话选项传递给每次换行后的塑形
type configuredTextShaper struct {
FontShaperOptions
options TextShapeOptions
}
// ShapeText 使用固定选项塑形单行原文
// 入参: value 原文, size 毫米字号
// 返回: []ShapedGlyph 字形, error 塑形错误
func (s configuredTextShaper) ShapeText(value string, size float64) ([]ShapedGlyph, error) {
return s.ShapeTextWithOptions(value, size, s.options)
}
// layoutShapedText 将显式塑形结果保存为标准文字定位,成功前不修改对象
// 入参: obj 文字对象, value 原文, options 排版选项, metrics 字体度量, width 行宽, lineHeight 行高, hScale 横向比例, baseline 首行基线
// 返回: error 塑形或排版错误
func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics FontMetrics, width, lineHeight, hScale, baseline float64) error {
shaper, ok := metrics.(FontShaper)
if !ok {
return fmt.Errorf("font shaping: %w", ErrBackendUnavailable)
}
if options.Shaping != (TextShapeOptions{}) {
configured, ok := metrics.(FontShaperOptions)
if !ok {
return fmt.Errorf("font shaping options: %w", ErrBackendUnavailable)
}
shaper = configuredTextShaper{FontShaperOptions: configured, options: options.Shaping}
}
var codes []TextCode
var transforms []CGTransform
offset := 0
for _, paragraph := range strings.Split(value, "\n") {
runes := []rune(paragraph)
paragraphGlyphs, advances, err := shapeTextLine(shaper, runes, obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing, options.FirstLineIndent)
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := 0.0
if i == 0 {
indent = options.FirstLineIndent
}
available := width - indent
glyphs, lineAdvances := paragraphGlyphs, advances
if line[0] != 0 || line[1] != len(runes) {
glyphs, lineAdvances, err = shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
}
for options.Wrap && shapedLineWidth(runes[line[0]:line[1]], lineAdvances, options.LetterSpacing) > available {
var seg segmenter.Segmenter
seg.Init(runes[line[0]:line[1]])
previous := 0
for it := seg.GraphemeIterator(); it.Next(); {
previous = it.Grapheme().Offset
}
if previous == 0 {
break
}
oldEnd := line[1]
line[1] = line[0] + previous
if i+1 < len(lines) {
lines[i+1][0] = line[1]
} else {
lines = append(lines, [2]int{line[1], oldEnd})
}
lines[i] = line
glyphs, lineAdvances, err = shapeTextLine(shaper, runes[line[0]:line[1]], obj.Font, obj.Size, hScale, options.LetterSpacing)
if err != nil {
return err
}
}
chars := runes[line[0]:line[1]]
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing)
if options.Shaping.Direction == "rtl" {
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
x -= lineAdvances[j]
}
}
x += options.LeftIndent + indent
y := baseline + float64(len(codes))*lineHeight
if !finite(x) || !finite(y) {
return fmt.Errorf("text position exceeds finite range")
}
adjusted := parseFloats(delta)
extra := make([]float64, len(chars)+1)
raw := make([]float64, len(chars))
for _, glyph := range glyphs {
raw[glyph.Cluster] += glyph.Advance * hScale
}
for j := range chars {
advance := lineAdvances[j]
if j < len(adjusted) {
advance = adjusted[j]
}
extra[j+1] = extra[j] + advance - raw[j]
}
code := TextCode{X: ofdNumber(x), Y: ofdNumber(y), Value: escapeOFDText(string(chars))}
var dx, dy []string
var lastX, lastY float64
var visualShifts map[int]float64
if options.Shaping.Bidi {
visualShifts = bidiClusterShifts(glyphs, extra)
}
for j, glyph := range glyphs {
shift := extra[glyph.Cluster]
if options.Shaping.Bidi {
shift = visualShifts[glyph.Cluster]
} else if options.Shaping.Direction == "rtl" {
shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing
}
gx, gy := x+glyph.X*hScale+shift, y+glyph.Y
if !finite(gx) || !finite(gy) {
return fmt.Errorf("shaped position exceeds finite range")
}
if j == 0 {
code.X, code.Y = ofdNumber(gx), ofdNumber(gy)
} else {
dx, dy = append(dx, ofdNumber(gx-lastX)), append(dy, ofdNumber(gy-lastY))
}
lastX, lastY = gx, gy
}
code.DeltaX, code.DeltaY = strings.Join(dx, " "), strings.Join(dy, " ")
for j := 0; j < len(glyphs); {
end := j + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[j].Cluster {
end++
}
next := len(chars)
if end < len(glyphs) {
next = glyphs[end].Cluster
}
ids := make([]string, end-j)
for k := j; k < end; k++ {
ids[k-j] = strconv.Itoa(int(glyphs[k].Glyph))
}
transforms = append(transforms, CGTransform{CodePosition: offset + glyphs[j].Cluster, CodeCount: next - glyphs[j].Cluster, GlyphCount: end - j, Glyphs: strings.Join(ids, " ")})
j = end
}
codes = append(codes, code)
offset += len(chars)
}
}
obj.TextCode, obj.CGTransform = codes, transforms
obj.layout = &textLayout{value: value, options: options}
return nil
}
// bidiClusterShifts 按视觉簇顺序累计字距和两端对齐产生的偏移
// 入参: glyphs 逻辑顺序字形, extra 逻辑字符累计偏移
// 返回: map[int]float64 各簇的视觉偏移
func bidiClusterShifts(glyphs []ShapedGlyph, extra []float64) map[int]float64 {
type cluster struct {
index, order int
extra float64
}
var clusters []cluster
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
end++
}
next := len(extra) - 1
if end < len(glyphs) {
next = glyphs[end].Cluster
}
clusters = append(clusters, cluster{glyphs[i].Cluster, glyphs[i].VisualOrder, extra[next] - extra[glyphs[i].Cluster]})
i = end
}
slices.SortFunc(clusters, func(a, b cluster) int { return a.order - b.order })
shifts := make(map[int]float64, len(clusters))
var offset float64
for _, cluster := range clusters {
shifts[cluster.index] = offset
offset += cluster.extra
}
return shifts
}
// shapeTextLine 检查可选后端的簇映射,按原文字符分配塑形步进
// 入参: shaper 字体塑形器, runes 单行字符, font 字体标识, size 字号, scale 横向比例, spacing 字距
// 返回: []ShapedGlyph 字形, []float64 含字距的字符步进, error 塑形错误
func shapeTextLine(shaper FontShaper, runes []rune, font string, size, scale, spacing float64) ([]ShapedGlyph, []float64, error) {
glyphs, err := shaper.ShapeText(string(runes), size)
if err != nil {
return nil, nil, err
}
if len(runes) > 0 && (len(glyphs) == 0 || glyphs[0].Cluster != 0) {
return nil, nil, fmt.Errorf("shaper omitted source text")
}
advances := make([]float64, len(runes))
var missing []rune
for i, glyph := range glyphs {
if glyph.Cluster < 0 || glyph.Cluster >= len(runes) || i > 0 && glyph.Cluster < glyphs[i-1].Cluster || glyph.Glyph >= shaper.NumGlyphs() || !finite(glyph.X) || !finite(glyph.Y) || !finite(glyph.Advance) {
return nil, nil, fmt.Errorf("invalid shaped glyph")
}
advances[glyph.Cluster] += glyph.Advance * scale
}
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
end++
}
next := len(runes)
if end < len(glyphs) {
next = glyphs[end].Cluster
}
start := glyphs[i].Cluster
if slices.ContainsFunc(glyphs[i:end], func(glyph ShapedGlyph) bool { return glyph.Glyph == 0 }) {
before := len(missing)
for _, char := range runes[start:next] {
if shaper.GlyphIndex(char) == 0 {
missing = append(missing, char)
}
}
if len(missing) == before {
missing = append(missing, runes[start:next]...)
}
}
advance := advances[start] / float64(next-start)
for j := start; j < next; j++ {
advances[j] = advance
}
i = end
}
if err := missingGlyphError(font, missing); err != nil {
return nil, nil, err
}
spaceTextAdvances(runes, advances, spacing)
if slices.ContainsFunc(advances, func(value float64) bool { return !finite(value) }) {
return nil, nil, fmt.Errorf("shaped advance exceeds finite range")
}
return glyphs, advances, nil
}
// shapedLineWidth 计算不含尾随空格和末尾字距的行宽
// 入参: runes 行内字符, advances 字符步进, spacing 字距
// 返回: float64 行宽
func shapedLineWidth(runes []rune, advances []float64, spacing float64) float64 {
end := len(runes)
for end > 0 && runes[end-1] == ' ' {
end--
}
width := 0.0
for _, advance := range advances[:end] {
width += advance
}
if end > 0 {
width -= spacing
}
return width
}
// validateTextGlyphs 校验显式字形编号及原文区间,不允许跨TextCode或重叠映射
// 入参: obj 文字对象, glyphCount 字体字形数量
// 返回: error 无效映射
func validateTextGlyphs(obj TextObject, glyphCount int) error {
if len(obj.CGTransform) == 0 {
return nil
}
var ends []int
total := 0
for _, code := range obj.TextCode {
total += len(textCodeRunes(code.Value))
ends = append(ends, total)
}
previous, boundary := 0, 0
for _, transform := range obj.CGTransform {
start, count := transform.CodePosition, transform.CodeCount
if start < previous || count <= 0 || start >= total || count > total-start {
return fmt.Errorf("invalid glyph character range")
}
end := start + count
for ends[boundary] <= start {
boundary++
}
if end > ends[boundary] {
return fmt.Errorf("glyph mapping crosses text codes")
}
ids := strings.Fields(transform.Glyphs)
if len(ids) == 0 || len(ids) != transform.GlyphCount {
return fmt.Errorf("invalid glyph count")
}
for _, value := range ids {
id, err := strconv.ParseUint(value, 10, 16)
if err != nil || id >= uint64(glyphCount) {
return fmt.Errorf("invalid glyph index %q", value)
}
}
previous = end
}
return nil
}