diff --git a/go.mod b/go.mod
index 044b6e7..f554f82 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1
- github.com/xiaoqidun/pdfgo v0.0.0-20261007022230-c74c18b6029f
+ github.com/xiaoqidun/pdfgo v0.0.0-20261007170436-881165d037e5
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/text v0.42.0
diff --git a/go.sum b/go.sum
index d4d66ba..67c0438 100644
--- a/go.sum
+++ b/go.sum
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
-github.com/xiaoqidun/pdfgo v0.0.0-20261007022230-c74c18b6029f h1:8nEO48NLav1IVGlljGlrT1Fe8EnnSYJutIUJQe0+TpA=
-github.com/xiaoqidun/pdfgo v0.0.0-20261007022230-c74c18b6029f/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
+github.com/xiaoqidun/pdfgo v0.0.0-20261007170436-881165d037e5 h1:FZ24gdR14GDzZ0+pV0ZQUjxA71eSiEMAiHH2B5O8fPo=
+github.com/xiaoqidun/pdfgo v0.0.0-20261007170436-881165d037e5/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
diff --git a/ofdgo_compression_parallel.go b/ofdgo_compression_parallel.go
new file mode 100644
index 0000000..c0e40ea
--- /dev/null
+++ b/ofdgo_compression_parallel.go
@@ -0,0 +1,140 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "crypto/sha256"
+ "image"
+ "runtime"
+ "sync"
+
+ "github.com/xiaoqidun/pdfgo"
+)
+
+// outputImageBatchLimit 限制并行批次持有的原始编码,超限图片单独处理
+const outputImageBatchLimit = 16 << 20
+
+// outputImageJob 保存独立图片任务,不访问编辑文档或调用进度回调
+type outputImageJob struct {
+ key outputImageKey
+ data []byte
+ candidate []byte
+ err error
+}
+
+// outputImageOptions 统一资源压缩及像素需求,受保护图片只进行无损编码
+// 入参: key 资源路径
+// 返回: CompressionOptions 实际策略, image.Point 像素需求
+func (e *Editor) outputImageOptions(key string) (CompressionOptions, image.Point) {
+ options := e.output.options
+ if !e.output.images[key] {
+ options.Mode = CompressionLossless
+ }
+ demand := e.output.sizes[key]
+ if options.Mode != CompressionLossy {
+ options = CompressionOptions{Mode: options.Mode}
+ demand = image.Point{}
+ }
+ return options, demand
+}
+
+// prepareOutputImages 有界并行优化独立图片,读取、缓存及进度仍在调用线程处理
+// 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 本次写出的候选缓存
+// 返回: error 读取、进度或取消错误
+func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte) error {
+ workers := min(2, runtime.GOMAXPROCS(0))
+ if workers < 2 || len(keys) < 2 {
+ return nil
+ }
+ batch := make([]outputImageJob, 0, workers)
+ size := 0
+ run := func() error {
+ if err := e.runOutputImages(batch, results); err != nil {
+ return err
+ }
+ clear(batch)
+ batch, size = batch[:0], 0
+ return nil
+ }
+ for _, key := range keys {
+ if err := e.output.ctx.Err(); err != nil {
+ return err
+ }
+ if err := editorProgress(e.OnWriteProgress).report("compress", e.output.completed, 0); err != nil {
+ return err
+ }
+ data, err := load(key)
+ if err != nil {
+ return err
+ }
+ if len(data) == 0 {
+ continue
+ }
+ options, demand := e.outputImageOptions(key)
+ cacheKey := outputImageKey{hash: sha256.Sum256(data), options: options, size: demand}
+ if _, cached := results[cacheKey]; cached {
+ continue
+ }
+ duplicate := false
+ for _, job := range batch {
+ duplicate = duplicate || job.key == cacheKey
+ }
+ if duplicate {
+ continue
+ }
+ if len(batch) != 0 && len(data) > outputImageBatchLimit-size {
+ if err := run(); err != nil {
+ return err
+ }
+ }
+ batch = append(batch, outputImageJob{key: cacheKey, data: data})
+ size += len(data)
+ if len(batch) == workers || size >= outputImageBatchLimit {
+ if err := run(); err != nil {
+ return err
+ }
+ }
+ }
+ return run()
+}
+
+// runOutputImages 等待整批结束后记录更小候选,失败图片保留原始编码
+// 入参: jobs 独占图片任务, results 本次写出的候选缓存
+// 返回: error 取消错误
+func (e *Editor) runOutputImages(jobs []outputImageJob, results map[outputImageKey][]byte) error {
+ ctx := e.output.ctx
+ optimize := func(job *outputImageJob) {
+ job.candidate, job.err = pdfgo.OptimizeImageResource(ctx, job.data, job.key.options, job.key.size)
+ }
+ if len(jobs) == 1 {
+ optimize(&jobs[0])
+ } else {
+ var group sync.WaitGroup
+ for i := range jobs {
+ group.Go(func() { optimize(&jobs[i]) })
+ }
+ group.Wait()
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ for _, job := range jobs {
+ results[job.key] = nil
+ if job.err == nil && len(job.candidate) < len(job.data) {
+ results[job.key] = job.candidate
+ }
+ }
+ return nil
+}
diff --git a/ofdgo_compression_resources.go b/ofdgo_compression_resources.go
index a46bba0..57c2da2 100644
--- a/ofdgo_compression_resources.go
+++ b/ofdgo_compression_resources.go
@@ -67,7 +67,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
if data, ok := parts[name]; ok {
return data, nil
}
- return reader.readFile(name)
+ return reader.readFileView(name)
}
refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)}
var resources []string
@@ -169,7 +169,32 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
formats := make(map[string]string)
seen := make(map[string]map[[32]byte]string)
imageResults := make(map[outputImageKey][]byte)
- for _, key := range slices.Sorted(maps.Keys(actual)) {
+ ordered := slices.Sorted(maps.Keys(actual))
+ var imageKeys []string
+ for _, key := range ordered {
+ if !refs.files[key] && files[resourceFile{key, "MediaFile"}] && !files[resourceFile{key, "FontFile"}] {
+ imageKeys = append(imageKeys, key)
+ }
+ }
+ load := func(key string) ([]byte, error) {
+ if data, ok := parts[actual[key]]; ok && len(data) > outputOptimizationBufferLimit {
+ return nil, nil
+ }
+ if reader != nil && parts[actual[key]] == nil {
+ if file, ok := reader.packageFile(actual[key]); ok && file.UncompressedSize64 > outputOptimizationBufferLimit {
+ return nil, nil
+ }
+ }
+ data, err := read(actual[key])
+ if len(data) > outputOptimizationBufferLimit {
+ return nil, nil
+ }
+ return data, err
+ }
+ if err := e.prepareOutputImages(imageKeys, load, imageResults); err != nil {
+ return err
+ }
+ for _, key := range ordered {
if refs.files[key] || files[resourceFile{key, "FontFile"}] && files[resourceFile{key, "MediaFile"}] {
continue
}
@@ -196,15 +221,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea
continue
}
if role == "MediaFile" {
- options := e.output.options
- if !e.output.images[key] {
- options.Mode = CompressionLossless
- }
- demand := e.output.sizes[key]
- if options.Mode != CompressionLossy {
- options = CompressionOptions{Mode: options.Mode}
- demand = image.Point{}
- }
+ options, demand := e.outputImageOptions(key)
cacheKey := outputImageKey{hash: sha256.Sum256(data), options: options, size: demand}
candidate, cached := imageResults[cacheKey]
var err error
diff --git a/ofdgo_editor_action.go b/ofdgo_editor_action.go
new file mode 100644
index 0000000..c031f19
--- /dev/null
+++ b/ofdgo_editor_action.go
@@ -0,0 +1,134 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "fmt"
+ "reflect"
+ "slices"
+)
+
+// DocumentActions 获取文档打开动作的独立副本,不执行动作
+// 返回: []Action 动作列表
+func (e *Editor) DocumentActions() []Action {
+ if e.source == nil {
+ return nil
+ }
+ return cloneEditorData(e.source.document.Actions)
+}
+
+// SetDocumentActions 替换文档打开动作,空列表清除动作并记录撤销
+// 入参: actions 事件为DO的动作及已注册的资源引用
+// 返回: error 结构、编码或提交错误
+func (e *Editor) SetDocumentActions(actions []Action) error {
+ if err := validateLifecycleActions(actions, "DO"); err != nil {
+ return err
+ }
+ current := e.DocumentActions()
+ if reflect.DeepEqual(current, actions) || len(current) == 0 && len(actions) == 0 {
+ return nil
+ }
+ base := e.source
+ var err error
+ if base == nil {
+ base, err = e.importBase()
+ if err != nil {
+ return err
+ }
+ }
+ name := cleanPackagePath(base.reader.OFD.DocBody[base.reader.documentIndex].DocRoot)
+ data, err := base.reader.readFile(name)
+ if err != nil {
+ return err
+ }
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return err
+ }
+ encoded, err := editorActionsXML(actions)
+ if err != nil {
+ return err
+ }
+ patch := editorXMLPatch{root.close, root.close, encoded}
+ if node := root.child("Actions"); node != nil {
+ patch.start, patch.end = node.start, node.end
+ } else {
+ for _, child := range root.children {
+ if packageOFDNode(child, child.name.Local) && slices.Contains([]string{"VPreferences", "Bookmarks", "Annotations", "CustomTags", "Attachments", "Extensions"}, child.name.Local) {
+ patch.start, patch.end = child.start, child.start
+ break
+ }
+ }
+ }
+ return e.commitAnnotationParts(base, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{patch})})
+}
+
+// SetPageActions 替换页面打开动作,输入独立保存,空列表清除动作
+// 入参: index 页面索引, actions 事件为PO的动作及已注册的资源引用
+// 返回: error 页码或动作错误
+func (e *Editor) SetPageActions(index int, actions []Action) error {
+ if err := validateLifecycleActions(actions, "PO"); err != nil {
+ return err
+ }
+ page, err := e.page(index)
+ if err != nil {
+ return err
+ }
+ if reflect.DeepEqual(page.Actions, actions) || len(page.Actions) == 0 && len(actions) == 0 {
+ return nil
+ }
+ before, after := cloneEditorData(page.Actions), cloneEditorData(actions)
+ page.Actions = cloneEditorData(after)
+ if change := e.recordChange(); change != nil {
+ change.undo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(before) }
+ change.redo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(after) }
+ }
+ return nil
+}
+
+// validateLifecycleActions 校验文档或页面动作的事件类型和动作结构
+// 入参: actions 动作列表, event 允许的事件
+// 返回: error 事件或动作错误
+func validateLifecycleActions(actions []Action, event string) error {
+ for _, action := range actions {
+ if action.Event != event {
+ return fmt.Errorf("expected %s action event", event)
+ }
+ }
+ return validateObjectActions(actions)
+}
+
+// editorActionsXML 复用动作编码并补齐独立片段的命名空间,空列表不输出节点
+// 入参: actions 动作列表
+// 返回: []byte 动作片段, error 编码错误
+func editorActionsXML(actions []Action) ([]byte, error) {
+ if len(actions) == 0 {
+ return nil, nil
+ }
+ data, err := encodeOFDXML(func(x *ofdXML) {
+ x.root("Document", nil)
+ x.actions(actions)
+ x.end("Document")
+ })
+ if err != nil {
+ return nil, err
+ }
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return nil, err
+ }
+ node := root.child("Actions")
+ return editorXMLStandalone(data[node.start:node.end], node)
+}
diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go
index 49b6e21..18bac92 100644
--- a/ofdgo_editor_package.go
+++ b/ofdgo_editor_package.go
@@ -270,7 +270,7 @@ func (e *Editor) sourcePartsPrepared(progress editorProgress, generated map[stri
return parts, nil
}
-// sourcePageXML 仅改写已修改的对象、页面尺寸和新增图层,保留原页块层级
+// sourcePageXML 仅改写已修改的对象、页面尺寸、动作和新增图层,保留原页块层级
// 入参: index 页面索引, source 原页面
// 返回: []byte 页面XML, error 错误信息
func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, error) {
@@ -416,13 +416,28 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err
value := []byte("")
value = append(value, added...)
value = append(value, []byte("")...)
- patches = append(patches, editorXMLPatch{source.root.close, source.root.close, value})
+ position := source.root.close
+ if actions := source.root.child("Actions"); actions != nil {
+ position = actions.start
+ }
+ patches = append(patches, editorXMLPatch{position, position, value})
} else if content.open == content.end {
patches = append(patches, editorXMLContent(data, content, added))
} else {
patches = append(patches, editorXMLPatch{content.close, content.close, added})
}
}
+ if !reflect.DeepEqual(page.Actions, source.original.Actions) {
+ encoded, err := editorActionsXML(page.Actions)
+ if err != nil {
+ return nil, err
+ }
+ patch := editorXMLPatch{source.root.close, source.root.close, encoded}
+ if node := source.root.child("Actions"); node != nil {
+ patch.start, patch.end = node.start, node.end
+ }
+ patches = append(patches, patch)
+ }
return editorPatchXML(data, patches), nil
}
diff --git a/ofdgo_font_cmap.go b/ofdgo_font_cmap.go
index e69a16b..c6d3369 100644
--- a/ofdgo_font_cmap.go
+++ b/ofdgo_font_cmap.go
@@ -435,7 +435,7 @@ func addPackedGlyphMapping(mapping map[rune]uint16, numGlyphs uint16) {
}
}
-// buildCmapTable 构建cmap表 (Format 4)
+// buildCmapTable 构建格式4或12字符映射,省略缺字映射
// 入参: numGlyphs 字形数量, mapping 字符映射
// 返回: []byte cmap表数据
func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
@@ -444,15 +444,13 @@ func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
}
var segs []cmapSegment
if mapping == nil {
- end := uint16(0xFFFF)
if numGlyphs > 0 {
- end = numGlyphs - 1
+ segs = append(segs, cmapSegment{start: 0, end: numGlyphs - 1, delta: 0, offset: 0})
}
- segs = append(segs, cmapSegment{start: 0, end: end, delta: 0, offset: 0})
} else {
var codes []int
- for r := range mapping {
- if r <= 0xFFFF {
+ for r, gid := range mapping {
+ if r >= 0 && r <= 0xFFFF && gid != 0 {
codes = append(codes, int(r))
}
}
@@ -494,13 +492,13 @@ func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte {
for _, s := range segs {
endCounts = append(endCounts, s.end)
startCounts = append(startCounts, s.start)
- if mapping == nil {
- idDeltas = append(idDeltas, 0)
+ if s.start == 0xFFFF {
+ idDeltas = append(idDeltas, 1)
idRangeOffsets = append(idRangeOffsets, 0)
continue
}
- if s.start == 0xFFFF {
- idDeltas = append(idDeltas, 1)
+ if mapping == nil {
+ idDeltas = append(idDeltas, 0)
idRangeOffsets = append(idRangeOffsets, 0)
continue
}
@@ -549,7 +547,10 @@ func shouldBuildCmapFormat12(mapping map[rune]uint16) bool {
return false
}
var codes []int
- for r := range mapping {
+ for r, gid := range mapping {
+ if r < 0 || gid == 0 {
+ continue
+ }
if r >= 0xFFFF {
return true
}
@@ -588,8 +589,8 @@ func buildCmapTableFormat12(numGlyphs uint16, mapping map[rune]uint16) []byte {
codes = append(codes, i)
}
} else {
- for r := range mapping {
- if r >= 0 {
+ for r, gid := range mapping {
+ if r >= 0 && gid != 0 {
codes = append(codes, int(r))
}
}
@@ -617,7 +618,7 @@ func buildCmapTableFormat12(numGlyphs uint16, mapping map[rune]uint16) []byte {
if mapping != nil {
nextGID = mapping[nextRune]
}
- if nextRune != prevRune+1 || nextGID != prevGID+1 {
+ if nextRune != prevRune+1 || uint32(nextGID) != uint32(prevGID)+1 {
break
}
group.endChar = uint32(nextRune)
@@ -627,26 +628,22 @@ func buildCmapTableFormat12(numGlyphs uint16, mapping map[rune]uint16) []byte {
}
groups = append(groups, group)
}
- sub := new(bytes.Buffer)
- binary.Write(sub, binary.BigEndian, uint16(12))
- binary.Write(sub, binary.BigEndian, uint16(0))
- binary.Write(sub, binary.BigEndian, uint32(16+12*len(groups)))
- binary.Write(sub, binary.BigEndian, uint32(0))
- binary.Write(sub, binary.BigEndian, uint32(len(groups)))
- for _, group := range groups {
- binary.Write(sub, binary.BigEndian, group.startChar)
- binary.Write(sub, binary.BigEndian, group.endChar)
- binary.Write(sub, binary.BigEndian, group.startGID)
+ data := make([]byte, 36+12*len(groups))
+ binary.BigEndian.PutUint16(data[2:], 2)
+ binary.BigEndian.PutUint16(data[6:], 4)
+ binary.BigEndian.PutUint32(data[8:], 20)
+ binary.BigEndian.PutUint16(data[12:], 3)
+ binary.BigEndian.PutUint16(data[14:], 10)
+ binary.BigEndian.PutUint32(data[16:], 20)
+ sub := data[20:]
+ binary.BigEndian.PutUint16(sub, 12)
+ binary.BigEndian.PutUint32(sub[4:], uint32(len(sub)))
+ binary.BigEndian.PutUint32(sub[12:], uint32(len(groups)))
+ for index, group := range groups {
+ position := 16 + 12*index
+ binary.BigEndian.PutUint32(sub[position:], group.startChar)
+ binary.BigEndian.PutUint32(sub[position+4:], group.endChar)
+ binary.BigEndian.PutUint32(sub[position+8:], group.startGID)
}
- mainBuf := new(bytes.Buffer)
- binary.Write(mainBuf, binary.BigEndian, uint16(0))
- binary.Write(mainBuf, binary.BigEndian, uint16(2))
- binary.Write(mainBuf, binary.BigEndian, uint16(0))
- binary.Write(mainBuf, binary.BigEndian, uint16(4))
- binary.Write(mainBuf, binary.BigEndian, uint32(20))
- binary.Write(mainBuf, binary.BigEndian, uint16(3))
- binary.Write(mainBuf, binary.BigEndian, uint16(10))
- binary.Write(mainBuf, binary.BigEndian, uint32(20))
- mainBuf.Write(sub.Bytes())
- return mainBuf.Bytes()
+ return data
}
diff --git a/ofdgo_font_glyf.go b/ofdgo_font_glyf.go
index 632df4e..5b92e81 100644
--- a/ofdgo_font_glyf.go
+++ b/ofdgo_font_glyf.go
@@ -125,11 +125,12 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
return result, fmt.Errorf("truncated TrueType glyph %d", id)
}
n := int(int16(binary.BigEndian.Uint16(data)))
+ phantoms := f.phantomPoints(id, data)
var program []byte
if n >= 0 {
result, program, err = readTTSimple(data, n)
} else {
- result, program, err = f.composite(data, active)
+ result, program, err = f.composite(id, data, phantoms, active)
}
if err != nil {
return result, err
@@ -137,12 +138,10 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
if result.warning != nil {
f.recordGlyphWarning(id, result.warning)
}
- left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.font.Hmtx.LeftSideBearing(id))
- top := int32(int16(binary.BigEndian.Uint16(data[8:])))
if result.metrics != nil {
result.points = append(result.points, result.metrics[:]...)
} else {
- result.points = append(result.points, ttPoint{x: left * 64, ox: left * 64}, ttPoint{x: (left + int32(f.font.GlyphAdvance(id))) * 64, ox: (left + int32(f.font.GlyphAdvance(id))) * 64}, ttPoint{y: top * 64, oy: top * 64}, ttPoint{y: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64})
+ result.points = append(result.points, phantoms[:]...)
}
if len(program) == 0 {
return result, nil
@@ -156,6 +155,11 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
}
f.once.Do(func() { f.program, f.err = newTTInterpreter(f.font.Tables, f.font.UnitsPerEm()) })
if f.err != nil {
+ if ttRecoverableHint(f.err) {
+ result.warning = f.err
+ f.recordGlyphWarning(id, f.err)
+ return result, nil
+ }
return result, f.err
}
if f.program.graphics.inhibit {
@@ -164,7 +168,7 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
vm := f.program.clone()
vm.zones[1], vm.ends = append([]ttPoint(nil), result.points...), result.ends
if err := vm.run(program); err != nil {
- if errors.Is(err, errTTStackUnderflow) {
+ if ttRecoverableHint(err) {
result.warning = err
f.recordGlyphWarning(id, err)
return result, nil
@@ -175,6 +179,25 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error)
return result, nil
}
+// phantomPoints 按水平和垂直度量生成字形末尾的四个虚拟点
+// 入参: id 字形编号, data 完整字形头
+// 返回: [4]ttPoint 左右边距与上下原点
+func (f *sfntOutliner) phantomPoints(id uint16, data []byte) [4]ttPoint {
+ left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.font.Hmtx.LeftSideBearing(id))
+ top := int32(int16(binary.BigEndian.Uint16(data[8:])))
+ if f.font.Vmtx != nil {
+ top += int32(f.font.Vmtx.TopSideBearing(id))
+ }
+ return [4]ttPoint{{x: left * 64, ox: left * 64}, {x: (left + int32(f.font.GlyphAdvance(id))) * 64, ox: (left + int32(f.font.GlyphAdvance(id))) * 64}, {y: top * 64, oy: top * 64}, {y: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64, oy: (top - int32(f.font.GlyphVerticalAdvance(id))) * 64}}
+}
+
+// ttRecoverableHint 判断栈参数、指令引用或控制流错误是否允许保留执行前轮廓
+// 入参: err 指令错误
+// 返回: bool 是否可恢复
+func ttRecoverableHint(err error) bool {
+ return errors.Is(err, errTTStackUnderflow) || errors.Is(err, errTTInvalidReference) || errors.Is(err, errTTInvalidControlFlow)
+}
+
// trueTypeGlyphData 按loca索引读取字形,拒绝倒序或越界的数据范围
// 入参: tables 字体表, id 字形编号
// 返回: []byte 字形数据, error 索引错误
@@ -231,6 +254,9 @@ func (f *sfntOutliner) glyphWarning(id uint16) error {
// 返回: ttGlyph 字形轮廓, []byte 字节码, error 数据错误
func readTTSimple(data []byte, count int) (ttGlyph, []byte, error) {
var result ttGlyph
+ if count == 0 && len(data) == 10 {
+ return result, nil, nil
+ }
if len(data) < 12+2*count {
return result, nil, fmt.Errorf("truncated TrueType contour endpoints")
}
@@ -311,17 +337,23 @@ func readTTSimple(data []byte, count int) (ttGlyph, []byte, error) {
}
// composite 合成分量变换和点匹配,保留分量顺序及父级字节码
-// 入参: data 复合字形数据, active 递归访问集合
+// 入参: glyph 父字形编号, data 复合字形数据, phantoms 父字形虚拟点, active 递归访问集合
// 返回: ttGlyph 字形轮廓, []byte 字节码, error 数据错误
-func (f *sfntOutliner) composite(data []byte, active map[uint16]bool) (ttGlyph, []byte, error) {
+func (f *sfntOutliner) composite(glyph uint16, data []byte, phantoms [4]ttPoint, active map[uint16]bool) (ttGlyph, []byte, error) {
var result ttGlyph
pos := 10
+ total := -1
+ first := true
hasInstructions := false
for {
if len(data)-pos < 6 {
return result, nil, fmt.Errorf("truncated TrueType component")
}
flags, id := binary.BigEndian.Uint16(data[pos:]), binary.BigEndian.Uint16(data[pos+2:])
+ if first && flags&2 == 0 {
+ return result, nil, fmt.Errorf("first TrueType component requires coordinate offsets")
+ }
+ first = false
pos += 4
a, b := int32(data[pos]), int32(data[pos+1])
pos += 2
@@ -371,11 +403,13 @@ func (f *sfntOutliner) composite(data []byte, active map[uint16]bool) (ttGlyph,
result.warning = fmt.Errorf("TrueType component %d: %w", id, part.warning)
}
if flags&0x200 != 0 {
- result.metrics = new([4]ttPoint)
+ if result.metrics == nil {
+ result.metrics = new([4]ttPoint)
+ }
copy(result.metrics[:], part.points[len(part.points)-4:])
}
- part.points = part.points[:len(part.points)-4]
- for i := range part.points {
+ pointCount := len(part.points) - 4
+ for i := range pointCount {
p := &part.points[i]
x, y := float64(p.x), float64(p.y)
p.x = int32(math.Round(matrix[0]*x + matrix[2]*y))
@@ -383,17 +417,44 @@ func (f *sfntOutliner) composite(data []byte, active map[uint16]bool) (ttGlyph,
}
dx, dy := a*64, b*64
if flags&2 == 0 {
- if a < 0 || b < 0 || int64(a) >= int64(len(result.points)) || int64(b) >= int64(len(part.points)) {
+ if b < 0 || int64(b) >= int64(len(part.points)) {
return result, nil, fmt.Errorf("invalid TrueType component point matching")
}
- dx, dy = result.points[a].x-part.points[b].x, result.points[a].y-part.points[b].y
+ child := part.points[b]
+ if int64(b) >= int64(pointCount) {
+ x, y := float64(child.x), float64(child.y)
+ child.x = int32(math.Round(matrix[0]*x + matrix[2]*y))
+ child.y = int32(math.Round(matrix[1]*x + matrix[3]*y))
+ }
+ var parent ttPoint
+ if a >= 0 && int64(a) < int64(len(result.points)) {
+ parent = result.points[a]
+ } else {
+ if total < 0 {
+ var err error
+ total, err = trueTypePointCount(f.font.Tables, glyph, map[uint16]int{}, map[uint16]bool{})
+ if err != nil {
+ return result, nil, err
+ }
+ }
+ if a < int32(total) || a >= int32(total)+4 {
+ return result, nil, fmt.Errorf("invalid TrueType component point matching")
+ }
+ metrics := &phantoms
+ if result.metrics != nil {
+ metrics = result.metrics
+ }
+ parent = metrics[a-int32(total)]
+ }
+ dx, dy = parent.x-child.x, parent.y-child.y
} else if flags&0x1800 == 0x800 {
x, y := float64(dx), float64(dy)
dx, dy = int32(math.Round(matrix[0]*x+matrix[2]*y)), int32(math.Round(matrix[1]*x+matrix[3]*y))
}
- if flags&4 != 0 {
+ if flags&6 == 6 {
dx, dy = int32(math.Round(float64(dx)/64))*64, int32(math.Round(float64(dy)/64))*64
}
+ part.points = part.points[:pointCount]
if len(result.points)+len(part.points) > 65536 {
return result, nil, fmt.Errorf("excessive TrueType component points")
}
diff --git a/ofdgo_font_hint.go b/ofdgo_font_hint.go
index ab654d1..ace3506 100644
--- a/ofdgo_font_hint.go
+++ b/ofdgo_font_hint.go
@@ -25,6 +25,12 @@ import (
// errTTStackUnderflow 表示字形程序缺少必需的栈参数
var errTTStackUnderflow = errors.New("TrueType instruction stack underflow")
+// errTTInvalidReference 表示字形指令引用不存在的点或函数
+var errTTInvalidReference = errors.New("invalid TrueType instruction reference")
+
+// errTTInvalidControlFlow 表示条件分支未闭合或跳转超出字节码范围
+var errTTInvalidControlFlow = errors.New("invalid TrueType control flow")
+
// ttPoint 保存TrueType当前坐标、原始坐标及点状态,坐标使用26.6定点数
type ttPoint struct {
x, y, ox, oy int32
@@ -139,7 +145,7 @@ func (v *ttInterpreter) push(values ...int32) {
// 返回: *ttPoint 点
func (v *ttInterpreter) point(zone, index int32) *ttPoint {
if zone < 0 || zone > 1 || index < 0 || int64(index) >= int64(len(v.zones[zone])) {
- v.err = fmt.Errorf("invalid TrueType point %d in zone %d", index, zone)
+ v.err = fmt.Errorf("%w: point %d in zone %d", errTTInvalidReference, index, zone)
return &ttPoint{}
}
return &v.zones[zone][index]
@@ -247,6 +253,7 @@ func ttInstructionEnd(code []byte, position int) (int, error) {
// 入参: code 字节码, position 起始位置, alternate 是否停在ELSE
// 返回: int 后续指令位置, error 分支错误
func skipTTConditional(code []byte, position int, alternate bool) (int, error) {
+ start := position
depth := 0
for position < len(code) {
op := code[position]
@@ -268,7 +275,7 @@ func skipTTConditional(code []byte, position int, alternate bool) (int, error) {
return 0, err
}
}
- return 0, fmt.Errorf("unterminated TrueType conditional")
+ return 0, fmt.Errorf("%w: unterminated conditional at %d in %d bytes", errTTInvalidControlFlow, start, len(code))
}
// run 执行字体、预处理或字形程序
@@ -443,10 +450,13 @@ func (v *ttInterpreter) execute(code []byte, depth int) error {
if op == 0x2a {
count = v.pop()
}
- body, ok := v.functions[id]
- if !ok || count < 0 || count > 1000000 {
+ if count < 0 || count > 1000000 {
return fmt.Errorf("invalid TrueType function call %d", id)
}
+ body, ok := v.functions[id]
+ if !ok {
+ return fmt.Errorf("%w: function %d", errTTInvalidReference, id)
+ }
for n := int32(0); n < count; n++ {
if err = v.execute(body, depth+1); err != nil {
return fmt.Errorf("TrueType function %d: %w", id, err)
@@ -749,7 +759,7 @@ func (v *ttInterpreter) execute(code []byte, depth int) error {
return fmt.Errorf("TrueType instruction 0x%02x at %d: %w", op, start, v.err)
}
if pc < 0 || pc > len(code) {
- return fmt.Errorf("invalid TrueType jump target")
+ return fmt.Errorf("%w: jump target %d at %d in %d bytes", errTTInvalidControlFlow, pc, start, len(code))
}
}
return nil
diff --git a/ofdgo_font_loca.go b/ofdgo_font_loca.go
new file mode 100644
index 0000000..2bc5605
--- /dev/null
+++ b/ofdgo_font_loca.go
@@ -0,0 +1,299 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "slices"
+)
+
+// errTTInvalidLocations 表示字形索引不满足递增偏移要求
+var errTTInvalidLocations = errors.New("invalid TrueType glyph locations")
+
+// normalizeTrueTypeLocations 验证物理字形边界后按原编号重排,不改变空字形和分量引用
+// 入参: tables 独占字体表目录,表内容只读
+// 返回: bool 是否重建索引, error 无法可靠恢复的索引或轮廓错误
+func normalizeTrueTypeLocations(tables map[string][]byte) (bool, error) {
+ if _, ok := tables["glyf"]; !ok {
+ return false, nil
+ }
+ head, maxp, loca, glyf := tables["head"], tables["maxp"], tables["loca"], tables["glyf"]
+ if len(head) < 54 || len(maxp) < 6 {
+ return false, fmt.Errorf("%w: missing header", errTTInvalidLocations)
+ }
+ count := int(binary.BigEndian.Uint16(maxp[4:]))
+ width := 2
+ switch binary.BigEndian.Uint16(head[50:]) {
+ case 0:
+ case 1:
+ width = 4
+ default:
+ return false, fmt.Errorf("%w: format", errTTInvalidLocations)
+ }
+ if count == 0 || len(loca) < (count+1)*width {
+ return false, fmt.Errorf("%w: truncated index", errTTInvalidLocations)
+ }
+ location := func(i int) uint64 {
+ if width == 2 {
+ return uint64(binary.BigEndian.Uint16(loca[i*2:])) * 2
+ }
+ return uint64(binary.BigEndian.Uint32(loca[i*4:]))
+ }
+ ordered, previous := true, uint64(0)
+ for i := range count + 1 {
+ offset := location(i)
+ if offset > uint64(len(glyf)) {
+ return false, fmt.Errorf("%w: offset %d outside table", errTTInvalidLocations, i)
+ }
+ ordered = ordered && previous <= offset
+ previous = offset
+ }
+ if ordered {
+ return false, nil
+ }
+ offsets := make([]int, count+1)
+ for i := range offsets {
+ offsets[i] = int(location(i))
+ }
+ physical := slices.Clone(offsets)
+ physical = append(physical, len(glyf))
+ slices.Sort(physical)
+ physical = slices.Compact(physical)
+ bodies := make(map[int][]byte, count)
+ components := make([][]uint16, count)
+ var total uint64
+ for id := range count {
+ start := offsets[id]
+ if start == offsets[id+1] {
+ continue
+ }
+ if _, duplicate := bodies[start]; duplicate {
+ return false, fmt.Errorf("%w: ambiguous shared offset %d", errTTInvalidLocations, start)
+ }
+ index, _ := slices.BinarySearch(physical, start)
+ if index+1 >= len(physical) {
+ return false, fmt.Errorf("%w: missing glyph %d body", errTTInvalidLocations, id)
+ }
+ data := glyf[start:physical[index+1]]
+ size, refs, err := trueTypeGlyphSize(data, count)
+ if err != nil {
+ return false, fmt.Errorf("%w: glyph %d: %w", errTTInvalidLocations, id, err)
+ }
+ if len(data)-size > 3 || len(bytes.Trim(data[size:], "\x00")) != 0 {
+ return false, fmt.Errorf("%w: ambiguous glyph %d padding", errTTInvalidLocations, id)
+ }
+ bodies[start], components[id] = data[:size], refs
+ total += (uint64(size) + 3) &^ 3
+ if total > uint64(^uint(0)>>1) || total > uint64(^uint32(0)) {
+ return false, fmt.Errorf("%w: rebuilt table exceeds capacity", errTTInvalidLocations)
+ }
+ }
+ state := make([]uint8, count)
+ var visit func(int) error
+ visit = func(id int) error {
+ if state[id] == 1 {
+ return fmt.Errorf("%w: cyclic component %d", errTTInvalidLocations, id)
+ }
+ if state[id] == 2 {
+ return nil
+ }
+ state[id] = 1
+ for _, ref := range components[id] {
+ if err := visit(int(ref)); err != nil {
+ return err
+ }
+ }
+ state[id] = 2
+ return nil
+ }
+ for id := range count {
+ if err := visit(id); err != nil {
+ return false, err
+ }
+ }
+ result, locations := make([]byte, 0, total), make([]byte, (count+1)*4)
+ for id := range count {
+ binary.BigEndian.PutUint32(locations[id*4:], uint32(len(result)))
+ if offsets[id] != offsets[id+1] {
+ result = append(result, bodies[offsets[id]]...)
+ result = append(result, make([]byte, (-len(result))&3)...)
+ }
+ }
+ binary.BigEndian.PutUint32(locations[count*4:], uint32(len(result)))
+ header := bytes.Clone(head)
+ binary.BigEndian.PutUint16(header[50:], 1)
+ tables["head"], tables["loca"], tables["glyf"] = header, locations, result
+ return true, nil
+}
+
+// trueTypeGlyphSize 检查单个字形的完整记录长度及复合引用,不执行指令或分配坐标
+// 入参: data 字形及尾部对齐数据, count 字形总数
+// 返回: int 记录长度, []uint16 分量编号, error 结构错误
+func trueTypeGlyphSize(data []byte, count int) (int, []uint16, error) {
+ if len(data) < 10 {
+ return 0, nil, fmt.Errorf("truncated glyph header")
+ }
+ contours := int(int16(binary.BigEndian.Uint16(data)))
+ pos := 10
+ if contours >= 0 {
+ if contours == 0 && len(data) == 10 {
+ return 10, nil, nil
+ }
+ if contours > (len(data)-12)/2 || len(data) < 12 {
+ return 0, nil, fmt.Errorf("truncated contour endpoints")
+ }
+ points := 0
+ for range contours {
+ end := int(binary.BigEndian.Uint16(data[pos:])) + 1
+ if end <= points {
+ return 0, nil, fmt.Errorf("unordered contour endpoints")
+ }
+ points, pos = end, pos+2
+ }
+ instructions := int(binary.BigEndian.Uint16(data[pos:]))
+ pos += 2
+ if instructions > len(data)-pos {
+ return 0, nil, fmt.Errorf("truncated glyph instructions")
+ }
+ pos += instructions
+ coordinates := 0
+ for seen := 0; seen < points; {
+ if pos >= len(data) {
+ return 0, nil, fmt.Errorf("truncated glyph flags")
+ }
+ flag, repeat := data[pos], 1
+ pos++
+ if flag&0x80 != 0 {
+ return 0, nil, fmt.Errorf("reserved glyph flag")
+ }
+ if flag&8 != 0 {
+ if pos >= len(data) {
+ return 0, nil, fmt.Errorf("truncated glyph repeat")
+ }
+ repeat += int(data[pos])
+ pos++
+ }
+ if repeat > points-seen {
+ return 0, nil, fmt.Errorf("excessive glyph repeat")
+ }
+ for axis := range 2 {
+ if flag&byte(2< len(data)-pos {
+ return 0, nil, fmt.Errorf("truncated glyph coordinates")
+ }
+ return pos + coordinates, nil, nil
+ }
+ var refs []uint16
+ instructions := false
+ for {
+ if len(data)-pos < 6 {
+ return 0, nil, fmt.Errorf("truncated glyph component")
+ }
+ flags, id := binary.BigEndian.Uint16(data[pos:]), binary.BigEndian.Uint16(data[pos+2:])
+ if int(id) >= count || flags&0xe010 != 0 {
+ return 0, nil, fmt.Errorf("invalid glyph component")
+ }
+ refs = append(refs, id)
+ pos += 6
+ if flags&1 != 0 {
+ pos += 2
+ }
+ switch flags & 0xc8 {
+ case 0:
+ case 8:
+ pos += 2
+ case 0x40:
+ pos += 4
+ case 0x80:
+ pos += 8
+ default:
+ return 0, nil, fmt.Errorf("conflicting component transforms")
+ }
+ if pos > len(data) {
+ return 0, nil, fmt.Errorf("truncated component arguments")
+ }
+ instructions = instructions || flags&0x100 != 0
+ if flags&0x20 == 0 {
+ break
+ }
+ }
+ if instructions {
+ if len(data)-pos < 2 {
+ return 0, nil, fmt.Errorf("truncated component instructions")
+ }
+ length := int(binary.BigEndian.Uint16(data[pos:]))
+ pos += 2
+ if length > len(data)-pos {
+ return 0, nil, fmt.Errorf("truncated component instructions")
+ }
+ pos += length
+ }
+ return pos, refs, nil
+}
+
+// trueTypePointCount 按完整轮廓计算虚拟点编号,局部复用分量计数,不执行字形指令
+// 入参: tables 字体表, id 字形编号, counts 本次计数缓存, active 递归访问集合
+// 返回: int 不含虚拟点的轮廓点数, error 结构或循环引用错误
+func trueTypePointCount(tables map[string][]byte, id uint16, counts map[uint16]int, active map[uint16]bool) (int, error) {
+ if count, ok := counts[id]; ok {
+ return count, nil
+ }
+ maxp := tables["maxp"]
+ if len(maxp) < 6 || id >= binary.BigEndian.Uint16(maxp[4:]) || active[id] {
+ return 0, fmt.Errorf("invalid TrueType component reference %d", id)
+ }
+ active[id] = true
+ defer delete(active, id)
+ data, err := trueTypeGlyphData(tables, id)
+ if err != nil {
+ return 0, err
+ }
+ if len(data) == 0 {
+ counts[id] = 0
+ return 0, nil
+ }
+ _, refs, err := trueTypeGlyphSize(data, int(binary.BigEndian.Uint16(maxp[4:])))
+ if err != nil {
+ return 0, err
+ }
+ contours := int(int16(binary.BigEndian.Uint16(data)))
+ count := 0
+ if contours > 0 {
+ count = int(binary.BigEndian.Uint16(data[10+2*(contours-1):])) + 1
+ } else {
+ for _, ref := range refs {
+ points, err := trueTypePointCount(tables, ref, counts, active)
+ if err != nil {
+ return 0, err
+ }
+ count += points
+ if count > 65536 {
+ return 0, fmt.Errorf("excessive TrueType component points")
+ }
+ }
+ }
+ counts[id] = count
+ return count, nil
+}
diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go
index c6ba3ee..0136d43 100644
--- a/ofdgo_pdf.go
+++ b/ofdgo_pdf.go
@@ -78,6 +78,7 @@ type pdfImporter struct {
report PDFImportReport
matrix pdfgo.Matrix
page int
+ pageActionPage int
fontIDs map[*pdfgo.Font]string
fonts map[*pdfgo.Font]*pdfImportedFont
fontWarnings map[string]bool
@@ -91,6 +92,7 @@ type pdfImporter struct {
pages map[pdfgo.Reference]*pdfgo.Page
pageIDs map[pdfgo.Reference]string
pageIndexes map[pdfgo.Reference]int
+ destinationBoxes map[pdfgo.Reference]Box
imageIDs map[pdfImageKey][]pdfImageResource
imageContents *renderCache[pdfImageContentKey, pdfImageContent]
attachmentIDs map[pdfAttachmentKey]string
@@ -209,6 +211,13 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
if err != nil {
return nil, PDFImportReport{}, err
}
+ if len(editor.pages) != 0 {
+ importer.page = -1
+ if err := importer.documentOpenActions(ctx); err != nil {
+ return nil, PDFImportReport{}, fmt.Errorf("import PDF document actions: %w", err)
+ }
+ importer.page = 0
+ }
if options.OnProgress != nil {
if err := options.OnProgress("convert", 0, len(editor.pages)); err != nil {
return nil, PDFImportReport{}, err
@@ -230,6 +239,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject)
importer.clipPaths = nil
importer.annotationData = nil
+ if err := importer.pageOpenActions(ctx, page); err != nil {
+ return fmt.Errorf("import PDF page %d actions: %w", index+1, err)
+ }
if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil {
return fmt.Errorf("import PDF page %d: %w", index+1, err)
}
@@ -478,24 +490,31 @@ func (p *pdfImporter) pathDataContext(ctx context.Context, path pdfgo.Path, orig
return string(data), nil
}
-// clips 复用只读裁剪几何,以区域CTM保留对象位移及路径交集
+// clips 省略覆盖对象边界的矩形裁剪,复用其余几何并保留位移及路径交集
// 入参: paths 裁剪路径, origin 对象边界
// 返回: *Clips OFD裁剪区域,无裁剪时为空, error 未解析的裁剪字形
func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) {
if len(paths) == 0 {
return nil, nil
}
- flag := false
- clips := &Clips{TransFlag: &flag, Clip: make([]Clip, len(paths))}
- areas := make([]ClipArea, len(paths))
+ var clips *Clips
+ var areas []ClipArea
translation := ""
- if origin.X != 0 || origin.Y != 0 {
- translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y)
- }
- for index, path := range paths {
+ for _, path := range paths {
if err := p.ctx.Err(); err != nil {
return nil, err
}
+ if pdfClipContainsBoundary(path, p.matrix, origin) {
+ continue
+ }
+ if clips == nil {
+ clips = &Clips{TransFlag: new(false), Clip: make([]Clip, 0, len(paths))}
+ areas = make([]ClipArea, len(paths))
+ if origin.X != 0 || origin.Y != 0 {
+ translation = pdfNumbers(1, 0, 0, 1, -origin.X, -origin.Y)
+ }
+ }
+ index := len(clips.Clip)
area := &areas[index]
area.CTM = translation
if len(path.Segments) != 0 {
@@ -530,7 +549,7 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) {
fill, stroke := true, false
area.Path = []PathObject{{Boundary: "0 0 1 1", AbbreviatedData: "M 0 0 C", Fill: &fill, Stroke: &stroke}}
}
- clips.Clip[index].Area = areas[index : index+1 : index+1]
+ clips.Clip = append(clips.Clip, Clip{Area: areas[index : index+1 : index+1]})
}
return clips, nil
}
diff --git a/ofdgo_pdf_3d.go b/ofdgo_pdf_3d.go
index bbb4fb7..64b035e 100644
--- a/ofdgo_pdf_3d.go
+++ b/ofdgo_pdf_3d.go
@@ -40,7 +40,7 @@ type PDFThreeDRenderOptions struct {
// 入参: ctx 取消上下文, page PDF页面, annotation 三维注解
// 返回: error 外观、资源或取消错误
func (p *pdfImporter) threeDAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error {
- source, err := p.reader.ReadThreeD(annotation)
+ source, err := p.reader.ReadThreeDContext(ctx, annotation)
if err != nil {
return p.interactiveAppearanceFallback(ctx, page, annotation, "metadata", err)
}
@@ -78,7 +78,7 @@ func (p *pdfImporter) threeDAnnotation(ctx context.Context, page *pdfgo.Page, an
return err
}
}
- paint, err = p.threeDPainter(ctx, source, annotation, data)
+ paint, err = p.threeDPainter(ctx, page, source, annotation, data)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
@@ -101,9 +101,9 @@ func (p *pdfImporter) threeDAnnotation(ctx context.Context, page *pdfgo.Page, an
}
// threeDPainter 准备三维静态图像,复用库层压缩,失败时不提交注解对象
-// 入参: ctx 取消上下文, source 三维参数, annotation 注解, data 原始模型
+// 入参: ctx 取消上下文, page PDF页面, source 三维参数, annotation 注解, data 原始模型
// 返回: func(*pdfImporter) error 绘制回调, error 模型、绘制或资源错误
-func (p *pdfImporter) threeDPainter(ctx context.Context, source pdfgo.ThreeD, annotation pdfgo.Annotation, data []byte) (func(*pdfImporter) error, error) {
+func (p *pdfImporter) threeDPainter(ctx context.Context, page *pdfgo.Page, source pdfgo.ThreeD, annotation pdfgo.Annotation, data []byte) (func(*pdfImporter) error, error) {
box := source.ViewBox
width, height := box.XMax-box.XMin, box.YMax-box.YMin
x, y := annotation.Rect.XMin/2+annotation.Rect.XMax/2, annotation.Rect.YMin/2+annotation.Rect.YMax/2
@@ -123,6 +123,17 @@ func (p *pdfImporter) threeDPainter(ctx context.Context, source pdfgo.ThreeD, an
nodes = append(nodes, pdfCompositeNode{path: &pdfgo.PathMark{Path: path, Fill: true, Style: pdfgo.Style{Fill: pdfgo.Paint{RGB: *source.Background.RGB, Alpha: 1}}}})
}
if width > 0 && height > 0 {
+ var overlay []pdfCompositeNode
+ if source.Presentation.Overlay != nil {
+ var err error
+ overlay, err = p.collectCompositeNodes(func(visitor pdfgo.Visitor) error {
+ return p.reader.WalkThreeDOverlay(ctx, page, source, visitor)
+ })
+ if err != nil {
+ return nil, err
+ }
+ source.Presentation.Overlay = nil
+ }
if source.Format != "U3D" {
return nil, &pdfgo.UnsupportedError{Feature: "3D model format " + string(source.Format)}
}
@@ -145,6 +156,7 @@ func (p *pdfImporter) threeDPainter(ctx context.Context, source pdfgo.ThreeD, an
return nil, err
}
nodes = append(nodes, pdfCompositeNode{image: &pdfgo.ImageMark{Image: resource, Matrix: pdfgo.Matrix{width, 0, 0, height, x + box.XMin, y + box.YMin}, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}})
+ nodes = append(nodes, overlay...)
}
return func(stamp *pdfImporter) error {
if err := ctx.Err(); err != nil {
@@ -169,9 +181,12 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD,
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.RenderMode != "" && presentation.RenderMode != "Solid" || presentation.Overlay != nil {
- return nil, &pdfgo.UnsupportedError{Feature: "3D view render mode or overlay"}
+ 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"}
@@ -186,6 +201,9 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD,
projection = *source.Projection
}
render := U3DRenderOptions{Width: options.Width, Height: options.Height, MaxMemoryBytes: options.MaxMemoryBytes, MaxInstances: options.MaxInstances, Background: source.Background.RGB}
+ if err := pdfThreeDRenderMode(source, &render); err != nil {
+ return nil, err
+ }
if err := pdfThreeDNodeStates(ctx, presentation.Nodes, &render); err != nil {
return nil, err
}
@@ -210,35 +228,11 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD,
if err := r.prepareSections(render.Sections); err != nil {
return nil, err
}
- scale := 0.0
- switch projection.Subtype {
- case "P":
- if !finite(projection.FieldOfView) || projection.FieldOfView <= 0 || projection.FieldOfView >= 180 {
- return nil, &pdfgo.UnsupportedError{Feature: "degenerate 3D perspective field of view"}
- }
- diameter := projection.PerspectiveDiameter
- if projection.PerspectiveBinding != "" {
- diameter, err = pdfThreeDBinding(projection.PerspectiveBinding, width, height)
- if err != nil {
- return nil, err
- }
- }
- if !finite(diameter) || diameter <= 0 {
- return nil, fmt.Errorf("invalid PDF 3D perspective diameter")
- }
- scale = diameter / (2 * math.Tan(projection.FieldOfView*math.Pi/360))
- camera.Perspective = true
- case "O":
- width := source.AnnotationBox.XMax - source.AnnotationBox.XMin
- height := source.AnnotationBox.YMax - source.AnnotationBox.YMin
- binding, err := pdfThreeDBinding(projection.OrthographicBinding, width, height)
- if err != nil {
- return nil, err
- }
- scale = projection.OrthographicScale * binding
- default:
- return nil, fmt.Errorf("invalid PDF 3D projection subtype")
+ 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 {
@@ -310,29 +304,6 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD,
return r.render(render, passes)
}
-// pdfThreeDBinding 计算目标区域的标准绑定倍率,不按模型包围盒重新适配
-// 入参: binding 绑定方式, width 区域宽度, height 区域高度
-// 返回: float64 倍率, error 方式或尺寸错误
-func pdfThreeDBinding(binding pdfgo.Name, width, height float64) (float64, error) {
- if binding == "Absolute" {
- return 1, nil
- }
- if !finite(width) || !finite(height) || width <= 0 || height <= 0 {
- return 0, fmt.Errorf("invalid PDF 3D binding dimensions")
- }
- switch binding {
- case "W":
- return width, nil
- case "H":
- return height, nil
- case "Min":
- return min(width, height), nil
- case "Max":
- return max(width, height), nil
- }
- return 0, fmt.Errorf("invalid PDF 3D binding")
-}
-
// pdfCamera 解析PDF相机来源并保留模型视图的绘制轮次
// 入参: source 相机来源
// 返回: U3DCamera 相机变换, string 视图节点, error 路径或变换错误
@@ -414,7 +385,7 @@ func (r *u3dRender) pdfCamera(source pdfgo.ThreeDCamera) (U3DCamera, string, err
return camera, view, nil
}
-// depthRange 计算可见模型面的相机深度范围,不为自动裁剪复制顶点
+// depthRange 计算可见模型几何的相机深度范围,不为自动裁剪复制顶点
// 返回: float64 最近深度, float64 最远深度, bool 是否含几何, error 几何或取消错误
func (r *u3dRender) depthRange() (float64, float64, bool, error) {
minimum, maximum := math.Inf(1), math.Inf(-1)
@@ -431,13 +402,14 @@ func (r *u3dRender) depthRange() (float64, float64, bool, error) {
}
mesh := &r.model.Meshes[index]
matrix := r.toCamera.multiply(instance.world)
- for j, face := range mesh.Faces {
+ for j := range len(mesh.Faces) + len(mesh.Lines) {
+ _, corners := mesh.primitive(j)
if j%256 == 0 {
if err := r.ctx.Err(); err != nil {
return 0, 0, false, err
}
}
- for _, corner := range face.Corners {
+ for _, corner := range corners {
if uint64(corner.Position) >= uint64(len(mesh.Positions)) {
return 0, 0, false, fmt.Errorf("invalid U3D position index")
}
diff --git a/ofdgo_pdf_3d_markup.go b/ofdgo_pdf_3d_markup.go
new file mode 100644
index 0000000..1b2a971
--- /dev/null
+++ b/ofdgo_pdf_3d_markup.go
@@ -0,0 +1,82 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "context"
+ "maps"
+
+ "github.com/xiaoqidun/pdfgo"
+)
+
+// pdfThreeDMarkupState 复用同页批注的宿主解析及模型校验,不保留模型解码缓冲
+type pdfThreeDMarkupState struct {
+ sources map[pdfgo.Reference]pdfgo.ThreeD
+ sums map[*pdfgo.Stream][16]byte
+}
+
+// threeDMarkupAnnotation 应用默认三维视图的批注可见性,其他视图批注保留为隐藏注解
+// 入参: ctx 取消上下文, annotation 批注, markup 关联, state 同页缓存, strict 严格检查开关
+// 返回: pdfgo.Annotation 静态批注, error 关联、校验或取消错误
+func (p *pdfImporter) threeDMarkupAnnotation(ctx context.Context, annotation pdfgo.Annotation, markup *pdfgo.ThreeDMarkup, state *pdfThreeDMarkupState, strict bool) (pdfgo.Annotation, error) {
+ if markup == nil {
+ return annotation, nil
+ }
+ if strict {
+ return annotation, &pdfgo.UnsupportedError{Feature: "3D markup view switching conversion"}
+ }
+ source, ok := state.sources[markup.Annotation.Reference]
+ if !ok || markup.Annotation.Reference == (pdfgo.Reference{}) {
+ var err error
+ source, err = p.reader.ReadThreeDContext(ctx, markup.Annotation)
+ if err != nil {
+ return annotation, err
+ }
+ if markup.Annotation.Reference != (pdfgo.Reference{}) {
+ if state.sources == nil {
+ state.sources = make(map[pdfgo.Reference]pdfgo.ThreeD)
+ }
+ state.sources[markup.Annotation.Reference] = source
+ }
+ }
+ if markup.Checksum != nil {
+ sum, ok := state.sums[source.Stream]
+ if !ok {
+ var err error
+ sum, err = pdfgo.ThreeDArtworkChecksum(ctx, source.Stream)
+ if err != nil {
+ return annotation, err
+ }
+ if state.sums == nil {
+ state.sums = make(map[*pdfgo.Stream][16]byte)
+ }
+ state.sums[source.Stream] = sum
+ }
+ if sum != *markup.Checksum {
+ p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D markup artwork checksum differs; view association may be stale"})
+ }
+ }
+ visible := source.ActivationPolicy.Activation != "XA" && markup.MatchesView(source.SelectedView)
+ if !visible {
+ flags, err := p.reader.ReadAnnotationFlags(annotation)
+ if err != nil {
+ return annotation, err
+ }
+ annotation.Dictionary = maps.Clone(annotation.Dictionary)
+ annotation.Dictionary["F"] = pdfgo.Integer(flags | 2)
+ }
+ p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D markup default view visibility applied; view switching not transferred"})
+ return annotation, ctx.Err()
+}
diff --git a/ofdgo_pdf_3d_mode.go b/ofdgo_pdf_3d_mode.go
new file mode 100644
index 0000000..4302748
--- /dev/null
+++ b/ofdgo_pdf_3d_mode.go
@@ -0,0 +1,74 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import "github.com/xiaoqidun/pdfgo"
+
+// pdfThreeDRenderMode 将PDF标准绘制模式映射到通用几何绘制参数
+// 入参: source 三维参数, options 绘制选项
+// 返回: error 未支持的模式错误
+func pdfThreeDRenderMode(source pdfgo.ThreeD, options *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 = U3DRenderSolidWireframe, &mode.AuxiliaryColor
+ if mode.Subtype == "TransparentWireframe" {
+ options.Opacity = &mode.Opacity
+ }
+ case "Wireframe":
+ options.Mode, options.AuxiliaryColor = U3DRenderWireframe, &mode.AuxiliaryColor
+ case "ShadedWireframe":
+ options.Mode = U3DRenderShadedWireframe
+ case "HiddenWireframe":
+ options.Mode, options.AuxiliaryColor = U3DRenderHiddenWireframe, &mode.AuxiliaryColor
+ case "Vertices":
+ options.Mode, options.AuxiliaryColor = U3DRenderVertices, &mode.AuxiliaryColor
+ case "ShadedVertices":
+ options.Mode = U3DRenderShadedVertices
+ case "BoundingBox":
+ options.Mode, options.AuxiliaryColor = U3DRenderBoundingBox, &mode.AuxiliaryColor
+ case "TransparentBoundingBox", "TransparentBoundingBoxOutline":
+ options.Mode = U3DRenderBoundingBoxFaces
+ options.FaceColor, options.Opacity = mode.FaceColor, &mode.Opacity
+ if mode.Subtype == "TransparentBoundingBoxOutline" {
+ options.Mode, options.AuxiliaryColor = U3DRenderBoundingBoxOutline, &mode.AuxiliaryColor
+ }
+ case "Illustration":
+ options.Mode = U3DRenderIllustration
+ options.AuxiliaryColor, options.FaceColor, options.CreaseAngle = &mode.AuxiliaryColor, mode.FaceColor, &mode.CreaseAngle
+ case "SolidOutline", "ShadedIllustration":
+ options.Mode = U3DRenderSolidOutline
+ if mode.Subtype == "ShadedIllustration" {
+ options.Mode = 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
+}
diff --git a/ofdgo_pdf_action_lifecycle.go b/ofdgo_pdf_action_lifecycle.go
new file mode 100644
index 0000000..8fc5f04
--- /dev/null
+++ b/ofdgo_pdf_action_lifecycle.go
@@ -0,0 +1,143 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/xiaoqidun/pdfgo"
+)
+
+// documentOpenActions 转换文档初始目标与打开动作,不执行脚本或打开资源
+// 入参: ctx 取消上下文
+// 返回: error 动作、编码或取消错误
+func (p *pdfImporter) documentOpenActions(ctx context.Context) error {
+ opened, err := p.reader.ReadOpenAction(ctx)
+ if err != nil {
+ if p.warning == nil || !errors.Is(err, pdfgo.ErrInvalidDestination) {
+ return err
+ }
+ p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF initial destination ignored: %v", err)})
+ }
+ var object pdfgo.Object
+ if opened.Action != nil {
+ object = opened.Action
+ }
+ if dest := opened.Destination; dest != nil {
+ array := append(pdfgo.Array{dest.Page, dest.Mode}, dest.Parameters...)
+ object = pdfgo.Dictionary{"S": pdfgo.Name("GoTo"), "D": array}
+ }
+ currentPage := 0
+ actions, err := p.eventActions(ctx, object, "DO", ¤tPage)
+ if err != nil {
+ return err
+ }
+ if err := p.editor.SetDocumentActions(actions); err != nil {
+ return err
+ }
+ triggers, err := p.reader.ReadDocumentTriggers(ctx)
+ if err != nil {
+ return err
+ }
+ for _, trigger := range triggers {
+ if _, err := p.reader.ReadJavaScriptAction(ctx, trigger.Action); err != nil {
+ return err
+ }
+ if err := p.unmappedTrigger(ctx, "document", trigger); err != nil {
+ return err
+ }
+ }
+ return ctx.Err()
+}
+
+// pageOpenActions 将页面打开动作放在注解打开动作之前,关闭事件不改为打开事件
+// 入参: ctx 取消上下文, page PDF页面
+// 返回: error 事件、动作或取消错误
+func (p *pdfImporter) pageOpenActions(ctx context.Context, page *pdfgo.Page) error {
+ p.pageActionPage = p.page
+ triggers, err := p.reader.ReadPageTriggers(ctx, page)
+ if err != nil {
+ return err
+ }
+ for _, trigger := range triggers {
+ if trigger.Event != "O" {
+ if err := p.unmappedTrigger(ctx, "page", trigger); err != nil {
+ return err
+ }
+ continue
+ }
+ actions, err := p.eventActions(ctx, trigger.Action, "PO", &p.pageActionPage)
+ if err != nil {
+ return err
+ }
+ if err := p.editor.SetPageActions(p.page, actions); err != nil {
+ return err
+ }
+ }
+ return ctx.Err()
+}
+
+// eventActions 按原顺序转换动作链,保留事件类型与链内跳转页状态
+// 入参: ctx 取消上下文, object 动作对象, event OFD事件, currentPage 当前页面
+// 返回: []Action 转换动作, error 动作或取消错误
+func (p *pdfImporter) eventActions(ctx context.Context, object pdfgo.Object, event string, currentPage *int) ([]Action, error) {
+ var actions []Action
+ err := p.reader.WalkActions(ctx, object, func(info pdfgo.ActionInfo) error {
+ annotation := pdfgo.Annotation{Dictionary: pdfgo.Dictionary{"A": info.Dictionary}}
+ action, err := p.linkAction(annotation, p.warning == nil, currentPage)
+ if err != nil {
+ var unsupported *pdfgo.UnsupportedError
+ if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidAction) {
+ p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s action ignored: %v", event, err)})
+ return nil
+ }
+ if event != "CLICK" && p.warning != nil && errors.Is(err, pdfgo.ErrInvalidDestination) {
+ p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("invalid PDF %s destination ignored: %v", event, err)})
+ return nil
+ }
+ if event != "CLICK" && p.warning != nil && errors.As(err, &unsupported) {
+ p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF %s action not transferred to OFD: %s", event, unsupported.Feature)})
+ return nil
+ }
+ return err
+ }
+ if action != nil {
+ action.Event = event
+ actions = append(actions, *action)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return actions, ctx.Err()
+}
+
+// unmappedTrigger 校验无法映射的事件链,按导入策略报告而不改变触发条件
+// 入参: ctx 取消上下文, owner 事件归属, trigger PDF事件
+// 返回: error 动作链、取消或严格模式错误
+func (p *pdfImporter) unmappedTrigger(ctx context.Context, owner string, trigger pdfgo.ActionTrigger) error {
+ if err := p.reader.WalkActions(ctx, trigger.Action, func(pdfgo.ActionInfo) error { return nil }); err != nil {
+ return err
+ }
+ feature := owner + " trigger " + string(trigger.Event)
+ if p.warning == nil {
+ return &pdfgo.UnsupportedError{Feature: feature}
+ }
+ p.warning(pdfgo.Diagnostic{Message: "PDF " + feature + " not transferred to OFD"})
+ return nil
+}
diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go
index 32eab81..ea320e0 100644
--- a/ofdgo_pdf_annot.go
+++ b/ofdgo_pdf_annot.go
@@ -17,6 +17,7 @@ package ofdgo
import (
"bytes"
"context"
+ "encoding/json"
"encoding/xml"
"fmt"
"maps"
@@ -36,8 +37,12 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page
// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, paint 绘制回调,空值使用原外观, actions 外观区域的动作
// 返回: error 外观或转换错误
func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, paint func(*pdfImporter) error, actions ...Action) error {
- for _, key := range []pdfgo.Name{"AA", "OC", "A"} {
- if annotation.Subtype == "Widget" && (key == "A" || key == "AA") {
+ clicks, opened, err := p.annotationTriggerActions(ctx, annotation)
+ if err != nil {
+ return err
+ }
+ for _, key := range []pdfgo.Name{"OC", "A"} {
+ if annotation.Subtype == "Widget" && key == "A" {
continue
}
if key == "A" && (annotation.Subtype == "Link" || annotation.Subtype == "Movie") {
@@ -63,6 +68,9 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
if err != nil {
return err
}
+ if flags&64 != 0 && annotation.Subtype != "Widget" {
+ actions, clicks = nil, nil
+ }
invisible := flags&1 != 0 && !annotation.IsStandard()
if flags&256 != 0 {
return &pdfgo.UnsupportedError{Feature: "annotation ToggleNoView flag"}
@@ -73,6 +81,16 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
p.matrix.Apply(pdfgo.Point{X: annotation.Rect.XMax, Y: annotation.Rect.YMax}),
p.matrix.Apply(pdfgo.Point{X: annotation.Rect.XMin, Y: annotation.Rect.YMax}),
})
+ if len(clicks) != 0 && annotation.Subtype == "Link" {
+ region, err := p.linkRegion(annotation, box)
+ if err != nil {
+ return err
+ }
+ for i := range clicks {
+ clicks[i].Region = region
+ }
+ }
+ actions = append(actions, clicks...)
stamp := *p
stamp.matrix = (pdfgo.Matrix{1, 0, 0, 1, -box.X, -box.Y}).Mul(p.matrix)
stamp.objects = nil
@@ -120,6 +138,21 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
return err
}
converted := Annotation{Type: pdfAnnotationType(annotation.Subtype), Subtype: string(annotation.Subtype), Remark: remark, Appearance: Appearance{Boundary: pdfBoundary(box), Objects: objects}}
+ if annotation.Subtype == "Projection" {
+ projection, err := p.reader.ReadProjectionAnnotation(annotation)
+ if err != nil {
+ return err
+ }
+ converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Projection.Runtime", Value: "unavailable"}}}
+ if projection.Measurement != nil {
+ data, err := json.Marshal(projection.Measurement)
+ if err != nil {
+ return err
+ }
+ converted.Parameters.Parameter = append(converted.Parameters.Parameter, AnnotationParameter{Name: "PDF.Projection.Measurement", Value: string(data)})
+ }
+ p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF projection annotation metadata and static appearance retained; runtime rendering not transferred"})
+ }
if annotation.Subtype == "Redact" {
converted.Parameters = &AnnotationParameters{Parameter: []AnnotationParameter{{Name: "PDF.Redact.Pending", Value: "true"}}}
}
@@ -187,13 +220,16 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
return fmt.Errorf("PDF annotation: %w", err)
}
p.annotationData = append(p.annotationData, bytes.TrimPrefix(data, []byte(xml.Header))...)
+ if len(opened) != 0 {
+ p.editor.pages[p.page].Actions = append(p.editor.pages[p.page].Actions, opened...)
+ }
+ if len(actions) != 0 && annotation.Subtype == "Link" || len(clicks) != 0 {
+ p.report.Links++
+ }
p.report.TextObjects += stamp.report.TextObjects
p.report.PathObjects += stamp.report.PathObjects
p.report.ImageObjects += stamp.report.ImageObjects
p.report.Warnings = append(p.report.Warnings, stamp.report.Warnings...)
- if flags&64 != 0 {
- p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF annotation read-only flag not transferred"})
- }
return nil
}
@@ -208,7 +244,7 @@ func pdfAnnotationType(subtype pdfgo.Name) string {
return "Watermark"
case "Highlight", "Underline", "Squiggly", "StrikeOut":
return "Highlight"
- case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup", "Widget", "PrinterMark", "TrapNet", "3D", "RichMedia", "Redact":
+ case "Text", "FreeText", "Line", "Square", "Circle", "Polygon", "PolyLine", "Caret", "Ink", "FileAttachment", "Sound", "Movie", "Screen", "Link", "Popup", "Widget", "PrinterMark", "TrapNet", "3D", "RichMedia", "Redact", "Projection":
return "Path"
}
if !(pdfgo.Annotation{Subtype: subtype}).IsStandard() {
diff --git a/ofdgo_pdf_annot_trigger.go b/ofdgo_pdf_annot_trigger.go
new file mode 100644
index 0000000..815fa94
--- /dev/null
+++ b/ofdgo_pdf_annot_trigger.go
@@ -0,0 +1,75 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/xiaoqidun/pdfgo"
+)
+
+// annotationTriggerActions 将释放点击和页面打开事件转换为OFD,不改变其他事件的触发条件
+// 入参: ctx 取消上下文, annotation PDF注解
+// 返回: []Action 点击动作, []Action 页面打开动作, error 事件、动作或取消错误
+func (p *pdfImporter) annotationTriggerActions(ctx context.Context, annotation pdfgo.Annotation) ([]Action, []Action, error) {
+ if annotation.Subtype == "Widget" {
+ return nil, nil, nil
+ }
+ triggers, err := p.reader.ReadAnnotationTriggers(ctx, annotation)
+ if err != nil {
+ return nil, nil, err
+ }
+ if len(triggers) == 0 {
+ return nil, nil, nil
+ }
+ flags, err := p.reader.ReadAnnotationFlags(annotation)
+ if err != nil {
+ return nil, nil, err
+ }
+ var clicks, opened []Action
+ for _, trigger := range triggers {
+ if flags&64 != 0 && (trigger.Event == "E" || trigger.Event == "X" || trigger.Event == "D" || trigger.Event == "U") {
+ continue
+ }
+ event := ""
+ switch trigger.Event {
+ case "U":
+ event = "CLICK"
+ case "PO":
+ event = "PO"
+ default:
+ return nil, nil, &pdfgo.UnsupportedError{Feature: "annotation trigger " + string(trigger.Event)}
+ }
+ currentPage := p.page
+ if event == "PO" {
+ currentPage = p.pageActionPage
+ }
+ actions, err := p.eventActions(ctx, trigger.Action, event, ¤tPage)
+ if err != nil {
+ return nil, nil, err
+ }
+ if event == "CLICK" {
+ clicks = append(clicks, actions...)
+ } else {
+ p.pageActionPage = currentPage
+ opened = append(opened, actions...)
+ }
+ }
+ if len(opened) != 0 && (p.page < 0 || p.page >= len(p.editor.pages)) {
+ return nil, nil, fmt.Errorf("invalid annotation trigger page")
+ }
+ return clicks, opened, ctx.Err()
+}
diff --git a/ofdgo_pdf_clip.go b/ofdgo_pdf_clip.go
index e046678..060086a 100644
--- a/ofdgo_pdf_clip.go
+++ b/ofdgo_pdf_clip.go
@@ -30,6 +30,55 @@ type pdfClipPath struct {
path *[1]PathObject
}
+// pdfClipContainsBoundary 判断单一矩形裁剪是否完整覆盖对象边界,不近似曲线或斜边
+// 入参: path PDF裁剪路径, matrix 页面变换, boundary OFD对象边界
+// 返回: bool 是否可由对象边界代替裁剪
+func pdfClipContainsBoundary(path pdfgo.Path, matrix pdfgo.Matrix, boundary Box) bool {
+ if len(path.Text) != 0 || len(path.Segments) < 4 || len(path.Segments) > 6 || !finite(boundary.X) || !finite(boundary.Y) || !finite(boundary.W) || !finite(boundary.H) || boundary.W <= 0 || boundary.H <= 0 {
+ return false
+ }
+ var points [4]pdfgo.Point
+ for index := range 4 {
+ segment := path.Segments[index]
+ operator := "L"
+ if index == 0 {
+ operator = "M"
+ }
+ if segment.Operator != operator || len(segment.Points) != 1 {
+ return false
+ }
+ points[index] = matrix.Apply(segment.Points[0])
+ if !finite(points[index].X) || !finite(points[index].Y) {
+ return false
+ }
+ }
+ end := 4
+ if end < len(path.Segments) && path.Segments[end].Operator == "L" {
+ segment := path.Segments[end]
+ if len(segment.Points) != 1 || matrix.Apply(segment.Points[0]) != points[0] {
+ return false
+ }
+ end++
+ }
+ if end < len(path.Segments) {
+ segment := path.Segments[end]
+ if segment.Operator != "C" || len(segment.Points) != 0 {
+ return false
+ }
+ end++
+ }
+ if end != len(path.Segments) {
+ return false
+ }
+ a, b, c, d := points[0], points[1], points[2], points[3]
+ if !(a.Y == b.Y && b.X == c.X && c.Y == d.Y && d.X == a.X || a.X == b.X && b.Y == c.Y && c.X == d.X && d.Y == a.Y) {
+ return false
+ }
+ left, right := math.Min(a.X, c.X), math.Max(a.X, c.X)
+ top, bottom := math.Min(a.Y, c.Y), math.Max(a.Y, c.Y)
+ return left < right && top < bottom && boundary.X >= left && boundary.Y >= top && boundary.X+boundary.W <= right && boundary.Y+boundary.H <= bottom
+}
+
// pdfClipPathKey 按路径完整内容与变换生成键,不依赖可变切片的地址
// 入参: ctx 取消上下文, path 裁剪路径, matrix 页面变换
// 返回: [32]byte 内容摘要, error 取消错误
diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go
index b45ef68..572518b 100644
--- a/ofdgo_pdf_composite_draw.go
+++ b/ofdgo_pdf_composite_draw.go
@@ -17,6 +17,7 @@ package ofdgo
import (
"context"
"fmt"
+ "image"
"math"
"github.com/xiaoqidun/pdfgo"
@@ -51,9 +52,13 @@ func (c *pdfCompositor) draw(nodes []pdfCompositeNode, pixels []pdfCompositePixe
// 返回: error 几何或颜色错误
func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error {
box, err := c.markBounds(node)
- if err != nil || c.pixelBounds(box).Empty() {
+ if err != nil {
return err
}
+ region := c.pixelBounds(box)
+ if region.Empty() {
+ return nil
+ }
style, fill, stroke := node.style()
mask, err := c.mask(style.SoftMask, space)
if err != nil {
@@ -96,7 +101,7 @@ func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePix
}
}
if grouped {
- return pdfCompositeGroup(c.importer.ctx, pixels, initial, result, space, space, style.Fill.Alpha, mask, style.BlendMode, style.RenderingIntent, style.ColorConversion)
+ return pdfCompositeGroupRegion(c.importer.ctx, pixels, initial, result, c.width, region, space, space, style.Fill.Alpha, mask, style.BlendMode, style.RenderingIntent, style.ColorConversion)
}
return nil
}
@@ -333,6 +338,7 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi
if b.W <= 0 || b.H <= 0 || b.X >= c.box.X+c.box.W || b.Y >= c.box.Y+c.box.H || b.X+b.W <= c.box.X || b.Y+b.H <= c.box.Y {
return nil
}
+ region := c.pixelBounds(b)
blocked := c.transferBlocked
c.transferBlocked = blocked || g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode)
defer func() { c.transferBlocked = blocked }()
@@ -362,12 +368,13 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi
} else {
sameSpace := space.Equal(parent)
components := parent.Components()
- for i, pixel := range pixels {
+ for i := range pixels {
if i%pdfCompositeTileSize == 0 {
if err := c.importer.ctx.Err(); err != nil {
return err
}
}
+ pixel := &pixels[i]
values := pixel.values
if !sameSpace {
var err error
@@ -408,7 +415,7 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi
}
}
}
- return pdfCompositeGroup(c.importer.ctx, pixels, initial, result, space, parent, g.Alpha, mask, g.BlendMode, g.RenderingIntent, g.ColorConversion)
+ return pdfCompositeGroupRegion(c.importer.ctx, pixels, initial, result, c.width, region, space, parent, g.Alpha, mask, g.BlendMode, g.RenderingIntent, g.ColorConversion)
}
// drawKnockout 将每个对象与组初始背景合成,再按对象形状替换先前对象
@@ -477,37 +484,56 @@ func pdfCompositeKnockout(target *pdfCompositePixel, initial, source pdfComposit
// 入参: ctx 取消上下文, pixels 父组输出, initial 初始背景, result 组结果, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式, intent 渲染意图, conversion 设备转换函数
// 返回: error 颜色或混合错误
func pdfCompositeGroup(ctx context.Context, pixels, initial, result []pdfCompositePixel, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode, intent pdfgo.Name, conversion pdfgo.ColorConversion) error {
+ return pdfCompositeGroupRegion(ctx, pixels, initial, result, max(1, len(pixels)), image.Rect(0, 0, len(pixels), 1), space, parent, opacity, mask, mode, intent, conversion)
+}
+
+// pdfCompositeGroupRegion 在组覆盖区域合成像素,保留原栅格及形状贡献
+// 入参: ctx 取消上下文, pixels 父组输出, initial 初始背景, result 组结果, stride 行跨度, region 覆盖区域, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式, intent 渲染意图, conversion 设备转换函数
+// 返回: error 缓冲、区域、颜色或混合错误
+func pdfCompositeGroupRegion(ctx context.Context, pixels, initial, result []pdfCompositePixel, stride int, region image.Rectangle, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode, intent pdfgo.Name, conversion pdfgo.ColorConversion) error {
if err := ctx.Err(); err != nil {
return err
}
if len(pixels) != len(initial) || len(pixels) != len(result) || mask != nil && len(mask) != len(pixels) {
return fmt.Errorf("invalid PDF transparency group buffers")
}
+ if stride <= 0 || region.Min.X < 0 || region.Min.Y < 0 || region.Max.X > stride || !region.Empty() && region.Max.Y > len(pixels)/stride {
+ return fmt.Errorf("invalid PDF transparency group region")
+ }
group, destination := pdfgo.ColorantGroup{Space: space}, pdfgo.ColorantGroup{Space: parent}
compositor, err := destination.PrepareGroup(&group, mode, intent, conversion)
if err != nil {
return err
}
+ if region.Empty() {
+ return nil
+ }
components, parentComponents := space.Components(), parent.Components()
- for i, pixel := range result {
- if i%pdfCompositeTileSize == 0 {
- if err := ctx.Err(); err != nil {
- return err
- }
- }
- alpha := opacity
- if mask != nil {
- alpha *= mask[i]
- }
- target := pdfgo.ColorantPixel{Values: pixels[i].values[:parentComponents], Alpha: pixels[i].alpha, Shape: pixels[i].shape, Effect: pixels[i].effect}
- if err := compositor.Composite(&target,
- pdfgo.ColorantPixel{Values: initial[i].values[:components], Alpha: initial[i].alpha, Shape: initial[i].shape, Effect: initial[i].effect},
- pdfgo.ColorantPixel{Values: pixel.values[:components], Alpha: pixel.alpha, Shape: pixel.shape, Effect: pixel.effect},
- alpha,
- ); err != nil {
+ for y := region.Min.Y; y < region.Max.Y; y++ {
+ if err := ctx.Err(); err != nil {
return err
}
- pixels[i].alpha, pixels[i].effect, pixels[i].shape = target.Alpha, target.Effect, target.Shape
+ for i, end := y*stride+region.Min.X, y*stride+region.Max.X; i < end; i++ {
+ if i%pdfCompositeTileSize == 0 {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ }
+ alpha := opacity
+ if mask != nil {
+ alpha *= mask[i]
+ }
+ pixel := &result[i]
+ target := pdfgo.ColorantPixel{Values: pixels[i].values[:parentComponents], Alpha: pixels[i].alpha, Shape: pixels[i].shape, Effect: pixels[i].effect}
+ if err := compositor.Composite(&target,
+ pdfgo.ColorantPixel{Values: initial[i].values[:components], Alpha: initial[i].alpha, Shape: initial[i].shape, Effect: initial[i].effect},
+ pdfgo.ColorantPixel{Values: pixel.values[:components], Alpha: pixel.alpha, Shape: pixel.shape, Effect: pixel.effect},
+ alpha,
+ ); err != nil {
+ return err
+ }
+ pixels[i].alpha, pixels[i].effect, pixels[i].shape = target.Alpha, target.Effect, target.Shape
+ }
}
return nil
}
diff --git a/ofdgo_pdf_destination.go b/ofdgo_pdf_destination.go
new file mode 100644
index 0000000..0015d15
--- /dev/null
+++ b/ofdgo_pdf_destination.go
@@ -0,0 +1,124 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/xiaoqidun/pdfgo"
+)
+
+// pdfDestinationFontBackend 禁止内容度量忽略实际绘制所需的缺失字体
+type pdfDestinationFontBackend struct {
+ FontBackend
+}
+
+// ResolveFont 沿用字体匹配,只在实际绘制缺少字体数据时中止度量
+// 入参: renderer 度量渲染器, id 字体标识, exact 是否禁止无关回退
+// 返回: ResolvedFont 字体资源, error 解析或缺失错误
+func (b pdfDestinationFontBackend) ResolveFont(renderer *Renderer, id string, exact bool) (ResolvedFont, error) {
+ font, err := b.FontBackend.ResolveFont(renderer, id, exact)
+ if err == nil && len(font.Data) == 0 {
+ err = fmt.Errorf("PDF destination font %q unavailable", id)
+ }
+ return font, err
+}
+
+// destinationBounds 度量目标页内容并缓存页面内范围,不包含注解或像素取整
+// 入参: page PDF目标页
+// 返回: Box OFD内容范围, error 度量错误
+func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) {
+ if err := p.ctx.Err(); err != nil {
+ return Box{}, err
+ }
+ if box, ok := p.destinationBoxes[page.Reference]; ok {
+ return box, nil
+ }
+ if index, ok := p.pageIndexes[page.Reference]; ok {
+ previous := p.page
+ p.page = index
+ defer func() { p.page = previous }()
+ }
+ matrix, width, height := pdfPageMatrix(page)
+ editor := NewEditor()
+ editor.SetRenderBackends(p.editor.Backends())
+ editor.SetFontDirs(p.editor.fontDirs...)
+ editor.SetFontFS(p.editor.fontFS...)
+ editor.fontSourcesCache = p.renderer.fontSourcesCache
+ editor.validatedPaths = p.editor.pathValidationCache()
+ if _, err := editor.AddPage(width, height); err != nil {
+ return Box{}, err
+ }
+ local := pdfImporter{
+ ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer,
+ matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height,
+ rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string),
+ resolveFile: p.resolveFile, resolveReference: p.resolveReference,
+ referenceReaders: make(map[[32]byte]*pdfgo.Reader),
+ referenceStreams: make(map[*pdfgo.Stream]*pdfgo.Reader),
+ referencePages: make(map[pdfReferencePageKey]*pdfgo.Page),
+ halftones: p.halftones, halftoneWarnings: make(map[*pdfgo.Halftone]bool),
+ }
+ defer local.closeReferenceReaders()
+ if err := local.compositePage(nil, func(visitor pdfgo.Visitor) error {
+ return p.reader.WalkPage(p.ctx, page, visitor)
+ }); err != nil {
+ return Box{}, fmt.Errorf("PDF destination content: %w", err)
+ }
+ if err := local.commitObjects(); err != nil {
+ return Box{}, err
+ }
+ reader, err := editor.resourceReader()
+ if err != nil {
+ return Box{}, err
+ }
+ defer reader.Close()
+ renderer := p.renderer.childRenderer(reader)
+ renderer.TransparentBackground = true
+ renderer.RenderAnnotations = false
+ if renderer.backends.Fonts != nil {
+ renderer.backends.Fonts = pdfDestinationFontBackend{renderer.backends.Fonts}
+ }
+ scene, err := renderer.CompilePage(&editor.pages[0])
+ if err != nil {
+ return Box{}, err
+ }
+ if scene == nil {
+ return Box{}, fmt.Errorf("PDF destination content scene unavailable")
+ }
+ geometry, err := renderer.Geometry()
+ if err != nil {
+ return Box{}, err
+ }
+ box, err := scene.BoundsContext(p.ctx, geometry)
+ if err != nil {
+ return Box{}, err
+ }
+ left, top := math.Max(0, box.X), math.Max(0, box.Y)
+ right, bottom := math.Min(width, box.X+box.W), math.Min(height, box.Y+box.H)
+ box = Box{X: left, Y: top, W: right - left, H: bottom - top}
+ if !finite(left) || !finite(top) || !finite(right) || !finite(bottom) || box.W <= 0 || box.H <= 0 {
+ return Box{}, fmt.Errorf("PDF destination content bounds empty or invalid")
+ }
+ if err := p.ctx.Err(); err != nil {
+ return Box{}, err
+ }
+ if p.destinationBoxes == nil {
+ p.destinationBoxes = make(map[pdfgo.Reference]Box)
+ }
+ p.destinationBoxes[page.Reference] = box
+ return box, nil
+}
diff --git a/ofdgo_pdf_font.go b/ofdgo_pdf_font.go
index 9647a9b..53d4d42 100644
--- a/ofdgo_pdf_font.go
+++ b/ofdgo_pdf_font.go
@@ -36,6 +36,12 @@ type pdfImportedFont struct {
repairLimit uint16
}
+// pdfFontRepair 保存字体封装修复范围,不混用字形度量与位置索引
+type pdfFontRepair struct {
+ metricsLimit uint16
+ locations bool
+}
+
// embeddedTextFont 匹配同名内嵌字体,仅复用唯一且覆盖全部字符的字形程序
// 入参: source 外部字体, glyphs 当前文字字形
// 返回: *pdfImportedFont 可复用字体,无可靠匹配时为空
@@ -102,10 +108,13 @@ func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error)
type1 = &parsed
result.type1Glyphs = parsed.glyphIDs()
}
- program, limit, err := pdfFontProgram(source, type1)
+ program, repair, err := pdfFontProgram(source, type1)
if err != nil {
return nil, fmt.Errorf("PDF font %s program: %w", source.Name, err)
}
+ if repair.locations && p.warning == nil {
+ return nil, fmt.Errorf("PDF font %s program: %w", source.Name, errTTInvalidLocations)
+ }
if p.editor.backends.FontResources == nil {
return nil, fmt.Errorf("PDF font %s resource: font resources: %w", source.Name, ErrBackendUnavailable)
}
@@ -123,7 +132,10 @@ func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error)
if _, ok := result.metrics.(FontOutlines); !ok {
return nil, fmt.Errorf("PDF font backend does not provide glyph outlines")
}
- result.checksum, result.repairLimit = sha256.Sum256(result.resource.Data), limit
+ result.checksum, result.repairLimit = sha256.Sum256(result.resource.Data), repair.metricsLimit
+ if repair.locations {
+ p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF font %s has invalid glyph locations; index rebuilt with original glyph IDs", source.Name)})
+ }
if p.fonts == nil {
p.fonts = make(map[*pdfgo.Font]*pdfImportedFont)
}
@@ -132,23 +144,24 @@ func (p *pdfImporter) importedFont(source *pdfgo.Font) (*pdfImportedFont, error)
}
// pdfFontProgram 为PDF子集字体补齐封装表,保留字形轮廓和编号
-// 复合字体的重复映射区段按PDF字符映射重建,不改变CID对应的字形
+// 无效字符表按PDF已解码字形重建,不改变字符对应的字形编号
// 入参: source PDF字体, type1 已解析的Type1程序,nil时按需解析
-// 返回: []byte 封装后的字体数据, uint16 缺失度量前的完整字形数, error 错误信息
-func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, error) {
+// 返回: []byte 封装后的字体数据, pdfFontRepair 字体修复范围, error 错误信息
+func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, pdfFontRepair, error) {
+ var repair pdfFontRepair
program := source.Program
if source.ProgramType == "FontFile" {
if type1 == nil {
parsed, err := parseType1Program(program)
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
type1 = &parsed
}
var err error
program, err = type1.toCFF(pdfFontIdentity(source))
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
}
bareCFF := len(program) >= 4 && program[0] == 1 && program[1] == 0 && program[2] >= 4 && program[3] >= 1 && program[3] <= 4
@@ -156,21 +169,25 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
var err error
program, _, err = wrapCFFToOTF(program)
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
}
tables, err := fontFileTables(program, 0)
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
if len(tables["head"]) < 54 || len(tables["maxp"]) < 6 || len(tables["hhea"]) < 36 || len(tables["hmtx"]) == 0 {
- return nil, 0, fmt.Errorf("embedded PDF font lacks required metrics")
+ return nil, repair, fmt.Errorf("embedded PDF font lacks required metrics")
}
count := binary.BigEndian.Uint16(tables["maxp"][4:6])
if count == 0 {
- return nil, 0, fmt.Errorf("embedded PDF font has no glyphs")
+ return nil, repair, fmt.Errorf("embedded PDF font has no glyphs")
}
- changed := false
+ repair.locations, err = 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
@@ -178,34 +195,33 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
if cff := tables["CFF "]; len(cff) != 0 {
sanitized, err := sanitizeCFF(cff)
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
normalized, err := normalizeCFFCharstringsAt(sanitized, binary.BigEndian.Uint16(tables["head"][18:20]))
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
if !bytes.Equal(normalized, cff) {
tables["CFF "] = normalized
changed = true
}
}
- var repairedLimit uint16
metrics := int(binary.BigEndian.Uint16(tables["hhea"][34:36]))
if metrics == 0 || metrics > int(count) {
- return nil, 0, fmt.Errorf("invalid embedded PDF font metric 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, 0, fmt.Errorf("incomplete embedded PDF font metrics: have %d, need %d", len(tables["hmtx"]), metricLength)
+ 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 := pdfMissingLeftBearings(tables, limit, int(count))
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
tables["hmtx"] = append(bytes.Clone(tables["hmtx"]), missing...)
- repairedLimit = uint16(limit)
+ repair.metricsLimit = uint16(limit)
changed = true
}
if len(tables["hmtx"]) > metricLength {
@@ -226,19 +242,28 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
changed = true
}
}
- if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && (invalidCmap || pdfCmapOverlaps(tables["cmap"])) {
+ if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && invalidCmap || (source.Subtype == "Type0" || source.Subtype == "TrueType") && pdfCmapNeedsRebuild(tables["cmap"]) {
mapping := map[rune]uint16{}
- for _, code := range slices.Sorted(maps.Keys(source.Unicode)) {
+ var codes []string
+ if source.Subtype == "TrueType" {
+ codes = make([]string, 256)
+ for code := range codes {
+ codes[code] = string([]byte{byte(code)})
+ }
+ } else {
+ codes = slices.Sorted(maps.Keys(source.Unicode))
+ }
+ for _, code := range codes {
glyphs, err := source.Decode([]byte(code))
if err != nil {
- return nil, 0, err
+ return nil, repair, err
}
if len(glyphs) != 1 || !glyphs[0].HasID {
- return nil, 0, &pdfgo.UnsupportedError{Feature: "font without explicit glyph mapping"}
+ return nil, repair, &pdfgo.UnsupportedError{Feature: "font without explicit glyph mapping"}
}
glyph := glyphs[0]
if glyph.ID >= count {
- return nil, 0, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph.ID, count)
+ return nil, repair, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph.ID, count)
}
if utf8.RuneCountInString(glyph.Text) == 1 {
char, _ := utf8.DecodeRuneInString(glyph.Text)
@@ -255,7 +280,7 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
fontName := pdfFontIdentity(source)
name := utf16.Encode([]rune(fontName))
if len(name) > 32767 {
- return nil, 0, fmt.Errorf("PDF font name exceeds name table capacity")
+ 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)
@@ -290,10 +315,10 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, uint16, er
}
}
if !changed && !pdfSFNTMissingPadding(program) {
- return program, repairedLimit, nil
+ return program, repair, nil
}
result, err := serializeOTF(tables)
- return result, repairedLimit, err
+ return result, repair, err
}
// pdfFontIdentity 返回字体名称或由原始程序生成的稳定封装标识
@@ -307,35 +332,58 @@ func pdfFontIdentity(source *pdfgo.Font) string {
return fmt.Sprintf("PDF-%x", checksum[:28])
}
-// pdfCmapOverlaps 检查格式4字符映射是否包含交叠区段
+// pdfCmapNeedsRebuild 检查字体封装字符表是否存在结构错误或交叠区段
// 入参: data cmap表数据
-// 返回: bool 是否存在交叠
-func pdfCmapOverlaps(data []byte) bool {
- if len(data) < 4 {
- return false
+// 返回: bool 是否需要按PDF显式字形编号重建
+func pdfCmapNeedsRebuild(data []byte) bool {
+ if len(data) < 4 || binary.BigEndian.Uint16(data) != 0 {
+ return true
}
count := int(binary.BigEndian.Uint16(data[2:]))
- if count > (len(data)-4)/8 {
- return false
+ 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(len(data)) || uint64(len(data))-offset < 16 {
- continue
+ 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
}
- length := int(binary.BigEndian.Uint16(sub[2:]))
- segments := int(binary.BigEndian.Uint16(sub[6:])) / 2
- if length > len(sub) || 16+8*segments > length {
- continue
+ if len(sub) < 16 {
+ return true
}
- for i := 1; i < segments; i++ {
- previous := binary.BigEndian.Uint16(sub[14+2*(i-1):])
- start := binary.BigEndian.Uint16(sub[16+2*segments+2*i:])
- if start <= previous {
+ 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
}
}
diff --git a/ofdgo_pdf_link.go b/ofdgo_pdf_link.go
index 1b15094..1f48f30 100644
--- a/ofdgo_pdf_link.go
+++ b/ofdgo_pdf_link.go
@@ -31,6 +31,11 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if err != nil {
return err
}
+ markups, err := p.reader.ReadThreeDMarkups(ctx, annotations)
+ if err != nil {
+ return err
+ }
+ var markupState pdfThreeDMarkupState
parents := make(map[pdfgo.Reference]int)
for i, annotation := range annotations {
if err := ctx.Err(); err != nil {
@@ -77,6 +82,12 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if attached[i] {
continue
}
+ if len(markups) != 0 {
+ annotation, err = p.threeDMarkupAnnotation(ctx, annotation, markups[i], &markupState, strict)
+ if err != nil {
+ return err
+ }
+ }
dict := annotation.Dictionary
optional, err := p.reader.Resolve(dict["OC"])
if err != nil {
@@ -223,6 +234,9 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF pending redaction appearance imported; content removal not applied; overlay data not transferred"})
continue
}
+ if annotation.Subtype == "Projection" && strict {
+ return &pdfgo.UnsupportedError{Feature: "projection annotation runtime conversion"}
+ }
if annotation.Subtype != "Link" && pdfAnnotationType(annotation.Subtype) != "" {
if err := p.appearanceAnnotation(ctx, page, annotation); err != nil {
return err
@@ -232,12 +246,15 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if annotation.Subtype != "Link" {
return &pdfgo.UnsupportedError{Feature: "annotation " + string(annotation.Subtype)}
}
- additional, err := p.reader.Resolve(dict["AA"])
+ flags, err := p.reader.ReadAnnotationFlags(annotation)
if err != nil {
return err
}
- if additional != nil {
- return &pdfgo.UnsupportedError{Feature: "link annotation field \"AA\""}
+ if flags&64 != 0 {
+ if err := p.appearanceAnnotation(ctx, page, annotation); err != nil {
+ return err
+ }
+ continue
}
primary, err := p.reader.Resolve(dict["A"])
if err != nil {
@@ -281,9 +298,6 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if err := p.appearanceAnnotation(ctx, page, annotation, actions...); err != nil {
return err
}
- if len(actions) != 0 {
- p.report.Links++
- }
}
return nil
}
@@ -446,73 +460,34 @@ func (p *pdfImporter) linkAction(annotation pdfgo.Annotation, strict bool, curre
return nil, fmt.Errorf("PDF destination page missing")
}
matrix, _, _ := pdfPageMatrix(targetPage)
- dest := &Dest{Type: string(destination.Mode), PageID: p.pageIDs[destination.Page]}
- if destination.Mode == "XYZ" {
- values := [3]float64{}
- retained := [3]bool{}
- for n, v := range destination.Parameters {
- switch v := v.(type) {
- case pdfgo.Integer:
- values[n] = float64(v)
- case pdfgo.Real:
- values[n] = float64(v)
- case nil:
- retained[n] = true
- }
- }
- point := matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]})
- dest.Left, dest.Top, dest.Zoom = point.X, point.Y, values[2]
- dest.OmitLeft, dest.OmitTop = retained[0], retained[1]
- if targetPage.Rotation == 90 || targetPage.Rotation == 270 {
- dest.OmitLeft, dest.OmitTop = retained[1], retained[0]
- }
- dest.OmitZoom = retained[2] || values[2] == 0
- } else if destination.Mode == "FitH" || destination.Mode == "FitV" {
- var coordinate float64
- switch value := destination.Parameters[0].(type) {
- case pdfgo.Integer:
- coordinate = float64(value)
- case pdfgo.Real:
- coordinate = float64(value)
- }
- point := pdfgo.Point{X: coordinate}
- if destination.Mode == "FitH" {
- point = pdfgo.Point{Y: coordinate}
- }
- point = matrix.Apply(point)
- if targetPage.Rotation == 90 || targetPage.Rotation == 270 {
- if dest.Type == "FitH" {
- dest.Type = "FitV"
- } else {
- dest.Type = "FitH"
- }
- }
- if dest.Type == "FitH" {
- dest.Top, dest.OmitTop = point.Y, destination.Parameters[0] == nil
- } else {
- dest.Left, dest.OmitLeft = point.X, destination.Parameters[0] == nil
- }
- } else if destination.Mode == "FitR" {
- values := [4]float64{}
- for n, v := range destination.Parameters {
- switch v := v.(type) {
- case pdfgo.Integer:
- values[n] = float64(v)
- case pdfgo.Real:
- values[n] = float64(v)
- default:
- return nil, fmt.Errorf("invalid PDF FitR destination coordinate")
- }
- }
- bounds := pdfBounds([]pdfgo.Point{
- matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]}),
- matrix.Apply(pdfgo.Point{X: values[2], Y: values[3]}),
- })
- dest.Left, dest.Top = bounds.X, bounds.Y
- dest.Right, dest.Bottom = bounds.X+bounds.W, bounds.Y+bounds.H
- } else if destination.Mode != "Fit" {
+ if destination.Mode == "FitBH" || destination.Mode == "FitBV" {
return nil, &pdfgo.UnsupportedError{Feature: "destination mode " + string(destination.Mode)}
}
+ target, err := destination.Transform(matrix)
+ if err != nil {
+ return nil, err
+ }
+ if destination.Mode == "FitB" {
+ box, err := p.destinationBounds(targetPage)
+ if err != nil {
+ if canceled := p.ctx.Err(); canceled != nil {
+ return nil, canceled
+ }
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return nil, err
+ }
+ if strict {
+ return nil, err
+ }
+ p.report.Warnings = append(p.report.Warnings, pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("PDF FitB destination bounds unavailable: %v; link omitted", err)})
+ return nil, nil
+ }
+ target.Mode = "FitR"
+ target.Left, target.Top = box.X, box.Y
+ target.Right, target.Bottom = box.X+box.W, box.Y+box.H
+ target.KeepLeft, target.KeepTop = false, false
+ }
+ dest := &Dest{Type: string(target.Mode), PageID: p.pageIDs[destination.Page], Left: target.Left, Top: target.Top, Right: target.Right, Bottom: target.Bottom, Zoom: target.Zoom, OmitLeft: target.KeepLeft, OmitTop: target.KeepTop, OmitZoom: target.KeepZoom}
action.Goto = &Goto{Dest: dest}
*currentPage = p.pageIndexes[destination.Page]
}
diff --git a/ofdgo_renderer_font_canvas.go b/ofdgo_renderer_font_canvas.go
index 1eec533..263a626 100644
--- a/ofdgo_renderer_font_canvas.go
+++ b/ofdgo_renderer_font_canvas.go
@@ -117,7 +117,7 @@ func (f canvasFontMetrics) GlyphBounds(glyph uint16, size float64) (Box, error)
return value, nil
}
-// GlyphWarning 返回字形指令栈下溢后保留设计轮廓的提示,原始字体不变
+// GlyphWarning 返回无效字形指令后保留设计轮廓的提示,原始字体不变
// 入参: glyph 字形编号
// 返回: error 恢复原因,无恢复时为空
func (f canvasFontMetrics) GlyphWarning(glyph uint16) error {
diff --git a/ofdgo_u3d.go b/ofdgo_u3d.go
index a7a3826..0c30c2a 100644
--- a/ofdgo_u3d.go
+++ b/ofdgo_u3d.go
@@ -58,6 +58,7 @@ type U3DNode struct {
Parents []U3DParent
Visibility uint32
Shaders [][]string
+ LineShaders [][]string
View *U3DView
}
@@ -110,7 +111,7 @@ type u3dDecoder struct {
palettes map[uint32]map[string]bool
}
-// DecodeU3D 按ECMA-363解码静态基础网格、组、材质、着色器和光源
+// DecodeU3D 按ECMA-363解码静态基础网格、线集、组、材质、着色器和光源
// 暂不支持渐进网格、纹理和动画,遇到未支持内容返回错误而非部分场景
// 入参: ctx 取消上下文, data U3D数据, options 解码限制
// 返回: *U3DModel 独立场景数据, error 格式、能力、预算或取消错误
@@ -174,7 +175,7 @@ func DecodeU3D(ctx context.Context, data []byte, options U3DOptions) (*U3DModel,
if err != nil {
return nil, fmt.Errorf("U3D block at %d: %w", offset, err)
}
- continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c
+ continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c || block.kind == 0xffffff3f
if uint64(offset) < declarationEnd && (uint64(end) > declarationEnd || continuation) || uint64(offset) >= declarationEnd && !continuation {
return nil, fmt.Errorf("invalid U3D declaration boundary at %d", offset)
}
@@ -381,6 +382,16 @@ func (d *u3dDecoder) block(block u3dBlock, owner string, chain int, index uint32
return name, fmt.Errorf("invalid U3D mesh continuation location")
}
err = d.baseMesh(&r, name)
+ case 0xffffff37:
+ if chain >= 0 && (chain != 1 || index != 0) {
+ return name, fmt.Errorf("invalid U3D line set chain")
+ }
+ err = d.lineDeclaration(&r, name)
+ case 0xffffff3f:
+ if chain >= 0 {
+ return name, fmt.Errorf("invalid U3D line continuation location")
+ }
+ err = d.lineContinuation(&r, name)
case 0xffffff45:
if chain != 0 || index == 0 || owner != name || r.u32() != index {
return name, fmt.Errorf("invalid U3D shading modifier chain")
@@ -450,7 +461,7 @@ func (d *u3dDecoder) node(r *u3dValues, kind uint32, name string) error {
return r.err
}
-// shading 读取网格着色列表,保留列表内各着色器的先后次序
+// shading 分别读取网格和线集着色列表,保留列表内各着色器的先后次序
// 入参: r 字段读取器, name 目标节点名
// 返回: error 结构、能力或预算错误
func (d *u3dDecoder) shading(r *u3dValues, name string) error {
@@ -458,8 +469,9 @@ func (d *u3dDecoder) shading(r *u3dValues, name string) error {
if !exists || d.model.Nodes[index].Kind != "Model" {
return fmt.Errorf("missing U3D shading target")
}
- if r.u32() != 1 {
- return fmt.Errorf("unsupported U3D non-mesh shading modifier")
+ flags := r.u32()
+ if flags & ^uint32(3) != 0 {
+ return fmt.Errorf("unsupported U3D shading modifier target")
}
count := r.u32()
if uint64(count) > uint64(len(r.data)-r.pos)/4 {
@@ -487,7 +499,12 @@ func (d *u3dDecoder) shading(r *u3dValues, name string) error {
lists[index][j] = r.text()
}
}
- d.model.Nodes[index].Shaders = lists
+ if flags&1 != 0 {
+ d.model.Nodes[index].Shaders = lists
+ }
+ if flags&2 != 0 {
+ d.model.Nodes[index].LineShaders = lists
+ }
return r.err
}
diff --git a/ofdgo_u3d_bits.go b/ofdgo_u3d_bits.go
index e7454e1..dd52044 100644
--- a/ofdgo_u3d_bits.go
+++ b/ofdgo_u3d_bits.go
@@ -36,6 +36,7 @@ type u3dBits struct {
pos uint64
low, high, code uint32
err error
+ reserve func(uint64, uint64) error
}
// u3dHistogram 保存一个动态压缩上下文的频次及前缀和
@@ -224,7 +225,23 @@ func (r *u3dBits) index(count uint32) uint32 {
// 入参: h 频次上下文
// 返回: uint32 原始整数
func (r *u3dBits) dynamic(h *u3dHistogram) uint32 {
+ return r.dynamicValue(h, false)
+}
+
+// dynamicValue 按字段位宽处理动态上下文的逃逸字面量,分配前检查可选预算
+// 入参: h 频次上下文, small 是否为8位字段
+// 返回: uint32 解码值
+func (r *u3dBits) dynamicValue(h *u3dHistogram, small bool) uint32 {
+ if r.err != nil {
+ return 0
+ }
if h.total == 0 {
+ if r.reserve != nil {
+ if err := r.reserve(1, 32); err != nil {
+ r.err = err
+ return 0
+ }
+ }
h.counts = make([]uint32, 1)
h.tree = make([]uint32, 2)
h.add(0)
@@ -235,17 +252,44 @@ func (r *u3dBits) dynamic(h *u3dHistogram) uint32 {
return 0
}
r.advance(start, h.counts[symbol], h.total)
- h.add(symbol)
+ r.histogramAdd(h, symbol)
+ if r.err != nil {
+ return 0
+ }
if symbol != 0 {
+ if small && symbol > 256 {
+ r.err = fmt.Errorf("invalid U3D compressed byte")
+ }
return symbol - 1
}
- value := r.u32()
+ var value uint32
+ if small {
+ value = uint32(r.byteValue())
+ } else {
+ value = r.u32()
+ }
if value < 0xffff && r.err == nil {
- h.add(value + 1)
+ r.histogramAdd(h, value+1)
}
return value
}
+// histogramAdd 在频次数组扩展前计入解码预算
+// 入参: h 频次上下文, symbol 待更新符号
+func (r *u3dBits) histogramAdd(h *u3dHistogram, symbol uint32) {
+ if r.err != nil {
+ return
+ }
+ if r.reserve != nil && symbol <= 0xffff && int(symbol) >= len(h.counts) {
+ length := min(65536, max(int(symbol)+1, len(h.counts)*2))
+ if err := r.reserve(uint64(length-len(h.counts)), 32); err != nil {
+ r.err = err
+ return
+ }
+ }
+ h.add(symbol)
+}
+
// find 以树状前缀和定位符号,避免按符号数量线性扫描
// 入参: position 累计频次位置
// 返回: uint32 符号, uint32 符号前缀频次
diff --git a/ofdgo_u3d_light.go b/ofdgo_u3d_light.go
index 5845363..e4c429d 100644
--- a/ofdgo_u3d_light.go
+++ b/ofdgo_u3d_light.go
@@ -230,12 +230,17 @@ func (r *u3dRender) shade(position, normal u3dVector, style u3dRenderStyle, diff
if style.shader.Attributes&1 == 0 {
return color, nil
}
+ copy(color[:3], style.emissive[:])
+ if r.mode == U3DRenderShadedIllustration {
+ for c := range 3 {
+ color[c] += max(0, style.diffuse[c]) / 4
+ }
+ }
if r.lighting != nil {
for c := range 3 {
style.diffuse[c] += r.lighting.DiffuseOffset[c]
}
}
- copy(color[:3], style.emissive[:])
eye := (u3dVector{r.camera.ToWorld[12], r.camera.ToWorld[13], r.camera.ToWorld[14]}).sub(position).unit()
if !r.camera.Perspective {
eye = (u3dVector{-r.camera.ToWorld[8], -r.camera.ToWorld[9], -r.camera.ToWorld[10]}).unit()
@@ -300,6 +305,40 @@ func (r *u3dRender) shade(position, normal u3dVector, style u3dRenderStyle, diff
return color, nil
}
+// u3dFragmentColor 对点、线、面的插值颜色应用透明度测试和相机空间雾效
+// 入参: color 待更新的未预乘颜色, position 相机位置, shader 着色器, pass 绘制轮次
+// 返回: bool 是否绘制, error 非有限颜色错误
+func u3dFragmentColor(color *[4]float64, position u3dVector, shader *U3DShader, pass *U3DViewPass) (bool, error) {
+ for _, value := range color {
+ if !finite(value) {
+ return false, fmt.Errorf("invalid U3D fragment color")
+ }
+ }
+ color[3] = min(1, max(0, color[3]))
+ if shader.Attributes&2 != 0 && !u3dAlphaTest(shader.AlphaFunction, color[3], float64(shader.AlphaReference)) {
+ return false, nil
+ }
+ if pass.Attributes&1 != 0 {
+ distance := math.Hypot(math.Hypot(position[0], position[1]), position[2])
+ factor := (float64(pass.FogFar) - distance) / (float64(pass.FogFar) - float64(pass.FogNear))
+ if pass.FogMode != 0 {
+ amount := distance * math.Log(100) / float64(pass.FogFar)
+ if pass.FogMode == 2 {
+ amount *= amount
+ }
+ factor = math.Exp(-amount)
+ }
+ factor = min(1, max(0, factor))
+ for c := range 3 {
+ color[c] = color[c]*factor + float64(pass.FogColor[c])*(1-factor)
+ }
+ }
+ for c := range 3 {
+ color[c] = min(1, max(0, color[c]))
+ }
+ return true, nil
+}
+
// u3dAlphaTest 按ECMA-363枚举比较透明度,不套用图形接口的枚举顺序
// 入参: function 比较函数, value 透明度, reference 参考值
// 返回: bool 是否通过
diff --git a/ofdgo_u3d_line.go b/ofdgo_u3d_line.go
index d114796..ca2213e 100644
--- a/ofdgo_u3d_line.go
+++ b/ofdgo_u3d_line.go
@@ -19,6 +19,168 @@ import (
"math"
)
+// drawLines 绘制作者线集,端点独立着色且不参与面的背向剔除
+// 入参: instance 场景实例, node 模型节点, mesh 属性数组, positions 世界位置, normals 世界法线, pass 绘制轮次, passIndex 轮次索引
+// 返回: error 几何、资源或取消错误
+func (r *u3dRender) drawLines(instance u3dInstance, node *U3DNode, mesh *U3DMesh, positions, normals []u3dVector, pass U3DViewPass, passIndex int) error {
+ if r.depthOnly {
+ return nil
+ }
+ shaded := r.mode.shaded()
+ for index, line := range mesh.Lines {
+ if index%128 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ if uint64(line.Shading) >= uint64(len(mesh.Shadings)) {
+ return fmt.Errorf("invalid U3D render line shading index")
+ }
+ shading := mesh.Shadings[line.Shading]
+ if shading.Attributes > 3 || len(shading.TextureDimensions) > 8 {
+ return fmt.Errorf("invalid U3D render line shading")
+ }
+ var world [2]u3dVector
+ var vertices [2]u3dVertex
+ for i, corner := range line.Corners {
+ if uint64(corner.Position) >= uint64(len(positions)) || shaded && (!mesh.ExcludeNormals && uint64(corner.Normal) >= uint64(len(normals)) || shading.Attributes&1 != 0 && uint64(corner.Diffuse) >= uint64(len(mesh.Diffuse)) || shading.Attributes&2 != 0 && uint64(corner.Specular) >= uint64(len(mesh.Specular))) {
+ return fmt.Errorf("invalid U3D render line corner index")
+ }
+ world[i] = positions[corner.Position]
+ vertices[i].point = r.toCamera.point(world[i])
+ for _, value := range vertices[i].point {
+ if !finite(value) {
+ return fmt.Errorf("invalid U3D line camera position")
+ }
+ }
+ }
+ shaderNames := []string{""}
+ if uint64(line.Shading) < uint64(len(node.LineShaders)) {
+ shaderNames = node.LineShaders[line.Shading]
+ }
+ for _, name := range shaderNames {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: math.MaxUint32}}
+ var err error
+ if shaded {
+ style, err = r.style(name)
+ if err != nil {
+ return err
+ }
+ } else if index, ok := r.shaders[name]; ok {
+ style.shader.RenderPass = r.model.Shaders[index].RenderPass
+ }
+ if style.shader.RenderPass&(1<= 0 || r.opacity >= 0 {
+ style.shader.Blend = 0x606
+ }
+ for i, corner := range line.Corners {
+ vertices[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
+ if !shaded {
+ continue
+ }
+ var normal u3dVector
+ if !mesh.ExcludeNormals {
+ normal = normals[corner.Normal]
+ }
+ var diffuse, specular *[4]float32
+ if shading.Attributes&1 != 0 {
+ diffuse = &mesh.Diffuse[corner.Diffuse]
+ }
+ if shading.Attributes&2 != 0 {
+ specular = &mesh.Specular[corner.Specular]
+ }
+ vertices[i].color, err = r.shade(world[i], normal, style, diffuse, specular)
+ if err != nil {
+ return err
+ }
+ if instance.opacity >= 0 {
+ vertices[i].color[3] = instance.opacity
+ }
+ if r.opacity >= 0 {
+ vertices[i].color[3] *= r.opacity
+ }
+ }
+ if r.mode == U3DRenderVertices || r.mode == U3DRenderShadedVertices {
+ for _, vertex := range vertices {
+ fresh, err := r.uniquePrimitive(vertex, vertex, style.shader)
+ if err != nil {
+ return err
+ }
+ if fresh {
+ if err := r.modePoint(vertex, style.shader, pass); err != nil {
+ return err
+ }
+ }
+ }
+ } else if err := r.modeLine(vertices[0], vertices[1], u3dVector{}, u3dVector{}, style.shader, pass); err != nil {
+ return err
+ }
+ for index, section := range r.sections {
+ if index%256 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ if section.intersection != nil {
+ if err := r.lineIntersection(vertices, index); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ return nil
+}
+
+// lineIntersection 绘制作者线段与剖切面的交点,不绘制共面线段
+// 入参: line 相机空间线段, index 剖切面索引
+// 返回: error 数值、资源或取消错误
+func (r *u3dRender) lineIntersection(line [2]u3dVertex, index int) error {
+ section := r.sections[index]
+ a, b := u3dPlaneDistance(section.plane, line[0].point), u3dPlaneDistance(section.plane, line[1].point)
+ if !finite(a) || !finite(b) {
+ return fmt.Errorf("invalid U3D line intersection distance")
+ }
+ if a == 0 && b == 0 || a < 0 && b < 0 || a > 0 && b > 0 {
+ return nil
+ }
+ point := u3dInterpolate(line[0], line[1], u3dClipFraction(a, b))
+ for i, other := range r.sections {
+ if i%256 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ if i != index {
+ distance := u3dPlaneDistance(other.plane, point.point)
+ if !finite(distance) {
+ return fmt.Errorf("invalid U3D line intersection clip")
+ }
+ if distance < 0 {
+ return nil
+ }
+ }
+ }
+ for i, plane := range r.frustumPlanes() {
+ if i == 1 && r.camera.Far == 0 && !r.camera.ClipFar {
+ continue
+ }
+ distance := u3dPlaneDistance(plane, point.point)
+ if !finite(distance) {
+ return fmt.Errorf("invalid U3D projected intersection")
+ }
+ if distance < 0 {
+ return nil
+ }
+ }
+ return r.rasterSectionLine(point, point, *section.intersection, u3dVector{}, u3dVector{})
+}
+
// frustumPlanes 返回保留正侧的六个相机视锥面
// 返回: [6][4]float64 近、远、左右及上下平面
func (r *u3dRender) frustumPlanes() [6][4]float64 {
@@ -161,6 +323,15 @@ func u3dClipLine(a, b *u3dVertex, plane [4]float64) (bool, error) {
// 入参: a 已裁剪起点, b 已裁剪终点, rgb 交线颜色, normal 原面法线, origin 原面上一点
// 返回: error 数值、资源或取消错误
func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, origin u3dVector) error {
+ a.color = [4]float64{rgb[0], rgb[1], rgb[2], 1}
+ b.color = a.color
+ return r.rasterLine(a, b, normal, origin, U3DShader{Blend: 0x606}, U3DViewPass{})
+}
+
+// rasterLine 绘制单像素线段,颜色与深度按透视插值,面深度偏移防止自身遮挡
+// 入参: a 已裁剪起点, b 已裁剪终点, normal 面法线, origin 面上点, shader 着色器, pass 绘制轮次
+// 返回: error 数值、资源或取消错误
+func (r *u3dRender) rasterLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
cx := r.viewport[0] + r.viewport[2]/2 + r.camera.ShiftX
cy := r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY
planes := [4][4]float64{{r.camera.ScaleX, 0, 0, cx}, {-r.camera.ScaleX, 0, 0, float64(r.output.Rect.Dx()) - cx}, {0, -r.camera.ScaleY, 0, cy}, {0, r.camera.ScaleY, 0, float64(r.output.Rect.Dy()) - cy}}
@@ -193,8 +364,8 @@ func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, or
return fmt.Errorf("invalid U3D line span")
}
count := max(1, int(steps))
- color := [4]float64{rgb[0], rgb[1], rgb[2], 1}
r.order++
+ previousPixel := -1
for i := 0; i <= count; i++ {
if i%1024 == 0 {
if err := r.ctx.Err(); err != nil {
@@ -202,13 +373,21 @@ func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, or
}
}
t := float64(i) / float64(count)
- px, py := math.Floor(x[0]*(1-t)+x[1]*t), math.Floor(y[0]*(1-t)+y[1]*t)
+ px := math.Floor(x[0] + float64(i)*(x[1]-x[0])/float64(count))
+ py := math.Floor(y[0] + float64(i)*(y[1]-y[0])/float64(count))
if px < 0 || py < 0 || px >= float64(r.output.Rect.Dx()) || py >= float64(r.output.Rect.Dy()) {
continue
}
- depth := a.point[2]*(1-t) + b.point[2]*t
+ pixel := int(py)*r.output.Rect.Dx() + int(px)
+ if pixel == previousPixel {
+ continue
+ }
+ previousPixel = pixel
+ depth := a.point[2] + (b.point[2]-a.point[2])*t
+ weight := t
if r.camera.Perspective {
depth = 1 / (q[0]*(1-t) + q[1]*t)
+ weight = t * q[1] * depth
}
ray := u3dVector{(px + .5 - cx) / r.camera.ScaleX, (cy - py - .5) / r.camera.ScaleY, 1}
denominator := normal[2]
@@ -220,9 +399,16 @@ func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, or
depth = sampled
}
depth -= 8 * math.Abs(depth-math.Nextafter(depth, math.Inf(-1)))
- pixel := int(py)*r.output.Rect.Dx() + int(px)
if depth <= r.frame[pixel].depth {
- if err := r.fragment(pixel, depth, color, 0x606); err != nil {
+ vertex := u3dInterpolate(a, b, weight)
+ visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass)
+ if err != nil {
+ return err
+ }
+ if !visible {
+ continue
+ }
+ if err := r.fragment(pixel, depth, vertex.color, shader.Blend); err != nil {
return err
}
}
diff --git a/ofdgo_u3d_lineset.go b/ofdgo_u3d_lineset.go
new file mode 100644
index 0000000..db384ae
--- /dev/null
+++ b/ofdgo_u3d_lineset.go
@@ -0,0 +1,433 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "fmt"
+ "io"
+)
+
+const (
+ u3dLinePositionSign = iota
+ u3dLinePositionX
+ u3dLinePositionY
+ u3dLinePositionZ
+ u3dLineNormalCount
+ u3dLineNormalSign
+ u3dLineNormalX
+ u3dLineNormalY
+ u3dLineNormalZ
+ u3dLineCount
+ u3dLineShading
+ u3dLineNormalIndex
+ u3dLineDiffuseDuplicate
+ u3dLineSpecularDuplicate
+ u3dLineTextureDuplicate
+ u3dLineDiffuseSign
+ u3dLineSpecularSign
+ u3dLineTextureSign
+ u3dLineColorR
+ u3dLineColorG
+ u3dLineColorB
+ u3dLineColorA
+ u3dLineTextureU
+ u3dLineTextureV
+ u3dLineTextureS
+ u3dLineTextureT
+ u3dLineContextCount
+)
+
+// u3dLineState 按顶点累计线段端点属性,预测开销不随顶点度数增长
+type u3dLineState struct {
+ offsets [11]int
+ stride int
+ stats []float64
+}
+
+// u3dLineReader 保存当前续块的独立压缩上下文
+type u3dLineReader struct {
+ values *u3dValues
+ bits *u3dBits
+ hist [u3dLineContextCount]u3dHistogram
+}
+
+// lineDeclaration 读取ECMA-363第9.6.3节的线集声明
+// 入参: r 字段读取器, name 资源名
+// 返回: error 结构、能力或预算错误
+func (d *u3dDecoder) lineDeclaration(r *u3dValues, name string) error {
+ if _, exists := d.meshes[name]; exists {
+ return fmt.Errorf("duplicate U3D model resource %q", name)
+ }
+ if r.u32() != 0 || r.u32() != 0 {
+ return fmt.Errorf("invalid U3D line declaration")
+ }
+ decl := &u3dMeshDeclaration{mesh: U3DMesh{Name: name}, line: new(u3dLineState)}
+ for i := range decl.counts {
+ decl.counts[i] = r.u32()
+ }
+ if err := d.meshShadings(r, decl); err != nil {
+ return err
+ }
+ for i := range decl.mesh.Quality {
+ decl.mesh.Quality[i] = r.u32()
+ }
+ for i := range decl.mesh.InverseQuantization {
+ decl.mesh.InverseQuantization[i] = r.f32()
+ }
+ for range 3 {
+ if r.u32() != 0 {
+ return fmt.Errorf("invalid U3D reserved line parameter")
+ }
+ }
+ if r.u32() != 0 {
+ return fmt.Errorf("unsupported U3D skeletal line set")
+ }
+ if err := r.done(); err != nil {
+ return err
+ }
+ if decl.counts[1] == 0 {
+ for _, count := range decl.counts[:6] {
+ if count != 0 {
+ return fmt.Errorf("invalid U3D empty line set")
+ }
+ }
+ decl.decoded = true
+ }
+ state := decl.line
+ var used [11]bool
+ used[0] = true
+ for _, shading := range decl.mesh.Shadings {
+ used[1] = used[1] || shading.Attributes&1 != 0
+ used[2] = used[2] || shading.Attributes&2 != 0
+ for layer := range shading.TextureDimensions {
+ used[3+layer] = true
+ }
+ }
+ for i, present := range used {
+ state.offsets[i] = -1
+ if present {
+ state.offsets[i] = state.stride
+ state.stride += 5
+ }
+ }
+ c := decl.counts
+ bytes := uint64(c[0])*100 + (uint64(c[1])+uint64(c[2]))*12 + (uint64(c[3])+uint64(c[4])+uint64(c[5]))*16
+ if err := d.reserve(bytes, 1); err != nil {
+ return err
+ }
+ if err := d.reserve(uint64(c[1]), uint64(state.stride)*8); err != nil {
+ return err
+ }
+ m := &decl.mesh
+ m.Lines = make([]U3DLine, 0, int(c[0]))
+ m.Positions, m.Normals = make([][3]float32, 0, int(c[1])), make([][3]float32, 0, int(c[2]))
+ m.Diffuse, m.Specular, m.TextureCoordinates = make([][4]float32, 0, int(c[3])), make([][4]float32, 0, int(c[4])), make([][4]float32, 0, int(c[5]))
+ state.stats = make([]float64, int(c[1])*state.stride)
+ d.meshes[name] = decl
+ d.meshOrder = append(d.meshOrder, name)
+ return nil
+}
+
+// average 取得指定顶点各属性的端点算术平均
+// 入参: position 顶点索引
+// 返回: [11][4]float64 法线、漫反射、镜面及八层纹理预测
+func (s *u3dLineState) average(position uint32) (values [11][4]float64) {
+ for slot, offset := range s.offsets {
+ if offset < 0 {
+ continue
+ }
+ base := int(position)*s.stride + offset
+ count := s.stats[base+4]
+ if count != 0 {
+ for i := range values[slot] {
+ values[slot][i] = s.stats[base+i] / count
+ }
+ }
+ }
+ return values
+}
+
+// accumulate 累计线段端点的独立属性,重复使用的属性按端点分别计数
+// 入参: mesh 共享属性数组, line 已完成的线段
+func (s *u3dLineState) accumulate(mesh *U3DMesh, line U3DLine) {
+ shading := mesh.Shadings[line.Shading]
+ for _, corner := range line.Corners {
+ var values [11][4]float32
+ var used [11]bool
+ normal := mesh.Normals[corner.Normal]
+ values[0] = [4]float32{normal[0], normal[1], normal[2], 0}
+ used[0] = true
+ if shading.Attributes&1 != 0 {
+ values[1], used[1] = mesh.Diffuse[corner.Diffuse], true
+ }
+ if shading.Attributes&2 != 0 {
+ values[2], used[2] = mesh.Specular[corner.Specular], true
+ }
+ for layer := range shading.TextureDimensions {
+ values[3+layer], used[3+layer] = mesh.TextureCoordinates[corner.Texture[layer]], true
+ }
+ for slot, present := range used {
+ if !present {
+ continue
+ }
+ base := int(corner.Position)*s.stride + s.offsets[slot]
+ for i, value := range values[slot] {
+ s.stats[base+i] += float64(value)
+ }
+ s.stats[base+4]++
+ }
+ }
+}
+
+// value 按指定上下文和位宽读取线集字段
+// 入参: context 压缩上下文, small 是否为8位字段
+// 返回: uint32 字段值
+func (r *u3dLineReader) value(context int, small bool) uint32 {
+ if r.bits != nil {
+ return r.bits.dynamicValue(&r.hist[context], small)
+ }
+ if small {
+ return uint32(r.values.u8())
+ }
+ return r.values.u32()
+}
+
+// index 读取静态索引并校验声明范围
+// 入参: count 可用索引数量
+// 返回: uint32 索引, error 读取或范围错误
+func (r *u3dLineReader) index(count uint32) (uint32, error) {
+ if count == 0 {
+ return 0, fmt.Errorf("invalid U3D line position range")
+ }
+ var value uint32
+ if r.bits != nil {
+ value = r.bits.index(count)
+ } else {
+ value = r.values.u32()
+ }
+ if err := r.err(); err != nil {
+ return 0, err
+ }
+ if value >= count {
+ return 0, fmt.Errorf("invalid U3D line position index")
+ }
+ return value, nil
+}
+
+// err 返回当前字段或算术解码错误
+// 返回: error 读取错误
+func (r *u3dLineReader) err() error {
+ if r.bits != nil {
+ return r.bits.err
+ }
+ return r.values.err
+}
+
+// reconstruct 按符号位和独立分量上下文执行逆量化
+// 入参: prediction 预测值, scale 逆量化系数, signContext 符号上下文, componentContext 分量首上下文, dimensions 分量数
+// 返回: [4]float32 重建属性, error 数值或读取错误
+func (r *u3dLineReader) reconstruct(prediction [4]float64, scale float32, signContext, componentContext, dimensions int) (values [4]float32, err error) {
+ signs := r.value(signContext, true)
+ if signs >= 1< 0 {
+ return uint32(len(*pool) - 1), nil
+ }
+ if flag != 0 || uint64(len(*pool)) >= uint64(limit) {
+ return 0, fmt.Errorf("invalid U3D line attribute pool")
+ }
+ value, err := r.reconstruct(prediction, scale, sign, component, 4)
+ if err != nil {
+ return 0, err
+ }
+ index := uint32(len(*pool))
+ *pool = append(*pool, value)
+ return index, nil
+}
+
+// lineContinuation 按位置分段恢复线集,预测状态跨续块保存
+// 入参: r 字段读取器, name 资源名
+// 返回: error 结构、数值、预算或取消错误
+func (d *u3dDecoder) lineContinuation(r *u3dValues, name string) error {
+ decl := d.meshes[name]
+ if decl == nil || decl.line == nil || decl.decoded || r.u32() != 0 {
+ return fmt.Errorf("invalid U3D line continuation reference")
+ }
+ start, end := r.u32(), r.u32()
+ mesh := &decl.mesh
+ if r.err != nil {
+ return r.err
+ }
+ if uint64(start) != uint64(len(mesh.Positions)) || end <= start || end > decl.counts[1] {
+ return fmt.Errorf("invalid U3D line resolution range")
+ }
+ reader := &u3dLineReader{values: r}
+ var charged uint64
+ if !d.plain {
+ reader.bits = newU3DBits(r.data[r.pos:])
+ reader.bits.reserve = func(count, size uint64) error {
+ if err := d.reserve(count, size); err != nil {
+ return err
+ }
+ charged += count * size
+ return nil
+ }
+ defer func() { d.remaining += charged }()
+ }
+ for position := start; position < end; position++ {
+ if err := d.ctx.Err(); err != nil {
+ return err
+ }
+ split, err := reader.index(max(1, position))
+ if err != nil {
+ return err
+ }
+ var prediction [4]float64
+ if position > 0 {
+ for i, value := range mesh.Positions[split] {
+ prediction[i] = float64(value)
+ }
+ }
+ attributes := decl.line.average(split)
+ value, err := reader.reconstruct(prediction, mesh.InverseQuantization[0], u3dLinePositionSign, u3dLinePositionX, 3)
+ if err != nil {
+ return err
+ }
+ mesh.Positions = append(mesh.Positions, [3]float32{value[0], value[1], value[2]})
+ normalStart := uint32(len(mesh.Normals))
+ normals := reader.value(u3dLineNormalCount, false)
+ if normals > decl.counts[2]-normalStart {
+ return fmt.Errorf("invalid U3D line normal count")
+ }
+ for i := uint32(0); i < normals; i++ {
+ if i%256 == 0 {
+ if err := d.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ value, err := reader.reconstruct(attributes[0], mesh.InverseQuantization[1], u3dLineNormalSign, u3dLineNormalX, 3)
+ if err != nil {
+ return err
+ }
+ mesh.Normals = append(mesh.Normals, [3]float32{value[0], value[1], value[2]})
+ }
+ lines := reader.value(u3dLineCount, false)
+ if lines > decl.counts[0]-uint32(len(mesh.Lines)) {
+ return fmt.Errorf("invalid U3D line count")
+ }
+ for i := uint32(0); i < lines; i++ {
+ if i%256 == 0 {
+ if err := d.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ line := U3DLine{Shading: reader.value(u3dLineShading, false)}
+ if uint64(line.Shading) >= uint64(len(mesh.Shadings)) {
+ return fmt.Errorf("invalid U3D line shading index")
+ }
+ first, err := reader.index(position)
+ if err != nil {
+ return err
+ }
+ line.Corners[0].Position, line.Corners[1].Position = first, position
+ shading := mesh.Shadings[line.Shading]
+ for j := range line.Corners {
+ corner := &line.Corners[j]
+ normal := reader.value(u3dLineNormalIndex, false)
+ if normal >= normals {
+ return fmt.Errorf("invalid U3D line local normal index")
+ }
+ corner.Normal = normalStart + normal
+ if shading.Attributes&1 != 0 {
+ corner.Diffuse, err = reader.attribute(&mesh.Diffuse, decl.counts[3], attributes[1], mesh.InverseQuantization[3], u3dLineDiffuseDuplicate, u3dLineDiffuseSign, u3dLineColorR)
+ if err != nil {
+ return err
+ }
+ }
+ if shading.Attributes&2 != 0 {
+ corner.Specular, err = reader.attribute(&mesh.Specular, decl.counts[4], attributes[2], mesh.InverseQuantization[4], u3dLineSpecularDuplicate, u3dLineSpecularSign, u3dLineColorR)
+ if err != nil {
+ return err
+ }
+ }
+ for layer := range shading.TextureDimensions {
+ corner.Texture[layer], err = reader.attribute(&mesh.TextureCoordinates, decl.counts[5], attributes[3+layer], mesh.InverseQuantization[2], u3dLineTextureDuplicate, u3dLineTextureSign, u3dLineTextureU)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ if err := reader.err(); err != nil {
+ return err
+ }
+ mesh.Lines = append(mesh.Lines, line)
+ decl.line.accumulate(mesh, line)
+ }
+ if err := reader.err(); err != nil {
+ return err
+ }
+ }
+ if b := reader.bits; b != nil {
+ if b.pos > uint64(len(b.data))*8 {
+ return io.ErrUnexpectedEOF
+ }
+ if b.u32() != 0 {
+ return fmt.Errorf("invalid U3D line compression flush")
+ }
+ if b.err != nil {
+ return b.err
+ }
+ if (b.pos-16+7)/8 != uint64(len(b.data)) {
+ return fmt.Errorf("unexpected U3D line compressed data")
+ }
+ r.pos = len(r.data)
+ }
+ if err := r.done(); err != nil {
+ return err
+ }
+ if end == decl.counts[1] {
+ for i, count := range []int{len(mesh.Lines), len(mesh.Positions), len(mesh.Normals), len(mesh.Diffuse), len(mesh.Specular), len(mesh.TextureCoordinates)} {
+ if uint64(count) != uint64(decl.counts[i]) {
+ return fmt.Errorf("U3D line set does not match declaration")
+ }
+ }
+ decl.decoded = true
+ d.remaining += uint64(len(decl.line.stats)) * 8
+ decl.line.stats = nil
+ }
+ return nil
+}
diff --git a/ofdgo_u3d_mesh.go b/ofdgo_u3d_mesh.go
index 428ea9c..fa9822e 100644
--- a/ofdgo_u3d_mesh.go
+++ b/ofdgo_u3d_mesh.go
@@ -19,7 +19,7 @@ import (
"io"
)
-// U3DMesh 保存完整基础网格的独立数组,索引对应原始作者网格
+// U3DMesh 保存静态三角面和线段的共享属性数组,索引对应原始作者几何
type U3DMesh struct {
Name string
ExcludeNormals bool
@@ -27,12 +27,13 @@ type U3DMesh struct {
Diffuse, Specular, TextureCoordinates [][4]float32
Shadings []U3DShading
Faces []U3DFace
+ Lines []U3DLine
Quality [3]uint32
InverseQuantization [5]float32
NormalCrease, NormalUpdate, NormalTolerance float32
}
-// U3DShading 描述各面使用的颜色及纹理坐标,纹理维数依层排列
+// U3DShading 描述图元使用的颜色及纹理坐标,纹理维数依层排列
type U3DShading struct {
Attributes uint32
TextureDimensions []uint32
@@ -45,17 +46,35 @@ type U3DFace struct {
Corners [3]U3DCorner
}
+// U3DLine 保存着色描述及线段两端的独立属性索引
+type U3DLine struct {
+ Shading uint32
+ Corners [2]U3DCorner
+}
+
// U3DCorner 保存各属性索引,未启用的属性不可用于访问数组
type U3DCorner struct {
Position, Normal, Diffuse, Specular uint32
Texture [8]uint32
}
-// u3dMeshDeclaration 保存基础网格声明及续块接收状态
+// u3dMeshDeclaration 保存基础网格或线集声明及续块接收状态
type u3dMeshDeclaration struct {
mesh U3DMesh
counts [7]uint32
decoded bool
+ line *u3dLineState
+}
+
+// primitive 访问三角面或线段的着色编号和角点,不复制属性数组
+// 入参: index 图元下标,三角面在前,线段在后
+// 返回: uint32 着色编号, []U3DCorner 只读角点
+func (m *U3DMesh) primitive(index int) (uint32, []U3DCorner) {
+ if index < len(m.Faces) {
+ return m.Faces[index].Shading, m.Faces[index].Corners[:]
+ }
+ line := &m.Lines[index-len(m.Faces)]
+ return line.Shading, line.Corners[:]
}
// meshDeclaration 读取完整基础网格声明,不将渐进网格当作完整模型
@@ -73,6 +92,46 @@ func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error {
for index := range decl.counts {
decl.counts[index] = r.u32()
}
+ if err := d.meshShadings(r, decl); err != nil {
+ return err
+ }
+ minimum, maximum := r.u32(), r.u32()
+ for index := range decl.mesh.Quality {
+ decl.mesh.Quality[index] = r.u32()
+ }
+ for index := range decl.mesh.InverseQuantization {
+ decl.mesh.InverseQuantization[index] = r.f32()
+ }
+ decl.mesh.NormalCrease, decl.mesh.NormalUpdate, decl.mesh.NormalTolerance = r.f32(), r.f32(), r.f32()
+ bones := r.u32()
+ if r.err != nil {
+ return r.err
+ }
+ if minimum > maximum || maximum != decl.counts[1] {
+ return fmt.Errorf("invalid U3D mesh resolution")
+ }
+ if minimum != maximum || bones != 0 {
+ return fmt.Errorf("unsupported U3D progressive or skeletal mesh")
+ }
+ if minimum == 0 {
+ for _, count := range decl.counts[:6] {
+ if count != 0 {
+ return fmt.Errorf("invalid U3D empty mesh")
+ }
+ }
+ }
+ d.meshes[name] = decl
+ d.meshOrder = append(d.meshOrder, name)
+ return nil
+}
+
+// meshShadings 读取三角面与线集共用的着色描述
+// 入参: r 字段读取器, decl 资源声明
+// 返回: error 结构或预算错误
+func (d *u3dDecoder) meshShadings(r *u3dValues, decl *u3dMeshDeclaration) error {
+ if r.err != nil {
+ return r.err
+ }
count := decl.counts[6]
if uint64(count) > uint64(len(r.data)-r.pos)/12 {
return io.ErrUnexpectedEOF
@@ -104,34 +163,7 @@ func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error {
}
shading.OriginalID = r.u32()
}
- minimum, maximum := r.u32(), r.u32()
- for index := range decl.mesh.Quality {
- decl.mesh.Quality[index] = r.u32()
- }
- for index := range decl.mesh.InverseQuantization {
- decl.mesh.InverseQuantization[index] = r.f32()
- }
- decl.mesh.NormalCrease, decl.mesh.NormalUpdate, decl.mesh.NormalTolerance = r.f32(), r.f32(), r.f32()
- bones := r.u32()
- if r.err != nil {
- return r.err
- }
- if minimum > maximum || maximum != decl.counts[1] {
- return fmt.Errorf("invalid U3D mesh resolution")
- }
- if minimum != maximum || bones != 0 {
- return fmt.Errorf("unsupported U3D progressive or skeletal mesh")
- }
- if minimum == 0 {
- for _, count := range decl.counts[:6] {
- if count != 0 {
- return fmt.Errorf("invalid U3D empty mesh")
- }
- }
- }
- d.meshes[name] = decl
- d.meshOrder = append(d.meshOrder, name)
- return nil
+ return r.err
}
// baseMesh 读取基础顶点与压缩面索引,保留各角点的独立属性
@@ -139,7 +171,7 @@ func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error {
// 返回: error 结构、能力、预算或取消错误
func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error {
decl := d.meshes[name]
- if decl == nil || decl.decoded || decl.counts[1] == 0 || r.u32() != 0 {
+ if decl == nil || decl.line != nil || decl.decoded || decl.counts[1] == 0 || r.u32() != 0 {
return fmt.Errorf("invalid U3D base mesh reference")
}
var counts [6]uint32
diff --git a/ofdgo_u3d_mode.go b/ofdgo_u3d_mode.go
new file mode 100644
index 0000000..03362a8
--- /dev/null
+++ b/ofdgo_u3d_mode.go
@@ -0,0 +1,347 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "fmt"
+ "math"
+)
+
+// U3DRenderMode 定义静态几何的呈现方式,边线和顶点使用单像素覆盖
+type U3DRenderMode uint8
+
+const (
+ U3DRenderSolid U3DRenderMode = iota // 实体
+ U3DRenderSolidWireframe // 实体线框
+ U3DRenderWireframe // 线框
+ U3DRenderShadedWireframe // 着色线框
+ U3DRenderHiddenWireframe // 隐藏线框
+ U3DRenderVertices // 顶点
+ U3DRenderShadedVertices // 着色顶点
+ U3DRenderBoundingBox // 包围盒线框
+ U3DRenderBoundingBoxFaces // 包围盒面
+ U3DRenderBoundingBoxOutline // 包围盒面及线框
+ U3DRenderIllustration // 插图
+ U3DRenderSolidOutline // 实体轮廓
+ U3DRenderShadedIllustration // 着色插图
+)
+
+// u3dPrimitiveKey 区分共享点线的颜色及混合状态,避免透明边点重复叠加
+type u3dPrimitiveKey struct {
+ a, b u3dVertex
+ blend uint32
+ function uint32
+ reference float32
+}
+
+// uniquePrimitive 在当前网格内去重着色边点,缓存按条目计入绘制预算
+// 入参: a 起点或顶点, b 终点或相同顶点, shader 着色器
+// 返回: bool 是否首次绘制, error 预算或取消错误
+func (r *u3dRender) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, error) {
+ if r.primitives == nil {
+ return true, nil
+ }
+ left := [7]float64{a.point[0], a.point[1], a.point[2], a.color[0], a.color[1], a.color[2], a.color[3]}
+ right := [7]float64{b.point[0], b.point[1], b.point[2], b.color[0], b.color[1], b.color[2], b.color[3]}
+ for i := range left {
+ if left[i] != right[i] {
+ if left[i] > right[i] {
+ a, b = b, a
+ }
+ break
+ }
+ }
+ key := u3dPrimitiveKey{a: a, b: b, blend: shader.Blend}
+ if shader.Attributes&2 != 0 {
+ key.function, key.reference = shader.AlphaFunction, shader.AlphaReference
+ }
+ if _, ok := r.primitives[key]; ok {
+ return false, nil
+ }
+ if err := r.reserve(1, 512); err != nil {
+ return false, err
+ }
+ r.primitives[key] = struct{}{}
+ return true, nil
+}
+
+// shaded 判断绘制模式是否使用材质、法线和灯光
+// 返回: bool 是否着色
+func (mode U3DRenderMode) shaded() bool {
+ return mode == U3DRenderSolid || mode == U3DRenderSolidWireframe || mode == U3DRenderShadedWireframe || mode == U3DRenderShadedVertices || mode == U3DRenderSolidOutline || mode == U3DRenderShadedIllustration
+}
+
+// silhouette 判断绘制模式是否仅描绘轮廓边
+// 返回: bool 是否使用轮廓边
+func (mode U3DRenderMode) silhouette() bool {
+ return mode >= U3DRenderIllustration && mode <= U3DRenderShadedIllustration
+}
+
+// modeTriangle 根据当前模式绘制原三角面、原始边或顶点,不为裁剪边界生成网格边
+// 入参: triangle 相机空间顶点, shader 着色器, pass 绘制轮次
+// 返回: error 数值、资源或取消错误
+func (r *u3dRender) modeTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error {
+ if r.depthOnly || r.mode == U3DRenderSolid || r.mode.silhouette() {
+ return r.sectionTriangle(triangle, shader, pass)
+ }
+ if r.mode == U3DRenderSolidWireframe {
+ if err := r.sectionTriangle(triangle, shader, pass); err != nil {
+ return err
+ }
+ }
+ if r.mode != U3DRenderShadedWireframe && r.mode != U3DRenderShadedVertices {
+ for i := range triangle {
+ triangle[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
+ }
+ shader = U3DShader{Blend: 0x606}
+ }
+ normal := u3dTriangleNormal([3]u3dVector{triangle[0].point, triangle[1].point, triangle[2].point})
+ for i, vertex := range triangle {
+ var err error
+ if r.mode == U3DRenderVertices || r.mode == U3DRenderShadedVertices {
+ fresh, uniqueErr := r.uniquePrimitive(vertex, vertex, shader)
+ if uniqueErr != nil {
+ return uniqueErr
+ }
+ if !fresh {
+ continue
+ }
+ err = r.modePoint(vertex, shader, pass)
+ } else {
+ fresh, uniqueErr := r.uniquePrimitive(vertex, triangle[(i+1)%3], shader)
+ if uniqueErr != nil {
+ return uniqueErr
+ }
+ if !fresh {
+ continue
+ }
+ err = r.modeLine(vertex, triangle[(i+1)%3], normal, triangle[0].point, shader, pass)
+ }
+ if err != nil {
+ return err
+ }
+ }
+ if r.mode != U3DRenderSolidWireframe {
+ for i, section := range r.sections {
+ if section.intersection != nil {
+ if err := r.sectionIntersection(triangle, i); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ return nil
+}
+
+// modeLine 对原始网格边应用剖切与视锥裁剪,再按像素深度绘制
+// 入参: a 起点, b 终点, normal 面法线, origin 面上点, shader 着色器, pass 绘制轮次
+// 返回: error 数值、资源或取消错误
+func (r *u3dRender) modeLine(a, b u3dVertex, normal, origin u3dVector, shader U3DShader, pass U3DViewPass) error {
+ for i, section := range r.sections {
+ if i%256 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ keep, err := u3dClipLine(&a, &b, section.plane)
+ if err != nil || !keep {
+ return err
+ }
+ }
+ for i, plane := range r.frustumPlanes() {
+ if i == 1 && r.camera.Far == 0 && !r.camera.ClipFar {
+ continue
+ }
+ keep, err := u3dClipLine(&a, &b, plane)
+ if err != nil || !keep {
+ return err
+ }
+ }
+ return r.rasterLine(a, b, normal, origin, shader, pass)
+}
+
+// modePoint 绘制原始顶点,不在裁剪边界补充顶点
+// 入参: vertex 顶点, shader 着色器, pass 绘制轮次
+// 返回: error 数值、资源或取消错误
+func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPass) error {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ for i, section := range r.sections {
+ if i%256 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ distance := u3dPlaneDistance(section.plane, vertex.point)
+ if !finite(distance) {
+ return fmt.Errorf("invalid U3D point clip distance")
+ }
+ if distance < 0 {
+ return nil
+ }
+ }
+ for i, plane := range r.frustumPlanes() {
+ if i == 1 && r.camera.Far == 0 && !r.camera.ClipFar {
+ continue
+ }
+ distance := u3dPlaneDistance(plane, vertex.point)
+ if !finite(distance) {
+ return fmt.Errorf("invalid U3D point clip distance")
+ }
+ if distance < 0 {
+ return nil
+ }
+ }
+ q := 1.0
+ if r.camera.Perspective {
+ q = 1 / vertex.point[2]
+ }
+ x := math.Floor(r.viewport[0] + r.viewport[2]/2 + r.camera.ShiftX + vertex.point[0]*r.camera.ScaleX*q)
+ y := math.Floor(r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY - vertex.point[1]*r.camera.ScaleY*q)
+ if !finite(x) || !finite(y) {
+ return fmt.Errorf("invalid U3D projected vertex")
+ }
+ if x < 0 || y < 0 || x >= float64(r.output.Rect.Dx()) || y >= float64(r.output.Rect.Dy()) {
+ return nil
+ }
+ pixel := int(y)*r.output.Rect.Dx() + int(x)
+ if vertex.point[2] > r.frame[pixel].depth {
+ return nil
+ }
+ visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass)
+ if err != nil || !visible {
+ return err
+ }
+ r.order++
+ return r.fragment(pixel, vertex.point[2], vertex.color, shader.Blend)
+}
+
+// drawBounds 在模型局部空间计算包围盒,再应用实例变换,不以世界坐标轴重建包围盒
+// 入参: instance 场景实例, node 模型节点, mesh 网格, pass 绘制轮次, passIndex 轮次索引
+// 返回: error 几何、数值、预算或取消错误
+func (r *u3dRender) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error {
+ minimum := u3dVector{math.Inf(1), math.Inf(1), math.Inf(1)}
+ maximum := u3dVector{math.Inf(-1), math.Inf(-1), math.Inf(-1)}
+ visible := false
+ for i := range len(mesh.Faces) + len(mesh.Lines) {
+ shading, corners := mesh.primitive(i)
+ shaders := node.Shaders
+ if i >= len(mesh.Faces) {
+ shaders = node.LineShaders
+ }
+ if i%256 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ if uint64(shading) >= uint64(len(shaders)) {
+ visible = true
+ } else if !visible {
+ for _, name := range shaders[shading] {
+ index, ok := r.shaders[name]
+ if !ok || r.model.Shaders[index].RenderPass&(1<= uint64(len(mesh.Positions)) {
+ return fmt.Errorf("invalid U3D bounds position index")
+ }
+ for j, value := range mesh.Positions[corner.Position] {
+ v := float64(value)
+ if !finite(v) {
+ return fmt.Errorf("invalid U3D bounds position")
+ }
+ minimum[j], maximum[j] = min(minimum[j], v), max(maximum[j], v)
+ }
+ }
+ }
+ if !visible || math.IsInf(minimum[0], 1) {
+ return nil
+ }
+ opacity := 1.0
+ if instance.opacity >= 0 {
+ opacity = instance.opacity
+ }
+ if r.opacity >= 0 {
+ opacity *= r.opacity
+ }
+ if r.mode == U3DRenderBoundingBox {
+ opacity = 0
+ }
+ var vertices [8]u3dVertex
+ for i := range vertices {
+ point := minimum
+ for j := range 3 {
+ if i&(1< b[c] {
+ a, b, pa, pb = b, a, pb, pa
+ }
+ break
+ }
+ }
+ key := [2]u3dVector{a, b}
+ index, exists := outline.index[key]
+ if !exists {
+ if err := r.reserve(1, 1024); err != nil {
+ return err
+ }
+ index = len(outline.edges)
+ outline.edges = append(outline.edges, u3dOutlineEdge{a: pa, b: pb})
+ outline.index[key] = index
+ }
+ edge := &outline.edges[index]
+ if visible && !edge.visible {
+ edge.visibleNormal = cameraNormal
+ }
+ edge.visible = edge.visible || visible
+ if !front {
+ edge.back++
+ continue
+ }
+ edge.frontVisible = edge.frontVisible || visible
+ if edge.front == 0 {
+ edge.reference = normal
+ edge.tangent = b.sub(a).unit().cross(normal).unit()
+ edge.minimumNormal, edge.maximumNormal = cameraNormal, cameraNormal
+ } else {
+ angle := math.Atan2(normal.dot(edge.tangent), normal.dot(edge.reference))
+ if !finite(angle) {
+ return fmt.Errorf("invalid U3D silhouette angle")
+ }
+ if angle < edge.minimum {
+ edge.minimum, edge.minimumNormal = angle, cameraNormal
+ }
+ if angle > edge.maximum {
+ edge.maximum, edge.maximumNormal = angle, cameraNormal
+ }
+ }
+ edge.front++
+ }
+ return nil
+}
+
+// drawOutline 绘制外缘、正背面交界和达到夹角的共享边,沿用像素深度去除遮挡
+// 入参: outline 共享边索引, pass 绘制轮次
+// 返回: error 数值、资源或取消错误
+func (r *u3dRender) drawOutline(outline *u3dOutline, pass U3DViewPass) error {
+ shader := U3DShader{Blend: 0x606}
+ color := [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
+ for i, edge := range outline.edges {
+ if i%128 == 0 {
+ if err := r.ctx.Err(); err != nil {
+ return err
+ }
+ }
+ if !edge.visible {
+ continue
+ }
+ angle := (edge.maximum - edge.minimum) * 180 / math.Pi
+ if edge.front+edge.back != 1 && !(edge.front > 0 && edge.back > 0) && !(edge.front > 1 && angle >= math.Nextafter(r.creaseAngle, math.Inf(-1))) {
+ continue
+ }
+ a, b := u3dVertex{point: edge.a, color: color}, u3dVertex{point: edge.b, color: color}
+ normals := [2]u3dVector{edge.visibleNormal, edge.visibleNormal}
+ if edge.frontVisible {
+ normals = [2]u3dVector{edge.minimumNormal, edge.maximumNormal}
+ }
+ for i, normal := range normals {
+ if i == 1 && normal == normals[0] {
+ continue
+ }
+ if err := r.modeLine(a, b, normal, edge.a, shader, pass); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/ofdgo_u3d_render.go b/ofdgo_u3d_render.go
index c72b1e7..b7fcc62 100644
--- a/ofdgo_u3d_render.go
+++ b/ofdgo_u3d_render.go
@@ -35,6 +35,9 @@ type U3DCamera struct {
// U3DRenderOptions 控制静态绘制,指定Camera时使用整个输出视口
// Camera为空时使用View指定的节点或首个视图节点,Instance指定其父路径实例
// Lighting为空时沿用模型灯光,非空时替换全部模型灯光
+// Opacity为空时沿用着色器混合,非空时叠加面片、着色边点及包围盒轮廓的透明度
+// Mode控制绘制方式,AuxiliaryColor用于固定颜色的边点,FaceColor用于包围盒面及插图面,空值分别使用黑色和背景色
+// CreaseAngle为轮廓夹角,单位为度,空值使用45;着色插图以漫反射色的1/4补充自发光
// Nodes按名称覆盖节点,重复名称使用最后一项,每次绘制均从原场景开始
// Background为空时使用白色;缓存与实例上限为0时分别使用256MiB和65536,不含输入及运行时开销
type U3DRenderOptions struct {
@@ -44,6 +47,11 @@ type U3DRenderOptions struct {
Camera *U3DCamera
Background *[3]float64
Lighting *U3DLighting
+ Opacity *float64
+ Mode U3DRenderMode
+ AuxiliaryColor *[3]float64
+ FaceColor *[3]float64
+ CreaseAngle *float64
Nodes []U3DNodeState
Sections []U3DCrossSection
MaxMemoryBytes int64
@@ -83,6 +91,12 @@ type u3dRender struct {
meshes, shaders, materials, lightIndex map[string]int
lights []u3dRenderLight
lighting *U3DLighting
+ opacity float64
+ mode U3DRenderMode
+ creaseAngle float64
+ auxiliaryColor, faceColor [3]float64
+ depthOnly bool
+ primitives map[u3dPrimitiveKey]struct{}
states map[string]U3DNodeState
sections []u3dSection
sectionScratch [2][]u3dVertex
@@ -92,7 +106,7 @@ type u3dRender struct {
order uint64
}
-// RenderU3D 绘制静态无纹理三角面,返回不透明图像,不执行脚本或动画
+// RenderU3D 绘制静态无纹理网格、线集及其顶点或包围盒,返回不透明图像,不执行脚本或动画
// 采用逐顶点光照及逐像素深度混合,缺失资源回退为空网格、灰色材质或无光源
// 入参: ctx 取消上下文, model 场景, options 视图、输出与资源限制
// 返回: *image.RGBA 图像, error 参数、能力、预算或取消错误
@@ -119,7 +133,23 @@ func newU3DRender(ctx context.Context, model *U3DModel, options U3DRenderOptions
if limit == 0 {
limit = u3dDefaultBytes
}
- r := &u3dRender{ctx: ctx, model: model, remaining: uint64(limit)}
+ r := &u3dRender{ctx: ctx, model: model, remaining: uint64(limit), opacity: -1, mode: options.Mode, creaseAngle: 45}
+ if options.Mode > U3DRenderShadedIllustration {
+ return nil, fmt.Errorf("unsupported U3D render mode")
+ }
+ if options.CreaseAngle != nil {
+ if !finite(*options.CreaseAngle) {
+ return nil, fmt.Errorf("invalid U3D crease angle")
+ }
+ r.creaseAngle = *options.CreaseAngle
+ }
+ if options.Opacity != nil {
+ value := *options.Opacity
+ if !finite(value) || value < 0 || value > 1 {
+ return nil, fmt.Errorf("invalid U3D render opacity")
+ }
+ r.opacity = value
+ }
if uint64(options.Width) > uint64(^uint(0)>>1)/uint64(options.Height) {
return nil, fmt.Errorf("invalid U3D render dimensions")
}
@@ -169,6 +199,20 @@ func (r *u3dRender) render(options U3DRenderOptions, passes []U3DViewPass) (*ima
}
background[i] = min(1, max(0, v))
}
+ r.faceColor = background
+ for _, item := range []struct {
+ source *[3]float64
+ target *[3]float64
+ }{{options.AuxiliaryColor, &r.auxiliaryColor}, {options.FaceColor, &r.faceColor}} {
+ if item.source != nil {
+ for i, value := range *item.source {
+ if !finite(value) || value < 0 || value > 1 {
+ return nil, fmt.Errorf("invalid U3D mode color")
+ }
+ item.target[i] = value
+ }
+ }
+ }
r.frame = make([]u3dPixel, options.Width*options.Height)
r.output = image.NewRGBA(image.Rect(0, 0, options.Width, options.Height))
for i := range r.frame {
@@ -412,9 +456,33 @@ func (r *u3dRender) renderPass(pass U3DViewPass, passIndex int, last bool) error
r.frame[i].head = -1
}
r.fragments = 0
- if err := r.collectLights(); err != nil {
+ if r.mode.shaded() {
+ if err := r.collectLights(); err != nil {
+ return err
+ }
+ }
+ if r.mode == U3DRenderHiddenWireframe {
+ r.depthOnly = true
+ if err := r.drawScene(pass, passIndex); err != nil {
+ return err
+ }
+ r.depthOnly = false
+ }
+ if err := r.drawScene(pass, passIndex); err != nil {
return err
}
+ if last {
+ if err := r.drawSectionPlanes(); err != nil {
+ return err
+ }
+ }
+ return r.resolveFragments()
+}
+
+// drawScene 绘制当前轮次中的可见模型,不重置深度及片元缓存
+// 入参: pass 绘制轮次, passIndex 轮次索引
+// 返回: error 场景、资源或取消错误
+func (r *u3dRender) drawScene(pass U3DViewPass, passIndex int) error {
for i, instance := range r.scene {
if err := r.ctx.Err(); err != nil {
return err
@@ -434,10 +502,5 @@ func (r *u3dRender) renderPass(pass U3DViewPass, passIndex int, last bool) error
return err
}
}
- if last {
- if err := r.drawSectionPlanes(); err != nil {
- return err
- }
- }
- return r.resolveFragments()
+ return nil
}
diff --git a/ofdgo_u3d_section.go b/ofdgo_u3d_section.go
index 63831a1..a26ff46 100644
--- a/ofdgo_u3d_section.go
+++ b/ofdgo_u3d_section.go
@@ -145,13 +145,14 @@ func (r *u3dRender) sceneDiagonal() (float64, error) {
continue
}
mesh := &r.model.Meshes[index]
- for i, face := range mesh.Faces {
+ for i := range len(mesh.Faces) + len(mesh.Lines) {
+ _, corners := mesh.primitive(i)
if i%256 == 0 {
if err := r.ctx.Err(); err != nil {
return 0, err
}
}
- for _, corner := range face.Corners {
+ for _, corner := range corners {
if uint64(corner.Position) >= uint64(len(mesh.Positions)) {
return 0, fmt.Errorf("invalid U3D position index")
}
@@ -235,6 +236,9 @@ func (r *u3dRender) sectionTriangle(triangle [3]u3dVertex, shader U3DShader, pas
return err
}
}
+ if r.depthOnly {
+ return nil
+ }
for i, section := range r.sections {
if section.intersection != nil {
if err := r.sectionIntersection(triangle, i); err != nil {
diff --git a/ofdgo_u3d_triangle.go b/ofdgo_u3d_triangle.go
index b9f4d15..36e3533 100644
--- a/ofdgo_u3d_triangle.go
+++ b/ofdgo_u3d_triangle.go
@@ -30,13 +30,42 @@ type u3dVertex struct {
// 入参: instance 场景实例, node 模型节点, mesh 网格, pass 绘制轮次, passIndex 轮次索引
// 返回: error 几何、资源或取消错误
func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error {
- count := uint64(len(mesh.Positions)) + uint64(len(mesh.Normals))
+ if r.mode >= U3DRenderBoundingBox && r.mode <= U3DRenderBoundingBoxOutline {
+ return r.drawBounds(instance, node, mesh, pass, passIndex)
+ }
+ if r.opacity == 0 && len(r.sections) == 0 && r.mode == U3DRenderSolid {
+ return r.ctx.Err()
+ }
+ shaded := r.mode.shaded() && !r.depthOnly
+ var outline *u3dOutline
+ if r.mode.silhouette() {
+ if err := r.reserve(1, 1024); err != nil {
+ return err
+ }
+ outline = &u3dOutline{index: make(map[[2]u3dVector]int)}
+ defer func() { r.remaining += 1024 + uint64(len(outline.edges))*1024 }()
+ }
+ if r.mode == U3DRenderShadedWireframe || r.mode == U3DRenderShadedVertices {
+ if err := r.reserve(1, 1024); err != nil {
+ return err
+ }
+ r.primitives = make(map[u3dPrimitiveKey]struct{})
+ defer func() {
+ r.remaining += 1024 + uint64(len(r.primitives))*512
+ r.primitives = nil
+ }()
+ }
+ normalCount := 0
+ if shaded {
+ normalCount = len(mesh.Normals)
+ }
+ count := uint64(len(mesh.Positions)) + uint64(normalCount)
if err := r.reserve(count, 24); err != nil {
return err
}
defer func() { r.remaining += count * 24 }()
positions := make([]u3dVector, len(mesh.Positions))
- normals := make([]u3dVector, len(mesh.Normals))
+ normals := make([]u3dVector, normalCount)
inverse, invertible := instance.world.inverse()
for i, p := range mesh.Positions {
if i%1024 == 0 {
@@ -51,7 +80,7 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
}
}
}
- for i, n := range mesh.Normals {
+ for i, n := range mesh.Normals[:normalCount] {
if i%1024 == 0 {
if err := r.ctx.Err(); err != nil {
return err
@@ -66,6 +95,9 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
normals[i] = inverse.normal(u3dVector{float64(n[0]), float64(n[1]), float64(n[2])})
}
}
+ if err := r.drawLines(instance, node, mesh, positions, normals, pass, passIndex); err != nil {
+ return err
+ }
for index, face := range mesh.Faces {
if index%128 == 0 {
if err := r.ctx.Err(); err != nil {
@@ -82,7 +114,7 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
var world [3]u3dVector
var vertices [3]u3dVertex
for i, corner := range face.Corners {
- if uint64(corner.Position) >= uint64(len(positions)) || !mesh.ExcludeNormals && uint64(corner.Normal) >= uint64(len(normals)) || shading.Attributes&1 != 0 && uint64(corner.Diffuse) >= uint64(len(mesh.Diffuse)) || shading.Attributes&2 != 0 && uint64(corner.Specular) >= uint64(len(mesh.Specular)) {
+ if uint64(corner.Position) >= uint64(len(positions)) || shaded && (!mesh.ExcludeNormals && uint64(corner.Normal) >= uint64(len(normals)) || shading.Attributes&1 != 0 && uint64(corner.Diffuse) >= uint64(len(mesh.Diffuse)) || shading.Attributes&2 != 0 && uint64(corner.Specular) >= uint64(len(mesh.Specular))) {
return fmt.Errorf("invalid U3D render corner index")
}
world[i] = positions[corner.Position]
@@ -94,7 +126,7 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
}
}
var faceNormal u3dVector
- if mesh.ExcludeNormals || !invertible {
+ if shaded && (mesh.ExcludeNormals || !invertible) {
faceNormal = u3dTriangleNormal(world)
}
p0, p1, p2 := vertices[0].point, vertices[1].point, vertices[2].point
@@ -107,30 +139,59 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
}
front := facing < 0
visibility := instance.modelVisibility(node)
- if facing == 0 || front && visibility&1 == 0 || !front && visibility&2 == 0 {
+ surfaceVisible := facing != 0 && (front && visibility&1 != 0 || !front && visibility&2 != 0)
+ if r.mode == U3DRenderHiddenWireframe && !front || (r.mode == U3DRenderSolid || r.mode == U3DRenderSolidWireframe || r.depthOnly) && (facing == 0 || front && visibility&1 == 0 || !front && visibility&2 == 0) {
continue
}
shaderNames := []string{""}
if uint64(face.Shading) < uint64(len(node.Shaders)) {
shaderNames = node.Shaders[face.Shading]
}
+ if outline != nil {
+ if err := r.outlineFace(outline, world, vertices, front, surfaceVisible, shaderNames, passIndex); err != nil {
+ return err
+ }
+ if !surfaceVisible {
+ continue
+ }
+ }
for shaderIndex, name := range shaderNames {
if shaderIndex%128 == 0 {
if err := r.ctx.Err(); err != nil {
return err
}
}
- style, err := r.style(name)
- if err != nil {
- return err
+ style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: math.MaxUint32}}
+ var err error
+ if shaded {
+ style, err = r.style(name)
+ if err != nil {
+ return err
+ }
+ } else if index, ok := r.shaders[name]; ok {
+ style.shader.RenderPass = r.model.Shaders[index].RenderPass
}
if style.shader.RenderPass&(1<= 0 {
+ if instance.opacity >= 0 || r.opacity >= 0 {
style.shader.Blend = 0x606
}
for i, corner := range face.Corners {
+ if !shaded {
+ vertices[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1}
+ if r.mode == U3DRenderIllustration {
+ alpha := 1.0
+ if instance.opacity >= 0 {
+ alpha = instance.opacity
+ }
+ if r.opacity >= 0 {
+ alpha *= r.opacity
+ }
+ vertices[i].color = [4]float64{r.faceColor[0], r.faceColor[1], r.faceColor[2], alpha}
+ }
+ continue
+ }
normal := faceNormal
if !mesh.ExcludeNormals && invertible {
normal = normals[corner.Normal]
@@ -154,13 +215,19 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
if instance.opacity >= 0 {
vertices[i].color[3] = instance.opacity
}
+ if r.opacity >= 0 {
+ vertices[i].color[3] *= r.opacity
+ }
}
r.order++
- if err := r.sectionTriangle(vertices, style.shader, pass); err != nil {
+ if err := r.modeTriangle(vertices, style.shader, pass); err != nil {
return err
}
}
}
+ if outline != nil {
+ return r.drawOutline(outline, pass)
+ }
return nil
}
@@ -168,6 +235,9 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh,
// 入参: triangle 三角面, shader 着色器, pass 绘制轮次
// 返回: error 数值、资源或取消错误
func (r *u3dRender) clipTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error {
+ if shader.Blend == 0x606 && triangle[0].color[3] == 0 && triangle[1].color[3] == 0 && triangle[2].color[3] == 0 {
+ return r.ctx.Err()
+ }
var storage [2][12]u3dVertex
copy(storage[0][:], triangle[:])
size, current := 3, 0
@@ -306,39 +376,28 @@ func (r *u3dRender) rasterTriangle(triangle [3]u3dVertex, shader U3DShader, pass
if depth > r.frame[pixel].depth {
continue
}
+ if r.depthOnly {
+ r.frame[pixel].depth = depth
+ continue
+ }
var color [4]float64
for c := range color {
color[c] = triangle[0].color[c] + w1*(triangle[1].color[c]-triangle[0].color[c]) + w2*(triangle[2].color[c]-triangle[0].color[c])
- if !finite(color[c]) {
- return fmt.Errorf("invalid U3D interpolated color")
- }
- }
- color[3] = min(1, max(0, color[3]))
- if shader.Attributes&2 != 0 && !u3dAlphaTest(shader.AlphaFunction, color[3], float64(shader.AlphaReference)) {
- continue
}
+ position := u3dVector{}
if pass.Attributes&1 != 0 {
- position := u3dVector{(px - r.viewport[0] - r.viewport[2]/2 - r.camera.ShiftX) / r.camera.ScaleX, (r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY - py) / r.camera.ScaleY, depth}
+ position = u3dVector{(px - r.viewport[0] - r.viewport[2]/2 - r.camera.ShiftX) / r.camera.ScaleX, (r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY - py) / r.camera.ScaleY, depth}
if r.camera.Perspective {
position[0] *= depth
position[1] *= depth
}
- distance := math.Hypot(math.Hypot(position[0], position[1]), position[2])
- factor := (float64(pass.FogFar) - distance) / (float64(pass.FogFar) - float64(pass.FogNear))
- if pass.FogMode != 0 {
- amount := distance * math.Log(100) / float64(pass.FogFar)
- if pass.FogMode == 2 {
- amount *= amount
- }
- factor = math.Exp(-amount)
- }
- factor = min(1, max(0, factor))
- for c := range 3 {
- color[c] = color[c]*factor + float64(pass.FogColor[c])*(1-factor)
- }
}
- for c := range 3 {
- color[c] = min(1, max(0, color[c]))
+ visible, err := u3dFragmentColor(&color, position, &shader, &pass)
+ if err != nil {
+ return err
+ }
+ if !visible {
+ continue
}
if err := r.fragment(pixel, depth, color, shader.Blend); err != nil {
return err
diff --git a/ofdgo_writer.go b/ofdgo_writer.go
index c6f7006..542a783 100644
--- a/ofdgo_writer.go
+++ b/ofdgo_writer.go
@@ -448,6 +448,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
promoted := make(map[string]bool)
files := make(map[string]bool)
if e.source != nil {
+ collectActionReferences(e.source.document.Actions, used)
for _, name := range e.annotationFiles() {
data, changed := e.source.reader.files[name]
if !changed {