feat(文件归并): 归并PDF适配与解析逻辑
build.yaml / build (push) Successful in 48s

This commit is contained in:
xiaoqidun committed 2026-10-14 09:22:47 +08:00
1 parent b3c4bbc844
commit 961bc52038
43 files changed
+2283 -2594

No files matched your search

+50
View File
@@ -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
}
+112
View File
@@ -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
}
+14
View File
@@ -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
}
+121
View File
@@ -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
}
+20 -3
View File
@@ -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
View File
@@ -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)
}
+61
View File
@@ -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()
}
-82
View File
@@ -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, &currentPage)
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()
}
-84
View File
@@ -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, &currentPage)
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()
}
-49
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-138
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
}
+283
View File
@@ -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
}
+54
View File
@@ -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
}
-42
View File
@@ -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
}
+324
View File
@@ -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
}
-56
View File
@@ -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)
}
-84
View File
@@ -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
}
+27
View File
@@ -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 解析、取消或无法表达的语义
-78
View File
@@ -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, &currentPage)
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
}
-125
View File
@@ -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, &currentPage)
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
}
+89
View File
@@ -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
}
-105
View File
@@ -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
}
+192
View File
@@ -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()
}
-213
View File
@@ -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
View File
@@ -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
}
+87 -208
View File
@@ -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
}
-104
View File
@@ -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
}
-28
View File
@@ -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)
}
+40
View File
@@ -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)
}
+64
View File
@@ -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()
}
-55
View File
@@ -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()
}
+29
View File
@@ -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 资源或目标错误
-49
View File
@@ -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
}
+218
View File
@@ -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 ""
}
-203
View File
@@ -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 ""
}
-61
View File
@@ -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)
}
-51
View File
@@ -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)
}