feat(转换渲染): 完善转换能力与图像渲染
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-09-27 19:28:58 +08:00
1 parent 052e34a4e6
commit 1739303f5e
17 files changed
+658 -130

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1 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/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/text v0.42.0 golang.org/x/text v0.42.0
+2 -2
View File
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/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 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= 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-20260927111518-3299b44c9705 h1:DgUrxq3/Mn3218rwv3Daw1FHcbmR0E3aOIsa6VZMMNQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260927082417-6c78229518ef/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= 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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+1
View File
@@ -152,6 +152,7 @@ func (r editorResource) definition() string {
type editorResourceKey struct { type editorResourceKey struct {
checksum [32]byte checksum [32]byte
index int index int
kind string
} }
// NewEditor 创建空白文档,保存前至少添加一页 // NewEditor 创建空白文档,保存前至少添加一页
+105
View File
@@ -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
}
+23 -7
View File
@@ -83,8 +83,8 @@ func (e *Editor) SetObjectLink(page int, ids []string, link *AnnotationLink) err
return e.updateObjects(page, objects, true) return e.updateObjects(page, objects, true)
} }
// SetObjectActions 原子替换普通对象的链接动作,空列表移除动作 // SetObjectActions 原子替换普通对象的动作,空列表移除动作
// 入参: page 页面索引, ids 对象标识, actions 链接动作 // 入参: page 页面索引, ids 对象标识, actions 动作及已注册的资源引用
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error { func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error {
if err := validateObjectActions(actions); err != nil { 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) return e.updateObjects(page, objects, true)
} }
// validateObjectActions 校验新建对象的链接动作,目标不必可达 // validateObjectActions 校验新建对象的动作结构,目标不必可达
// 入参: actions 动作列表 // 入参: actions 动作列表
// 返回: error 错误信息 // 返回: error 错误信息
func validateObjectActions(actions []Action) error { func validateObjectActions(actions []Action) error {
@@ -119,11 +119,27 @@ func validateObjectActions(actions []Action) error {
if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) { if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) {
return fmt.Errorf("unsupported action event %q", action.Event) return fmt.Errorf("unsupported action event %q", action.Event)
} }
if action.GotoA != nil || action.Sound != nil || action.Movie != nil { count := 0
return fmt.Errorf("new attachment and multimedia actions require resource registration") 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) { if count != 1 {
return fmt.Errorf("specify one URI or goto action") 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 != nil {
if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) { if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) {
+48
View File
@@ -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
}
+46 -25
View File
@@ -462,32 +462,53 @@ func imageWithTransparentEdge(img image.Image) (image.Image, int) {
if !hasZero || !hasVisible { if !hasZero || !hasVisible {
return img, 0 return img, 0
} }
srcBounds := src.Bounds() return &transparentEdgeImage{source: src, rect: image.Rect(0, 0, w+2, h+2)}, 1
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++ { // transparentEdgeImage 共享只读像素并按需计算透明边缘和采样边距
sy := srcBounds.Min.Y + y type transparentEdgeImage struct {
offset := src.PixOffset(srcBounds.Min.X, sy) + 3 source *image.NRGBA
for x := 0; x < w; x++ { rect image.Rectangle
if src.Pix[offset] == 0 { }
if edge, ok := transparentEdgeColor(src, srcBounds.Min.X+x, sy); ok {
out.SetNRGBA(x+1, y+1, edge) // Bounds 返回含采样边距的图像边界
} // 返回: image.Rectangle 图像边界
} func (s *transparentEdgeImage) Bounds() image.Rectangle { return s.rect }
offset += 4
// 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++ { if x == 0 || y == 0 || x == s.rect.Max.X-1 || y == s.rect.Max.Y-1 {
out.SetNRGBA(x+1, 0, transparentPaddingColor(out.NRGBAAt(x+1, 1))) c = transparentPaddingColor(c)
out.SetNRGBA(x+1, h+1, transparentPaddingColor(out.NRGBAAt(x+1, h)))
} }
for y := 0; y < h; y++ { return c
out.SetNRGBA(0, y+1, transparentPaddingColor(out.NRGBAAt(1, y+1))) }
out.SetNRGBA(w+1, y+1, transparentPaddingColor(out.NRGBAAt(w, y+1)))
} // At 返回非预乘像素颜色
out.SetNRGBA(0, 0, transparentPaddingColor(out.NRGBAAt(1, 1))) // 入参: x 横向坐标, y 纵向坐标
out.SetNRGBA(w+1, 0, transparentPaddingColor(out.NRGBAAt(w, 1))) // 返回: color.Color 像素颜色
out.SetNRGBA(0, h+1, transparentPaddingColor(out.NRGBAAt(1, h))) func (s *transparentEdgeImage) At(x, y int) color.Color { return s.NRGBAAt(x, y) }
out.SetNRGBA(w+1, h+1, transparentPaddingColor(out.NRGBAAt(w, h)))
return out, 1 // 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)}
} }
+1 -1
View File
@@ -522,7 +522,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
// 返回: *FillColor OFD颜色或渐变, error 图案转换错误 // 返回: *FillColor OFD颜色或渐变, error 图案转换错误
func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) { func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) {
if paint.Mesh != nil { if paint.Mesh != nil {
return nil, &pdfgo.UnsupportedError{Feature: "bicubic mesh conversion"} return p.meshColor(paint, box)
} }
color := p.color(paint) color := p.color(paint)
if paint.Tiling != nil { if paint.Tiling != nil {
+18 -4
View File
@@ -30,10 +30,13 @@ import (
) )
// appearanceAnnotation 将PDF静态外观转换为对应类型的OFD注解 // appearanceAnnotation 将PDF静态外观转换为对应类型的OFD注解
// 入参: ctx取消上下文, page PDF页面, annotation PDF注解 // 入参: ctx取消上下文, page PDF页面, annotation PDF注解, actions 外观区域的动作
// 返回: error 外观或转换错误 // 返回: error 外观或转换错误
func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error { func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, actions ...Action) error {
for _, key := range []pdfgo.Name{"AA", "OC"} { for _, key := range []pdfgo.Name{"AA", "OC", "A"} {
if key == "A" && (annotation.Subtype == "Link" || annotation.Subtype == "Movie") {
continue
}
if annotation.Dictionary[key] != nil { if annotation.Dictionary[key] != nil {
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("annotation field %q", key)} 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{} stamp.report = PDFImportReport{}
var darkenImages []pdfgo.ImageMark var darkenImages []pdfgo.ImageMark
visitor := pdfgo.Visitor{ visitor := pdfgo.Visitor{
Warning: p.warning,
Path: func(mark pdfgo.PathMark) error { Path: func(mark pdfgo.PathMark) error {
if mark.Style.BlendMode == "Darken" { if mark.Style.BlendMode == "Darken" {
return &pdfgo.UnsupportedError{Feature: "darken stamp path"} 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"}) 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}} 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 { if value, err := p.reader.Resolve(annotation.Dictionary["Contents"]); err != nil {
return err return err
} else if value != nil { } 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.TextObjects += stamp.report.TextObjects
p.report.PathObjects += stamp.report.PathObjects p.report.PathObjects += stamp.report.PathObjects
p.report.ImageObjects += stamp.report.ImageObjects p.report.ImageObjects += stamp.report.ImageObjects
p.report.Warnings = append(p.report.Warnings, stamp.report.Warnings...)
if flags&64 != 0 { 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"}) 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" return "Watermark"
case "Highlight", "Underline", "Squiggly", "StrikeOut": case "Highlight", "Underline", "Squiggly", "StrikeOut":
return "Highlight" 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 "Path"
} }
return "" return ""
+55 -59
View File
@@ -32,6 +32,51 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
} }
for _, annotation := range annotations { for _, annotation := range annotations {
dict := annotation.Dictionary 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" { if annotation.Subtype == "Widget" {
field, err := p.reader.ReadField(annotation) field, err := p.reader.ReadField(annotation)
if err != nil { if err != nil {
@@ -48,7 +93,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
} }
continue continue
} }
if pdfAnnotationType(annotation.Subtype) != "" { if annotation.Subtype != "Link" && pdfAnnotationType(annotation.Subtype) != "" {
if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { if err := p.appearanceAnnotation(ctx, page, annotation); err != nil {
return err return err
} }
@@ -57,61 +102,11 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if annotation.Subtype != "Link" { if annotation.Subtype != "Link" {
return &pdfgo.UnsupportedError{Feature: "annotation " + string(annotation.Subtype)} 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"} { for _, key := range []pdfgo.Name{"AA", "OC"} {
if dict[key] != nil { if dict[key] != nil {
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("link annotation field %q", key)} 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"} action := Action{Event: "CLICK"}
target := dict["Dest"] target := dict["Dest"]
if dict["A"] != nil { if dict["A"] != nil {
@@ -162,6 +157,9 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if err != nil { if err != nil {
if !strict && errors.Is(err, pdfgo.ErrDestinationNotFound) { 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)}) 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 continue
} }
return err return err
@@ -170,6 +168,9 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if targetPage == nil { if targetPage == nil {
if !strict { 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"}) 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 continue
} }
return fmt.Errorf("PDF destination page missing") 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 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})}) 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) action.Region, err = p.linkRegion(annotation, box)
if err != nil { if err != nil {
return err return err
} }
object.Actions = []Action{action} if err := p.appearanceAnnotation(ctx, page, annotation, action); err != nil {
p.objects = append(p.objects, GraphicObject{Type: "PathObject", PathObject: object}) return err
}
p.report.Links++ p.report.Links++
} }
return nil return nil
+199
View File
@@ -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
}
+49
View File
@@ -21,6 +21,44 @@ import (
"github.com/xiaoqidun/pdfgo" "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 保存局部毫米坐标及原曲面参数 // pdfMeshVertex 保存局部毫米坐标及原曲面参数
type pdfMeshVertex struct { type pdfMeshVertex struct {
point pdfgo.Point point pdfgo.Point
@@ -107,6 +145,17 @@ func (p *pdfImporter) meshTriangles(mesh *pdfgo.MeshGradient) ([]pdfMeshTriangle
row[n] = left 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 return triangles, nil
} }
+17 -4
View File
@@ -16,6 +16,7 @@ package ofdgo
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"image" "image"
@@ -425,7 +426,7 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
type1 = &parsed type1 = &parsed
} }
var err error var err error
program, err = type1.toCFF(source.Name) program, err = type1.toCFF(pdfFontIdentity(source))
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -513,9 +514,10 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
changed = true changed = true
} }
if len(fontNamesFromTable(tables["name"])) == 0 { if len(fontNamesFromTable(tables["name"])) == 0 {
name := utf16.Encode([]rune(source.Name)) fontName := pdfFontIdentity(source)
if len(name) == 0 || len(name) > 32767 { name := utf16.Encode([]rune(fontName))
return nil, 0, fmt.Errorf("PDF font name missing or excessive") if len(name) > 32767 {
return nil, 0, fmt.Errorf("PDF font name exceeds name table capacity")
} }
data := make([]byte, 42+len(name)*2) data := make([]byte, 42+len(name)*2)
binary.BigEndian.PutUint16(data[2:], 3) binary.BigEndian.PutUint16(data[2:], 3)
@@ -556,6 +558,17 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
return result, repairedLimit, err 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字符映射是否包含交叠区段 // pdfCmapOverlaps 检查格式4字符映射是否包含交叠区段
// 入参: data cmap表数据 // 入参: data cmap表数据
// 返回: bool 是否存在交叠 // 返回: bool 是否存在交叠
+7 -1
View File
@@ -64,7 +64,13 @@ func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace)
// 返回: error 不可等价表达的渐变 // 返回: error 不可等价表达的渐变
func pdfGradientError(paint pdfgo.Paint) error { func pdfGradientError(paint pdfgo.Paint) error {
if paint.Mesh != nil { 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 { if paint.Axial != nil {
return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space) return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space)
+4 -1
View File
@@ -43,7 +43,10 @@ func drawRasterImageCommand(backend RasterBackend, page *RasterPage, dst draw.Im
if mask.Rect.Empty() { if mask.Rect.Empty() {
return nil 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 return nil
} }
+52 -9
View File
@@ -17,7 +17,7 @@ package ofdgo
import ( import (
"fmt" "fmt"
"image" "image"
"image/draw" "image/color"
"github.com/tdewolff/canvas" "github.com/tdewolff/canvas"
) )
@@ -120,8 +120,21 @@ func (c *canvasPageCompiler) fill(path *canvas.Path, paint canvas.Paint, rule ca
} }
cmd.Paint.Gradient = g 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() path = path.ReplaceArcs()
cmd.Path = make([]RasterSegment, 0, path.Len()) segments := make([]RasterSegment, 0, path.Len())
scanner := path.Scanner() scanner := path.Scanner()
for scanner.Scan() { for scanner.Scan() {
s := RasterSegment{End: rasterPoint(scanner.End())} 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: case canvas.CloseCmd:
s.Verb = RasterClose s.Verb = RasterClose
default: default:
c.err = fmt.Errorf("unsupported raster path command %g", scanner.Cmd()) return nil, fmt.Errorf("unsupported raster path command %g", scanner.Cmd())
return
} }
cmd.Path = append(cmd.Path, s) segments = append(segments, s)
} }
c.page.Commands = append(c.page.Commands, cmd) return segments, nil
} }
// rasterPoint 转换局部坐标点 // rasterPoint 转换局部坐标点
@@ -167,12 +179,43 @@ func (c *canvasPageCompiler) RenderImage(img image.Image, m canvas.Matrix) {
img = imagePixelSource(img) img = imagePixelSource(img)
if (m[0][1] != 0 || m[1][0] != 0) && (m[0][0] != 0 || m[1][1] == 0) { if (m[0][1] != 0 || m[1][0] != 0) && (m[0][0] != 0 || m[1][1] == 0) {
bounds := img.Bounds() bounds := img.Bounds()
padded := image.NewRGBA(image.Rect(0, 0, bounds.Dx()+8, bounds.Dy()+8)) img = &rasterPaddedImage{source: img, rect: 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
m = m.Translate(-4, -4) m = m.Translate(-4, -4)
} }
h := float64(img.Bounds().Dy()) 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} 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}) 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)}
}
+30 -16
View File
@@ -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)) 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 { if r.renderError != nil {
return 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 m[1][2] -= m[1][0]*p + m[1][1]*p
} }
ctx.RenderImage(r.canvasEncodedImage(img), ctx.CoordSystemView().Mul(ctx.View()).Mul(m)) 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 { if obj.Border != nil {
r.renderImageBorder(ctx, obj, box, pageH, parentCTM, boundaryInCTM, clipPath) 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) 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 应用图片裁剪区域 // imageWithClip 应用图片裁剪区域
// 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率 // 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率
// 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵, dpi 最低蒙版分辨率,0保留原图精度 // 入参: 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 { if w == 0 || h == 0 {
return img return img
} }
p0 := m.Dot(canvas.Point{}) if imageClipContains(img, clipPath, m) {
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) {
return img return img
} }
source, err := imagePixelData(img) source, err := imagePixelData(img)