From 961bc5203873c43264202d24380d204680cad6a2 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 14 Oct 2026 09:22:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=96=87=E4=BB=B6=E5=BD=92=E5=B9=B6):=20?= =?UTF-8?q?=E5=BD=92=E5=B9=B6PDF=E9=80=82=E9=85=8D=E4=B8=8E=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/codec/pdf/clip.go | 50 ++ internal/codec/pdf/color.go | 112 ++++ internal/codec/pdf/image.go | 14 + internal/codec/pdf/reference.go | 121 ++++ internal/codec/pdf/shading.go | 23 +- ofdgo_pdf.go | 585 +----------------- ofdgo_pdf_3d.go | 61 ++ ofdgo_pdf_3d_markup.go | 82 --- ...action_lifecycle.go => ofdgo_pdf_action.go | 62 ++ ofdgo_pdf_annot_trigger.go | 84 --- ofdgo_pdf_attachment.go | 49 -- ofdgo_pdf_buffer.go | 25 +- ofdgo_pdf_clip.go | 234 +++++-- ofdgo_pdf_clip_group.go | 138 ----- ofdgo_pdf_composite_draw.go | 136 +--- ofdgo_pdf_composite_pattern.go | 6 +- ofdgo_pdf_geometry.go | 283 +++++++++ ofdgo_pdf_group.go | 54 ++ ofdgo_pdf_halftone.go | 42 -- ofdgo_pdf_image.go | 324 ++++++++++ ofdgo_pdf_image_cache.go | 56 -- ofdgo_pdf_image_native.go | 84 --- ofdgo_pdf_link_file.go | 27 + ofdgo_pdf_mask.go | 78 --- ..._destination.go => ofdgo_pdf_navigation.go | 112 +++- ofdgo_pdf_outline.go | 125 ---- ofdgo_pdf_pattern.go | 89 +++ ofdgo_pdf_reference.go | 105 ---- ofdgo_pdf_rendition.go | 192 ++++++ ofdgo_pdf_rendition_control.go | 213 ------- ofdgo_pdf_shading.go | 114 +++- ofdgo_pdf_object.go => ofdgo_pdf_text.go | 295 +++------ ofdgo_pdf_text_group.go | 104 ---- ofdgo_pdf_text_path.go | 28 - ofdgo_pdf_transfer.go | 40 ++ ofdgo_renderer_pdf_action.go | 64 ++ ofdgo_renderer_pdf_attachment.go | 55 -- ofdgo_renderer_pdf_canvas.go | 29 + ofdgo_renderer_pdf_lifecycle.go | 49 -- ofdgo_renderer_pdf_media.go | 218 +++++++ ofdgo_renderer_pdf_movie.go | 203 ------ ofdgo_renderer_pdf_sound.go | 61 -- ofdgo_renderer_pdf_write.go | 51 -- 43 files changed, 2283 insertions(+), 2594 deletions(-) create mode 100644 internal/codec/pdf/reference.go delete mode 100644 ofdgo_pdf_3d_markup.go rename ofdgo_pdf_action_lifecycle.go => ofdgo_pdf_action.go (73%) delete mode 100644 ofdgo_pdf_annot_trigger.go delete mode 100644 ofdgo_pdf_attachment.go delete mode 100644 ofdgo_pdf_clip_group.go delete mode 100644 ofdgo_pdf_halftone.go create mode 100644 ofdgo_pdf_image.go delete mode 100644 ofdgo_pdf_image_cache.go delete mode 100644 ofdgo_pdf_image_native.go delete mode 100644 ofdgo_pdf_mask.go rename ofdgo_pdf_destination.go => ofdgo_pdf_navigation.go (55%) delete mode 100644 ofdgo_pdf_outline.go delete mode 100644 ofdgo_pdf_reference.go delete mode 100644 ofdgo_pdf_rendition_control.go rename ofdgo_pdf_object.go => ofdgo_pdf_text.go (70%) delete mode 100644 ofdgo_pdf_text_group.go delete mode 100644 ofdgo_pdf_text_path.go delete mode 100644 ofdgo_renderer_pdf_attachment.go delete mode 100644 ofdgo_renderer_pdf_lifecycle.go delete mode 100644 ofdgo_renderer_pdf_movie.go delete mode 100644 ofdgo_renderer_pdf_sound.go delete mode 100644 ofdgo_renderer_pdf_write.go diff --git a/internal/codec/pdf/clip.go b/internal/codec/pdf/clip.go index fba845a..0cc4ac8 100644 --- a/internal/codec/pdf/clip.go +++ b/internal/codec/pdf/clip.go @@ -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 +} diff --git a/internal/codec/pdf/color.go b/internal/codec/pdf/color.go index d453c82..76fc718 100644 --- a/internal/codec/pdf/color.go +++ b/internal/codec/pdf/color.go @@ -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 +} diff --git a/internal/codec/pdf/image.go b/internal/codec/pdf/image.go index 4c6dbae..c9419f2 100644 --- a/internal/codec/pdf/image.go +++ b/internal/codec/pdf/image.go @@ -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 +} diff --git a/internal/codec/pdf/reference.go b/internal/codec/pdf/reference.go new file mode 100644 index 0000000..ad58c67 --- /dev/null +++ b/internal/codec/pdf/reference.go @@ -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 +} diff --git a/internal/codec/pdf/shading.go b/internal/codec/pdf/shading.go index 63cd84c..36e8978 100644 --- a/internal/codec/pdf/shading.go +++ b/internal/codec/pdf/shading.go @@ -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) +} diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 0d576f1..5bba546 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -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 +// 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{} } - 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) + return p.references.Resolve(ctx, source, reference, p.resolveFile, p.resolveReference, p.warning) } diff --git a/ofdgo_pdf_3d.go b/ofdgo_pdf_3d.go index 28917a1..6b62400 100644 --- a/ofdgo_pdf_3d.go +++ b/ofdgo_pdf_3d.go @@ -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() +} diff --git a/ofdgo_pdf_3d_markup.go b/ofdgo_pdf_3d_markup.go deleted file mode 100644 index 1b2a971..0000000 --- a/ofdgo_pdf_3d_markup.go +++ /dev/null @@ -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() -} diff --git a/ofdgo_pdf_action_lifecycle.go b/ofdgo_pdf_action.go similarity index 73% rename from ofdgo_pdf_action_lifecycle.go rename to ofdgo_pdf_action.go index fd3fa91..4284a97 100644 --- a/ofdgo_pdf_action_lifecycle.go +++ b/ofdgo_pdf_action.go @@ -145,3 +145,65 @@ func (p *pdfImporter) unmappedTrigger(ctx context.Context, owner string, trigger p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD"}) return nil } + +// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观 +// 入参: ctx 取消上下文, annotation PDF注解, event OFD事件 +// 返回: []Action 动作序列, error 事件、动作或取消错误 +func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation, event string) ([]Action, error) { + if annotation.Subtype == "Widget" && event == "CLICK" { + return nil, ctx.Err() + } + triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation) + if err != nil { + return nil, err + } + if len(triggers) == 0 { + return nil, ctx.Err() + } + flags, err := p.reader.ReadAnnotationFlags(annotation) + if err != nil { + return nil, err + } + var converted []Action + for _, trigger := range triggers { + if trigger.Event == "PC" || trigger.Event == "PV" || trigger.Event == "PI" { + if event == "PO" { + if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil { + return nil, err + } + } + continue + } + if (trigger.Event == "PO") != (event == "PO") { + continue + } + if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") { + continue + } + switch trigger.Event { + case "U", "PO": + default: + return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)} + } + currentPage := p.page + if event == "PO" { + currentPage = p.pageActionPage + } + var source pdfgo.Annotation + if event == "CLICK" { + source = annotation + } + actions, err := p.eventActions(ctx, trigger.Action, event, source, ¤tPage) + if err != nil { + return nil, err + } + converted = append(converted, actions...) + if event == "PO" { + p.pageActionPage = currentPage + } + } + if event == "PO" && len(converted) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) { + return nil, fmt.Errorf("invalid annotation trigger page") + } + return converted, ctx.Err() +} diff --git a/ofdgo_pdf_annot_trigger.go b/ofdgo_pdf_annot_trigger.go deleted file mode 100644 index 9ce5c51..0000000 --- a/ofdgo_pdf_annot_trigger.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "context" - "fmt" - - "github.com/xiaoqidun/pdfgo" -) - -// annotationTriggerActions 按事件转换注解动作,页面打开动作独立于注解外观 -// 入参: ctx 取消上下文, annotation PDF注解, event OFD事件 -// 返回: []Action 动作序列, error 事件、动作或取消错误 -func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation, event string) ([]Action, error) { - if annotation.Subtype == "Widget" && event == "CLICK" { - return nil, ctx.Err() - } - triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation) - if err != nil { - return nil, err - } - if len(triggers) == 0 { - return nil, ctx.Err() - } - flags, err := p.reader.ReadAnnotationFlags(annotation) - if err != nil { - return nil, err - } - var converted []Action - for _, trigger := range triggers { - if trigger.Event == "PC" || trigger.Event == "PV" || trigger.Event == "PI" { - if event == "PO" { - if err := p.unmappedTrigger(ctx, "annotation", trigger); err != nil { - return nil, err - } - } - continue - } - if (trigger.Event == "PO") != (event == "PO") { - continue - } - if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") { - continue - } - switch trigger.Event { - case "U", "PO": - default: - return nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)} - } - currentPage := p.page - if event == "PO" { - currentPage = p.pageActionPage - } - var source pdfgo.Annotation - if event == "CLICK" { - source = annotation - } - actions, err := p.eventActions(ctx, trigger.Action, event, source, ¤tPage) - if err != nil { - return nil, err - } - converted = append(converted, actions...) - if event == "PO" { - p.pageActionPage = currentPage - } - } - if event == "PO" && len(converted) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) { - return nil, fmt.Errorf("invalid annotation trigger page") - } - return converted, ctx.Err() -} diff --git a/ofdgo_pdf_attachment.go b/ofdgo_pdf_attachment.go deleted file mode 100644 index c90592d..0000000 --- a/ofdgo_pdf_attachment.go +++ /dev/null @@ -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 - }) -} diff --git a/ofdgo_pdf_buffer.go b/ofdgo_pdf_buffer.go index cee12f4..7563d17 100644 --- a/ofdgo_pdf_buffer.go +++ b/ofdgo_pdf_buffer.go @@ -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 +} diff --git a/ofdgo_pdf_clip.go b/ofdgo_pdf_clip.go index c214029..ce20ad1 100644 --- a/ofdgo_pdf_clip.go +++ b/ofdgo_pdf_clip.go @@ -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 +} diff --git a/ofdgo_pdf_clip_group.go b/ofdgo_pdf_clip_group.go deleted file mode 100644 index dd26aef..0000000 --- a/ofdgo_pdf_clip_group.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index 756be70..5cc3580 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -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 diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index d0885ac..01bd571 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -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 } diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go index d9c0c12..0595d09 100644 --- a/ofdgo_pdf_geometry.go +++ b/ofdgo_pdf_geometry.go @@ -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 +} diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 25ab7d7..a1ad7bb 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -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 +} diff --git a/ofdgo_pdf_halftone.go b/ofdgo_pdf_halftone.go deleted file mode 100644 index ead87de..0000000 --- a/ofdgo_pdf_halftone.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_pdf_image.go b/ofdgo_pdf_image.go new file mode 100644 index 0000000..21ee50d --- /dev/null +++ b/ofdgo_pdf_image.go @@ -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 +} diff --git a/ofdgo_pdf_image_cache.go b/ofdgo_pdf_image_cache.go deleted file mode 100644 index a7ebbfd..0000000 --- a/ofdgo_pdf_image_cache.go +++ /dev/null @@ -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) -} diff --git a/ofdgo_pdf_image_native.go b/ofdgo_pdf_image_native.go deleted file mode 100644 index a121b1b..0000000 --- a/ofdgo_pdf_image_native.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_pdf_link_file.go b/ofdgo_pdf_link_file.go index ca044ef..9ab433e 100644 --- a/ofdgo_pdf_link_file.go +++ b/ofdgo_pdf_link_file.go @@ -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 解析、取消或无法表达的语义 diff --git a/ofdgo_pdf_mask.go b/ofdgo_pdf_mask.go deleted file mode 100644 index f99afac..0000000 --- a/ofdgo_pdf_mask.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_pdf_destination.go b/ofdgo_pdf_navigation.go similarity index 55% rename from ofdgo_pdf_destination.go rename to ofdgo_pdf_navigation.go index 68db474..1d0d543 100644 --- a/ofdgo_pdf_destination.go +++ b/ofdgo_pdf_navigation.go @@ -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), - halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool), + references: &codecpdf.ReferenceCache{}, + halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool), } - defer local.closeReferenceReaders() + defer local.references.Close() if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error { return p.reader.WalkPage(p.ctx, page, visitor) }); err != nil { @@ -123,3 +126,102 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { p.destinationBoxes[page.Reference] = box return box, nil } + +// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序 +// 入参: ctx 取消上下文 +// 返回: error 严格模式转换错误或取消错误 +func (p *pdfImporter) outlines(ctx context.Context) error { + count := 0 + data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) { + x.root("Outlines", nil) + depth := 0 + err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { + actions, err := p.outlineActions(ctx, item) + if err != nil { + if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { + return err + } + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)}) + actions = nil + } + for depth > level { + x.end("OutlineElem") + depth-- + } + attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}} + attrs.add("Expanded", strconv.FormatBool(item.Expanded)) + x.start("OutlineElem", attrs) + x.actions(actions) + depth++ + count++ + return x.err + }) + if err != nil { + x.err = err + return + } + for depth > 0 { + x.end("OutlineElem") + depth-- + } + x.end("Outlines") + }) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { + return err + } + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)}) + return nil + } + if count > 0 { + p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header)) + } + return nil +} + +// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转 +// 入参: ctx 取消上下文, item PDF目录节点 +// 返回: []Action OFD动作序列, error 动作或取消错误 +func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) { + object, err := p.reader.Resolve(item.Action) + if err != nil { + return nil, err + } + dest, err := p.reader.Resolve(item.Destination) + if err != nil { + return nil, err + } + if dest != nil { + if object != nil { + return nil, fmt.Errorf("PDF outline has both destination and action") + } + object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest} + } + currentPage := -1 + var actions []Action + err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { + converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) + if err != nil { + if errors.Is(err, errPDFMediaRead) { + return err + } + var unsupported *pdfgo.UnsupportedError + if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) { + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)}) + currentPage = -1 + return nil + } + return err + } + if converted != nil { + actions = append(actions, converted...) + } else { + currentPage = -1 + } + return nil + }) + return actions, err +} diff --git a/ofdgo_pdf_outline.go b/ofdgo_pdf_outline.go deleted file mode 100644 index 6748804..0000000 --- a/ofdgo_pdf_outline.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ofdgo - -import ( - "bytes" - "context" - "encoding/xml" - "errors" - "fmt" - "strconv" - - "github.com/xiaoqidun/pdfgo" -) - -// outlines 按先序批量转换目录,保留层级、展开状态和动作顺序 -// 入参: ctx 取消上下文 -// 返回: error 严格模式转换错误或取消错误 -func (p *pdfImporter) outlines(ctx context.Context) error { - count := 0 - data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) { - x.root("Outlines", nil) - depth := 0 - err := p.reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { - actions, err := p.outlineActions(ctx, item) - if err != nil { - if p.warning == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { - return err - } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline %q actions not transferred to OFD: %v", item.Title, err)}) - actions = nil - } - for depth > level { - x.end("OutlineElem") - depth-- - } - attrs := ofdAttrs{{Name: xml.Name{Local: "Title"}, Value: item.Title}} - attrs.add("Expanded", strconv.FormatBool(item.Expanded)) - x.start("OutlineElem", attrs) - x.actions(actions) - depth++ - count++ - return x.err - }) - if err != nil { - x.err = err - return - } - for depth > 0 { - x.end("OutlineElem") - depth-- - } - x.end("Outlines") - }) - if err != nil { - if ctx.Err() != nil { - return ctx.Err() - } - if p.warning == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, errPDFMediaRead) { - return err - } - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outlines not transferred to OFD: %v", err)}) - return nil - } - if count > 0 { - p.editor.outlines = bytes.TrimPrefix(data, []byte(xml.Header)) - } - return nil -} - -// outlineActions 转换目录目标及动作链,不为无目标节点补充跳转 -// 入参: ctx 取消上下文, item PDF目录节点 -// 返回: []Action OFD动作序列, error 动作或取消错误 -func (p *pdfImporter) outlineActions(ctx context.Context, item pdfgo.OutlineItem) ([]Action, error) { - object, err := p.reader.Resolve(item.Action) - if err != nil { - return nil, err - } - dest, err := p.reader.Resolve(item.Destination) - if err != nil { - return nil, err - } - if dest != nil { - if object != nil { - return nil, fmt.Errorf("PDF outline has both destination and action") - } - object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": dest} - } - currentPage := -1 - var actions []Action - err = p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error { - converted, err := p.linkActions(pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}, p.warning == nil, ¤tPage) - if err != nil { - if errors.Is(err, errPDFMediaRead) { - return err - } - var unsupported *pdfgo.UnsupportedError - if p.warning != nil && (errors.As(err, &unsupported) || errors.Is(err, pdfgo.ErrInvalidDestination) || errors.Is(err, pdfgo.ErrInvalidAction)) { - p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF outline action not transferred to OFD: %v", err)}) - currentPage = -1 - return nil - } - return err - } - if converted != nil { - actions = append(actions, converted...) - } else { - currentPage = -1 - } - return nil - }) - return actions, err -} diff --git a/ofdgo_pdf_pattern.go b/ofdgo_pdf_pattern.go index 2d0a986..276623d 100644 --- a/ofdgo_pdf_pattern.go +++ b/ofdgo_pdf_pattern.go @@ -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 +} diff --git a/ofdgo_pdf_reference.go b/ofdgo_pdf_reference.go deleted file mode 100644 index 3fcb52a..0000000 --- a/ofdgo_pdf_reference.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_pdf_rendition.go b/ofdgo_pdf_rendition.go index 4cec0ab..c49721b 100644 --- a/ofdgo_pdf_rendition.go +++ b/ofdgo_pdf_rendition.go @@ -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() +} diff --git a/ofdgo_pdf_rendition_control.go b/ofdgo_pdf_rendition_control.go deleted file mode 100644 index ad3d162..0000000 --- a/ofdgo_pdf_rendition_control.go +++ /dev/null @@ -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() -} diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index aa1eb26..6121a7b 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -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 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 0, false + return nil, err } - return geometry.PositionAt(point) + color.Value = "" + color.AxialShd = shading + return color, nil } diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_text.go similarity index 70% rename from ofdgo_pdf_object.go rename to ofdgo_pdf_text.go index 3feb9ed..617d79d 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_text.go @@ -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 +} diff --git a/ofdgo_pdf_text_group.go b/ofdgo_pdf_text_group.go deleted file mode 100644 index f2ad716..0000000 --- a/ofdgo_pdf_text_group.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_pdf_text_path.go b/ofdgo_pdf_text_path.go deleted file mode 100644 index 375f482..0000000 --- a/ofdgo_pdf_text_path.go +++ /dev/null @@ -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) -} diff --git a/ofdgo_pdf_transfer.go b/ofdgo_pdf_transfer.go index d62a2f4..caaf32b 100644 --- a/ofdgo_pdf_transfer.go +++ b/ofdgo_pdf_transfer.go @@ -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) +} diff --git a/ofdgo_renderer_pdf_action.go b/ofdgo_renderer_pdf_action.go index 9b9e988..febf1d8 100644 --- a/ofdgo_renderer_pdf_action.go +++ b/ofdgo_renderer_pdf_action.go @@ -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() +} diff --git a/ofdgo_renderer_pdf_attachment.go b/ofdgo_renderer_pdf_attachment.go deleted file mode 100644 index 4094bbe..0000000 --- a/ofdgo_renderer_pdf_attachment.go +++ /dev/null @@ -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() -} diff --git a/ofdgo_renderer_pdf_canvas.go b/ofdgo_renderer_pdf_canvas.go index 1b62f5f..270dc42 100644 --- a/ofdgo_renderer_pdf_canvas.go +++ b/ofdgo_renderer_pdf_canvas.go @@ -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 资源或目标错误 diff --git a/ofdgo_renderer_pdf_lifecycle.go b/ofdgo_renderer_pdf_lifecycle.go deleted file mode 100644 index 9090c53..0000000 --- a/ofdgo_renderer_pdf_lifecycle.go +++ /dev/null @@ -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 -} diff --git a/ofdgo_renderer_pdf_media.go b/ofdgo_renderer_pdf_media.go index 3d9c9f9..8820f7c 100644 --- a/ofdgo_renderer_pdf_media.go +++ b/ofdgo_renderer_pdf_media.go @@ -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 "" +} diff --git a/ofdgo_renderer_pdf_movie.go b/ofdgo_renderer_pdf_movie.go deleted file mode 100644 index 2cb9eb2..0000000 --- a/ofdgo_renderer_pdf_movie.go +++ /dev/null @@ -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 "" -} diff --git a/ofdgo_renderer_pdf_sound.go b/ofdgo_renderer_pdf_sound.go deleted file mode 100644 index e983de1..0000000 --- a/ofdgo_renderer_pdf_sound.go +++ /dev/null @@ -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) -} diff --git a/ofdgo_renderer_pdf_write.go b/ofdgo_renderer_pdf_write.go deleted file mode 100644 index 9c65d2c..0000000 --- a/ofdgo_renderer_pdf_write.go +++ /dev/null @@ -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) -}