feat(文件拆分): 拆分PDF格式适配模块
build.yaml / build (push) Successful in 51s

This commit is contained in:
xiaoqidun committed 2026-10-14 09:02:24 +08:00
1 parent bec13d523f
commit b3c4bbc844
45 files changed
+1885 -1453

No files matched your search

-1
View File
@@ -12,7 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package threed 提供U3D模型解码、场景求值及软件绘制能力
package threed
import (
+67
View File
@@ -0,0 +1,67 @@
// 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 (
"context"
"crypto/sha256"
"encoding/binary"
"math"
"github.com/xiaoqidun/pdfgo"
)
// ClipPathKey 按路径完整内容与变换生成键,不依赖可变切片的地址
// 入参: ctx 取消上下文, path 裁剪路径, matrix 页面变换
// 返回: [32]byte 内容摘要, error 取消错误
func ClipPathKey(ctx context.Context, path pdfgo.Path, matrix pdfgo.Matrix) ([32]byte, error) {
hash := sha256.New()
var value [16]byte
for _, number := range matrix {
binary.LittleEndian.PutUint64(value[:8], math.Float64bits(number))
hash.Write(value[:8])
}
binary.LittleEndian.PutUint64(value[:8], uint64(len(path.Segments)))
value[8] = 0
if path.EvenOdd {
value[8] = 1
}
hash.Write(value[:9])
for _, segment := range path.Segments {
if err := ctx.Err(); err != nil {
return [32]byte{}, err
}
binary.LittleEndian.PutUint64(value[:8], uint64(len(segment.Operator)))
binary.LittleEndian.PutUint64(value[8:], uint64(len(segment.Points)))
hash.Write(value[:])
hash.Write([]byte(segment.Operator))
for index, point := range segment.Points {
if index&255 == 0 {
if err := ctx.Err(); err != nil {
return [32]byte{}, err
}
}
binary.LittleEndian.PutUint64(value[:8], math.Float64bits(point.X))
binary.LittleEndian.PutUint64(value[8:], math.Float64bits(point.Y))
hash.Write(value[:])
}
}
if err := ctx.Err(); err != nil {
return [32]byte{}, err
}
var key [32]byte
hash.Sum(key[:0])
return key, nil
}
+37
View File
@@ -0,0 +1,37 @@
// 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 (
"github.com/xiaoqidun/pdfgo"
)
// OutputColor 将合成结果映射到OFD输出,与保留的设备四色对象使用相同显示换算
// 入参: space 合成空间, values 非预乘分量
// 返回: [3]float64 显示颜色, error 校准颜色转换错误
func OutputColor(space *pdfgo.ColorSpace, values [4]float64) ([3]float64, error) {
if space.Model == "DeviceCMYK" && !space.Calibrated() {
black := 1 - values[3]
return [3]float64{(1 - values[0]) * black, (1 - values[1]) * black, (1 - values[2]) * black}, nil
}
return space.RGB(values[:space.Components()], "RelativeColorimetric")
}
// NormalBlend 判断混合模式是否为普通覆盖
// 入参: mode 混合模式
// 返回: bool 是否普通覆盖
func NormalBlend(mode pdfgo.Name) bool {
return mode == "" || mode == "Normal" || mode == "Compatible"
}
+195
View File
@@ -0,0 +1,195 @@
// 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"
"fmt"
"image"
"io"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
"github.com/xiaoqidun/pdfgo"
)
// TextPage 保存后端文字对象数量及待替换区段
type TextPage struct {
Count int
Replacements []pdfgo.TextReplacement
}
// WriteOptions 保存PDF最终写出所需的像素、文字和导航修正
// Images逐页记录绘制图像,nil图像表示不替换;Links保留全部链接位置,包括矩形区域的nil项
// PrepareRewrite在页面引用确定后填充导航动作,不得提前写出PDF
type WriteOptions struct {
Images [][]image.Image
Text []TextPage
Links map[int][]*pdfgo.LinkRegion
ExactImages bool
ExactText bool
Optimization pdfgo.OptimizeOptions
PrepareRewrite func(context.Context, *pdfgo.Reader, []pdfgo.Reference, *pdfgo.RewriteOptions) error
}
// Write 合并图像、文字、导航修正及压缩,直接写出最终PDF
// 入参: ctx 取消上下文, data 后端PDF数据, writer 输出流, settings 写出配置
// 返回: error 资源、压缩或写入错误
func Write(ctx context.Context, data []byte, writer io.Writer, settings WriteOptions) error {
nativeWrite := settings.PrepareRewrite != nil
if !settings.ExactImages && !settings.ExactText && !nativeWrite && settings.Optimization.Compression.Mode == pdfgo.CompressionUnchanged {
if err := ctx.Err(); err != nil {
return err
}
n, err := writer.Write(data)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
return err
}
reader, err := pdfgo.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return err
}
defer reader.Close()
if !settings.ExactImages && !settings.ExactText && !nativeWrite {
_, err := reader.OptimizeTo(ctx, writer, settings.Optimization)
return err
}
images := make(map[pdfgo.Reference]image.Image)
links := make(map[pdfgo.AnnotationLocation]pdfgo.LinkRegion)
texts := make(map[pdfgo.Reference][]pdfgo.TextReplacement)
references := make([]pdfgo.Reference, len(settings.Images))
pages := 0
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
if index >= len(settings.Images) {
return fmt.Errorf("unexpected PDF output page")
}
pages++
references[index] = page.Reference
if nativeWrite {
annotations, err := page.AnnotationsContext(ctx)
if err != nil {
return err
}
expected := settings.Links[index]
if len(annotations) != len(expected) {
return fmt.Errorf("PDF output link count differs")
}
for i, link := range expected {
if annotations[i].Subtype != pdfgo.Name("Link") {
return fmt.Errorf("unexpected PDF output annotation")
}
if link != nil {
links[pdfgo.AnnotationLocation{Page: page.Reference, Index: i}] = *link
}
}
}
if !settings.ExactImages && !settings.ExactText {
return nil
}
value, err := reader.Resolve(page.Resources["XObject"])
if err != nil {
return err
}
resources, _ := value.(pdfgo.Dictionary)
var content bytes.Buffer
if _, err := page.WriteContent(ctx, &content); err != nil {
return err
}
position := 0
textCount := 0
if err := pdfgo.WalkOperations(ctx, content.Bytes(), func(operation pdfgo.Operation) error {
if operation.Operator == "BT" {
textCount++
}
if !settings.ExactImages {
return nil
}
if operation.Operator != "Do" {
return nil
}
if len(operation.Operands) != 1 || position >= len(settings.Images[index]) {
return fmt.Errorf("unexpected PDF output image")
}
name, ok := operation.Operands[0].(pdfgo.Name)
if !ok {
return fmt.Errorf("invalid PDF output image name")
}
ref, ok := resources[name].(pdfgo.Reference)
if !ok {
return fmt.Errorf("invalid PDF output image reference")
}
original := settings.Images[index][position]
position++
if original == nil {
return nil
}
image, err := reader.ReadImage(ref)
if err != nil {
return err
}
if image.Width != original.Bounds().Dx() || image.Height != original.Bounds().Dy() {
return fmt.Errorf("PDF output image dimensions differ")
}
if previous := images[ref]; previous != nil && previous != original {
return fmt.Errorf("PDF output image reference conflicts")
}
images[ref] = original
return nil
}); err != nil {
return err
}
if settings.ExactImages && position != len(settings.Images[index]) {
return fmt.Errorf("PDF output image missing")
}
if settings.ExactText {
if index >= len(settings.Text) || textCount != settings.Text[index].Count {
return fmt.Errorf("PDF output text count differs")
}
if len(settings.Text[index].Replacements) != 0 {
texts[page.Reference] = settings.Text[index].Replacements
}
}
return nil
})
if err != nil {
return err
}
if pages != len(settings.Images) {
return fmt.Errorf("PDF output page missing")
}
options := pdfgo.RewriteOptions{Images: images, ImageOptions: pdfgo.ImageWriteOptions{PreblendBinary: true}, LinkRegions: links, TextReplacements: texts, Optimization: settings.Optimization}
if nativeWrite {
if err := settings.PrepareRewrite(ctx, reader, references, &options); err != nil {
return err
}
}
for ref, original := range images {
if err := ctx.Err(); err != nil {
return err
}
pixels, err := baseimage.PixelDataContext(ctx, original)
if err != nil {
return err
}
if pixels == nil || pixels.Bounds() != original.Bounds() {
return fmt.Errorf("PDF output image pixel bounds differ")
}
images[ref] = pixels
}
_, err = reader.RewriteTo(ctx, writer, options)
return err
}
+338
View File
@@ -0,0 +1,338 @@
// 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"
"crypto/sha256"
"encoding/binary"
"fmt"
"unicode/utf16"
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
"github.com/xiaoqidun/pdfgo"
)
// FontRepair 保存字体封装修复范围,不混用字形度量与位置索引
type FontRepair struct {
MetricsLimit uint16
Locations bool
}
// EmbeddedFontName 去除标准六位大写子集前缀,保留字体名称和样式后缀
// 入参: name PDF或内嵌字体名称
// 返回: string 原字体名称
func EmbeddedFontName(name string) string {
if len(name) <= 7 || name[6] != '+' {
return name
}
for i := range 6 {
if name[i] < 'A' || name[i] > 'Z' {
return name
}
}
return name[7:]
}
// FontProgram 为PDF子集字体补齐封装表,保留字形轮廓和编号
// 无效字符表按PDF已解码字形重建,不改变字符对应的字形编号
// 入参: source PDF字体, type1 已解析的Type1程序,nil时按需解析
// 返回: []byte 封装后的字体数据, FontRepair 字体修复范围, error 错误信息
func FontProgram(source *pdfgo.Font, type1 *basefont.Type1Program) ([]byte, FontRepair, error) {
var repair FontRepair
program := source.Program
if source.ProgramType == "FontFile" {
if type1 == nil {
parsed, err := basefont.ParseType1Program(program)
if err != nil {
return nil, repair, err
}
type1 = &parsed
}
var err error
program, err = type1.ToCFF(FontIdentity(source))
if err != nil {
return nil, repair, err
}
}
bareCFF := len(program) >= 4 && program[0] == 1 && program[1] == 0 && program[2] >= 4 && program[3] >= 1 && program[3] <= 4
if source.ProgramType == "Type1C" || source.ProgramType == "CIDFontType0C" || bareCFF {
var err error
program, _, err = basefont.WrapCFFToOTF(program)
if err != nil {
return nil, repair, err
}
}
tables, err := basefont.FileTables(program, 0)
if err != nil {
return nil, repair, err
}
if len(tables["head"]) < 54 || len(tables["maxp"]) < 6 || len(tables["hhea"]) < 36 || len(tables["hmtx"]) == 0 {
return nil, repair, fmt.Errorf("embedded PDF font lacks required metrics")
}
count := binary.BigEndian.Uint16(tables["maxp"][4:6])
if count == 0 {
return nil, repair, fmt.Errorf("embedded PDF font has no glyphs")
}
repair.Locations, err = basefont.NormalizeTrueTypeLocations(tables)
if err != nil {
return nil, repair, err
}
changed := repair.Locations
if head := tables["head"]; len(head) == 56 && binary.BigEndian.Uint32(head[:4]) == 0x00010000 && head[54] == 0 && head[55] == 0 {
tables["head"] = head[:54]
changed = true
}
if cff := tables["CFF "]; len(cff) != 0 {
sanitized, err := basefont.SanitizeCFF(cff)
if err != nil {
return nil, repair, err
}
normalized, err := basefont.NormalizeCFFCharstringsAt(sanitized, binary.BigEndian.Uint16(tables["head"][18:20]))
if err != nil {
return nil, repair, err
}
if !bytes.Equal(normalized, cff) {
tables["CFF "] = normalized
changed = true
}
}
metrics := int(binary.BigEndian.Uint16(tables["hhea"][34:36]))
if metrics == 0 || metrics > int(count) {
return nil, repair, fmt.Errorf("invalid embedded PDF font metric count")
}
metricLength := 4*metrics + 2*(int(count)-metrics)
if len(tables["hmtx"]) < metricLength {
if len(tables["hmtx"]) < 4*metrics || len(tables["hmtx"])%2 != 0 {
return nil, repair, fmt.Errorf("incomplete embedded PDF font metrics: have %d, need %d", len(tables["hmtx"]), metricLength)
}
limit := metrics + (len(tables["hmtx"])-4*metrics)/2
missing, err := missingLeftBearings(tables, limit, int(count))
if err != nil {
return nil, repair, err
}
tables["hmtx"] = append(bytes.Clone(tables["hmtx"]), missing...)
repair.MetricsLimit = uint16(limit)
changed = true
}
if len(tables["hmtx"]) > metricLength {
tables["hmtx"] = tables["hmtx"][:metricLength]
changed = true
}
invalidCmap := false
if source.Subtype == "TrueType" || source.Subtype == "Type0" {
mapping := basefont.ParseCmapMappings(tables["cmap"])
for char, glyph := range mapping {
if glyph >= count {
delete(mapping, char)
invalidCmap = true
}
}
if invalidCmap && source.Subtype == "TrueType" {
tables["cmap"] = basefont.BuildCmapTable(count, mapping)
changed = true
}
}
if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && invalidCmap || (source.Subtype == "Type0" || source.Subtype == "TrueType") && CmapNeedsRebuild(tables["cmap"]) {
mapping, err := source.GlyphMap()
if err != nil {
return nil, repair, err
}
for _, glyph := range mapping {
if glyph >= count {
return nil, repair, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph, count)
}
}
basefont.AddPackedGlyphMapping(mapping, count)
tables["cmap"] = basefont.BuildCmapTable(count, mapping)
changed = true
}
if len(basefont.NamesFromTable(tables["name"])) == 0 {
fontName := FontIdentity(source)
name := utf16.Encode([]rune(fontName))
if len(name) > 32767 {
return nil, repair, fmt.Errorf("PDF font name exceeds name table capacity")
}
data := make([]byte, 42+len(name)*2)
binary.BigEndian.PutUint16(data[2:], 3)
binary.BigEndian.PutUint16(data[4:], 42)
for n, id := range []uint16{1, 4, 6} {
record := data[6+n*12:]
binary.BigEndian.PutUint16(record, 3)
binary.BigEndian.PutUint16(record[2:], 1)
binary.BigEndian.PutUint16(record[4:], 0x409)
binary.BigEndian.PutUint16(record[6:], id)
binary.BigEndian.PutUint16(record[8:], uint16(len(name)*2))
}
for n, v := range name {
binary.BigEndian.PutUint16(data[42+n*2:], v)
}
tables["name"] = data
changed = true
}
if len(tables["post"]) == 0 {
tables["post"] = basefont.BuildPostTable()
changed = true
}
if len(tables["OS/2"]) == 0 {
hhea := tables["hhea"]
tables["OS/2"] = basefont.BuildOS2TableWithMetrics(int16(binary.BigEndian.Uint16(hhea[4:6])), int16(binary.BigEndian.Uint16(hhea[6:8])))
changed = true
}
for _, tag := range []string{"cvt ", "fpgm", "prep"} {
if data, ok := tables[tag]; ok && len(data) == 0 {
delete(tables, tag)
changed = true
}
}
if !changed && !sfntMissingPadding(program) {
return program, repair, nil
}
result, err := basefont.SerializeOTF(tables)
return result, repair, err
}
// FontIdentity 返回字体名称或由原始程序生成的稳定封装标识
// 入参: source PDF字体
// 返回: string 字体标识
func FontIdentity(source *pdfgo.Font) string {
if source.Name != "" {
return source.Name
}
checksum := sha256.Sum256(source.Program)
return fmt.Sprintf("PDF-%x", checksum[:28])
}
// CmapNeedsRebuild 检查字体封装字符表是否存在结构错误或交叠区段
// 入参: data cmap表数据
// 返回: bool 是否需要按PDF显式字形编号重建
func CmapNeedsRebuild(data []byte) bool {
if len(data) < 4 || binary.BigEndian.Uint16(data) != 0 {
return true
}
count := int(binary.BigEndian.Uint16(data[2:]))
if count == 0 || count > (len(data)-4)/8 {
return true
}
for index := range count {
offset := uint64(binary.BigEndian.Uint32(data[8+8*index:]))
if offset < uint64(4+8*count) || offset > uint64(len(data)) || uint64(len(data))-offset < 2 {
return true
}
sub := data[int(offset):]
if binary.BigEndian.Uint16(sub) == 6 {
if len(sub) < 10 {
return true
}
length := int(binary.BigEndian.Uint16(sub[2:]))
first, glyphs := int(binary.BigEndian.Uint16(sub[6:])), int(binary.BigEndian.Uint16(sub[8:]))
if length%2 != 0 || length > len(sub) || 10+2*glyphs > length || first+glyphs > 65536 {
return true
}
continue
}
if binary.BigEndian.Uint16(sub) != 4 {
continue
}
if len(sub) < 16 {
return true
}
length := int(binary.BigEndian.Uint16(sub[2:]))
segmentBytes := int(binary.BigEndian.Uint16(sub[6:]))
segments := segmentBytes / 2
if segmentBytes == 0 || segmentBytes%2 != 0 || length%2 != 0 || length > len(sub) || 16+8*segments > length {
return true
}
glyphStart := 16 + 8*segments
previous := -1
for i := 0; i < segments; i++ {
end := int(binary.BigEndian.Uint16(sub[14+2*i:]))
start := int(binary.BigEndian.Uint16(sub[16+2*segments+2*i:]))
if end < start || start <= previous || i == segments-1 && (start != 65535 || end != 65535) {
return true
}
previous = end
position := 16 + 6*segments + 2*i
offset := int(binary.BigEndian.Uint16(sub[position:]))
if offset != 0 && (offset%2 != 0 || position+offset < glyphStart || position+offset+2*(end-start+1) > length) {
return true
}
}
}
return false
}
// sfntMissingPadding 判断字体表目录是否引用了未写入的末尾对齐字节
// 入参: program OpenType字体数据
// 返回: bool 是否需要重新封装
func sfntMissingPadding(program []byte) bool {
if len(program) < 12 {
return true
}
count := int(binary.BigEndian.Uint16(program[4:6]))
if count > (len(program)-12)/16 {
return true
}
for index := range count {
record := program[12+16*index:]
offset := int(binary.BigEndian.Uint32(record[8:12]))
length := int(binary.BigEndian.Uint32(record[12:16]))
if offset > len(program) || length > len(program)-offset || (4-length%4)%4 > len(program)-offset-length {
return true
}
}
return false
}
// missingLeftBearings 从轮廓边界恢复缺失的尾部左侧边距
// 入参: tables 字体表, start 首个缺失字形, count 字形总数
// 返回: []byte 补齐的hmtx数据, error 无法读取轮廓
func missingLeftBearings(tables map[string][]byte, start, count int) ([]byte, error) {
head, loca, glyf := tables["head"], tables["loca"], tables["glyf"]
if len(head) < 54 || len(glyf) == 0 {
return nil, fmt.Errorf("embedded PDF font lacks glyph outlines for missing metrics")
}
format := int(int16(binary.BigEndian.Uint16(head[50:52])))
entrySize := 2
if format == 1 {
entrySize = 4
} else if format != 0 {
return nil, fmt.Errorf("invalid embedded PDF font location format")
}
if len(loca) < (count+1)*entrySize {
return nil, fmt.Errorf("incomplete embedded PDF font glyph locations")
}
location := func(index int) int {
if format == 0 {
return int(binary.BigEndian.Uint16(loca[index*2:])) * 2
}
return int(binary.BigEndian.Uint32(loca[index*4:]))
}
result := make([]byte, 2*(count-start))
for index := start; index < count; index++ {
from, to := location(index), location(index+1)
if from > to || to > len(glyf) {
return nil, fmt.Errorf("invalid embedded PDF font glyph location")
}
if from == to {
continue
}
if to-from < 10 {
return nil, fmt.Errorf("incomplete embedded PDF font glyph outline")
}
copy(result[(index-start)*2:], glyf[from+2:from+4])
}
return result, nil
}
+101
View File
@@ -0,0 +1,101 @@
// 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 (
"context"
"fmt"
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
"github.com/xiaoqidun/pdfgo"
)
// GeometryPath 直接转换PDF路径坐标,不经文本编码或曲线展平
// 入参: ctx 取消上下文, source PDF路径, matrix 坐标变换
// 返回: basegeom.Path 独立几何路径, error 指令、坐标或取消错误
func GeometryPath(ctx context.Context, source pdfgo.Path, matrix pdfgo.Matrix) (basegeom.Path, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
path := make(basegeom.Path, len(source.Segments))
for i, segment := range source.Segments {
if err := ctx.Err(); err != nil {
return nil, err
}
var count int
switch segment.Operator {
case "M":
path[i].Verb, count = basegeom.Move, 1
case "L":
path[i].Verb, count = basegeom.Line, 1
case "B":
path[i].Verb, count = basegeom.Cubic, 3
case "C":
path[i].Verb = basegeom.ClosePath
default:
return nil, fmt.Errorf("invalid PDF path operator %q", segment.Operator)
}
if len(segment.Points) != count {
return nil, fmt.Errorf("invalid PDF path operands for %s", segment.Operator)
}
var points [3]basegeom.Point
for j, point := range segment.Points {
point = matrix.Apply(point)
points[j] = basegeom.Point{X: point.X, Y: point.Y}
}
if count != 0 {
path[i].End = points[count-1]
}
if count == 3 {
path[i].Control1, path[i].Control2 = points[0], points[1]
}
}
if err := path.Validate(); err != nil {
return nil, err
}
return path, ctx.Err()
}
// GlyphPath 将字形轮廓变换到PDF页面坐标,二次曲线精确转换为三次曲线
// 入参: outline 字形轮廓, matrix 字形到页面的变换
// 返回: pdfgo.Path 页面路径, error 非字体轮廓指令
func GlyphPath(outline basegeom.Path, matrix pdfgo.Matrix) (pdfgo.Path, error) {
path := pdfgo.Path{Segments: make([]pdfgo.Segment, 0, len(outline))}
var current, start basegeom.Point
point := func(p basegeom.Point) pdfgo.Point { return matrix.Apply(pdfgo.Point{X: p.X, Y: p.Y}) }
for _, segment := range outline {
switch segment.Verb {
case basegeom.Move:
start = segment.End
path.Segments = append(path.Segments, pdfgo.Segment{Operator: "M", Points: []pdfgo.Point{point(segment.End)}})
case basegeom.Line:
path.Segments = append(path.Segments, pdfgo.Segment{Operator: "L", Points: []pdfgo.Point{point(segment.End)}})
case basegeom.Quad:
c1 := basegeom.Point{X: current.X + (segment.Control1.X-current.X)*2/3, Y: current.Y + (segment.Control1.Y-current.Y)*2/3}
c2 := basegeom.Point{X: segment.End.X + (segment.Control1.X-segment.End.X)*2/3, Y: segment.End.Y + (segment.Control1.Y-segment.End.Y)*2/3}
path.Segments = append(path.Segments, pdfgo.Segment{Operator: "B", Points: []pdfgo.Point{point(c1), point(c2), point(segment.End)}})
case basegeom.Cubic:
path.Segments = append(path.Segments, pdfgo.Segment{Operator: "B", Points: []pdfgo.Point{point(segment.Control1), point(segment.Control2), point(segment.End)}})
case basegeom.ClosePath:
path.Segments = append(path.Segments, pdfgo.Segment{Operator: "C"})
current = start
continue
default:
return pdfgo.Path{}, fmt.Errorf("unsupported PDF glyph geometry verb %d", segment.Verb)
}
current = segment.End
}
return path, nil
}
+95
View File
@@ -0,0 +1,95 @@
// 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 (
"image"
"image/color"
"math"
baseimage "github.com/xiaoqidun/ofdgo/internal/base/image"
"github.com/xiaoqidun/pdfgo"
)
// StencilImage 按当前填充色读取模板图像,不分配整幅彩色样本
type StencilImage struct {
Source image.Image
Fill color.NRGBA64
}
// NativeImage 按源像素输出原生过程色,不分配完整显示颜色缓冲
type NativeImage struct {
Source *pdfgo.ImageProcess
Space *pdfgo.ColorSpace
}
// ColorModel 保留填充色和模板的有效采样精度
// 返回: color.Model 非预乘颜色模型
func (s *StencilImage) ColorModel() color.Model {
model := s.Source.ColorModel()
if s.Fill.R%257 == 0 && s.Fill.G%257 == 0 && s.Fill.B%257 == 0 && (model == color.GrayModel || model == color.NRGBAModel || model == color.RGBAModel) {
return color.NRGBAModel
}
return color.NRGBA64Model
}
// Bounds 返回模板图像的采样边界
// 返回: image.Rectangle 图像边界
func (s *StencilImage) Bounds() image.Rectangle { return s.Source.Bounds() }
// At 以模板灰度计算填充覆盖率,不重复变换颜色
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.Color 非预乘颜色
func (s *StencilImage) At(x, y int) color.Color {
return s.NRGBA64At(x, y)
}
// NRGBA64At 直接读取模板覆盖率,保留填充色的有效精度
// 入参: x 横向坐标, y 纵向坐标
// 返回: color.NRGBA64 非预乘颜色
func (s *StencilImage) NRGBA64At(x, y int) color.NRGBA64 {
if !image.Pt(x, y).In(s.Bounds()) {
return color.NRGBA64{}
}
gray := baseimage.NRGBA64At(s.Source, x, y)
pixel := s.Fill
pixel.A = uint16(65535 - gray.R)
return pixel
}
// ColorModel 保留16位原生分量与源透明度
// 返回: color.Model 非预乘颜色模型
func (s *NativeImage) ColorModel() color.Model { return color.NRGBA64Model }
// Bounds 返回源图像的完整采样边界
// 返回: image.Rectangle 原始像素边界
func (s *NativeImage) Bounds() image.Rectangle { return s.Source.Bounds() }
// At 获取按OFD输出映射的原生颜色
// 入参: x 横坐标, y 纵坐标
// 返回: color.Color 非预乘颜色
func (s *NativeImage) At(x, y int) color.Color { return s.NRGBA64At(x, y) }
// NRGBA64At 按原始精度转换设备过程色,不进行页面采样或备用变换
// 入参: x 横坐标, y 纵坐标
// 返回: color.NRGBA64 非预乘颜色
func (s *NativeImage) NRGBA64At(x, y int) color.NRGBA64 {
if !image.Pt(x, y).In(s.Bounds()) {
return color.NRGBA64{}
}
values, alpha := s.Source.ValuesAt(x, y)
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))}
}
+106
View File
@@ -0,0 +1,106 @@
// 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"
"reflect"
"github.com/xiaoqidun/pdfgo"
)
// imageValueBudget 限制图像描述的检查深度和保留字节数
type imageValueBudget struct {
nodes int
bytes int
}
// ImageDigest 对无间接依赖的小图像生成编码摘要,超预算时不参与内容缓存
// 入参: ctx 取消上下文, source 图像描述
// 返回: [32]byte 编码摘要, bool 是否可复用, error 取消错误
func ImageDigest(ctx context.Context, source *pdfgo.Image) ([32]byte, bool, error) {
if source == nil || source.Stream == nil || len(source.Stream.Data) > 1<<20 {
return [32]byte{}, false, nil
}
budget := imageValueBudget{nodes: 256, bytes: 65536}
for _, value := range []pdfgo.Object{source.Stream.Dictionary, source.ColorSpace, source.Decode, source.Mask, source.SoftMask} {
if !imageDirectValue(value, &budget) {
return [32]byte{}, false, ctx.Err()
}
}
hash := sha256.New()
data := source.Stream.Data
for len(data) != 0 {
if err := ctx.Err(); err != nil {
return [32]byte{}, false, err
}
n := min(len(data), 65536)
hash.Write(data[:n])
data = data[n:]
}
var result [32]byte
hash.Sum(result[:0])
return result, true, ctx.Err()
}
// SameImage 核对完整编码与有效图像属性,不以摘要相同代替内容相同
// 入参: left 已缓存图像, right 待复用图像,二者须包含有效编码流
// 返回: bool 是否可使用同一图像资源
func SameImage(left, right *pdfgo.Image) bool {
return left.Width == right.Width && left.Height == right.Height && left.BitsPerComponent == right.BitsPerComponent &&
left.ImageMask == right.ImageMask && left.Interpolate == right.Interpolate &&
bytes.Equal(left.Stream.Data, right.Stream.Data) && reflect.DeepEqual(left.Stream.Dictionary, right.Stream.Dictionary) &&
reflect.DeepEqual(left.ColorSpace, right.ColorSpace) && reflect.DeepEqual(left.Decode, right.Decode) &&
reflect.DeepEqual(left.Mask, right.Mask) && reflect.DeepEqual(left.SoftMask, right.SoftMask)
}
// imageDirectValue 检查图像描述是否完全独立,拒绝间接引用和嵌套流
// 入参: value 描述值, budget 描述检查预算
// 返回: bool 是否不依赖阅读器资源
func imageDirectValue(value pdfgo.Object, budget *imageValueBudget) bool {
if budget.nodes <= 0 || budget.bytes < 64 {
return false
}
budget.nodes--
budget.bytes -= 64
switch v := value.(type) {
case nil, pdfgo.Boolean, pdfgo.Integer, pdfgo.Real:
return true
case pdfgo.Name:
budget.bytes -= len(v)
return budget.bytes >= 0
case pdfgo.String:
budget.bytes -= len(v)
return budget.bytes >= 0
case pdfgo.Array:
for _, child := range v {
if !imageDirectValue(child, budget) {
return false
}
}
return true
case pdfgo.Dictionary:
for key, child := range v {
budget.bytes -= len(key)
if !imageDirectValue(child, budget) {
return false
}
}
return true
}
return false
}
+444
View File
@@ -0,0 +1,444 @@
// 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 (
"context"
"fmt"
"math"
"slices"
"github.com/tdewolff/canvas"
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
"github.com/xiaoqidun/pdfgo"
)
// PDF链接区域细分上限与曲线逼近误差,误差单位为毫米
const (
LinkEdgeLimit = 4096
LinkQuadLimit = 65536
linkTolerance = 0.01
)
// linkEdge 保存按纵坐标排序的边及原始绕行方向
type linkEdge struct {
a, b pdfgo.Point
winding int
}
// linkCross 保存水平扫描线与区域边的交点
type linkCross struct {
x float64
edge int
}
// ActionRegion 将非零绕行区域分解为PDF链接四边形,保留空洞和相交轮廓
// 入参: ctx 取消上下文, outline 页面SVG路径, pageH 页面高度
// 返回: *pdfgo.LinkRegion 链接区域,nil表示沿用矩形, error 解析或复杂度错误
func ActionRegion(ctx context.Context, outline string, pageH float64) (*pdfgo.LinkRegion, error) {
if outline == "" {
return nil, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
path, err := canvas.ParseSVGPath(outline)
if err != nil {
return nil, err
}
if path.Len() > LinkEdgeLimit {
return nil, fmt.Errorf("PDF link region exceeds edge limit")
}
if _, ok := basegeom.RectangleBounds(linkGeometry(path)); ok {
return nil, nil
}
path, err = FlattenLink(ctx, path)
if err != nil {
return nil, err
}
if quads := convexLink(path, pageH); len(quads) != 0 {
return linkRegionBounds(quads), nil
}
var edges []linkEdge
var levels []float64
var start, previous pdfgo.Point
open := false
add := func(a, b pdfgo.Point) {
if a.Y == b.Y {
return
}
edge := linkEdge{a: a, b: b, winding: 1}
if a.Y > b.Y {
edge.a, edge.b, edge.winding = b, a, -1
}
edges = append(edges, edge)
levels = append(levels, edge.a.Y, edge.b.Y)
}
scanner := path.Scanner()
for scanner.Scan() {
p := scanner.End()
point := pdfgo.Point{X: p.X * 72 / 25.4, Y: (pageH - p.Y) * 72 / 25.4}
if !finite(point.X) || !finite(point.Y) {
return nil, fmt.Errorf("invalid PDF link coordinate")
}
switch scanner.Cmd() {
case canvas.MoveToCmd:
if open {
add(previous, start)
}
start, open = point, true
case canvas.LineToCmd:
if !open {
start, open = previous, true
}
add(previous, point)
case canvas.CloseCmd:
add(previous, start)
open = false
}
previous = point
}
if open {
add(previous, start)
}
for i, a := range edges {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, b := range edges[i+1:] {
low, high := max(a.a.Y, b.a.Y), min(a.b.Y, b.b.Y)
if low >= high {
continue
}
d0, d1 := a.x(low)-b.x(low), a.x(high)-b.x(high)
if d0 < 0 && d1 > 0 || d0 > 0 && d1 < 0 {
y := low + (high-low)*(d0/(d0-d1))
if !finite(y) {
return nil, fmt.Errorf("invalid PDF link intersection")
}
levels = append(levels, y)
if len(levels) > LinkQuadLimit {
return nil, fmt.Errorf("PDF link region exceeds intersection limit")
}
}
}
}
slices.Sort(levels)
levels = slices.Compact(levels)
region := &pdfgo.LinkRegion{}
crossings := make([]linkCross, 0, len(edges))
for i := 1; i < len(levels); i++ {
if err := ctx.Err(); err != nil {
return nil, err
}
low, high := levels[i-1], levels[i]
mid := low + (high-low)/2
if mid <= low || mid >= high {
continue
}
crossings = crossings[:0]
for j, edge := range edges {
if edge.a.Y < mid && mid < edge.b.Y {
crossings = append(crossings, linkCross{edge.x(mid), j})
}
}
slices.SortFunc(crossings, func(a, b linkCross) int {
if a.x < b.x {
return -1
}
if a.x > b.x {
return 1
}
return a.edge - b.edge
})
winding, left := 0, 0
for _, cross := range crossings {
before := winding
winding += edges[cross.edge].winding
if before == 0 && winding != 0 {
left = cross.edge
}
if before != 0 && winding == 0 {
a, b := edges[left], edges[cross.edge]
quad := [4]pdfgo.Point{{X: a.x(low), Y: low}, {X: b.x(low), Y: low}, {X: b.x(high), Y: high}, {X: a.x(high), Y: high}}
region.Quads = appendLinkQuad(region.Quads, quad)
if len(region.Quads) > LinkQuadLimit {
return nil, fmt.Errorf("PDF link region exceeds quadrilateral limit")
}
}
}
}
if len(region.Quads) == 0 {
return region, nil
}
return linkRegionBounds(region.Quads), nil
}
// convexLink 直接保留单个凸四边形,三角形只分成三个四边形
// 入参: path 已展开路径, pageH 页面高度
// 返回: [][4]pdfgo.Point 逆时针四边形,不适用时为空
func convexLink(path *canvas.Path, pageH float64) [][4]pdfgo.Point {
if path.HasSubpaths() || path.Len() > 6 {
return nil
}
points := path.Coords()
if len(points) > 1 && points[0] == points[len(points)-1] {
points = points[:len(points)-1]
}
if len(points) < 3 || len(points) > 4 {
return nil
}
var storage [4]pdfgo.Point
vertices := storage[:len(points)]
for i, p := range points {
vertices[i] = pdfgo.Point{X: p.X * 72 / 25.4, Y: (pageH - p.Y) * 72 / 25.4}
}
direction := 0.0
for i, a := range vertices {
b, c := vertices[(i+1)%len(vertices)], vertices[(i+2)%len(vertices)]
cross := linkTurn(a, b, c)
if !finite(cross) || cross == 0 || direction != 0 && (direction < 0) != (cross < 0) {
return nil
}
direction = cross
}
if direction < 0 {
slices.Reverse(vertices)
}
if len(vertices) == 4 {
return [][4]pdfgo.Point{storage}
}
return appendLinkTriangle(nil, [3]pdfgo.Point{vertices[0], vertices[1], vertices[2]})
}
// linkRegionBounds 计算四边形共同的注解矩形,保证全部顶点落在矩形内
// 入参: quads 链接四边形
// 返回: *pdfgo.LinkRegion 链接区域
func linkRegionBounds(quads [][4]pdfgo.Point) *pdfgo.LinkRegion {
region := &pdfgo.LinkRegion{Quads: quads, Rect: pdfgo.Rectangle{XMin: math.Inf(1), YMin: math.Inf(1), XMax: math.Inf(-1), YMax: math.Inf(-1)}}
for _, quad := range quads {
for _, p := range quad {
region.Rect.XMin, region.Rect.YMin = min(region.Rect.XMin, p.X), min(region.Rect.YMin, p.Y)
region.Rect.XMax, region.Rect.YMax = max(region.Rect.XMax, p.X), max(region.Rect.YMax, p.Y)
}
}
return region
}
// FlattenLink 在取消和数量限制内展开动作曲线,复用标准椭圆弧转换
// 入参: ctx 取消上下文, path 页面SVG路径
// 返回: *canvas.Path 闭合折线路径, error 精度或复杂度错误
func FlattenLink(ctx context.Context, path *canvas.Path) (*canvas.Path, error) {
geometry := linkGeometry(path)
result := &canvas.Path{}
var current, start basegeom.Point
count := 0
var flatten func([]basegeom.Point, int) error
flatten = func(points []basegeom.Point, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
first, last := points[0], points[len(points)-1]
dx, dy := last.X-first.X, last.Y-first.Y
length := math.Hypot(dx, dy)
distance := 0.0
for _, point := range points[1 : len(points)-1] {
t := 0.0
if length > 0 {
t = min(max((point.X-first.X)*(dx/length)+(point.Y-first.Y)*(dy/length), 0), length) / length
}
distance = max(distance, math.Hypot(point.X-(first.X*(1-t)+last.X*t), point.Y-(first.Y*(1-t)+last.Y*t)))
}
if !finite(length) || !finite(distance) {
return fmt.Errorf("invalid PDF link curve")
}
if distance <= linkTolerance/2 {
count++
if count > LinkEdgeLimit {
return fmt.Errorf("PDF link region exceeds edge limit")
}
result.LineTo(last.X, -last.Y)
return nil
}
if depth == 32 {
return fmt.Errorf("PDF link curve tolerance cannot be met")
}
var work, left, right [4]basegeom.Point
copy(work[:], points)
for n := len(points); n > 0; n-- {
left[len(points)-n], right[n-1] = work[0], work[n-1]
for i := 0; i < n-1; i++ {
work[i] = basegeom.Lerp(work[i], work[i+1], .5)
}
}
if err := flatten(left[:len(points)], depth+1); err != nil {
return err
}
return flatten(right[:len(points)], depth+1)
}
for _, segment := range geometry {
if err := ctx.Err(); err != nil {
return nil, err
}
switch segment.Verb {
case basegeom.Move:
result.MoveTo(segment.End.X, -segment.End.Y)
current, start = segment.End, segment.End
case basegeom.ClosePath:
result.Close()
current = start
default:
curves := basegeom.Path{segment}
if segment.Verb == basegeom.Arc {
var err error
curves, err = basegeom.ArcCurves(current, segment, linkTolerance/2)
if err != nil {
return nil, err
}
if len(curves) > LinkEdgeLimit-count {
return nil, fmt.Errorf("PDF link region exceeds edge limit")
}
}
for _, curve := range curves {
var storage [4]basegeom.Point
points := append(storage[:0], current)
if curve.Verb == basegeom.Quad || curve.Verb == basegeom.Cubic {
points = append(points, curve.Control1)
}
if curve.Verb == basegeom.Cubic {
points = append(points, curve.Control2)
}
points = append(points, curve.End)
if err := flatten(points, 0); err != nil {
return nil, err
}
current = curve.End
}
}
}
return result, nil
}
// x 计算非水平边在指定纵坐标处的横坐标
// 入参: y 扫描线纵坐标
// 返回: float64 交点横坐标
func (e linkEdge) x(y float64) float64 {
t := (y - e.a.Y) / (e.b.Y - e.a.Y)
return e.a.X*(1-t) + e.b.X*t
}
// appendLinkQuad 收集梯形,尖端退化为三角形时细分为三个非退化四边形
// 入参: quads 已有四边形, quad 扫描带梯形
// 返回: [][4]pdfgo.Point 四边形集合
func appendLinkQuad(quads [][4]pdfgo.Point, quad [4]pdfgo.Point) [][4]pdfgo.Point {
epsilon := 1e-12
for _, point := range quad {
epsilon = max(epsilon, math.Abs(point.X)*1e-12, math.Abs(point.Y)*1e-12)
}
if quad[2].Y-quad[0].Y <= epsilon {
return quads
}
if quad[1].X-quad[0].X <= epsilon {
quad[0].X, quad[1].X = (quad[0].X+quad[1].X)/2, (quad[0].X+quad[1].X)/2
}
if quad[2].X-quad[3].X <= epsilon {
quad[3].X, quad[2].X = (quad[3].X+quad[2].X)/2, (quad[3].X+quad[2].X)/2
}
var triangle [3]pdfgo.Point
if quad[0] == quad[1] {
if quad[2] == quad[3] {
return quads
}
triangle = [3]pdfgo.Point{quad[0], quad[2], quad[3]}
} else if quad[2] == quad[3] {
triangle = [3]pdfgo.Point{quad[0], quad[1], quad[2]}
} else {
return appendValidLinkQuad(quads, quad)
}
return appendLinkTriangle(quads, triangle)
}
// appendLinkTriangle 连接三角形重心和边中点,生成覆盖相同区域的凸四边形
// 入参: quads 已有四边形, triangle 逆时针三角形
// 返回: [][4]pdfgo.Point 四边形集合
func appendLinkTriangle(quads [][4]pdfgo.Point, triangle [3]pdfgo.Point) [][4]pdfgo.Point {
center := pdfgo.Point{X: (triangle[0].X + triangle[1].X + triangle[2].X) / 3, Y: (triangle[0].Y + triangle[1].Y + triangle[2].Y) / 3}
for i, a := range triangle {
b, c := triangle[(i+1)%3], triangle[(i+2)%3]
quads = appendValidLinkQuad(quads, [4]pdfgo.Point{a, {X: (a.X + b.X) / 2, Y: (a.Y + b.Y) / 2}, center, {X: (a.X + c.X) / 2, Y: (a.Y + c.Y) / 2}})
}
return quads
}
// linkTurn 计算相邻边的转向,消除近共线乘积相减产生的舍入噪声
// 入参: a 前顶点, b 当前顶点, c 后顶点
// 返回: float64 有向面积的两倍,数值退化时为零
func linkTurn(a, b, c pdfgo.Point) float64 {
u, v := (b.X-a.X)*(c.Y-b.Y), (b.Y-a.Y)*(c.X-b.X)
cross := u - v
if math.Abs(cross) <= (math.Abs(u)+math.Abs(v))*1e-12 {
return 0
}
return cross
}
// appendValidLinkQuad 仅保留有限且非退化的逆时针凸四边形
// 入参: quads 已有四边形, quad 候选四边形
// 返回: [][4]pdfgo.Point 四边形集合
func appendValidLinkQuad(quads [][4]pdfgo.Point, quad [4]pdfgo.Point) [][4]pdfgo.Point {
for i, a := range quad {
cross := linkTurn(a, quad[(i+1)%4], quad[(i+2)%4])
if !finite(cross) || cross <= 0 {
return quads
}
}
return append(quads, quad)
}
// linkGeometry 转换画布坐标为基础几何路径,保留曲线和椭圆弧参数
// 入参: path 画布路径
// 返回: basegeom.Path 左上角坐标路径
func linkGeometry(path *canvas.Path) basegeom.Path {
if path == nil {
return nil
}
result := make(basegeom.Path, 0, path.Len())
scanner := path.Scanner()
for scanner.Scan() {
end := scanner.End()
segment := basegeom.Segment{End: basegeom.Point{X: end.X, Y: -end.Y}}
switch scanner.Cmd() {
case canvas.MoveToCmd:
segment.Verb = basegeom.Move
case canvas.LineToCmd:
segment.Verb = basegeom.Line
case canvas.QuadToCmd:
control := scanner.CP1()
segment.Verb, segment.Control1 = basegeom.Quad, basegeom.Point{X: control.X, Y: -control.Y}
case canvas.CubeToCmd:
c1, c2 := scanner.CP1(), scanner.CP2()
segment.Verb = basegeom.Cubic
segment.Control1, segment.Control2 = basegeom.Point{X: c1.X, Y: -c1.Y}, basegeom.Point{X: c2.X, Y: -c2.Y}
case canvas.ArcToCmd:
segment.Verb = basegeom.Arc
segment.RadiusX, segment.RadiusY, segment.Rotation, segment.Large, segment.Sweep = scanner.Arc()
segment.Rotation, segment.Sweep = -segment.Rotation, !segment.Sweep
case canvas.CloseCmd:
segment.Verb = basegeom.ClosePath
}
result = append(result, segment)
}
return result
}
+162
View File
@@ -0,0 +1,162 @@
// 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"
"encoding/binary"
"fmt"
"math"
"net/url"
"path"
"strings"
"github.com/xiaoqidun/pdfgo"
)
// MediaFormat 获取媒体文件扩展名,URL参数和片段不参与格式判断
// 入参: file PDF文件说明
// 返回: string 媒体格式,未声明扩展名时为空
func MediaFormat(file pdfgo.FileSpecification) string {
name := strings.ReplaceAll(file.Name, "\\", "/")
if file.FileSystem == "URL" {
if location, err := url.Parse(name); err == nil {
name = location.Path
}
}
return strings.TrimPrefix(path.Ext(name), ".")
}
// SoundWAV 封装PDF采样音频,保持采样率、声道及数值,超过RIFF容量时使用RF64
// 入参: ctx 取消上下文, sound 音频流及参数
// 返回: []byte WAVE文件, error 不可表示的参数或损坏的采样数据
func SoundWAV(ctx context.Context, sound pdfgo.Sound) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if sound.File != nil || sound.Compression != "" {
return nil, &pdfgo.UnsupportedError{Feature: "external or compressed sound samples"}
}
if sound.Stream == nil || sound.Channels <= 0 || math.IsNaN(sound.Rate) || math.IsInf(sound.Rate, 0) || sound.Rate <= 0 {
return nil, fmt.Errorf("invalid sound sample parameters")
}
if sound.Rate != math.Trunc(sound.Rate) || sound.Rate > math.MaxUint32 || sound.Channels > math.MaxUint16 || sound.Bits != 8 && sound.Bits != 16 && sound.Bits != 24 && sound.Bits != 32 {
return nil, &pdfgo.UnsupportedError{Feature: "sound parameters in WAVE"}
}
format := uint16(1)
if sound.Encoding == "muLaw" {
format = 7
} else if sound.Encoding == "ALaw" {
format = 6
}
if format != 1 && sound.Bits != 8 {
return nil, fmt.Errorf("invalid companded sound depth")
}
width := sound.Bits / 8
align := uint64(sound.Channels) * uint64(width)
rate := uint64(sound.Rate)
if align > math.MaxUint16 || rate*align > math.MaxUint32 {
return nil, &pdfgo.UnsupportedError{Feature: "sound block alignment in WAVE"}
}
data, err := sound.Stream.DecodeContext(ctx)
if err != nil {
return nil, err
}
if uint64(len(data))%align != 0 {
return nil, fmt.Errorf("incomplete sound sample frame")
}
if format == 1 {
for offset := 0; offset < len(data); offset += width {
if offset%(1<<16) == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
if sound.Encoding == "Raw" && width > 1 || sound.Encoding == "Signed" && width == 1 {
data[offset] ^= 0x80
}
for left, right := offset, offset+width-1; left < right; left, right = left+1, right-1 {
data[left], data[right] = data[right], data[left]
}
}
}
formatSize := uint32(16)
extra := uint64(0)
if format == 1 && (sound.Bits > 16 || sound.Channels > 2) {
format = 0xfffe
formatSize = 40
extra = 36
} else if format != 1 {
formatSize = 18
extra = 14
}
size := uint64(36) + extra + uint64(len(data)) + uint64(len(data)%2)
large := size > math.MaxUint32
var out bytes.Buffer
put16 := func(v uint16) { _ = binary.Write(&out, binary.LittleEndian, v) }
put32 := func(v uint32) { _ = binary.Write(&out, binary.LittleEndian, v) }
put64 := func(v uint64) { _ = binary.Write(&out, binary.LittleEndian, v) }
if large {
out.WriteString("RF64")
put32(math.MaxUint32)
out.WriteString("WAVEds64")
put32(28)
put64(size + 36)
put64(uint64(len(data)))
put64(uint64(len(data)) / align)
put32(0)
} else {
out.WriteString("RIFF")
put32(uint32(size))
out.WriteString("WAVE")
}
out.WriteString("fmt ")
put32(formatSize)
put16(format)
put16(uint16(sound.Channels))
put32(uint32(rate))
put32(uint32(rate * align))
put16(uint16(align))
put16(uint16(sound.Bits))
if format != 1 {
if format == 0xfffe {
put16(22)
put16(uint16(sound.Bits))
put32(0)
out.Write([]byte{1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71})
} else {
put16(0)
}
out.WriteString("fact")
put32(4)
if large {
put32(math.MaxUint32)
} else {
put32(uint32(uint64(len(data)) / align))
}
}
out.WriteString("data")
if large {
put32(math.MaxUint32)
} else {
put32(uint32(len(data)))
}
out.Write(data)
if len(data)%2 != 0 {
out.WriteByte(0)
}
return out.Bytes(), ctx.Err()
}
+26
View File
@@ -0,0 +1,26 @@
// 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 (
"math"
)
// finite 判断数值是否有限
// 入参: value 待检查数值
// 返回: bool 是否为有限数值
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
+40
View File
@@ -0,0 +1,40 @@
// 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 (
"github.com/xiaoqidun/pdfgo"
)
// PageMatrix 将裁剪框、用户单位及页面旋转转换为OFD毫米坐标
// 入参: page PDF页面
// 返回: pdfgo.Matrix 坐标变换, float64 页面宽度, float64 页面高度
func PageMatrix(page *pdfgo.Page) (pdfgo.Matrix, float64, float64) {
b := page.CropBox
scale := 25.4 / 72 * page.UserUnit
width, height := (b.XMax-b.XMin)*scale, (b.YMax-b.YMin)*scale
m := pdfgo.Matrix{scale, 0, 0, -scale, -b.XMin * scale, b.YMax * scale}
switch page.Rotation {
case 90:
m = pdfgo.Matrix{0, 1, -1, 0, height, 0}.Mul(m)
width, height = height, width
case 180:
m = pdfgo.Matrix{-1, 0, 0, -1, width, height}.Mul(m)
case 270:
m = pdfgo.Matrix{0, -1, 1, 0, 0, width}.Mul(m)
width, height = height, width
}
return m, width, height
}
+158
View File
@@ -0,0 +1,158 @@
// 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 (
"fmt"
"github.com/xiaoqidun/pdfgo"
)
// RenditionFloatingWindow 校验浮窗必需的像素尺寸,呈现偏好不写成视频固有尺寸
// 入参: reader PDF阅读器, screen 媒体屏幕参数
// 返回: bool 是否具备合法尺寸, error 引用错误
func RenditionFloatingWindow(reader *pdfgo.Reader, screen pdfgo.Dictionary) (bool, error) {
value, err := reader.Resolve(screen["BE"])
if err != nil {
return false, err
}
preferences, _ := value.(pdfgo.Dictionary)
value, err = reader.Resolve(preferences["F"])
if err != nil {
return false, err
}
window, ok := value.(pdfgo.Dictionary)
if !ok {
return false, nil
}
value, err = reader.Resolve(window["D"])
if err != nil {
return false, err
}
size, ok := value.(pdfgo.Array)
if !ok || len(size) != 2 {
return false, nil
}
for _, part := range size {
value, err = reader.Resolve(part)
if err != nil {
return false, err
}
number, ok := value.(pdfgo.Integer)
if !ok || number < 0 {
return false, nil
}
}
return true, nil
}
// RenditionCriteria 检查媒体环境条件,未知尽力处理字段不影响候选可用性
// 入参: reader PDF阅读器, rendition 媒体呈现或选择器
// 返回: bool 是否可用, error 条件字典或引用错误
func RenditionCriteria(reader *pdfgo.Reader, rendition *pdfgo.Rendition) (bool, error) {
for _, constraints := range []struct {
dict pdfgo.Dictionary
required bool
}{{rendition.MustHonor, true}, {rendition.BestEffort, false}} {
for key, object := range constraints.dict {
if key != "C" {
if constraints.required {
return false, nil
}
continue
}
value, err := reader.Resolve(object)
if err != nil {
return false, err
}
criteria, ok := value.(pdfgo.Dictionary)
if !ok {
return false, fmt.Errorf("invalid media criteria")
}
for name, object := range criteria {
value, err := reader.Resolve(object)
if err != nil {
return false, err
}
if name == "Type" && value == pdfgo.Name("MediaCriteria") {
continue
}
switch name {
case "Type", "A", "C", "O", "S", "R", "D", "Z", "V", "P", "L":
return false, nil
default:
if constraints.required {
return false, nil
}
}
}
}
}
return true, nil
}
// RenditionParameters 合并可等价表达的播放参数,不支持的偏好不阻断播放
// 入参: reader PDF阅读器, dict 参数字典, supported 字段及可表达值的检查
// 返回: pdfgo.Dictionary 合并后的值, bool 是否可用, error 字典或引用错误
func RenditionParameters(reader *pdfgo.Reader, dict pdfgo.Dictionary, supported map[pdfgo.Name]func(pdfgo.Object) bool) (pdfgo.Dictionary, bool, error) {
values := make(pdfgo.Dictionary)
for key := range dict {
if key != "Type" && key != "MH" && key != "BE" {
return nil, false, nil
}
}
value, err := reader.Resolve(dict["MH"])
if err != nil {
return nil, false, err
}
mandatory, _ := value.(pdfgo.Dictionary)
for _, key := range []pdfgo.Name{"BE", "MH"} {
value, err := reader.Resolve(dict[key])
if err != nil {
return nil, false, err
}
if value == nil {
continue
}
parameters, ok := value.(pdfgo.Dictionary)
if !ok {
return nil, false, fmt.Errorf("invalid media playback constraints")
}
for name, object := range parameters {
if _, overridden := mandatory[name]; key == "BE" && overridden {
continue
}
check := supported[name]
if check == nil {
if key == "MH" {
return nil, false, nil
}
continue
}
resolved, err := reader.Resolve(object)
if err != nil {
return nil, false, err
}
if !check(resolved) {
if key == "MH" {
return nil, false, nil
}
continue
}
values[name] = resolved
}
}
return values, true, nil
}
+96
View File
@@ -0,0 +1,96 @@
// 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 (
"github.com/xiaoqidun/pdfgo"
)
// ShadingColorants 复用原始浓度缓冲及原生通道映射,不跨并发采样共享
type ShadingColorants struct {
paint pdfgo.Paint
Process *pdfgo.ColorantProcess
tints []float64
mesh *pdfgo.MeshSampler
}
// ShadingColorantSpace 返回渐变的源色料定义,不使用备用颜色推断通道
// 入参: paint 渐变画刷
// 返回: *pdfgo.ColorantSpace 原始色料定义,普通渐变为nil
func ShadingColorantSpace(paint pdfgo.Paint) *pdfgo.ColorantSpace {
switch {
case paint.Axial != nil:
return paint.Axial.ColorantSpace()
case paint.Radial != nil:
return paint.Radial.ColorantSpace()
case paint.Function != nil:
return paint.Function.ColorantSpace()
case paint.Mesh != nil:
return paint.Mesh.ColorantSpace()
}
return nil
}
// PrepareShadingColorants 为当前组建立原生过程采样,备用色保留原路径
// 入参: paint 渐变画刷, space 组空间, softMask 是否用于软蒙版
// 返回: *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{}) {
return nil
}
s := &ShadingColorants{paint: paint, Process: Process, tints: make([]float64, len(colorants.Names))}
if paint.Mesh != nil {
s.mesh = paint.Mesh.NewSampler()
}
return s
}
// At 在归一化位置求值轴向或径向原始浓度
// 入参: position 归一化渐变参数
// 返回: [4]float64 原生过程分量, error 求值错误
func (s *ShadingColorants) At(position float64) ([4]float64, error) {
var err error
if s.paint.Axial != nil {
err = s.paint.Axial.ColorantValuesAt(position, s.tints)
} else {
err = s.paint.Radial.ColorantValuesAt(position, s.tints)
}
if err != nil {
return [4]float64{}, err
}
return s.Process.Convert(s.tints)
}
// PointAt 在函数定义域内求值原始浓度
// 入参: point 函数坐标
// 返回: [4]float64 原生过程分量, error 求值错误
func (s *ShadingColorants) PointAt(point pdfgo.Point) ([4]float64, error) {
if err := s.paint.Function.ColorantValuesAt(point, s.tints); err != nil {
return [4]float64{}, err
}
return s.Process.Convert(s.tints)
}
// MeshAt 插值网格原始浓度并复用函数结果
// 入参: patch 网格序号, u 横向参数, v 纵向参数
// 返回: [4]float64 原生过程分量, error 求值错误
func (s *ShadingColorants) MeshAt(patch int, u, v float64) ([4]float64, error) {
if err := s.mesh.ColorantValuesAt(patch, u, v, s.tints); err != nil {
return [4]float64{}, err
}
return s.Process.Convert(s.tints)
}
+270
View File
@@ -0,0 +1,270 @@
// 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 (
"context"
"errors"
"fmt"
"image"
"math"
basethreed "github.com/xiaoqidun/ofdgo/internal/base/threed"
"github.com/xiaoqidun/pdfgo"
)
// ThreeDRenderOptions 控制PDF三维视口的静态绘制,不改变注解激活状态
// 缓存与实例上限为0时采用basethreed.U3DRenderOptions的默认值
// ResolveTexture按原始URL提供外部纹理,空值不访问外部资源
// Nodes按名称替换PDF视图中的节点状态,可指定动作轨道与采样时间,不改变原始注解
// Time以秒指定模型动画的采样时刻,零值为初始时刻
type ThreeDRenderOptions struct {
Width, Height int
MaxMemoryBytes int64
MaxInstances int
ResolveTexture basethreed.U3DTextureResolver
Nodes []basethreed.U3DNodeState
Time float64
}
// RenderThreeD 按PDF相机、投影、背景与灯光绘制指定时刻的3DB区域,不执行脚本
// 返回图像不含3DB以外的注解背景,未支持的视图效果返回明确错误
// 每次从传入场景建立独立节点状态,不继承此前绘制的运行时状态
// 入参: ctx 取消上下文, model 解码后的U3D模型, source PDF三维参数, options 输出与资源限制
// 返回: *image.RGBA 不透明图像, error 参数、能力、预算或取消错误
func RenderThreeD(ctx context.Context, model *basethreed.U3DModel, source pdfgo.ThreeD, options ThreeDRenderOptions) (*image.RGBA, error) {
if ctx == nil {
return nil, fmt.Errorf("invalid PDF 3D render context")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if source.Format != "U3D" {
return nil, &pdfgo.UnsupportedError{Feature: "3D model format " + string(source.Format)}
}
if len(source.Measurements) != 0 {
return nil, &pdfgo.UnsupportedError{Feature: "3D view measurement rendering"}
}
presentation := source.Presentation
if presentation.Overlay != nil {
return nil, &pdfgo.UnsupportedError{Feature: "3D view overlay"}
}
if source.Background.RGB == nil {
return nil, &pdfgo.UnsupportedError{Feature: "3D background color space"}
}
box := source.ViewBox
width, height := box.XMax-box.XMin, box.YMax-box.YMin
if !finite(width) || !finite(height) || width <= 0 || height <= 0 {
return nil, fmt.Errorf("invalid PDF 3D viewport")
}
projection := pdfgo.ThreeDProjection{Subtype: "P", Clipping: "ANF", FieldOfView: 90, PerspectiveBinding: "W"}
if source.Projection != nil {
projection = *source.Projection
}
render := basethreed.U3DRenderOptions{Width: options.Width, Height: options.Height, MaxMemoryBytes: options.MaxMemoryBytes, MaxInstances: options.MaxInstances, Background: source.Background.RGB, ResolveTexture: options.ResolveTexture, Time: options.Time}
if err := threeDRenderMode(source, &render); err != nil {
return nil, err
}
if err := threeDNodeStates(ctx, presentation.Nodes, &render); err != nil {
return nil, err
}
if err := threeDSections(ctx, presentation.CrossSections, &render); err != nil {
return nil, err
}
r, err := basethreed.NewRenderer(ctx, model, render, options.Nodes)
if err != nil {
return nil, err
}
camera, view, err := threeDCamera(ctx, model, r, source.Camera)
if err != nil {
return nil, err
}
render.View = view
camera.ScaleX, camera.ScaleY, camera.Near = 1, 1, 1
render.Camera = &camera
passes, err := r.SelectView(render)
if err != nil {
return nil, err
}
if err := r.PrepareSections(render.Sections); err != nil {
return nil, err
}
scale, err := projection.ScaleFactor(source.ViewBox, source.AnnotationBox)
if err != nil {
return nil, err
}
camera.Perspective = projection.Subtype == "P"
camera.ScaleX = scale * float64(options.Width) / width
camera.ScaleY = scale * float64(options.Height) / height
switch projection.Clipping {
case "XNF":
camera.Near = 0
if projection.Near != nil {
camera.Near = *projection.Near
}
if !finite(camera.Near) || camera.Near < 0 || camera.Perspective && camera.Near == 0 {
return nil, fmt.Errorf("invalid PDF 3D near distance")
}
if projection.Far != nil {
camera.ClipFar = true
camera.Far = *projection.Far
if !finite(camera.Far) {
return nil, fmt.Errorf("invalid PDF 3D far distance")
}
if camera.Far < camera.Near {
passes, camera.Far = nil, 0
camera.ClipFar = false
}
}
case "ANF":
minimum, maximum, found, err := r.DepthRange()
if err != nil {
return nil, err
}
if !found {
passes = nil
}
camera.Near = minimum
if camera.Perspective {
if maximum <= 0 {
passes = nil
}
camera.Near = max(1e-300, maximum*1e-9)
if minimum > 0 {
camera.Near = max(1e-300, minimum/2)
}
}
camera.Far = math.Nextafter(maximum, math.Inf(1))
if !finite(camera.Far) || camera.Far <= camera.Near {
camera.Far = 0
}
default:
return nil, fmt.Errorf("invalid PDF 3D clipping style")
}
if _, err := r.SelectView(render); err != nil {
return nil, err
}
if source.Lighting.Subtype != "Artwork" && source.Lighting.Subtype != "" {
if err := r.Reserve(uint64(len(source.Lighting.Lights)), 56); err != nil {
return nil, err
}
render.Lighting = &basethreed.U3DLighting{DiffuseOffset: source.Lighting.Ambient, Lights: make([]basethreed.U3DDirectionalLight, len(source.Lighting.Lights))}
for i, light := range source.Lighting.Lights {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
direction := light.Direction
if light.CameraAttached {
direction = [3]float64{0, 0, 1}
}
render.Lighting.Lights[i] = basethreed.U3DDirectionalLight{Color: light.Color, Direction: direction, CameraRelative: light.CameraAttached}
}
}
return r.Render(render, passes)
}
// threeDCamera 解析PDF相机来源并保留模型视图的绘制轮次
// 入参: ctx 取消上下文, model 三维模型, renderer 场景实例, source 相机来源
// 返回: basethreed.U3DCamera 相机变换, string 视图节点, error 路径或变换错误
func threeDCamera(ctx context.Context, model *basethreed.U3DModel, renderer *basethreed.Renderer, source pdfgo.ThreeDCamera) (basethreed.U3DCamera, string, error) {
var camera basethreed.U3DCamera
selected := -1
for i, node := range model.Nodes {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return camera, "", err
}
}
if node.Kind == "View" {
selected = i
break
}
}
if source.Source == "M" {
m := source.Matrix
camera.ToWorld = [16]float64{m[0], m[1], m[2], 0, m[3], m[4], m[5], 0, m[6], m[7], m[8], 0, m[9], m[10], m[11], 1}
} else {
if source.Source != "" && source.Source != "U3D" || source.Source == "U3D" && len(source.Path) == 0 {
return camera, "", fmt.Errorf("invalid PDF 3D camera source")
}
if source.Source == "U3D" {
selected = -1
for i, node := range model.Nodes {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return camera, "", err
}
}
if node.Name == source.Path[len(source.Path)-1] {
selected = i
break
}
}
}
if selected < 0 || model.Nodes[selected].Kind != "View" {
return camera, "", fmt.Errorf("missing PDF 3D camera node")
}
var err error
camera.ToWorld, err = renderer.ViewCamera(selected, source.Path, source.Source == "U3D")
if errors.Is(err, basethreed.ErrAmbiguousCamera) {
return camera, "", &pdfgo.UnsupportedError{Feature: "ambiguous 3D camera instance"}
}
if errors.Is(err, basethreed.ErrCameraNotFound) {
return camera, "", fmt.Errorf("missing PDF 3D camera path")
}
if err != nil {
return camera, "", err
}
}
view := ""
if selected >= 0 {
view = model.Nodes[selected].Name
}
return camera, view, nil
}
// threeDNodeStates 将PDF节点参数转为场景覆盖,转换缓存计入绘制预算
// 入参: ctx 取消上下文, nodes 节点参数, options 绘制选项
// 返回: error 预算或取消错误
func threeDNodeStates(ctx context.Context, nodes []pdfgo.ThreeDNode, options *basethreed.U3DRenderOptions) error {
if len(nodes) == 0 {
return nil
}
limit := options.MaxMemoryBytes
if limit == 0 {
limit = basethreed.DefaultBytes
}
if limit <= 0 || uint64(len(nodes)) >= uint64(limit)/168 {
return fmt.Errorf("PDF 3D node memory exceeds limit")
}
options.MaxMemoryBytes = limit - int64(len(nodes))*168
options.Nodes = make([]basethreed.U3DNodeState, len(nodes))
for i, node := range nodes {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
state := basethreed.U3DNodeState{Name: node.Name, Opacity: node.Opacity, Visible: node.Visible}
if node.Matrix != nil {
m := node.Matrix
state.Matrix = &[16]float64{m[0], m[1], m[2], 0, m[3], m[4], m[5], 0, m[6], m[7], m[8], 0, m[9], m[10], m[11], 1}
}
options.Nodes[i] = state
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
// 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 (
basethreed "github.com/xiaoqidun/ofdgo/internal/base/threed"
"github.com/xiaoqidun/pdfgo"
)
// threeDRenderMode 将PDF标准绘制模式映射到通用几何绘制参数
// 入参: source 三维参数, options 绘制选项
// 返回: error 未支持的模式错误
func threeDRenderMode(source pdfgo.ThreeD, options *basethreed.U3DRenderOptions) error {
mode := source.Presentation.RenderMode
if mode == nil {
return nil
}
switch mode.Subtype {
case "Solid":
case "Transparent":
options.Opacity = &mode.Opacity
case "SolidWireframe", "TransparentWireframe":
options.Mode, options.AuxiliaryColor = basethreed.U3DRenderSolidWireframe, &mode.AuxiliaryColor
if mode.Subtype == "TransparentWireframe" {
options.Opacity = &mode.Opacity
}
case "Wireframe":
options.Mode, options.AuxiliaryColor = basethreed.U3DRenderWireframe, &mode.AuxiliaryColor
case "ShadedWireframe":
options.Mode = basethreed.U3DRenderShadedWireframe
case "HiddenWireframe":
options.Mode, options.AuxiliaryColor = basethreed.U3DRenderHiddenWireframe, &mode.AuxiliaryColor
case "Vertices":
options.Mode, options.AuxiliaryColor = basethreed.U3DRenderVertices, &mode.AuxiliaryColor
case "ShadedVertices":
options.Mode = basethreed.U3DRenderShadedVertices
case "BoundingBox":
options.Mode, options.AuxiliaryColor = basethreed.U3DRenderBoundingBox, &mode.AuxiliaryColor
case "TransparentBoundingBox", "TransparentBoundingBoxOutline":
options.Mode = basethreed.U3DRenderBoundingBoxFaces
options.FaceColor, options.Opacity = mode.FaceColor, &mode.Opacity
if mode.Subtype == "TransparentBoundingBoxOutline" {
options.Mode, options.AuxiliaryColor = basethreed.U3DRenderBoundingBoxOutline, &mode.AuxiliaryColor
}
case "Illustration":
options.Mode = basethreed.U3DRenderIllustration
options.AuxiliaryColor, options.FaceColor, options.CreaseAngle = &mode.AuxiliaryColor, mode.FaceColor, &mode.CreaseAngle
case "SolidOutline", "ShadedIllustration":
options.Mode = basethreed.U3DRenderSolidOutline
if mode.Subtype == "ShadedIllustration" {
options.Mode = basethreed.U3DRenderShadedIllustration
}
options.AuxiliaryColor, options.CreaseAngle = &mode.AuxiliaryColor, &mode.CreaseAngle
default:
return &pdfgo.UnsupportedError{Feature: "3D render mode " + string(mode.Subtype)}
}
if mode.AnnotationBackground && (mode.Subtype == "Illustration" || mode.Subtype == "TransparentBoundingBox" || mode.Subtype == "TransparentBoundingBoxOutline") {
options.FaceColor = source.AnnotationBackground
if source.AnnotationBackground == nil {
zero := 0.0
options.Opacity = &zero
}
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
// 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 (
"context"
"fmt"
"math"
basethreed "github.com/xiaoqidun/ofdgo/internal/base/threed"
"github.com/xiaoqidun/pdfgo"
)
// threeDSections 按XYZ顺序构造剖切方向,中心保持为世界坐标固定点
// 入参: ctx 取消上下文, sections PDF剖切面, options 绘制选项
// 返回: error 方向、预算或取消错误
func threeDSections(ctx context.Context, sections []pdfgo.ThreeDCrossSection, options *basethreed.U3DRenderOptions) error {
if len(sections) == 0 {
return nil
}
limit := options.MaxMemoryBytes
if limit == 0 {
limit = basethreed.DefaultBytes
}
if limit <= 0 || uint64(len(sections)) >= uint64(limit)/136 {
return fmt.Errorf("PDF 3D section memory exceeds limit")
}
options.MaxMemoryBytes = limit - int64(len(sections))*136
options.Sections = make([]basethreed.U3DCrossSection, len(sections))
for i, source := range sections {
if err := ctx.Err(); err != nil {
return err
}
if source.Axis < 0 || source.Axis > 2 {
return fmt.Errorf("invalid PDF 3D section axis")
}
section := basethreed.U3DCrossSection{Center: source.Center, Color: source.Color, Opacity: source.Opacity}
section.AxisU[(source.Axis+1)%3], section.AxisV[(source.Axis+2)%3] = 1, 1
for axis, angle := range source.Rotation {
if axis == source.Axis {
continue
}
if !finite(angle) {
return fmt.Errorf("invalid PDF 3D section rotation")
}
sin, cos := math.Sincos(math.Remainder(angle, 360) * math.Pi / 180)
a, b := (axis+1)%3, (axis+2)%3
for _, vector := range []*[3]float64{&section.AxisU, &section.AxisV} {
vector[a], vector[b] = cos*vector[a]-sin*vector[b], sin*vector[a]+cos*vector[b]
}
}
if source.IntersectionVisible {
color := source.IntersectionColor
section.Intersection = &color
}
options.Sections[i] = section
}
return nil
}