From c8a163a077c0fb5620435e0ba4882f33302506cc Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 8 Oct 2026 01:06:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B2=E6=9F=93=E5=8E=8B=E7=BC=A9):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AD=97=E4=BD=93=E4=B8=89=E7=BB=B4=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=AF=BC=E8=88=AA=E5=8F=8A=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_compression_parallel.go | 140 +++++++++++ ofdgo_compression_resources.go | 39 ++- ofdgo_editor_action.go | 134 ++++++++++ ofdgo_editor_package.go | 19 +- ofdgo_font_cmap.go | 67 +++-- ofdgo_font_glyf.go | 87 ++++++- ofdgo_font_hint.go | 20 +- ofdgo_font_loca.go | 299 +++++++++++++++++++++++ ofdgo_pdf.go | 37 ++- ofdgo_pdf_3d.go | 92 +++---- ofdgo_pdf_3d_markup.go | 82 +++++++ ofdgo_pdf_3d_mode.go | 74 ++++++ ofdgo_pdf_action_lifecycle.go | 143 +++++++++++ ofdgo_pdf_annot.go | 48 +++- ofdgo_pdf_annot_trigger.go | 75 ++++++ ofdgo_pdf_clip.go | 49 ++++ ofdgo_pdf_composite_draw.go | 68 ++++-- ofdgo_pdf_destination.go | 124 ++++++++++ ofdgo_pdf_font.go | 136 +++++++---- ofdgo_pdf_link.go | 117 ++++----- ofdgo_renderer_font_canvas.go | 2 +- ofdgo_u3d.go | 29 ++- ofdgo_u3d_bits.go | 50 +++- ofdgo_u3d_light.go | 41 +++- ofdgo_u3d_line.go | 196 ++++++++++++++- ofdgo_u3d_lineset.go | 433 +++++++++++++++++++++++++++++++++ ofdgo_u3d_mesh.go | 96 +++++--- ofdgo_u3d_mode.go | 347 ++++++++++++++++++++++++++ ofdgo_u3d_outline.go | 151 ++++++++++++ ofdgo_u3d_render.go | 81 +++++- ofdgo_u3d_section.go | 8 +- ofdgo_u3d_triangle.go | 127 +++++++--- ofdgo_writer.go | 1 + 35 files changed, 3045 insertions(+), 373 deletions(-) create mode 100644 ofdgo_compression_parallel.go create mode 100644 ofdgo_editor_action.go create mode 100644 ofdgo_font_loca.go create mode 100644 ofdgo_pdf_3d_markup.go create mode 100644 ofdgo_pdf_3d_mode.go create mode 100644 ofdgo_pdf_action_lifecycle.go create mode 100644 ofdgo_pdf_annot_trigger.go create mode 100644 ofdgo_pdf_destination.go create mode 100644 ofdgo_u3d_lineset.go create mode 100644 ofdgo_u3d_mode.go create mode 100644 ofdgo_u3d_outline.go 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 {