mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
1 parent
b3c4bbc844
commit
961bc52038
43 files changed
+2283
-2594
No files matched your search
@@ -20,6 +20,7 @@ import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
|
||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
@@ -65,3 +66,52 @@ func ClipPathKey(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix) ([32
|
||||
hash.Sum(key[:0])
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ClipContainsBoundary 判断单一矩形裁剪是否完整覆盖对象边界,不近似曲线或斜边
|
||||
// 入参: path PDF裁剪路径, matrix 页面变换, boundary OFD对象边界
|
||||
// 返回: bool 是否可由对象边界代替裁剪
|
||||
func ClipContainsBoundary(path pdfgo.Path, matrix pdfgo.Matrix, boundary basegeom.Box) bool {
|
||||
if len(path.Text) != 0 || len(path.Segments) < 4 || len(path.Segments) > 6 || !finite(boundary.X) || !finite(boundary.Y) || !finite(boundary.W) || !finite(boundary.H) || boundary.W <= 0 || boundary.H <= 0 {
|
||||
return false
|
||||
}
|
||||
var points [4]pdfgo.Point
|
||||
for index := range 4 {
|
||||
segment := path.Segments[index]
|
||||
operator := "L"
|
||||
if index == 0 {
|
||||
operator = "M"
|
||||
}
|
||||
if segment.Operator != operator || len(segment.Points) != 1 {
|
||||
return false
|
||||
}
|
||||
points[index] = matrix.Apply(segment.Points[0])
|
||||
if !finite(points[index].X) || !finite(points[index].Y) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end := 4
|
||||
if end < len(path.Segments) && path.Segments[end].Operator == "L" {
|
||||
segment := path.Segments[end]
|
||||
if len(segment.Points) != 1 || matrix.Apply(segment.Points[0]) != points[0] {
|
||||
return false
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end < len(path.Segments) {
|
||||
segment := path.Segments[end]
|
||||
if segment.Operator != "C" || len(segment.Points) != 0 {
|
||||
return false
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end != len(path.Segments) {
|
||||
return false
|
||||
}
|
||||
a, b, c, d := points[0], points[1], points[2], points[3]
|
||||
if !(a.Y == b.Y && b.X == c.X && c.Y == d.Y && d.X == a.X || a.X == b.X && b.Y == c.Y && c.X == d.X && d.Y == a.Y) {
|
||||
return false
|
||||
}
|
||||
left, right := math.Min(a.X, c.X), math.Max(a.X, c.X)
|
||||
top, bottom := math.Min(a.Y, c.Y), math.Max(a.Y, c.Y)
|
||||
return left < right && top < bottom && boundary.X >= left && boundary.Y >= top && boundary.X+boundary.W <= right && boundary.Y+boundary.H <= bottom
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
@@ -35,3 +37,113 @@ func OutputColor(space *pdfgo.ColorSpace, values [4]float64) ([3]float64, error)
|
||||
func NormalBlend(mode pdfgo.Name) bool {
|
||||
return mode == "" || mode == "Normal" || mode == "Compatible"
|
||||
}
|
||||
|
||||
// NativePaint 使用库层色料定义求值设备四色,不以备用色代替原生浓度
|
||||
// 入参: paint 源画刷, space 设备四色空间, intent 渲染意图
|
||||
// 返回: *pdfgo.Paint 原生画刷或nil, [4]bool 显式通道, error 色料定义错误
|
||||
func NativePaint(paint pdfgo.Paint, space *pdfgo.ColorSpace, intent pdfgo.Name) (*pdfgo.Paint, [4]bool, error) {
|
||||
if paint.Colorant == nil {
|
||||
return nil, [4]bool{}, nil
|
||||
}
|
||||
result, err := paint.Colorant.Space.ResolveInGroup(paint.Colorant.Tints, &pdfgo.ColorantDevice{Space: space}, space, intent, false)
|
||||
if err != nil || len(result.Alternates) != 0 {
|
||||
return nil, [4]bool{}, err
|
||||
}
|
||||
return result.Process, result.ProcessMask, nil
|
||||
}
|
||||
|
||||
// CompositeColor 将原始画刷变换到混合空间,渐变在源空间求值
|
||||
// 入参: paint 画刷, point 页面坐标, space 混合空间, intent 渲染意图, conversion 设备转换函数
|
||||
// 返回: [4]float64 混合分量, bool 是否着色, error 颜色错误
|
||||
func CompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorSpace, intent pdfgo.Name, conversion pdfgo.ColorConversion) ([4]float64, bool, error) {
|
||||
if paint.Shading != nil {
|
||||
return [4]float64{}, false, &pdfgo.UnsupportedError{Feature: "local shading pattern compositing"}
|
||||
}
|
||||
if paint.Tiling != nil {
|
||||
return [4]float64{}, false, &pdfgo.UnsupportedError{Feature: "local tiling pattern compositing"}
|
||||
}
|
||||
var values [4]float64
|
||||
var source *pdfgo.ColorSpace
|
||||
if paint.Function != nil {
|
||||
g := paint.Function
|
||||
if g.Bounds != nil {
|
||||
inverse, ok := g.PatternMatrix.Inverse()
|
||||
if !ok {
|
||||
return values, false, fmt.Errorf("singular function shading bounds transform")
|
||||
}
|
||||
local := inverse.Apply(point)
|
||||
if local.X < g.Bounds.XMin || local.X > g.Bounds.XMax || local.Y < g.Bounds.YMin || local.Y > g.Bounds.YMax {
|
||||
return values, false, nil
|
||||
}
|
||||
}
|
||||
inverse, ok := g.Matrix.Inverse()
|
||||
if !ok {
|
||||
return values, false, fmt.Errorf("singular function shading transform")
|
||||
}
|
||||
local := inverse.Apply(point)
|
||||
source, intent = g.Space, g.Intent
|
||||
if local.X < g.Domain.XMin || local.X > g.Domain.XMax || local.Y < g.Domain.YMin || local.Y > g.Domain.YMax {
|
||||
if g.Background == nil {
|
||||
return values, false, nil
|
||||
}
|
||||
values = *g.Background
|
||||
} else {
|
||||
var err error
|
||||
values, err = g.ValuesAt(local)
|
||||
if err != nil {
|
||||
return values, false, err
|
||||
}
|
||||
}
|
||||
} else if paint.Axial != nil || paint.Radial != nil {
|
||||
var background *[4]float64
|
||||
var bounds *pdfgo.Rectangle
|
||||
var matrix pdfgo.Matrix
|
||||
if g := paint.Axial; g != nil {
|
||||
source, intent = g.Space, g.Intent
|
||||
background, bounds, matrix = g.Background, g.Bounds, g.Matrix
|
||||
} else {
|
||||
g := paint.Radial
|
||||
source, intent = g.Space, g.Intent
|
||||
background, bounds, matrix = g.Background, g.Bounds, g.Matrix
|
||||
}
|
||||
if bounds != nil {
|
||||
local := point
|
||||
if matrix != (pdfgo.Matrix{}) {
|
||||
inverse, ok := matrix.Inverse()
|
||||
if !ok {
|
||||
return values, false, fmt.Errorf("singular shading transform")
|
||||
}
|
||||
local = inverse.Apply(point)
|
||||
}
|
||||
if local.X < bounds.XMin || local.X > bounds.XMax || local.Y < bounds.YMin || local.Y > bounds.YMax {
|
||||
return values, false, nil
|
||||
}
|
||||
}
|
||||
t, visible := ShadingPosition(paint, point)
|
||||
var err error
|
||||
if !visible {
|
||||
if background == nil {
|
||||
return values, false, nil
|
||||
}
|
||||
values = *background
|
||||
} else if paint.Axial != nil {
|
||||
values, err = paint.Axial.ValuesAt(t)
|
||||
} else {
|
||||
values, err = paint.Radial.ValuesAt(t)
|
||||
}
|
||||
if err != nil {
|
||||
return values, false, err
|
||||
}
|
||||
} else if paint.Space != nil {
|
||||
values, source = paint.Values, paint.Space
|
||||
} else if paint.CMYK != nil {
|
||||
values, source = *paint.CMYK, &pdfgo.ColorSpace{Model: "DeviceCMYK"}
|
||||
} else if paint.SourceSpace == "DeviceGray" {
|
||||
values[0], source = paint.RGB[0], &pdfgo.ColorSpace{Model: "DeviceGray"}
|
||||
} else {
|
||||
copy(values[:], paint.RGB[:])
|
||||
source = &pdfgo.ColorSpace{Model: "DeviceRGB"}
|
||||
}
|
||||
values, err := space.ConvertWith(values[:source.Components()], source, intent, conversion)
|
||||
return values, true, err
|
||||
}
|
||||
@@ -93,3 +93,17 @@ func (s *NativeImage) NRGBA64At(x, y int) color.NRGBA64 {
|
||||
rgb, _ := OutputColor(s.Space, values)
|
||||
return color.NRGBA64{R: uint16(math.Round(rgb[0] * 65535)), G: uint16(math.Round(rgb[1] * 65535)), B: uint16(math.Round(rgb[2] * 65535)), A: uint16(math.Round(alpha * 65535))}
|
||||
}
|
||||
|
||||
// ImageProcessColorants 识别可在四色设备上直接绘制的专色色料
|
||||
// 入参: image PDF图像
|
||||
// 返回: [4]bool 参与绘制的四色通道, bool 是否全部属于印刷原色, error 色空间错误
|
||||
func ImageProcessColorants(image *pdfgo.Image) ([4]bool, bool, error) {
|
||||
source, err := image.ColorantSpace()
|
||||
if err != nil {
|
||||
return [4]bool{}, false, err
|
||||
}
|
||||
space := &pdfgo.ColorSpace{Model: "DeviceCMYK"}
|
||||
process := source.PrepareProcess(&pdfgo.ColorantDevice{Space: space}, space, false)
|
||||
mask := process.Mask()
|
||||
return mask, mask != ([4]bool{}), nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// ReferenceCache 保存单次会话拥有的引用文档及页面,零值可用,须串行使用且使用后不应复制
|
||||
// Close只释放缓存自行打开的阅读器,不关闭来源文档或调用方提供的阅读器
|
||||
type ReferenceCache struct {
|
||||
readers map[[32]byte]*pdfgo.Reader
|
||||
streams map[*pdfgo.Stream]*pdfgo.Reader
|
||||
pages map[referencePageKey]*pdfgo.Page
|
||||
}
|
||||
|
||||
// referencePageKey 区分目标文档、索引和文本标签,避免重复遍历页面树
|
||||
type referencePageKey struct {
|
||||
reader *pdfgo.Reader
|
||||
index int64
|
||||
label string
|
||||
labeled bool
|
||||
}
|
||||
|
||||
// Resolve 解析引用目标,目标不可用时按PDF标准保留代理内容
|
||||
// 入参: ctx 取消上下文, source 来源文档, reference 引用目标, fileResolver 外部文件读取, referenceResolver 调用方页面解析, warning 诊断回调
|
||||
// 返回: *pdfgo.Page 目标页面,不可用时为空, error 取消或调用方解析错误
|
||||
func (c *ReferenceCache) Resolve(ctx context.Context, source *pdfgo.Reader, reference pdfgo.ReferenceXObject, fileResolver pdfgo.FileResolver, referenceResolver pdfgo.ReferenceResolver, warning func(pdfgo.Diagnostic)) (*pdfgo.Page, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if referenceResolver != nil {
|
||||
return referenceResolver(ctx, source, reference)
|
||||
}
|
||||
reader := c.streams[reference.File.Embedded]
|
||||
if reader == nil {
|
||||
if reference.File.Embedded == nil && fileResolver == nil {
|
||||
return referenceProxy(ctx, fmt.Errorf("external reference file unavailable"), warning)
|
||||
}
|
||||
data, err := source.ReadFileData(ctx, reference.File, fileResolver)
|
||||
if err != nil {
|
||||
return referenceProxy(ctx, err, warning)
|
||||
}
|
||||
key := sha256.Sum256(data)
|
||||
reader = c.readers[key]
|
||||
if reader == nil {
|
||||
reader, err = pdfgo.NewReaderWithOptions(bytes.NewReader(data), int64(len(data)), pdfgo.ReaderOptions{Warning: warning})
|
||||
if err != nil {
|
||||
return referenceProxy(ctx, err, warning)
|
||||
}
|
||||
if c.readers == nil {
|
||||
c.readers = make(map[[32]byte]*pdfgo.Reader)
|
||||
}
|
||||
c.readers[key] = reader
|
||||
}
|
||||
if reference.File.Embedded != nil {
|
||||
if c.streams == nil {
|
||||
c.streams = make(map[*pdfgo.Stream]*pdfgo.Reader)
|
||||
}
|
||||
c.streams[reference.File.Embedded] = reader
|
||||
}
|
||||
}
|
||||
key := referencePageKey{reader: reader, index: reference.PageIndex}
|
||||
if reference.PageLabel != nil {
|
||||
key.index, key.label, key.labeled = 0, *reference.PageLabel, true
|
||||
}
|
||||
if page := c.pages[key]; page != nil {
|
||||
return page, ctx.Err()
|
||||
}
|
||||
page, err := reader.ResolveReferencePage(ctx, reference)
|
||||
if err != nil {
|
||||
return referenceProxy(ctx, err, warning)
|
||||
}
|
||||
if c.pages == nil {
|
||||
c.pages = make(map[referencePageKey]*pdfgo.Page)
|
||||
}
|
||||
c.pages[key] = page
|
||||
return page, ctx.Err()
|
||||
}
|
||||
|
||||
// referenceProxy 记录目标不可用原因,不将合法的代理绘制视为转换失败
|
||||
// 入参: ctx 取消上下文, cause 目标不可用原因, warning 诊断回调
|
||||
// 返回: *pdfgo.Page 空目标, error 取消错误
|
||||
func referenceProxy(ctx context.Context, cause error, warning func(pdfgo.Diagnostic)) (*pdfgo.Page, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||
return nil, cause
|
||||
}
|
||||
if warning != nil {
|
||||
warning(pdfgo.Diagnostic{Message: "reference XObject proxy retained: " + cause.Error()})
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Close 释放本次会话打开的引用文档并清空缓存,可重复调用,之后可重新解析
|
||||
func (c *ReferenceCache) Close() {
|
||||
for _, reader := range c.readers {
|
||||
reader.Close()
|
||||
}
|
||||
c.readers, c.streams, c.pages = nil, nil, nil
|
||||
}
|
||||
@@ -48,11 +48,11 @@ func ShadingColorantSpace(paint pdfgo.Paint) *pdfgo.ColorantSpace {
|
||||
// 返回: *ShadingColorants 独立采样状态,不可原生映射时为nil
|
||||
func PrepareShadingColorants(paint pdfgo.Paint, space *pdfgo.ColorSpace, softMask bool) *ShadingColorants {
|
||||
colorants := ShadingColorantSpace(paint)
|
||||
Process := colorants.PrepareProcess(&pdfgo.ColorantDevice{Space: space}, space, softMask)
|
||||
if Process == nil || Process.Mask() == ([4]bool{}) {
|
||||
process := colorants.PrepareProcess(&pdfgo.ColorantDevice{Space: space}, space, softMask)
|
||||
if process == nil || process.Mask() == ([4]bool{}) {
|
||||
return nil
|
||||
}
|
||||
s := &ShadingColorants{paint: paint, Process: Process, tints: make([]float64, len(colorants.Names))}
|
||||
s := &ShadingColorants{paint: paint, Process: process, tints: make([]float64, len(colorants.Names))}
|
||||
if paint.Mesh != nil {
|
||||
s.mesh = paint.Mesh.NewSampler()
|
||||
}
|
||||
@@ -94,3 +94,20 @@ func (s *ShadingColorants) MeshAt(patch int, u, v float64) ([4]float64, error) {
|
||||
}
|
||||
return s.Process.Convert(s.tints)
|
||||
}
|
||||
|
||||
// ShadingPosition 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色
|
||||
// 入参: paint 渐变画刷, point 页面坐标
|
||||
// 返回: float64 归一化参数, bool 是否着色
|
||||
func ShadingPosition(paint pdfgo.Paint, point pdfgo.Point) (float64, bool) {
|
||||
var geometry pdfgo.GradientPosition
|
||||
var err error
|
||||
if g := paint.Axial; g != nil {
|
||||
geometry, err = g.PreparePosition()
|
||||
} else {
|
||||
geometry, err = paint.Radial.PreparePosition()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return geometry.PositionAt(point)
|
||||
}
|
||||
+11
-576
@@ -18,9 +18,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
@@ -64,12 +61,6 @@ type PDFImportReport struct {
|
||||
Warnings []pdfgo.Diagnostic
|
||||
}
|
||||
|
||||
// pdfPointBounds 逐点汇总边界,不保留文字或路径的临时坐标数组
|
||||
type pdfPointBounds struct {
|
||||
minX, minY, maxX, maxY float64
|
||||
found bool
|
||||
}
|
||||
|
||||
// pdfImporter 保存单次转换的对象与资源状态
|
||||
type pdfImporter struct {
|
||||
ctx context.Context
|
||||
@@ -119,9 +110,7 @@ type pdfImporter struct {
|
||||
annotationData []byte
|
||||
resolveFile pdfgo.FileResolver
|
||||
resolveReference pdfgo.ReferenceResolver
|
||||
referenceReaders map[[32]byte]*pdfgo.Reader
|
||||
referenceStreams map[*pdfgo.Stream]*pdfgo.Reader
|
||||
referencePages map[pdfReferencePageKey]*pdfgo.Page
|
||||
references *codecpdf.ReferenceCache
|
||||
halftoneWarnings map[*pdfgo.Halftone]bool
|
||||
halftones map[string]*pdfgo.Halftone
|
||||
transferBackdrop bool
|
||||
@@ -181,10 +170,8 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
|
||||
importer.resolveReference = options.ResolveReference
|
||||
importer.halftones = options.Halftones
|
||||
importer.compression = options.Compression
|
||||
importer.referenceReaders = make(map[[32]byte]*pdfgo.Reader)
|
||||
importer.referenceStreams = make(map[*pdfgo.Stream]*pdfgo.Reader)
|
||||
importer.referencePages = make(map[pdfReferencePageKey]*pdfgo.Page)
|
||||
defer importer.closeReferenceReaders()
|
||||
importer.references = &codecpdf.ReferenceCache{}
|
||||
defer importer.references.Close()
|
||||
importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
||||
if security := reader.Encryption(); security != nil && !security.Owner && security.Permissions&0xf3c != 0xf3c {
|
||||
if options.Strict {
|
||||
@@ -325,564 +312,12 @@ func ConvertPDF(ctx context.Context, source io.ReaderAt, size int64, output io.W
|
||||
return report, err
|
||||
}
|
||||
|
||||
// commitObjects 将当前页面待转换对象写入编辑文档
|
||||
// 返回: error 对象写入错误
|
||||
func (p *pdfImporter) commitObjects() error {
|
||||
if p.objects.len() == 0 {
|
||||
return nil
|
||||
}
|
||||
objects, err := p.objects.data(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
objects, err = p.sharedClipObjects(objects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil {
|
||||
return err
|
||||
}
|
||||
p.objects = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// pdfNumbers 按OFD数值格式序列化坐标
|
||||
// 入参: values 数值列表
|
||||
// 返回: string 空格分隔的数值
|
||||
func pdfNumbers(values ...float64) string {
|
||||
var buffer [128]byte
|
||||
data := buffer[:0]
|
||||
for n, v := range values {
|
||||
if n != 0 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = strconv.AppendFloat(data, v, 'g', -1, 64)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// pdfDashPattern 换算PDF虚线长度,将单值数组展开为等长线段与间隔
|
||||
// 入参: dash PDF虚线数组, scale 坐标缩放比例
|
||||
// 返回: string OFD虚线数组
|
||||
func pdfDashPattern(dash []float64, scale float64) string {
|
||||
if len(dash) == 0 {
|
||||
return ""
|
||||
}
|
||||
values := make([]float64, max(2, len(dash)))
|
||||
for i, value := range dash {
|
||||
values[i] = value * scale
|
||||
}
|
||||
if len(dash) == 1 {
|
||||
values[1] = values[0]
|
||||
}
|
||||
return pdfNumbers(values...)
|
||||
}
|
||||
|
||||
// pdfBoundary 序列化对象边界
|
||||
// 入参: b 对象边界
|
||||
// 返回: string OFD边界数据
|
||||
func pdfBoundary(b Box) string { return pdfNumbers(b.X, b.Y, b.W, b.H) }
|
||||
|
||||
// pdfBounds 计算点集合的包围框
|
||||
// 入参: points 坐标点集合
|
||||
// 返回: Box 包围框
|
||||
func pdfBounds(points []pdfgo.Point) Box {
|
||||
var bounds pdfPointBounds
|
||||
for _, point := range points {
|
||||
bounds.add(point)
|
||||
}
|
||||
return bounds.box()
|
||||
}
|
||||
|
||||
// add 按原顺序累计一个控制点的最小和最大坐标
|
||||
// 入参: point 坐标点
|
||||
func (b *pdfPointBounds) add(point pdfgo.Point) {
|
||||
if !b.found {
|
||||
b.minX, b.minY, b.maxX, b.maxY = point.X, point.Y, point.X, point.Y
|
||||
b.found = true
|
||||
}
|
||||
b.minX, b.minY = math.Min(b.minX, point.X), math.Min(b.minY, point.Y)
|
||||
b.maxX, b.maxY = math.Max(b.maxX, point.X), math.Max(b.maxY, point.Y)
|
||||
}
|
||||
|
||||
// box 取得原精度边界,空集合仍返回零值
|
||||
// 返回: Box 坐标范围
|
||||
func (b *pdfPointBounds) box() Box {
|
||||
if !b.found {
|
||||
return Box{}
|
||||
}
|
||||
return Box{X: b.minX, Y: b.minY, W: math.Max(b.maxX-b.minX, 1e-6), H: math.Max(b.maxY-b.minY, 1e-6)}
|
||||
}
|
||||
|
||||
// pathBoundsContext 逐段计算变换后的控制点边界,不创建临时点集合
|
||||
// 入参: ctx 取消上下文, path PDF路径
|
||||
// 返回: Box 控制点边界,空路径返回零值, error 取消错误
|
||||
func (p *pdfImporter) pathBoundsContext(ctx context.Context, path pdfgo.Path) (Box, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
found := false
|
||||
for _, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
for index, point := range segment.Points {
|
||||
if index&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
}
|
||||
point = p.matrix.Apply(point)
|
||||
minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y)
|
||||
maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if !found {
|
||||
return Box{}, nil
|
||||
}
|
||||
return Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil
|
||||
}
|
||||
|
||||
// color 保留设备色彩分量并复用文档颜色空间
|
||||
// 入参: paint PDF颜色与不透明度
|
||||
// 返回: *FillColor OFD颜色
|
||||
func (p *pdfImporter) color(paint pdfgo.Paint) *FillColor {
|
||||
alpha := int(math.Round(paint.Alpha * 255))
|
||||
if paint.CMYK != nil && (paint.Space == nil || !paint.Space.Calibrated()) {
|
||||
if p.cmykSpace == "" {
|
||||
p.cmykSpace = p.editor.nextID()
|
||||
p.editor.resources = append(p.editor.resources, editorResource{space: &ColorSpace{ID: p.cmykSpace, Type: "CMYK", BitsPerComponent: 16}})
|
||||
}
|
||||
v := paint.CMYK
|
||||
return &FillColor{ColorSpace: p.cmykSpace, Value: pdfNumbers(math.Round(v[0]*65535), math.Round(v[1]*65535), math.Round(v[2]*65535), math.Round(v[3]*65535)), Alpha: &alpha}
|
||||
}
|
||||
return &FillColor{Value: pdfNumbers(math.Round(paint.RGB[0]*255), math.Round(paint.RGB[1]*255), math.Round(paint.RGB[2]*255)), Alpha: &alpha}
|
||||
}
|
||||
|
||||
// pathDataContext 转换路径并在分段处理时检查取消
|
||||
// 入参: ctx 取消上下文, path PDF路径, origin 对象边界
|
||||
// 返回: string OFD路径数据, error 取消错误
|
||||
func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, origin Box) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := make([]byte, 0, len(path.Segments)*48)
|
||||
for index, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if index != 0 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = append(data, segment.Operator...)
|
||||
for _, point := range segment.Points {
|
||||
point = p.matrix.Apply(point)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// clips 省略覆盖对象边界的矩形裁剪,复用其余几何并保留位移及路径交集
|
||||
// 入参: paths 裁剪路径, origin 对象边界
|
||||
// 返回: *Clips OFD裁剪区域,无裁剪时为空, error 未解析的裁剪字形
|
||||
func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) {
|
||||
if len(paths) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var clips *Clips
|
||||
var areas []ClipArea
|
||||
translation := ""
|
||||
for _, path := range paths {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pdfClipContainsBoundary(path, p.matrix, origin) {
|
||||
continue
|
||||
}
|
||||
if clips == nil {
|
||||
clips = &Clips{Clip: make([]Clip, 0, len(paths))}
|
||||
areas = make([]ClipArea, len(paths))
|
||||
if origin.X != 0 || origin.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y)
|
||||
}
|
||||
}
|
||||
index := len(clips.Clip)
|
||||
area := &areas[index]
|
||||
area.CTM = translation
|
||||
if len(path.Segments) != 0 {
|
||||
encoded, err := p.clipPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
area.Path = encoded.path[:1:1]
|
||||
}
|
||||
for _, mark := range path.Text {
|
||||
objects, ok := p.clipTexts[mark]
|
||||
if !ok {
|
||||
if err := p.flushPath(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := p.objects.len()
|
||||
textCount := p.report.TextObjects
|
||||
if err := p.text(pdfgo.TextMark{Font: mark.Font, Glyphs: mark.Glyphs, Positions: mark.Positions, Matrix: mark.Matrix, Size: mark.Size, HorizontalScale: mark.HorizontalScale, Mode: 7, Clip: mark, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.objects.truncate(count)
|
||||
p.report.TextObjects = textCount
|
||||
objects = p.clipTexts[mark]
|
||||
}
|
||||
if len(path.Text) == 1 {
|
||||
area.Text = objects[:len(objects):len(objects)]
|
||||
} else {
|
||||
area.Text = append(area.Text, objects...)
|
||||
}
|
||||
}
|
||||
if len(area.Path)+len(area.Text) == 0 {
|
||||
fill, stroke := true, false
|
||||
area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}}
|
||||
}
|
||||
clips.Clip = append(clips.Clip, Clip{Area: areas[index : index+1 : index+1]})
|
||||
}
|
||||
return clips, nil
|
||||
}
|
||||
|
||||
// path 合并相同状态下的不透明描边,保留独立子路径与绘制顺序
|
||||
// 入参: mark PDF路径绘制信息
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) path(mark pdfgo.PathMark) error {
|
||||
if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" {
|
||||
return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)}
|
||||
}
|
||||
if !mark.Stroke || mark.Fill || mark.Style.Stroke.Alpha != 1 {
|
||||
if err := p.flushPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appendPath(mark)
|
||||
}
|
||||
if p.pendingPath != nil && (p.pendingPath.StrokeMatrix != mark.StrokeMatrix || !reflect.DeepEqual(p.pendingPath.Style, mark.Style)) {
|
||||
if err := p.flushPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.pendingPath == nil {
|
||||
p.pendingPath = &mark
|
||||
} else {
|
||||
p.pendingPath.Path.Segments = append(p.pendingPath.Path.Segments, mark.Path.Segments...)
|
||||
}
|
||||
if len(p.pendingPath.Path.Segments) >= 512 {
|
||||
return p.flushPath()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushPath 写入连续描边批次,保持后续图元的叠放位置
|
||||
// 返回: error 转换错误
|
||||
func (p *pdfImporter) flushPath() error {
|
||||
if p.pendingPath == nil {
|
||||
return nil
|
||||
}
|
||||
mark := *p.pendingPath
|
||||
p.pendingPath = nil
|
||||
return p.appendPath(mark)
|
||||
}
|
||||
|
||||
// appendPath 添加独立可编辑路径
|
||||
// 入参: mark PDF路径绘制信息
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
||||
var err error
|
||||
mark.Style, err = p.maskStyle(mark.Style)
|
||||
if err != nil {
|
||||
if p.rasterMaskAllowed(err) {
|
||||
mask := mark.Style.SoftMask
|
||||
mark.Style.SoftMask = nil
|
||||
return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Path(mark) })
|
||||
}
|
||||
return err
|
||||
}
|
||||
if mark.Fill && mark.Style.FillOverprint && pdfOverprintNeedsSeparation(mark.Style.Fill) || mark.Stroke && mark.Style.StrokeOverprint && pdfOverprintNeedsSeparation(mark.Style.Stroke) {
|
||||
return &pdfgo.UnsupportedError{Feature: "color separation overprint"}
|
||||
}
|
||||
if p.warning != nil && (mark.Fill && p.gradientError(mark.Style.Fill) != nil || mark.Stroke && p.gradientError(mark.Style.Stroke) != nil) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.gradientPath(mark)
|
||||
}
|
||||
box, err := p.pathBoundsContext(p.ctx, mark.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if box == (Box{}) {
|
||||
return fmt.Errorf("empty PDF painted path")
|
||||
}
|
||||
scale := math.Hypot(p.matrix[0], p.matrix[1])
|
||||
drawing, origin, ctm := *p, box, ""
|
||||
marginScale := scale
|
||||
if mark.StrokeMatrix != (pdfgo.Matrix{}) {
|
||||
inverse, ok := mark.StrokeMatrix.Inverse()
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid PDF stroke matrix")
|
||||
}
|
||||
drawing.matrix, origin, scale = inverse, Box{}, 1
|
||||
m := p.matrix.Mul(mark.StrokeMatrix)
|
||||
marginScale = math.Max(math.Hypot(m[0], m[2]), math.Hypot(m[1], m[3]))
|
||||
}
|
||||
if mark.Stroke {
|
||||
margin := mark.Style.LineWidth * marginScale / 2 * math.Max(1, mark.Style.MiterLimit)
|
||||
box = Box{X: box.X - margin, Y: box.Y - margin, W: box.W + 2*margin, H: box.H + 2*margin}
|
||||
}
|
||||
if mark.StrokeMatrix == (pdfgo.Matrix{}) {
|
||||
origin = box
|
||||
} else {
|
||||
m := p.matrix.Mul(mark.StrokeMatrix)
|
||||
m[4], m[5] = m[4]-box.X, m[5]-box.Y
|
||||
ctm = pdfNumbers(m[:]...)
|
||||
}
|
||||
paint := func(value pdfgo.Paint) (*FillColor, error) {
|
||||
if value.Tiling != nil || pdfGradientPattern(value) {
|
||||
return p.paintColor(value, box)
|
||||
}
|
||||
return drawing.paintColor(value, origin)
|
||||
}
|
||||
fillColor, strokePaint := p.color(mark.Style.Fill), p.color(mark.Style.Stroke)
|
||||
if mark.Fill {
|
||||
fillColor, err = paint(mark.Style.Fill)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if mark.Stroke {
|
||||
strokePaint, err = paint(mark.Style.Stroke)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
strokeColor := StrokeColor(*strokePaint)
|
||||
clips, err := p.clips(mark.Style.Clips, box)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := drawing.pathDataContext(p.ctx, mark.Path, origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object := PathObject{Boundary: pdfBoundary(box), CTM: ctm, AbbreviatedData: data, Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: fillColor, StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: clips}
|
||||
object.LineWidthSet = object.LineWidth == 0
|
||||
if mark.Path.EvenOdd {
|
||||
object.Rule = "Even-Odd"
|
||||
}
|
||||
if len(mark.Style.Dash) > 0 {
|
||||
object.DashPattern = pdfDashPattern(mark.Style.Dash, scale)
|
||||
phase := mark.Style.DashPhase * scale
|
||||
object.DashOffset = &phase
|
||||
}
|
||||
p.appendObject(GraphicObject{Type: "PathObject", PathObject: object})
|
||||
p.report.PathObjects++
|
||||
return nil
|
||||
}
|
||||
|
||||
// paintColor 将页面渐变转换为对象局部坐标
|
||||
// 入参: paint PDF画刷, box 对象边界
|
||||
// 返回: *FillColor OFD颜色或渐变, error 图案转换错误
|
||||
func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) {
|
||||
if err := p.gradientError(paint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pdfGradientPattern(paint) {
|
||||
return p.gradientPattern(paint, box)
|
||||
}
|
||||
if paint.Mesh != nil {
|
||||
return p.meshColor(paint, box)
|
||||
}
|
||||
color := p.color(paint)
|
||||
if paint.Tiling != nil {
|
||||
pattern, err := p.tilingPattern(paint.Tiling, paint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color.Pattern = pattern
|
||||
return color, nil
|
||||
}
|
||||
if paint.Radial != nil {
|
||||
gradient := paint.Radial
|
||||
matrix := p.matrix
|
||||
if gradient.Matrix != (pdfgo.Matrix{}) {
|
||||
matrix = matrix.Mul(gradient.Matrix)
|
||||
}
|
||||
start, end := matrix.Apply(gradient.Start), matrix.Apply(gradient.End)
|
||||
xx, yy, xy := matrix[0]*matrix[0]+matrix[2]*matrix[2], matrix[1]*matrix[1]+matrix[3]*matrix[3], matrix[0]*matrix[1]+matrix[2]*matrix[3]
|
||||
major := (xx + yy + math.Hypot(xx-yy, 2*xy)) / 2
|
||||
determinant := matrix[0]*matrix[3] - matrix[1]*matrix[2]
|
||||
if major <= 0 || determinant == 0 {
|
||||
return nil, fmt.Errorf("invalid PDF radial gradient matrix")
|
||||
}
|
||||
scale := math.Sqrt(major)
|
||||
eccentricity := math.Sqrt(math.Max(0, 1-determinant*determinant/(major*major)))
|
||||
angle := math.Atan2(2*xy, xx-yy) * 90 / math.Pi
|
||||
extend := 0
|
||||
if gradient.Extend[0] {
|
||||
extend |= 1
|
||||
}
|
||||
if gradient.Extend[1] {
|
||||
extend |= 2
|
||||
}
|
||||
shading := &RadialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), StartRadius: gradient.StartRadius * scale, EndRadius: gradient.EndRadius * scale, Eccentricity: eccentricity, Angle: angle, Extend: strconv.Itoa(extend)}
|
||||
var err error
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space, gradient.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color.Value = ""
|
||||
color.RadialShd = shading
|
||||
return color, nil
|
||||
}
|
||||
if paint.Axial == nil {
|
||||
return color, nil
|
||||
}
|
||||
gradient := paint.Axial
|
||||
start := p.matrix.Apply(gradient.Start)
|
||||
inverse, ok := p.matrix.Inverse()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid PDF gradient matrix")
|
||||
}
|
||||
dx, dy := gradient.End.X-gradient.Start.X, gradient.End.Y-gradient.Start.Y
|
||||
gx, gy := inverse[0]*dx+inverse[1]*dy, inverse[2]*dx+inverse[3]*dy
|
||||
length := gx*gx + gy*gy
|
||||
if length == 0 {
|
||||
return nil, fmt.Errorf("invalid PDF axial gradient axis")
|
||||
}
|
||||
factor := (dx*dx + dy*dy) / length
|
||||
end := pdfgo.Point{X: start.X + gx*factor, Y: start.Y + gy*factor}
|
||||
extend := 0
|
||||
if gradient.Extend[0] {
|
||||
extend |= 1
|
||||
}
|
||||
if gradient.Extend[1] {
|
||||
extend |= 2
|
||||
}
|
||||
shading := &AxialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), Extend: strconv.Itoa(extend)}
|
||||
var err error
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space, gradient.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color.Value = ""
|
||||
color.AxialShd = shading
|
||||
return color, nil
|
||||
}
|
||||
|
||||
// buildTilingPattern 保留PDF图案单元与页面坐标之间的仿射关系
|
||||
// 入参: source 平铺图案, base 无色图案基色
|
||||
// 返回: *Pattern OFD图案, error 转换错误
|
||||
func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo.Paint) (*Pattern, error) {
|
||||
const unit = 25.4 / 72
|
||||
box := source.BBox
|
||||
width, height := box.XMax-box.XMin, box.YMax-box.YMin
|
||||
xstep, ystep := math.Abs(source.XStep), math.Abs(source.YStep)
|
||||
if width <= 0 || height <= 0 || !finite(xstep) || !finite(ystep) || xstep == 0 || ystep == 0 {
|
||||
return nil, fmt.Errorf("invalid PDF tiling pattern dimensions")
|
||||
}
|
||||
localToPDF := pdfgo.Matrix{1 / unit, 0, 0, -1 / unit, box.XMin, box.YMax}
|
||||
m := p.matrix.Mul(source.Matrix).Mul(localToPDF)
|
||||
pattern := &Pattern{Width: math.Min(width, xstep) * unit, Height: math.Min(height, ystep) * unit, XStep: xstep * unit, YStep: ystep * unit, RelativeTo: "Page", CTM: pdfNumbers(m[:]...)}
|
||||
cell := *p
|
||||
cell.matrix = pdfgo.Matrix{unit, 0, 0, -unit, -box.XMin * unit, box.YMax * unit}
|
||||
cell.pageWidth, cell.pageHeight = width*unit, height*unit
|
||||
cell.rasterDPI *= m.MaxScale()
|
||||
if !finite(cell.rasterDPI) || cell.rasterDPI < 0 {
|
||||
return nil, fmt.Errorf("invalid PDF tiling pattern raster scale")
|
||||
}
|
||||
cell.objects, cell.pendingPath = nil, nil
|
||||
if err := source.Walk(p.ctx, base, cell.visitor()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cell.flushPath(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects, err := cell.objects.data(p.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pattern.CellContent.Objects = objects
|
||||
if len(objects) != 0 && (width > xstep || height > ystep) {
|
||||
columns, rows := math.Ceil(width/xstep), math.Ceil(height/ystep)
|
||||
if !finite(columns*rows) || columns*rows >= float64(uint(^uint(0)>>1)+1) {
|
||||
return nil, fmt.Errorf("PDF tiling pattern instance count overflow")
|
||||
}
|
||||
bounds := make([]Box, len(objects))
|
||||
for index, object := range objects {
|
||||
boundary := ""
|
||||
if object.Type == "PathObject" && object.PathObject.Stroke != nil && !*object.PathObject.Stroke {
|
||||
boundary = object.PathObject.Boundary
|
||||
} else if object.Type == "ImageObject" && object.ImageObject.Border == nil {
|
||||
boundary = object.ImageObject.Boundary
|
||||
}
|
||||
if boundary != "" {
|
||||
var err error
|
||||
bounds[index], err = ParseBox(boundary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
left, right, top, bottom := pdfPatternInstanceRange(columns, rows, bounds, pattern.Width, pattern.Height, xstep*unit, ystep*unit)
|
||||
var expanded pdfObjectBuffer
|
||||
for row := top; row <= bottom; row++ {
|
||||
for column := left; column <= right; column++ {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dx, dy := float64(column)*xstep*unit, float64(row)*ystep*unit
|
||||
for index, object := range objects {
|
||||
if index%pdfObjectChunkLimit == 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b := bounds[index]
|
||||
if b.W > 0 && b.H > 0 && (b.X+dx+b.W < 0 || b.Y+dy+b.H < 0 || b.X+dx > pattern.Width || b.Y+dy > pattern.Height) {
|
||||
continue
|
||||
}
|
||||
translated, err := transformEditorObject(object, dx, dy, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expanded.append(translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
pattern.CellContent.Objects, err = expanded.data(p.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return pattern, nil
|
||||
}
|
||||
|
||||
// pdfOpaqueBlack 判断不受底层分色影响的全黑设备色
|
||||
// 入参: paint PDF颜色与不透明度
|
||||
// 返回: bool 是否为不透明全黑CMYK颜色
|
||||
func pdfOpaqueBlack(paint pdfgo.Paint) bool {
|
||||
return paint.Alpha == 1 && paint.CMYK != nil && paint.CMYK[3] == 1
|
||||
}
|
||||
|
||||
// pdfOverprintNeedsSeparation 判断彩色CMYK套印是否依赖分色输出
|
||||
// 入参: paint PDF颜色与不透明度
|
||||
// 返回: bool 是否需要保留分色语义
|
||||
func pdfOverprintNeedsSeparation(paint pdfgo.Paint) bool {
|
||||
_, process := paint.Process.CMYKMask()
|
||||
return (paint.CMYK != nil || process) && !pdfOpaqueBlack(paint)
|
||||
// referencePage 衔接PDF访问器与当前导入会话的引用文档缓存
|
||||
// 入参: ctx 取消上下文, source 来源文档, reference 引用目标
|
||||
// 返回: *pdfgo.Page 目标页面,不可用时为空, error 取消或调用方解析错误
|
||||
func (p *pdfImporter) referencePage(ctx context.Context, source *pdfgo.Reader, reference pdfgo.ReferenceXObject) (*pdfgo.Page, error) {
|
||||
if p.references == nil {
|
||||
p.references = &codecpdf.ReferenceCache{}
|
||||
}
|
||||
return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"maps"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +44,12 @@ type PDFThreeDRenderOptions struct {
|
||||
Time float64
|
||||
}
|
||||
|
||||
// pdfThreeDMarkupState 复用同页批注的宿主解析及模型校验,不保留模型解码缓冲
|
||||
type pdfThreeDMarkupState struct {
|
||||
sources map[pdfgo.Reference]pdfgo.ThreeD
|
||||
sums map[*pdfgo.Stream][16]byte
|
||||
}
|
||||
|
||||
// threeDAnnotation 按非交互呈现规则处理激活状态,保留原始模型与附件动作
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation 三维注解
|
||||
// 返回: error 外观、资源或取消错误
|
||||
@@ -191,3 +198,57 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD,
|
||||
ResolveTexture: options.ResolveTexture, Nodes: options.Nodes, Time: options.Time,
|
||||
})
|
||||
}
|
||||
|
||||
// threeDMarkupAnnotation 应用默认三维视图的批注可见性,其他视图批注保留为隐藏注解
|
||||
// 入参: ctx 取消上下文, annotation 批注, markup 关联, state 同页缓存, strict 严格检查开关
|
||||
// 返回: pdfgo.Annotation 静态批注, error 关联、校验或取消错误
|
||||
func (p *pdfImporter) threeDMarkupAnnotation(ctx context.Context, annotation pdfgo.Annotation, markup *pdfgo.ThreeDMarkup, state *pdfThreeDMarkupState, strict bool) (pdfgo.Annotation, error) {
|
||||
if markup == nil {
|
||||
return annotation, nil
|
||||
}
|
||||
if strict {
|
||||
return annotation, &pdfgo.UnsupportedError{Feature: "3D markup view switching conversion"}
|
||||
}
|
||||
source, ok := state.sources[markup.Annotation.Reference]
|
||||
if !ok || markup.Annotation.Reference == (pdfgo.Reference{}) {
|
||||
var err error
|
||||
source, err = p.reader.ReadThreeDContext(ctx, markup.Annotation)
|
||||
if err != nil {
|
||||
return annotation, err
|
||||
}
|
||||
if markup.Annotation.Reference != (pdfgo.Reference{}) {
|
||||
if state.sources == nil {
|
||||
state.sources = make(map[pdfgo.Reference]pdfgo.ThreeD)
|
||||
}
|
||||
state.sources[markup.Annotation.Reference] = source
|
||||
}
|
||||
}
|
||||
if markup.Checksum != nil {
|
||||
sum, ok := state.sums[source.Stream]
|
||||
if !ok {
|
||||
var err error
|
||||
sum, err = pdfgo.ThreeDArtworkChecksum(ctx, source.Stream)
|
||||
if err != nil {
|
||||
return annotation, err
|
||||
}
|
||||
if state.sums == nil {
|
||||
state.sums = make(map[*pdfgo.Stream][16]byte)
|
||||
}
|
||||
state.sums[source.Stream] = sum
|
||||
}
|
||||
if sum != *markup.Checksum {
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D markup artwork checksum differs; view association may be stale"})
|
||||
}
|
||||
}
|
||||
visible := source.ActivationPolicy.Activation != "XA" && markup.MatchesView(source.SelectedView)
|
||||
if !visible {
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return annotation, err
|
||||
}
|
||||
annotation.Dictionary = maps.Clone(annotation.Dictionary)
|
||||
annotation.Dictionary["F"] = pdfgo.Integer(flags | 2)
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D markup default view visibility applied; view switching not transferred"})
|
||||
return annotation, ctx.Err()
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// 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"
|
||||
"maps"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfThreeDMarkupState 复用同页批注的宿主解析及模型校验,不保留模型解码缓冲
|
||||
type pdfThreeDMarkupState struct {
|
||||
sources map[pdfgo.Reference]pdfgo.ThreeD
|
||||
sums map[*pdfgo.Stream][16]byte
|
||||
}
|
||||
|
||||
// threeDMarkupAnnotation 应用默认三维视图的批注可见性,其他视图批注保留为隐藏注解
|
||||
// 入参: ctx 取消上下文, annotation 批注, markup 关联, state 同页缓存, strict 严格检查开关
|
||||
// 返回: pdfgo.Annotation 静态批注, error 关联、校验或取消错误
|
||||
func (p *pdfImporter) threeDMarkupAnnotation(ctx context.Context, annotation pdfgo.Annotation, markup *pdfgo.ThreeDMarkup, state *pdfThreeDMarkupState, strict bool) (pdfgo.Annotation, error) {
|
||||
if markup == nil {
|
||||
return annotation, nil
|
||||
}
|
||||
if strict {
|
||||
return annotation, &pdfgo.UnsupportedError{Feature: "3D markup view switching conversion"}
|
||||
}
|
||||
source, ok := state.sources[markup.Annotation.Reference]
|
||||
if !ok || markup.Annotation.Reference == (pdfgo.Reference{}) {
|
||||
var err error
|
||||
source, err = p.reader.ReadThreeDContext(ctx, markup.Annotation)
|
||||
if err != nil {
|
||||
return annotation, err
|
||||
}
|
||||
if markup.Annotation.Reference != (pdfgo.Reference{}) {
|
||||
if state.sources == nil {
|
||||
state.sources = make(map[pdfgo.Reference]pdfgo.ThreeD)
|
||||
}
|
||||
state.sources[markup.Annotation.Reference] = source
|
||||
}
|
||||
}
|
||||
if markup.Checksum != nil {
|
||||
sum, ok := state.sums[source.Stream]
|
||||
if !ok {
|
||||
var err error
|
||||
sum, err = pdfgo.ThreeDArtworkChecksum(ctx, source.Stream)
|
||||
if err != nil {
|
||||
return annotation, err
|
||||
}
|
||||
if state.sums == nil {
|
||||
state.sums = make(map[*pdfgo.Stream][16]byte)
|
||||
}
|
||||
state.sums[source.Stream] = sum
|
||||
}
|
||||
if sum != *markup.Checksum {
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D markup artwork checksum differs; view association may be stale"})
|
||||
}
|
||||
}
|
||||
visible := source.ActivationPolicy.Activation != "XA" && markup.MatchesView(source.SelectedView)
|
||||
if !visible {
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return annotation, err
|
||||
}
|
||||
annotation.Dictionary = maps.Clone(annotation.Dictionary)
|
||||
annotation.Dictionary["F"] = pdfgo.Integer(flags | 2)
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D markup default view visibility applied; view switching not transferred"})
|
||||
return annotation, ctx.Err()
|
||||
}
|
||||
@@ -145,3 +145,65 @@ func (p *pdfImporter) unmappedTrigger(ctx context.Context, owner string, trigger
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观
|
||||
// 入参: ctx 取消上下文, annotation PDF注解, event OFD事件
|
||||
// 返回: []Action 动作序列, error 事件、动作或取消错误
|
||||
func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation, event string) ([]Action, error) {
|
||||
if annotation.Subtype == "Widget" && event == "CLICK" {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(triggers) == 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var converted []Action
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "PC" || trigger.Event == "PV" || trigger.Event == "PI" {
|
||||
if event == "PO" {
|
||||
if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (trigger.Event == "PO") != (event == "PO") {
|
||||
continue
|
||||
}
|
||||
if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") {
|
||||
continue
|
||||
}
|
||||
switch trigger.Event {
|
||||
case "U", "PO":
|
||||
default:
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)}
|
||||
}
|
||||
currentPage := p.page
|
||||
if event == "PO" {
|
||||
currentPage = p.pageActionPage
|
||||
}
|
||||
var source pdfgo.Annotation
|
||||
if event == "CLICK" {
|
||||
source = annotation
|
||||
}
|
||||
actions, err := p.eventActions(ctx, trigger.Action, event, source, ¤tPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
converted = append(converted, actions...)
|
||||
if event == "PO" {
|
||||
p.pageActionPage = currentPage
|
||||
}
|
||||
}
|
||||
if event == "PO" && len(converted) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) {
|
||||
return nil, fmt.Errorf("invalid annotation trigger page")
|
||||
}
|
||||
return converted, ctx.Err()
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观
|
||||
// 入参: ctx 取消上下文, annotation PDF注解, event OFD事件
|
||||
// 返回: []Action 动作序列, error 事件、动作或取消错误
|
||||
func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation, event string) ([]Action, error) {
|
||||
if annotation.Subtype == "Widget" && event == "CLICK" {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(triggers) == 0 {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var converted []Action
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "PC" || trigger.Event == "PV" || trigger.Event == "PI" {
|
||||
if event == "PO" {
|
||||
if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (trigger.Event == "PO") != (event == "PO") {
|
||||
continue
|
||||
}
|
||||
if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") {
|
||||
continue
|
||||
}
|
||||
switch trigger.Event {
|
||||
case "U", "PO":
|
||||
default:
|
||||
return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)}
|
||||
}
|
||||
currentPage := p.page
|
||||
if event == "PO" {
|
||||
currentPage = p.pageActionPage
|
||||
}
|
||||
var source pdfgo.Annotation
|
||||
if event == "CLICK" {
|
||||
source = annotation
|
||||
}
|
||||
actions, err := p.eventActions(ctx, trigger.Action, event, source, ¤tPage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
converted = append(converted, actions...)
|
||||
if event == "PO" {
|
||||
p.pageActionPage = currentPage
|
||||
}
|
||||
}
|
||||
if event == "PO" && len(converted) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) {
|
||||
return nil, fmt.Errorf("invalid annotation trigger page")
|
||||
}
|
||||
return converted, ctx.Err()
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// documentAttachments 导入文档名称树中的附件,与注解及动作引用共用附件资源
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 名称树、附件读取或写入错误
|
||||
func (p *pdfImporter) documentAttachments(ctx context.Context) error {
|
||||
return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error {
|
||||
if file.Embedded == nil {
|
||||
if p.warning == nil {
|
||||
return fmt.Errorf("PDF embedded file has no embedded data")
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF embedded file without embedded data ignored"})
|
||||
return nil
|
||||
}
|
||||
data, err := file.Embedded.DecodeContext(ctx)
|
||||
if err != nil {
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && errors.As(err, &unsupported) {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()})
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = p.attachmentFile(file.Name, data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
+24
-1
@@ -14,7 +14,9 @@
|
||||
|
||||
package ofdgo
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// pdfObjectChunkLimit 限制导入暂存片段的容量,不限制页面对象数量
|
||||
const pdfObjectChunkLimit = 256
|
||||
@@ -126,3 +128,24 @@ func (b *pdfObjectBuffer) data(ctx context.Context) ([]GraphicObject, error) {
|
||||
b.parts = b.parts[:1]
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// commitObjects 将当前页面待转换对象写入编辑文档
|
||||
// 返回: error 对象写入错误
|
||||
func (p *pdfImporter) commitObjects() error {
|
||||
if p.objects.len() == 0 {
|
||||
return nil
|
||||
}
|
||||
objects, err := p.objects.data(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
objects, err = p.sharedClipObjects(objects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil {
|
||||
return err
|
||||
}
|
||||
p.objects = nil
|
||||
return nil
|
||||
}
|
||||
+184
-50
@@ -15,7 +15,7 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
@@ -28,55 +28,6 @@ type pdfClipPath struct {
|
||||
path *[1]PathObject
|
||||
}
|
||||
|
||||
// pdfClipContainsBoundary 判断单一矩形裁剪是否完整覆盖对象边界,不近似曲线或斜边
|
||||
// 入参: path PDF裁剪路径, matrix 页面变换, boundary OFD对象边界
|
||||
// 返回: bool 是否可由对象边界代替裁剪
|
||||
func pdfClipContainsBoundary(path pdfgo.Path, matrix pdfgo.Matrix, boundary Box) bool {
|
||||
if len(path.Text) != 0 || len(path.Segments) < 4 || len(path.Segments) > 6 || !finite(boundary.X) || !finite(boundary.Y) || !finite(boundary.W) || !finite(boundary.H) || boundary.W <= 0 || boundary.H <= 0 {
|
||||
return false
|
||||
}
|
||||
var points [4]pdfgo.Point
|
||||
for index := range 4 {
|
||||
segment := path.Segments[index]
|
||||
operator := "L"
|
||||
if index == 0 {
|
||||
operator = "M"
|
||||
}
|
||||
if segment.Operator != operator || len(segment.Points) != 1 {
|
||||
return false
|
||||
}
|
||||
points[index] = matrix.Apply(segment.Points[0])
|
||||
if !finite(points[index].X) || !finite(points[index].Y) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end := 4
|
||||
if end < len(path.Segments) && path.Segments[end].Operator == "L" {
|
||||
segment := path.Segments[end]
|
||||
if len(segment.Points) != 1 || matrix.Apply(segment.Points[0]) != points[0] {
|
||||
return false
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end < len(path.Segments) {
|
||||
segment := path.Segments[end]
|
||||
if segment.Operator != "C" || len(segment.Points) != 0 {
|
||||
return false
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end != len(path.Segments) {
|
||||
return false
|
||||
}
|
||||
a, b, c, d := points[0], points[1], points[2], points[3]
|
||||
if !(a.Y == b.Y && b.X == c.X && c.Y == d.Y && d.X == a.X || a.X == b.X && b.Y == c.Y && c.X == d.X && d.Y == a.Y) {
|
||||
return false
|
||||
}
|
||||
left, right := math.Min(a.X, c.X), math.Max(a.X, c.X)
|
||||
top, bottom := math.Min(a.Y, c.Y), math.Max(a.Y, c.Y)
|
||||
return left < right && top < bottom && boundary.X >= left && boundary.Y >= top && boundary.X+boundary.W <= right && boundary.Y+boundary.H <= bottom
|
||||
}
|
||||
|
||||
// clipPath 复用页面内重复路径编码,按估算字节预算淘汰且不缓存错误
|
||||
// 入参: path 裁剪路径
|
||||
// 返回: pdfClipPath 变换后的边界及编码, error 取消错误
|
||||
@@ -109,3 +60,186 @@ func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) {
|
||||
p.clipPaths.put(key, result, len(data)+len(boundary)+512)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// sharedClipObjects 将连续重复的大裁剪区提取为复合对象,保留页面坐标和绘制顺序
|
||||
// 入参: objects 本库生成且尚未分配标识的对象
|
||||
// 返回: []GraphicObject 提取后的对象, error 资源创建或取消错误
|
||||
func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObject, error) {
|
||||
var output []GraphicObject
|
||||
for start := 0; start < len(objects); {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clips := pdfPageClip(objects[start])
|
||||
end := start + 1
|
||||
if clips != nil {
|
||||
for end < len(objects) && pdfMatchesPageClip(objects[end], clips) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end-start < 2 {
|
||||
if output != nil {
|
||||
output = append(output, objects[start])
|
||||
}
|
||||
start = end
|
||||
continue
|
||||
}
|
||||
if output == nil {
|
||||
output = append(make([]GraphicObject, 0, len(objects)), objects[:start]...)
|
||||
}
|
||||
members := objects[start:end:end]
|
||||
for index := range members {
|
||||
*editorObjectClips(&members[index]) = nil
|
||||
}
|
||||
id, err := p.objectValidation().addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{
|
||||
Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: id, Clips: clips,
|
||||
}})
|
||||
start = end
|
||||
}
|
||||
if output == nil {
|
||||
return objects, nil
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// pdfPageClip 将导入裁剪区还原到页面坐标,仅提取包含大量重复路径数据的裁剪
|
||||
// 入参: object 导入对象
|
||||
// 返回: *Clips 页面坐标裁剪,不满足提取条件时为空
|
||||
func pdfPageClip(object GraphicObject) *Clips {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return nil
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) == 0 {
|
||||
return nil
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
bytes := 0
|
||||
result := &Clips{Clip: make([]Clip, len(clips.Clip))}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return nil
|
||||
}
|
||||
area := clip.Area[0]
|
||||
area.CTM = ""
|
||||
result.Clip[index].Area = []ClipArea{area}
|
||||
for _, path := range area.Path {
|
||||
bytes += len(path.AbbreviatedData)
|
||||
}
|
||||
}
|
||||
if bytes < 2048 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// pdfMatchesPageClip 比较页面坐标裁剪,不将相同外接矩形视为相同路径
|
||||
// 入参: object 导入对象, pageClip 已还原的裁剪区
|
||||
// 返回: bool 是否可共享裁剪区
|
||||
func pdfMatchesPageClip(object GraphicObject, pageClip *Clips) bool {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return false
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) != len(pageClip.Clip) {
|
||||
return false
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return false
|
||||
}
|
||||
area, expected := clip.Area[0], pageClip.Clip[index].Area[0]
|
||||
if area.DrawParam != expected.DrawParam || !reflect.DeepEqual(area.Path, expected.Path) || !reflect.DeepEqual(area.Text, expected.Text) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clips 省略覆盖对象边界的矩形裁剪,复用其余几何并保留位移及路径交集
|
||||
// 入参: paths 裁剪路径, origin 对象边界
|
||||
// 返回: *Clips OFD裁剪区域,无裁剪时为空, error 未解析的裁剪字形
|
||||
func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) {
|
||||
if len(paths) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var clips *Clips
|
||||
var areas []ClipArea
|
||||
translation := ""
|
||||
for _, path := range paths {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if codecpdf.ClipContainsBoundary(path, p.matrix, origin) {
|
||||
continue
|
||||
}
|
||||
if clips == nil {
|
||||
clips = &Clips{Clip: make([]Clip, 0, len(paths))}
|
||||
areas = make([]ClipArea, len(paths))
|
||||
if origin.X != 0 || origin.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y)
|
||||
}
|
||||
}
|
||||
index := len(clips.Clip)
|
||||
area := &areas[index]
|
||||
area.CTM = translation
|
||||
if len(path.Segments) != 0 {
|
||||
encoded, err := p.clipPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
area.Path = encoded.path[:1:1]
|
||||
}
|
||||
for _, mark := range path.Text {
|
||||
objects, ok := p.clipTexts[mark]
|
||||
if !ok {
|
||||
if err := p.flushPath(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := p.objects.len()
|
||||
textCount := p.report.TextObjects
|
||||
if err := p.text(pdfgo.TextMark{Font: mark.Font, Glyphs: mark.Glyphs, Positions: mark.Positions, Matrix: mark.Matrix, Size: mark.Size, HorizontalScale: mark.HorizontalScale, Mode: 7, Clip: mark, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.objects.truncate(count)
|
||||
p.report.TextObjects = textCount
|
||||
objects = p.clipTexts[mark]
|
||||
}
|
||||
if len(path.Text) == 1 {
|
||||
area.Text = objects[:len(objects):len(objects)]
|
||||
} else {
|
||||
area.Text = append(area.Text, objects...)
|
||||
}
|
||||
}
|
||||
if len(area.Path)+len(area.Text) == 0 {
|
||||
fill, stroke := true, false
|
||||
area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}}
|
||||
}
|
||||
clips.Clip = append(clips.Clip, Clip{Area: areas[index : index+1 : index+1]})
|
||||
}
|
||||
return clips, nil
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
// 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 (
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// sharedClipObjects 将连续重复的大裁剪区提取为复合对象,保留页面坐标和绘制顺序
|
||||
// 入参: objects 本库生成且尚未分配标识的对象
|
||||
// 返回: []GraphicObject 提取后的对象, error 资源创建或取消错误
|
||||
func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObject, error) {
|
||||
var output []GraphicObject
|
||||
for start := 0; start < len(objects); {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clips := pdfPageClip(objects[start])
|
||||
end := start + 1
|
||||
if clips != nil {
|
||||
for end < len(objects) && pdfMatchesPageClip(objects[end], clips) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end-start < 2 {
|
||||
if output != nil {
|
||||
output = append(output, objects[start])
|
||||
}
|
||||
start = end
|
||||
continue
|
||||
}
|
||||
if output == nil {
|
||||
output = append(make([]GraphicObject, 0, len(objects)), objects[:start]...)
|
||||
}
|
||||
members := objects[start:end:end]
|
||||
for index := range members {
|
||||
*editorObjectClips(&members[index]) = nil
|
||||
}
|
||||
id, err := p.objectValidation().addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output = append(output, GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{
|
||||
Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: id, Clips: clips,
|
||||
}})
|
||||
start = end
|
||||
}
|
||||
if output == nil {
|
||||
return objects, nil
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// pdfPageClip 将导入裁剪区还原到页面坐标,仅提取包含大量重复路径数据的裁剪
|
||||
// 入参: object 导入对象
|
||||
// 返回: *Clips 页面坐标裁剪,不满足提取条件时为空
|
||||
func pdfPageClip(object GraphicObject) *Clips {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return nil
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) == 0 {
|
||||
return nil
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
bytes := 0
|
||||
result := &Clips{Clip: make([]Clip, len(clips.Clip))}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return nil
|
||||
}
|
||||
area := clip.Area[0]
|
||||
area.CTM = ""
|
||||
result.Clip[index].Area = []ClipArea{area}
|
||||
for _, path := range area.Path {
|
||||
bytes += len(path.AbbreviatedData)
|
||||
}
|
||||
}
|
||||
if bytes < 2048 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// pdfMatchesPageClip 比较页面坐标裁剪,不将相同外接矩形视为相同路径
|
||||
// 入参: object 导入对象, pageClip 已还原的裁剪区
|
||||
// 返回: bool 是否可共享裁剪区
|
||||
func pdfMatchesPageClip(object GraphicObject, pageClip *Clips) bool {
|
||||
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" {
|
||||
return false
|
||||
}
|
||||
clips := *editorObjectClips(&object)
|
||||
if clips == nil || clips.usesObjectMatrix() || len(clips.Clip) != len(pageClip.Clip) {
|
||||
return false
|
||||
}
|
||||
boundary, _ := editorGeometry(object)
|
||||
box, err := ParseBox(boundary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
translation := ""
|
||||
if box.X != 0 || box.Y != 0 {
|
||||
translation = pdfNumbers(1, 0, 0, 1, -box.X, -box.Y)
|
||||
}
|
||||
for index, clip := range clips.Clip {
|
||||
if len(clip.Area) != 1 || clip.Area[0].CTM != translation {
|
||||
return false
|
||||
}
|
||||
area, expected := clip.Area[0], pageClip.Clip[index].Area[0]
|
||||
if area.DrawParam != expected.DrawParam || !reflect.DeepEqual(area.Path, expected.Path) || !reflect.DeepEqual(area.Text, expected.Text) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+6
-130
@@ -145,7 +145,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
|
||||
if !c.softMask && space.Model == "DeviceCMYK" && !space.Calibrated() {
|
||||
if node.image != nil && !node.image.Image.ImageMask {
|
||||
if overprint {
|
||||
processMask, processOverprint, err = pdfImageProcessColorants(node.image.Image)
|
||||
processMask, processOverprint, err = codecpdf.ImageProcessColorants(node.image.Image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
|
||||
processMask, processOverprint = paint.Process.CMYKMask()
|
||||
}
|
||||
if paint.Colorant != nil {
|
||||
nativePaint, processMask, err = pdfCompositeNativePaint(paint, space, style.RenderingIntent)
|
||||
nativePaint, processMask, err = codecpdf.NativePaint(paint, space, style.RenderingIntent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -311,7 +311,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
|
||||
if nativePaint != nil {
|
||||
uniformValues = nativePaint.Values
|
||||
} else {
|
||||
uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent, style.ColorConversion)
|
||||
uniformValues, _, err = codecpdf.CompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent, style.ColorConversion)
|
||||
}
|
||||
uniformReady = true
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
|
||||
if sampler != nil {
|
||||
values, visible, err = sampler.color(point)
|
||||
} else {
|
||||
values, visible, err = pdfCompositeColor(paint, point, space, style.RenderingIntent, style.ColorConversion)
|
||||
values, visible, err = codecpdf.CompositeColor(paint, point, space, style.RenderingIntent, style.ColorConversion)
|
||||
}
|
||||
if !visible {
|
||||
shape = 0
|
||||
@@ -409,20 +409,6 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
|
||||
return nil
|
||||
}
|
||||
|
||||
// pdfCompositeNativePaint 使用库层色料定义求值设备四色,不以备用色代替原生浓度
|
||||
// 入参: paint 源画刷, space 设备四色空间, intent 渲染意图
|
||||
// 返回: *pdfgo.Paint 原生画刷或nil, [4]bool 显式通道, error 色料定义错误
|
||||
func pdfCompositeNativePaint(paint pdfgo.Paint, space *pdfgo.ColorSpace, intent pdfgo.Name) (*pdfgo.Paint, [4]bool, error) {
|
||||
if paint.Colorant == nil {
|
||||
return nil, [4]bool{}, nil
|
||||
}
|
||||
result, err := paint.Colorant.Space.ResolveInGroup(paint.Colorant.Tints, &pdfgo.ColorantDevice{Space: space}, space, intent, false)
|
||||
if err != nil || len(result.Alternates) != 0 {
|
||||
return nil, [4]bool{}, err
|
||||
}
|
||||
return result.Process, result.ProcessMask, nil
|
||||
}
|
||||
|
||||
// pdfCompositeInterpolate 按形状覆盖混合预乘颜色及累计透明度
|
||||
// 入参: target 原像素与输出, source 替换像素, shape 覆盖率
|
||||
// 返回: error 像素或覆盖率错误
|
||||
@@ -860,7 +846,7 @@ func (c *pdfCompositor) imageColor(mark *pdfgo.ImageMark, point pdfgo.Point, spa
|
||||
if mark.Style.Fill.Tiling != nil || mark.Style.Fill.Shading != nil {
|
||||
return [4]float64{}, alpha * (1 - values[0]), nil
|
||||
}
|
||||
color, visible, err := pdfCompositeColor(mark.Style.Fill, mark.Matrix.Apply(point), space, mark.Style.RenderingIntent, mark.Style.ColorConversion)
|
||||
color, visible, err := codecpdf.CompositeColor(mark.Style.Fill, mark.Matrix.Apply(point), space, mark.Style.RenderingIntent, mark.Style.ColorConversion)
|
||||
if !visible {
|
||||
return color, 0, err
|
||||
}
|
||||
@@ -873,116 +859,6 @@ func (c *pdfCompositor) imageColor(mark *pdfgo.ImageMark, point pdfgo.Point, spa
|
||||
return values, alpha, err
|
||||
}
|
||||
|
||||
// pdfImageProcessColorants 识别可在四色设备上直接绘制的专色色料
|
||||
// 入参: image PDF图像
|
||||
// 返回: [4]bool 参与绘制的四色通道, bool 是否全部属于印刷原色, error 色空间错误
|
||||
func pdfImageProcessColorants(image *pdfgo.Image) ([4]bool, bool, error) {
|
||||
source, err := image.ColorantSpace()
|
||||
if err != nil {
|
||||
return [4]bool{}, false, err
|
||||
}
|
||||
space := &pdfgo.ColorSpace{Model: "DeviceCMYK"}
|
||||
process := source.PrepareProcess(&pdfgo.ColorantDevice{Space: space}, space, false)
|
||||
mask := process.Mask()
|
||||
return mask, mask != ([4]bool{}), nil
|
||||
}
|
||||
|
||||
// pdfCompositeColor 将原始画刷变换到混合空间,渐变在源空间求值
|
||||
// 入参: paint 画刷, point 页面坐标, space 混合空间, intent 渲染意图, conversion 设备转换函数
|
||||
// 返回: [4]float64 混合分量, bool 是否着色, error 颜色错误
|
||||
func pdfCompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorSpace, intent pdfgo.Name, conversion pdfgo.ColorConversion) ([4]float64, bool, error) {
|
||||
if paint.Shading != nil {
|
||||
return [4]float64{}, false, &pdfgo.UnsupportedError{Feature: "local shading pattern compositing"}
|
||||
}
|
||||
if paint.Tiling != nil {
|
||||
return [4]float64{}, false, &pdfgo.UnsupportedError{Feature: "local tiling pattern compositing"}
|
||||
}
|
||||
var values [4]float64
|
||||
var source *pdfgo.ColorSpace
|
||||
if paint.Function != nil {
|
||||
g := paint.Function
|
||||
if g.Bounds != nil {
|
||||
inverse, ok := g.PatternMatrix.Inverse()
|
||||
if !ok {
|
||||
return values, false, fmt.Errorf("singular function shading bounds transform")
|
||||
}
|
||||
local := inverse.Apply(point)
|
||||
if local.X < g.Bounds.XMin || local.X > g.Bounds.XMax || local.Y < g.Bounds.YMin || local.Y > g.Bounds.YMax {
|
||||
return values, false, nil
|
||||
}
|
||||
}
|
||||
inverse, ok := g.Matrix.Inverse()
|
||||
if !ok {
|
||||
return values, false, fmt.Errorf("singular function shading transform")
|
||||
}
|
||||
local := inverse.Apply(point)
|
||||
source, intent = g.Space, g.Intent
|
||||
if local.X < g.Domain.XMin || local.X > g.Domain.XMax || local.Y < g.Domain.YMin || local.Y > g.Domain.YMax {
|
||||
if g.Background == nil {
|
||||
return values, false, nil
|
||||
}
|
||||
values = *g.Background
|
||||
} else {
|
||||
var err error
|
||||
values, err = g.ValuesAt(local)
|
||||
if err != nil {
|
||||
return values, false, err
|
||||
}
|
||||
}
|
||||
} else if paint.Axial != nil || paint.Radial != nil {
|
||||
var background *[4]float64
|
||||
var bounds *pdfgo.Rectangle
|
||||
var matrix pdfgo.Matrix
|
||||
if g := paint.Axial; g != nil {
|
||||
source, intent = g.Space, g.Intent
|
||||
background, bounds, matrix = g.Background, g.Bounds, g.Matrix
|
||||
} else {
|
||||
g := paint.Radial
|
||||
source, intent = g.Space, g.Intent
|
||||
background, bounds, matrix = g.Background, g.Bounds, g.Matrix
|
||||
}
|
||||
if bounds != nil {
|
||||
local := point
|
||||
if matrix != (pdfgo.Matrix{}) {
|
||||
inverse, ok := matrix.Inverse()
|
||||
if !ok {
|
||||
return values, false, fmt.Errorf("singular shading transform")
|
||||
}
|
||||
local = inverse.Apply(point)
|
||||
}
|
||||
if local.X < bounds.XMin || local.X > bounds.XMax || local.Y < bounds.YMin || local.Y > bounds.YMax {
|
||||
return values, false, nil
|
||||
}
|
||||
}
|
||||
t, visible := pdfShadingPosition(paint, point)
|
||||
var err error
|
||||
if !visible {
|
||||
if background == nil {
|
||||
return values, false, nil
|
||||
}
|
||||
values = *background
|
||||
} else if paint.Axial != nil {
|
||||
values, err = paint.Axial.ValuesAt(t)
|
||||
} else {
|
||||
values, err = paint.Radial.ValuesAt(t)
|
||||
}
|
||||
if err != nil {
|
||||
return values, false, err
|
||||
}
|
||||
} else if paint.Space != nil {
|
||||
values, source = paint.Values, paint.Space
|
||||
} else if paint.CMYK != nil {
|
||||
values, source = *paint.CMYK, &pdfgo.ColorSpace{Model: "DeviceCMYK"}
|
||||
} else if paint.SourceSpace == "DeviceGray" {
|
||||
values[0], source = paint.RGB[0], &pdfgo.ColorSpace{Model: "DeviceGray"}
|
||||
} else {
|
||||
copy(values[:], paint.RGB[:])
|
||||
source = &pdfgo.ColorSpace{Model: "DeviceRGB"}
|
||||
}
|
||||
values, err := space.ConvertWith(values[:source.Components()], source, intent, conversion)
|
||||
return values, true, err
|
||||
}
|
||||
|
||||
// color 复用精确参数对应的颜色,区域、背景及其他画刷保持原求值路径
|
||||
// 入参: point 页面坐标
|
||||
// 返回: [4]float64 混合分量, bool 是否着色, error 颜色错误
|
||||
@@ -990,7 +866,7 @@ func (s *pdfCompositeColorSampler) color(point pdfgo.Point) ([4]float64, bool, e
|
||||
g := s.paint.Axial
|
||||
r := s.paint.Radial
|
||||
if g == nil && r == nil || g != nil && (g.Bounds != nil || g.Background != nil) || r != nil && (r.Bounds != nil || r.Background != nil) || s.paint.Function != nil || s.paint.Tiling != nil {
|
||||
return pdfCompositeColor(s.paint, point, s.space, s.intent, s.conversion)
|
||||
return codecpdf.CompositeColor(s.paint, point, s.space, s.intent, s.conversion)
|
||||
}
|
||||
if !s.prepared {
|
||||
var err error
|
||||
|
||||
@@ -247,7 +247,7 @@ func (p *pdfImporter) directCompositeNode(node pdfCompositeNode, space *pdfgo.Co
|
||||
}
|
||||
if space.Model == "DeviceCMYK" && style.ColorConversion != (pdfgo.ColorConversion{}) {
|
||||
if node.image != nil && !node.image.Image.ImageMask {
|
||||
_, native, err := pdfImageProcessColorants(node.image.Image)
|
||||
_, native, err := codecpdf.ImageProcessColorants(node.image.Image)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -348,7 +348,7 @@ func (p *pdfImporter) processOverprint(nodes []pdfCompositeNode) (bool, error) {
|
||||
}
|
||||
style, fill, stroke := node.style()
|
||||
if node.image != nil && style.FillOverprint {
|
||||
if _, found, err := pdfImageProcessColorants(node.image.Image); err != nil || found {
|
||||
if _, found, err := codecpdf.ImageProcessColorants(node.image.Image); err != nil || found {
|
||||
return found, err
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ func (p *pdfImporter) processOverprint(nodes []pdfCompositeNode) (bool, error) {
|
||||
if native := codecpdf.PrepareShadingColorants(paint, &pdfgo.ColorSpace{Model: "DeviceCMYK"}, false); native != nil {
|
||||
return true, nil
|
||||
}
|
||||
native, _, err := pdfCompositeNativePaint(paint, &pdfgo.ColorSpace{Model: "DeviceCMYK"}, style.RenderingIntent)
|
||||
native, _, err := codecpdf.NativePaint(paint, &pdfgo.ColorSpace{Model: "DeviceCMYK"}, style.RenderingIntent)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -21,12 +21,20 @@ import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfPointBounds 逐点汇总边界,不保留文字或路径的临时坐标数组
|
||||
type pdfPointBounds struct {
|
||||
minX, minY, maxX, maxY float64
|
||||
found bool
|
||||
}
|
||||
|
||||
// pdfGeometryPath 转换PDF路径并衔接OFD几何类型
|
||||
// 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换
|
||||
// 返回: GeometryPath 独立几何路径, error 指令、坐标或取消错误
|
||||
@@ -203,3 +211,278 @@ func (p *pdfImporter) coverageClip(paths []pdfgo.Path, geometry GeometryBackend)
|
||||
}
|
||||
return clip, nil
|
||||
}
|
||||
|
||||
// pdfGlyphPath 将OFD字形几何交给PDF路径适配器转换
|
||||
// 入参: outline 字形轮廓, matrix 字形到页面的变换
|
||||
// 返回: pdfgo.Path 页面路径, error 非字体轮廓指令
|
||||
func pdfGlyphPath(outline GeometryPath, matrix pdfgo.Matrix) (pdfgo.Path, error) {
|
||||
return codecpdf.GlyphPath(basegeom.Path(outline), matrix)
|
||||
}
|
||||
|
||||
// pdfNumbers 按OFD数值格式序列化坐标
|
||||
// 入参: values 数值列表
|
||||
// 返回: string 空格分隔的数值
|
||||
func pdfNumbers(values ...float64) string {
|
||||
var buffer [128]byte
|
||||
data := buffer[:0]
|
||||
for n, v := range values {
|
||||
if n != 0 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = strconv.AppendFloat(data, v, 'g', -1, 64)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// pdfDashPattern 换算PDF虚线长度,将单值数组展开为等长线段与间隔
|
||||
// 入参: dash PDF虚线数组, scale 坐标缩放比例
|
||||
// 返回: string OFD虚线数组
|
||||
func pdfDashPattern(dash []float64, scale float64) string {
|
||||
if len(dash) == 0 {
|
||||
return ""
|
||||
}
|
||||
values := make([]float64, max(2, len(dash)))
|
||||
for i, value := range dash {
|
||||
values[i] = value * scale
|
||||
}
|
||||
if len(dash) == 1 {
|
||||
values[1] = values[0]
|
||||
}
|
||||
return pdfNumbers(values...)
|
||||
}
|
||||
|
||||
// pdfBoundary 序列化对象边界
|
||||
// 入参: b 对象边界
|
||||
// 返回: string OFD边界数据
|
||||
func pdfBoundary(b Box) string { return pdfNumbers(b.X, b.Y, b.W, b.H) }
|
||||
|
||||
// pdfBounds 计算点集合的包围框
|
||||
// 入参: points 坐标点集合
|
||||
// 返回: Box 包围框
|
||||
func pdfBounds(points []pdfgo.Point) Box {
|
||||
var bounds pdfPointBounds
|
||||
for _, point := range points {
|
||||
bounds.add(point)
|
||||
}
|
||||
return bounds.box()
|
||||
}
|
||||
|
||||
// add 按原顺序累计一个控制点的最小和最大坐标
|
||||
// 入参: point 坐标点
|
||||
func (b *pdfPointBounds) add(point pdfgo.Point) {
|
||||
if !b.found {
|
||||
b.minX, b.minY, b.maxX, b.maxY = point.X, point.Y, point.X, point.Y
|
||||
b.found = true
|
||||
}
|
||||
b.minX, b.minY = math.Min(b.minX, point.X), math.Min(b.minY, point.Y)
|
||||
b.maxX, b.maxY = math.Max(b.maxX, point.X), math.Max(b.maxY, point.Y)
|
||||
}
|
||||
|
||||
// box 取得原精度边界,空集合仍返回零值
|
||||
// 返回: Box 坐标范围
|
||||
func (b *pdfPointBounds) box() Box {
|
||||
if !b.found {
|
||||
return Box{}
|
||||
}
|
||||
return Box{X: b.minX, Y: b.minY, W: math.Max(b.maxX-b.minX, 1e-6), H: math.Max(b.maxY-b.minY, 1e-6)}
|
||||
}
|
||||
|
||||
// pathBoundsContext 逐段计算变换后的控制点边界,不创建临时点集合
|
||||
// 入参: ctx 取消上下文, path PDF路径
|
||||
// 返回: Box 控制点边界,空路径返回零值, error 取消错误
|
||||
func (p *pdfImporter) pathBoundsContext(ctx context.Context, path pdfgo.Path) (Box, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
found := false
|
||||
for _, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
for index, point := range segment.Points {
|
||||
if index&255 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
}
|
||||
point = p.matrix.Apply(point)
|
||||
minX, minY = math.Min(minX, point.X), math.Min(minY, point.Y)
|
||||
maxX, maxY = math.Max(maxX, point.X), math.Max(maxY, point.Y)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Box{}, err
|
||||
}
|
||||
if !found {
|
||||
return Box{}, nil
|
||||
}
|
||||
return Box{X: minX, Y: minY, W: math.Max(maxX-minX, 1e-6), H: math.Max(maxY-minY, 1e-6)}, nil
|
||||
}
|
||||
|
||||
// pathDataContext 转换路径并在分段处理时检查取消
|
||||
// 入参: ctx 取消上下文, path PDF路径, origin 对象边界
|
||||
// 返回: string OFD路径数据, error 取消错误
|
||||
func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, origin Box) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := make([]byte, 0, len(path.Segments)*48)
|
||||
for index, segment := range path.Segments {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if index != 0 {
|
||||
data = append(data, ' ')
|
||||
}
|
||||
data = append(data, segment.Operator...)
|
||||
for _, point := range segment.Points {
|
||||
point = p.matrix.Apply(point)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.X-origin.X, 'g', -1, 64)
|
||||
data = append(data, ' ')
|
||||
data = strconv.AppendFloat(data, point.Y-origin.Y, 'g', -1, 64)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// path 合并相同状态下的不透明描边,保留独立子路径与绘制顺序
|
||||
// 入参: mark PDF路径绘制信息
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) path(mark pdfgo.PathMark) error {
|
||||
if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" {
|
||||
return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)}
|
||||
}
|
||||
if !mark.Stroke || mark.Fill || mark.Style.Stroke.Alpha != 1 {
|
||||
if err := p.flushPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.appendPath(mark)
|
||||
}
|
||||
if p.pendingPath != nil && (p.pendingPath.StrokeMatrix != mark.StrokeMatrix || !reflect.DeepEqual(p.pendingPath.Style, mark.Style)) {
|
||||
if err := p.flushPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.pendingPath == nil {
|
||||
p.pendingPath = &mark
|
||||
} else {
|
||||
p.pendingPath.Path.Segments = append(p.pendingPath.Path.Segments, mark.Path.Segments...)
|
||||
}
|
||||
if len(p.pendingPath.Path.Segments) >= 512 {
|
||||
return p.flushPath()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushPath 写入连续描边批次,保持后续图元的叠放位置
|
||||
// 返回: error 转换错误
|
||||
func (p *pdfImporter) flushPath() error {
|
||||
if p.pendingPath == nil {
|
||||
return nil
|
||||
}
|
||||
mark := *p.pendingPath
|
||||
p.pendingPath = nil
|
||||
return p.appendPath(mark)
|
||||
}
|
||||
|
||||
// appendPath 添加独立可编辑路径
|
||||
// 入参: mark PDF路径绘制信息
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
|
||||
var err error
|
||||
mark.Style, err = p.maskStyle(mark.Style)
|
||||
if err != nil {
|
||||
if p.rasterMaskAllowed(err) {
|
||||
mask := mark.Style.SoftMask
|
||||
mark.Style.SoftMask = nil
|
||||
return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Path(mark) })
|
||||
}
|
||||
return err
|
||||
}
|
||||
if mark.Fill && mark.Style.FillOverprint && pdfOverprintNeedsSeparation(mark.Style.Fill) || mark.Stroke && mark.Style.StrokeOverprint && pdfOverprintNeedsSeparation(mark.Style.Stroke) {
|
||||
return &pdfgo.UnsupportedError{Feature: "color separation overprint"}
|
||||
}
|
||||
if p.warning != nil && (mark.Fill && p.gradientError(mark.Style.Fill) != nil || mark.Stroke && p.gradientError(mark.Style.Stroke) != nil) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.gradientPath(mark)
|
||||
}
|
||||
box, err := p.pathBoundsContext(p.ctx, mark.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if box == (Box{}) {
|
||||
return fmt.Errorf("empty PDF painted path")
|
||||
}
|
||||
scale := math.Hypot(p.matrix[0], p.matrix[1])
|
||||
drawing, origin, ctm := *p, box, ""
|
||||
marginScale := scale
|
||||
if mark.StrokeMatrix != (pdfgo.Matrix{}) {
|
||||
inverse, ok := mark.StrokeMatrix.Inverse()
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid PDF stroke matrix")
|
||||
}
|
||||
drawing.matrix, origin, scale = inverse, Box{}, 1
|
||||
m := p.matrix.Mul(mark.StrokeMatrix)
|
||||
marginScale = math.Max(math.Hypot(m[0], m[2]), math.Hypot(m[1], m[3]))
|
||||
}
|
||||
if mark.Stroke {
|
||||
margin := mark.Style.LineWidth * marginScale / 2 * math.Max(1, mark.Style.MiterLimit)
|
||||
box = Box{X: box.X - margin, Y: box.Y - margin, W: box.W + 2*margin, H: box.H + 2*margin}
|
||||
}
|
||||
if mark.StrokeMatrix == (pdfgo.Matrix{}) {
|
||||
origin = box
|
||||
} else {
|
||||
m := p.matrix.Mul(mark.StrokeMatrix)
|
||||
m[4], m[5] = m[4]-box.X, m[5]-box.Y
|
||||
ctm = pdfNumbers(m[:]...)
|
||||
}
|
||||
paint := func(value pdfgo.Paint) (*FillColor, error) {
|
||||
if value.Tiling != nil || pdfGradientPattern(value) {
|
||||
return p.paintColor(value, box)
|
||||
}
|
||||
return drawing.paintColor(value, origin)
|
||||
}
|
||||
fillColor, strokePaint := p.color(mark.Style.Fill), p.color(mark.Style.Stroke)
|
||||
if mark.Fill {
|
||||
fillColor, err = paint(mark.Style.Fill)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if mark.Stroke {
|
||||
strokePaint, err = paint(mark.Style.Stroke)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
strokeColor := StrokeColor(*strokePaint)
|
||||
clips, err := p.clips(mark.Style.Clips, box)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := drawing.pathDataContext(p.ctx, mark.Path, origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object := PathObject{Boundary: pdfBoundary(box), CTM: ctm, AbbreviatedData: data, Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: fillColor, StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: clips}
|
||||
object.LineWidthSet = object.LineWidth == 0
|
||||
if mark.Path.EvenOdd {
|
||||
object.Rule = "Even-Odd"
|
||||
}
|
||||
if len(mark.Style.Dash) > 0 {
|
||||
object.DashPattern = pdfDashPattern(mark.Style.Dash, scale)
|
||||
phase := mark.Style.DashPhase * scale
|
||||
object.DashOffset = &phase
|
||||
}
|
||||
p.appendObject(GraphicObject{Type: "PathObject", PathObject: object})
|
||||
p.report.PathObjects++
|
||||
return nil
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
|
||||
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
@@ -743,3 +744,56 @@ func (p *pdfImporter) maskStyle(style pdfgo.Style) (pdfgo.Style, error) {
|
||||
style.SoftMask = nil
|
||||
return style, nil
|
||||
}
|
||||
|
||||
// rasterMaskAllowed 仅对不可矢量表达的蒙版启用显式局部合成,格式错误仍返回
|
||||
// 入参: err 蒙版转换错误
|
||||
// 返回: bool 是否允许局部合成
|
||||
func (p *pdfImporter) rasterMaskAllowed(err error) bool {
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
return p.warning != nil && errors.As(err, &unsupported)
|
||||
}
|
||||
|
||||
// applySoftMask 按原始颜色分量计算蒙版,逐块应用透明度及亮度传递
|
||||
// 入参: source 源图像, box 页面区域, mask PDF软蒙版
|
||||
// 返回: image.Image 合成图像, error 蒙版解释或渲染错误
|
||||
func (p *pdfImporter) applySoftMask(source image.Image, box Box, mask *pdfgo.SoftMask) (image.Image, error) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inverse, ok := p.matrix.Inverse()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid PDF mask transform")
|
||||
}
|
||||
out := image.NewNRGBA(source.Bounds())
|
||||
w, h := out.Rect.Dx(), out.Rect.Dy()
|
||||
step := 25.4 / p.rasterDPI
|
||||
cache := p.compositingCache()
|
||||
scratch := &pdfCompositeScratch{}
|
||||
space := &pdfgo.ColorSpace{Model: "DeviceRGB"}
|
||||
for y := 0; y < h; y += pdfCompositeTileSize {
|
||||
for x := 0; x < w; x += pdfCompositeTileSize {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
width, height := min(pdfCompositeTileSize, w-x), min(pdfCompositeTileSize, h-y)
|
||||
c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, scratch: scratch, masks: map[*pdfgo.SoftMask][]float64{}}
|
||||
alpha, err := c.mask(mask, space)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, opacity := range alpha {
|
||||
if i%width == 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
px, py := out.Rect.Min.X+x+i%width, out.Rect.Min.Y+y+i/width
|
||||
pixel := baseimage.NRGBAAt(source, px, py)
|
||||
pixel.A = uint8(math.Round(float64(pixel.A) * opacity))
|
||||
out.SetNRGBA(px, py, pixel)
|
||||
}
|
||||
c.releaseMasks()
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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 "github.com/xiaoqidun/pdfgo"
|
||||
|
||||
// halftone 检查连续色调转换,避免将打印网屏误用于屏幕显示
|
||||
// 入参: style 图形状态
|
||||
// 返回: error 严格模式无法保留设备网屏
|
||||
func (p *pdfImporter) halftone(style pdfgo.Style) error {
|
||||
h := style.Halftone
|
||||
message := "PDF continuous-tone appearance retained; device halftone not transferred to OFD"
|
||||
if h == nil || h.Type == 0 {
|
||||
if style.HalftoneOrigin == nil {
|
||||
return nil
|
||||
}
|
||||
message = "PDF continuous-tone appearance retained; device halftone origin not transferred to OFD"
|
||||
}
|
||||
if p.warning == nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "device halftone conversion"}
|
||||
}
|
||||
if p.halftoneWarnings == nil {
|
||||
p.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
||||
}
|
||||
if !p.halftoneWarnings[h] {
|
||||
p.halftoneWarnings[h] = true
|
||||
p.warning(pdfgo.Diagnostic{Message: message})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfImageKey 区分图像流、渲染意图、原生输出空间、模板填充色及采样尺寸
|
||||
type pdfImageKey struct {
|
||||
stream *pdfgo.Stream
|
||||
intent pdfgo.Name
|
||||
process pdfgo.Name
|
||||
tint color.NRGBA64
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// pdfImageResource 保存图像的有效颜色空间及对应资源,避免跨资源重映射复用
|
||||
type pdfImageResource struct {
|
||||
space pdfgo.Object
|
||||
id string
|
||||
}
|
||||
|
||||
// pdfImageContentKey 区分编码内容、渲染意图、原生输出空间、模板填充色及有损采样尺寸
|
||||
type pdfImageContentKey struct {
|
||||
data [32]byte
|
||||
intent pdfgo.Name
|
||||
process pdfgo.Name
|
||||
tint color.NRGBA64
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// pdfImageContent 保存不依赖间接资源的图像描述及已注册编号
|
||||
type pdfImageContent struct {
|
||||
source *pdfgo.Image
|
||||
id string
|
||||
}
|
||||
|
||||
// image 转换图像样本,不将整页或其他对象栅格化
|
||||
// 有损编码复用已解码像素,候选容量超限时沿用原编码路径
|
||||
// 入参: mark PDF图像绘制信息
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" {
|
||||
return &pdfgo.UnsupportedError{Feature: "image blend mode " + string(mark.Style.BlendMode)}
|
||||
}
|
||||
if err := p.flushPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
if mark.Image.ImageMask && (mark.Style.Fill.Axial != nil || mark.Style.Fill.Radial != nil || mark.Style.Fill.Function != nil || mark.Style.Fill.Mesh != nil || mark.Style.Fill.Shading != nil) {
|
||||
if p.warning != nil {
|
||||
return p.compositeRegion(nil, pdfCompositeNode{image: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false)
|
||||
}
|
||||
return &pdfgo.UnsupportedError{Feature: "gradient stencil image"}
|
||||
}
|
||||
var err error
|
||||
mark.Style, err = p.maskStyle(mark.Style)
|
||||
if err != nil {
|
||||
if p.rasterMaskAllowed(err) {
|
||||
mask := mark.Style.SoftMask
|
||||
mark.Style.SoftMask = nil
|
||||
return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Image(mark) })
|
||||
}
|
||||
return err
|
||||
}
|
||||
if mark.Style.FillOverprint {
|
||||
return &pdfgo.UnsupportedError{Feature: "image color separation overprint"}
|
||||
}
|
||||
source := mark.Image
|
||||
key := pdfImageKey{stream: source.Stream, intent: source.Intent}
|
||||
key.process, err = p.imageProcessModel(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
m := p.matrix.Mul(mark.Matrix)
|
||||
key.size = compressionImageSize(math.Hypot(m[0], m[1]), math.Hypot(m[2], m[3]), p.compression.ImageDPI())
|
||||
if key.size.X >= source.Width || key.size.Y >= source.Height {
|
||||
key.size = image.Point{}
|
||||
}
|
||||
}
|
||||
if source.ImageMask {
|
||||
fill := mark.Style.Fill.RGB
|
||||
if values := mark.Style.Fill.CMYK; values != nil {
|
||||
space := &pdfgo.ColorSpace{Model: "DeviceCMYK"}
|
||||
fill, err = space.RGB(values[:], mark.Style.RenderingIntent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
key.tint = color.NRGBA64{
|
||||
R: uint16(math.Round(fill[0] * 65535)),
|
||||
G: uint16(math.Round(fill[1] * 65535)),
|
||||
B: uint16(math.Round(fill[2] * 65535)),
|
||||
A: 65535,
|
||||
}
|
||||
}
|
||||
id := ""
|
||||
for _, resource := range p.imageIDs[key] {
|
||||
if reflect.DeepEqual(resource.space, source.ColorSpace) {
|
||||
id = resource.id
|
||||
break
|
||||
}
|
||||
}
|
||||
streamCached := id != ""
|
||||
var contentKey pdfImageContentKey
|
||||
cacheable := false
|
||||
if id == "" {
|
||||
contentKey, cacheable, err = p.imageContentKey(source, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheable {
|
||||
if p.imageContents == nil {
|
||||
p.imageContents = &renderCache[pdfImageContentKey, pdfImageContent]{limit: 8 << 20}
|
||||
}
|
||||
if cached, found := p.imageContents.get(contentKey); found && cached.matches(source) {
|
||||
id = cached.id
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
var jbig2Original, jpegOriginal []byte
|
||||
var decoded image.Image
|
||||
if key.process != "" {
|
||||
decoded, err = p.imageProcess(source, key.process)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key.size != (image.Point{}) {
|
||||
decoded, err = pdfgo.ResizeImage(p.ctx, decoded, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
jbig2Original, err = source.JBIG2FileContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jpegOriginal, err = source.JPEGFileContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(jbig2Original) == 0 && len(jpegOriginal) == 0 && (source.ImageMask || key.size != (image.Point{})) {
|
||||
decoded, err = source.DecodeImageSizeContext(p.ctx, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if source.ImageMask {
|
||||
decoded = &codecpdf.StencilImage{Source: decoded, Fill: key.tint}
|
||||
}
|
||||
data := jbig2Original
|
||||
if len(data) == 0 {
|
||||
data = jpegOriginal
|
||||
}
|
||||
optimized := false
|
||||
if len(data) == 0 && decoded != nil && !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
data, err = pdfgo.EncodeImageResource(p.ctx, decoded, p.compression)
|
||||
if err != nil && !errors.Is(err, io.ErrShortBuffer) {
|
||||
return err
|
||||
}
|
||||
optimized = err == nil
|
||||
}
|
||||
if len(data) == 0 {
|
||||
var encoded bytes.Buffer
|
||||
if decoded != nil {
|
||||
err = pdfgo.EncodePNG(p.ctx, &encoded, decoded)
|
||||
} else {
|
||||
err = source.EncodePNGContext(p.ctx, &encoded)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = encoded.Bytes()
|
||||
}
|
||||
if !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
if !optimized {
|
||||
data, err = pdfgo.OptimizeImageResource(p.ctx, data, p.compression, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
decoded = nil
|
||||
}
|
||||
id, err = p.editor.addDecodedImageContext(p.ctx, data, decoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheable {
|
||||
p.imageContents.put(contentKey, pdfImageContent{source: source, id: id}, len(source.Stream.Data)+65536)
|
||||
}
|
||||
}
|
||||
if !streamCached {
|
||||
if p.imageIDs == nil {
|
||||
p.imageIDs = map[pdfImageKey][]pdfImageResource{}
|
||||
}
|
||||
p.imageIDs[key] = append(p.imageIDs[key], pdfImageResource{space: source.ColorSpace, id: id})
|
||||
}
|
||||
return p.appendImage(mark, id)
|
||||
}
|
||||
|
||||
// appendImage 定位已注册的图像资源,保留透明度与裁剪
|
||||
// 入参: mark 图像绘制信息, id 资源编号
|
||||
// 返回: error 裁剪错误
|
||||
func (p *pdfImporter) appendImage(mark pdfgo.ImageMark, id string) error {
|
||||
m := p.matrix.Mul(mark.Matrix).Mul(pdfgo.Matrix{1, 0, 0, -1, 0, 1})
|
||||
box := pdfBounds([]pdfgo.Point{m.Apply(pdfgo.Point{}), m.Apply(pdfgo.Point{X: 1}), m.Apply(pdfgo.Point{Y: 1}), m.Apply(pdfgo.Point{X: 1, Y: 1})})
|
||||
alpha := int(math.Round(mark.Style.Fill.Alpha * 255))
|
||||
clips, err := p.clips(mark.Style.Clips, box)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object := ImageObject{Boundary: pdfBoundary(box), ResourceID: id, CTM: pdfNumbers(m[0], m[1], m[2], m[3], m[4]-box.X, m[5]-box.Y), Alpha: &alpha, Clips: clips}
|
||||
p.appendObject(GraphicObject{Type: "ImageObject", ImageObject: object})
|
||||
p.report.ImageObjects++
|
||||
return nil
|
||||
}
|
||||
|
||||
// imageContentKey 对无间接依赖的小图像生成内容键,超大图像仍沿用流缓存
|
||||
// 入参: source 图像描述, key 流缓存键
|
||||
// 返回: pdfImageContentKey 内容键, bool 是否可复用, error 取消错误
|
||||
func (p *pdfImporter) imageContentKey(source *pdfgo.Image, key pdfImageKey) (pdfImageContentKey, bool, error) {
|
||||
digest, reusable, err := codecpdf.ImageDigest(p.ctx, source)
|
||||
if !reusable {
|
||||
return pdfImageContentKey{}, reusable, err
|
||||
}
|
||||
return pdfImageContentKey{data: digest, intent: key.intent, process: key.process, tint: key.tint, size: key.size}, true, err
|
||||
}
|
||||
|
||||
// matches 核对完整编码与有效图像属性,不以摘要相同代替内容相同
|
||||
// 入参: source 待复用图像
|
||||
// 返回: bool 是否可使用同一图像资源
|
||||
func (c pdfImageContent) matches(source *pdfgo.Image) bool {
|
||||
return codecpdf.SameImage(c.source, source)
|
||||
}
|
||||
|
||||
// imageProcessModel 选择与页面原生过程色一致的图像输出,已等价的备用色沿用现有编码路径
|
||||
// 入参: source 源图像
|
||||
// 返回: pdfgo.Name 原生输出空间,无需重映射时为空, error 色料解析错误
|
||||
func (p *pdfImporter) imageProcessModel(source *pdfgo.Image) (pdfgo.Name, error) {
|
||||
space := p.compositeSpace
|
||||
if source.ImageMask || !space.Device() {
|
||||
return "", nil
|
||||
}
|
||||
colorants, err := source.ColorantSpace()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
process := colorants.PrepareProcess(&pdfgo.ColorantDevice{Space: space}, space, false)
|
||||
if process == nil || process.Mask() == ([4]bool{}) || process.AlternateEquivalent() {
|
||||
return "", nil
|
||||
}
|
||||
return space.Model, nil
|
||||
}
|
||||
|
||||
// imageProcess 复用源分量并保留原始分辨率,颜色变换随编码逐像素执行
|
||||
// 入参: source 源图像, model 原生设备空间
|
||||
// 返回: image.Image 只读图像, error 解码或分量映射错误
|
||||
func (p *pdfImporter) imageProcess(source *pdfgo.Image, model pdfgo.Name) (image.Image, error) {
|
||||
cache := p.compositingCache()
|
||||
space := &pdfgo.ColorSpace{Model: model}
|
||||
process, err := cache.imageProcess(p, source, space, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if process == nil {
|
||||
return nil, fmt.Errorf("invalid native PDF image process space %s", model)
|
||||
}
|
||||
return &codecpdf.NativeImage{Source: process, Space: space}, nil
|
||||
}
|
||||
|
||||
// imageProcess 复用同组原生采样,仅在需要备用颜色时保留完整分量
|
||||
// 入参: importer 导入器, source 源图像, space 组混合空间, softMask 是否用于软蒙版
|
||||
// 返回: *pdfgo.ImageProcess 原生采样,需要备用色时为nil, error 解码或布局错误
|
||||
func (c *pdfCompositeCache) imageProcess(importer *pdfImporter, source *pdfgo.Image, space *pdfgo.ColorSpace, softMask bool) (*pdfgo.ImageProcess, error) {
|
||||
key := pdfImageProcessKey{image: source, space: space, softMask: softMask}
|
||||
if process, found := c.imageProcesses[key]; found {
|
||||
return process, nil
|
||||
}
|
||||
device := &pdfgo.ColorantDevice{Space: space}
|
||||
var process *pdfgo.ImageProcess
|
||||
var err error
|
||||
if components := c.images[source]; components != nil {
|
||||
process, err = components.PrepareProcess(device, space, softMask)
|
||||
} else {
|
||||
process, err = source.DecodeProcessContext(importer.ctx, device, space, softMask)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.imageProcesses == nil {
|
||||
c.imageProcesses = make(map[pdfImageProcessKey]*pdfgo.ImageProcess)
|
||||
}
|
||||
c.imageProcesses[key] = process
|
||||
return process, nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// 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 (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfImageContentKey 区分编码内容、渲染意图、原生输出空间、模板填充色及有损采样尺寸
|
||||
type pdfImageContentKey struct {
|
||||
data [32]byte
|
||||
intent pdfgo.Name
|
||||
process pdfgo.Name
|
||||
tint color.NRGBA64
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// pdfImageContent 保存不依赖间接资源的图像描述及已注册编号
|
||||
type pdfImageContent struct {
|
||||
source *pdfgo.Image
|
||||
id string
|
||||
}
|
||||
|
||||
// imageContentKey 对无间接依赖的小图像生成内容键,超大图像仍沿用流缓存
|
||||
// 入参: source 图像描述, key 流缓存键
|
||||
// 返回: pdfImageContentKey 内容键, bool 是否可复用, error 取消错误
|
||||
func (p *pdfImporter) imageContentKey(source *pdfgo.Image, key pdfImageKey) (pdfImageContentKey, bool, error) {
|
||||
digest, reusable, err := codecpdf.ImageDigest(p.ctx, source)
|
||||
if !reusable {
|
||||
return pdfImageContentKey{}, reusable, err
|
||||
}
|
||||
return pdfImageContentKey{data: digest, intent: key.intent, process: key.process, tint: key.tint, size: key.size}, true, err
|
||||
}
|
||||
|
||||
// matches 核对完整编码与有效图像属性,不以摘要相同代替内容相同
|
||||
// 入参: source 待复用图像
|
||||
// 返回: bool 是否可使用同一图像资源
|
||||
func (c pdfImageContent) matches(source *pdfgo.Image) bool {
|
||||
return codecpdf.SameImage(c.source, source)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// imageProcessModel 选择与页面原生过程色一致的图像输出,已等价的备用色沿用现有编码路径
|
||||
// 入参: source 源图像
|
||||
// 返回: pdfgo.Name 原生输出空间,无需重映射时为空, error 色料解析错误
|
||||
func (p *pdfImporter) imageProcessModel(source *pdfgo.Image) (pdfgo.Name, error) {
|
||||
space := p.compositeSpace
|
||||
if source.ImageMask || !space.Device() {
|
||||
return "", nil
|
||||
}
|
||||
colorants, err := source.ColorantSpace()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
process := colorants.PrepareProcess(&pdfgo.ColorantDevice{Space: space}, space, false)
|
||||
if process == nil || process.Mask() == ([4]bool{}) || process.AlternateEquivalent() {
|
||||
return "", nil
|
||||
}
|
||||
return space.Model, nil
|
||||
}
|
||||
|
||||
// imageProcess 复用源分量并保留原始分辨率,颜色变换随编码逐像素执行
|
||||
// 入参: source 源图像, model 原生设备空间
|
||||
// 返回: image.Image 只读图像, error 解码或分量映射错误
|
||||
func (p *pdfImporter) imageProcess(source *pdfgo.Image, model pdfgo.Name) (image.Image, error) {
|
||||
cache := p.compositingCache()
|
||||
space := &pdfgo.ColorSpace{Model: model}
|
||||
process, err := cache.imageProcess(p, source, space, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if process == nil {
|
||||
return nil, fmt.Errorf("invalid native PDF image process space %s", model)
|
||||
}
|
||||
return &codecpdf.NativeImage{Source: process, Space: space}, nil
|
||||
}
|
||||
|
||||
// imageProcess 复用同组原生采样,仅在需要备用颜色时保留完整分量
|
||||
// 入参: importer 导入器, source 源图像, space 组混合空间, softMask 是否用于软蒙版
|
||||
// 返回: *pdfgo.ImageProcess 原生采样,需要备用色时为nil, error 解码或布局错误
|
||||
func (c *pdfCompositeCache) imageProcess(importer *pdfImporter, source *pdfgo.Image, space *pdfgo.ColorSpace, softMask bool) (*pdfgo.ImageProcess, error) {
|
||||
key := pdfImageProcessKey{image: source, space: space, softMask: softMask}
|
||||
if process, found := c.imageProcesses[key]; found {
|
||||
return process, nil
|
||||
}
|
||||
device := &pdfgo.ColorantDevice{Space: space}
|
||||
var process *pdfgo.ImageProcess
|
||||
var err error
|
||||
if components := c.images[source]; components != nil {
|
||||
process, err = components.PrepareProcess(device, space, softMask)
|
||||
} else {
|
||||
process, err = source.DecodeProcessContext(importer.ctx, device, space, softMask)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.imageProcesses == nil {
|
||||
c.imageProcesses = make(map[pdfImageProcessKey]*pdfgo.ImageProcess)
|
||||
}
|
||||
c.imageProcesses[key] = process
|
||||
return process, nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -36,6 +37,32 @@ type pdfEmbeddedFile struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// documentAttachments 导入文档名称树中的附件,与注解及动作引用共用附件资源
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 名称树、附件读取或写入错误
|
||||
func (p *pdfImporter) documentAttachments(ctx context.Context) error {
|
||||
return p.reader.WalkEmbeddedFiles(ctx, func(_ string, file pdfgo.FileSpecification) error {
|
||||
if file.Embedded == nil {
|
||||
if p.warning == nil {
|
||||
return fmt.Errorf("PDF embedded file has no embedded data")
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF embedded file without embedded data ignored"})
|
||||
return nil
|
||||
}
|
||||
data, err := file.Embedded.DecodeContext(ctx)
|
||||
if err != nil {
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && errors.As(err, &unsupported) {
|
||||
p.warning(pdfgo.Diagnostic{Message: "PDF document attachment not transferred to OFD: " + err.Error()})
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = p.attachmentFile(file.Name, data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// launchLinkAction 保留文件打开目标,不执行应用程序或猜测平台启动参数
|
||||
// 入参: object 启动动作, strict 是否禁止启动语义损失
|
||||
// 返回: *Action 附件动作, error 解析、取消或无法表达的语义
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
|
||||
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// rasterMaskAllowed 仅对不可矢量表达的蒙版启用显式局部合成,格式错误仍返回
|
||||
// 入参: err 蒙版转换错误
|
||||
// 返回: bool 是否允许局部合成
|
||||
func (p *pdfImporter) rasterMaskAllowed(err error) bool {
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
return p.warning != nil && errors.As(err, &unsupported)
|
||||
}
|
||||
|
||||
// applySoftMask 按原始颜色分量计算蒙版,逐块应用透明度及亮度传递
|
||||
// 入参: source 源图像, box 页面区域, mask PDF软蒙版
|
||||
// 返回: image.Image 合成图像, error 蒙版解释或渲染错误
|
||||
func (p *pdfImporter) applySoftMask(source image.Image, box Box, mask *pdfgo.SoftMask) (image.Image, error) {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inverse, ok := p.matrix.Inverse()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid PDF mask transform")
|
||||
}
|
||||
out := image.NewNRGBA(source.Bounds())
|
||||
w, h := out.Rect.Dx(), out.Rect.Dy()
|
||||
step := 25.4 / p.rasterDPI
|
||||
cache := p.compositingCache()
|
||||
scratch := &pdfCompositeScratch{}
|
||||
space := &pdfgo.ColorSpace{Model: "DeviceRGB"}
|
||||
for y := 0; y < h; y += pdfCompositeTileSize {
|
||||
for x := 0; x < w; x += pdfCompositeTileSize {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
width, height := min(pdfCompositeTileSize, w-x), min(pdfCompositeTileSize, h-y)
|
||||
c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, scratch: scratch, masks: map[*pdfgo.SoftMask][]float64{}}
|
||||
alpha, err := c.mask(mask, space)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, opacity := range alpha {
|
||||
if i%width == 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
px, py := out.Rect.Min.X+x+i%width, out.Rect.Min.Y+y+i/width
|
||||
pixel := baseimage.NRGBAAt(source, px, py)
|
||||
pixel.A = uint8(math.Round(float64(pixel.A) * opacity))
|
||||
out.SetNRGBA(px, py, pixel)
|
||||
}
|
||||
c.releaseMasks()
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -15,8 +15,13 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
@@ -68,12 +73,10 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
||||
matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height,
|
||||
rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string),
|
||||
resolveFile: p.resolveFile, resolveReference: p.resolveReference,
|
||||
referenceReaders: make(map[[32]byte]*pdfgo.Reader),
|
||||
referenceStreams: make(map[*pdfgo.Stream]*pdfgo.Reader),
|
||||
referencePages: make(map[pdfReferencePageKey]*pdfgo.Page),
|
||||
references: &codecpdf.ReferenceCache{},
|
||||
halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool),
|
||||
}
|
||||
defer local.closeReferenceReaders()
|
||||
defer local.references.Close()
|
||||
if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error {
|
||||
return p.reader.WalkPage(p.ctx, page, visitor)
|
||||
}); err != nil {
|
||||
@@ -123,3 +126,102 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
|
||||
p.destinationBoxes[page.Reference] = box
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 严格模式转换错误或取消错误
|
||||
func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||
count := 0
|
||||
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
||||
x.root("Outlines", nil)
|
||||
depth := 0
|
||||
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
||||
actions, err := p.outlineActions(ctx, item)
|
||||
if err != nil {
|
||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)})
|
||||
actions = nil
|
||||
}
|
||||
for depth > level {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}}
|
||||
attrs.add("Expanded", strconv.FormatBool(item.Expanded))
|
||||
x.start("OutlineElem", attrs)
|
||||
x.actions(actions)
|
||||
depth++
|
||||
count++
|
||||
return x.err
|
||||
})
|
||||
if err != nil {
|
||||
x.err = err
|
||||
return
|
||||
}
|
||||
for depth > 0 {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
x.end("Outlines")
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)})
|
||||
return nil
|
||||
}
|
||||
if count > 0 {
|
||||
p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转
|
||||
// 入参: ctx 取消上下文, item PDF目录节点
|
||||
// 返回: []Action OFD动作序列, error 动作或取消错误
|
||||
func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) {
|
||||
object, err := p.reader.Resolve(item.Action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dest, err := p.reader.Resolve(item.Destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dest != nil {
|
||||
if object != nil {
|
||||
return nil, fmt.Errorf("PDF outline has both destination and action")
|
||||
}
|
||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest}
|
||||
}
|
||||
currentPage := -1
|
||||
var actions []Action
|
||||
err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)})
|
||||
currentPage = -1
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if converted != nil {
|
||||
actions = append(actions, converted...)
|
||||
} else {
|
||||
currentPage = -1
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return actions, err
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 严格模式转换错误或取消错误
|
||||
func (p *pdfImporter) outlines(ctx context.Context) error {
|
||||
count := 0
|
||||
data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) {
|
||||
x.root("Outlines", nil)
|
||||
depth := 0
|
||||
err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error {
|
||||
actions, err := p.outlineActions(ctx, item)
|
||||
if err != nil {
|
||||
if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)})
|
||||
actions = nil
|
||||
}
|
||||
for depth > level {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}}
|
||||
attrs.add("Expanded", strconv.FormatBool(item.Expanded))
|
||||
x.start("OutlineElem", attrs)
|
||||
x.actions(actions)
|
||||
depth++
|
||||
count++
|
||||
return x.err
|
||||
})
|
||||
if err != nil {
|
||||
x.err = err
|
||||
return
|
||||
}
|
||||
for depth > 0 {
|
||||
x.end("OutlineElem")
|
||||
depth--
|
||||
}
|
||||
x.end("Outlines")
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)})
|
||||
return nil
|
||||
}
|
||||
if count > 0 {
|
||||
p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转
|
||||
// 入参: ctx 取消上下文, item PDF目录节点
|
||||
// 返回: []Action OFD动作序列, error 动作或取消错误
|
||||
func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) {
|
||||
object, err := p.reader.Resolve(item.Action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dest, err := p.reader.Resolve(item.Destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dest != nil {
|
||||
if object != nil {
|
||||
return nil, fmt.Errorf("PDF outline has both destination and action")
|
||||
}
|
||||
object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest}
|
||||
}
|
||||
currentPage := -1
|
||||
var actions []Action
|
||||
err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage)
|
||||
if err != nil {
|
||||
if errors.Is(err, errPDFMediaRead) {
|
||||
return err
|
||||
}
|
||||
var unsupported *pdfgo.UnsupportedError
|
||||
if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) {
|
||||
p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)})
|
||||
currentPage = -1
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if converted != nil {
|
||||
actions = append(actions, converted...)
|
||||
} else {
|
||||
currentPage = -1
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return actions, err
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
|
||||
@@ -127,3 +128,91 @@ func pdfPatternCellCost(value reflect.Value, budget, depth int) int {
|
||||
}
|
||||
return cost
|
||||
}
|
||||
|
||||
// buildTilingPattern 保留PDF图案单元与页面坐标之间的仿射关系
|
||||
// 入参: source 平铺图案, base 无色图案基色
|
||||
// 返回: *Pattern OFD图案, error 转换错误
|
||||
func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo.Paint) (*Pattern, error) {
|
||||
const unit = 25.4 / 72
|
||||
box := source.BBox
|
||||
width, height := box.XMax-box.XMin, box.YMax-box.YMin
|
||||
xstep, ystep := math.Abs(source.XStep), math.Abs(source.YStep)
|
||||
if width <= 0 || height <= 0 || !finite(xstep) || !finite(ystep) || xstep == 0 || ystep == 0 {
|
||||
return nil, fmt.Errorf("invalid PDF tiling pattern dimensions")
|
||||
}
|
||||
localToPDF := pdfgo.Matrix{1 / unit, 0, 0, -1 / unit, box.XMin, box.YMax}
|
||||
m := p.matrix.Mul(source.Matrix).Mul(localToPDF)
|
||||
pattern := &Pattern{Width: math.Min(width, xstep) * unit, Height: math.Min(height, ystep) * unit, XStep: xstep * unit, YStep: ystep * unit, RelativeTo: "Page", CTM: pdfNumbers(m[:]...)}
|
||||
cell := *p
|
||||
cell.matrix = pdfgo.Matrix{unit, 0, 0, -unit, -box.XMin * unit, box.YMax * unit}
|
||||
cell.pageWidth, cell.pageHeight = width*unit, height*unit
|
||||
cell.rasterDPI *= m.MaxScale()
|
||||
if !finite(cell.rasterDPI) || cell.rasterDPI < 0 {
|
||||
return nil, fmt.Errorf("invalid PDF tiling pattern raster scale")
|
||||
}
|
||||
cell.objects, cell.pendingPath = nil, nil
|
||||
if err := source.Walk(p.ctx, base, cell.visitor()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cell.flushPath(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects, err := cell.objects.data(p.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pattern.CellContent.Objects = objects
|
||||
if len(objects) != 0 && (width > xstep || height > ystep) {
|
||||
columns, rows := math.Ceil(width/xstep), math.Ceil(height/ystep)
|
||||
if !finite(columns*rows) || columns*rows >= float64(uint(^uint(0)>>1)+1) {
|
||||
return nil, fmt.Errorf("PDF tiling pattern instance count overflow")
|
||||
}
|
||||
bounds := make([]Box, len(objects))
|
||||
for index, object := range objects {
|
||||
boundary := ""
|
||||
if object.Type == "PathObject" && object.PathObject.Stroke != nil && !*object.PathObject.Stroke {
|
||||
boundary = object.PathObject.Boundary
|
||||
} else if object.Type == "ImageObject" && object.ImageObject.Border == nil {
|
||||
boundary = object.ImageObject.Boundary
|
||||
}
|
||||
if boundary != "" {
|
||||
var err error
|
||||
bounds[index], err = ParseBox(boundary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
left, right, top, bottom := pdfPatternInstanceRange(columns, rows, bounds, pattern.Width, pattern.Height, xstep*unit, ystep*unit)
|
||||
var expanded pdfObjectBuffer
|
||||
for row := top; row <= bottom; row++ {
|
||||
for column := left; column <= right; column++ {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dx, dy := float64(column)*xstep*unit, float64(row)*ystep*unit
|
||||
for index, object := range objects {
|
||||
if index%pdfObjectChunkLimit == 0 {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b := bounds[index]
|
||||
if b.W > 0 && b.H > 0 && (b.X+dx+b.W < 0 || b.Y+dy+b.H < 0 || b.X+dx > pattern.Width || b.Y+dy > pattern.Height) {
|
||||
continue
|
||||
}
|
||||
translated, err := transformEditorObject(object, dx, dy, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expanded.append(translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
pattern.CellContent.Objects, err = expanded.data(p.ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return pattern, nil
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfReferencePageKey 区分目标文档、索引和文本标签,避免重复遍历页面树
|
||||
type pdfReferencePageKey struct {
|
||||
reader *pdfgo.Reader
|
||||
index int64
|
||||
label string
|
||||
labeled bool
|
||||
}
|
||||
|
||||
// referencePage 解析引用目标,目标不可用时按PDF标准保留代理内容
|
||||
// 入参: ctx 取消上下文, source 来源文档, reference 引用目标
|
||||
// 返回: *pdfgo.Page 目标页面,不可用时为空, error 取消或调用方解析错误
|
||||
func (p *pdfImporter) referencePage(ctx context.Context, source *pdfgo.Reader, reference pdfgo.ReferenceXObject) (*pdfgo.Page, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.resolveReference != nil {
|
||||
return p.resolveReference(ctx, source, reference)
|
||||
}
|
||||
reader := p.referenceStreams[reference.File.Embedded]
|
||||
if reader == nil {
|
||||
if reference.File.Embedded == nil && p.resolveFile == nil {
|
||||
return p.referenceProxy(ctx, fmt.Errorf("external reference file unavailable"))
|
||||
}
|
||||
data, err := source.ReadFileData(ctx, reference.File, p.resolveFile)
|
||||
if err != nil {
|
||||
return p.referenceProxy(ctx, err)
|
||||
}
|
||||
key := sha256.Sum256(data)
|
||||
reader = p.referenceReaders[key]
|
||||
if reader == nil {
|
||||
reader, err = pdfgo.NewReaderWithOptions(bytes.NewReader(data), int64(len(data)), pdfgo.ReaderOptions{Warning: p.warning})
|
||||
if err != nil {
|
||||
return p.referenceProxy(ctx, err)
|
||||
}
|
||||
p.referenceReaders[key] = reader
|
||||
}
|
||||
if reference.File.Embedded != nil {
|
||||
p.referenceStreams[reference.File.Embedded] = reader
|
||||
}
|
||||
}
|
||||
key := pdfReferencePageKey{reader: reader, index: reference.PageIndex}
|
||||
if reference.PageLabel != nil {
|
||||
key.index, key.label, key.labeled = 0, *reference.PageLabel, true
|
||||
}
|
||||
if page := p.referencePages[key]; page != nil {
|
||||
return page, ctx.Err()
|
||||
}
|
||||
page, err := reader.ResolveReferencePage(ctx, reference)
|
||||
if err != nil {
|
||||
return p.referenceProxy(ctx, err)
|
||||
}
|
||||
p.referencePages[key] = page
|
||||
return page, ctx.Err()
|
||||
}
|
||||
|
||||
// referenceProxy 记录目标不可用原因,不将合法的代理绘制视为转换失败
|
||||
// 入参: ctx 取消上下文, cause 目标不可用原因
|
||||
// 返回: *pdfgo.Page 空目标, error 取消错误
|
||||
func (p *pdfImporter) referenceProxy(ctx context.Context, cause error) (*pdfgo.Page, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||
return nil, cause
|
||||
}
|
||||
if p.warning != nil {
|
||||
p.warning(pdfgo.Diagnostic{Message: "reference XObject proxy retained: " + cause.Error()})
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// closeReferenceReaders 释放本次转换打开的引用文档,不关闭调用方提供的阅读器
|
||||
func (p *pdfImporter) closeReferenceReaders() {
|
||||
for _, reader := range p.referenceReaders {
|
||||
reader.Close()
|
||||
}
|
||||
p.referenceReaders, p.referenceStreams = nil, nil
|
||||
p.referencePages = nil
|
||||
}
|
||||
@@ -20,12 +20,32 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"mime"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfRenditionImport 保存可转换事件中的屏幕关联,首次遇到呈现动作时建立
|
||||
type pdfRenditionImport struct {
|
||||
actions map[pdfgo.Reference][]pdfgo.Dictionary
|
||||
media map[pdfgo.Reference][]pdfRenditionResource
|
||||
err error
|
||||
}
|
||||
|
||||
// pdfRenditionResource 保存屏幕候选媒体及其独立控制标识
|
||||
type pdfRenditionResource struct {
|
||||
ID string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// pdfRenditionAssignment 区分同一屏幕的呈现定义,重复触发不重复登记
|
||||
type pdfRenditionAssignment struct {
|
||||
Screen pdfgo.Reference
|
||||
Rendition uintptr
|
||||
}
|
||||
|
||||
// screenAnnotation 转换屏幕注解的音视频呈现,不忽略必须遵守的播放器约束
|
||||
// 入参: ctx 取消上下文, page PDF页面, annotation 屏幕注解, strict 严格检查开关
|
||||
// 返回: error 动作、媒体或外观错误
|
||||
@@ -259,3 +279,175 @@ func (p *pdfImporter) nativeRendition(rendition *pdfgo.Rendition) (Action, strin
|
||||
result.Movie = &Movie{Operator: "Play"}
|
||||
return result, "Video", true, nil
|
||||
}
|
||||
|
||||
// renditionControls 生成屏幕关联的控制序列,切换时不停止即将重新播放的资源
|
||||
// 入参: screen 屏幕引用, operation 停止、暂停或恢复操作, playing 即将播放的资源,控制动作为空, strict 是否禁止语义损失
|
||||
// 返回: []Action 控制序列,nil表示无法转换, error 资源或严格模式错误
|
||||
func (p *pdfImporter) renditionControls(screen pdfgo.Reference, operation int64, playing string, strict bool) ([]Action, error) {
|
||||
if p.renditions == nil {
|
||||
p.renditions = &pdfRenditionImport{actions: make(map[pdfgo.Reference][]pdfgo.Dictionary), media: make(map[pdfgo.Reference][]pdfRenditionResource)}
|
||||
p.renditions.err = p.collectRenditions()
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.renditions.err != nil {
|
||||
return nil, p.renditionActionLoss("screen associations cannot be determined: "+p.renditions.err.Error(), strict)
|
||||
}
|
||||
resources, err := p.renditionResources(screen, strict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.Kind != "Video" && (playing == "" || len(resources) > 1) {
|
||||
return nil, p.renditionActionLoss("audio rendition control cannot be represented by OFD Sound", strict)
|
||||
}
|
||||
}
|
||||
operator := map[int64]string{1: "Stop", 2: "Pause", 3: "Resume"}[operation]
|
||||
actions := make([]Action, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.ID != playing {
|
||||
actions = append(actions, Action{Event: "CLICK", Movie: &Movie{ResourceID: resource.ID, Operator: operator}})
|
||||
}
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// renditionResources 解析一个屏幕的可用媒体,文件数据仅在该屏幕参与转换时读取
|
||||
// 入参: screen 屏幕引用, strict 是否禁止忽略无效动作
|
||||
// 返回: []pdfRenditionResource 候选媒体, error 解析、资源或取消错误
|
||||
func (p *pdfImporter) renditionResources(screen pdfgo.Reference, strict bool) ([]pdfRenditionResource, error) {
|
||||
if resources, exists := p.renditions.media[screen]; exists {
|
||||
return resources, nil
|
||||
}
|
||||
resources := make([]pdfRenditionResource, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, object := range p.renditions.actions[screen] {
|
||||
playback, err := p.reader.ReadRenditionPlayback(p.ctx, object)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := p.pages[playback.Page]; !exists {
|
||||
continue
|
||||
}
|
||||
selected, err := playback.Rendition.Select(p.ctx, func(candidate *pdfgo.Rendition) (bool, error) {
|
||||
_, _, viable, err := p.nativeRendition(candidate)
|
||||
return viable, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected == nil {
|
||||
continue
|
||||
}
|
||||
_, kind, _, err := p.nativeRendition(selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.renditionResource(selected, kind, playback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
resources = append(resources, pdfRenditionResource{ID: id, Kind: kind})
|
||||
}
|
||||
}
|
||||
p.renditions.media[screen] = resources
|
||||
return resources, p.ctx.Err()
|
||||
}
|
||||
|
||||
// collectRenditions 索引可转换事件中的媒体关联,不解码媒体或执行脚本
|
||||
// 返回: error 动作结构、引用或取消错误
|
||||
func (p *pdfImporter) collectRenditions() error {
|
||||
seen := make(map[pdfRenditionAssignment]bool)
|
||||
collect := func(object pdfgo.Object) error {
|
||||
return p.reader.WalkActions(p.ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
if info.Type != "Rendition" {
|
||||
return nil
|
||||
}
|
||||
value, err := p.reader.Resolve(info.Dictionary["OP"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != pdfgo.Integer(0) && value != pdfgo.Integer(4) {
|
||||
return nil
|
||||
}
|
||||
screen, ok := info.Dictionary["AN"].(pdfgo.Reference)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
value, err = p.reader.Resolve(info.Dictionary["R"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, ok := value.(pdfgo.Dictionary)
|
||||
if !ok || r == nil {
|
||||
return nil
|
||||
}
|
||||
key := pdfRenditionAssignment{Screen: screen, Rendition: reflect.ValueOf(r).Pointer()}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
p.renditions.actions[screen] = append(p.renditions.actions[screen], info.Dictionary)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
opened, err := p.reader.ReadOpenAction(p.ctx)
|
||||
if err != nil && !errors.Is(err, pdfgo.ErrInvalidDestination) {
|
||||
return err
|
||||
}
|
||||
if opened.Action != nil {
|
||||
if err := collect(opened.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := p.reader.WalkOutlines(p.ctx, func(_ int, item pdfgo.OutlineItem) error {
|
||||
return collect(item.Action)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, page := range p.pageOrder {
|
||||
triggers, err := p.reader.ReadPageTriggers(p.ctx, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "O" {
|
||||
if err := collect(trigger.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
annotations, err := page.AnnotationsContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, annotation := range annotations {
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags&64 == 0 && (annotation.Subtype == "Screen" || annotation.Subtype == "Link") {
|
||||
if err := collect(annotation.Dictionary["A"]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
triggers, err := p.reader.ReadAnnotationTriggers(p.ctx, annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "PO" || trigger.Event == "U" && flags&64 == 0 && annotation.Subtype != "Widget" {
|
||||
if err := collect(trigger.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.ctx.Err()
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfRenditionImport 保存可转换事件中的屏幕关联,首次遇到呈现动作时建立
|
||||
type pdfRenditionImport struct {
|
||||
actions map[pdfgo.Reference][]pdfgo.Dictionary
|
||||
media map[pdfgo.Reference][]pdfRenditionResource
|
||||
err error
|
||||
}
|
||||
|
||||
// pdfRenditionResource 保存屏幕候选媒体及其独立控制标识
|
||||
type pdfRenditionResource struct {
|
||||
ID string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// pdfRenditionAssignment 区分同一屏幕的呈现定义,重复触发不重复登记
|
||||
type pdfRenditionAssignment struct {
|
||||
Screen pdfgo.Reference
|
||||
Rendition uintptr
|
||||
}
|
||||
|
||||
// renditionControls 生成屏幕关联的控制序列,切换时不停止即将重新播放的资源
|
||||
// 入参: screen 屏幕引用, operation 停止、暂停或恢复操作, playing 即将播放的资源,控制动作为空, strict 是否禁止语义损失
|
||||
// 返回: []Action 控制序列,nil表示无法转换, error 资源或严格模式错误
|
||||
func (p *pdfImporter) renditionControls(screen pdfgo.Reference, operation int64, playing string, strict bool) ([]Action, error) {
|
||||
if p.renditions == nil {
|
||||
p.renditions = &pdfRenditionImport{actions: make(map[pdfgo.Reference][]pdfgo.Dictionary), media: make(map[pdfgo.Reference][]pdfRenditionResource)}
|
||||
p.renditions.err = p.collectRenditions()
|
||||
}
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.renditions.err != nil {
|
||||
return nil, p.renditionActionLoss("screen associations cannot be determined: "+p.renditions.err.Error(), strict)
|
||||
}
|
||||
resources, err := p.renditionResources(screen, strict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resource := range resources {
|
||||
if resource.Kind != "Video" && (playing == "" || len(resources) > 1) {
|
||||
return nil, p.renditionActionLoss("audio rendition control cannot be represented by OFD Sound", strict)
|
||||
}
|
||||
}
|
||||
operator := map[int64]string{1: "Stop", 2: "Pause", 3: "Resume"}[operation]
|
||||
actions := make([]Action, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
if resource.ID != playing {
|
||||
actions = append(actions, Action{Event: "CLICK", Movie: &Movie{ResourceID: resource.ID, Operator: operator}})
|
||||
}
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// renditionResources 解析一个屏幕的可用媒体,文件数据仅在该屏幕参与转换时读取
|
||||
// 入参: screen 屏幕引用, strict 是否禁止忽略无效动作
|
||||
// 返回: []pdfRenditionResource 候选媒体, error 解析、资源或取消错误
|
||||
func (p *pdfImporter) renditionResources(screen pdfgo.Reference, strict bool) ([]pdfRenditionResource, error) {
|
||||
if resources, exists := p.renditions.media[screen]; exists {
|
||||
return resources, nil
|
||||
}
|
||||
resources := make([]pdfRenditionResource, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, object := range p.renditions.actions[screen] {
|
||||
playback, err := p.reader.ReadRenditionPlayback(p.ctx, object)
|
||||
if err != nil {
|
||||
if !strict && errors.Is(err, pdfgo.ErrInvalidAction) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := p.pages[playback.Page]; !exists {
|
||||
continue
|
||||
}
|
||||
selected, err := playback.Rendition.Select(p.ctx, func(candidate *pdfgo.Rendition) (bool, error) {
|
||||
_, _, viable, err := p.nativeRendition(candidate)
|
||||
return viable, err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected == nil {
|
||||
continue
|
||||
}
|
||||
_, kind, _, err := p.nativeRendition(selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.renditionResource(selected, kind, playback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
resources = append(resources, pdfRenditionResource{ID: id, Kind: kind})
|
||||
}
|
||||
}
|
||||
p.renditions.media[screen] = resources
|
||||
return resources, p.ctx.Err()
|
||||
}
|
||||
|
||||
// collectRenditions 索引可转换事件中的媒体关联,不解码媒体或执行脚本
|
||||
// 返回: error 动作结构、引用或取消错误
|
||||
func (p *pdfImporter) collectRenditions() error {
|
||||
seen := make(map[pdfRenditionAssignment]bool)
|
||||
collect := func(object pdfgo.Object) error {
|
||||
return p.reader.WalkActions(p.ctx, object, func(info pdfgo.ActionInfo) error {
|
||||
if info.Type != "Rendition" {
|
||||
return nil
|
||||
}
|
||||
value, err := p.reader.Resolve(info.Dictionary["OP"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value != pdfgo.Integer(0) && value != pdfgo.Integer(4) {
|
||||
return nil
|
||||
}
|
||||
screen, ok := info.Dictionary["AN"].(pdfgo.Reference)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
value, err = p.reader.Resolve(info.Dictionary["R"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, ok := value.(pdfgo.Dictionary)
|
||||
if !ok || r == nil {
|
||||
return nil
|
||||
}
|
||||
key := pdfRenditionAssignment{Screen: screen, Rendition: reflect.ValueOf(r).Pointer()}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
p.renditions.actions[screen] = append(p.renditions.actions[screen], info.Dictionary)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
opened, err := p.reader.ReadOpenAction(p.ctx)
|
||||
if err != nil && !errors.Is(err, pdfgo.ErrInvalidDestination) {
|
||||
return err
|
||||
}
|
||||
if opened.Action != nil {
|
||||
if err := collect(opened.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := p.reader.WalkOutlines(p.ctx, func(_ int, item pdfgo.OutlineItem) error {
|
||||
return collect(item.Action)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, page := range p.pageOrder {
|
||||
triggers, err := p.reader.ReadPageTriggers(p.ctx, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "O" {
|
||||
if err := collect(trigger.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
annotations, err := page.AnnotationsContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, annotation := range annotations {
|
||||
flags, err := p.reader.ReadAnnotationFlags(annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags&64 == 0 && (annotation.Subtype == "Screen" || annotation.Subtype == "Link") {
|
||||
if err := collect(annotation.Dictionary["A"]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
triggers, err := p.reader.ReadAnnotationTriggers(p.ctx, annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
if trigger.Event == "PO" || trigger.Event == "U" && flags&64 == 0 && annotation.Subtype != "Widget" {
|
||||
if err := collect(trigger.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.ctx.Err()
|
||||
}
|
||||
+102
-12
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
@@ -725,19 +726,108 @@ func pdfShadingClippedNode(node pdfCompositeNode, box pdfgo.Rectangle, matrix pd
|
||||
return node
|
||||
}
|
||||
|
||||
// pdfShadingPosition 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色
|
||||
// 入参: paint 渐变画刷, point 页面坐标
|
||||
// 返回: float64 归一化参数, bool 是否着色
|
||||
func pdfShadingPosition(paint pdfgo.Paint, point pdfgo.Point) (float64, bool) {
|
||||
var geometry pdfgo.GradientPosition
|
||||
var err error
|
||||
if g := paint.Axial; g != nil {
|
||||
geometry, err = g.PreparePosition()
|
||||
} else {
|
||||
geometry, err = paint.Radial.PreparePosition()
|
||||
// color 保留设备色彩分量并复用文档颜色空间
|
||||
// 入参: paint PDF颜色与不透明度
|
||||
// 返回: *FillColor OFD颜色
|
||||
func (p *pdfImporter) color(paint pdfgo.Paint) *FillColor {
|
||||
alpha := int(math.Round(paint.Alpha * 255))
|
||||
if paint.CMYK != nil && (paint.Space == nil || !paint.Space.Calibrated()) {
|
||||
if p.cmykSpace == "" {
|
||||
p.cmykSpace = p.editor.nextID()
|
||||
p.editor.resources = append(p.editor.resources, editorResource{space: &ColorSpace{ID: p.cmykSpace, Type: "CMYK", BitsPerComponent: 16}})
|
||||
}
|
||||
v := paint.CMYK
|
||||
return &FillColor{ColorSpace: p.cmykSpace, Value: pdfNumbers(math.Round(v[0]*65535), math.Round(v[1]*65535), math.Round(v[2]*65535), math.Round(v[3]*65535)), Alpha: &alpha}
|
||||
}
|
||||
return &FillColor{Value: pdfNumbers(math.Round(paint.RGB[0]*255), math.Round(paint.RGB[1]*255), math.Round(paint.RGB[2]*255)), Alpha: &alpha}
|
||||
}
|
||||
|
||||
// paintColor 将页面渐变转换为对象局部坐标
|
||||
// 入参: paint PDF画刷, box 对象边界
|
||||
// 返回: *FillColor OFD颜色或渐变, error 图案转换错误
|
||||
func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) {
|
||||
if err := p.gradientError(paint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pdfGradientPattern(paint) {
|
||||
return p.gradientPattern(paint, box)
|
||||
}
|
||||
if paint.Mesh != nil {
|
||||
return p.meshColor(paint, box)
|
||||
}
|
||||
color := p.color(paint)
|
||||
if paint.Tiling != nil {
|
||||
pattern, err := p.tilingPattern(paint.Tiling, paint)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
return nil, err
|
||||
}
|
||||
return geometry.PositionAt(point)
|
||||
color.Pattern = pattern
|
||||
return color, nil
|
||||
}
|
||||
if paint.Radial != nil {
|
||||
gradient := paint.Radial
|
||||
matrix := p.matrix
|
||||
if gradient.Matrix != (pdfgo.Matrix{}) {
|
||||
matrix = matrix.Mul(gradient.Matrix)
|
||||
}
|
||||
start, end := matrix.Apply(gradient.Start), matrix.Apply(gradient.End)
|
||||
xx, yy, xy := matrix[0]*matrix[0]+matrix[2]*matrix[2], matrix[1]*matrix[1]+matrix[3]*matrix[3], matrix[0]*matrix[1]+matrix[2]*matrix[3]
|
||||
major := (xx + yy + math.Hypot(xx-yy, 2*xy)) / 2
|
||||
determinant := matrix[0]*matrix[3] - matrix[1]*matrix[2]
|
||||
if major <= 0 || determinant == 0 {
|
||||
return nil, fmt.Errorf("invalid PDF radial gradient matrix")
|
||||
}
|
||||
scale := math.Sqrt(major)
|
||||
eccentricity := math.Sqrt(math.Max(0, 1-determinant*determinant/(major*major)))
|
||||
angle := math.Atan2(2*xy, xx-yy) * 90 / math.Pi
|
||||
extend := 0
|
||||
if gradient.Extend[0] {
|
||||
extend |= 1
|
||||
}
|
||||
if gradient.Extend[1] {
|
||||
extend |= 2
|
||||
}
|
||||
shading := &RadialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), StartRadius: gradient.StartRadius * scale, EndRadius: gradient.EndRadius * scale, Eccentricity: eccentricity, Angle: angle, Extend: strconv.Itoa(extend)}
|
||||
var err error
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space, gradient.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color.Value = ""
|
||||
color.RadialShd = shading
|
||||
return color, nil
|
||||
}
|
||||
if paint.Axial == nil {
|
||||
return color, nil
|
||||
}
|
||||
gradient := paint.Axial
|
||||
start := p.matrix.Apply(gradient.Start)
|
||||
inverse, ok := p.matrix.Inverse()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid PDF gradient matrix")
|
||||
}
|
||||
dx, dy := gradient.End.X-gradient.Start.X, gradient.End.Y-gradient.Start.Y
|
||||
gx, gy := inverse[0]*dx+inverse[1]*dy, inverse[2]*dx+inverse[3]*dy
|
||||
length := gx*gx + gy*gy
|
||||
if length == 0 {
|
||||
return nil, fmt.Errorf("invalid PDF axial gradient axis")
|
||||
}
|
||||
factor := (dx*dx + dy*dy) / length
|
||||
end := pdfgo.Point{X: start.X + gx*factor, Y: start.Y + gy*factor}
|
||||
extend := 0
|
||||
if gradient.Extend[0] {
|
||||
extend |= 1
|
||||
}
|
||||
if gradient.Extend[1] {
|
||||
extend |= 2
|
||||
}
|
||||
shading := &AxialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), Extend: strconv.Itoa(extend)}
|
||||
var err error
|
||||
shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space, gradient.Intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
color.Value = ""
|
||||
color.AxialShd = shading
|
||||
return color, nil
|
||||
}
|
||||
@@ -15,223 +15,15 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"unicode/utf8"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfImageKey 区分图像流、渲染意图、原生输出空间、模板填充色及采样尺寸
|
||||
type pdfImageKey struct {
|
||||
stream *pdfgo.Stream
|
||||
intent pdfgo.Name
|
||||
process pdfgo.Name
|
||||
tint color.NRGBA64
|
||||
size image.Point
|
||||
}
|
||||
|
||||
// pdfImageResource 保存图像的有效颜色空间及对应资源,避免跨资源重映射复用
|
||||
type pdfImageResource struct {
|
||||
space pdfgo.Object
|
||||
id string
|
||||
}
|
||||
|
||||
// image 转换图像样本,不将整页或其他对象栅格化
|
||||
// 有损编码复用已解码像素,候选容量超限时沿用原编码路径
|
||||
// 入参: mark PDF图像绘制信息
|
||||
// 返回: error 错误信息
|
||||
func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
|
||||
if err := p.ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" {
|
||||
return &pdfgo.UnsupportedError{Feature: "image blend mode " + string(mark.Style.BlendMode)}
|
||||
}
|
||||
if err := p.flushPath(); err != nil {
|
||||
return err
|
||||
}
|
||||
if mark.Image.ImageMask && (mark.Style.Fill.Axial != nil || mark.Style.Fill.Radial != nil || mark.Style.Fill.Function != nil || mark.Style.Fill.Mesh != nil || mark.Style.Fill.Shading != nil) {
|
||||
if p.warning != nil {
|
||||
return p.compositeRegion(nil, pdfCompositeNode{image: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false)
|
||||
}
|
||||
return &pdfgo.UnsupportedError{Feature: "gradient stencil image"}
|
||||
}
|
||||
var err error
|
||||
mark.Style, err = p.maskStyle(mark.Style)
|
||||
if err != nil {
|
||||
if p.rasterMaskAllowed(err) {
|
||||
mask := mark.Style.SoftMask
|
||||
mark.Style.SoftMask = nil
|
||||
return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Image(mark) })
|
||||
}
|
||||
return err
|
||||
}
|
||||
if mark.Style.FillOverprint {
|
||||
return &pdfgo.UnsupportedError{Feature: "image color separation overprint"}
|
||||
}
|
||||
source := mark.Image
|
||||
key := pdfImageKey{stream: source.Stream, intent: source.Intent}
|
||||
key.process, err = p.imageProcessModel(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
m := p.matrix.Mul(mark.Matrix)
|
||||
key.size = compressionImageSize(math.Hypot(m[0], m[1]), math.Hypot(m[2], m[3]), p.compression.ImageDPI())
|
||||
if key.size.X >= source.Width || key.size.Y >= source.Height {
|
||||
key.size = image.Point{}
|
||||
}
|
||||
}
|
||||
if source.ImageMask {
|
||||
fill := mark.Style.Fill.RGB
|
||||
if values := mark.Style.Fill.CMYK; values != nil {
|
||||
space := &pdfgo.ColorSpace{Model: "DeviceCMYK"}
|
||||
fill, err = space.RGB(values[:], mark.Style.RenderingIntent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
key.tint = color.NRGBA64{
|
||||
R: uint16(math.Round(fill[0] * 65535)),
|
||||
G: uint16(math.Round(fill[1] * 65535)),
|
||||
B: uint16(math.Round(fill[2] * 65535)),
|
||||
A: 65535,
|
||||
}
|
||||
}
|
||||
id := ""
|
||||
for _, resource := range p.imageIDs[key] {
|
||||
if reflect.DeepEqual(resource.space, source.ColorSpace) {
|
||||
id = resource.id
|
||||
break
|
||||
}
|
||||
}
|
||||
streamCached := id != ""
|
||||
var contentKey pdfImageContentKey
|
||||
cacheable := false
|
||||
if id == "" {
|
||||
contentKey, cacheable, err = p.imageContentKey(source, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheable {
|
||||
if p.imageContents == nil {
|
||||
p.imageContents = &renderCache[pdfImageContentKey, pdfImageContent]{limit: 8 << 20}
|
||||
}
|
||||
if cached, found := p.imageContents.get(contentKey); found && cached.matches(source) {
|
||||
id = cached.id
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
var jbig2Original, jpegOriginal []byte
|
||||
var decoded image.Image
|
||||
if key.process != "" {
|
||||
decoded, err = p.imageProcess(source, key.process)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if key.size != (image.Point{}) {
|
||||
decoded, err = pdfgo.ResizeImage(p.ctx, decoded, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
jbig2Original, err = source.JBIG2FileContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jpegOriginal, err = source.JPEGFileContext(p.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(jbig2Original) == 0 && len(jpegOriginal) == 0 && (source.ImageMask || key.size != (image.Point{})) {
|
||||
decoded, err = source.DecodeImageSizeContext(p.ctx, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if source.ImageMask {
|
||||
decoded = &codecpdf.StencilImage{Source: decoded, Fill: key.tint}
|
||||
}
|
||||
data := jbig2Original
|
||||
if len(data) == 0 {
|
||||
data = jpegOriginal
|
||||
}
|
||||
optimized := false
|
||||
if len(data) == 0 && decoded != nil && !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
data, err = pdfgo.EncodeImageResource(p.ctx, decoded, p.compression)
|
||||
if err != nil && !errors.Is(err, io.ErrShortBuffer) {
|
||||
return err
|
||||
}
|
||||
optimized = err == nil
|
||||
}
|
||||
if len(data) == 0 {
|
||||
var encoded bytes.Buffer
|
||||
if decoded != nil {
|
||||
err = pdfgo.EncodePNG(p.ctx, &encoded, decoded)
|
||||
} else {
|
||||
err = source.EncodePNGContext(p.ctx, &encoded)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = encoded.Bytes()
|
||||
}
|
||||
if !source.ImageMask && p.compression.Mode == CompressionLossy {
|
||||
if !optimized {
|
||||
data, err = pdfgo.OptimizeImageResource(p.ctx, data, p.compression, key.size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
decoded = nil
|
||||
}
|
||||
id, err = p.editor.addDecodedImageContext(p.ctx, data, decoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cacheable {
|
||||
p.imageContents.put(contentKey, pdfImageContent{source: source, id: id}, len(source.Stream.Data)+65536)
|
||||
}
|
||||
}
|
||||
if !streamCached {
|
||||
if p.imageIDs == nil {
|
||||
p.imageIDs = map[pdfImageKey][]pdfImageResource{}
|
||||
}
|
||||
p.imageIDs[key] = append(p.imageIDs[key], pdfImageResource{space: source.ColorSpace, id: id})
|
||||
}
|
||||
return p.appendImage(mark, id)
|
||||
}
|
||||
|
||||
// appendImage 定位已注册的图像资源,保留透明度与裁剪
|
||||
// 入参: mark 图像绘制信息, id 资源编号
|
||||
// 返回: error 裁剪错误
|
||||
func (p *pdfImporter) appendImage(mark pdfgo.ImageMark, id string) error {
|
||||
m := p.matrix.Mul(mark.Matrix).Mul(pdfgo.Matrix{1, 0, 0, -1, 0, 1})
|
||||
box := pdfBounds([]pdfgo.Point{m.Apply(pdfgo.Point{}), m.Apply(pdfgo.Point{X: 1}), m.Apply(pdfgo.Point{Y: 1}), m.Apply(pdfgo.Point{X: 1, Y: 1})})
|
||||
alpha := int(math.Round(mark.Style.Fill.Alpha * 255))
|
||||
clips, err := p.clips(mark.Style.Clips, box)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object := ImageObject{Boundary: pdfBoundary(box), ResourceID: id, CTM: pdfNumbers(m[0], m[1], m[2], m[3], m[4]-box.X, m[5]-box.Y), Alpha: &alpha, Clips: clips}
|
||||
p.appendObject(GraphicObject{Type: "ImageObject", ImageObject: object})
|
||||
p.report.ImageObjects++
|
||||
return nil
|
||||
}
|
||||
|
||||
// text 保留字形及逐字基线,外部字体优先复用同名内嵌资源
|
||||
// 入参: mark PDF文字绘制信息
|
||||
// 返回: error 错误信息
|
||||
@@ -632,3 +424,90 @@ func (p *pdfImporter) degenerateText(mark pdfgo.TextMark) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// disjointText 检查实际字形轮廓是否互不相交,允许无重叠挖空文字直接保留
|
||||
// 入参: node 隐式文字组
|
||||
// 返回: bool 是否互不相交, error 轮廓编译错误
|
||||
func (p *pdfImporter) disjointText(node pdfCompositeNode) (bool, error) {
|
||||
cache := p.compositingCache()
|
||||
if result, found := cache.textDisjoint[node.group]; found {
|
||||
return result, nil
|
||||
}
|
||||
geometry, err := p.renderer.Geometry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var inverse pdfgo.Matrix
|
||||
var boxes []Box
|
||||
for _, child := range node.children {
|
||||
mark := child.text
|
||||
if mark == nil {
|
||||
mark = child.glyph
|
||||
}
|
||||
if mark == nil || len(mark.Glyphs) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
if inverse == (pdfgo.Matrix{}) {
|
||||
var ok bool
|
||||
inverse, ok = p.matrix.Mul(mark.Matrix).Inverse()
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
scene, _, err := p.compileGroup(func(local *pdfImporter) error {
|
||||
if child.group != nil {
|
||||
return child.coverage(local, false)
|
||||
}
|
||||
_, fill, stroke := child.style()
|
||||
if fill {
|
||||
if err := child.coverage(local, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stroke {
|
||||
return child.coverage(local, true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if scene == nil {
|
||||
continue
|
||||
}
|
||||
var box Box
|
||||
for _, command := range scene.Commands {
|
||||
path, err := rasterCommandGeometry(geometry, command)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path, err = geometry.Transform(path, MatrixFromValues([6]float64(inverse)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
bounds, err := geometry.Bounds(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
box = unionTextBox(box, bounds)
|
||||
}
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
continue
|
||||
}
|
||||
for _, previous := range boxes {
|
||||
if box.X < previous.X+previous.W && previous.X < box.X+box.W && box.Y < previous.Y+previous.H && previous.Y < box.Y+box.H {
|
||||
if cache.textDisjoint == nil {
|
||||
cache.textDisjoint = make(map[*pdfgo.GroupMark]bool)
|
||||
}
|
||||
cache.textDisjoint[node.group] = false
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
if cache.textDisjoint == nil {
|
||||
cache.textDisjoint = make(map[*pdfgo.GroupMark]bool)
|
||||
}
|
||||
cache.textDisjoint[node.group] = true
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// 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 "github.com/xiaoqidun/pdfgo"
|
||||
|
||||
// disjointText 检查实际字形轮廓是否互不相交,允许无重叠挖空文字直接保留
|
||||
// 入参: node 隐式文字组
|
||||
// 返回: bool 是否互不相交, error 轮廓编译错误
|
||||
func (p *pdfImporter) disjointText(node pdfCompositeNode) (bool, error) {
|
||||
cache := p.compositingCache()
|
||||
if result, found := cache.textDisjoint[node.group]; found {
|
||||
return result, nil
|
||||
}
|
||||
geometry, err := p.renderer.Geometry()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var inverse pdfgo.Matrix
|
||||
var boxes []Box
|
||||
for _, child := range node.children {
|
||||
mark := child.text
|
||||
if mark == nil {
|
||||
mark = child.glyph
|
||||
}
|
||||
if mark == nil || len(mark.Glyphs) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
if inverse == (pdfgo.Matrix{}) {
|
||||
var ok bool
|
||||
inverse, ok = p.matrix.Mul(mark.Matrix).Inverse()
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
scene, _, err := p.compileGroup(func(local *pdfImporter) error {
|
||||
if child.group != nil {
|
||||
return child.coverage(local, false)
|
||||
}
|
||||
_, fill, stroke := child.style()
|
||||
if fill {
|
||||
if err := child.coverage(local, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stroke {
|
||||
return child.coverage(local, true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if scene == nil {
|
||||
continue
|
||||
}
|
||||
var box Box
|
||||
for _, command := range scene.Commands {
|
||||
path, err := rasterCommandGeometry(geometry, command)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path, err = geometry.Transform(path, MatrixFromValues([6]float64(inverse)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
bounds, err := geometry.Bounds(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
box = unionTextBox(box, bounds)
|
||||
}
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
continue
|
||||
}
|
||||
for _, previous := range boxes {
|
||||
if box.X < previous.X+previous.W && previous.X < box.X+box.W && box.Y < previous.Y+previous.H && previous.Y < box.Y+box.H {
|
||||
if cache.textDisjoint == nil {
|
||||
cache.textDisjoint = make(map[*pdfgo.GroupMark]bool)
|
||||
}
|
||||
cache.textDisjoint[node.group] = false
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
boxes = append(boxes, box)
|
||||
}
|
||||
if cache.textDisjoint == nil {
|
||||
cache.textDisjoint = make(map[*pdfgo.GroupMark]bool)
|
||||
}
|
||||
cache.textDisjoint[node.group] = true
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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 (
|
||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfGlyphPath 将OFD字形几何交给PDF路径适配器转换
|
||||
// 入参: outline 字形轮廓, matrix 字形到页面的变换
|
||||
// 返回: pdfgo.Path 页面路径, error 非字体轮廓指令
|
||||
func pdfGlyphPath(outline GeometryPath, matrix pdfgo.Matrix) (pdfgo.Path, error) {
|
||||
return codecpdf.GlyphPath(basegeom.Path(outline), matrix)
|
||||
}
|
||||
@@ -217,3 +217,43 @@ func (p *pdfImporter) transferOpaque(node pdfCompositeNode) (bool, error) {
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// halftone 检查连续色调转换,避免将打印网屏误用于屏幕显示
|
||||
// 入参: style 图形状态
|
||||
// 返回: error 严格模式无法保留设备网屏
|
||||
func (p *pdfImporter) halftone(style pdfgo.Style) error {
|
||||
h := style.Halftone
|
||||
message := "PDF continuous-tone appearance retained; device halftone not transferred to OFD"
|
||||
if h == nil || h.Type == 0 {
|
||||
if style.HalftoneOrigin == nil {
|
||||
return nil
|
||||
}
|
||||
message = "PDF continuous-tone appearance retained; device halftone origin not transferred to OFD"
|
||||
}
|
||||
if p.warning == nil {
|
||||
return &pdfgo.UnsupportedError{Feature: "device halftone conversion"}
|
||||
}
|
||||
if p.halftoneWarnings == nil {
|
||||
p.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
||||
}
|
||||
if !p.halftoneWarnings[h] {
|
||||
p.halftoneWarnings[h] = true
|
||||
p.warning(pdfgo.Diagnostic{Message: message})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pdfOpaqueBlack 判断不受底层分色影响的全黑设备色
|
||||
// 入参: paint PDF颜色与不透明度
|
||||
// 返回: bool 是否为不透明全黑CMYK颜色
|
||||
func pdfOpaqueBlack(paint pdfgo.Paint) bool {
|
||||
return paint.Alpha == 1 && paint.CMYK != nil && paint.CMYK[3] == 1
|
||||
}
|
||||
|
||||
// pdfOverprintNeedsSeparation 判断彩色CMYK套印是否依赖分色输出
|
||||
// 入参: paint PDF颜色与不透明度
|
||||
// 返回: bool 是否需要保留分色语义
|
||||
func pdfOverprintNeedsSeparation(paint pdfgo.Paint) bool {
|
||||
_, process := paint.Process.CMYKMask()
|
||||
return (paint.CMYK != nil || process) && !pdfOpaqueBlack(paint)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ package ofdgo
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
@@ -106,6 +108,38 @@ func (n *pdfNavigation) addActions(renderer *Renderer, page int, source actionSo
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLifecycleActions 按事件归属收集打开动作,不受点击区域限制
|
||||
// 入参: ctx 取消上下文, source 动作来源, page 页面索引,负值表示文档, bookmarks 书签, pageIndex 页面索引表, pages 页面数据
|
||||
// 返回: error 目标或取消错误
|
||||
func (n *pdfNavigation) addLifecycleActions(ctx context.Context, source actionSource, page int, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) error {
|
||||
event := "PO"
|
||||
if page < 0 {
|
||||
event = "DO"
|
||||
}
|
||||
for _, action := range source.Actions {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Event != event || source.Graphic {
|
||||
continue
|
||||
}
|
||||
value, err := n.actionTarget(action, page, source, bookmarks, pageIndex, pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if action.Event == "DO" {
|
||||
n.Open = append(n.Open, *value)
|
||||
} else {
|
||||
n.PageOpen[page] = append(n.PageOpen[page], *value)
|
||||
}
|
||||
n.nativeWrite = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// partitionPDFActions 将新增动作区域与已有区域分离,使每个区域具有唯一动作序列
|
||||
// 入参: ctx 取消上下文, renderer 渲染器, areas 已有分区, path 新动作区域, action 新动作
|
||||
// 返回: []pdfActionArea 新分区, error 几何、复杂度或取消错误
|
||||
@@ -219,3 +253,33 @@ func pdfNavigationValues(actions []pdfNavigationAction, pages []pdfgo.Reference)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// writeAttachments 在页面绘制后读取附件,保留文件字节,不自动添加个人信息和时间
|
||||
// 入参: ctx 取消上下文, options PDF写出配置
|
||||
// 返回: error 附件读取或取消错误
|
||||
func (n *pdfNavigation) writeAttachments(ctx context.Context, options *pdfgo.RewriteOptions) error {
|
||||
if len(n.attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
options.Attachments = make(map[string]*pdfgo.EmbeddedFile, len(n.attachments))
|
||||
for _, id := range slices.Sorted(maps.Keys(n.attachments)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
attachment := n.attachments[id]
|
||||
input, err := n.source.openFile(n.source.ResPath(attachment.FileLoc))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", id, err)
|
||||
}
|
||||
data, err := io.ReadAll(imageInput{ReadCloser: input, context: ctx})
|
||||
closeErr := input.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", id, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
options.Attachments[id] = &pdfgo.EmbeddedFile{Name: attachment.FileName(), Data: data}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// writeAttachments 在页面绘制后读取附件,保留文件字节,不自动添加个人信息和时间
|
||||
// 入参: ctx 取消上下文, options PDF写出配置
|
||||
// 返回: error 附件读取或取消错误
|
||||
func (n *pdfNavigation) writeAttachments(ctx context.Context, options *pdfgo.RewriteOptions) error {
|
||||
if len(n.attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
options.Attachments = make(map[string]*pdfgo.EmbeddedFile, len(n.attachments))
|
||||
for _, id := range slices.Sorted(maps.Keys(n.attachments)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
attachment := n.attachments[id]
|
||||
input, err := n.source.openFile(n.source.ResPath(attachment.FileLoc))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", id, err)
|
||||
}
|
||||
data, err := io.ReadAll(imageInput{ReadCloser: input, context: ctx})
|
||||
closeErr := input.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read attachment %s: %w", id, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
options.Attachments[id] = &pdfgo.EmbeddedFile{Name: attachment.FileName(), Data: data}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/tdewolff/canvas"
|
||||
@@ -150,6 +151,34 @@ func (r *pdfRenderer) glyphPath(path *canvas.Path, matrix canvas.Matrix) *canvas
|
||||
return converted
|
||||
}
|
||||
|
||||
// writePDF 传递后端资源记录及OFD导航修正,写出最终PDF
|
||||
// 入参: ctx 取消上下文, data 后端PDF数据, writer 输出流, optimization 优化配置
|
||||
// 返回: error 资源、压缩或写入错误
|
||||
func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Writer, optimization pdfgo.OptimizeOptions) error {
|
||||
if r.imageError != nil {
|
||||
return r.imageError
|
||||
}
|
||||
options := codecpdf.WriteOptions{
|
||||
Images: r.images,
|
||||
Text: r.text,
|
||||
ExactImages: r.exactImages,
|
||||
ExactText: r.exactText,
|
||||
Optimization: optimization,
|
||||
}
|
||||
if r.navigation != nil && r.navigation.nativeWrite {
|
||||
options.Links = make(map[int][]*pdfgo.LinkRegion, len(r.navigation.Link))
|
||||
for page, links := range r.navigation.Link {
|
||||
regions := make([]*pdfgo.LinkRegion, len(links))
|
||||
for i, link := range links {
|
||||
regions[i] = link.Region
|
||||
}
|
||||
options.Links[page] = regions
|
||||
}
|
||||
options.PrepareRewrite = r.navigation.prepareRewrite
|
||||
}
|
||||
return codecpdf.Write(ctx, data, writer, options)
|
||||
}
|
||||
|
||||
// newPDFNavigation 建立文档导航与输出页索引,不读取页面图元
|
||||
// 入参: renderer 渲染器, doc 文档结构, pages 输出页面描述
|
||||
// 返回: *pdfNavigation 导航信息, error 资源或目标错误
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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"
|
||||
|
||||
// addLifecycleActions 按事件归属收集打开动作,不受点击区域限制
|
||||
// 入参: ctx 取消上下文, source 动作来源, page 页面索引,负值表示文档, bookmarks 书签, pageIndex 页面索引表, pages 页面数据
|
||||
// 返回: error 目标或取消错误
|
||||
func (n *pdfNavigation) addLifecycleActions(ctx context.Context, source actionSource, page int, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) error {
|
||||
event := "PO"
|
||||
if page < 0 {
|
||||
event = "DO"
|
||||
}
|
||||
for _, action := range source.Actions {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Event != event || source.Graphic {
|
||||
continue
|
||||
}
|
||||
value, err := n.actionTarget(action, page, source, bookmarks, pageIndex, pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if action.Event == "DO" {
|
||||
n.Open = append(n.Open, *value)
|
||||
} else {
|
||||
n.PageOpen[page] = append(n.PageOpen[page], *value)
|
||||
}
|
||||
n.nativeWrite = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -16,14 +16,37 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfVideoResource 共享视频内容及各播放区域,控制动作按资源关联
|
||||
type pdfVideoResource struct {
|
||||
Clip *pdfgo.MediaClip
|
||||
Screens []*pdfVideoScreen
|
||||
}
|
||||
|
||||
// pdfVideoScreen 保存播放区域的页面归属及呈现参数
|
||||
type pdfVideoScreen struct {
|
||||
Page int
|
||||
Screen *pdfgo.ScreenAnnotation
|
||||
Rendition *pdfgo.Rendition
|
||||
}
|
||||
|
||||
// pdfMovieAction 保存视频控制及可选播放区域,待全部来源收集后关联
|
||||
type pdfMovieAction struct {
|
||||
Resource *pdfVideoResource
|
||||
Target *pdfVideoScreen
|
||||
Operation int
|
||||
}
|
||||
|
||||
// mediaFile 复用同一包内文件的输出说明,不合并播放资源及窗口
|
||||
// 入参: media 音视频资源
|
||||
// 返回: *pdfgo.FileSpecification 待读取的共享文件
|
||||
@@ -67,3 +90,198 @@ func (n *pdfNavigation) loadMediaFiles(ctx context.Context, files map[string]*pd
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// soundAction 保留音量、循环及等待语义,PDF同步播放同时限制交互
|
||||
// 入参: action OFD音频动作
|
||||
// 返回: *pdfNavigationAction PDF音频动作, error 资源或参数错误
|
||||
func (n *pdfNavigation) soundAction(action Sound) (*pdfNavigationAction, error) {
|
||||
volume := 100
|
||||
if action.Volume != nil {
|
||||
volume = *action.Volume
|
||||
}
|
||||
if volume < 0 || volume > 100 {
|
||||
return nil, fmt.Errorf("invalid sound volume")
|
||||
}
|
||||
file := n.audio[action.ResourceID]
|
||||
if file == nil {
|
||||
media, err := n.source.Media(action.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media.Type != "Audio" {
|
||||
return nil, fmt.Errorf("sound resource is not audio: %s", action.ResourceID)
|
||||
}
|
||||
if n.audio == nil {
|
||||
n.audio = make(map[string]*pdfgo.FileSpecification)
|
||||
}
|
||||
file = n.mediaFile(media)
|
||||
n.audio[action.ResourceID] = file
|
||||
}
|
||||
return &pdfNavigationAction{Sound: &pdfgo.SoundAction{
|
||||
Sound: pdfgo.Sound{File: file}, Volume: float64(volume) / 100,
|
||||
Repeat: action.Repeat, Synchronous: action.Synchronous && !action.Repeat,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// loadAudio 在绘制后读取自描述音频文件,原始内容不作采样转换
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) loadAudio(ctx context.Context) error {
|
||||
return n.loadMediaFiles(ctx, n.audio)
|
||||
}
|
||||
|
||||
// movieAction 按OFD第12章选择内嵌区域或浮窗,控制动作不另建播放器
|
||||
// 入参: action 视频动作, page 来源页索引,负值表示文档, source 动作来源, pages 输出页面
|
||||
// 返回: *pdfNavigationAction 待关联视频动作, error 资源或参数错误
|
||||
func (n *pdfNavigation) movieAction(action Movie, page int, source actionSource, pages []RenderDocumentPage) (*pdfNavigationAction, error) {
|
||||
operation := 0
|
||||
switch action.Operator {
|
||||
case "", "Play":
|
||||
case "Stop":
|
||||
operation = 1
|
||||
case "Pause":
|
||||
operation = 2
|
||||
case "Resume":
|
||||
operation = 3
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid movie operator: %s", action.Operator)
|
||||
}
|
||||
resource := n.video[action.ResourceID]
|
||||
if resource == nil {
|
||||
media, err := n.source.Media(action.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media.Type != "Video" {
|
||||
return nil, fmt.Errorf("movie resource is not video: %s", action.ResourceID)
|
||||
}
|
||||
resource = &pdfVideoResource{Clip: &pdfgo.MediaClip{
|
||||
Subtype: "MCD", ContentType: pdfVideoContentType(media),
|
||||
File: n.mediaFile(media),
|
||||
}}
|
||||
if n.video == nil {
|
||||
n.video = make(map[string]*pdfVideoResource)
|
||||
}
|
||||
n.video[action.ResourceID] = resource
|
||||
}
|
||||
value := &pdfMovieAction{Resource: resource, Operation: operation}
|
||||
if operation != 0 {
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
if len(pages) == 0 || page >= len(pages) {
|
||||
return nil, fmt.Errorf("missing movie playback page")
|
||||
}
|
||||
var rect pdfgo.Rectangle
|
||||
if source.Graphic {
|
||||
if page < 0 || source.Box.W <= 0 || source.Box.H <= 0 {
|
||||
return nil, fmt.Errorf("invalid movie playback region")
|
||||
}
|
||||
const scale = 72.0 / 25.4
|
||||
box := source.Box
|
||||
rect = pdfgo.Rectangle{XMin: box.X * scale, YMin: (pages[page].Box.H - box.Y - box.H) * scale, XMax: (box.X + box.W) * scale, YMax: (pages[page].Box.H - box.Y) * scale}
|
||||
} else {
|
||||
page = 0
|
||||
}
|
||||
for _, screen := range resource.Screens {
|
||||
if screen.Page == page && screen.Screen.Rect == rect {
|
||||
value.Target = screen
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
}
|
||||
screen := &pdfVideoScreen{Page: page, Screen: &pdfgo.ScreenAnnotation{Rect: rect, Hidden: true}, Rendition: &pdfgo.Rendition{Subtype: "MR", Clip: resource.Clip}}
|
||||
if !source.Graphic {
|
||||
screen.Rendition.Screen = pdfgo.Dictionary{"MH": pdfgo.Dictionary{"W": pdfgo.Integer(0)}, "BE": pdfgo.Dictionary{"F": pdfgo.Dictionary{"D": pdfgo.Array{pdfgo.Integer(640), pdfgo.Integer(360)}, "R": pdfgo.Integer(1)}}}
|
||||
}
|
||||
resource.Screens = append(resource.Screens, screen)
|
||||
value.Target = screen
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
|
||||
// values 将资源控制关联到全部播放区域,重新播放前停止同一资源的其他实例
|
||||
// 返回: []pdfgo.NavigationAction 有序呈现动作
|
||||
func (a *pdfMovieAction) values() []pdfgo.NavigationAction {
|
||||
values := make([]pdfgo.NavigationAction, 0, len(a.Resource.Screens))
|
||||
for _, screen := range a.Resource.Screens {
|
||||
if screen == a.Target {
|
||||
continue
|
||||
}
|
||||
operation := a.Operation
|
||||
if operation == 0 {
|
||||
operation = 1
|
||||
}
|
||||
values = append(values, pdfgo.NavigationAction{Rendition: &pdfgo.RenditionAction{Target: screen.Screen, Operation: operation}})
|
||||
}
|
||||
if a.Target != nil {
|
||||
values = append(values, pdfgo.NavigationAction{Rendition: &pdfgo.RenditionAction{Target: a.Target.Screen, Rendition: a.Target.Rendition}})
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// writeVideo 在绘制后读取被播放的视频,原样嵌入并关联输出页面
|
||||
// 入参: ctx 取消上下文, pages 输出页引用, options 替换配置
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) writeVideo(ctx context.Context, pages []pdfgo.Reference, options *pdfgo.RewriteOptions) error {
|
||||
files := make(map[string]*pdfgo.FileSpecification)
|
||||
for _, id := range slices.Sorted(maps.Keys(n.video)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
resource := n.video[id]
|
||||
for _, screen := range resource.Screens {
|
||||
if screen.Page < 0 || screen.Page >= len(pages) {
|
||||
return fmt.Errorf("missing movie playback page")
|
||||
}
|
||||
if options.ScreenAnnotations == nil {
|
||||
options.ScreenAnnotations = make(map[pdfgo.Reference][]*pdfgo.ScreenAnnotation)
|
||||
}
|
||||
ref := pages[screen.Page]
|
||||
options.ScreenAnnotations[ref] = append(options.ScreenAnnotations[ref], screen.Screen)
|
||||
files[id] = resource.Clip.File
|
||||
}
|
||||
}
|
||||
if err := n.loadMediaFiles(ctx, files); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range slices.Sorted(maps.Keys(files)) {
|
||||
clip := n.video[id].Clip
|
||||
if clip.ContentType == "" {
|
||||
kind := http.DetectContentType(clip.File.Embedded.Data)
|
||||
if !strings.HasPrefix(kind, "video/") && kind != "application/ogg" {
|
||||
return fmt.Errorf("cannot identify video content type: %s", id)
|
||||
}
|
||||
clip.ContentType = kind
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// pdfVideoContentType 转换明确声明的视频格式,未知格式留待检查文件头
|
||||
// 入参: media 视频资源
|
||||
// 返回: string 媒体类型,无法确定时为空
|
||||
func pdfVideoContentType(media MultiMedia) string {
|
||||
format := strings.ToLower(strings.TrimSpace(media.Format))
|
||||
if format == "" {
|
||||
format = strings.TrimPrefix(strings.ToLower(path.Ext(media.MediaFile)), ".")
|
||||
}
|
||||
switch format {
|
||||
case "mp4", "m4v":
|
||||
return "video/mp4"
|
||||
case "mpeg", "mpg":
|
||||
return "video/mpeg"
|
||||
case "mov", "qt":
|
||||
return "video/quicktime"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
case "ogv", "ogg":
|
||||
return "video/ogg"
|
||||
case "avi":
|
||||
return "video/x-msvideo"
|
||||
case "wmv":
|
||||
return "video/x-ms-wmv"
|
||||
case "3gp":
|
||||
return "video/3gpp"
|
||||
case "3g2":
|
||||
return "video/3gpp2"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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"
|
||||
"maps"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// pdfVideoResource 共享视频内容及各播放区域,控制动作按资源关联
|
||||
type pdfVideoResource struct {
|
||||
Clip *pdfgo.MediaClip
|
||||
Screens []*pdfVideoScreen
|
||||
}
|
||||
|
||||
// pdfVideoScreen 保存播放区域的页面归属及呈现参数
|
||||
type pdfVideoScreen struct {
|
||||
Page int
|
||||
Screen *pdfgo.ScreenAnnotation
|
||||
Rendition *pdfgo.Rendition
|
||||
}
|
||||
|
||||
// pdfMovieAction 保存视频控制及可选播放区域,待全部来源收集后关联
|
||||
type pdfMovieAction struct {
|
||||
Resource *pdfVideoResource
|
||||
Target *pdfVideoScreen
|
||||
Operation int
|
||||
}
|
||||
|
||||
// movieAction 按OFD第12章选择内嵌区域或浮窗,控制动作不另建播放器
|
||||
// 入参: action 视频动作, page 来源页索引,负值表示文档, source 动作来源, pages 输出页面
|
||||
// 返回: *pdfNavigationAction 待关联视频动作, error 资源或参数错误
|
||||
func (n *pdfNavigation) movieAction(action Movie, page int, source actionSource, pages []RenderDocumentPage) (*pdfNavigationAction, error) {
|
||||
operation := 0
|
||||
switch action.Operator {
|
||||
case "", "Play":
|
||||
case "Stop":
|
||||
operation = 1
|
||||
case "Pause":
|
||||
operation = 2
|
||||
case "Resume":
|
||||
operation = 3
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid movie operator: %s", action.Operator)
|
||||
}
|
||||
resource := n.video[action.ResourceID]
|
||||
if resource == nil {
|
||||
media, err := n.source.Media(action.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media.Type != "Video" {
|
||||
return nil, fmt.Errorf("movie resource is not video: %s", action.ResourceID)
|
||||
}
|
||||
resource = &pdfVideoResource{Clip: &pdfgo.MediaClip{
|
||||
Subtype: "MCD", ContentType: pdfVideoContentType(media),
|
||||
File: n.mediaFile(media),
|
||||
}}
|
||||
if n.video == nil {
|
||||
n.video = make(map[string]*pdfVideoResource)
|
||||
}
|
||||
n.video[action.ResourceID] = resource
|
||||
}
|
||||
value := &pdfMovieAction{Resource: resource, Operation: operation}
|
||||
if operation != 0 {
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
if len(pages) == 0 || page >= len(pages) {
|
||||
return nil, fmt.Errorf("missing movie playback page")
|
||||
}
|
||||
var rect pdfgo.Rectangle
|
||||
if source.Graphic {
|
||||
if page < 0 || source.Box.W <= 0 || source.Box.H <= 0 {
|
||||
return nil, fmt.Errorf("invalid movie playback region")
|
||||
}
|
||||
const scale = 72.0 / 25.4
|
||||
box := source.Box
|
||||
rect = pdfgo.Rectangle{XMin: box.X * scale, YMin: (pages[page].Box.H - box.Y - box.H) * scale, XMax: (box.X + box.W) * scale, YMax: (pages[page].Box.H - box.Y) * scale}
|
||||
} else {
|
||||
page = 0
|
||||
}
|
||||
for _, screen := range resource.Screens {
|
||||
if screen.Page == page && screen.Screen.Rect == rect {
|
||||
value.Target = screen
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
}
|
||||
screen := &pdfVideoScreen{Page: page, Screen: &pdfgo.ScreenAnnotation{Rect: rect, Hidden: true}, Rendition: &pdfgo.Rendition{Subtype: "MR", Clip: resource.Clip}}
|
||||
if !source.Graphic {
|
||||
screen.Rendition.Screen = pdfgo.Dictionary{"MH": pdfgo.Dictionary{"W": pdfgo.Integer(0)}, "BE": pdfgo.Dictionary{"F": pdfgo.Dictionary{"D": pdfgo.Array{pdfgo.Integer(640), pdfgo.Integer(360)}, "R": pdfgo.Integer(1)}}}
|
||||
}
|
||||
resource.Screens = append(resource.Screens, screen)
|
||||
value.Target = screen
|
||||
return &pdfNavigationAction{Movie: value}, nil
|
||||
}
|
||||
|
||||
// values 将资源控制关联到全部播放区域,重新播放前停止同一资源的其他实例
|
||||
// 返回: []pdfgo.NavigationAction 有序呈现动作
|
||||
func (a *pdfMovieAction) values() []pdfgo.NavigationAction {
|
||||
values := make([]pdfgo.NavigationAction, 0, len(a.Resource.Screens))
|
||||
for _, screen := range a.Resource.Screens {
|
||||
if screen == a.Target {
|
||||
continue
|
||||
}
|
||||
operation := a.Operation
|
||||
if operation == 0 {
|
||||
operation = 1
|
||||
}
|
||||
values = append(values, pdfgo.NavigationAction{Rendition: &pdfgo.RenditionAction{Target: screen.Screen, Operation: operation}})
|
||||
}
|
||||
if a.Target != nil {
|
||||
values = append(values, pdfgo.NavigationAction{Rendition: &pdfgo.RenditionAction{Target: a.Target.Screen, Rendition: a.Target.Rendition}})
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// writeVideo 在绘制后读取被播放的视频,原样嵌入并关联输出页面
|
||||
// 入参: ctx 取消上下文, pages 输出页引用, options 替换配置
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) writeVideo(ctx context.Context, pages []pdfgo.Reference, options *pdfgo.RewriteOptions) error {
|
||||
files := make(map[string]*pdfgo.FileSpecification)
|
||||
for _, id := range slices.Sorted(maps.Keys(n.video)) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
resource := n.video[id]
|
||||
for _, screen := range resource.Screens {
|
||||
if screen.Page < 0 || screen.Page >= len(pages) {
|
||||
return fmt.Errorf("missing movie playback page")
|
||||
}
|
||||
if options.ScreenAnnotations == nil {
|
||||
options.ScreenAnnotations = make(map[pdfgo.Reference][]*pdfgo.ScreenAnnotation)
|
||||
}
|
||||
ref := pages[screen.Page]
|
||||
options.ScreenAnnotations[ref] = append(options.ScreenAnnotations[ref], screen.Screen)
|
||||
files[id] = resource.Clip.File
|
||||
}
|
||||
}
|
||||
if err := n.loadMediaFiles(ctx, files); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range slices.Sorted(maps.Keys(files)) {
|
||||
clip := n.video[id].Clip
|
||||
if clip.ContentType == "" {
|
||||
kind := http.DetectContentType(clip.File.Embedded.Data)
|
||||
if !strings.HasPrefix(kind, "video/") && kind != "application/ogg" {
|
||||
return fmt.Errorf("cannot identify video content type: %s", id)
|
||||
}
|
||||
clip.ContentType = kind
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// pdfVideoContentType 转换明确声明的视频格式,未知格式留待检查文件头
|
||||
// 入参: media 视频资源
|
||||
// 返回: string 媒体类型,无法确定时为空
|
||||
func pdfVideoContentType(media MultiMedia) string {
|
||||
format := strings.ToLower(strings.TrimSpace(media.Format))
|
||||
if format == "" {
|
||||
format = strings.TrimPrefix(strings.ToLower(path.Ext(media.MediaFile)), ".")
|
||||
}
|
||||
switch format {
|
||||
case "mp4", "m4v":
|
||||
return "video/mp4"
|
||||
case "mpeg", "mpg":
|
||||
return "video/mpeg"
|
||||
case "mov", "qt":
|
||||
return "video/quicktime"
|
||||
case "webm":
|
||||
return "video/webm"
|
||||
case "ogv", "ogg":
|
||||
return "video/ogg"
|
||||
case "avi":
|
||||
return "video/x-msvideo"
|
||||
case "wmv":
|
||||
return "video/x-ms-wmv"
|
||||
case "3gp":
|
||||
return "video/3gpp"
|
||||
case "3g2":
|
||||
return "video/3gpp2"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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"
|
||||
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// soundAction 保留音量、循环及等待语义,PDF同步播放同时限制交互
|
||||
// 入参: action OFD音频动作
|
||||
// 返回: *pdfNavigationAction PDF音频动作, error 资源或参数错误
|
||||
func (n *pdfNavigation) soundAction(action Sound) (*pdfNavigationAction, error) {
|
||||
volume := 100
|
||||
if action.Volume != nil {
|
||||
volume = *action.Volume
|
||||
}
|
||||
if volume < 0 || volume > 100 {
|
||||
return nil, fmt.Errorf("invalid sound volume")
|
||||
}
|
||||
file := n.audio[action.ResourceID]
|
||||
if file == nil {
|
||||
media, err := n.source.Media(action.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if media.Type != "Audio" {
|
||||
return nil, fmt.Errorf("sound resource is not audio: %s", action.ResourceID)
|
||||
}
|
||||
if n.audio == nil {
|
||||
n.audio = make(map[string]*pdfgo.FileSpecification)
|
||||
}
|
||||
file = n.mediaFile(media)
|
||||
n.audio[action.ResourceID] = file
|
||||
}
|
||||
return &pdfNavigationAction{Sound: &pdfgo.SoundAction{
|
||||
Sound: pdfgo.Sound{File: file}, Volume: float64(volume) / 100,
|
||||
Repeat: action.Repeat, Synchronous: action.Synchronous && !action.Repeat,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// loadAudio 在绘制后读取自描述音频文件,原始内容不作采样转换
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 资源读取或取消错误
|
||||
func (n *pdfNavigation) loadAudio(ctx context.Context) error {
|
||||
return n.loadMediaFiles(ctx, n.audio)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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"
|
||||
"io"
|
||||
|
||||
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
|
||||
"github.com/xiaoqidun/pdfgo"
|
||||
)
|
||||
|
||||
// writePDF 传递后端资源记录及OFD导航修正,写出最终PDF
|
||||
// 入参: ctx 取消上下文, data 后端PDF数据, writer 输出流, optimization 优化配置
|
||||
// 返回: error 资源、压缩或写入错误
|
||||
func (r *pdfRenderer) writePDF(ctx context.Context, data []byte, writer io.Writer, optimization pdfgo.OptimizeOptions) error {
|
||||
if r.imageError != nil {
|
||||
return r.imageError
|
||||
}
|
||||
options := codecpdf.WriteOptions{
|
||||
Images: r.images,
|
||||
Text: r.text,
|
||||
ExactImages: r.exactImages,
|
||||
ExactText: r.exactText,
|
||||
Optimization: optimization,
|
||||
}
|
||||
if r.navigation != nil && r.navigation.nativeWrite {
|
||||
options.Links = make(map[int][]*pdfgo.LinkRegion, len(r.navigation.Link))
|
||||
for page, links := range r.navigation.Link {
|
||||
regions := make([]*pdfgo.LinkRegion, len(links))
|
||||
for i, link := range links {
|
||||
regions[i] = link.Region
|
||||
}
|
||||
options.Links[page] = regions
|
||||
}
|
||||
options.PrepareRewrite = r.navigation.prepareRewrite
|
||||
}
|
||||
return codecpdf.Write(ctx, data, writer, options)
|
||||
}
|
||||
Reference in new issue
Block a user