mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
1 parent
052e34a4e6
commit
1739303f5e
17 files changed
+658
-130
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -152,6 +152,7 @@ func (r editorResource) definition() string {
|
||||
type editorResourceKey struct {
|
||||
checksum [32]byte
|
||||
index int
|
||||
kind string
|
||||
}
|
||||
|
||||
// NewEditor 创建空白文档,保存前至少添加一页
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
}
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+18
-4
@@ -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 ""
|
||||
|
||||
+55
-59
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+17
-4
@@ -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 是否存在交叠
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user