diff --git a/go.mod b/go.mod index 2ec1f33..4a42d1a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6094822..6b8daec 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/ofdgo_color_profile.go b/ofdgo_color_profile.go new file mode 100644 index 0000000..554ba59 --- /dev/null +++ b/ofdgo_color_profile.go @@ -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)} +} diff --git a/ofdgo_compression.go b/ofdgo_compression.go index 75f6514..0938ed1 100644 --- a/ofdgo_compression.go +++ b/ofdgo_compression.go @@ -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) - for name := range r.fileIndex { - names[name] = true - } - for name := range r.files { - names[name] = true + 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))]) - 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 { + compressed, err := e.compressOutputBytes(data, len(data)) + if 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++ diff --git a/ofdgo_compression_images.go b/ofdgo_compression_images.go index 830b593..a3ea3d7 100644 --- a/ofdgo_compression_images.go +++ b/ofdgo_compression_images.go @@ -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 } diff --git a/ofdgo_compression_resources.go b/ofdgo_compression_resources.go index 0cac40e..20ce667 100644 --- a/ofdgo_compression_resources.go +++ b/ofdgo_compression_resources.go @@ -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) diff --git a/ofdgo_convert.go b/ofdgo_convert.go index 8f89254..7558b4d 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -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 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index fa21585..4498ff5 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -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) diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go index 29d796c..c3c8618 100644 --- a/ofdgo_editor_color.go +++ b/ofdgo_editor_color.go @@ -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< 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< 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 -} diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index f591de1..43d1234 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -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 diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 059347e..50ed31f 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -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 } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 1b7e811..49b6e21 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -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 { diff --git a/ofdgo_editor_stage.go b/ofdgo_editor_stage.go index d25ce8d..a733356 100644 --- a/ofdgo_editor_stage.go +++ b/ofdgo_editor_stage.go @@ -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 diff --git a/ofdgo_editor_vector.go b/ofdgo_editor_vector.go new file mode 100644 index 0000000..1eef752 --- /dev/null +++ b/ofdgo_editor_vector.go @@ -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() +} diff --git a/ofdgo_extract_image.go b/ofdgo_extract_image.go index 37dc7d1..a298b11 100644 --- a/ofdgo_extract_image.go +++ b/ofdgo_extract_image.go @@ -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}) diff --git a/ofdgo_image_resource.go b/ofdgo_image_resource.go index f9ede12..1ffa6c1 100644 --- a/ofdgo_image_resource.go +++ b/ofdgo_image_resource.go @@ -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) } diff --git a/ofdgo_media.go b/ofdgo_media.go index ccf669f..3bf49bd 100644 --- a/ofdgo_media.go +++ b/ofdgo_media.go @@ -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)) } diff --git a/ofdgo_paint.go b/ofdgo_paint.go index 3b597e7..ea8fbc2 100644 --- a/ofdgo_paint.go +++ b/ofdgo_paint.go @@ -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 diff --git a/ofdgo_paint_mesh.go b/ofdgo_paint_mesh.go index f3177a6..f5db66c 100644 --- a/ofdgo_paint_mesh.go +++ b/ofdgo_paint_mesh.go @@ -45,10 +45,11 @@ type ShdPoint struct { // MeshVertex 保存局部毫米坐标、原颜色分量及非预乘透明度 type MeshVertex struct { - Point Point - Space string - Values [4]float64 - Alpha float64 + Point Point + 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 非法拓扑 diff --git a/ofdgo_paint_model.go b/ofdgo_paint_model.go index f2c705f..2ccaf56 100644 --- a/ofdgo_paint_model.go +++ b/ofdgo_paint_model.go @@ -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 diff --git a/ofdgo_paint_profile.go b/ofdgo_paint_profile.go new file mode 100644 index 0000000..fc892c6 --- /dev/null +++ b/ofdgo_paint_profile.go @@ -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) +} diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 30cb93c..1b5ad13 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -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 } diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index db9f740..68b2061 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -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 } diff --git a/ofdgo_pdf_clip_group.go b/ofdgo_pdf_clip_group.go new file mode 100644 index 0000000..ac50a50 --- /dev/null +++ b/ofdgo_pdf_clip_group.go @@ -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 +} diff --git a/ofdgo_pdf_font.go b/ofdgo_pdf_font.go index 14394a8..9647a9b 100644 --- a/ofdgo_pdf_font.go +++ b/ofdgo_pdf_font.go @@ -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 { diff --git a/ofdgo_pdf_image_cache.go b/ofdgo_pdf_image_cache.go new file mode 100644 index 0000000..42e3dd4 --- /dev/null +++ b/ofdgo_pdf_image_cache.go @@ -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) +} diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index 4a38dc6..1b15094 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -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 { + value, err := p.reader.Resolve(dict["A"]) + if err != nil { + return nil, err + } + if target == nil && value == nil { return nil, nil } - if dict["A"] != nil { - value, err := p.reader.Resolve(dict["A"]) - if err != nil { - return nil, err - } + 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 +} diff --git a/ofdgo_pdf_link_file.go b/ofdgo_pdf_link_file.go index 2f8fabb..ca044ef 100644 --- a/ofdgo_pdf_link_file.go +++ b/ofdgo_pdf_link_file.go @@ -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 结构、取消或转换错误 diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go index 4e04015..f2c3e37 100644 --- a/ofdgo_pdf_media.go +++ b/ofdgo_pdf_media.go @@ -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") + parameters, err := p.reader.ReadMovieActivation(ctx, activation) + if err != nil { + return err } - for key, value := range dict { - value, err = p.reader.Resolve(value) - 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 { diff --git a/ofdgo_pdf_mesh.go b/ofdgo_pdf_mesh.go index 80b189f..6cc77fa 100644 --- a/ofdgo_pdf_mesh.go +++ b/ofdgo_pdf_mesh.go @@ -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 diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 8467107..8153f44 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -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))}) diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index b78330f..48acfd1 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -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"} } } diff --git a/ofdgo_raster.go b/ofdgo_raster.go index ad6cb5c..59a2945 100644 --- a/ofdgo_raster.go +++ b/ofdgo_raster.go @@ -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{} } diff --git a/ofdgo_reader.go b/ofdgo_reader.go index a6eb8c5..b8644c6 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -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,11 +702,12 @@ 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.Join(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) } diff --git a/ofdgo_reader_dependencies.go b/ofdgo_reader_dependencies.go new file mode 100644 index 0000000..b46607a --- /dev/null +++ b/ofdgo_reader_dependencies.go @@ -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() +} diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index 2ff4c44..85ba9bc 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -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 { diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index 29b4a1b..d9a691b 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -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 } diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index 57bb920..fee0fe5 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -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 是否为矩形 diff --git a/ofdgo_renderer_clip_convex_canvas.go b/ofdgo_renderer_clip_convex_canvas.go index 68a8cc6..62c4d07 100644 --- a/ofdgo_renderer_clip_convex_canvas.go +++ b/ofdgo_renderer_clip_convex_canvas.go @@ -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 { diff --git a/ofdgo_renderer_compile_canvas.go b/ofdgo_renderer_compile_canvas.go index f84454c..bb3deb1 100644 --- a/ofdgo_renderer_compile_canvas.go +++ b/ofdgo_renderer_compile_canvas.go @@ -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,26 +191,36 @@ 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() - for scanner.Scan() { - s := RasterSegment{End: rasterPoint(scanner.End())} - switch scanner.Cmd() { - case canvas.MoveToCmd: - s.Verb = RasterMove - case canvas.LineToCmd: - s.Verb = RasterLine - case canvas.QuadToCmd: - s.Verb, s.Control1 = RasterQuad, rasterPoint(scanner.CP1()) - case canvas.CubeToCmd: - s.Verb, s.Control1, s.Control2 = RasterCubic, rasterPoint(scanner.CP1()), rasterPoint(scanner.CP2()) - case canvas.CloseCmd: - s.Verb = RasterClose - default: - return nil, fmt.Errorf("unsupported raster path command %g", scanner.Cmd()) + 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() { + case canvas.MoveToCmd: + s.Verb = RasterMove + case canvas.LineToCmd: + s.Verb = RasterLine + case canvas.QuadToCmd: + s.Verb, s.Control1 = RasterQuad, rasterPoint(scanner.CP1()) + case canvas.CubeToCmd: + s.Verb, s.Control1, s.Control2 = RasterCubic, rasterPoint(scanner.CP1()), rasterPoint(scanner.CP2()) + case canvas.CloseCmd: + s.Verb = RasterClose + default: + return nil, fmt.Errorf("unsupported raster path command %g", scanner.Cmd()) + } + segments = append(segments, s) } - 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 } diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 59537f2..6616b77 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -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{} diff --git a/ofdgo_renderer_image_canvas.go b/ofdgo_renderer_image_canvas.go index 822fc8e..a76ad48 100644 --- a/ofdgo_renderer_image_canvas.go +++ b/ofdgo_renderer_image_canvas.go @@ -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 diff --git a/ofdgo_renderer_paint_canvas.go b/ofdgo_renderer_paint_canvas.go index 3a9a625..79ebd18 100644 --- a/ofdgo_renderer_paint_canvas.go +++ b/ofdgo_renderer_paint_canvas.go @@ -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) diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index 855db30..537f7b4 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -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) } - sp = applyClipPath(sp, strokeClip) if style.strokePattern != nil { r.renderPattern(ctx, style.strokePattern, pageH, sp, objectCTM) } else { diff --git a/ofdgo_renderer_shading_canvas.go b/ofdgo_renderer_shading_canvas.go index d667d5c..1270960 100644 --- a/ofdgo_renderer_shading_canvas.go +++ b/ofdgo_renderer_shading_canvas.go @@ -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) { diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index 5169153..1c02394 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -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) diff --git a/ofdgo_res.go b/ofdgo_res.go index 0bd1a15..3a1ed6d 100644 --- a/ofdgo_res.go +++ b/ofdgo_res.go @@ -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 +} diff --git a/ofdgo_sign_policy.go b/ofdgo_sign_policy.go index 2ddb446..dcd02f3 100644 --- a/ofdgo_sign_policy.go +++ b/ofdgo_sign_policy.go @@ -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") } } diff --git a/ofdgo_sign_write.go b/ofdgo_sign_write.go index f7e579e..1f2011d 100644 --- a/ofdgo_sign_write.go +++ b/ofdgo_sign_write.go @@ -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 diff --git a/ofdgo_writer.go b/ofdgo_writer.go index facca85..204dadc 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -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")