From 1739303f5e1549d2014652c9c0c34d5a38d5f5f1 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 27 Sep 2026 19:28:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BD=AC=E6=8D=A2=E6=B8=B2=E6=9F=93):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=BD=AC=E6=8D=A2=E8=83=BD=E5=8A=9B=E4=B8=8E?= =?UTF-8?q?=E5=9B=BE=E5=83=8F=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_editor.go | 1 + ofdgo_editor_attachment.go | 105 ++++++++++++++++ ofdgo_editor_link.go | 30 +++-- ofdgo_editor_media.go | 48 ++++++++ ofdgo_image.go | 71 +++++++---- ofdgo_pdf.go | 2 +- ofdgo_pdf_annot.go | 22 +++- ofdgo_pdf_link.go | 114 +++++++++--------- ofdgo_pdf_media.go | 199 +++++++++++++++++++++++++++++++ ofdgo_pdf_mesh.go | 49 ++++++++ ofdgo_pdf_object.go | 21 +++- ofdgo_pdf_shading.go | 8 +- ofdgo_raster_image.go | 5 +- ofdgo_renderer_compile_canvas.go | 61 ++++++++-- ofdgo_renderer_image_canvas.go | 46 ++++--- 17 files changed, 658 insertions(+), 130 deletions(-) create mode 100644 ofdgo_editor_attachment.go create mode 100644 ofdgo_editor_media.go create mode 100644 ofdgo_pdf_media.go diff --git a/go.mod b/go.mod index b1c0e17..6e7162e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,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-20260927082417-6c78229518ef + github.com/xiaoqidun/pdfgo v0.0.0-20260927111518-3299b44c9705 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 f74f6bc..447f04b 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-20260927082417-6c78229518ef h1:GW25sDMcACPksCOx58dKcbV2qWhbhClj6m7nuu+rZbo= -github.com/xiaoqidun/pdfgo v0.0.0-20260927082417-6c78229518ef/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260927111518-3299b44c9705 h1:DgUrxq3/Mn3218rwv3Daw1FHcbmR0E3aOIsa6VZMMNQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260927111518-3299b44c9705/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_editor.go b/ofdgo_editor.go index 2c5ee2b..0e7cf79 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -152,6 +152,7 @@ func (r editorResource) definition() string { type editorResourceKey struct { checksum [32]byte index int + kind string } // NewEditor 创建空白文档,保存前至少添加一页 diff --git a/ofdgo_editor_attachment.go b/ofdgo_editor_attachment.go new file mode 100644 index 0000000..5443eaf --- /dev/null +++ b/ofdgo_editor_attachment.go @@ -0,0 +1,105 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/xml" + "fmt" + "path" + "strings" +) + +// AddAttachment 添加文档附件,保留原始数据,一次操作计入一条撤销记录 +// 入参: name 显示文件名, data 文件数据 +// 返回: string 附件标识, error 文件名或文档结构错误 +func (e *Editor) AddAttachment(name string, data []byte) (string, error) { + if strings.TrimSpace(name) == "" { + return "", fmt.Errorf("attachment name is required") + } + if err := e.prepareSourceIDs(); err != nil { + return "", err + } + base := e.source + var err error + if base == nil { + base, err = e.importBase() + if err != nil { + return "", err + } + } + id := e.nextID() + file := e.packageName("Attachments/Attachment_" + id) + attrs := ofdAttrs{} + attrs.add("ID", id) + attrs.add("Name", name) + attrs.add("Format", strings.TrimPrefix(path.Ext(name), ".")) + attrs.add("Size", ofdNumber(float64(len(data))/1024)) + attrs.add("Visible", "true") + entry, err := editorXMLContainer("Attachment", attrs, editorXMLText("FileLoc", "/"+file)) + if err != nil { + return "", err + } + entry = bytes.TrimPrefix(entry, []byte(xml.Header)) + docName := cleanPackagePath(base.reader.OFD.DocBody[0].DocRoot) + docData, err := base.reader.readFile(docName) + if err != nil { + return "", err + } + doc, err := parseEditorXML(docData) + if err != nil { + return "", err + } + node := doc.child("Attachments") + parts := map[string][]byte{file: bytes.Clone(data)} + if base.document.Attachments.Path != "" { + name := base.reader.ResPath(base.document.Attachments.Path) + existing, err := base.reader.readFile(name) + if err != nil { + return "", err + } + root, err := parseEditorXML(existing) + if err != nil { + return "", err + } + if root.open == root.end { + parts[name] = editorPatchXML(existing, []editorXMLPatch{editorXMLContent(existing, root, entry)}) + } else { + parts[name] = editorPatchXML(existing, []editorXMLPatch{{root.close, root.close, entry}}) + } + } else if node != nil { + if node.open == node.end { + parts[docName] = editorPatchXML(docData, []editorXMLPatch{editorXMLContent(docData, node, entry)}) + } else { + parts[docName] = editorPatchXML(docData, []editorXMLPatch{{node.close, node.close, entry}}) + } + } else { + container, err := editorXMLContainer("Attachments", nil, entry) + if err != nil { + return "", err + } + name := e.packageName("Attachments.xml") + parts[name] = container + position := doc.close + if next := doc.child("Extensions"); next != nil { + position = next.start + } + parts[docName] = editorPatchXML(docData, []editorXMLPatch{{position, position, editorXMLText("Attachments", "/"+name)}}) + } + if err := e.commitAnnotationParts(base, parts); err != nil { + return "", err + } + return id, nil +} diff --git a/ofdgo_editor_link.go b/ofdgo_editor_link.go index 802d56e..2ffc099 100644 --- a/ofdgo_editor_link.go +++ b/ofdgo_editor_link.go @@ -83,8 +83,8 @@ func (e *Editor) SetObjectLink(page int, ids []string, link *AnnotationLink) err return e.updateObjects(page, objects, true) } -// SetObjectActions 原子替换普通对象的链接动作,空列表移除动作 -// 入参: page 页面索引, ids 对象标识, actions 链接动作 +// SetObjectActions 原子替换普通对象的动作,空列表移除动作 +// 入参: page 页面索引, ids 对象标识, actions 动作及已注册的资源引用 // 返回: error 错误信息 func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error { if err := validateObjectActions(actions); err != nil { @@ -111,7 +111,7 @@ func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) erro return e.updateObjects(page, objects, true) } -// validateObjectActions 校验新建对象的链接动作,目标不必可达 +// validateObjectActions 校验新建对象的动作结构,目标不必可达 // 入参: actions 动作列表 // 返回: error 错误信息 func validateObjectActions(actions []Action) error { @@ -119,11 +119,27 @@ func validateObjectActions(actions []Action) error { if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) { return fmt.Errorf("unsupported action event %q", action.Event) } - if action.GotoA != nil || action.Sound != nil || action.Movie != nil { - return fmt.Errorf("new attachment and multimedia actions require resource registration") + count := 0 + for _, present := range []bool{action.URI != nil, action.Goto != nil, action.GotoA != nil, action.Sound != nil, action.Movie != nil} { + if present { + count++ + } } - if (action.URI == nil) == (action.Goto == nil) { - return fmt.Errorf("specify one URI or goto action") + if count != 1 { + return fmt.Errorf("specify one action target") + } + if action.GotoA != nil && action.GotoA.AttachID == "" { + return fmt.Errorf("attachment action requires an attachment ID") + } + if sound := action.Sound; sound != nil { + if sound.ResourceID == "" || sound.Volume != nil && (*sound.Volume < 0 || *sound.Volume > 100) { + return fmt.Errorf("invalid sound action resource or volume") + } + } + if movie := action.Movie; movie != nil { + if movie.ResourceID == "" || !slices.Contains([]string{"", "Play", "Stop", "Pause", "Resume"}, movie.Operator) { + return fmt.Errorf("invalid movie action resource or operator") + } } if action.Goto != nil { if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) { diff --git a/ofdgo_editor_media.go b/ofdgo_editor_media.go new file mode 100644 index 0000000..940a519 --- /dev/null +++ b/ofdgo_editor_media.go @@ -0,0 +1,48 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto/sha256" + "fmt" + "strings" +) + +// AddMedia 注册音频或视频资源,保持原始编码,资源被动作引用后写入文档 +// 本方法不转码或验证媒体编解码器,播放能力由调用方提供 +// 入参: kind 为Audio或Video, format 媒体格式,可为空, data 完整媒体文件 +// 返回: string 资源标识, error 无效类型或数据 +func (e *Editor) AddMedia(kind, format string, data []byte) (string, error) { + if kind != "Audio" && kind != "Video" { + return "", fmt.Errorf("media type must be Audio or Video") + } + if len(data) == 0 { + return "", fmt.Errorf("empty media data") + } + format = strings.ToUpper(strings.TrimSpace(format)) + key := editorResourceKey{checksum: sha256.Sum256(data), index: -1, kind: kind + "/" + format} + if id, ok := e.resourceID[key]; ok { + return id, nil + } + if err := e.prepareSourceIDs(); err != nil { + return "", err + } + id := e.nextID() + name := e.packageName("Res/Media/Media_" + id) + e.resources = append(e.resources, editorResource{name: name, data: bytes.Clone(data), image: &MultiMedia{ID: id, Type: kind, Format: format, MediaFile: "/" + name}}) + e.resourceID[key] = id + return id, nil +} diff --git a/ofdgo_image.go b/ofdgo_image.go index cc8d6ba..176dbc0 100644 --- a/ofdgo_image.go +++ b/ofdgo_image.go @@ -462,32 +462,53 @@ func imageWithTransparentEdge(img image.Image) (image.Image, int) { if !hasZero || !hasVisible { return img, 0 } - srcBounds := src.Bounds() - out := image.NewNRGBA(image.Rect(0, 0, w+2, h+2)) - draw.Draw(out, out.Bounds().Inset(1), src, srcBounds.Min, draw.Src) - for y := 0; y < h; y++ { - sy := srcBounds.Min.Y + y - offset := src.PixOffset(srcBounds.Min.X, sy) + 3 - for x := 0; x < w; x++ { - if src.Pix[offset] == 0 { - if edge, ok := transparentEdgeColor(src, srcBounds.Min.X+x, sy); ok { - out.SetNRGBA(x+1, y+1, edge) - } - } - offset += 4 + return &transparentEdgeImage{source: src, rect: image.Rect(0, 0, w+2, h+2)}, 1 +} + +// transparentEdgeImage 共享只读像素并按需计算透明边缘和采样边距 +type transparentEdgeImage struct { + source *image.NRGBA + rect image.Rectangle +} + +// Bounds 返回含采样边距的图像边界 +// 返回: image.Rectangle 图像边界 +func (s *transparentEdgeImage) Bounds() image.Rectangle { return s.rect } + +// ColorModel 返回非预乘颜色模型 +// 返回: color.Model 颜色模型 +func (s *transparentEdgeImage) ColorModel() color.Model { return color.NRGBAModel } + +// NRGBAAt 读取原始像素或按邻域补齐透明边缘,边界外透明 +// 入参: x 横向坐标, y 纵向坐标 +// 返回: color.NRGBA 非预乘颜色 +func (s *transparentEdgeImage) NRGBAAt(x, y int) color.NRGBA { + if !image.Pt(x, y).In(s.rect) { + return color.NRGBA{} + } + b := s.source.Bounds() + sx, sy := b.Min.X+min(max(x-1, 0), b.Dx()-1), b.Min.Y+min(max(y-1, 0), b.Dy()-1) + c := s.source.NRGBAAt(sx, sy) + if c.A == 0 { + if edge, ok := transparentEdgeColor(s.source, sx, sy); ok { + c = edge } } - for x := 0; x < w; x++ { - out.SetNRGBA(x+1, 0, transparentPaddingColor(out.NRGBAAt(x+1, 1))) - out.SetNRGBA(x+1, h+1, transparentPaddingColor(out.NRGBAAt(x+1, h))) + if x == 0 || y == 0 || x == s.rect.Max.X-1 || y == s.rect.Max.Y-1 { + c = transparentPaddingColor(c) } - for y := 0; y < h; y++ { - out.SetNRGBA(0, y+1, transparentPaddingColor(out.NRGBAAt(1, y+1))) - out.SetNRGBA(w+1, y+1, transparentPaddingColor(out.NRGBAAt(w, y+1))) - } - out.SetNRGBA(0, 0, transparentPaddingColor(out.NRGBAAt(1, 1))) - out.SetNRGBA(w+1, 0, transparentPaddingColor(out.NRGBAAt(w, 1))) - out.SetNRGBA(0, h+1, transparentPaddingColor(out.NRGBAAt(1, h))) - out.SetNRGBA(w+1, h+1, transparentPaddingColor(out.NRGBAAt(w, h))) - return out, 1 + return c +} + +// At 返回非预乘像素颜色 +// 入参: x 横向坐标, y 纵向坐标 +// 返回: color.Color 像素颜色 +func (s *transparentEdgeImage) At(x, y int) color.Color { return s.NRGBAAt(x, y) } + +// RGBA64At 返回预乘十六位像素,供采样器无装箱读取 +// 入参: x 横向坐标, y 纵向坐标 +// 返回: color.RGBA64 预乘颜色 +func (s *transparentEdgeImage) RGBA64At(x, y int) color.RGBA64 { + r, g, b, a := s.NRGBAAt(x, y).RGBA() + return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)} } diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index de90795..151c26d 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -522,7 +522,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { // 返回: *FillColor OFD颜色或渐变, error 图案转换错误 func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) { if paint.Mesh != nil { - return nil, &pdfgo.UnsupportedError{Feature: "bicubic mesh conversion"} + return p.meshColor(paint, box) } color := p.color(paint) if paint.Tiling != nil { diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index a219661..cb3bdbe 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -30,10 +30,13 @@ import ( ) // appearanceAnnotation 将PDF静态外观转换为对应类型的OFD注解 -// 入参: ctx取消上下文, page PDF页面, annotation PDF注解 +// 入参: ctx取消上下文, page PDF页面, annotation PDF注解, actions 外观区域的动作 // 返回: error 外观或转换错误 -func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error { - for _, key := range []pdfgo.Name{"AA", "OC"} { +func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, actions ...Action) error { + for _, key := range []pdfgo.Name{"AA", "OC", "A"} { + if key == "A" && (annotation.Subtype == "Link" || annotation.Subtype == "Movie") { + continue + } if annotation.Dictionary[key] != nil { return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)} } @@ -66,6 +69,7 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page stamp.report = PDFImportReport{} var darkenImages []pdfgo.ImageMark visitor := pdfgo.Visitor{ + Warning: p.warning, Path: func(mark pdfgo.PathMark) error { if mark.Style.BlendMode == "Darken" { return &pdfgo.UnsupportedError{Feature: "darken stamp path"} @@ -108,6 +112,15 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF darken stamp appearance flattened over page content"}) } converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Appearance: Appearance{Boundary: pdfBoundary(box), Objects: stamp.objects}} + if len(actions) != 0 { + region, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H}) + if err != nil { + return err + } + no := false + region.Fill, region.Stroke, region.Actions = &no, &no, actions + converted.Appearance.Objects = append(converted.Appearance.Objects, GraphicObject{Type: "PathObject", PathObject: region}) + } if value, err := p.reader.Resolve(annotation.Dictionary["Contents"]); err != nil { return err } else if value != nil { @@ -189,6 +202,7 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page p.report.TextObjects += stamp.report.TextObjects p.report.PathObjects += stamp.report.PathObjects p.report.ImageObjects += stamp.report.ImageObjects + p.report.Warnings = append(p.report.Warnings, stamp.report.Warnings...) if flags&64 != 0 { p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF annotation read-only flag not transferred"}) } @@ -206,7 +220,7 @@ func pdfAnnotationType(subtype pdfgo.Name) string { return "Watermark" case "Highlight", "Underline", "Squiggly", "StrikeOut": return "Highlight" - case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink": + case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link": return "Path" } return "" diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go index b2a4a7e..267c20c 100644 --- a/ofdgo_pdf_link.go +++ b/ofdgo_pdf_link.go @@ -32,6 +32,51 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } for _, annotation := range annotations { dict := annotation.Dictionary + if annotation.Subtype == "Sound" { + sound, err := p.reader.ReadSound(dict["Sound"]) + if err != nil { + return err + } + data, err := pdfSoundWAV(sound) + if err != nil { + return err + } + id, err := p.editor.AddMedia("Audio", "WAV", data) + if err != nil { + return err + } + if err := p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Sound: &Sound{ResourceID: id}}); err != nil { + return err + } + continue + } + if annotation.Subtype == "Movie" { + if err := p.movieAnnotation(ctx, page, annotation); err != nil { + return err + } + continue + } + if annotation.Subtype == "FileAttachment" { + file, err := p.reader.ReadFileSpecification(dict["FS"]) + if err != nil { + return err + } + if file.Embedded == nil { + return &pdfgo.UnsupportedError{Feature: "external file attachment"} + } + data, err := file.Embedded.Decode() + if err != nil { + return err + } + id, err := p.editor.AddAttachment(file.Name, data) + if err != nil { + return err + } + if err := p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", GotoA: &GotoA{AttachID: id}}); err != nil { + return err + } + continue + } if annotation.Subtype == "Widget" { field, err := p.reader.ReadField(annotation) if err != nil { @@ -48,7 +93,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } continue } - if pdfAnnotationType(annotation.Subtype) != "" { + if annotation.Subtype != "Link" && pdfAnnotationType(annotation.Subtype) != "" { if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { return err } @@ -57,61 +102,11 @@ 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["AP"] != nil { - visitor := pdfgo.Visitor{ - Path: func(pdfgo.PathMark) error { return &pdfgo.UnsupportedError{Feature: "visible link appearance"} }, - Text: func(pdfgo.TextMark) error { return &pdfgo.UnsupportedError{Feature: "visible link appearance"} }, - Image: func(pdfgo.ImageMark) error { return &pdfgo.UnsupportedError{Feature: "visible link appearance"} }, - } - visitor.Group = func(_ pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { - return walk(visitor) - } - if err := p.reader.WalkAnnotationAppearance(ctx, page, annotation, visitor); err != nil { - return err - } - } for _, key := range []pdfgo.Name{"AA", "OC"} { if dict[key] != nil { return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("link annotation field %q", key)} } } - if value, err := p.reader.Resolve(dict["F"]); err != nil { - return err - } else if value != nil { - flags, ok := value.(pdfgo.Integer) - if !ok { - return fmt.Errorf("invalid PDF annotation flags") - } - if flags != 0 && flags != 4 { - return &pdfgo.UnsupportedError{Feature: "link annotation flags"} - } - } - borderStyle, err := p.reader.Resolve(dict["BS"]) - if err != nil { - return err - } - if borderStyle != nil { - style, ok := borderStyle.(pdfgo.Dictionary) - if !ok { - return fmt.Errorf("invalid PDF link border style") - } - width, err := p.reader.Resolve(style["W"]) - if err != nil { - return err - } - if width != pdfgo.Integer(0) && width != pdfgo.Real(0) { - return &pdfgo.UnsupportedError{Feature: "visible link border"} - } - } else { - border, err := p.reader.Resolve(dict["Border"]) - if err != nil { - return err - } - array, ok := border.(pdfgo.Array) - if !ok || len(array) < 3 || (array[2] != pdfgo.Integer(0) && array[2] != pdfgo.Real(0)) { - return &pdfgo.UnsupportedError{Feature: "visible link border"} - } - } action := Action{Event: "CLICK"} target := dict["Dest"] if dict["A"] != nil { @@ -162,6 +157,9 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if err != nil { if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("%v; link omitted", err)}) + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } continue } return err @@ -170,6 +168,9 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict if targetPage == nil { if !strict { p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF link targets a page outside the document page tree; link omitted"}) + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } continue } return fmt.Errorf("PDF destination page missing") @@ -221,18 +222,13 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict } b := annotation.Rect box := pdfBounds([]pdfgo.Point{p.matrix.Apply(pdfgo.Point{X: b.XMin, Y: b.YMin}), p.matrix.Apply(pdfgo.Point{X: b.XMax, Y: b.YMax})}) - object, err := NewShape(ShapeRectangle, box) - if err != nil { - return err - } - no := false - object.Fill, object.Stroke = &no, &no action.Region, err = p.linkRegion(annotation, box) if err != nil { return err } - object.Actions = []Action{action} - p.objects = append(p.objects, GraphicObject{Type: "PathObject", PathObject: object}) + if err := p.appearanceAnnotation(ctx, page, annotation, action); err != nil { + return err + } p.report.Links++ } return nil diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go new file mode 100644 index 0000000..1c42049 --- /dev/null +++ b/ofdgo_pdf_media.go @@ -0,0 +1,199 @@ +// 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" + "context" + "encoding/binary" + "fmt" + "math" + "path" + "strings" + + "github.com/xiaoqidun/pdfgo" +) + +// movieAnnotation 转换内嵌视频及其点击播放动作,保留独立海报外观 +// 入参: ctx 取消上下文, page PDF页面, annotation 视频注解 +// 返回: error 媒体资源或播放参数无法表示时返回错误 +func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error { + movie, err := p.reader.ReadMovie(annotation.Dictionary["Movie"]) + if err != nil { + return err + } + activation, err := p.reader.Resolve(annotation.Dictionary["A"]) + if err != nil { + return err + } + if activation == pdfgo.Boolean(false) { + return p.appearanceAnnotation(ctx, page, annotation) + } + if activation != nil && activation != pdfgo.Boolean(true) { + dict, ok := activation.(pdfgo.Dictionary) + if !ok { + return fmt.Errorf("invalid movie activation") + } + for key, value := range dict { + value, err = p.reader.Resolve(value) + 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 movie.Rotate != 0 { + return &pdfgo.UnsupportedError{Feature: "rotated movie playback"} + } + if movie.File.Embedded == nil { + return fmt.Errorf("movie media is not embedded: %s", movie.File.Name) + } + data, err := movie.File.Embedded.Decode() + if err != nil { + return err + } + id, err := p.editor.AddMedia("Video", strings.TrimPrefix(path.Ext(movie.File.Name), "."), data) + if err != nil { + return err + } + return p.appearanceAnnotation(ctx, page, annotation, Action{Event: "CLICK", Movie: &Movie{ResourceID: id, Operator: "Play"}}) +} + +// pdfSoundWAV 封装PDF采样音频,保持采样率、声道及数值,超过RIFF容量时使用RF64 +// 入参: sound 音频流及参数 +// 返回: []byte WAVE文件, error 不可表示的参数或损坏的采样数据 +func pdfSoundWAV(sound pdfgo.Sound) ([]byte, error) { + if sound.File != nil || sound.Compression != "" { + return nil, &pdfgo.UnsupportedError{Feature: "external or compressed sound samples"} + } + if sound.Rate != math.Trunc(sound.Rate) || sound.Rate > math.MaxUint32 || sound.Channels > math.MaxUint16 || sound.Bits != 8 && sound.Bits != 16 && sound.Bits != 24 && sound.Bits != 32 { + return nil, &pdfgo.UnsupportedError{Feature: "sound parameters in WAVE"} + } + format := uint16(1) + if sound.Encoding == "muLaw" { + format = 7 + } else if sound.Encoding == "ALaw" { + format = 6 + } + if format != 1 && sound.Bits != 8 { + return nil, fmt.Errorf("invalid companded sound depth") + } + width := sound.Bits / 8 + align := uint64(sound.Channels) * uint64(width) + rate := uint64(sound.Rate) + if align > math.MaxUint16 || rate*align > math.MaxUint32 { + return nil, &pdfgo.UnsupportedError{Feature: "sound block alignment in WAVE"} + } + data, err := sound.Stream.Decode() + if err != nil { + return nil, err + } + if uint64(len(data))%align != 0 { + return nil, fmt.Errorf("incomplete sound sample frame") + } + data = bytes.Clone(data) + if format == 1 { + for offset := 0; offset < len(data); offset += width { + if sound.Encoding == "Raw" && width > 1 || sound.Encoding == "Signed" && width == 1 { + data[offset] ^= 0x80 + } + for left, right := offset, offset+width-1; left < right; left, right = left+1, right-1 { + data[left], data[right] = data[right], data[left] + } + } + } + formatSize := uint32(16) + extra := uint64(0) + if format == 1 && (sound.Bits > 16 || sound.Channels > 2) { + format = 0xfffe + formatSize = 40 + extra = 36 + } else if format != 1 { + formatSize = 18 + extra = 14 + } + size := uint64(36) + extra + uint64(len(data)) + uint64(len(data)%2) + large := size > math.MaxUint32 + var out bytes.Buffer + put16 := func(v uint16) { _ = binary.Write(&out, binary.LittleEndian, v) } + put32 := func(v uint32) { _ = binary.Write(&out, binary.LittleEndian, v) } + put64 := func(v uint64) { _ = binary.Write(&out, binary.LittleEndian, v) } + if large { + out.WriteString("RF64") + put32(math.MaxUint32) + out.WriteString("WAVEds64") + put32(28) + put64(size + 36) + put64(uint64(len(data))) + put64(uint64(len(data)) / align) + put32(0) + } else { + out.WriteString("RIFF") + put32(uint32(size)) + out.WriteString("WAVE") + } + out.WriteString("fmt ") + put32(formatSize) + put16(format) + put16(uint16(sound.Channels)) + put32(uint32(rate)) + put32(uint32(rate * align)) + put16(uint16(align)) + put16(uint16(sound.Bits)) + if format != 1 { + if format == 0xfffe { + put16(22) + put16(uint16(sound.Bits)) + put32(0) + out.Write([]byte{1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71}) + } else { + put16(0) + } + out.WriteString("fact") + put32(4) + if large { + put32(math.MaxUint32) + } else { + put32(uint32(uint64(len(data)) / align)) + } + } + out.WriteString("data") + if large { + put32(math.MaxUint32) + } else { + put32(uint32(len(data))) + } + out.Write(data) + if len(data)%2 != 0 { + out.WriteByte(0) + } + return out.Bytes(), nil +} diff --git a/ofdgo_pdf_mesh.go b/ofdgo_pdf_mesh.go index fcc5a96..1c28093 100644 --- a/ofdgo_pdf_mesh.go +++ b/ofdgo_pdf_mesh.go @@ -21,6 +21,44 @@ import ( "github.com/xiaoqidun/pdfgo" ) +// meshColor 将线性三角网格映射为OFD高洛德渐变,保留顶点和覆盖顺序 +// 入参: paint PDF网格画刷, box 对象边界 +// 返回: *FillColor OFD渐变, error 不可线性表达的颜色或坐标错误 +func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error) { + if err := pdfGradientError(paint); err != nil { + return nil, err + } + mesh := paint.Mesh + matrix := p.matrix.Mul(mesh.Matrix) + shading := &GouraudShd{} + for _, triangle := range mesh.Triangles { + if err := p.ctx.Err(); err != nil { + return nil, err + } + for i, point := range triangle.Points { + point = matrix.Apply(point) + if !finite(point.X) || !finite(point.Y) { + return nil, fmt.Errorf("nonfinite mesh vertex") + } + values := triangle.Colors[i] + rgb, err := mesh.Space.RGB(values[:mesh.Space.Components()], mesh.Intent) + if err != nil { + return nil, err + } + color := pdfgo.Paint{RGB: rgb, Alpha: 1} + if mesh.Space.Model == "DeviceCMYK" && !mesh.Space.Calibrated() { + color.CMYK = &values + } + converted := p.color(color) + shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, Color: ShdColor{Value: converted.Value, ColorSpace: converted.ColorSpace}}) + } + } + color := p.color(paint) + color.Value = "" + color.GouraudShd = shading + return color, nil +} + // pdfMeshVertex 保存局部毫米坐标及原曲面参数 type pdfMeshVertex struct { point pdfgo.Point @@ -107,6 +145,17 @@ func (p *pdfImporter) meshTriangles(mesh *pdfgo.MeshGradient) ([]pdfMeshTriangle row[n] = left } } + for index, triangle := range mesh.Triangles { + if err := p.ctx.Err(); err != nil { + return nil, err + } + vertices := [3]pdfMeshVertex{ + {matrix.Apply(triangle.Points[0]), 0, 0}, + {matrix.Apply(triangle.Points[1]), 1, 0}, + {matrix.Apply(triangle.Points[2]), 0, 1}, + } + triangles = append(triangles, pdfMeshTriangle{vertices, len(mesh.Patches) + index}) + } return triangles, nil } diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 1a0f437..e52b24a 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -16,6 +16,7 @@ package ofdgo import ( "bytes" + "crypto/sha256" "encoding/binary" "fmt" "image" @@ -425,7 +426,7 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er type1 = &parsed } var err error - program, err = type1.toCFF(source.Name) + program, err = type1.toCFF(pdfFontIdentity(source)) if err != nil { return nil, 0, err } @@ -513,9 +514,10 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er changed = true } if len(fontNamesFromTable(tables["name"])) == 0 { - name := utf16.Encode([]rune(source.Name)) - if len(name) == 0 || len(name) > 32767 { - return nil, 0, fmt.Errorf("PDF font name missing or excessive") + fontName := pdfFontIdentity(source) + name := utf16.Encode([]rune(fontName)) + if len(name) > 32767 { + return nil, 0, fmt.Errorf("PDF font name exceeds name table capacity") } data := make([]byte, 42+len(name)*2) binary.BigEndian.PutUint16(data[2:], 3) @@ -556,6 +558,17 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er return result, repairedLimit, err } +// pdfFontIdentity 返回字体名称或由原始程序生成的稳定封装标识 +// 入参: source PDF字体 +// 返回: string 字体标识 +func pdfFontIdentity(source *pdfgo.Font) string { + if source.Name != "" { + return source.Name + } + checksum := sha256.Sum256(source.Program) + return fmt.Sprintf("PDF-%x", checksum[:28]) +} + // pdfCmapOverlaps 检查格式4字符映射是否包含交叠区段 // 入参: data cmap表数据 // 返回: bool 是否存在交叠 diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index d419ec4..d6c2b3b 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -64,7 +64,13 @@ func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) // 返回: error 不可等价表达的渐变 func pdfGradientError(paint pdfgo.Paint) error { if paint.Mesh != nil { - return &pdfgo.UnsupportedError{Feature: "bicubic mesh conversion"} + mesh := paint.Mesh + if len(mesh.Patches) != 0 || len(mesh.Triangles) == 0 || mesh.UsesFunction() { + return &pdfgo.UnsupportedError{Feature: "nonlinear mesh conversion"} + } + if mesh.Space.Calibrated() && !mesh.Space.SRGBEquivalent() { + return &pdfgo.UnsupportedError{Feature: "nonlinear ICC mesh conversion"} + } } if paint.Axial != nil { return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space) diff --git a/ofdgo_raster_image.go b/ofdgo_raster_image.go index 140f991..afc4d5a 100644 --- a/ofdgo_raster_image.go +++ b/ofdgo_raster_image.go @@ -43,7 +43,10 @@ func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Im if mask.Rect.Empty() { return nil } - draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, imagePixelSource(src), src.Bounds(), draw.Over, &draw.Options{DstMask: mask}) + bounds := mask.Rect.Intersect(dst.Bounds()) + layer := image.NewRGBA64(bounds) + draw.CatmullRom.Transform(layer, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, imagePixelSource(src), src.Bounds(), draw.Src, nil) + draw.DrawMask(dst, bounds, layer, bounds.Min, mask, bounds.Min, draw.Over) return nil } diff --git a/ofdgo_renderer_compile_canvas.go b/ofdgo_renderer_compile_canvas.go index 76c16e5..c0e4667 100644 --- a/ofdgo_renderer_compile_canvas.go +++ b/ofdgo_renderer_compile_canvas.go @@ -17,7 +17,7 @@ package ofdgo import ( "fmt" "image" - "image/draw" + "image/color" "github.com/tdewolff/canvas" ) @@ -120,8 +120,21 @@ func (c *canvasPageCompiler) fill(path *canvas.Path, paint canvas.Paint, rule ca } cmd.Paint.Gradient = g } + var err error + cmd.Path, err = canvasRasterPath(path) + if err != nil { + c.err = err + return + } + c.page.Commands = append(c.page.Commands, cmd) +} + +// canvasRasterPath 转换曲线路径,保留各子路径和闭合标记 +// 入参: path Canvas路径 +// 返回: []RasterSegment 独立路径, error 未知指令错误 +func canvasRasterPath(path *canvas.Path) ([]RasterSegment, error) { path = path.ReplaceArcs() - cmd.Path = make([]RasterSegment, 0, path.Len()) + segments := make([]RasterSegment, 0, path.Len()) scanner := path.Scanner() for scanner.Scan() { s := RasterSegment{End: rasterPoint(scanner.End())} @@ -137,12 +150,11 @@ func (c *canvasPageCompiler) fill(path *canvas.Path, paint canvas.Paint, rule ca case canvas.CloseCmd: s.Verb = RasterClose default: - c.err = fmt.Errorf("unsupported raster path command %g", scanner.Cmd()) - return + return nil, fmt.Errorf("unsupported raster path command %g", scanner.Cmd()) } - cmd.Path = append(cmd.Path, s) + segments = append(segments, s) } - c.page.Commands = append(c.page.Commands, cmd) + return segments, nil } // rasterPoint 转换局部坐标点 @@ -167,12 +179,43 @@ func (c *canvasPageCompiler) RenderImage(img image.Image, m canvas.Matrix) { img = imagePixelSource(img) if (m[0][1] != 0 || m[1][0] != 0) && (m[0][0] != 0 || m[1][1] == 0) { bounds := img.Bounds() - padded := image.NewRGBA(image.Rect(0, 0, bounds.Dx()+8, bounds.Dy()+8)) - draw.Draw(padded, image.Rect(4, 4, bounds.Dx()+4, bounds.Dy()+4), img, bounds.Min, draw.Src) - img = padded + img = &rasterPaddedImage{source: img, rect: image.Rect(0, 0, bounds.Dx()+8, bounds.Dy()+8)} m = m.Translate(-4, -4) } h := float64(img.Bounds().Dy()) t := RasterMatrix{m[0][0], -m[1][0], -m[0][1], m[1][1], m[0][2] + m[0][1]*h, c.page.Height - m[1][2] - m[1][1]*h} c.page.Commands = append(c.page.Commands, RasterCommand{Image: img, Transform: t}) } + +// rasterPaddedImage 为旋转采样提供透明边距,共享原图而不复制整幅像素 +type rasterPaddedImage struct { + source image.Image + rect image.Rectangle +} + +// Bounds 返回含四像素边距的图像边界 +// 返回: image.Rectangle 图像边界 +func (s *rasterPaddedImage) Bounds() image.Rectangle { return s.rect } + +// ColorModel 返回预乘八位颜色模型,与实体补边图像保持一致 +// 返回: color.Model 颜色模型 +func (s *rasterPaddedImage) ColorModel() color.Model { return color.RGBAModel } + +// At 读取补边图像的预乘像素,边距及图像外透明 +// 入参: x 横向坐标, y 纵向坐标 +// 返回: color.Color 像素颜色 +func (s *rasterPaddedImage) At(x, y int) color.Color { + if !image.Pt(x, y).In(s.rect.Inset(4)) { + return color.RGBA{} + } + b := s.source.Bounds() + return color.RGBAModel.Convert(s.source.At(x-4+b.Min.X, y-4+b.Min.Y)) +} + +// RGBA64At 返回预乘十六位像素供采样器读取 +// 入参: x 横向坐标, y 纵向坐标 +// 返回: color.RGBA64 像素颜色 +func (s *rasterPaddedImage) RGBA64At(x, y int) color.RGBA64 { + r, g, b, a := s.At(x, y).RGBA() + return color.RGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)} +} diff --git a/ofdgo_renderer_image_canvas.go b/ofdgo_renderer_image_canvas.go index dd25023..4477a47 100644 --- a/ofdgo_renderer_image_canvas.go +++ b/ofdgo_renderer_image_canvas.go @@ -96,7 +96,14 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float } } clipPath := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, box.X, box.Y, localCTM, parentCTM, boundaryInCTM)) - img = r.imageWithClip(img, clipPath, m, 96) + imageClip := clipPath + compiler, compiled := ctx.Renderer.(*canvasPageCompiler) + if compiled && imageClip != nil && imageClipContains(img, imageClip, m) { + imageClip = nil + } + if !compiled { + img = r.imageWithClip(img, clipPath, m, 96) + } if r.renderError != nil { return } @@ -108,6 +115,15 @@ func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float m[1][2] -= m[1][0]*p + m[1][1]*p } ctx.RenderImage(r.canvasEncodedImage(img), ctx.CoordSystemView().Mul(ctx.View()).Mul(m)) + if compiled && imageClip != nil && compiler.err == nil { + view := (canvas.Matrix{{1, 0, 0}, {0, -1, compiler.page.Height}}).Mul(ctx.CoordSystemView()).Mul(ctx.View()) + segments, err := canvasRasterPath(imageClip.Copy().Transform(view)) + if err != nil { + compiler.err = err + return + } + compiler.page.Commands[len(compiler.page.Commands)-1].Clip = segments + } if obj.Border != nil { r.renderImageBorder(ctx, obj, box, pageH, parentCTM, boundaryInCTM, clipPath) } @@ -146,6 +162,18 @@ func (r *Renderer) renderImageBorder(ctx *canvas.Context, obj ImageObject, box B r.renderPath(ctx, border, pageH, nil, parentCTM, boundaryInCTM, clipPath) } +// imageClipContains 判断裁剪是否完整包含变换后的图像,避免重复削弱边缘覆盖率 +// 入参: img 图片, clipPath 裁剪路径, m 图片到页面变换 +// 返回: bool 是否完整包含 +func imageClipContains(img image.Image, clipPath *canvas.Path, m canvas.Matrix) bool { + b := img.Bounds() + outline := canvas.Rectangle(float64(b.Dx()), float64(b.Dy())).Transform(m) + if rect, ok := rectangularPath(clipPath); ok { + return rect.Contains(outline.FastBounds()) + } + return clipPath.Contains(outline) +} + // imageWithClip 应用图片裁剪区域 // 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率 // 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵, dpi 最低蒙版分辨率,0保留原图精度 @@ -159,21 +187,7 @@ func (r *Renderer) imageWithClip(img image.Image, clipPath *canvas.Path, m canva if w == 0 || h == 0 { return img } - p0 := m.Dot(canvas.Point{}) - p1 := m.Dot(canvas.Point{X: float64(w)}) - p2 := m.Dot(canvas.Point{X: float64(w), Y: float64(h)}) - p3 := m.Dot(canvas.Point{Y: float64(h)}) - imagePath := &canvas.Path{} - imagePath.MoveTo(p0.X, p0.Y) - imagePath.LineTo(p1.X, p1.Y) - imagePath.LineTo(p2.X, p2.Y) - imagePath.LineTo(p3.X, p3.Y) - imagePath.Close() - if rect, ok := rectangularPath(clipPath); ok { - if rect.Contains(imagePath.FastBounds()) { - return img - } - } else if clipPath.Contains(imagePath) { + if imageClipContains(img, clipPath, m) { return img } source, err := imagePixelData(img)