feat(压缩编辑): 优化图片压缩与编辑链接
build.yaml / build (push) Successful in 46s

This commit is contained in:
xiaoqidun committed 2026-10-09 17:52:25 +08:00
1 parent 9856dd3bd5
commit 31ba6752cf
14 files changed
+796 -57

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ require (
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893
github.com/xiaoqidun/jbig2 v1.1.3 github.com/xiaoqidun/jbig2 v1.1.3
github.com/xiaoqidun/pdfgo v0.0.0-20261009064143-9a945dbdcbb6 github.com/xiaoqidun/pdfgo v0.0.0-20261009094649-718bb9e8a149
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/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 h1:jmQlBO7R+bcimOM
github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/jbig2 v1.1.3 h1:p0d8FMLiLfzQuX278T4VTfTz8tIJEw87u4v63lH4CBc= github.com/xiaoqidun/jbig2 v1.1.3 h1:p0d8FMLiLfzQuX278T4VTfTz8tIJEw87u4v63lH4CBc=
github.com/xiaoqidun/jbig2 v1.1.3/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= github.com/xiaoqidun/jbig2 v1.1.3/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
github.com/xiaoqidun/pdfgo v0.0.0-20261009064143-9a945dbdcbb6 h1:r1G1wTBO32uPvfT3iMl1S96+yj4294bNuFiHHGA+vM0= github.com/xiaoqidun/pdfgo v0.0.0-20261009094649-718bb9e8a149 h1:E5g5tl7ms0DTBYnccc33VoQOpGQUnQQPKd4UlIh0LCM=
github.com/xiaoqidun/pdfgo v0.0.0-20261009064143-9a945dbdcbb6/go.mod h1:swzoqA5+wSHsRPesJS1VOMLhSyWkLCSQ+IgHNYXbcKo= github.com/xiaoqidun/pdfgo v0.0.0-20261009094649-718bb9e8a149/go.mod h1:swzoqA5+wSHsRPesJS1VOMLhSyWkLCSQ+IgHNYXbcKo=
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=
+8 -21
View File
@@ -31,8 +31,6 @@ import (
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/xiaoqidun/jbig2"
) )
// editorPathValidationLimit 限制同次对象校验保留的路径数量 // editorPathValidationLimit 限制同次对象校验保留的路径数量
@@ -463,11 +461,18 @@ func (e *Editor) ResizePages(indexes []int, width, height float64) error {
// 入参: data 图片数据 // 入参: data 图片数据
// 返回: string 图片资源标识, error 错误信息 // 返回: string 图片资源标识, error 错误信息
func (e *Editor) AddImage(data []byte) (string, error) { func (e *Editor) AddImage(data []byte) (string, error) {
return e.addDecodedImage(data, nil)
}
// addDecodedImage 注册图片并复用与编码数据一致的像素
// 入参: data 图片数据, decoded 已解码像素,为nil时按需解码
// 返回: string 资源标识, error 注册错误
func (e *Editor) addDecodedImage(data []byte, decoded image.Image) (string, error) {
config, format, err := image.DecodeConfig(bytes.NewReader(data)) config, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil { if err != nil {
return "", err return "", err
} }
return e.addImage(data, config, format, nil) return e.addImage(data, config, format, decoded)
} }
// addImage 注册图片资源,复用调用方已完成解码的像素数据 // addImage 注册图片资源,复用调用方已完成解码的像素数据
@@ -759,24 +764,6 @@ func (e *Editor) nextID() string {
return strconv.Itoa(e.maxID) return strconv.Itoa(e.maxID)
} }
// editorBinaryImage 尝试纯黑白无损编码,不二值化,不改变透明度,无体积收益时保留原图
// 入参: data PNG图片数据, img 已解码像素,为nil时读取原图
// 返回: []byte 更小的JBIG2数据,不适用时为nil
func editorBinaryImage(data []byte, img image.Image) []byte {
if img == nil {
var err error
img, _, err = image.Decode(bytes.NewReader(data))
if err != nil {
return nil
}
}
var output bytes.Buffer
if err := jbig2.Encode(&output, img, &jbig2.Options{MaxPageBytes: uint64(len(data))}); err != nil || output.Len() >= len(data) {
return nil
}
return output.Bytes()
}
// findObject 查找页面各图层中的对象 // findObject 查找页面各图层中的对象
// 入参: page 页面索引, id 对象标识 // 入参: page 页面索引, id 对象标识
// 返回: *Layer 所属图层, int 对象索引, error 错误信息 // 返回: *Layer 所属图层, int 对象索引, error 错误信息
+3 -4
View File
@@ -676,8 +676,7 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp
// 返回: bool 是否支持 // 返回: bool 是否支持
func (n *editorCompositeNode) transformable() bool { func (n *editorCompositeNode) transformable() bool {
_, invertible := n.parent.Invert() _, invertible := n.parent.Invert()
borderActions := n.object.Type == "ImageObject" && n.object.ImageObject.Border != nil && len(n.object.ImageObject.Actions) != 0 return invertible && editorXMLTransformable(n.node) && validateEditorGeometry(n.object) == nil
return invertible && editorXMLTransformable(n.node) && validateEditorGeometry(n.object) == nil && !borderActions
} }
// compositeMemberStyle 解析成员的继承样式,缺失或循环参数不开放改色 // compositeMemberStyle 解析成员的继承样式,缺失或循环参数不开放改色
@@ -842,10 +841,10 @@ func (e *Editor) transformCompositeMember(renderer *Renderer, n *editorComposite
object = compositeBoundary(object, n.parent, true) object = compositeBoundary(object, n.parent, true)
} }
local := inverse.Multiply(matrix).Multiply(n.parent) local := inverse.Multiply(matrix).Multiply(n.parent)
if object.Type == "ImageObject" && object.ImageObject.Border != nil { if object.Type == "ImageObject" && imageTransformNeedsContainer(object.ImageObject, local) {
object.state = n.states[editorObjectID(object)] object.state = n.states[editorObjectID(object)]
origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object} origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object}
object, err = e.transformBorderedImage(object, local, origin, renderer) object, err = e.transformImageResource(object, local, origin, renderer)
if err != nil { if err != nil {
return err return err
} }
+91
View File
@@ -15,6 +15,7 @@
package ofdgo package ofdgo
import ( import (
"bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"maps" "maps"
@@ -272,6 +273,19 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
return err return err
} }
} }
preserved, err := copy.preserveActionRegions(source.parent)
if err != nil {
return err
}
if !preserved {
wrapped, err := e.vectorInstance(renderer, []*editorCompositeNode{copy})
if err != nil {
return err
}
wrapped.setStates(copy.states)
wrapped.defaults, wrapped.drawParams = copy.defaults, copy.drawParams
copy = wrapped
}
copy.parent, copy.boundaryInCTM = IdentityMatrix, true copy.parent, copy.boundaryInCTM = IdentityMatrix, true
if err := e.transformCompositeMember(renderer, copy, source.parent); err != nil { if err := e.transformCompositeMember(renderer, copy, source.parent); err != nil {
return err return err
@@ -316,6 +330,83 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
return paste(objects) return paste(objects)
} }
// preserveActionRegions 将默认点击范围固定到对象坐标,保留动作原文及显式区域
// 入参: matrix 即将合入对象的父级变换
// 返回: bool 是否可直接表示,否则需保留容器, error 错误信息
func (n *editorCompositeNode) preserveActionRegions(matrix Matrix) (bool, error) {
actions := n.node.child("Actions")
if actions == nil {
return true, nil
}
boundary, ctm := editorGeometry(n.object)
if n.object.Type == "ImageObject" && ctm == "" && (matrix.a != 1 || matrix.b != 0 || matrix.c != 0 || matrix.d != 1) {
return false, nil
}
if axisAlignedMatrix(matrix) {
return true, nil
}
var targets []*editorXML
for _, action := range actions.children {
if action.name.Local != "Action" || action.name.Space != "" && action.name.Space != actions.name.Space && action.name.Space != ofdNamespace {
continue
}
if action.attr("Event") == "CLICK" && action.child("Region") == nil {
targets = append(targets, action)
}
}
if len(targets) == 0 {
return true, nil
}
box, err := creationBox(boundary)
if err != nil {
return false, err
}
inverse, ok := NewMatrix(ctm).Invert()
if !ok {
return false, nil
}
points := [4]Point{{}, {X: box.W}, {X: box.W, Y: box.H}, {Y: box.H}}
var values [4]string
for i, point := range points {
x, y := inverse.Transform(point.X, point.Y)
if !finite(x) || !finite(y) {
return false, nil
}
values[i] = ofdNumber(x) + " " + ofdNumber(y)
}
region, err := encodeOFDXML(func(x *ofdXML) {
x.root("Region", nil)
x.start("Area", ofdAttrs{{Name: xml.Name{Local: "Start"}, Value: values[0]}})
for _, point := range values[1:] {
x.start("Line", ofdAttrs{{Name: xml.Name{Local: "Point1"}, Value: point}})
x.end("Line")
}
x.start("Close", nil)
x.end("Close")
x.end("Area")
x.end("Region")
})
if err != nil {
return false, err
}
region = bytes.TrimPrefix(region, []byte(xml.Header))
patches := make([]editorXMLPatch, 0, len(targets))
for _, target := range targets {
patch := editorXMLPatch{target.open, target.open, region}
if target.open == target.end {
patch = editorXMLContent(n.data, target, region)
}
patches = append(patches, patch)
}
data := editorPatchXML(n.data, patches)
next, err := newEditorCompositeNode(data)
if err != nil {
return false, err
}
n.data, n.node, n.object, n.changed = data, next.node, next.object, true
return true, nil
}
// convertCoordinates 转换内联基本成员的边界约定,引用资源保持标准局部坐标 // convertCoordinates 转换内联基本成员的边界约定,引用资源保持标准局部坐标
// 入参: parent 所在范围矩阵, inward 是否转换到随父变换的坐标 // 入参: parent 所在范围矩阵, inward 是否转换到随父变换的坐标
// 返回: error 错误信息 // 返回: error 错误信息
+203
View File
@@ -0,0 +1,203 @@
// 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"
"compress/zlib"
"encoding/binary"
"image"
"io"
"github.com/xiaoqidun/jbig2"
)
// pngBinaryProbeLimit 限制黑白检查读取的压缩数据与块头总量
const pngBinaryProbeLimit = 4 << 20
// pngBinaryProbe 按需读取连续的IDAT块,不复制压缩数据
type pngBinaryProbe struct {
data []byte
payload []byte
remaining int
}
// editorBinaryImage 尝试纯黑白无损编码,不二值化,不改变透明度,无体积收益时保留原图
// 入参: data PNG图片数据, img 已解码像素,为nil时读取原图
// 返回: []byte 更小的JBIG2数据,不适用时为nil
func editorBinaryImage(data []byte, img image.Image) []byte {
if img == nil {
if pngNonBinaryImage(data) {
return nil
}
var err error
img, _, err = image.Decode(bytes.NewReader(data))
if err != nil {
return nil
}
}
var output bytes.Buffer
if err := jbig2.Encode(&output, img, &jbig2.Options{MaxPageBytes: uint64(len(data))}); err != nil || output.Len() >= len(data) {
return nil
}
return output.Bytes()
}
// pngNonBinaryImage 检查PNG前部像素是否包含灰阶、彩色或透明度,仅用于排除黑白压缩
// 入参: data PNG图片数据
// 返回: bool 已发现非不透明黑白像素,无法判定时为false
func pngNonBinaryImage(data []byte) bool {
if len(data) < 33 || string(data[:8]) != "\x89PNG\r\n\x1a\n" || binary.BigEndian.Uint32(data[8:]) != 13 || string(data[12:16]) != "IHDR" {
return false
}
header := data[16:29]
width, height := binary.BigEndian.Uint32(header), binary.BigEndian.Uint32(header[4:])
depth, kind := header[8], header[9]
if width == 0 || height == 0 || (depth != 8 && depth != 16) || header[10] != 0 || header[11] != 0 || header[12] != 0 {
return false
}
colors, channels := 0, 0
switch kind {
case 0:
colors, channels = 1, 1
case 2:
colors, channels = 3, 3
case 4:
colors, channels = 1, 2
case 6:
colors, channels = 3, 4
default:
return false
}
colors *= int(depth / 8)
channels *= int(depth / 8)
size := uint64(width) * uint64(channels)
if size > 256<<10 {
return false
}
probe := pngBinaryProbe{data: data[33:], remaining: pngBinaryProbeLimit}
for len(probe.data) >= 12 && string(probe.data[4:8]) != "IDAT" {
length := uint64(binary.BigEndian.Uint32(probe.data)) + 12
if length > uint64(len(probe.data)) || length > uint64(probe.remaining) {
return false
}
probe.data = probe.data[int(length):]
probe.remaining -= int(length)
}
stream, err := zlib.NewReader(&probe)
if err != nil {
return false
}
defer stream.Close()
row := make([]byte, int(size)+1)
previous := make([]byte, int(size))
rows := min(int64(height), 64, (1<<20)/int64(len(row)))
for index := int64(0); index < rows; index++ {
if _, err := io.ReadFull(stream, row); err != nil || !pngBinaryProbeFilter(row[1:], previous, channels, row[0]) {
return false
}
for offset := 1; offset < len(row); offset += channels {
value := row[offset]
if value != 0 && value != 255 {
return true
}
for _, sample := range row[offset : offset+colors] {
if sample != value {
return true
}
}
for _, sample := range row[offset+colors : offset+channels] {
if sample != 255 {
return true
}
}
}
copy(previous, row[1:])
}
return false
}
// Read 读取有限的IDAT数据,边界或长度无效时停止检查
// 入参: output 读取缓冲区
// 返回: int 读取字节数, error 读取错误
func (p *pngBinaryProbe) Read(output []byte) (int, error) {
if len(output) == 0 {
return 0, nil
}
for len(p.payload) == 0 {
if len(p.data) < 12 || p.remaining < 12 || string(p.data[4:8]) != "IDAT" {
return 0, io.EOF
}
length := uint64(binary.BigEndian.Uint32(p.data))
if length > uint64(len(p.data)-12) {
return 0, io.ErrUnexpectedEOF
}
p.payload = p.data[8 : 8+int(length)]
p.data = p.data[12+int(length):]
p.remaining -= 12
}
if p.remaining == 0 {
return 0, io.EOF
}
count := copy(output[:min(len(output), p.remaining)], p.payload)
p.payload = p.payload[count:]
p.remaining -= count
return count, nil
}
// pngBinaryProbeFilter 还原黑白检查所需的扫描行
// 入参: row 当前行, previous 前一行, stride 像素字节数, filter PNG过滤方式
// 返回: bool 过滤方式有效
func pngBinaryProbeFilter(row, previous []byte, stride int, filter byte) bool {
if filter > 4 {
return false
}
for index := range row {
var left, upperLeft byte
if index >= stride {
left, upperLeft = row[index-stride], previous[index-stride]
}
up := previous[index]
switch filter {
case 1:
row[index] += left
case 2:
row[index] += up
case 3:
row[index] += byte((uint16(left) + uint16(up)) / 2)
case 4:
a, b := int(up)-int(upperLeft), int(left)-int(upperLeft)
c := a + b
if a < 0 {
a = -a
}
if b < 0 {
b = -b
}
if c < 0 {
c = -c
}
switch {
case a <= b && a <= c:
row[index] += left
case b <= c:
row[index] += up
default:
row[index] += upperLeft
}
}
}
return true
}
-4
View File
@@ -574,10 +574,6 @@ func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorX
all.ReplaceImage = all.Update && object.Type == "ImageObject" all.ReplaceImage = all.Update && object.Type == "ImageObject"
all.CropImage = all.ReplaceImage all.CropImage = all.ReplaceImage
all.FitImage = all.ReplaceImage && axisAlignedMatrix(NewMatrix(object.ImageObject.CTM)) all.FitImage = all.ReplaceImage && axisAlignedMatrix(NewMatrix(object.ImageObject.CTM))
if object.Type == "ImageObject" && object.ImageObject.Border != nil && len(object.ImageObject.Actions) != 0 {
all.Transform, all.Arrange = false, false
all.Reason, all.ReasonCode = "image actions cannot be transformed together with the border", EditUnsupportedObject
}
if object.Type == "TextObject" { if object.Type == "TextObject" {
all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0 && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0) all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0 && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0)
all.Reflow = all.Reflow && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0) all.Reflow = all.Reflow && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0)
+28 -16
View File
@@ -47,7 +47,7 @@ func (e *Editor) RotateObject(page int, id string, degrees int) error {
// RotateObjects 绕同页选区的可见范围中心旋转 // RotateObjects 绕同页选区的可见范围中心旋转
// 保留相对位置及资源,一次撤销恢复全部 // 保留相对位置及资源,一次撤销恢复全部
// 带边框图片转为保留原内容的复合对象,标识不变 // 必要时将图片转为复合对象,保留原内容及标识
// 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍 // 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) RotateObjects(page int, ids []string, degrees int) error { func (e *Editor) RotateObjects(page int, ids []string, degrees int) error {
@@ -217,7 +217,7 @@ func (obj TextObject) ResizeTextFrame(offset, width float64) (TextObject, error)
// 入参: object 对象, dx 页面横向位移, dy 页面纵向位移, scale 缩放比例 // 入参: object 对象, dx 页面横向位移, dy 页面纵向位移, scale 缩放比例
// 返回: GraphicObject 新对象, error 错误信息 // 返回: GraphicObject 新对象, error 错误信息
func (e *Editor) transformObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) { func (e *Editor) transformObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) {
if object.Type == "ImageObject" && object.ImageObject.Border != nil && scale != 1 { if object.Type == "ImageObject" && scale != 1 && imageTransformNeedsContainer(object.ImageObject, Matrix{a: scale, d: scale}) {
return e.transformMatrix(object, Matrix{a: scale, d: scale, e: dx, f: dy}) return e.transformMatrix(object, Matrix{a: scale, d: scale, e: dx, f: dy})
} }
if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!editorXMLSupported(origin.node) || origin.reason != nil) { if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!editorXMLSupported(origin.node) || origin.reason != nil) {
@@ -309,19 +309,23 @@ func (e *Editor) transformObjects(page int, objects []GraphicObject, transform f
// 入参: object 对象, matrix 页面变换 // 入参: object 对象, matrix 页面变换
// 返回: GraphicObject 变换后的对象, error 错误信息 // 返回: GraphicObject 变换后的对象, error 错误信息
func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicObject, error) { func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicObject, error) {
if object.Type != "ImageObject" || object.ImageObject.Border == nil { if object.Type != "ImageObject" || !imageTransformNeedsContainer(object.ImageObject, matrix) {
return transformEditorMatrix(object, matrix), nil return transformEditorMatrix(object, matrix), nil
} }
origin := e.objectOrigin(object.ImageObject.ID) origin := e.objectOrigin(object.ImageObject.ID)
if origin == nil {
return GraphicObject{}, fmt.Errorf("image source is unavailable")
}
reader, err := e.Reader() reader, err := e.Reader()
if err != nil { if err != nil {
return GraphicObject{}, err return GraphicObject{}, err
} }
defer reader.Close() defer reader.Close()
return e.transformBorderedImage(object, matrix, origin, e.newRenderer(reader)) return e.transformImageResource(object, matrix, origin, e.newRenderer(reader))
}
// imageTransformNeedsContainer 判断图片变换是否需保留边框或隐式坐标中的动作
// 入参: object 图片对象, matrix 所在坐标系中的变换
// 返回: bool 是否需要复合资源
func imageTransformNeedsContainer(object ImageObject, matrix Matrix) bool {
return object.Border != nil || object.CTM == "" && len(object.Actions) != 0 && (matrix.a != 1 || matrix.b != 0 || matrix.c != 0 || matrix.d != 1)
} }
// objectStretchable 允许图片及纯图片资源独立调整宽高,内联组合仍保持比例 // objectStretchable 允许图片及纯图片资源独立调整宽高,内联组合仍保持比例
@@ -339,7 +343,7 @@ func (e *Editor) objectStretchable(object GraphicObject, visiting map[string]boo
// 返回: bool 是否只包含图片 // 返回: bool 是否只包含图片
func (e *Editor) objectImagesOnly(object GraphicObject, visiting map[string]bool) bool { func (e *Editor) objectImagesOnly(object GraphicObject, visiting map[string]bool) bool {
if object.Type == "ImageObject" { if object.Type == "ImageObject" {
return object.ImageObject.Border == nil || len(object.ImageObject.Actions) == 0 return true
} }
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
return false return false
@@ -366,10 +370,10 @@ func (e *Editor) objectImagesOnly(object GraphicObject, visiting map[string]bool
return found return found
} }
// transformBorderedImage 将原文图片封装为复合资源,保留边框和裁剪 // transformImageResource 将图片封装为复合资源,保留边框、裁剪及动作
// 入参: object 图片对象, matrix 变换, origin 原文, renderer 资源度量器 // 入参: object 图片对象, matrix 变换, origin 原文来源,可为空, renderer 资源度量器
// 返回: GraphicObject 变换结果, error 错误信息 // 返回: GraphicObject 变换结果, error 错误信息
func (e *Editor) transformBorderedImage(object GraphicObject, matrix Matrix, origin *editorObjectOrigin, renderer *Renderer) (GraphicObject, error) { func (e *Editor) transformImageResource(object GraphicObject, matrix Matrix, origin *editorObjectOrigin, renderer *Renderer) (GraphicObject, error) {
if err := e.prepareSourceIDs(); err != nil { if err := e.prepareSourceIDs(); err != nil {
return GraphicObject{}, err return GraphicObject{}, err
} }
@@ -385,11 +389,15 @@ func (e *Editor) transformBorderedImage(object GraphicObject, matrix Matrix, ori
child := cloneEditorData(object) child := cloneEditorData(object)
child.ImageObject.ID = e.nextID() child.ImageObject.ID = e.nextID()
child.ImageObject.Boundary = editorBoxString(Box{X: box.X - extent.X, Y: box.Y - extent.Y, W: box.W, H: box.H}) child.ImageObject.Boundary = editorBoxString(Box{X: box.X - extent.X, Y: box.Y - extent.Y, W: box.W, H: box.H})
data, err := editorXMLObject(origin.data, origin.node, origin.object, child) var data []byte
if err != nil { if origin == nil {
return GraphicObject{}, err data, err = editorObjectXML(child)
} } else {
data, err = editorXMLObject(origin.data, origin.node, origin.object, child)
if err == nil {
data, err = editorXMLStandalone(data, origin.node) data, err = editorXMLStandalone(data, origin.node)
}
}
if err != nil { if err != nil {
return GraphicObject{}, err return GraphicObject{}, err
} }
@@ -437,10 +445,14 @@ func transformEditorMatrix(object GraphicObject, matrix Matrix) GraphicObject {
boundary, ctm = &object.CompositeGraphicUnit.Boundary, &object.CompositeGraphicUnit.CTM boundary, ctm = &object.CompositeGraphicUnit.Boundary, &object.CompositeGraphicUnit.CTM
} }
before, _ := ParseBox(*boundary) before, _ := ParseBox(*boundary)
after := matrix.TransformBox(before)
if object.Type == "ImageObject" && *ctm == "" { if object.Type == "ImageObject" && *ctm == "" {
if matrix.a == 1 && matrix.b == 0 && matrix.c == 0 && matrix.d == 1 {
*boundary = editorBoxString(after)
return object
}
*ctm = Matrix{a: before.W, d: before.H}.String() *ctm = Matrix{a: before.W, d: before.H}.String()
} }
after := matrix.TransformBox(before)
local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y)) local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y))
*ctm = local.Multiply(NewMatrix(*ctm)).String() *ctm = local.Multiply(NewMatrix(*ctm)).String()
*boundary = editorBoxString(after) *boundary = editorBoxString(after)
+1 -1
View File
@@ -493,7 +493,7 @@ func (p *pdfImporter) appendRasterGroup(img image.Image, box Box, opacity float6
if err := p.ctx.Err(); err != nil { if err := p.ctx.Err(); err != nil {
return err return err
} }
id, err := p.editor.AddImage(data.Bytes()) id, err := p.editor.addDecodedImage(data.Bytes(), img)
if err != nil { if err != nil {
return err return err
} }
+2 -1
View File
@@ -222,8 +222,9 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
if err != nil { if err != nil {
return err return err
} }
decoded = nil
} }
id, err = p.editor.AddImage(data) id, err = p.editor.addDecodedImage(data, decoded)
if err != nil { if err != nil {
return err return err
} }
+2 -2
View File
@@ -127,7 +127,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
} }
start := buf.Len() start := buf.Len()
p := pdf.New(buf, pages[0].Box.W, pages[0].Box.H, nil) p := pdf.New(buf, pages[0].Box.W, pages[0].Box.H, nil)
renderer := &pdfRenderer{PDF: p, glyphPaths: make(map[*canvas.Path]*canvas.Path), images: make([][]image.Image, 1)} renderer := &pdfRenderer{PDF: p, glyphPaths: make(map[*canvas.Path]*canvas.Path), images: make([][]image.Image, 1), navigation: navigation}
defer func() { defer func() {
if renderer.imageError != nil { if renderer.imageError != nil {
r.canvasState().images = renderCache[*EncodedImage, image.Image]{limit: imageCacheLimit} r.canvasState().images = renderCache[*EncodedImage, image.Image]{limit: imageCacheLimit}
@@ -170,7 +170,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
buf.Truncate(start) buf.Truncate(start)
return err return err
} }
if direct && renderer.exactImages { if direct && (renderer.exactImages || navigation.exactLinks) {
buf.Truncate(start) buf.Truncate(start)
_, err = buf.Write(data) _, err = buf.Write(data)
return err return err
+21 -1
View File
@@ -22,6 +22,7 @@ import (
"github.com/tdewolff/canvas" "github.com/tdewolff/canvas"
cimage "github.com/tdewolff/canvas/image" cimage "github.com/tdewolff/canvas/image"
"github.com/tdewolff/canvas/renderers/pdf" "github.com/tdewolff/canvas/renderers/pdf"
"github.com/xiaoqidun/pdfgo"
) )
// pdfRenderer PDF渲染器 // pdfRenderer PDF渲染器
@@ -31,6 +32,7 @@ type pdfRenderer struct {
images [][]image.Image images [][]image.Image
exactImages bool exactImages bool
imageError error imageError error
navigation *pdfNavigation
} }
// pdfNavigation PDF导航信息 // pdfNavigation PDF导航信息
@@ -39,6 +41,7 @@ type pdfNavigation struct {
Link map[int][]pdfLink Link map[int][]pdfLink
Outline map[int][]pdfOutline Outline map[int][]pdfOutline
nextID int nextID int
exactLinks bool
} }
// pdfAnchor PDF跳转目标 // pdfAnchor PDF跳转目标
@@ -51,6 +54,7 @@ type pdfAnchor struct {
type pdfLink struct { type pdfLink struct {
URI string URI string
Rect canvas.Rect Rect canvas.Rect
Region *pdfgo.LinkRegion
} }
// pdfOutline PDF大纲 // pdfOutline PDF大纲
@@ -149,7 +153,7 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentP
if action.Event != "CLICK" { if action.Event != "CLICK" {
continue continue
} }
box, _, err := renderer.actionLinkRegion(source, action) box, outline, err := renderer.actionLinkRegion(source, action)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -157,7 +161,23 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentP
continue continue
} }
rect := pdfSourceRect(box, page.Box.H) rect := pdfSourceRect(box, page.Box.H)
count := len(navigation.Link[i])
navigation.addAction(i, rect, action, bookmarks, pageIndex, pages) navigation.addAction(i, rect, action, bookmarks, pageIndex, pages)
if len(navigation.Link[i]) == count || outline == "" {
continue
}
region, err := pdfActionRegion(renderer.outputContext(), outline, page.Box.H)
if err != nil {
return nil, err
}
if region != nil && len(region.Quads) == 0 {
navigation.Link[i] = navigation.Link[i][:count]
continue
}
if region != nil {
navigation.Link[i][count].Region = region
navigation.exactLinks = true
}
} }
} }
} }
+27 -4
View File
@@ -23,14 +23,15 @@ import (
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// preserveImages 按绘制顺序恢复颜色及透明度精度,二值透明采用标准Matte预混合 // preserveImages 一次恢复图像精度和链接区域,二值透明采用标准Matte预混合
// 入参: ctx 取消上下文, data 后端PDF数据 // 入参: ctx 取消上下文, data 后端PDF数据
// 返回: []byte 保留图像精度的PDF数据, error 资源或写入错误 // 返回: []byte 保留图像及导航信息的PDF数据, error 资源或写入错误
func (r *pdfRenderer) preserveImages(ctx context.Context, data []byte) ([]byte, error) { func (r *pdfRenderer) preserveImages(ctx context.Context, data []byte) ([]byte, error) {
if r.imageError != nil { if r.imageError != nil {
return nil, r.imageError return nil, r.imageError
} }
if !r.exactImages { exactLinks := r.navigation != nil && r.navigation.exactLinks
if !r.exactImages && !exactLinks {
return data, nil return data, nil
} }
reader, err := pdfgo.NewReader(bytes.NewReader(data), int64(len(data))) reader, err := pdfgo.NewReader(bytes.NewReader(data), int64(len(data)))
@@ -39,12 +40,34 @@ func (r *pdfRenderer) preserveImages(ctx context.Context, data []byte) ([]byte,
} }
defer reader.Close() defer reader.Close()
images := make(map[pdfgo.Reference]image.Image) images := make(map[pdfgo.Reference]image.Image)
links := make(map[pdfgo.AnnotationLocation]pdfgo.LinkRegion)
pages := 0 pages := 0
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error { err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
if index >= len(r.images) { if index >= len(r.images) {
return fmt.Errorf("unexpected PDF output page") return fmt.Errorf("unexpected PDF output page")
} }
pages++ pages++
if exactLinks {
annotations, err := page.AnnotationsContext(ctx)
if err != nil {
return err
}
expected := r.navigation.Link[index]
if len(annotations) != len(expected) {
return fmt.Errorf("PDF output link count differs")
}
for i, link := range expected {
if annotations[i].Subtype != pdfgo.Name("Link") {
return fmt.Errorf("unexpected PDF output annotation")
}
if link.Region != nil {
links[pdfgo.AnnotationLocation{Page: page.Reference, Index: i}] = *link.Region
}
}
}
if !r.exactImages {
return nil
}
value, err := reader.Resolve(page.Resources["XObject"]) value, err := reader.Resolve(page.Resources["XObject"])
if err != nil { if err != nil {
return err return err
@@ -115,7 +138,7 @@ func (r *pdfRenderer) preserveImages(ctx context.Context, data []byte) ([]byte,
images[ref] = pixels images[ref] = pixels
} }
var result bytes.Buffer var result bytes.Buffer
if _, err := reader.ReplaceImagesTo(ctx, &result, images, pdfgo.ImageWriteOptions{PreblendBinary: true}); err != nil { if _, err := reader.RewriteTo(ctx, &result, pdfgo.RewriteOptions{Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links}); err != nil {
return nil, err return nil, err
} }
return result.Bytes(), nil return result.Bytes(), nil
+407
View File
@@ -0,0 +1,407 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"context"
"fmt"
"math"
"slices"
"github.com/tdewolff/canvas"
"github.com/xiaoqidun/pdfgo"
)
// PDF链接区域细分上限与曲线逼近误差,误差单位为毫米
const (
pdfLinkEdgeLimit = 4096
pdfLinkQuadLimit = 65536
pdfLinkTolerance = 0.01
)
// pdfLinkEdge 保存按纵坐标排序的边及原始绕行方向
type pdfLinkEdge struct {
a, b pdfgo.Point
winding int
}
// pdfLinkCross 保存水平扫描线与区域边的交点
type pdfLinkCross struct {
x float64
edge int
}
// pdfActionRegion 将非零绕行区域分解为PDF链接四边形,保留空洞和相交轮廓
// 入参: ctx 取消上下文, outline 页面SVG路径, pageH 页面高度
// 返回: *pdfgo.LinkRegion 链接区域,nil表示沿用矩形, error 解析或复杂度错误
func pdfActionRegion(ctx context.Context, outline string, pageH float64) (*pdfgo.LinkRegion, error) {
if outline == "" {
return nil, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
path, err := canvas.ParseSVGPath(outline)
if err != nil {
return nil, err
}
if path.Len() > pdfLinkEdgeLimit {
return nil, fmt.Errorf("PDF link region exceeds edge limit")
}
if _, ok := geometryRectangleBounds(*geometryFromCanvasPath(path)); ok {
return nil, nil
}
path, err = flattenPDFLink(ctx, path)
if err != nil {
return nil, err
}
if quads := convexPDFLink(path, pageH); len(quads) != 0 {
return pdfLinkRegionBounds(quads), nil
}
var edges []pdfLinkEdge
var levels []float64
var start, previous pdfgo.Point
open := false
add := func(a, b pdfgo.Point) {
if a.Y == b.Y {
return
}
edge := pdfLinkEdge{a: a, b: b, winding: 1}
if a.Y > b.Y {
edge.a, edge.b, edge.winding = b, a, -1
}
edges = append(edges, edge)
levels = append(levels, edge.a.Y, edge.b.Y)
}
scanner := path.Scanner()
for scanner.Scan() {
p := scanner.End()
point := pdfgo.Point{X: p.X * 72 / 25.4, Y: (pageH - p.Y) * 72 / 25.4}
if !finite(point.X) || !finite(point.Y) {
return nil, fmt.Errorf("invalid PDF link coordinate")
}
switch scanner.Cmd() {
case canvas.MoveToCmd:
if open {
add(previous, start)
}
start, open = point, true
case canvas.LineToCmd:
if !open {
start, open = previous, true
}
add(previous, point)
case canvas.CloseCmd:
add(previous, start)
open = false
}
previous = point
}
if open {
add(previous, start)
}
for i, a := range edges {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, b := range edges[i+1:] {
low, high := max(a.a.Y, b.a.Y), min(a.b.Y, b.b.Y)
if low >= high {
continue
}
d0, d1 := a.x(low)-b.x(low), a.x(high)-b.x(high)
if d0 < 0 && d1 > 0 || d0 > 0 && d1 < 0 {
y := low + (high-low)*(d0/(d0-d1))
if !finite(y) {
return nil, fmt.Errorf("invalid PDF link intersection")
}
levels = append(levels, y)
if len(levels) > pdfLinkQuadLimit {
return nil, fmt.Errorf("PDF link region exceeds intersection limit")
}
}
}
}
slices.Sort(levels)
levels = slices.Compact(levels)
region := &pdfgo.LinkRegion{}
crossings := make([]pdfLinkCross, 0, len(edges))
for i := 1; i < len(levels); i++ {
if err := ctx.Err(); err != nil {
return nil, err
}
low, high := levels[i-1], levels[i]
mid := low + (high-low)/2
if mid <= low || mid >= high {
continue
}
crossings = crossings[:0]
for j, edge := range edges {
if edge.a.Y < mid && mid < edge.b.Y {
crossings = append(crossings, pdfLinkCross{edge.x(mid), j})
}
}
slices.SortFunc(crossings, func(a, b pdfLinkCross) int {
if a.x < b.x {
return -1
}
if a.x > b.x {
return 1
}
return a.edge - b.edge
})
winding, left := 0, 0
for _, cross := range crossings {
before := winding
winding += edges[cross.edge].winding
if before == 0 && winding != 0 {
left = cross.edge
}
if before != 0 && winding == 0 {
a, b := edges[left], edges[cross.edge]
quad := [4]pdfgo.Point{{X: a.x(low), Y: low}, {X: b.x(low), Y: low}, {X: b.x(high), Y: high}, {X: a.x(high), Y: high}}
region.Quads = appendPDFLinkQuad(region.Quads, quad)
if len(region.Quads) > pdfLinkQuadLimit {
return nil, fmt.Errorf("PDF link region exceeds quadrilateral limit")
}
}
}
}
if len(region.Quads) == 0 {
return region, nil
}
return pdfLinkRegionBounds(region.Quads), nil
}
// convexPDFLink 直接保留单个凸四边形,三角形只分成三个四边形
// 入参: path 已展开路径, pageH 页面高度
// 返回: [][4]pdfgo.Point 逆时针四边形,不适用时为空
func convexPDFLink(path *canvas.Path, pageH float64) [][4]pdfgo.Point {
if path.HasSubpaths() || path.Len() > 6 {
return nil
}
points := path.Coords()
if len(points) > 1 && points[0] == points[len(points)-1] {
points = points[:len(points)-1]
}
if len(points) < 3 || len(points) > 4 {
return nil
}
var storage [4]pdfgo.Point
vertices := storage[:len(points)]
for i, p := range points {
vertices[i] = pdfgo.Point{X: p.X * 72 / 25.4, Y: (pageH - p.Y) * 72 / 25.4}
}
direction := 0.0
for i, a := range vertices {
b, c := vertices[(i+1)%len(vertices)], vertices[(i+2)%len(vertices)]
cross := pdfLinkTurn(a, b, c)
if !finite(cross) || cross == 0 || direction != 0 && (direction < 0) != (cross < 0) {
return nil
}
direction = cross
}
if direction < 0 {
slices.Reverse(vertices)
}
if len(vertices) == 4 {
return [][4]pdfgo.Point{storage}
}
return appendPDFLinkTriangle(nil, [3]pdfgo.Point{vertices[0], vertices[1], vertices[2]})
}
// pdfLinkRegionBounds 计算四边形共同的注解矩形,保证全部顶点落在矩形内
// 入参: quads 链接四边形
// 返回: *pdfgo.LinkRegion 链接区域
func pdfLinkRegionBounds(quads [][4]pdfgo.Point) *pdfgo.LinkRegion {
region := &pdfgo.LinkRegion{Quads: quads, Rect: pdfgo.Rectangle{XMin: math.Inf(1), YMin: math.Inf(1), XMax: math.Inf(-1), YMax: math.Inf(-1)}}
for _, quad := range quads {
for _, p := range quad {
region.Rect.XMin, region.Rect.YMin = min(region.Rect.XMin, p.X), min(region.Rect.YMin, p.Y)
region.Rect.XMax, region.Rect.YMax = max(region.Rect.XMax, p.X), max(region.Rect.YMax, p.Y)
}
}
return region
}
// flattenPDFLink 在取消和数量限制内展开动作曲线,复用标准椭圆弧转换
// 入参: ctx 取消上下文, path 页面SVG路径
// 返回: *canvas.Path 闭合折线路径, error 精度或复杂度错误
func flattenPDFLink(ctx context.Context, path *canvas.Path) (*canvas.Path, error) {
geometry := geometryFromCanvasPath(path)
result := &canvas.Path{}
var current, start Point
count := 0
var flatten func([]Point, int) error
flatten = func(points []Point, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
first, last := points[0], points[len(points)-1]
dx, dy := last.X-first.X, last.Y-first.Y
length := math.Hypot(dx, dy)
distance := 0.0
for _, point := range points[1 : len(points)-1] {
t := 0.0
if length > 0 {
t = min(max((point.X-first.X)*(dx/length)+(point.Y-first.Y)*(dy/length), 0), length) / length
}
distance = max(distance, math.Hypot(point.X-(first.X*(1-t)+last.X*t), point.Y-(first.Y*(1-t)+last.Y*t)))
}
if !finite(length) || !finite(distance) {
return fmt.Errorf("invalid PDF link curve")
}
if distance <= pdfLinkTolerance/2 {
count++
if count > pdfLinkEdgeLimit {
return fmt.Errorf("PDF link region exceeds edge limit")
}
result.LineTo(last.X, -last.Y)
return nil
}
if depth == 32 {
return fmt.Errorf("PDF link curve tolerance cannot be met")
}
var work, left, right [4]Point
copy(work[:], points)
for n := len(points); n > 0; n-- {
left[len(points)-n], right[n-1] = work[0], work[n-1]
for i := 0; i < n-1; i++ {
work[i] = geometryLerp(work[i], work[i+1], .5)
}
}
if err := flatten(left[:len(points)], depth+1); err != nil {
return err
}
return flatten(right[:len(points)], depth+1)
}
for _, segment := range *geometry {
if err := ctx.Err(); err != nil {
return nil, err
}
switch segment.Verb {
case GeometryMove:
result.MoveTo(segment.End.X, -segment.End.Y)
current, start = segment.End, segment.End
case GeometryClose:
result.Close()
current = start
default:
curves := GeometryPath{segment}
if segment.Verb == GeometryArc {
var err error
curves, err = arcCurves(current, segment, pdfLinkTolerance/2)
if err != nil {
return nil, err
}
if len(curves) > pdfLinkEdgeLimit-count {
return nil, fmt.Errorf("PDF link region exceeds edge limit")
}
}
for _, curve := range curves {
var storage [4]Point
points := append(storage[:0], current)
if curve.Verb == GeometryQuad || curve.Verb == GeometryCubic {
points = append(points, curve.Control1)
}
if curve.Verb == GeometryCubic {
points = append(points, curve.Control2)
}
points = append(points, curve.End)
if err := flatten(points, 0); err != nil {
return nil, err
}
current = curve.End
}
}
}
return result, nil
}
// x 计算非水平边在指定纵坐标处的横坐标
// 入参: y 扫描线纵坐标
// 返回: float64 交点横坐标
func (e pdfLinkEdge) x(y float64) float64 {
t := (y - e.a.Y) / (e.b.Y - e.a.Y)
return e.a.X*(1-t) + e.b.X*t
}
// appendPDFLinkQuad 收集梯形,尖端退化为三角形时细分为三个非退化四边形
// 入参: quads 已有四边形, quad 扫描带梯形
// 返回: [][4]pdfgo.Point 四边形集合
func appendPDFLinkQuad(quads [][4]pdfgo.Point, quad [4]pdfgo.Point) [][4]pdfgo.Point {
epsilon := 1e-12
for _, point := range quad {
epsilon = max(epsilon, math.Abs(point.X)*1e-12, math.Abs(point.Y)*1e-12)
}
if quad[2].Y-quad[0].Y <= epsilon {
return quads
}
if quad[1].X-quad[0].X <= epsilon {
quad[0].X, quad[1].X = (quad[0].X+quad[1].X)/2, (quad[0].X+quad[1].X)/2
}
if quad[2].X-quad[3].X <= epsilon {
quad[3].X, quad[2].X = (quad[3].X+quad[2].X)/2, (quad[3].X+quad[2].X)/2
}
var triangle [3]pdfgo.Point
if quad[0] == quad[1] {
if quad[2] == quad[3] {
return quads
}
triangle = [3]pdfgo.Point{quad[0], quad[2], quad[3]}
} else if quad[2] == quad[3] {
triangle = [3]pdfgo.Point{quad[0], quad[1], quad[2]}
} else {
return appendValidPDFLinkQuad(quads, quad)
}
return appendPDFLinkTriangle(quads, triangle)
}
// appendPDFLinkTriangle 连接三角形重心和边中点,生成覆盖相同区域的凸四边形
// 入参: quads 已有四边形, triangle 逆时针三角形
// 返回: [][4]pdfgo.Point 四边形集合
func appendPDFLinkTriangle(quads [][4]pdfgo.Point, triangle [3]pdfgo.Point) [][4]pdfgo.Point {
center := pdfgo.Point{X: (triangle[0].X + triangle[1].X + triangle[2].X) / 3, Y: (triangle[0].Y + triangle[1].Y + triangle[2].Y) / 3}
for i, a := range triangle {
b, c := triangle[(i+1)%3], triangle[(i+2)%3]
quads = appendValidPDFLinkQuad(quads, [4]pdfgo.Point{a, {X: (a.X + b.X) / 2, Y: (a.Y + b.Y) / 2}, center, {X: (a.X + c.X) / 2, Y: (a.Y + c.Y) / 2}})
}
return quads
}
// pdfLinkTurn 计算相邻边的转向,消除近共线乘积相减产生的舍入噪声
// 入参: a 前顶点, b 当前顶点, c 后顶点
// 返回: float64 有向面积的两倍,数值退化时为零
func pdfLinkTurn(a, b, c pdfgo.Point) float64 {
u, v := (b.X-a.X)*(c.Y-b.Y), (b.Y-a.Y)*(c.X-b.X)
cross := u - v
if math.Abs(cross) <= (math.Abs(u)+math.Abs(v))*1e-12 {
return 0
}
return cross
}
// appendValidPDFLinkQuad 仅保留有限且非退化的逆时针凸四边形
// 入参: quads 已有四边形, quad 候选四边形
// 返回: [][4]pdfgo.Point 四边形集合
func appendValidPDFLinkQuad(quads [][4]pdfgo.Point, quad [4]pdfgo.Point) [][4]pdfgo.Point {
for i, a := range quad {
cross := pdfLinkTurn(a, quad[(i+1)%4], quad[(i+2)%4])
if !finite(cross) || cross <= 0 {
return quads
}
}
return append(quads, quad)
}