Files
ofdgo/ofdgo_pdf_3d.go
T
2026-10-14 09:02:24 +08:00

194 lines
7.8 KiB
Go

// 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"
"image"
"math"
"strings"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
"github.com/xiaoqidun/pdfgo"
)
// pdfModelAttachmentLimit 限制单次转换保留的共享模型索引数量
const pdfModelAttachmentLimit = 1024
// PDFThreeDRenderOptions 控制PDF三维视口的静态绘制,不改变注解激活状态
// 缓存与实例上限为0时采用U3DRenderOptions的默认值
// ResolveTexture按原始URL提供外部纹理,空值不访问外部资源
// Nodes按名称替换PDF视图中的节点状态,可指定动作轨道与采样时间,不改变原始注解
// Time以秒指定模型动画的采样时刻,零值为初始时刻
type PDFThreeDRenderOptions struct {
Width, Height int
MaxMemoryBytes int64
MaxInstances int
ResolveTexture U3DTextureResolver
Nodes []U3DNodeState
Time float64
}
// threeDAnnotation 按非交互呈现规则处理激活状态,保留原始模型与附件动作
// 入参: ctx 取消上下文, page PDF页面, annotation 三维注解
// 返回: error 外观、资源或取消错误
func (p *pdfImporter) threeDAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error {
source, err := p.reader.ReadThreeDContext(ctx, annotation)
if err != nil {
return p.interactiveAppearanceFallback(ctx, page, annotation, "metadata", err)
}
var data []byte
id := p.modelAttachments[source.Stream]
if id == "" {
data, err = source.Stream.DecodeContext(ctx)
if err != nil {
return p.interactiveAppearanceFallback(ctx, page, annotation, "model", err)
}
id, err = p.editor.AddAttachment(fmt.Sprintf("Model_%d.%s", p.page+1, strings.ToLower(string(source.Format))), data)
if err != nil {
return err
}
if p.modelAttachments == nil {
p.modelAttachments = make(map[*pdfgo.Stream]string)
}
if len(p.modelAttachments) < pdfModelAttachmentLimit {
p.modelAttachments[source.Stream] = id
}
}
action := Action{Event: "CLICK", GotoA: &GotoA{AttachID: id}}
var paint func(*pdfImporter) error
if source.ActivationPolicy.Activation != "XA" {
appearance, err := p.reader.ReadAnnotationAppearance(annotation)
if err != nil {
return p.interactiveAppearanceFallback(ctx, page, annotation, "appearance", err)
}
if appearance == nil {
return fmt.Errorf("missing PDF 3D normal appearance")
}
if data == nil {
data, err = p.editor.source.reader.AttachmentData(id)
if err != nil {
return err
}
}
paint, err = p.threeDPainter(ctx, page, source, annotation, data)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D rendering unavailable; normal appearance retained: " + err.Error()})
}
}
if err := p.paintedAnnotation(ctx, page, annotation, paint, action); err != nil {
return err
}
message := "PDF 3D appearance and original model retained; advanced interaction not transferred to OFD"
if paint != nil {
message = "PDF 3D static view and original model retained; interactive rendering not transferred to OFD"
}
p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message})
return nil
}
// threeDPainter 准备三维静态图像,复用库层压缩,失败时不提交注解对象
// 入参: ctx 取消上下文, page PDF页面, source 三维参数, annotation 注解, data 原始模型
// 返回: func(*pdfImporter) error 绘制回调, error 模型、绘制或资源错误
func (p *pdfImporter) threeDPainter(ctx context.Context, page *pdfgo.Page, source pdfgo.ThreeD, annotation pdfgo.Annotation, data []byte) (func(*pdfImporter) error, error) {
box := source.ViewBox
width, height := box.XMax-box.XMin, box.YMax-box.YMin
x, y := annotation.Rect.XMin/2+annotation.Rect.XMax/2, annotation.Rect.YMin/2+annotation.Rect.YMax/2
var nodes []pdfCompositeNode
if source.Background.RGB == nil {
return nil, &pdfgo.UnsupportedError{Feature: "3D background color space"}
}
if source.Background.EntireAnnotation {
r := annotation.Rect
path := pdfgo.Path{Segments: []pdfgo.Segment{
{Operator: "M", Points: []pdfgo.Point{{X: r.XMin, Y: r.YMin}}},
{Operator: "L", Points: []pdfgo.Point{{X: r.XMax, Y: r.YMin}}},
{Operator: "L", Points: []pdfgo.Point{{X: r.XMax, Y: r.YMax}}},
{Operator: "L", Points: []pdfgo.Point{{X: r.XMin, Y: r.YMax}}},
{Operator: "C"},
}}
nodes = append(nodes, pdfCompositeNode{path: &pdfgo.PathMark{Path: path, Fill: true, Style: pdfgo.Style{Fill: pdfgo.Paint{RGB: *source.Background.RGB, Alpha: 1}}}})
}
if width > 0 && height > 0 {
var overlay []pdfCompositeNode
if source.Presentation.Overlay != nil {
var err error
overlay, err = p.collectCompositeNodes(func(visitor pdfgo.Visitor) error {
return p.reader.WalkThreeDOverlay(ctx, page, source, visitor)
}, false)
if err != nil {
return nil, err
}
source.Presentation.Overlay = nil
}
if source.Format != "U3D" {
return nil, &pdfgo.UnsupportedError{Feature: "3D model format " + string(source.Format)}
}
model, err := DecodeU3D(ctx, data, U3DOptions{})
if err != nil {
return nil, err
}
widthMM := width * math.Hypot(p.matrix[0], p.matrix[1])
heightMM := height * math.Hypot(p.matrix[2], p.matrix[3])
w, h := math.Ceil(widthMM*p.rasterDPI/25.4), math.Ceil(heightMM*p.rasterDPI/25.4)
if !finite(w) || !finite(h) || w <= 0 || h <= 0 || w > 1<<30 || h > 1<<30 {
return nil, fmt.Errorf("invalid PDF 3D raster dimensions")
}
options := PDFThreeDRenderOptions{Width: int(w), Height: int(h)}
if p.resolveFile != nil {
options.ResolveTexture = func(ctx context.Context, address string) ([]byte, error) {
return p.resolveFile(ctx, pdfgo.FileSpecification{Name: address, FileSystem: "URL"})
}
}
img, err := RenderPDFThreeD(ctx, model, source, options)
if err != nil {
return nil, err
}
resource, err := p.reader.CreateImage(ctx, img)
if err != nil {
return nil, err
}
nodes = append(nodes, pdfCompositeNode{image: &pdfgo.ImageMark{Image: resource, Matrix: pdfgo.Matrix{width, 0, 0, height, x + box.XMin, y + box.YMin}, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}})
nodes = append(nodes, overlay...)
}
return func(stamp *pdfImporter) error {
if err := ctx.Err(); err != nil {
return err
}
return stamp.compositeObjects(nodes)
}, nil
}
// RenderPDFThreeD 按PDF相机、投影、背景与灯光绘制指定时刻的3DB区域,不执行脚本
// 返回图像不含3DB以外的注解背景,未支持的视图效果返回明确错误
// 每次从传入场景建立独立节点状态,不继承此前绘制的运行时状态
// 入参: ctx 取消上下文, model 解码后的U3D模型, source PDF三维参数, options 输出与资源限制
// 返回: *image.RGBA 不透明图像, error 参数、能力、预算或取消错误
func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD, options PDFThreeDRenderOptions) (*image.RGBA, error) {
return codecpdf.RenderThreeD(ctx, model, source, codecpdf.ThreeDRenderOptions{
Width: options.Width, Height: options.Height,
MaxMemoryBytes: options.MaxMemoryBytes, MaxInstances: options.MaxInstances,
ResolveTexture: options.ResolveTexture, Nodes: options.Nodes, Time: options.Time,
})
}