mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
1 parent
9856dd3bd5
commit
31ba6752cf
14 files changed
+796
-57
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 错误信息
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 错误信息
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user