diff --git a/ofdgo_crypto.go b/ofdgo_crypto.go index 37746a3..963433a 100644 --- a/ofdgo_crypto.go +++ b/ofdgo_crypto.go @@ -17,8 +17,18 @@ package ofdgo import ( "bytes" "crypto" + "crypto/cipher" + "crypto/ecdsa" + "crypto/rand" "errors" + "fmt" "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编码 @@ -84,6 +94,9 @@ type encryptionState struct { options *EncryptionOptions } +// GMCryptoProvider 使用纯Go国密实现,可在原生环境和WASM使用 +type GMCryptoProvider struct{} + // 加密错误可通过errors.Is识别,不包含口令或私钥 var ( 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)) } + +// 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 +} diff --git a/ofdgo_crypto_gm.go b/ofdgo_crypto_gm.go deleted file mode 100644 index 99d3245..0000000 --- a/ofdgo_crypto_gm.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "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 -} diff --git a/ofdgo_editor_annot.go b/ofdgo_editor_annot.go index 2e25a70..5cbe1c9 100644 --- a/ofdgo_editor_annot.go +++ b/ofdgo_editor_annot.go @@ -18,11 +18,22 @@ import ( "bytes" "encoding/xml" "fmt" + "maps" "path" "strings" "time" ) +// AnnotationSelection 保存当前编辑器内可重复粘贴的注解原文及资源上下文 +// 来源修改或删除不影响快照,不用于跨文档传输 +type AnnotationSelection struct { + editor *Editor + document editorDocumentIdentity + data [][]byte + resources []string + states map[string]map[string]editorCompositeState +} + // Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立 // 入参: index 页面索引 // 返回: []AnnotationInfo 注解信息, error 错误信息 @@ -297,3 +308,273 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi } 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 +} diff --git a/ofdgo_editor_annot_ops.go b/ofdgo_editor_annot_ops.go deleted file mode 100644 index 6fbec85..0000000 --- a/ofdgo_editor_annot_ops.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_editor_clone.go b/ofdgo_editor_clone.go deleted file mode 100644 index 2734a08..0000000 --- a/ofdgo_editor_clone.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "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) -} diff --git a/ofdgo_editor_copy.go b/ofdgo_editor_copy.go index fa8fe8e..174d51f 100644 --- a/ofdgo_editor_copy.go +++ b/ofdgo_editor_copy.go @@ -18,9 +18,14 @@ import ( "bytes" "encoding/xml" "fmt" + "reflect" "strconv" + "sync" ) +// editorClonePlans 仅共享类型的可变字段索引,不持有文档对象或资源 +var editorClonePlans sync.Map + // copiedObjectStyle 保留复杂路径的原始颜色引用,普通对象沿用有效样式快照 // 入参: object 原对象, layer 图层绘制参数 // 返回: GraphicObject 复制快照, error 错误信息 @@ -231,3 +236,103 @@ func (e *Editor) copiedLayerStyle(object GraphicObject) string { } 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) +} diff --git a/ofdgo_editor_media.go b/ofdgo_editor_media.go deleted file mode 100644 index 6922056..0000000 --- a/ofdgo_editor_media.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index b0be6ed..6ee1b47 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -17,9 +17,11 @@ package ofdgo import ( "archive/zip" "bytes" + "compress/flate" "context" "encoding/xml" "fmt" + "hash/crc32" "io" "maps" "reflect" @@ -28,6 +30,25 @@ import ( "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和新增资源,同步清理已删除页面及附件的引用 // 入参: ctx 取消上下文, progress 保存进度回调,预览时为nil // 返回: map[string][]byte 替换及新增条目, error 错误信息 @@ -810,3 +831,93 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re } 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 +} diff --git a/ofdgo_editor_resource.go b/ofdgo_editor_resource.go index 096c6da..9f0c05c 100644 --- a/ofdgo_editor_resource.go +++ b/ofdgo_editor_resource.go @@ -17,6 +17,7 @@ package ofdgo import ( "bytes" "context" + "crypto/sha256" "fmt" "image" "path" @@ -662,3 +663,63 @@ func resourceActionMatches(action Action, kind, id string) bool { 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 +} diff --git a/ofdgo_editor_stage.go b/ofdgo_editor_stage.go deleted file mode 100644 index a733356..0000000 --- a/ofdgo_editor_stage.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_image.go b/ofdgo_image.go index fdfdb13..ad20c5d 100644 --- a/ofdgo_image.go +++ b/ofdgo_image.go @@ -18,10 +18,25 @@ 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 +} + // imageWithEffects 合并同尺寸二值蒙版和对象透明度,保留原图采样精度及只读像素 // 入参: ctx 取消上下文,nil使用后台上下文, img 原图, mask 二值蒙版,可为空, alpha 对象透明度,可为空 // 返回: image.Image 处理后的图片, error 蒙版、解码或取消错误 @@ -167,3 +182,187 @@ func validateImageMask(ctx context.Context, mask image.Image, size image.Point) } 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) } diff --git a/ofdgo_image_resource.go b/ofdgo_image_resource.go deleted file mode 100644 index a910632..0000000 --- a/ofdgo_image_resource.go +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "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) } diff --git a/ofdgo_media.go b/ofdgo_media.go deleted file mode 100644 index 8d00d39..0000000 --- a/ofdgo_media.go +++ /dev/null @@ -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)) -} diff --git a/ofdgo_raster.go b/ofdgo_raster.go index 9422f36..a3b5186 100644 --- a/ofdgo_raster.go +++ b/ofdgo_raster.go @@ -16,12 +16,16 @@ package ofdgo import ( "context" + "crypto/sha256" + "encoding/binary" "fmt" "image" "image/color" "math" 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 } + +// 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 +} diff --git a/ofdgo_raster_clip.go b/ofdgo_raster_clip.go deleted file mode 100644 index 1322119..0000000 --- a/ofdgo_raster_clip.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_renderer_eps_compression.go b/ofdgo_renderer_eps_compression.go deleted file mode 100644 index 131b93d..0000000 --- a/ofdgo_renderer_eps_compression.go +++ /dev/null @@ -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 <>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") -} diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index 37a08a4..c4f8f88 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -17,9 +17,14 @@ package ofdgo import ( "bufio" "bytes" + "compress/zlib" + "context" + "encoding/ascii85" "fmt" "image" + "image/jpeg" "io" + "math" "strconv" "github.com/tdewolff/canvas" @@ -35,6 +40,15 @@ type canvasEPSWriter struct { io.Writer } +// epsImageRenderer 在EPS输出端优化图片,不修改页面及渲染缓存 +type epsImageRenderer struct { + *ps.PS + writer io.Writer + options CompressionOptions + ctx context.Context + err error +} + // RenderSVG 使用canvas输出SVG,按模式保留资源和对象分组 // 入参: r 渲染器, page 页面内容, writer 输出流, mode 资源模式 // 返回: SVGResources 外部资源, error 错误信息 @@ -234,3 +248,101 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i } 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 <>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") +} diff --git a/ofdgo_resource.go b/ofdgo_resource.go index f4e45fe..c141cf4 100644 --- a/ofdgo_resource.go +++ b/ofdgo_resource.go @@ -20,6 +20,7 @@ import ( "encoding/xml" "fmt" "io" + "maps" "path" "slices" "strings" @@ -74,6 +75,14 @@ type resourcePageFrame struct { link bool } +// ResourceLocation 保存资源引用的页面实例范围,Page从1开始,Bounds单位为毫米 +// Object为承载引用的图元标识;隐藏或完全裁剪的图元不产生定位结果 +type ResourceLocation struct { + Page int `json:"page"` + Object string `json:"object"` + Bounds Box `json:"bounds"` +} + // Resources 列出当前文档及所选版本的资源,标记缺失文件,不读取二进制内容或执行媒体动作 // 重复查询复用已完成的扫描,返回值及引用位置与阅读器相互独立 // 入参: 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) } + +// 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)) +} diff --git a/ofdgo_resource_location.go b/ofdgo_resource_location.go deleted file mode 100644 index 1a66211..0000000 --- a/ofdgo_resource_location.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_text_layout.go b/ofdgo_text_layout.go index ab6a379..1ec00f3 100644 --- a/ofdgo_text_layout.go +++ b/ofdgo_text_layout.go @@ -18,12 +18,20 @@ import ( "fmt" "math" "slices" + "strconv" "strings" "unicode/utf8" + "github.com/go-text/typesetting/segmenter" "golang.org/x/text/unicode/bidi" ) +// configuredTextShaper 将会话选项传递给每次换行后的塑形 +type configuredTextShaper struct { + FontShaperOptions + options TextShapeOptions +} + // LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形 // 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位 // 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量 @@ -141,3 +149,294 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM } 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 +} diff --git a/ofdgo_text_lines.go b/ofdgo_text_lines.go deleted file mode 100644 index 0180c7e..0000000 --- a/ofdgo_text_lines.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "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 -} diff --git a/ofdgo_text_position.go b/ofdgo_text_position.go index 69db231..82b4ff7 100644 --- a/ofdgo_text_position.go +++ b/ofdgo_text_position.go @@ -15,6 +15,7 @@ package ofdgo import ( + "math" "slices" "strconv" "strings" @@ -37,6 +38,18 @@ type textPositionValues struct { 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 按标准继承文本段原点,首段缺失坐标时以零横坐标和字体上升高度容错恢复 // 入参: codes 文本段, ascent 对象坐标系下的字体上升高度 // 返回: []TextCode 定位文本段,不修改原始数据 @@ -175,3 +188,121 @@ func (v *textPositionValues) field() string { v.data = "" 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 +} diff --git a/ofdgo_text_shape.go b/ofdgo_text_shape.go deleted file mode 100644 index 1bdcc82..0000000 --- a/ofdgo_text_shape.go +++ /dev/null @@ -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 -}