refactor(重构提升): 优化转换渲染与资源压缩
build.yaml / build (push) Successful in 2m49s

This commit is contained in:
xiaoqidun committed 2026-10-06 17:53:24 +08:00
1 parent bcb9f98f8b
commit 3959664448
52 files changed
+1829 -372

No files matched your search

+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+113
View File
@@ -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
View File
@@ -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++
+85 -4
View File
@@ -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
}
+38 -5
View File
@@ -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)
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+9 -4
View File
@@ -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
View File
@@ -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
}
+8
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+65
View File
@@ -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
View File
@@ -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})
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 非法拓扑
+21
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+138
View File
@@ -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
}
+4
View File
@@ -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 {
+122
View File
@@ -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
View File
@@ -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
}
+40
View File
@@ -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
View File
@@ -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 {
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
}
}
+5
View File
@@ -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
View File
@@ -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)
}
+149
View File
@@ -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()
}
+1
View File
@@ -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 {
+2 -2
View File
@@ -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
}
+28
View File
@@ -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 是否为矩形
+53
View File
@@ -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 {
+14 -4
View File
@@ -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
}
+52 -6
View File
@@ -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{}
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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)
+7 -2
View File
@@ -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 {
+27 -6
View File
@@ -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) {
+1 -1
View File
@@ -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)
+31
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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")