mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
bcb9f98f8b
commit
3959664448
52 files changed
+1829
-372
No files matched your search
@@ -10,7 +10,7 @@ require (
|
||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
|
||||
github.com/xiaoqidun/jbig2 v1.1.1
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261005144606-00d3cdef7848
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261006094416-cd6540bbc9f7
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
|
||||
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
|
||||
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261005144606-00d3cdef7848 h1:jacPfuuqF5qQVchUp935MHaE75ubIRBDjpJgEJD0Hk4=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261005144606-00d3cdef7848/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261006094416-cd6540bbc9f7 h1:ZvZaxIYiPd8BlcGDztowwmSHT1vFQK3mV3RBS2p27Cs=
|
||||
github.com/xiaoqidun/pdfgo v0.0.0-20261006094416-cd6540bbc9f7/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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"
|
||||
"image/color"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// colorProfile 保存包内配置的不可变解析结果,不跨阅读器或文档共享
|
||||
type colorProfile struct {
|
||||
space *pdfgo.ColorSpace
|
||||
err error
|
||||
}
|
||||
|
||||
// colorProfileUsed 判断新增ICC文件是否被颜色空间引用,不作为资源XML登记
|
||||
// 入参: spaces 当前使用的新增颜色空间
|
||||
// 返回: bool 是否保留文件
|
||||
func (r editorResource) colorProfileUsed(spaces []ColorSpace) bool {
|
||||
return r.space != nil && r.name != "" && r.space.Profile != "" && slices.ContainsFunc(spaces, func(space ColorSpace) bool { return space.Profile == r.space.Profile })
|
||||
}
|
||||
|
||||
// parseColorProfile 验证OFD模型与ICC源模型一致,不以设备色替代声明的配置
|
||||
// 入参: kind OFD颜色模型, data ICC文件
|
||||
// 返回: *colorProfile 解析结果, error 模型或配置错误
|
||||
func parseColorProfile(kind string, data []byte) (*colorProfile, error) {
|
||||
model := map[string]string{"GRAY": "GRAY", "RGB": "RGB ", "CMYK": "CMYK"}[strings.ToUpper(kind)]
|
||||
if len(data) < 132 || model == "" || string(data[16:20]) != model {
|
||||
return nil, fmt.Errorf("ICC source model does not match OFD color space %q", kind)
|
||||
}
|
||||
space, err := pdfgo.NewICCColorSpace(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &colorProfile{space: space}, nil
|
||||
}
|
||||
|
||||
// colorProfile 按文件及模型复用只读配置,读取失败也保留结果以免重复读取
|
||||
// 入参: space 颜色空间
|
||||
// 返回: *colorProfile 配置,未声明时为nil, error 读取或配置错误
|
||||
func (r *Reader) colorProfile(space *ColorSpace) (*colorProfile, error) {
|
||||
if space == nil || space.Profile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if space.profile != nil {
|
||||
return space.profile, space.profile.err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("color profile %q is unavailable", space.Profile)
|
||||
}
|
||||
name := r.ResPath(space.Profile)
|
||||
for _, other := range r.colorSpaceCache {
|
||||
if other != nil && other.profile != nil && strings.EqualFold(other.Type, space.Type) && strings.EqualFold(r.ResPath(other.Profile), name) {
|
||||
space.profile = other.profile
|
||||
return space.profile, space.profile.err
|
||||
}
|
||||
}
|
||||
data, err := r.readFile(name)
|
||||
if err == nil {
|
||||
space.profile, err = parseColorProfile(space.Type, data)
|
||||
}
|
||||
if err != nil {
|
||||
space.profile = &colorProfile{err: fmt.Errorf("color profile %q: %w", space.Profile, err)}
|
||||
}
|
||||
return space.profile, space.profile.err
|
||||
}
|
||||
|
||||
// colorDefinition 获取显式或文档默认颜色空间,统一资源标识别名
|
||||
// 入参: id 颜色空间标识
|
||||
// 返回: *ColorSpace 已加载定义,未声明时为nil
|
||||
func (r *Renderer) colorDefinition(id string) *ColorSpace {
|
||||
if r.Reader == nil {
|
||||
return nil
|
||||
}
|
||||
if id == "" && r.Reader.doc != nil {
|
||||
id = strconv.Itoa(r.Reader.doc.CommonData.DefaultCS)
|
||||
}
|
||||
space, _ := resourceValue(r.Reader.colorSpaceCache, id)
|
||||
return space
|
||||
}
|
||||
|
||||
// rgb 转换归一化源分量,不在ICC变换前量化为8位
|
||||
// 入参: values 源分量
|
||||
// 返回: [3]float64 sRGB分量, error 变换错误
|
||||
func (p *colorProfile) rgb(values [4]float64) ([3]float64, error) {
|
||||
return p.space.RGB(values[:p.space.Components()], p.space.DefaultIntent())
|
||||
}
|
||||
|
||||
// profileColor 将显示分量一次量化并按透明度预乘
|
||||
// 入参: rgb sRGB分量, alpha 非预乘透明度
|
||||
// 返回: color.RGBA 预乘颜色
|
||||
func profileColor(rgb [3]float64, alpha float64) color.RGBA {
|
||||
alpha = math.Max(0, math.Min(1, alpha))
|
||||
return color.RGBA{meshByte(rgb[0] * alpha), meshByte(rgb[1] * alpha), meshByte(rgb[2] * alpha), meshByte(alpha)}
|
||||
}
|
||||
+221
-62
@@ -20,6 +20,7 @@ import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/xml"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
@@ -40,6 +42,8 @@ const (
|
||||
CompressionStrong = pdfgo.CompressionStrong
|
||||
)
|
||||
|
||||
const outputOptimizationBufferLimit = 64 << 20
|
||||
|
||||
// CompressionMode 选择默认、无损或有损输出策略
|
||||
type CompressionMode = pdfgo.CompressionMode
|
||||
|
||||
@@ -63,7 +67,22 @@ type outputOptimization struct {
|
||||
protected bool
|
||||
completed int
|
||||
deflater *flate.Writer
|
||||
optimized map[string]bool
|
||||
}
|
||||
|
||||
// outputCompressionBuffer 限制候选ZIP编码,不能缩小时终止本次压缩
|
||||
type outputCompressionBuffer struct {
|
||||
buffer *bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
// Write 写入有界候选编码,超过体积上限时返回短缓冲错误
|
||||
// 入参: data 编码数据
|
||||
// 返回: int 写入字节数, error 容量错误
|
||||
func (b outputCompressionBuffer) Write(data []byte) (int, error) {
|
||||
if len(data) > b.limit-b.buffer.Len() {
|
||||
return 0, io.ErrShortBuffer
|
||||
}
|
||||
return b.buffer.Write(data)
|
||||
}
|
||||
|
||||
// WriteToWithOptions 按压缩策略写出OFD,签名文档不额外改写资源内容
|
||||
@@ -140,23 +159,10 @@ func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Edi
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
images, err := reader.Images(ctx)
|
||||
snapshot.output.images, snapshot.output.sizes, err = reader.compressionImagePlan(ctx, options.Compression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
masks, geometry, err := reader.compressionImageSafety(ctx, images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name := range masks {
|
||||
snapshot.output.images[name] = false
|
||||
}
|
||||
if geometry {
|
||||
snapshot.output.sizes, err = reader.compressionImageSizes(ctx, images, options.Compression.ImageDPI())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return &snapshot, nil
|
||||
}
|
||||
@@ -166,16 +172,37 @@ func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Edi
|
||||
// 返回: map[string]bool 只能无损处理的资源路径, bool 是否可分析尺寸, error 读取或取消错误
|
||||
func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo) (map[string]bool, bool, error) {
|
||||
names := make(map[string]bool)
|
||||
safe := true
|
||||
if len(r.OFD.DocBody) != 0 {
|
||||
dependencies, err := r.documentFiles(ctx, r.DocumentIndex())
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, false, ctx.Err()
|
||||
}
|
||||
safe = false
|
||||
}
|
||||
for key := range dependencies {
|
||||
name, exists := r.fileNamesFold[key]
|
||||
if !exists {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
names[name] = true
|
||||
}
|
||||
} else {
|
||||
for name := range r.fileIndex {
|
||||
names[name] = true
|
||||
}
|
||||
for name := range r.files {
|
||||
names[name] = true
|
||||
}
|
||||
}
|
||||
masks := make(map[string]bool)
|
||||
safe := true
|
||||
geometry := len(r.OFD.DocBody) == 1
|
||||
geometry := true
|
||||
for _, name := range slices.Sorted(maps.Keys(names)) {
|
||||
if !safe {
|
||||
break
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -201,6 +228,10 @@ func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo)
|
||||
break
|
||||
}
|
||||
if element, ok := token.(xml.StartElement); ok {
|
||||
if element.Name.Space != "" && element.Name.Space != ofdNamespace && element.Name.Space != "http://www.ofdspec.org" {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
if element.Name.Local == "Pattern" {
|
||||
geometry = false
|
||||
}
|
||||
@@ -210,10 +241,10 @@ func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo)
|
||||
geometry = false
|
||||
}
|
||||
if attr.Name.Local == "ResourceID" {
|
||||
resource = strings.TrimSpace(attr.Value)
|
||||
resource = editorResourceID(attr.Value)
|
||||
}
|
||||
if attr.Name.Local == "ImageMask" {
|
||||
masks[strings.TrimSpace(attr.Value)] = true
|
||||
masks[editorResourceID(attr.Value)] = true
|
||||
masked = true
|
||||
}
|
||||
}
|
||||
@@ -230,7 +261,7 @@ func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo)
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, img := range images {
|
||||
if !safe || masks[img.ID] {
|
||||
if !safe || masks[editorResourceID(img.ID)] {
|
||||
paths[strings.ToLower(cleanPackagePath(img.Location))] = true
|
||||
}
|
||||
}
|
||||
@@ -261,72 +292,200 @@ func (r *Reader) hasOutputSignatures() (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// writeOutputEntry 逐项优化图片与ZIP编码,保留资源名称及其他文件的原始字节
|
||||
// writeOutputEntry 优化ZIP编码,保留资源名称及条目的未压缩字节
|
||||
// 入参: archive 归档写入器, header 条目属性, data 原始数据
|
||||
// 返回: error 优化或写入错误
|
||||
func (e *Editor) writeOutputEntry(archive *zip.Writer, header zip.FileHeader, data []byte) error {
|
||||
if e.output == nil || e.output.options.Mode == CompressionUnchanged || len(data) > 64<<20 {
|
||||
if e.output == nil || e.output.options.Mode == CompressionUnchanged || len(data) > outputOptimizationBufferLimit {
|
||||
entry, err := archive.CreateHeader(&header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.output != nil {
|
||||
return e.writeOutputBytes(entry, data)
|
||||
}
|
||||
_, err = entry.Write(data)
|
||||
return err
|
||||
}
|
||||
if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if lossy, ok := e.output.images[strings.ToLower(cleanPackagePath(header.Name))]; ok && !e.output.optimized[strings.ToLower(cleanPackagePath(header.Name))] {
|
||||
options := e.output.options
|
||||
if !lossy {
|
||||
options.Mode = CompressionLossless
|
||||
}
|
||||
candidate, err := pdfgo.OptimizeImageSize(e.output.ctx, data, options, e.output.sizes[strings.ToLower(cleanPackagePath(header.Name))])
|
||||
compressed, err := e.compressOutputBytes(data, len(data))
|
||||
if err != nil {
|
||||
if e.output.ctx.Err() != nil {
|
||||
return e.output.ctx.Err()
|
||||
}
|
||||
} else {
|
||||
data = candidate
|
||||
}
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
deflater := e.output.deflater
|
||||
if deflater == nil {
|
||||
deflater, _ = flate.NewWriter(&compressed, flate.BestCompression)
|
||||
e.output.deflater = deflater
|
||||
} else {
|
||||
deflater.Reset(&compressed)
|
||||
}
|
||||
for offset := 0; offset < len(data); {
|
||||
if err := e.output.ctx.Err(); err != nil {
|
||||
deflater.Close()
|
||||
return err
|
||||
}
|
||||
end := min(offset+64<<10, len(data))
|
||||
if _, err := deflater.Write(data[offset:end]); err != nil {
|
||||
return err
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
if err := deflater.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := data
|
||||
header.Method = zip.Store
|
||||
if compressed.Len() < len(data) {
|
||||
payload = compressed.Bytes()
|
||||
if compressed != nil {
|
||||
payload = compressed
|
||||
header.Method = zip.Deflate
|
||||
}
|
||||
header.CRC32 = crc32.ChecksumIEEE(data)
|
||||
header.UncompressedSize64 = uint64(len(data))
|
||||
header.CompressedSize64 = uint64(len(payload))
|
||||
header.Flags &^= 8
|
||||
prepareOutputHeader(&header, header.Method, uint64(len(data)), uint64(len(payload)), crc32.ChecksumIEEE(data))
|
||||
entry, err := archive.CreateRaw(&header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = entry.Write(payload); err != nil {
|
||||
if err = e.writeOutputBytes(entry, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
e.output.completed++
|
||||
return editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0)
|
||||
}
|
||||
|
||||
// writeOutputBytes 分块写出已编码数据,不额外分配复制缓冲
|
||||
// 入参: writer 输出流, data 已编码字节
|
||||
// 返回: error 写入或取消错误
|
||||
func (e *Editor) writeOutputBytes(writer io.Writer, data []byte) error {
|
||||
for offset := 0; offset < len(data); {
|
||||
if err := e.output.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
end := min(offset+64<<10, len(data))
|
||||
n, err := writer.Write(data[offset:end])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != end-offset {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
return e.output.ctx.Err()
|
||||
}
|
||||
|
||||
// compressOutputBytes 复用压缩器并限制候选体积,无收益时返回空值
|
||||
// 入参: data 未压缩数据, limit 候选体积上限
|
||||
// 返回: []byte 更小的DEFLATE编码, error 取消或编码错误
|
||||
func (e *Editor) compressOutputBytes(data []byte, limit int) ([]byte, error) {
|
||||
var compressed bytes.Buffer
|
||||
output := outputCompressionBuffer{buffer: &compressed, limit: min(limit, outputOptimizationBufferLimit)}
|
||||
deflater := e.output.deflater
|
||||
if deflater == nil {
|
||||
deflater, _ = flate.NewWriter(output, flate.BestCompression)
|
||||
e.output.deflater = deflater
|
||||
} else {
|
||||
deflater.Reset(output)
|
||||
}
|
||||
defer deflater.Close()
|
||||
for offset := 0; offset < len(data); {
|
||||
if err := e.output.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := min(offset+64<<10, len(data))
|
||||
if _, err := deflater.Write(data[offset:end]); err != nil {
|
||||
if err == io.ErrShortBuffer {
|
||||
return nil, e.output.ctx.Err()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
if err := deflater.Close(); err != nil {
|
||||
if err == io.ErrShortBuffer {
|
||||
return nil, e.output.ctx.Err()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if compressed.Len() >= limit {
|
||||
return nil, e.output.ctx.Err()
|
||||
}
|
||||
return compressed.Bytes(), e.output.ctx.Err()
|
||||
}
|
||||
|
||||
// prepareOutputHeader 更新原始ZIP编码属性,保留名称、备注和字符集标记
|
||||
// 入参: header 条目属性, method 编码方法, size 原始尺寸, packed 编码尺寸, checksum 校验值
|
||||
func prepareOutputHeader(header *zip.FileHeader, method uint16, size, packed uint64, checksum uint32) {
|
||||
header.Method, header.ReaderVersion = method, 20
|
||||
header.CreatorVersion = header.CreatorVersion&0xff00 | 20
|
||||
header.CRC32, header.UncompressedSize64, header.CompressedSize64 = checksum, size, packed
|
||||
header.Flags &^= 8
|
||||
header.Extra = outputZIPExtra(header.Extra)
|
||||
if header.NonUTF8 {
|
||||
header.Flags &^= 0x800
|
||||
} else if utf8.ValidString(header.Name) && utf8.ValidString(header.Comment) {
|
||||
for _, value := range header.Name + header.Comment {
|
||||
if value < 0x20 || value > 0x7d || value == 0x5c {
|
||||
header.Flags |= 0x800
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outputZIPExtra 保留扩展属性,移除由新归档重新生成的ZIP64尺寸及偏移
|
||||
// 入参: extra 原始扩展属性
|
||||
// 返回: []byte 输出扩展属性,不修改原始缓冲
|
||||
func outputZIPExtra(extra []byte) []byte {
|
||||
var output []byte
|
||||
start := 0
|
||||
for offset := 0; offset+4 <= len(extra); {
|
||||
size := 4 + int(binary.LittleEndian.Uint16(extra[offset+2:]))
|
||||
if size > len(extra)-offset {
|
||||
break
|
||||
}
|
||||
if binary.LittleEndian.Uint16(extra[offset:]) == 1 {
|
||||
if output == nil {
|
||||
output = make([]byte, 0, len(extra))
|
||||
}
|
||||
output = append(output, extra[start:offset]...)
|
||||
start = offset + size
|
||||
}
|
||||
offset += size
|
||||
}
|
||||
if output == nil {
|
||||
return extra
|
||||
}
|
||||
return append(output, extra[start:]...)
|
||||
}
|
||||
|
||||
// writeOutputFile 重压缩未修改条目,只采用优于原ZIP编码的结果
|
||||
// 入参: archive 输出归档, file 原始条目, buffer 流式复制缓冲
|
||||
// 返回: error 读取、写入或取消错误
|
||||
func (e *Editor) writeOutputFile(archive *zip.Writer, file *zip.File, buffer []byte) error {
|
||||
if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
header := file.FileHeader
|
||||
header.Extra = outputZIPExtra(header.Extra)
|
||||
var payload io.Reader
|
||||
if file.UncompressedSize64 <= outputOptimizationBufferLimit {
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(imageInput{ReadCloser: input, context: e.output.ctx}, outputOptimizationBufferLimit+1))
|
||||
input.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if uint64(len(data)) != file.UncompressedSize64 {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
limit := min(file.CompressedSize64, uint64(len(data)))
|
||||
compressed, err := e.compressOutputBytes(data, int(min(limit, outputOptimizationBufferLimit)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if compressed != nil {
|
||||
prepareOutputHeader(&header, zip.Deflate, uint64(len(data)), uint64(len(compressed)), file.CRC32)
|
||||
payload = bytes.NewReader(compressed)
|
||||
} else if uint64(len(data)) < file.CompressedSize64 {
|
||||
prepareOutputHeader(&header, zip.Store, uint64(len(data)), uint64(len(data)), file.CRC32)
|
||||
payload = bytes.NewReader(data)
|
||||
}
|
||||
}
|
||||
if payload == nil {
|
||||
var err error
|
||||
payload, err = file.OpenRaw()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
entry, err := archive.CreateRaw(&header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.CopyBuffer(entry, imageInput{ReadCloser: io.NopCloser(payload), context: e.output.ctx}, buffer); err != nil {
|
||||
return err
|
||||
}
|
||||
e.output.completed++
|
||||
|
||||
@@ -29,11 +29,74 @@ type compressionImages struct {
|
||||
sizes map[string]image.Point
|
||||
}
|
||||
|
||||
// compressionImagePlan 分文档分析标识,按共享路径合并最大尺寸和保护条件
|
||||
// 入参: ctx 取消上下文, options 压缩配置
|
||||
// 返回: map[string]bool 可有损处理的路径, map[string]image.Point 像素需求, error 读取或取消错误
|
||||
func (r *Reader) compressionImagePlan(ctx context.Context, options CompressionOptions) (map[string]bool, map[string]image.Point, error) {
|
||||
images := make(map[string]bool)
|
||||
sizes := make(map[string]image.Point)
|
||||
blocked := make(map[string]bool)
|
||||
for index := 0; index < r.DocumentCount(); index++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
document := r
|
||||
if index != r.DocumentIndex() {
|
||||
var err error
|
||||
document, err = r.Document(index)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
catalog, err := document.Images(ctx)
|
||||
if err != nil {
|
||||
if document != r {
|
||||
document.Close()
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
var masks map[string]bool
|
||||
var demands map[string]image.Point
|
||||
if options.Mode == CompressionLossy {
|
||||
var geometry bool
|
||||
masks, geometry, err = document.compressionImageSafety(ctx, catalog)
|
||||
if err == nil && geometry {
|
||||
demands, err = document.compressionImageSizes(ctx, catalog, options.ImageDPI())
|
||||
}
|
||||
}
|
||||
if document != r {
|
||||
document.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, img := range catalog {
|
||||
name := strings.ToLower(cleanPackagePath(img.Location))
|
||||
if old, ok := images[name]; !ok || old {
|
||||
images[name] = !masks[name]
|
||||
}
|
||||
size := demands[name]
|
||||
if size.X <= 0 || size.Y <= 0 || masks[name] {
|
||||
blocked[name] = true
|
||||
delete(sizes, name)
|
||||
continue
|
||||
}
|
||||
if !blocked[name] {
|
||||
old := sizes[name]
|
||||
sizes[name] = image.Pt(max(old.X, size.X), max(old.Y, size.Y))
|
||||
}
|
||||
}
|
||||
}
|
||||
return images, sizes, ctx.Err()
|
||||
}
|
||||
|
||||
// compressionImageSize 将毫米显示尺寸换算为像素,非法尺寸不降采样
|
||||
// 整像素边界消除单位换算产生的单ULP上溢,不降低实际像素需求
|
||||
// 入参: width 显示宽度, height 显示高度, dpi 分辨率上限
|
||||
// 返回: image.Point 像素需求
|
||||
func compressionImageSize(width, height float64, dpi int) image.Point {
|
||||
w, h := math.Ceil(width*float64(dpi)/25.4), math.Ceil(height*float64(dpi)/25.4)
|
||||
w := math.Ceil(math.Nextafter(width*float64(dpi)/25.4, math.Inf(-1)))
|
||||
h := math.Ceil(math.Nextafter(height*float64(dpi)/25.4, math.Inf(-1)))
|
||||
if w <= 0 || h <= 0 || math.IsNaN(w) || math.IsNaN(h) || math.IsInf(w, 0) || math.IsInf(h, 0) || w > 1<<30 || h > 1<<30 {
|
||||
return image.Point{}
|
||||
}
|
||||
@@ -71,10 +134,24 @@ func (r *Reader) compressionImageSizes(ctx context.Context, images []ImageInfo,
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
for _, ref := range r.doc.CommonData.TemplatePage {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
template := renderer.loadTemplate(ref.ID)
|
||||
if template == nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
for order := range 3 {
|
||||
if err := renderer.walkLayers(template.Content.Layer, order, visitor); err != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make(map[string]image.Point)
|
||||
for _, img := range images {
|
||||
name := strings.ToLower(cleanPackagePath(img.Location))
|
||||
size := visitor.sizes[img.ID]
|
||||
size := visitor.sizes[editorResourceID(img.ID)]
|
||||
old := result[name]
|
||||
result[name] = image.Pt(max(old.X, size.X), max(old.Y, size.Y))
|
||||
}
|
||||
@@ -92,6 +169,10 @@ func (v *compressionImages) DrawObject(object *GraphicObject, state RenderState)
|
||||
return nil
|
||||
}
|
||||
img := object.ImageObject
|
||||
id := editorResourceID(img.ResourceID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("invalid image resource")
|
||||
}
|
||||
box, err := ParseBox(img.Boundary)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -105,8 +186,8 @@ func (v *compressionImages) DrawObject(object *GraphicObject, state RenderState)
|
||||
if size.X == 0 || size.Y == 0 {
|
||||
return fmt.Errorf("invalid image placement")
|
||||
}
|
||||
old := v.sizes[img.ResourceID]
|
||||
v.sizes[img.ResourceID] = image.Pt(max(old.X, size.X), max(old.Y, size.Y))
|
||||
old := v.sizes[id]
|
||||
v.sizes[id] = image.Pt(max(old.X, size.X), max(old.Y, size.Y))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"maps"
|
||||
"path"
|
||||
@@ -28,6 +29,13 @@ import (
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// outputImageKey 区分相同图像的压缩配置和像素需求,不跨写出保留状态
|
||||
type outputImageKey struct {
|
||||
hash [32]byte
|
||||
options CompressionOptions
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// compressResourceParts 优化输出图片并合并相同资源文件,保留资源标识及各自属性
|
||||
// 入参: parts 输出改动, reader 原包,可为空, removed 已移除条目
|
||||
// 返回: error 读取或取消错误
|
||||
@@ -159,7 +167,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
|
||||
}
|
||||
formats := make(map[string]string)
|
||||
seen := make(map[string]map[[32]byte]string)
|
||||
e.output.optimized = make(map[string]bool)
|
||||
imageResults := make(map[outputImageKey][]byte)
|
||||
for _, key := range slices.Sorted(maps.Keys(actual)) {
|
||||
if refs.files[key] || files[resourceFile{key, "FontFile"}] && files[resourceFile{key, "MediaFile"}] {
|
||||
continue
|
||||
@@ -171,21 +179,48 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
|
||||
if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if data, ok := parts[actual[key]]; ok && len(data) > outputOptimizationBufferLimit {
|
||||
continue
|
||||
}
|
||||
if reader != nil && parts[actual[key]] == nil {
|
||||
if file, ok := reader.packageFile(actual[key]); ok && file.UncompressedSize64 > outputOptimizationBufferLimit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
data, err := read(actual[key])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > outputOptimizationBufferLimit {
|
||||
continue
|
||||
}
|
||||
if role == "MediaFile" {
|
||||
options := e.output.options
|
||||
if !e.output.images[key] {
|
||||
options.Mode = CompressionLossless
|
||||
}
|
||||
candidate, err := pdfgo.OptimizeImageResource(e.output.ctx, data, options, e.output.sizes[key])
|
||||
demand := e.output.sizes[key]
|
||||
if options.Mode != CompressionLossy {
|
||||
options = CompressionOptions{Mode: options.Mode}
|
||||
demand = image.Point{}
|
||||
}
|
||||
cacheKey := outputImageKey{hash: sha256.Sum256(data), options: options, size: demand}
|
||||
candidate, cached := imageResults[cacheKey]
|
||||
var err error
|
||||
if !cached {
|
||||
candidate, err = pdfgo.OptimizeImageResource(e.output.ctx, data, options, demand)
|
||||
if err == nil {
|
||||
imageResults[cacheKey] = nil
|
||||
if len(candidate) < len(data) {
|
||||
imageResults[cacheKey] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if e.output.ctx.Err() != nil {
|
||||
return e.output.ctx.Err()
|
||||
}
|
||||
} else if len(candidate) < len(data) {
|
||||
} else if candidate != nil && len(candidate) < len(data) {
|
||||
data = candidate
|
||||
parts[actual[key]] = data
|
||||
}
|
||||
@@ -203,10 +238,8 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
|
||||
parts[name] = data
|
||||
delete(parts, actual[key])
|
||||
removed[key] = true
|
||||
e.output.optimized[strings.ToLower(name)] = true
|
||||
}
|
||||
}
|
||||
e.output.optimized[key] = true
|
||||
}
|
||||
if seen[role] == nil {
|
||||
seen[role] = make(map[[32]byte]string)
|
||||
|
||||
@@ -48,6 +48,7 @@ type ConvertProgress struct {
|
||||
// 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出
|
||||
// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用
|
||||
// Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置
|
||||
// Compression统一配置输出及PDF图片导入,优先于PDF.Compression,文本输出不优化图片
|
||||
// PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil
|
||||
// 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换
|
||||
type ConvertOptions struct {
|
||||
@@ -202,6 +203,10 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ
|
||||
switch report.Input {
|
||||
case "pdf":
|
||||
pdfOptions := options.PDF
|
||||
pdfOptions.Compression = options.Compression
|
||||
if format == "txt" {
|
||||
pdfOptions.Compression = CompressionOptions{}
|
||||
}
|
||||
pdfOptions.RendererOptions = append(append([]RendererOption(nil), pdfOptions.RendererOptions...), options.RendererOptions...)
|
||||
if options.Backends != nil {
|
||||
pdfOptions.Backends = options.Backends
|
||||
|
||||
+2
-2
@@ -1161,7 +1161,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
||||
}
|
||||
code.Value = escapeOFDText(string(runes))
|
||||
}
|
||||
if strings.Trim(obj.Text(), "\n") == "" {
|
||||
if position == 0 {
|
||||
return fmt.Errorf("text codes are empty")
|
||||
}
|
||||
return missingGlyphError(obj.Font, missing)
|
||||
@@ -1296,7 +1296,7 @@ func ofdNumber(value float64) string {
|
||||
func escapeOFDText(value string) string {
|
||||
var text strings.Builder
|
||||
for _, char := range value {
|
||||
if char < ' ' || char == '\\' || char == '\ufffe' || char == '\uffff' {
|
||||
if char <= ' ' || char == '\\' || char == '\ufffe' || char == '\uffff' {
|
||||
fmt.Fprintf(&text, "\\%04X", char)
|
||||
} else {
|
||||
text.WriteRune(char)
|
||||
|
||||
+94
-16
@@ -15,6 +15,8 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"maps"
|
||||
@@ -41,6 +43,70 @@ type editorValidation struct {
|
||||
paths map[string]bool
|
||||
}
|
||||
|
||||
// AddColorSpace 注册颜色空间和可选ICC快照,相同配置共用文件,ID与Profile由库生成
|
||||
// 入参: space 模型、位深和调色板, profile ICC文件,无配置时为nil
|
||||
// 返回: string 资源标识, error 定义或配置错误
|
||||
func (e *Editor) AddColorSpace(space ColorSpace, profile []byte) (string, error) {
|
||||
space.ID, space.Profile, space.profile = "", "", nil
|
||||
space.Type = strings.ToUpper(space.Type)
|
||||
count := map[string]int{"GRAY": 1, "RGB": 3, "CMYK": 4}[space.Type]
|
||||
if count == 0 {
|
||||
return "", fmt.Errorf("unsupported color space %q", space.Type)
|
||||
}
|
||||
if space.BitsPerComponent == 0 {
|
||||
space.BitsPerComponent = 8
|
||||
}
|
||||
if !slices.Contains([]int{1, 2, 4, 8, 16}, space.BitsPerComponent) {
|
||||
return "", fmt.Errorf("unsupported color depth %d", space.BitsPerComponent)
|
||||
}
|
||||
limit := 1<<space.BitsPerComponent - 1
|
||||
for _, entry := range space.Palette {
|
||||
fields := strings.Fields(entry)
|
||||
if len(fields) != count {
|
||||
return "", fmt.Errorf("expected %d palette components", count)
|
||||
}
|
||||
for _, field := range fields {
|
||||
value, err := parseColorComponent(field)
|
||||
if err != nil || value < 0 || value > limit {
|
||||
return "", fmt.Errorf("invalid palette component %q", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
var key editorResourceKey
|
||||
if len(profile) != 0 {
|
||||
key = editorResourceKey{checksum: sha256.Sum256(profile), index: -1, kind: "ColorSpace/" + space.Type}
|
||||
if id := e.resourceID[key]; id != "" {
|
||||
for _, resource := range e.resources {
|
||||
if resource.space != nil && resource.space.ID == id && bytes.Equal(resource.data, profile) {
|
||||
space.profile, space.Profile = resource.space.profile, resource.space.Profile
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if space.profile == nil {
|
||||
var err error
|
||||
space.profile, err = parseColorProfile(space.Type, profile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := e.prepareSourceIDs(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
space.ID = e.nextID()
|
||||
space.Palette = slices.Clone(space.Palette)
|
||||
resource := editorResource{space: &space}
|
||||
if space.profile != nil && space.Profile == "" {
|
||||
resource.name = e.packageName("Res/ColorSpaces/ColorSpace_" + space.ID + ".icc")
|
||||
resource.data = bytes.Clone(profile)
|
||||
space.Profile = "/" + resource.name
|
||||
e.resourceID[key] = space.ID
|
||||
}
|
||||
e.resources = append(e.resources, resource)
|
||||
return space.ID, nil
|
||||
}
|
||||
|
||||
// GradientStops 解析当前文档的渐变分段,保留顺序并合并透明度
|
||||
// 入参: segments 渐变分段, alpha 外层透明度
|
||||
// 返回: []ColorStop 后端无关的色标, error 颜色解析错误
|
||||
@@ -112,11 +178,11 @@ func (e *Editor) StylePatterns(page int, ids []string, stroke bool, style Patter
|
||||
return e.updateObjects(page, objects, true)
|
||||
}
|
||||
|
||||
// Color 解析当前文档的纯色,支持RGB、灰度、CMYK及调色板,不修改原颜色
|
||||
// Color 解析当前文档的纯色,支持RGB、灰度、CMYK、调色板及ICC配置,不修改原颜色
|
||||
// 入参: value 颜色,nil表示默认黑色
|
||||
// 返回: color.NRGBA 非预乘颜色, error 错误信息
|
||||
func (e *Editor) Color(value *FillColor) (color.NRGBA, error) {
|
||||
space := ColorSpace{Type: "RGB", BitsPerComponent: 8}
|
||||
space := &ColorSpace{Type: "RGB", BitsPerComponent: 8}
|
||||
if value == nil {
|
||||
return color.NRGBA{A: 255}, nil
|
||||
}
|
||||
@@ -130,7 +196,7 @@ func (e *Editor) Color(value *FillColor) (color.NRGBA, error) {
|
||||
if id != "" {
|
||||
var found *ColorSpace
|
||||
if e.source != nil {
|
||||
found = e.source.reader.colorSpaceCache[id]
|
||||
found, _ = resourceValue(e.source.reader.colorSpaceCache, id)
|
||||
}
|
||||
for _, resource := range e.resources {
|
||||
if resource.space != nil && resource.space.ID == id {
|
||||
@@ -141,7 +207,7 @@ func (e *Editor) Color(value *FillColor) (color.NRGBA, error) {
|
||||
if found == nil {
|
||||
return color.NRGBA{}, fmt.Errorf("color space %q not found", id)
|
||||
}
|
||||
space = *found
|
||||
space = found
|
||||
}
|
||||
bits := space.BitsPerComponent
|
||||
if bits == 0 {
|
||||
@@ -151,7 +217,8 @@ func (e *Editor) Color(value *FillColor) (color.NRGBA, error) {
|
||||
return color.NRGBA{}, fmt.Errorf("unsupported color depth %d", bits)
|
||||
}
|
||||
count := 3
|
||||
switch space.Type {
|
||||
kind := strings.ToUpper(space.Type)
|
||||
switch kind {
|
||||
case "RGB":
|
||||
case "GRAY":
|
||||
count = 1
|
||||
@@ -168,10 +235,11 @@ func (e *Editor) Color(value *FillColor) (color.NRGBA, error) {
|
||||
text = space.Palette[*value.Index]
|
||||
}
|
||||
fields := strings.Fields(text)
|
||||
if len(fields) != count {
|
||||
if len(fields) != 0 && len(fields) != count {
|
||||
return color.NRGBA{}, fmt.Errorf("expected %d color components", count)
|
||||
}
|
||||
var components [4]uint8
|
||||
var normalized [4]float64
|
||||
limit := 1<<bits - 1
|
||||
for i, field := range fields {
|
||||
v, err := parseColorComponent(field)
|
||||
@@ -179,13 +247,29 @@ func (e *Editor) Color(value *FillColor) (color.NRGBA, error) {
|
||||
return color.NRGBA{}, fmt.Errorf("invalid color component %q", field)
|
||||
}
|
||||
components[i] = uint8((v*255 + limit/2) / limit)
|
||||
normalized[i] = float64(v) / float64(limit)
|
||||
}
|
||||
r, g, b := components[0], components[1], components[2]
|
||||
if space.Type == "GRAY" {
|
||||
if kind == "GRAY" {
|
||||
g, b = r, r
|
||||
} else if space.Type == "CMYK" {
|
||||
} else if kind == "CMYK" {
|
||||
r, g, b = color.CMYKToRGB(components[0], components[1], components[2], components[3])
|
||||
}
|
||||
var reader *Reader
|
||||
if e.source != nil {
|
||||
reader = e.source.reader
|
||||
}
|
||||
profile, err := reader.colorProfile(space)
|
||||
if err != nil {
|
||||
return color.NRGBA{}, err
|
||||
}
|
||||
if profile != nil {
|
||||
rgb, err := profile.rgb(normalized)
|
||||
if err != nil {
|
||||
return color.NRGBA{}, err
|
||||
}
|
||||
r, g, b = meshByte(rgb[0]), meshByte(rgb[1]), meshByte(rgb[2])
|
||||
}
|
||||
a := 255
|
||||
if value.Alpha != nil {
|
||||
a = *value.Alpha
|
||||
@@ -211,14 +295,14 @@ func (e *Editor) RGBColor(value color.NRGBA) (*FillColor, error) {
|
||||
}
|
||||
for _, id := range slices.Sorted(maps.Keys(e.source.reader.colorSpaceCache)) {
|
||||
space := e.source.reader.colorSpaceCache[id]
|
||||
if space.Type == "RGB" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0 {
|
||||
if space.Type == "RGB" && space.Profile == "" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0 {
|
||||
result.ColorSpace = id
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
for _, resource := range e.resources {
|
||||
space := resource.space
|
||||
if space != nil && space.Type == "RGB" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0 {
|
||||
if space != nil && space.Type == "RGB" && space.Profile == "" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0 {
|
||||
result.ColorSpace = space.ID
|
||||
return result, nil
|
||||
}
|
||||
@@ -400,9 +484,6 @@ func (e *Editor) editorMeshColor(value *FillColor) error {
|
||||
colors = append(colors, *back)
|
||||
}
|
||||
for _, color := range colors {
|
||||
if color.ColorSpace == "" {
|
||||
color.ColorSpace = value.ColorSpace
|
||||
}
|
||||
if _, err := e.Color(&FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -516,9 +597,6 @@ func (v *editorValidation) editorColor(value *FillColor) error {
|
||||
}
|
||||
previous = position
|
||||
color := segment.Color
|
||||
if color.ColorSpace == "" {
|
||||
color.ColorSpace = value.ColorSpace
|
||||
}
|
||||
if _, err := e.Color(&FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -377,13 +377,15 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer,
|
||||
}
|
||||
object := layer.Objects[index]
|
||||
origin := e.objectOrigin(path.ID)
|
||||
var data []byte
|
||||
if origin == nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("composite requires original content")
|
||||
}
|
||||
data, err := editorXMLObject(origin.data, origin.node, origin.object, object)
|
||||
data, err = editorObjectXML(object)
|
||||
} else {
|
||||
data, err = editorXMLObject(origin.data, origin.node, origin.object, object)
|
||||
if err == nil {
|
||||
data, err = editorXMLStandalone(data, origin.node)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
@@ -818,8 +820,11 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render
|
||||
next.setStates(root.states)
|
||||
next.object.CompositeGraphicUnit.states = next.states
|
||||
origin := e.objectOrigin(path.ID)
|
||||
updated := &editorObjectOrigin{data: data, node: next.node, object: next.object}
|
||||
if origin != nil {
|
||||
next.node.parent = origin.node.parent
|
||||
updated := &editorObjectOrigin{page: origin.page, data: data, node: next.node, object: next.object}
|
||||
updated.page = origin.page
|
||||
}
|
||||
return e.updateObjectOrigins(page, []GraphicObject{next.object}, true, map[string]*editorObjectOrigin{path.ID: updated})
|
||||
}
|
||||
|
||||
|
||||
+3
-116
@@ -16,11 +16,10 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"path"
|
||||
"reflect"
|
||||
@@ -217,7 +216,7 @@ func (e *Editor) DeleteDocument(index int) error {
|
||||
return fmt.Errorf("package must contain at least one document")
|
||||
}
|
||||
return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) {
|
||||
removed, err := editorDocumentFiles(reader, index)
|
||||
removed, err := reader.documentFiles(context.Background(), index)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -236,7 +235,7 @@ func (e *Editor) DeleteDocument(index int) error {
|
||||
if other == index {
|
||||
continue
|
||||
}
|
||||
kept, err := editorDocumentFiles(reader, other)
|
||||
kept, err := reader.documentFiles(context.Background(), other)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -341,115 +340,3 @@ func (e *Editor) sameDocument(other *Editor) bool {
|
||||
}
|
||||
return e.documentRoot() == other.documentRoot() && e.source.info.DocID == other.source.info.DocID
|
||||
}
|
||||
|
||||
// editorDocumentFiles 按标准路径遍历文档依赖,签名摘要引用不作为内容依赖
|
||||
// 入参: reader 包快照, index 文档索引
|
||||
// 返回: map[string]bool 大小写折叠的文件路径, error 读取错误
|
||||
func editorDocumentFiles(reader *Reader, index int) (map[string]bool, error) {
|
||||
body := reader.OFD.DocBody[index]
|
||||
queue := []string{cleanPackagePath(body.DocRoot)}
|
||||
data, err := reader.readFile("OFD.xml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var collect func(*editorXML)
|
||||
collect = func(node *editorXML) {
|
||||
if node.name.Local == "Cover" || node.name.Local == "Signatures" || node.name.Local == "DocRoot" {
|
||||
if value := strings.TrimSpace(editorImportText(data, node)); value != "" {
|
||||
queue = append(queue, resolveResourcePath("OFD.xml", "", value))
|
||||
}
|
||||
}
|
||||
if node.name.Local == "Version" && node.attr("BaseLoc") != "" {
|
||||
queue = append(queue, resolveResourcePath("OFD.xml", "", node.attr("BaseLoc")))
|
||||
}
|
||||
for _, child := range node.children {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
collect(root.childAt("DocBody", index))
|
||||
seen := make(map[string]bool)
|
||||
for len(queue) > 0 {
|
||||
name := queue[0]
|
||||
queue = queue[1:]
|
||||
key := strings.ToLower(name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
actual, exists := reader.fileNamesFold[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
input, err := reader.openFile(actual)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffer := bufio.NewReader(input)
|
||||
if prefix, _ := buffer.Peek(3); bytes.Equal(prefix, []byte{0xef, 0xbb, 0xbf}) {
|
||||
buffer.Discard(3)
|
||||
}
|
||||
header, _ := buffer.Peek(128)
|
||||
for len(header) > 0 && len(bytes.TrimSpace(header)) == 0 {
|
||||
buffer.Discard(len(header))
|
||||
header, _ = buffer.Peek(128)
|
||||
}
|
||||
if !bytes.HasPrefix(bytes.TrimSpace(header), []byte("<")) {
|
||||
input.Close()
|
||||
continue
|
||||
}
|
||||
decoder := xml.NewDecoder(buffer)
|
||||
base, resource, depth := "", false, 0
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return nil, err
|
||||
}
|
||||
switch node := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 && node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" {
|
||||
input.Close()
|
||||
goto nextFile
|
||||
}
|
||||
if node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" {
|
||||
input.Close()
|
||||
return nil, fmt.Errorf("cannot determine document dependencies in %s", actual)
|
||||
}
|
||||
if depth == 0 {
|
||||
resource = node.Name.Local == "Res"
|
||||
}
|
||||
for _, attr := range node.Attr {
|
||||
if resource && depth == 0 && attr.Name.Local == "BaseLoc" {
|
||||
base = attr.Value
|
||||
} else if attr.Name.Local == "BaseLoc" || attr.Name.Local == "FileLoc" || node.Name.Local == "DrawParam" && attr.Name.Local == "Link" {
|
||||
queue = append(queue, resolveResourcePath(actual, base, attr.Value))
|
||||
}
|
||||
}
|
||||
depth++
|
||||
text.Reset()
|
||||
case xml.CharData:
|
||||
text.Write(node)
|
||||
case xml.EndElement:
|
||||
switch node.Name.Local {
|
||||
case "DocRoot", "Signatures", "Cover", "PublicRes", "DocumentRes", "PageRes", "Annotations", "Attachments", "FileLoc", "Profile", "SignedValue", "SchemaLoc", "ExtendData", "File", "FontFile", "MediaFile", "CustomTags", "Extensions":
|
||||
if value := strings.TrimSpace(text.String()); value != "" {
|
||||
queue = append(queue, resolveResourcePath(actual, base, value))
|
||||
}
|
||||
}
|
||||
depth--
|
||||
text.Reset()
|
||||
}
|
||||
}
|
||||
input.Close()
|
||||
nextFile:
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
@@ -960,6 +960,14 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
|
||||
}
|
||||
case "AttachID":
|
||||
value, err = m.attachment(value)
|
||||
case "Profile":
|
||||
if name == "ColorSpace" {
|
||||
loc, resolveErr := m.resolve(entry.name, entry.base, strings.TrimSpace(value))
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
value, err = m.copyData(loc, path.Join("Res/ColorSpaces", path.Base(loc)), false)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -346,7 +346,7 @@ func (e *Editor) sourceRGB() bool {
|
||||
return true
|
||||
}
|
||||
space := e.source.reader.colorSpaceCache[strconv.Itoa(e.source.document.CommonData.DefaultCS)]
|
||||
return space != nil && space.Type == "RGB" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0
|
||||
return space != nil && space.Type == "RGB" && space.Profile == "" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0
|
||||
}
|
||||
|
||||
// repairSourceObjectIDs 按实际节点区分重复对象,只修复没有引用歧义的编号
|
||||
@@ -891,14 +891,18 @@ func (e *Editor) editorTextMeasurable(text TextObject) bool {
|
||||
// 入参: id 图片资源标识
|
||||
// 返回: image.Point 图片尺寸, error 错误信息
|
||||
func (e *Editor) editorImage(id string) (image.Point, error) {
|
||||
if size := e.images[id]; size.X > 0 {
|
||||
if size, ok := resourceValue(e.images, id); ok && size.X > 0 {
|
||||
return size, nil
|
||||
}
|
||||
if e.source == nil {
|
||||
return image.Point{}, fmt.Errorf("image resource %q not found", id)
|
||||
}
|
||||
reader := e.source.reader
|
||||
input, err := reader.openFile(reader.ResPath(reader.ResMap[id]))
|
||||
name, ok := resourceValue(reader.ResMap, id)
|
||||
if !ok {
|
||||
return image.Point{}, fmt.Errorf("image resource %q not found", id)
|
||||
}
|
||||
input, err := reader.openFile(reader.ResPath(name))
|
||||
if err != nil {
|
||||
return image.Point{}, err
|
||||
}
|
||||
|
||||
+3
-12
@@ -104,7 +104,7 @@ func (e *Editor) sourcePartsPrepared(progress editorProgress, generated map[stri
|
||||
}
|
||||
fonts, images, spaces, resourceFiles := e.usedResources()
|
||||
for _, resource := range e.resources {
|
||||
if resource.definition() != "" && slices.Contains(resourceFiles, resource.name) {
|
||||
if resource.definition() != "" && slices.Contains(resourceFiles, resource.name) || resource.colorProfileUsed(spaces) {
|
||||
parts[resource.name] = resource.data
|
||||
}
|
||||
}
|
||||
@@ -607,17 +607,8 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
|
||||
return output.count, err
|
||||
}
|
||||
delete(remaining, name)
|
||||
} else if e.output != nil && e.output.options.Mode != CompressionUnchanged && file.UncompressedSize64 <= 64<<20 {
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
data, err := io.ReadAll(imageInput{ReadCloser: input, context: e.output.ctx})
|
||||
input.Close()
|
||||
if err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
if err := e.writeOutputEntry(archive, file.FileHeader, data); err != nil {
|
||||
} else if e.output != nil && e.output.options.Mode != CompressionUnchanged {
|
||||
if err := e.writeOutputFile(archive, file, buffer); err != nil {
|
||||
return output.count, err
|
||||
}
|
||||
} else {
|
||||
|
||||
+1
-15
@@ -21,7 +21,6 @@ import (
|
||||
"encoding/xml"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// editorStagedPage 保存单次写出的压缩页面及校验值,不持有未压缩XML
|
||||
@@ -107,20 +106,7 @@ func (e *Editor) writeStagedPage(archive *zip.Writer, header zip.FileHeader, pag
|
||||
return err
|
||||
}
|
||||
}
|
||||
header.Method, header.ReaderVersion = zip.Deflate, 20
|
||||
header.CreatorVersion = header.CreatorVersion&0xff00 | 20
|
||||
header.CRC32, header.UncompressedSize64, header.CompressedSize64 = page.checksum, page.size, uint64(len(page.data))
|
||||
header.Flags &^= 8
|
||||
if header.NonUTF8 {
|
||||
header.Flags &^= 0x800
|
||||
} else if utf8.ValidString(header.Name) && utf8.ValidString(header.Comment) {
|
||||
for _, value := range header.Name + header.Comment {
|
||||
if value < 0x20 || value > 0x7d || value == 0x5c {
|
||||
header.Flags |= 0x800
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
prepareOutputHeader(&header, zip.Deflate, page.size, uint64(len(page.data)), page.checksum)
|
||||
entry, err := archive.CreateRaw(&header)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
)
|
||||
|
||||
// addOwnedVector 将本库生成的独立对象注册为标准矢量资源,不保留内联复合内容
|
||||
// 入参: ctx 取消上下文, objects 无原文和标识的对象, width 资源宽度, height 资源高度
|
||||
// 返回: string 资源编号, error 校验、编码或取消错误
|
||||
func (e *Editor) addOwnedVector(ctx context.Context, objects []GraphicObject, width, height float64) (string, error) {
|
||||
validation := &editorValidation{Editor: e}
|
||||
for index, object := range objects {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
prepared, err := validation.prepareObject(e.nextID(), object)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
objects[index] = prepared
|
||||
}
|
||||
id := e.nextID()
|
||||
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
||||
x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}})
|
||||
x.start("CompositeGraphicUnits", nil)
|
||||
x.start("CompositeGraphicUnit", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id},
|
||||
{Name: xml.Name{Local: "Width"}, Value: ofdNumber(width)}, {Name: xml.Name{Local: "Height"}, Value: ofdNumber(height)}})
|
||||
x.start("Content", nil)
|
||||
for _, object := range objects {
|
||||
if err := ctx.Err(); err != nil {
|
||||
x.err = err
|
||||
return
|
||||
}
|
||||
x.object(object, false)
|
||||
}
|
||||
x.end("Content")
|
||||
x.end("CompositeGraphicUnit")
|
||||
x.end("CompositeGraphicUnits")
|
||||
x.end("Res")
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resource, err := e.compositeResource(id, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
e.resources = append(e.resources, resource)
|
||||
return id, ctx.Err()
|
||||
}
|
||||
+21
-8
@@ -46,6 +46,16 @@ type imageInput struct {
|
||||
context context.Context
|
||||
}
|
||||
|
||||
// imageResourceKey 合并有效十进制标识,非标准标识保留原值,不相互混淆
|
||||
// 入参: id 图片标识
|
||||
// 返回: string 查找键
|
||||
func imageResourceKey(id string) string {
|
||||
if key := editorResourceID(id); key != "" {
|
||||
return key
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Read 检查取消状态后读取原始数据
|
||||
// 入参: data 数据缓冲区
|
||||
// 返回: int 读取字节数, error 读取或取消错误
|
||||
@@ -129,13 +139,14 @@ func (r *Reader) Images(ctx context.Context) ([]ImageInfo, error) {
|
||||
return nil, fmt.Errorf("invalid image resource")
|
||||
}
|
||||
item := ImageInfo{ID: media.ID, Name: path.Base(location), Format: media.Format, Location: location}
|
||||
if previous, ok := ids[item.ID]; ok {
|
||||
if previous != item {
|
||||
key := imageResourceKey(item.ID)
|
||||
if previous, ok := ids[key]; ok {
|
||||
if previous.Name != item.Name || previous.Format != item.Format || previous.Location != item.Location {
|
||||
return nil, fmt.Errorf("conflicting image resource %q", item.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
ids[item.ID] = item
|
||||
ids[key] = item
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +161,8 @@ func (r *Reader) Image(ctx context.Context, id string) (ImageInfo, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
if name := r.resourceFiles[id]; name != "" {
|
||||
name, _ := resourceValue(r.resourceFiles, id)
|
||||
if name != "" {
|
||||
data, err := r.readFile(name)
|
||||
if err != nil {
|
||||
return ImageInfo{}, err
|
||||
@@ -160,7 +172,7 @@ func (r *Reader) Image(ctx context.Context, id string) (ImageInfo, error) {
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
for _, media := range res.MultiMedias.MultiMedia {
|
||||
if media.ID == id && media.Type == "Image" && media.MediaFile != "" {
|
||||
if imageResourceKey(media.ID) == imageResourceKey(id) && media.Type == "Image" && media.MediaFile != "" {
|
||||
location := resolveResourcePath(name, res.BaseLoc, media.MediaFile)
|
||||
return ImageInfo{ID: id, Name: path.Base(location), Format: media.Format, Location: location}, nil
|
||||
}
|
||||
@@ -172,7 +184,7 @@ func (r *Reader) Image(ctx context.Context, id string) (ImageInfo, error) {
|
||||
return ImageInfo{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
if imageResourceKey(item.ID) == imageResourceKey(id) {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
@@ -209,7 +221,8 @@ func (r *Reader) WriteImages(ctx context.Context, writer io.Writer, ids ...strin
|
||||
} else {
|
||||
seen := make(map[string]bool)
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
key := imageResourceKey(id)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
item, err := r.Image(ctx, id)
|
||||
@@ -217,7 +230,7 @@ func (r *Reader) WriteImages(ctx context.Context, writer io.Writer, ids ...strin
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
seen[id] = true
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
archive := zip.NewWriter(imageOutput{ctx, writer})
|
||||
|
||||
@@ -87,7 +87,7 @@ func (img *EncodedImage) At(x, y int) color.Color {
|
||||
// 入参: id 图片资源标识
|
||||
// 返回: image.Image 图片资源, error 读取错误
|
||||
func (r *Renderer) ImageResource(id string) (image.Image, error) {
|
||||
path, ok := r.Reader.ResMap[id]
|
||||
path, ok := resourceValue(r.Reader.ResMap, id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("image resource %q not found", id)
|
||||
}
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ func (r *Reader) Media(id string) (MultiMedia, error) {
|
||||
if _, err := r.Doc(); err != nil {
|
||||
return MultiMedia{}, err
|
||||
}
|
||||
media, ok := r.mediaCache[id]
|
||||
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)
|
||||
}
|
||||
@@ -41,5 +41,5 @@ func (r *Reader) OpenMedia(id string) (io.ReadCloser, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.openFile(media.MediaFile)
|
||||
return r.openFile(r.ResPath(media.MediaFile))
|
||||
}
|
||||
+19
-5
@@ -53,6 +53,23 @@ func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop
|
||||
// 返回: color.Color 颜色对象
|
||||
func (r *Renderer) parseColorWithAlpha(value string, index *int, space string, alpha *int) color.Color {
|
||||
kind, values := r.colorComponents(value, index, space)
|
||||
profile, err := r.Reader.colorProfile(r.colorDefinition(space))
|
||||
if err != nil {
|
||||
r.renderError = err
|
||||
return color.RGBA{}
|
||||
}
|
||||
if profile != nil {
|
||||
rgb, err := profile.rgb(values)
|
||||
if err != nil {
|
||||
r.renderError = err
|
||||
return color.RGBA{}
|
||||
}
|
||||
a := 1.0
|
||||
if alpha != nil {
|
||||
a = float64(clampColor(*alpha)) / 255
|
||||
}
|
||||
return profileColor(rgb, a)
|
||||
}
|
||||
var components [4]uint8
|
||||
for i, value := range values {
|
||||
components[i] = uint8(math.Round(value * 255))
|
||||
@@ -75,13 +92,10 @@ func (r *Renderer) parseColorWithAlpha(value string, index *int, space string, a
|
||||
// 入参: value 颜色值, index 调色板索引, space 颜色空间标识
|
||||
// 返回: string 颜色模型, [4]float64 单位分量
|
||||
func (r *Renderer) colorComponents(value string, index *int, space string) (string, [4]float64) {
|
||||
if space == "" && r.Reader.doc != nil {
|
||||
space = strconv.Itoa(r.Reader.doc.CommonData.DefaultCS)
|
||||
}
|
||||
cs := r.Reader.colorSpaceCache[space]
|
||||
cs := r.colorDefinition(space)
|
||||
kind, bits, count := "RGB", 8, 3
|
||||
if cs != nil {
|
||||
kind = cs.Type
|
||||
kind = strings.ToUpper(cs.Type)
|
||||
switch cs.BitsPerComponent {
|
||||
case 1, 2, 4, 8, 16:
|
||||
bits = cs.BitsPerComponent
|
||||
|
||||
+40
-10
@@ -49,6 +49,7 @@ type MeshVertex struct {
|
||||
Space string
|
||||
Values [4]float64
|
||||
Alpha float64
|
||||
profile *colorProfile
|
||||
}
|
||||
|
||||
// MeshShading 保存后端无关的三角网格,后出现的三角形覆盖先出现的三角形
|
||||
@@ -82,7 +83,7 @@ func (m *MeshShading) At(x, y float64) color.RGBA {
|
||||
values[k] += weight * t[j].Values[k]
|
||||
}
|
||||
}
|
||||
red, green, blue := meshRGB(t[0].Space, values)
|
||||
red, green, blue := t[0].rgb(values)
|
||||
return color.RGBA{meshByte(red * alpha), meshByte(green * alpha), meshByte(blue * alpha), meshByte(alpha)}
|
||||
}
|
||||
return m.Background
|
||||
@@ -129,22 +130,20 @@ func (r *Renderer) resolveMesh(fill *FillColor) (*MeshShading, error) {
|
||||
vertices := make([]MeshVertex, len(points))
|
||||
for i, point := range points {
|
||||
space := point.Color.ColorSpace
|
||||
if space == "" {
|
||||
space = fill.ColorSpace
|
||||
}
|
||||
kind, values := r.colorComponents(point.Color.Value, point.Color.Index, space)
|
||||
profile, err := r.Reader.colorProfile(r.colorDefinition(space))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alpha := 1.0
|
||||
if value := mergeAlpha(point.Color.Alpha, fill.Alpha); value != nil {
|
||||
alpha = float64(*value) / 255
|
||||
}
|
||||
vertices[i] = MeshVertex{Point{point.X, point.Y}, kind, values, alpha}
|
||||
vertices[i] = MeshVertex{Point: Point{point.X, point.Y}, Space: kind, Values: values, Alpha: alpha, profile: profile}
|
||||
}
|
||||
mesh := &MeshShading{}
|
||||
if extend == 1 && back != nil {
|
||||
space := back.ColorSpace
|
||||
if space == "" {
|
||||
space = fill.ColorSpace
|
||||
}
|
||||
mesh.Background = r.ResolveColor(back.Value, back.Index, space, mergeAlpha(back.Alpha, fill.Alpha))
|
||||
}
|
||||
for _, triangle := range indices {
|
||||
@@ -152,16 +151,47 @@ func (r *Renderer) resolveMesh(fill *FillColor) (*MeshShading, error) {
|
||||
}
|
||||
for i := range mesh.Triangles {
|
||||
t := &mesh.Triangles[i]
|
||||
if t[0].Space != t[1].Space || t[0].Space != t[2].Space {
|
||||
if !t[0].sameSpace(t[1]) || !t[0].sameSpace(t[2]) {
|
||||
for j := range t {
|
||||
r, g, b := meshRGB(t[j].Space, t[j].Values)
|
||||
r, g, b := t[j].rgb(t[j].Values)
|
||||
t[j].Space, t[j].Values = "RGB", [4]float64{r, g, b}
|
||||
t[j].profile = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return mesh, nil
|
||||
}
|
||||
|
||||
// sameSpace 判断分量能否在原颜色空间内插值,不混合不同ICC定义的分量
|
||||
// 入参: other 另一顶点
|
||||
// 返回: bool 模型及配置是否相同
|
||||
func (v MeshVertex) sameSpace(other MeshVertex) bool {
|
||||
if v.Space != other.Space {
|
||||
return false
|
||||
}
|
||||
if v.profile == other.profile {
|
||||
return true
|
||||
}
|
||||
return v.profile != nil && other.profile != nil && v.profile.space.Equal(other.profile.space)
|
||||
}
|
||||
|
||||
// rgb 转换采样分量,插值浮点误差裁切到源空间单位区间
|
||||
// 入参: values 插值分量
|
||||
// 返回: float64 红、绿、蓝分量
|
||||
func (v MeshVertex) rgb(values [4]float64) (float64, float64, float64) {
|
||||
if v.profile != nil {
|
||||
for i := range values {
|
||||
values[i] = math.Max(0, math.Min(1, values[i]))
|
||||
}
|
||||
rgb, err := v.profile.rgb(values)
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return rgb[0], rgb[1], rgb[2]
|
||||
}
|
||||
return meshRGB(v.Space, values)
|
||||
}
|
||||
|
||||
// meshTriangleIndices 校验控制点并生成共享边或格构三角形编号
|
||||
// 入参: points 控制点, columns 每行点数,零表示非格构, extend 延伸标志
|
||||
// 返回: [][3]int 三角形顶点编号, error 非法拓扑
|
||||
|
||||
@@ -45,18 +45,23 @@ type Paint struct {
|
||||
|
||||
// Shading 保存OFD对象局部坐标中的渐变,单位为毫米,纵轴向下
|
||||
// Extend、MapType和MapUnit保留标准的延伸及重复语义
|
||||
// Sample存在时按单位区间采样源颜色变换,Stops仍保存各段的显示颜色
|
||||
type Shading struct {
|
||||
Start, End Point
|
||||
StartRadius, EndRadius float64
|
||||
Stops []ColorStop
|
||||
Extend, MapType string
|
||||
MapUnit, Eccentricity, Angle float64
|
||||
Sample func(float64) color.RGBA
|
||||
}
|
||||
|
||||
// ResolvePaint 解析颜色、透明度、渐变分段和图案,不依赖绘图库
|
||||
// 入参: fill 填充或转换后的描边节点
|
||||
// 返回: Paint 只读画刷, error 无效渐变
|
||||
func (r *Renderer) ResolvePaint(fill *FillColor) (Paint, error) {
|
||||
previous := r.renderError
|
||||
r.renderError = nil
|
||||
defer func() { r.renderError = previous }()
|
||||
if fill == nil {
|
||||
return Paint{}, nil
|
||||
}
|
||||
@@ -68,9 +73,15 @@ func (r *Renderer) ResolvePaint(fill *FillColor) (Paint, error) {
|
||||
if err != nil {
|
||||
return Paint{}, err
|
||||
}
|
||||
if r.renderError != nil {
|
||||
return Paint{}, r.renderError
|
||||
}
|
||||
return Paint{Kind: PaintMesh, Mesh: mesh}, nil
|
||||
}
|
||||
base := r.parseFillColor(fill)
|
||||
if r.renderError != nil {
|
||||
return Paint{}, r.renderError
|
||||
}
|
||||
if base == nil {
|
||||
return Paint{}, nil
|
||||
}
|
||||
@@ -78,18 +89,28 @@ func (r *Renderer) ResolvePaint(fill *FillColor) (Paint, error) {
|
||||
if node := fill.AxialShd; node != nil {
|
||||
start, end := parseFloats(node.StartPoint), parseFloats(node.EndPoint)
|
||||
stops := r.renderGradientStops(node.Segment, fill.Alpha)
|
||||
sample, err := r.profileGradient(node.Segment, fill.Alpha)
|
||||
if err != nil {
|
||||
return Paint{}, err
|
||||
}
|
||||
if len(start) >= 2 && len(end) >= 2 && len(stops) != 0 && (!geometryEqual(start[0], end[0]) || !geometryEqual(start[1], end[1])) {
|
||||
paint.Kind = PaintLinear
|
||||
paint.Gradient = &Shading{Start: Point{X: start[0], Y: start[1]}, End: Point{X: end[0], Y: end[1]}, Stops: stops, Extend: node.Extend, MapType: node.MapType, MapUnit: node.MapUnit}
|
||||
paint.Gradient.Sample = sample
|
||||
return paint, nil
|
||||
}
|
||||
}
|
||||
if node := fill.RadialShd; node != nil && node.EndRadius > 0 {
|
||||
start, end := parseFloats(node.StartPoint), parseFloats(node.EndPoint)
|
||||
stops := r.renderGradientStops(node.Segment, fill.Alpha)
|
||||
sample, err := r.profileGradient(node.Segment, fill.Alpha)
|
||||
if err != nil {
|
||||
return Paint{}, err
|
||||
}
|
||||
if len(start) >= 2 && len(end) >= 2 && len(stops) != 0 {
|
||||
paint.Kind = PaintRadial
|
||||
paint.Gradient = &Shading{Start: Point{X: start[0], Y: start[1]}, End: Point{X: end[0], Y: end[1]}, StartRadius: node.StartRadius, EndRadius: node.EndRadius, Stops: stops, Extend: node.Extend, MapType: node.MapType, MapUnit: node.MapUnit, Eccentricity: node.Eccentricity, Angle: node.Angle}
|
||||
paint.Gradient.Sample = sample
|
||||
}
|
||||
}
|
||||
return paint, nil
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// profileGradient 保存独立色标和只读配置,延伸及重复由几何采样器处理
|
||||
type profileGradient struct {
|
||||
positions []float64
|
||||
colors []MeshVertex
|
||||
}
|
||||
|
||||
// profileGradient 准备ICC渐变的源分量采样,不用转换后的端点近似非线性色彩变换
|
||||
// 入参: segments 原色标, alpha 外层透明度
|
||||
// 返回: func(float64) color.RGBA 单位区间采样器,无ICC时为nil, error 配置错误
|
||||
func (r *Renderer) profileGradient(segments []ShdSegment, alpha *int) (func(float64) color.RGBA, error) {
|
||||
profiled := false
|
||||
for _, segment := range segments {
|
||||
if space := r.colorDefinition(segment.Color.ColorSpace); space != nil && space.Profile != "" {
|
||||
profiled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !profiled {
|
||||
return nil, nil
|
||||
}
|
||||
gradient := &profileGradient{positions: gradientPositions(segments), colors: make([]MeshVertex, len(segments))}
|
||||
for i, segment := range segments {
|
||||
kind, values := r.colorComponents(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace)
|
||||
profile, err := r.Reader.colorProfile(r.colorDefinition(segment.Color.ColorSpace))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opacity := 1.0
|
||||
if value := mergeAlpha(segment.Color.Alpha, alpha); value != nil {
|
||||
opacity = float64(clampColor(*value)) / 255
|
||||
}
|
||||
gradient.colors[i] = MeshVertex{Space: kind, Values: values, Alpha: opacity, profile: profile}
|
||||
}
|
||||
return gradient.At, nil
|
||||
}
|
||||
|
||||
// At 在相同源空间内插值,不同空间先转换为sRGB,保留重复位置的右侧色标
|
||||
// 入参: t 单位区间位置
|
||||
// 返回: color.RGBA 预乘采样颜色
|
||||
func (g *profileGradient) At(t float64) color.RGBA {
|
||||
if len(g.colors) == 0 || math.IsNaN(t) {
|
||||
return color.RGBA{}
|
||||
}
|
||||
first := g.colors[0]
|
||||
if t <= g.positions[0] {
|
||||
r, green, b := first.rgb(first.Values)
|
||||
return profileColor([3]float64{r, green, b}, first.Alpha)
|
||||
}
|
||||
for i := 1; i < len(g.colors); i++ {
|
||||
if t < g.positions[i] {
|
||||
before, after := g.colors[i-1], g.colors[i]
|
||||
x := (t - g.positions[i-1]) / (g.positions[i] - g.positions[i-1])
|
||||
if before.sameSpace(after) {
|
||||
var values [4]float64
|
||||
for j := range values {
|
||||
values[j] = before.Values[j]*(1-x) + after.Values[j]*x
|
||||
}
|
||||
r, green, b := before.rgb(values)
|
||||
return profileColor([3]float64{r, green, b}, before.Alpha*(1-x)+after.Alpha*x)
|
||||
}
|
||||
r0, g0, b0 := before.rgb(before.Values)
|
||||
r1, g1, b1 := after.rgb(after.Values)
|
||||
return color.RGBA{meshByte(r0*before.Alpha*(1-x) + r1*after.Alpha*x), meshByte(g0*before.Alpha*(1-x) + g1*after.Alpha*x), meshByte(b0*before.Alpha*(1-x) + b1*after.Alpha*x), meshByte(before.Alpha*(1-x) + after.Alpha*x)}
|
||||
}
|
||||
}
|
||||
last := g.colors[len(g.colors)-1]
|
||||
r, green, b := last.rgb(last.Values)
|
||||
return profileColor([3]float64{r, green, b}, last.Alpha)
|
||||
}
|
||||
+20
-4
@@ -37,6 +37,7 @@ var ErrPDFPassword = pdfgo.ErrPassword
|
||||
// ResolveFile读取PDF引用的外部媒体、附件和页面,默认不访问网络或本地路径
|
||||
// ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护
|
||||
// Halftones提供只读设备命名网屏,所属阅读器由调用方维护,连续色调输出不网屏化
|
||||
// Compression配置转换输出,有损模式按图片实际尺寸降采样,默认及无损保持完整采样
|
||||
type PDFImportOptions struct {
|
||||
Password []byte
|
||||
PasswordUTF8 bool
|
||||
@@ -49,6 +50,7 @@ type PDFImportOptions struct {
|
||||
ResolveFile pdfgo.FileResolver
|
||||
ResolveReference pdfgo.ReferenceResolver
|
||||
Halftones map[string]*pdfgo.Halftone
|
||||
Compression CompressionOptions
|
||||
}
|
||||
|
||||
// PDFImportReport 汇总转换页数、对象数、链接数和转换警告
|
||||
@@ -82,11 +84,13 @@ type pdfImporter struct {
|
||||
clipTexts map[*pdfgo.TextClip][]TextObject
|
||||
clipPaths *renderCache[[32]byte, pdfClipPath]
|
||||
cmykSpace string
|
||||
iccSpaces map[pdfICCKey]string
|
||||
objects *pdfObjectBuffer
|
||||
pages map[pdfgo.Reference]*pdfgo.Page
|
||||
pageIDs map[pdfgo.Reference]string
|
||||
pageIndexes map[pdfgo.Reference]int
|
||||
imageIDs map[pdfImageKey][]pdfImageResource
|
||||
imageContents *renderCache[pdfImageContentKey, pdfImageContent]
|
||||
attachmentIDs map[pdfAttachmentKey]string
|
||||
maskClips map[*pdfgo.SoftMask]pdfgo.Path
|
||||
pageBox pdfgo.Rectangle
|
||||
@@ -109,6 +113,7 @@ type pdfImporter struct {
|
||||
halftoneWarnings map[*pdfgo.Halftone]bool
|
||||
halftones map[string]*pdfgo.Halftone
|
||||
transferBackdrop bool
|
||||
compression CompressionOptions
|
||||
}
|
||||
|
||||
// ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果
|
||||
@@ -118,6 +123,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, PDFImportReport{}, err
|
||||
}
|
||||
if err := options.Compression.Validate(); err != nil {
|
||||
return nil, PDFImportReport{}, err
|
||||
}
|
||||
if !finite(options.RasterDPI) || options.RasterDPI < 0 {
|
||||
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
|
||||
}
|
||||
@@ -138,8 +146,10 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
}
|
||||
readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8}
|
||||
var diagnostics []pdfgo.Diagnostic
|
||||
var readWarning func(pdfgo.Diagnostic)
|
||||
if !options.Strict {
|
||||
readOptions.Warning = func(diagnostic pdfgo.Diagnostic) { diagnostics = append(diagnostics, diagnostic) }
|
||||
readWarning = func(diagnostic pdfgo.Diagnostic) { diagnostics = append(diagnostics, diagnostic) }
|
||||
readOptions.Warning = func(diagnostic pdfgo.Diagnostic) { readWarning(diagnostic) }
|
||||
}
|
||||
reader, err := pdfgo.NewReaderWithOptions(source, size, readOptions)
|
||||
if err != nil {
|
||||
@@ -156,6 +166,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
importer.resolveFile = options.ResolveFile
|
||||
importer.resolveReference = options.ResolveReference
|
||||
importer.halftones = options.Halftones
|
||||
importer.compression = options.Compression
|
||||
importer.referenceReaders = make(map[[32]byte]*pdfgo.Reader)
|
||||
importer.referenceStreams = make(map[*pdfgo.Stream]*pdfgo.Reader)
|
||||
importer.referencePages = make(map[pdfReferencePageKey]*pdfgo.Page)
|
||||
@@ -172,6 +183,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
warning.Page = importer.page + 1
|
||||
importer.report.Warnings = append(importer.report.Warnings, warning)
|
||||
}
|
||||
readWarning = importer.warning
|
||||
}
|
||||
if options.OnProgress != nil {
|
||||
if err := options.OnProgress("pages", 0, 0); err != nil {
|
||||
@@ -278,7 +290,7 @@ func ConvertPDF(ctx context.Context, source io.ReaderAt, size int64, output io.W
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err = editor.WriteToWithOptions(ctx, output, WriteOptions{})
|
||||
_, err = editor.WriteToWithOptions(ctx, output, WriteOptions{Compression: options.Compression})
|
||||
return report, err
|
||||
}
|
||||
|
||||
@@ -292,6 +304,10 @@ func (p *pdfImporter) commitObjects() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
objects, err = p.sharedClipObjects(objects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.editor.appendOwnedObjects(p.ctx, p.page, objects); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -693,7 +709,7 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error)
|
||||
}
|
||||
shading := &RadialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), StartRadius: gradient.StartRadius * scale, EndRadius: gradient.EndRadius * scale, Eccentricity: eccentricity, Angle: angle, Extend: strconv.Itoa(extend)}
|
||||
var err error
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space)
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space, gradient.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -727,7 +743,7 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error)
|
||||
}
|
||||
shading := &AxialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), Extend: strconv.Itoa(extend)}
|
||||
var err error
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space)
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space, gradient.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+9
-2
@@ -36,7 +36,11 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page
|
||||
if key == "A" && (annotation.Subtype == "Link" || annotation.Subtype == "Movie") {
|
||||
continue
|
||||
}
|
||||
if annotation.Dictionary[key] != nil {
|
||||
value, err := p.reader.Resolve(annotation.Dictionary[key])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != nil {
|
||||
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +117,10 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page
|
||||
}
|
||||
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Text.Open", Value: strconv.FormatBool(value == pdfgo.Boolean(true))}}}
|
||||
}
|
||||
popupObject := annotation.Dictionary["Popup"]
|
||||
popupObject, err := p.reader.Resolve(annotation.Dictionary["Popup"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if annotation.Subtype == "Popup" {
|
||||
popupObject = annotation.Dictionary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// sharedClipObjects 将连续重复的大裁剪区提取为复合对象,保留页面坐标和绘制顺序
|
||||
// 入参: objects 本库生成且尚未分配标识的对象
|
||||
// 返回: []GraphicObject 提取后的对象, error 资源创建或取消错误
|
||||
func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObject, error) {
|
||||
var output []GraphicObject
|
||||
for start := 0; start < len(objects); {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clips := pdfPageClip(objects[start])
|
||||
end := start + 1
|
||||
if clips != nil {
|
||||
for end < len(objects) && pdfMatchesPageClip(objects[end], clips) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end-start < 2 {
|
||||
if output != nil {
|
||||
output = append(output, objects[start])
|
||||
}
|
||||
start = end
|
||||
continue
|
||||
}
|
||||
if output == nil {
|
||||
output = append(make([]GraphicObject, 0, len(objects)), objects[:start]...)
|
||||
}
|
||||
members := objects[start:end:end]
|
||||
for index := range members {
|
||||
*editorObjectClips(&members[index]) = nil
|
||||
}
|
||||
id, err := p.editor.addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{
|
||||
Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: id, Clips: clips,
|
||||
}})
|
||||
start = end
|
||||
}
|
||||
if output == nil {
|
||||
return objects, nil
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// pdfPageClip 将导入裁剪区还原到页面坐标,仅提取包含大量重复路径数据的裁剪
|
||||
// 入参: object 导入对象
|
||||
// 返回: *Clips 页面坐标裁剪,不满足提取条件时为空
|
||||
func pdfPageClip(object GraphicObject) *Clips {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return nil
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.TransFlag == nil || *clips.TransFlag || len(clips.Clip) == 0 {
|
||||
return nil
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
bytes := 0
|
||||
result := &Clips{TransFlag: clips.TransFlag, Clip: make([]Clip, len(clips.Clip))}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return nil
|
||||
}
|
||||
area := clip.Area[0]
|
||||
area.CTM = ""
|
||||
result.Clip[index].Area = []ClipArea{area}
|
||||
for _, path := range area.Path {
|
||||
bytes += len(path.AbbreviatedData)
|
||||
}
|
||||
}
|
||||
if bytes < 2048 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// pdfMatchesPageClip 比较页面坐标裁剪,不将相同外接矩形视为相同路径
|
||||
// 入参: object 导入对象, pageClip 已还原的裁剪区
|
||||
// 返回: bool 是否可共享裁剪区
|
||||
func pdfMatchesPageClip(object GraphicObject, pageClip *Clips) bool {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return false
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.TransFlag == nil || *clips.TransFlag || len(clips.Clip) != len(pageClip.Clip) {
|
||||
return false
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return false
|
||||
}
|
||||
area, expected := clip.Area[0], pageClip.Clip[index].Area[0]
|
||||
if area.DrawParam != expected.DrawParam || !reflect.DeepEqual(area.Path, expected.Path) || !reflect.DeepEqual(area.Text, expected.Text) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -171,6 +171,10 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
|
||||
return nil, 0, fmt.Errorf("embedded PDF font has no glyphs")
|
||||
}
|
||||
changed := false
|
||||
if head := tables["head"]; len(head) == 56 && binary.BigEndian.Uint32(head[:4]) == 0x00010000 && head[54] == 0 && head[55] == 0 {
|
||||
tables["head"] = head[:54]
|
||||
changed = true
|
||||
}
|
||||
if cff := tables["CFF "]; len(cff) != 0 {
|
||||
sanitized, err := sanitizeCFF(cff)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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"
|
||||
"image"
|
||||
"image/color"
|
||||
"reflect"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfImageContentKey 区分编码内容、渲染意图、模板填充色及有损采样尺寸
|
||||
type pdfImageContentKey struct {
|
||||
data [32]byte
|
||||
intent pdfgo.Name
|
||||
tint color.NRGBA64
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// pdfImageContent 保存不依赖间接资源的图像描述及已注册编号
|
||||
type pdfImageContent struct {
|
||||
source *pdfgo.Image
|
||||
id string
|
||||
}
|
||||
|
||||
// pdfImageValueBudget 限制图像描述的检查深度和保留字节数
|
||||
type pdfImageValueBudget struct {
|
||||
nodes int
|
||||
bytes int
|
||||
}
|
||||
|
||||
// imageContentKey 对无间接依赖的小图像生成内容键,超大图像仍沿用流缓存
|
||||
// 入参: source 图像描述, key 流缓存键
|
||||
// 返回: pdfImageContentKey 内容键, bool 是否可复用, error 取消错误
|
||||
func (p *pdfImporter) imageContentKey(source *pdfgo.Image, key pdfImageKey) (pdfImageContentKey, bool, error) {
|
||||
if source == nil || source.Stream == nil || len(source.Stream.Data) > 1<<20 {
|
||||
return pdfImageContentKey{}, false, nil
|
||||
}
|
||||
budget := pdfImageValueBudget{nodes: 256, bytes: 65536}
|
||||
for _, value := range []pdfgo.Object{source.Stream.Dictionary, source.ColorSpace, source.Decode, source.Mask, source.SoftMask} {
|
||||
if !pdfImageDirectValue(value, &budget) {
|
||||
return pdfImageContentKey{}, false, p.ctx.Err()
|
||||
}
|
||||
}
|
||||
hash := sha256.New()
|
||||
data := source.Stream.Data
|
||||
for len(data) != 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return pdfImageContentKey{}, false, err
|
||||
}
|
||||
n := min(len(data), 65536)
|
||||
hash.Write(data[:n])
|
||||
data = data[n:]
|
||||
}
|
||||
result := pdfImageContentKey{intent: key.intent, tint: key.tint, size: key.size}
|
||||
hash.Sum(result.data[:0])
|
||||
return result, true, p.ctx.Err()
|
||||
}
|
||||
|
||||
// pdfImageDirectValue 检查图像描述是否完全独立,拒绝间接引用和嵌套流
|
||||
// 入参: value 描述值, budget 描述检查预算
|
||||
// 返回: bool 是否不依赖阅读器资源
|
||||
func pdfImageDirectValue(value pdfgo.Object, budget *pdfImageValueBudget) bool {
|
||||
if budget.nodes <= 0 || budget.bytes < 64 {
|
||||
return false
|
||||
}
|
||||
budget.nodes--
|
||||
budget.bytes -= 64
|
||||
switch v := value.(type) {
|
||||
case nil, pdfgo.Boolean, pdfgo.Integer, pdfgo.Real:
|
||||
return true
|
||||
case pdfgo.Name:
|
||||
budget.bytes -= len(v)
|
||||
return budget.bytes >= 0
|
||||
case pdfgo.String:
|
||||
budget.bytes -= len(v)
|
||||
return budget.bytes >= 0
|
||||
case pdfgo.Array:
|
||||
for _, child := range v {
|
||||
if !pdfImageDirectValue(child, budget) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case pdfgo.Dictionary:
|
||||
for key, child := range v {
|
||||
budget.bytes -= len(key)
|
||||
if !pdfImageDirectValue(child, budget) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matches 核对完整编码与有效图像属性,不以摘要相同代替内容相同
|
||||
// 入参: source 待复用图像
|
||||
// 返回: bool 是否可使用同一图像资源
|
||||
func (c pdfImageContent) matches(source *pdfgo.Image) bool {
|
||||
s := c.source
|
||||
return s.Width == source.Width && s.Height == source.Height && s.BitsPerComponent == source.BitsPerComponent &&
|
||||
s.ImageMask == source.ImageMask && s.Interpolate == source.Interpolate &&
|
||||
bytes.Equal(s.Stream.Data, source.Stream.Data) && reflect.DeepEqual(s.Stream.Dictionary, source.Stream.Dictionary) &&
|
||||
reflect.DeepEqual(s.ColorSpace, source.ColorSpace) && reflect.DeepEqual(s.Decode, source.Decode) &&
|
||||
reflect.DeepEqual(s.Mask, source.Mask) && reflect.DeepEqual(s.SoftMask, source.SoftMask)
|
||||
}
|
||||
+61
-11
@@ -54,8 +54,12 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
}
|
||||
if parent, ok := parents[popup.Parent]; ok {
|
||||
dict := maps.Clone(annotations[parent].Dictionary)
|
||||
if previous := dict["Popup"]; previous != nil {
|
||||
if reference, ok := previous.(pdfgo.Reference); !ok || reference != annotation.Reference {
|
||||
previous, err := p.reader.Resolve(dict["Popup"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous != nil {
|
||||
if reference, ok := dict["Popup"].(pdfgo.Reference); !ok || reference != annotation.Reference {
|
||||
return fmt.Errorf("inconsistent PDF popup parent")
|
||||
}
|
||||
}
|
||||
@@ -74,7 +78,11 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
continue
|
||||
}
|
||||
dict := annotation.Dictionary
|
||||
if dict["OC"] != nil {
|
||||
optional, err := p.reader.Resolve(dict["OC"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if optional != nil {
|
||||
if strict {
|
||||
return &pdfgo.UnsupportedError{Feature: "annotation optional content conversion"}
|
||||
}
|
||||
@@ -105,7 +113,10 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF text annotation open state retained as annotation parameter"})
|
||||
}
|
||||
}
|
||||
popupObject := dict["Popup"]
|
||||
popupObject, err := p.reader.Resolve(dict["Popup"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if annotation.Subtype == "Popup" {
|
||||
popupObject = dict
|
||||
}
|
||||
@@ -221,9 +232,17 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
if annotation.Subtype != "Link" {
|
||||
return &pdfgo.UnsupportedError{Feature: "annotation " + string(annotation.Subtype)}
|
||||
}
|
||||
if dict["AA"] != nil {
|
||||
additional, err := p.reader.Resolve(dict["AA"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if additional != nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "link annotation field \"AA\""}
|
||||
}
|
||||
primary, err := p.reader.Resolve(dict["A"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var actions []Action
|
||||
currentPage := p.page
|
||||
appendAction := func(dict pdfgo.Dictionary) error {
|
||||
@@ -238,11 +257,11 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if dict["A"] == nil {
|
||||
if primary == nil {
|
||||
if err := appendAction(dict); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := p.reader.WalkActions(ctx, dict["A"], func(info pdfgo.ActionInfo) error {
|
||||
} else if err := p.reader.WalkActions(ctx, primary, func(info pdfgo.ActionInfo) error {
|
||||
current := maps.Clone(dict)
|
||||
current["A"] = info.Dictionary
|
||||
return appendAction(current)
|
||||
@@ -325,14 +344,14 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil && dict["A"] == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if dict["A"] != nil {
|
||||
value, err := p.reader.Resolve(dict["A"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil && value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if value != nil {
|
||||
a, ok := value.(pdfgo.Dictionary)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid PDF link action")
|
||||
@@ -346,8 +365,12 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre
|
||||
target = a["D"]
|
||||
case pdfgo.Name("GoToR"):
|
||||
return p.remoteLinkAction(a, strict)
|
||||
case pdfgo.Name("Launch"):
|
||||
return p.launchLinkAction(a, strict)
|
||||
case pdfgo.Name("Sound"):
|
||||
return p.soundLinkAction(a, strict)
|
||||
case pdfgo.Name("JavaScript"), pdfgo.Name("ResetForm"), pdfgo.Name("ImportData"), pdfgo.Name("Hide"), pdfgo.Name("Movie"):
|
||||
return p.staticLinkAction(a, kind.(pdfgo.Name), strict)
|
||||
case pdfgo.Name("GoToE"):
|
||||
converted, local, err := p.embeddedLinkAction(a, strict)
|
||||
if err != nil || local == nil {
|
||||
@@ -495,3 +518,30 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre
|
||||
}
|
||||
return &action, nil
|
||||
}
|
||||
|
||||
// staticLinkAction 校验OFD无法等价表达的标准动作,按策略保留静态外观
|
||||
// 入参: object PDF动作, kind 动作类型, strict 是否禁止交互损失
|
||||
// 返回: *Action 空动作, error 无效参数或不可等价转换错误
|
||||
func (p *pdfImporter) staticLinkAction(object pdfgo.Object, kind pdfgo.Name, strict bool) (*Action, error) {
|
||||
var err error
|
||||
switch kind {
|
||||
case "JavaScript":
|
||||
_, err = p.reader.ReadJavaScriptAction(p.ctx, object)
|
||||
case "ResetForm":
|
||||
_, err = p.reader.ReadResetFormAction(p.ctx, object)
|
||||
case "ImportData":
|
||||
_, err = p.reader.ReadImportDataAction(p.ctx, object)
|
||||
case "Hide":
|
||||
_, err = p.reader.ReadHideAction(p.ctx, object)
|
||||
case "Movie":
|
||||
_, err = p.reader.ReadMovieAction(p.ctx, object)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strict {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "interactive " + string(kind) + " action conversion"}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF " + string(kind) + " action not transferred to OFD; static appearance retained"})
|
||||
return nil, nil
|
||||
}
|
||||
@@ -36,6 +36,46 @@ type pdfEmbeddedFile struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// launchLinkAction 保留文件打开目标,不执行应用程序或猜测平台启动参数
|
||||
// 入参: object 启动动作, strict 是否禁止启动语义损失
|
||||
// 返回: *Action 附件动作, error 解析、取消或无法表达的语义
|
||||
func (p *pdfImporter) launchLinkAction(object pdfgo.Object, strict bool) (*Action, error) {
|
||||
action, err := p.reader.ReadLaunchAction(p.ctx, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action.File == nil || action.Windows != nil || action.Mac != nil || action.Unix != nil {
|
||||
if strict {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "platform launch action conversion"}
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF platform launch parameters not transferred to OFD; appearance retained"})
|
||||
return nil, nil
|
||||
}
|
||||
if strict && action.NewWindow == nil {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "launch reader window preference conversion"}
|
||||
}
|
||||
data, available, err := p.mediaData(p.ctx, *action.File)
|
||||
if err != nil || !available {
|
||||
return nil, err
|
||||
}
|
||||
pdfTarget := bytes.HasPrefix(bytes.TrimSpace(data[:min(len(data), 1024)]), []byte("%PDF-"))
|
||||
if !pdfTarget && strict {
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "application or non-PDF launch action conversion"}
|
||||
}
|
||||
id, err := p.attachmentFile(action.File.Name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
window := action.NewWindow
|
||||
if !pdfTarget {
|
||||
window = nil
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; application execution not transferred to OFD"})
|
||||
} else if window == nil {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF launch target retained as attachment; reader window preference not transferred to OFD"})
|
||||
}
|
||||
return &Action{Event: "CLICK", GotoA: &GotoA{AttachID: id, NewWindow: window}}, nil
|
||||
}
|
||||
|
||||
// remoteLinkAction 保留调用方提供的远程文件,不生成OFD无法表达的文件内目标
|
||||
// 入参: object 远程跳转动作, strict 是否禁止目标语义损失
|
||||
// 返回: *Action 附件动作, error 结构、取消或转换错误
|
||||
|
||||
+3
-23
@@ -105,32 +105,12 @@ func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, ann
|
||||
return p.appearanceAnnotation(ctx, page, annotation)
|
||||
}
|
||||
if activation != nil && activation != pdfgo.Boolean(true) {
|
||||
dict, ok := activation.(pdfgo.Dictionary)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid movie activation")
|
||||
}
|
||||
for key, value := range dict {
|
||||
value, err = p.reader.Resolve(value)
|
||||
parameters, err := p.reader.ReadMovieActivation(ctx, activation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
allowed := false
|
||||
switch key {
|
||||
case "Start":
|
||||
allowed = value == pdfgo.Integer(0)
|
||||
case "Rate", "Volume":
|
||||
allowed = value == pdfgo.Integer(1) || value == pdfgo.Real(1)
|
||||
case "ShowControls", "Synchronous":
|
||||
allowed = value == pdfgo.Boolean(false)
|
||||
case "Mode":
|
||||
allowed = value == pdfgo.Name("Once")
|
||||
}
|
||||
if !allowed {
|
||||
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("movie activation field %q", key)}
|
||||
}
|
||||
if parameters.Start != nil && parameters.Start.Value != 0 || parameters.Duration != nil || parameters.Rate != 1 || parameters.Volume != 1 || parameters.ShowControls || parameters.Synchronous || parameters.Mode != "Once" || parameters.FloatingScale != nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "movie activation playback parameters conversion"}
|
||||
}
|
||||
}
|
||||
if movie.Rotate != 0 {
|
||||
|
||||
@@ -56,6 +56,14 @@ func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error)
|
||||
mesh := paint.Mesh
|
||||
matrix := p.matrix.Mul(mesh.Matrix)
|
||||
shading := &GouraudShd{}
|
||||
icc := ""
|
||||
if pdfICCTransfer(mesh.Space) {
|
||||
var err error
|
||||
icc, err = p.iccColorSpace(mesh.Space, mesh.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, triangle := range mesh.Triangles {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -66,6 +74,14 @@ func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error)
|
||||
return nil, fmt.Errorf("nonfinite mesh vertex")
|
||||
}
|
||||
values := triangle.Colors[i]
|
||||
if icc != "" {
|
||||
value, err := pdfICCValues(values, mesh.Space.Components())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, Color: ShdColor{Value: value, ColorSpace: icc}})
|
||||
continue
|
||||
}
|
||||
rgb, err := mesh.Space.RGB(values[:mesh.Space.Components()], mesh.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+42
-4
@@ -33,6 +33,7 @@ type pdfImageKey struct {
|
||||
stream *pdfgo.Stream
|
||||
intent pdfgo.Name
|
||||
tint color.NRGBA64
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// pdfImageResource 保存图像的有效颜色空间及对应资源,避免跨资源重映射复用
|
||||
@@ -115,6 +116,13 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
}
|
||||
source := mark.Image
|
||||
key := pdfImageKey{stream: source.Stream, intent: source.Intent}
|
||||
if !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
m := p.matrix.Mul(mark.Matrix)
|
||||
key.size = compressionImageSize(math.Hypot(m[0], m[1]), math.Hypot(m[2], m[3]), p.compression.ImageDPI())
|
||||
if key.size.X >= source.Width || key.size.Y >= source.Height {
|
||||
key.size = image.Point{}
|
||||
}
|
||||
}
|
||||
if source.ImageMask {
|
||||
fill := mark.Style.Fill.RGB
|
||||
if values := mark.Style.Fill.CMYK; values != nil {
|
||||
@@ -138,6 +146,22 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
var contentKey pdfImageContentKey
|
||||
cacheable := false
|
||||
if id == "" {
|
||||
contentKey, cacheable, err = p.imageContentKey(source, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheable {
|
||||
if p.imageContents == nil {
|
||||
p.imageContents = &renderCache[pdfImageContentKey, pdfImageContent]{limit: 8 << 20}
|
||||
}
|
||||
if cached, found := p.imageContents.get(contentKey); found && cached.matches(source) {
|
||||
id = cached.id
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
jbig2Original, err := source.JBIG2FileContext(p.ctx)
|
||||
if err != nil {
|
||||
@@ -148,8 +172,8 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
return err
|
||||
}
|
||||
var decoded image.Image
|
||||
if len(jbig2Original) == 0 {
|
||||
decoded, err = source.DecodeImageContext(p.ctx)
|
||||
if len(jbig2Original) == 0 && len(jpegOriginal) == 0 && (source.ImageMask || key.size != (image.Point{})) {
|
||||
decoded, err = source.DecodeImageSizeContext(p.ctx, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,11 +187,22 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
}
|
||||
if len(data) == 0 {
|
||||
var encoded bytes.Buffer
|
||||
if err := pdfgo.EncodePNG(p.ctx, &encoded, decoded); err != nil {
|
||||
if decoded != nil {
|
||||
err = pdfgo.EncodePNG(p.ctx, &encoded, decoded)
|
||||
} else {
|
||||
err = source.EncodePNGContext(p.ctx, &encoded)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = encoded.Bytes()
|
||||
}
|
||||
if !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
data, err = pdfgo.OptimizeImageResource(p.ctx, data, p.compression, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
id, err = p.editor.AddImage(data)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -176,6 +211,9 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
p.imageIDs = map[pdfImageKey][]pdfImageResource{}
|
||||
}
|
||||
p.imageIDs[key] = append(p.imageIDs[key], pdfImageResource{space: source.ColorSpace, id: id})
|
||||
if cacheable {
|
||||
p.imageContents.put(contentKey, pdfImageContent{source: source, id: id}, len(source.Stream.Data)+65536)
|
||||
}
|
||||
}
|
||||
return p.appendImage(mark, id)
|
||||
}
|
||||
@@ -385,7 +423,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
|
||||
gid = metrics.GlyphIndex(char)
|
||||
}
|
||||
origin := mark.Positions[n]
|
||||
object.TextCode = append(object.TextCode, TextCode{X: pdfNumbers(origin.X * unit), Y: pdfNumbers(-origin.Y * unit), Value: glyph.Text})
|
||||
object.TextCode = append(object.TextCode, TextCode{X: pdfNumbers(origin.X * unit), Y: pdfNumbers(-origin.Y * unit), Value: escapeOFDText(glyph.Text)})
|
||||
count := utf8.RuneCountInString(glyph.Text)
|
||||
if embedded || undefined {
|
||||
object.CGTransform = append(object.CGTransform, CGTransform{CodePosition: position, CodeCount: count, GlyphCount: 1, Glyphs: strconv.Itoa(int(gid))})
|
||||
|
||||
+109
-6
@@ -17,6 +17,7 @@ package ofdgo
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
@@ -37,16 +38,118 @@ type pdfGradientKey struct {
|
||||
conversion pdfgo.ColorConversion
|
||||
}
|
||||
|
||||
// gradientSegments 保留线性源颜色的设备分量,校准颜色经源空间转换
|
||||
// 入参: stops 源颜色分段, space 源颜色空间
|
||||
// pdfICCKey 区分原始ICC空间及输出渲染意图
|
||||
type pdfICCKey struct {
|
||||
space *pdfgo.ColorSpace
|
||||
intent pdfgo.Name
|
||||
}
|
||||
|
||||
// pdfICCTransfer 检查OFD可原生表达的ICC模型与单位分量范围
|
||||
// 入参: space 源颜色空间
|
||||
// 返回: bool 是否可保留原始配置及颜色分量
|
||||
func pdfICCTransfer(space *pdfgo.ColorSpace) bool {
|
||||
model, ranges := space.ICCSource()
|
||||
if model != "GRAY" && model != "RGB" && model != "CMYK" {
|
||||
return false
|
||||
}
|
||||
for i := range space.Components() {
|
||||
if ranges[2*i] != 0 || ranges[2*i+1] != 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// iccColorSpace 注册16位ICC空间,复用同一源空间与意图的资源
|
||||
// 入参: space 源颜色空间, intent 输出渲染意图
|
||||
// 返回: string OFD资源标识, error 配置或注册错误
|
||||
func (p *pdfImporter) iccColorSpace(space *pdfgo.ColorSpace, intent pdfgo.Name) (string, error) {
|
||||
if p.ctx != nil {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
key := pdfICCKey{space: space, intent: intent}
|
||||
if id := p.iccSpaces[key]; id != "" {
|
||||
return id, nil
|
||||
}
|
||||
if !pdfICCTransfer(space) {
|
||||
return "", &pdfgo.UnsupportedError{Feature: "ICC source range conversion"}
|
||||
}
|
||||
for cached, id := range p.iccSpaces {
|
||||
if cached.intent == intent && cached.space.Equal(space) {
|
||||
p.iccSpaces[key] = id
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
model, _ := space.ICCSource()
|
||||
id, err := p.editor.AddColorSpace(ColorSpace{Type: string(model), BitsPerComponent: 16}, space.ICCProfile(intent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.iccSpaces == nil {
|
||||
p.iccSpaces = make(map[pdfICCKey]string)
|
||||
}
|
||||
p.iccSpaces[key] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// pdfICCValues 将单位源分量量化到OFD16位颜色,不提前转换为显示RGB
|
||||
// 入参: values 单位分量, count 分量数
|
||||
// 返回: string OFD颜色分量, error 非有限或范围错误
|
||||
func pdfICCValues(values []float64, count int) (string, error) {
|
||||
if count < 1 || count > 4 || len(values) < count {
|
||||
return "", fmt.Errorf("invalid ICC shading component count")
|
||||
}
|
||||
var encoded [4]float64
|
||||
for i := range count {
|
||||
if !finite(values[i]) || values[i] < 0 || values[i] > 1 {
|
||||
return "", fmt.Errorf("invalid ICC shading component")
|
||||
}
|
||||
encoded[i] = math.Round(values[i] * 65535)
|
||||
}
|
||||
return pdfNumbers(encoded[:count]...), nil
|
||||
}
|
||||
|
||||
// gradientSegments 保留设备及ICC源分量,其他校准颜色经源空间转换
|
||||
// 入参: stops 源颜色分段, space 源颜色空间, intent 输出渲染意图
|
||||
// 返回: []ShdSegment OFD颜色分段, error 不可线性表达的变换
|
||||
func (p *pdfImporter) gradientSegments(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) ([]ShdSegment, error) {
|
||||
func (p *pdfImporter) gradientSegments(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace, intent pdfgo.Name) ([]ShdSegment, error) {
|
||||
if p.ctx != nil {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := pdfGradientStopsError(stops, space); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmyk := space != nil && space.Model == "DeviceCMYK" && !space.Calibrated()
|
||||
segments := make([]ShdSegment, len(stops))
|
||||
if pdfICCTransfer(space) {
|
||||
id, err := p.iccColorSpace(space, intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, stop := range stops {
|
||||
if p.ctx != nil && i&255 == 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value, err := pdfICCValues(stop.Values[:], space.Components())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
segments[i] = ShdSegment{Position: stop.Position, Color: ShdColor{Value: value, ColorSpace: id}}
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
for i, stop := range stops {
|
||||
if p.ctx != nil && i&255 == 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paint := pdfgo.Paint{RGB: stop.RGB, Alpha: 1}
|
||||
if cmyk {
|
||||
paint.CMYK = &stop.Values
|
||||
@@ -64,7 +167,7 @@ func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace)
|
||||
if len(stops) == 0 {
|
||||
return &pdfgo.UnsupportedError{Feature: "nonlinear gradient conversion"}
|
||||
}
|
||||
calibrated := space != nil && space.Calibrated() && !space.SRGBEquivalent()
|
||||
calibrated := space != nil && space.Calibrated() && !pdfICCTransfer(space) && !space.SRGBEquivalent()
|
||||
if calibrated {
|
||||
for i := 1; i < len(stops); i++ {
|
||||
a, b := stops[i-1], stops[i]
|
||||
@@ -76,7 +179,7 @@ func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pdfGradientError 检查渐变能否精确映射为OFD线性分段
|
||||
// pdfGradientError 检查渐变函数与源空间能否原生映射为OFD分段
|
||||
// 入参: paint 画刷
|
||||
// 返回: error 不可等价表达的渐变
|
||||
func pdfGradientError(paint pdfgo.Paint) error {
|
||||
@@ -91,7 +194,7 @@ func pdfGradientError(paint pdfgo.Paint) error {
|
||||
if len(mesh.Patches) != 0 || len(mesh.Triangles) == 0 || mesh.UsesFunction() || mesh.Background != nil || mesh.Bounds != nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "nonlinear mesh conversion"}
|
||||
}
|
||||
if mesh.Space.Calibrated() && !mesh.Space.SRGBEquivalent() {
|
||||
if mesh.Space.Calibrated() && !pdfICCTransfer(mesh.Space) && !mesh.Space.SRGBEquivalent() {
|
||||
return &pdfgo.UnsupportedError{Feature: "nonlinear calibrated mesh conversion"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ type RasterGradientKind uint8
|
||||
|
||||
// RasterGradient 局部坐标下的轴向、双圆径向或三角网格渐变
|
||||
// 轴向及径向渐变的Spread为空时延续边界颜色,网格渐变使用Mesh
|
||||
// Sample可选,按单位区间采样源颜色变换,优先于Stops且不负责几何延伸
|
||||
type RasterGradient struct {
|
||||
Kind RasterGradientKind
|
||||
Start, End RasterPoint
|
||||
@@ -93,6 +94,7 @@ type RasterGradient struct {
|
||||
Stops []ColorStop
|
||||
Spread *RasterSpread
|
||||
Mesh *MeshShading
|
||||
Sample func(float64) color.RGBA
|
||||
}
|
||||
|
||||
// RasterSpread 保存OFD渐变延伸和周期,nil表示连续延伸
|
||||
@@ -256,6 +258,9 @@ func rasterRadialPosition(a, b, c, radius, delta float64) float64 {
|
||||
// 入参: t 渐变位置
|
||||
// 返回: color.RGBA 预乘颜色
|
||||
func (g *RasterGradient) colorAt(t float64) color.RGBA {
|
||||
if g.Sample != nil {
|
||||
return g.Sample(t)
|
||||
}
|
||||
if len(g.Stops) == 0 {
|
||||
return color.RGBA{}
|
||||
}
|
||||
|
||||
+25
-6
@@ -635,12 +635,15 @@ func (r *Reader) loadRes(resPath string) {
|
||||
baseLoc := res.BaseLoc
|
||||
for i := range res.ColorSpaces.ColorSpace {
|
||||
cs := &res.ColorSpaces.ColorSpace[i]
|
||||
if cs.Profile != "" {
|
||||
cs.Profile = r.resourceLocation(fullPath, baseLoc, cs.Profile)
|
||||
}
|
||||
r.colorSpaceCache[cs.ID] = cs
|
||||
r.resourceFiles[cs.ID] = fullPath
|
||||
}
|
||||
for _, mm := range res.MultiMedias.MultiMedia {
|
||||
if mm.MediaFile != "" {
|
||||
if finalPath := resolveResourcePath(resPath, baseLoc, mm.MediaFile); finalPath != "" {
|
||||
if finalPath := r.resourceLocation(fullPath, baseLoc, mm.MediaFile); finalPath != "" {
|
||||
mm.MediaFile = finalPath
|
||||
if r.mediaCache == nil {
|
||||
r.mediaCache = make(map[string]MultiMedia)
|
||||
@@ -654,7 +657,7 @@ func (r *Reader) loadRes(resPath string) {
|
||||
for i := range res.Fonts.Font {
|
||||
f := &res.Fonts.Font[i]
|
||||
if f.FontFile != "" {
|
||||
f.FontFile = resolveResourcePath(resPath, baseLoc, f.FontFile)
|
||||
f.FontFile = r.resourceLocation(fullPath, baseLoc, f.FontFile)
|
||||
}
|
||||
r.fontCache[f.ID] = f
|
||||
r.resourceFiles[f.ID] = fullPath
|
||||
@@ -672,6 +675,21 @@ func (r *Reader) loadRes(resPath string) {
|
||||
r.resourcesRead[fullPath] = true
|
||||
}
|
||||
|
||||
// resourceLocation 按资源XML所在位置解析文件,文档目录外的共享文件保留绝对路径
|
||||
// 入参: name 资源XML包内路径, base 资源基准目录, file 文件声明
|
||||
// 返回: string 可供ResPath解析的文件位置
|
||||
func (r *Reader) resourceLocation(name, base, file string) string {
|
||||
location := resolveResourcePath(name, base, file)
|
||||
if location == "" {
|
||||
return ""
|
||||
}
|
||||
root := cleanPackagePath(r.RootDir)
|
||||
if root != "." && strings.HasPrefix(location, root+"/") {
|
||||
return strings.TrimPrefix(location, root+"/")
|
||||
}
|
||||
return "/" + location
|
||||
}
|
||||
|
||||
// resolveResourcePath 解析资源文件路径
|
||||
// 入参: resPath 资源文件路径, baseLoc 资源基准路径, filePath 文件路径
|
||||
// 返回: string 资源文件路径
|
||||
@@ -684,12 +702,13 @@ func resolveResourcePath(resPath, baseLoc, filePath string) string {
|
||||
if strings.HasPrefix(p, "/") {
|
||||
return strings.TrimPrefix(path.Clean(p), "/")
|
||||
}
|
||||
dir := path.Dir(resPath)
|
||||
if baseLoc != "" {
|
||||
if dir != baseLoc {
|
||||
dir := path.Dir(strings.ReplaceAll(strings.TrimSpace(resPath), "\\", "/"))
|
||||
baseLoc = strings.ReplaceAll(strings.TrimSpace(baseLoc), "\\", "/")
|
||||
if strings.HasPrefix(baseLoc, "/") {
|
||||
dir = cleanPackagePath(baseLoc)
|
||||
} else if baseLoc != "" {
|
||||
dir = path.Join(dir, baseLoc)
|
||||
}
|
||||
}
|
||||
return path.Join(dir, p)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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 (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// documentFiles 按标准路径遍历独立文档依赖,签名摘要引用不作为内容依赖
|
||||
// 入参: ctx 取消上下文, index 文档索引
|
||||
// 返回: map[string]bool 大小写折叠的文件路径, error 读取或取消错误
|
||||
func (r *Reader) documentFiles(ctx context.Context, index int) (map[string]bool, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index < 0 || index >= r.DocumentCount() {
|
||||
return nil, fmt.Errorf("document index out of range: %d", index)
|
||||
}
|
||||
queue := []string{cleanPackagePath(r.OFD.DocBody[index].DocRoot)}
|
||||
data, err := r.readFile("OFD.xml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := root.childAt("DocBody", index)
|
||||
if body == nil {
|
||||
return nil, fmt.Errorf("document body not found: %d", index)
|
||||
}
|
||||
var collect func(*editorXML)
|
||||
collect = func(node *editorXML) {
|
||||
if node.name.Local == "Cover" || node.name.Local == "Signatures" || node.name.Local == "DocRoot" {
|
||||
if value := strings.TrimSpace(editorImportText(data, node)); value != "" {
|
||||
queue = append(queue, resolveResourcePath("OFD.xml", "", value))
|
||||
}
|
||||
}
|
||||
if node.name.Local == "Version" && node.attr("BaseLoc") != "" {
|
||||
queue = append(queue, resolveResourcePath("OFD.xml", "", node.attr("BaseLoc")))
|
||||
}
|
||||
for _, child := range node.children {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
collect(body)
|
||||
seen := make(map[string]bool)
|
||||
for len(queue) > 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := queue[0]
|
||||
queue = queue[1:]
|
||||
key := strings.ToLower(name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
actual, exists := r.fileNamesFold[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
input, err := r.openFile(actual)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffer := bufio.NewReader(imageInput{ReadCloser: input, context: ctx})
|
||||
if prefix, _ := buffer.Peek(3); bytes.Equal(prefix, []byte{0xef, 0xbb, 0xbf}) {
|
||||
buffer.Discard(3)
|
||||
}
|
||||
header, _ := buffer.Peek(128)
|
||||
for len(header) > 0 && len(bytes.TrimSpace(header)) == 0 {
|
||||
buffer.Discard(len(header))
|
||||
header, _ = buffer.Peek(128)
|
||||
}
|
||||
if !bytes.HasPrefix(bytes.TrimSpace(header), []byte("<")) {
|
||||
input.Close()
|
||||
continue
|
||||
}
|
||||
decoder := xml.NewDecoder(buffer)
|
||||
base, resource, depth := "", false, 0
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return nil, err
|
||||
}
|
||||
switch node := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 && node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" {
|
||||
input.Close()
|
||||
goto nextFile
|
||||
}
|
||||
if node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" {
|
||||
input.Close()
|
||||
return nil, fmt.Errorf("cannot determine document dependencies in %s", actual)
|
||||
}
|
||||
if depth == 0 {
|
||||
resource = node.Name.Local == "Res"
|
||||
}
|
||||
for _, attr := range node.Attr {
|
||||
if resource && depth == 0 && attr.Name.Local == "BaseLoc" {
|
||||
base = attr.Value
|
||||
} else if attr.Name.Local == "BaseLoc" || attr.Name.Local == "FileLoc" || node.Name.Local == "DrawParam" && attr.Name.Local == "Link" || node.Name.Local == "ColorSpace" && attr.Name.Local == "Profile" {
|
||||
queue = append(queue, resolveResourcePath(actual, base, attr.Value))
|
||||
}
|
||||
}
|
||||
depth++
|
||||
text.Reset()
|
||||
case xml.CharData:
|
||||
text.Write(node)
|
||||
case xml.EndElement:
|
||||
switch node.Name.Local {
|
||||
case "DocRoot", "Signatures", "Cover", "PublicRes", "DocumentRes", "PageRes", "Annotations", "Attachments", "FileLoc", "Profile", "SignedValue", "SchemaLoc", "ExtendData", "File", "FontFile", "MediaFile", "CustomTags", "Extensions":
|
||||
if value := strings.TrimSpace(text.String()); value != "" {
|
||||
queue = append(queue, resolveResourcePath(actual, base, value))
|
||||
}
|
||||
}
|
||||
depth--
|
||||
text.Reset()
|
||||
}
|
||||
}
|
||||
input.Close()
|
||||
nextFile:
|
||||
}
|
||||
return seen, ctx.Err()
|
||||
}
|
||||
@@ -261,6 +261,7 @@ func semanticGradient(paint Paint, matrix Matrix) (*RasterGradient, Matrix) {
|
||||
period = s.MapUnit / length
|
||||
}
|
||||
g := &RasterGradient{Start: RasterPoint{s.Start.X, s.Start.Y}, End: RasterPoint{s.End.X, s.End.Y}, R0: s.StartRadius, R1: s.EndRadius, Stops: s.Stops, Spread: &RasterSpread{Extend: extend, MapType: s.MapType, Period: period}}
|
||||
g.Sample = s.Sample
|
||||
if paint.Kind == PaintRadial {
|
||||
g.Kind = RasterRadial
|
||||
if s.Eccentricity > 0 && s.Eccentricity < 1 {
|
||||
|
||||
@@ -115,7 +115,7 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) {
|
||||
default:
|
||||
return nil, fmt.Errorf("canvas command %d: unsupported gradient %d", i, g.Kind)
|
||||
}
|
||||
if g.Spread != nil {
|
||||
if g.Spread != nil || g.Sample != nil {
|
||||
paint = canvas.Paint{Gradient: rasterGradientCanvas{gradient: g}}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func (g rasterGradientCanvas) At(x, y float64) color.RGBA {
|
||||
// 入参: space 目标颜色空间
|
||||
// 返回: canvas.Gradient 采样器
|
||||
func (g rasterGradientCanvas) SetColorSpace(space canvas.ColorSpace) canvas.Gradient {
|
||||
if g.gradient.Mesh != nil {
|
||||
if g.gradient.Mesh != nil || g.gradient.Sample != nil {
|
||||
g.space = space
|
||||
return g
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"image"
|
||||
"slices"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
)
|
||||
@@ -152,6 +153,9 @@ func intersectClipPath(parent, current *canvas.Path) *canvas.Path {
|
||||
if current == nil {
|
||||
return parent
|
||||
}
|
||||
if slices.Equal(parent.Data(), current.Data()) {
|
||||
return parent
|
||||
}
|
||||
parentRect, parentOK := rectangularPath(parent)
|
||||
currentRect, currentOK := rectangularPath(current)
|
||||
if parentOK && currentOK {
|
||||
@@ -188,6 +192,15 @@ func intersectClipPath(parent, current *canvas.Path) *canvas.Path {
|
||||
// 入参: left 左侧裁剪路径, right 右侧裁剪路径
|
||||
// 返回: *canvas.Path 合并后的裁剪路径
|
||||
func unionClipPath(left, right *canvas.Path) *canvas.Path {
|
||||
if left == nil {
|
||||
return right
|
||||
}
|
||||
if right == nil {
|
||||
return left
|
||||
}
|
||||
if slices.Equal(left.Data(), right.Data()) {
|
||||
return left
|
||||
}
|
||||
leftRect, leftOK := rectangularPath(left)
|
||||
rightRect, rightOK := rectangularPath(right)
|
||||
if leftOK && rightOK {
|
||||
@@ -207,6 +220,9 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path {
|
||||
if path == nil || clip == nil {
|
||||
return path
|
||||
}
|
||||
if slices.Equal(path.Data(), clip.Data()) {
|
||||
return path
|
||||
}
|
||||
if path.Empty() || clip.Empty() {
|
||||
return &canvas.Path{}
|
||||
}
|
||||
@@ -228,6 +244,18 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path {
|
||||
return path.And(clip)
|
||||
}
|
||||
|
||||
// applyFillClipPath 为多段描边直接填充裁剪,不将相消轮廓用于几何测量
|
||||
// 入参: path 非零填充描边, clip 裁剪路径
|
||||
// 返回: *canvas.Path 绘制轮廓
|
||||
func applyFillClipPath(path, clip *canvas.Path) *canvas.Path {
|
||||
if path != nil && clip != nil && path.HasSubpaths() && len(path.Split()) >= 8 {
|
||||
if result, ok := intersectCanvasContours(path, clip); ok {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return applyClipPath(path, clip)
|
||||
}
|
||||
|
||||
// rectangularPath 获取矩形路径区域
|
||||
// 入参: path 路径对象
|
||||
// 返回: canvas.Rect 矩形区域, bool 是否为矩形
|
||||
|
||||
@@ -32,6 +32,53 @@ func intersectConvexCanvasPaths(left, right *canvas.Path) (*canvas.Path, bool) {
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return intersectCanvasPolygons(a, b)
|
||||
}
|
||||
|
||||
// intersectCanvasContours 按凸窗口裁剪闭合轮廓,保留绕向、孔洞和重叠透明度
|
||||
// 入参: path 绘制路径, clip 单个凸裁剪路径
|
||||
// 返回: *canvas.Path 交集路径, bool 是否适用分段裁剪
|
||||
func intersectCanvasContours(path, clip *canvas.Path) (*canvas.Path, bool) {
|
||||
if path == nil {
|
||||
return nil, false
|
||||
}
|
||||
b, ok := convexCanvasPolygon(clip)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
parts := path.Split()
|
||||
polygons := make([][]canvas.Point, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = part.Flatten(canvas.Tolerance)
|
||||
if !part.Closed() {
|
||||
return nil, false
|
||||
}
|
||||
a := part.Coords()
|
||||
if len(a) < 4 || a[0] != a[len(a)-1] {
|
||||
continue
|
||||
}
|
||||
for _, point := range a {
|
||||
if !finite(point.X) || !finite(point.Y) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
polygons = append(polygons, a[:len(a)-1])
|
||||
}
|
||||
result := &canvas.Path{}
|
||||
for _, polygon := range polygons {
|
||||
part, ok := intersectCanvasPolygons(polygon, b)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
result = result.Append(part)
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
// intersectCanvasPolygons 按凸窗口逐边裁剪多边形,保留原顶点绕向
|
||||
// 入参: a 绘制顶点, b 裁剪顶点
|
||||
// 返回: *canvas.Path 交集路径, bool 是否满足数值精度
|
||||
func intersectCanvasPolygons(a, b []canvas.Point) (*canvas.Path, bool) {
|
||||
for i, start := range b {
|
||||
end := b[(i+1)%len(b)]
|
||||
dx, dy := end.X-start.X, end.Y-start.Y
|
||||
@@ -71,6 +118,12 @@ func intersectConvexCanvasPaths(left, right *canvas.Path) (*canvas.Path, bool) {
|
||||
scale := math.Max(math.Abs(d), math.Abs(previousDistance))
|
||||
fraction := (previousDistance / scale) / (previousDistance/scale - d/scale)
|
||||
intersection := geometryLerp(Point{previous.X, previous.Y}, Point{p.X, p.Y}, fraction)
|
||||
if dx == 0 {
|
||||
intersection.X = start.X
|
||||
}
|
||||
if dy == 0 {
|
||||
intersection.Y = start.Y
|
||||
}
|
||||
appendPoint(canvas.Point{X: intersection.X, Y: intersection.Y})
|
||||
}
|
||||
if d >= 0 {
|
||||
|
||||
@@ -81,12 +81,12 @@ func (c *canvasPageCompiler) RenderPath(path *canvas.Path, style canvas.Style, m
|
||||
options.DashOffset, options.Dashes = canvas.ScaleDash(style.StrokeWidth, style.DashOffset, style.Dashes)
|
||||
}
|
||||
var err error
|
||||
stroke, err = canvasStrokePath(c.geometry, stroke, options)
|
||||
stroke, err = canvasStrokeFillPath(c.geometry, stroke, options)
|
||||
if err != nil {
|
||||
c.err = err
|
||||
return
|
||||
}
|
||||
c.fill(stroke, style.Stroke, style.FillRule, m)
|
||||
c.fill(stroke, style.Stroke, canvas.NonZero, m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,9 +191,13 @@ func (c *canvasPageCompiler) fill(path *canvas.Path, paint canvas.Paint, rule ca
|
||||
// 入参: path Canvas路径
|
||||
// 返回: []RasterSegment 独立路径, error 未知指令错误
|
||||
func canvasRasterPath(path *canvas.Path) ([]RasterSegment, error) {
|
||||
path = path.ReplaceArcs()
|
||||
segments := make([]RasterSegment, 0, path.Len())
|
||||
scanner := path.Scanner()
|
||||
parts := path.Split()
|
||||
if len(parts) == 0 {
|
||||
parts = []*canvas.Path{path}
|
||||
}
|
||||
for _, part := range parts {
|
||||
scanner := part.ReplaceArcs().Scanner()
|
||||
for scanner.Scan() {
|
||||
s := RasterSegment{End: rasterPoint(scanner.End())}
|
||||
switch scanner.Cmd() {
|
||||
@@ -212,6 +216,12 @@ func canvasRasterPath(path *canvas.Path) ([]RasterSegment, error) {
|
||||
}
|
||||
segments = append(segments, s)
|
||||
}
|
||||
}
|
||||
data := path.Data()
|
||||
last := parts[len(parts)-1].Data()
|
||||
if len(data) >= 4 && data[len(data)-4] == canvas.MoveToCmd && len(last) != 0 && last[len(last)-1] != canvas.MoveToCmd {
|
||||
segments = append(segments, RasterSegment{Verb: RasterMove, End: RasterPoint{X: data[len(data)-3], Y: data[len(data)-2]}})
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -144,10 +144,10 @@ func (CanvasBackend) Combine(left, right GeometryPath, operation GeometryOperati
|
||||
return *geometryFromCanvasPath(result), nil
|
||||
}
|
||||
|
||||
// Stroke 将绝对长度虚线与描边转换为填充区域,曲线按指定精度展开
|
||||
// Stroke 将绝对长度虚线与描边转换为填充区域,几何后端异常时使用本库非零描边
|
||||
// 入参: path 页面路径, options 描边样式
|
||||
// 返回: GeometryPath 描边区域, error 样式或路径错误
|
||||
func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryPath, error) {
|
||||
func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (result GeometryPath, err error) {
|
||||
if err := validateStroke(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,6 +162,15 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (GeometryP
|
||||
if tolerance == 0 {
|
||||
tolerance = canvas.Tolerance
|
||||
}
|
||||
options.Tolerance = tolerance
|
||||
defer func() {
|
||||
if failure := recover(); failure != nil {
|
||||
result, err = path.Stroke(options)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("stroke fallback after %v: %w", failure, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
style := pathStyle{lineJoin: canvas.MiterJoin, lineCap: canvas.ButtCap, miterLimit: defaultMiterLimit}
|
||||
style.applyLineJoin(options.Join, options.MiterLimit)
|
||||
style.lineCap = pathLineCap(options.Cap, style.lineCap)
|
||||
@@ -229,17 +238,50 @@ func canvasStrokePath(geometry GeometryBackend, path *canvas.Path, options Strok
|
||||
return geometryToCanvasPath(&outline)
|
||||
}
|
||||
|
||||
// canvasStrokeFillPath 为直接非零填充展开复杂描边,不将重叠轮廓交给区域运算
|
||||
// 入参: geometry 几何后端, path Canvas路径, options 描边样式
|
||||
// 返回: *canvas.Path 描边轮廓, error 几何错误
|
||||
func canvasStrokeFillPath(geometry GeometryBackend, path *canvas.Path, options StrokeOptions) (*canvas.Path, error) {
|
||||
if canvasNativeStroke(geometry) && !slices.Contains(options.Dashes, 0) {
|
||||
if options.Tolerance == 0 {
|
||||
options.Tolerance = canvas.Tolerance
|
||||
}
|
||||
if err := validateStroke(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(path.Dash(options.DashOffset, options.Dashes...).Data()) > 256 {
|
||||
outline, err := geometryFromCanvasPath(path).Stroke(options)
|
||||
if err != nil {
|
||||
return canvasStrokePath(geometry, path, options)
|
||||
}
|
||||
return geometryToCanvasPath(&outline)
|
||||
}
|
||||
}
|
||||
return canvasStrokePath(geometry, path, options)
|
||||
}
|
||||
|
||||
// canvasFillClip 检查描边轮廓能否直接填充或按凸窗口独立裁剪
|
||||
// 入参: clip 裁剪路径
|
||||
// 返回: bool 是否不要求整理重叠区域
|
||||
func canvasFillClip(clip *canvas.Path) bool {
|
||||
if clip == nil {
|
||||
return true
|
||||
}
|
||||
_, ok := convexCanvasPolygon(clip)
|
||||
return ok
|
||||
}
|
||||
|
||||
// strokeCanvasPath 将文字和复杂画刷的描边交给配置的几何后端
|
||||
// 入参: path Canvas路径, width 描边宽度, cap 线帽, join 连接
|
||||
// 返回: *canvas.Path 描边轮廓
|
||||
func (r *Renderer) strokeCanvasPath(path *canvas.Path, width float64, cap canvas.Capper, join canvas.Joiner) *canvas.Path {
|
||||
return r.strokeDashedCanvasPath(path, width, cap, join, 0, nil)
|
||||
return r.strokeDashedCanvasPath(path, width, cap, join, 0, nil, false)
|
||||
}
|
||||
|
||||
// strokeDashedCanvasPath 通过公共几何后端展开虚线和线帽,保留零长度绘制段
|
||||
// 入参: path 路径, width 线宽, cap 线帽, join 连接, offset 虚线偏移, dashes 虚线数组
|
||||
// 入参: path 路径, width 线宽, cap 线帽, join 连接, offset 虚线偏移, dashes 虚线数组, fill 是否直接填充或凸窗口裁剪
|
||||
// 返回: *canvas.Path 描边填充轮廓
|
||||
func (r *Renderer) strokeDashedCanvasPath(path *canvas.Path, width float64, cap canvas.Capper, join canvas.Joiner, offset float64, dashes []float64) *canvas.Path {
|
||||
func (r *Renderer) strokeDashedCanvasPath(path *canvas.Path, width float64, cap canvas.Capper, join canvas.Joiner, offset float64, dashes []float64, fill bool) *canvas.Path {
|
||||
geometry, err := r.Geometry()
|
||||
if err != nil {
|
||||
r.renderError = err
|
||||
@@ -247,7 +289,11 @@ func (r *Renderer) strokeDashedCanvasPath(path *canvas.Path, width float64, cap
|
||||
}
|
||||
options := canvasStrokeOptions(width, cap, join, canvas.Tolerance)
|
||||
options.DashOffset, options.Dashes = offset, dashes
|
||||
result, err := canvasStrokePath(geometry, path, options)
|
||||
stroke := canvasStrokePath
|
||||
if fill {
|
||||
stroke = canvasStrokeFillPath
|
||||
}
|
||||
result, err := stroke(geometry, path, options)
|
||||
if err != nil {
|
||||
r.renderError = err
|
||||
return &canvas.Path{}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float
|
||||
if obj.Visible != nil && !*obj.Visible || obj.Alpha != nil && *obj.Alpha == 0 {
|
||||
return
|
||||
}
|
||||
resPath, ok := r.Reader.ResMap[obj.ResourceID]
|
||||
resPath, ok := resourceValue(r.Reader.ResMap, obj.ResourceID)
|
||||
if !ok {
|
||||
r.renderError = fmt.Errorf("image resource %q not found", obj.ResourceID)
|
||||
return
|
||||
|
||||
@@ -68,9 +68,12 @@ func canvasShading(paint Paint, x, y, pageH float64) *shdPaint {
|
||||
start := canvas.Point{X: x + source.Start.X, Y: pageH - y - source.Start.Y}
|
||||
end := canvas.Point{X: x + source.End.X, Y: pageH - y - source.End.Y}
|
||||
if paint.Kind == PaintLinear {
|
||||
return newShdPaint(gradient.ToLinear(start, end), source.Extend, source.MapType, source.MapUnit)
|
||||
result := newShdPaint(gradient.ToLinear(start, end), source.Extend, source.MapType, source.MapUnit)
|
||||
result.sample = source.Sample
|
||||
return result
|
||||
}
|
||||
result := newShdPaint(gradient.ToRadial(start, source.StartRadius, end, source.EndRadius), source.Extend, source.MapType, source.MapUnit)
|
||||
result.sample = source.Sample
|
||||
if e := source.Eccentricity; 0 < e && e < 1 {
|
||||
result.view = canvas.Identity.Translate(start.X, start.Y).Rotate(-source.Angle).Scale(1, math.Sqrt(1-e*e)).Translate(-start.X, -start.Y)
|
||||
result.gradient = gradient.ToRadial(start, source.StartRadius, result.view.Inv().Dot(end), source.EndRadius)
|
||||
|
||||
@@ -298,12 +298,17 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64
|
||||
_, repeat := strokePaint.(*repeatAxialGradient)
|
||||
if strokeClip != nil || strokeView != canvas.Identity || repeat || style.strokePattern != nil || !canvasNativeStroke(r.backends.Geometry) || zeroDash {
|
||||
sp = sp.Copy()
|
||||
fill := style.strokePattern == nil && canvasFillClip(strokeClip)
|
||||
if zeroDash {
|
||||
sp = r.strokeDashedCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin, style.dashOffset, style.dashPattern)
|
||||
sp = r.strokeDashedCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin, style.dashOffset, style.dashPattern, fill)
|
||||
} else {
|
||||
sp = r.strokeCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin)
|
||||
sp = r.strokeDashedCanvasPath(sp, style.lineWidth, style.lineCap, style.lineJoin, 0, nil, fill)
|
||||
}
|
||||
if fill {
|
||||
sp = applyFillClipPath(sp, strokeClip)
|
||||
} else {
|
||||
sp = applyClipPath(sp, strokeClip)
|
||||
}
|
||||
if style.strokePattern != nil {
|
||||
r.renderPattern(ctx, style.strokePattern, pageH, sp, objectCTM)
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,7 @@ type shdPaint struct {
|
||||
extend int
|
||||
mapType string
|
||||
period float64
|
||||
sample func(float64) color.RGBA
|
||||
}
|
||||
|
||||
// repeatAxialGradient 轴向重复渐变
|
||||
@@ -135,6 +136,9 @@ func resolveShdPaint(ctx *canvas.Context, paint any) (any, *canvas.Path, canvas.
|
||||
if clip != nil && s.view != canvas.Identity {
|
||||
clip = clip.Transform(s.view)
|
||||
}
|
||||
if s.sample != nil {
|
||||
return s.sampledRaster(), clip, s.view
|
||||
}
|
||||
if s.mapType == "Repeat" {
|
||||
return &repeatAxialGradient{LinearGradient: g, period: s.period}, clip, s.view
|
||||
}
|
||||
@@ -154,18 +158,16 @@ func resolveShdPaint(ctx *canvas.Context, paint any) (any, *canvas.Path, canvas.
|
||||
d, dr := g.C1.Sub(g.C0), g.R1-g.R0
|
||||
length := d.Length()
|
||||
if length >= math.Abs(dr) || s.mapType == "Repeat" {
|
||||
stops := make([]ColorStop, len(g.Grad))
|
||||
for i, stop := range g.Grad {
|
||||
stops[i] = ColorStop{Offset: stop.Offset, Color: stop.Color}
|
||||
}
|
||||
gradient := &RasterGradient{Kind: RasterRadial, Start: rasterPoint(g.C0), End: rasterPoint(g.C1), R0: g.R0, R1: g.R1, Stops: stops, Spread: &RasterSpread{Extend: s.extend, MapType: s.mapType, Period: s.period}}
|
||||
return rasterGradientCanvas{gradient: gradient}, nil, s.view
|
||||
return s.sampledRaster(), nil, s.view
|
||||
}
|
||||
bounds := shdCanvasBounds(ctx).Transform(s.view.Inv())
|
||||
clip := radialShdClip(g, s.extend, bounds)
|
||||
if clip != nil && s.view != canvas.Identity {
|
||||
clip = clip.Transform(s.view)
|
||||
}
|
||||
if s.sample != nil {
|
||||
return s.sampledRaster(), clip, s.view
|
||||
}
|
||||
if s.mapType != "Reflect" {
|
||||
return g, clip, s.view
|
||||
}
|
||||
@@ -192,6 +194,25 @@ func resolveShdPaint(ctx *canvas.Context, paint any) (any, *canvas.Path, canvas.
|
||||
return s.gradient, nil, s.view
|
||||
}
|
||||
|
||||
// sampledRaster 保留原双圆或轴向几何与周期,不把ICC采样器降为端点渐变
|
||||
// 返回: rasterGradientCanvas 局部坐标采样器
|
||||
func (s *shdPaint) sampledRaster() rasterGradientCanvas {
|
||||
gradient := &RasterGradient{Sample: s.sample, Spread: &RasterSpread{Extend: s.extend, MapType: s.mapType, Period: s.period}}
|
||||
var stops canvas.Grad
|
||||
switch g := s.gradient.(type) {
|
||||
case *canvas.LinearGradient:
|
||||
gradient.Start, gradient.End, stops = rasterPoint(g.Start), rasterPoint(g.End), g.Grad
|
||||
case *canvas.RadialGradient:
|
||||
gradient.Kind = RasterRadial
|
||||
gradient.Start, gradient.End, gradient.R0, gradient.R1, stops = rasterPoint(g.C0), rasterPoint(g.C1), g.R0, g.R1, g.Grad
|
||||
}
|
||||
gradient.Stops = make([]ColorStop, len(stops))
|
||||
for i, stop := range stops {
|
||||
gradient.Stops[i] = ColorStop{Offset: stop.Offset, Color: stop.Color}
|
||||
}
|
||||
return rasterGradientCanvas{gradient: gradient}
|
||||
}
|
||||
|
||||
// transformShdPaint 应用渐变的父级坐标变换
|
||||
// 入参: paint 画刷, parentCTM 父级变换矩阵, bx 边界X坐标, by 边界Y坐标, pageH 页面高度, boundaryInCTM 边界是否参与父级变换
|
||||
func transformShdPaint(paint any, parentCTM *Matrix, bx, by, pageH float64, boundaryInCTM bool) {
|
||||
|
||||
@@ -308,7 +308,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
||||
ctx.SetStrokeColor(canvas.Transparent)
|
||||
r.drawShdPath(ctx, applyClipPath(path.Copy(), fillClip), fillView)
|
||||
}
|
||||
path = r.strokeDashedCanvasPath(path, strokeStyle.lineWidth, strokeStyle.lineCap, strokeStyle.lineJoin, strokeStyle.dashOffset, strokeStyle.dashPattern)
|
||||
path = r.strokeDashedCanvasPath(path, strokeStyle.lineWidth, strokeStyle.lineCap, strokeStyle.lineJoin, strokeStyle.dashOffset, strokeStyle.dashPattern, false)
|
||||
path = applyClipPath(path, strokeClip)
|
||||
if strokeStyle.strokePattern != nil {
|
||||
r.renderPattern(ctx, strokeStyle.strokePattern, pageH, path, objectCTM)
|
||||
|
||||
@@ -37,7 +37,9 @@ type ColorSpace struct {
|
||||
ID string `xml:"ID,attr"`
|
||||
Type string `xml:"Type,attr"`
|
||||
BitsPerComponent int `xml:"BitsPerComponent,attr"`
|
||||
Profile string `xml:"Profile,attr,omitempty"`
|
||||
Palette []string `xml:"Palette>CV"`
|
||||
profile *colorProfile
|
||||
}
|
||||
|
||||
// Fonts 字体集合
|
||||
@@ -129,3 +131,32 @@ type CompositeGraphicUnit struct {
|
||||
states map[string]editorCompositeState
|
||||
extentSet bool
|
||||
}
|
||||
|
||||
// resourceValue 优先查找原标识与标准十进制标识,回退别名有冲突时不猜测
|
||||
// 入参: values 已加载资源, id 标识文本
|
||||
// 返回: T 资源值, bool 是否存在唯一结果
|
||||
func resourceValue[T comparable](values map[string]T, id string) (T, bool) {
|
||||
if value, ok := values[id]; ok {
|
||||
return value, true
|
||||
}
|
||||
var value T
|
||||
key := editorResourceID(id)
|
||||
if key == "" {
|
||||
return value, false
|
||||
}
|
||||
if value, ok := values[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
found := false
|
||||
for alias, candidate := range values {
|
||||
if editorResourceID(alias) != key {
|
||||
continue
|
||||
}
|
||||
if found && value != candidate {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
value, found = candidate, true
|
||||
}
|
||||
return value, found
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func (r *Reader) signatureCoverageReferences(root string, required map[string]bo
|
||||
return
|
||||
}
|
||||
for _, attr := range current.Attrs {
|
||||
if attr.Name.Local == "BaseLoc" && kind != "Res" || attr.Name.Local == "Link" && kind == "DrawParam" {
|
||||
if attr.Name.Local == "BaseLoc" && kind != "Res" || attr.Name.Local == "Link" && kind == "DrawParam" || attr.Name.Local == "Profile" && kind == "ColorSpace" {
|
||||
add(attr.Value, node.XMLName.Local == "Res")
|
||||
}
|
||||
}
|
||||
|
||||
+2
-22
@@ -262,34 +262,14 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption
|
||||
}
|
||||
}
|
||||
if options.Compression.Mode != CompressionUnchanged && doc.Signatures == "" && len(protected) == 0 {
|
||||
images, err := r.Images(context.Background())
|
||||
images, sizes, err := r.compressionImagePlan(context.Background(), options.Compression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var masks map[string]bool
|
||||
geometry := false
|
||||
if options.Compression.Mode == CompressionLossy {
|
||||
masks, geometry, err = r.compressionImageSafety(context.Background(), images)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
dpi := 0
|
||||
if geometry {
|
||||
dpi = options.Compression.ImageDPI()
|
||||
}
|
||||
sizes, err := r.compressionImageSizes(context.Background(), images, dpi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
optimizer := &Editor{output: &outputOptimization{ctx: context.Background(), options: options.Compression, sizes: sizes, images: make(map[string]bool)}}
|
||||
optimizer := &Editor{output: &outputOptimization{ctx: context.Background(), options: options.Compression, sizes: sizes, images: images}}
|
||||
optimizer.OnWriteProgress = func(stage string, completed, total int) error {
|
||||
return options.progress.report(stage, completed, total)
|
||||
}
|
||||
for _, img := range images {
|
||||
key := strings.ToLower(cleanPackagePath(img.Location))
|
||||
optimizer.output.images[key] = !masks[key]
|
||||
}
|
||||
removed := make(map[string]bool)
|
||||
if err := optimizer.compressResourceParts(parts, nil, removed); err != nil {
|
||||
return nil, err
|
||||
|
||||
+11
-2
@@ -432,7 +432,7 @@ func (e *Editor) writePartsWithPages(write func(string, []byte, bool) error, pro
|
||||
}
|
||||
}
|
||||
for _, resource := range e.resources {
|
||||
if resource.definition() != "" && slices.Contains(definitions, resource.name) {
|
||||
if resource.definition() != "" && slices.Contains(definitions, resource.name) || resource.colorProfileUsed(spaces) {
|
||||
if err := write(resource.name, resource.data, false); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -581,7 +581,16 @@ func (x *ofdXML) resources(fonts, images []editorResource, spaces []ColorSpace)
|
||||
if x.err != nil {
|
||||
return
|
||||
}
|
||||
x.start("ColorSpace", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: space.ID}, {Name: xml.Name{Local: "Type"}, Value: space.Type}, {Name: xml.Name{Local: "BitsPerComponent"}, Value: strconv.Itoa(space.BitsPerComponent)}})
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: space.ID}, {Name: xml.Name{Local: "Type"}, Value: space.Type}, {Name: xml.Name{Local: "BitsPerComponent"}, Value: strconv.Itoa(space.BitsPerComponent)}}
|
||||
attrs.add("Profile", space.Profile)
|
||||
x.start("ColorSpace", attrs)
|
||||
if len(space.Palette) != 0 {
|
||||
x.start("Palette", nil)
|
||||
for _, entry := range space.Palette {
|
||||
x.text("CV", entry)
|
||||
}
|
||||
x.end("Palette")
|
||||
}
|
||||
x.end("ColorSpace")
|
||||
}
|
||||
x.end("ColorSpaces")
|
||||
|
||||
Reference in new issue
Block a user