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