mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
1 parent
bec13d523f
commit
b3c4bbc844
45 files changed
+1885
-1453
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{§ion.AxisU, §ion.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
|
||||
}
|
||||
Reference in new issue
Block a user