签名
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index a7e268e..0e70e4c 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -2696,6 +2696,7 @@ audio.page-media {
.doc-font-panel,
.attachment-panel,
.signature-panel,
+.structure-panel,
.security-panel {
margin-top: 18px;
padding-top: 12px;
@@ -2999,6 +3000,43 @@ audio.page-media {
overflow-wrap: anywhere;
}
+.structure-list,
+.structure-content {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.structure-node {
+ min-width: 0;
+ font-size: 12px;
+}
+
+.structure-node > summary {
+ cursor: pointer;
+ color: var(--accent-strong);
+ overflow-wrap: anywhere;
+}
+
+.structure-content {
+ margin-top: 6px;
+}
+
+.structure-content > .structure-node {
+ padding-left: 10px;
+}
+
+.structure-content .info-label {
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+
+.structure-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
.info-line.ok .info-value {
color: var(--accent-strong);
}
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 2d12ef2..ab5b837 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -537,6 +537,9 @@ const el = {
attachmentCancel: document.querySelector("#attachmentCancel"),
attachmentSubmit: document.querySelector("#attachmentSubmit"),
signaturePanel: document.querySelector("#signaturePanel"),
+ structurePanel: document.querySelector("#structurePanel"),
+ structureList: document.querySelector("#structureList"),
+ structureSummary: document.querySelector("#structureSummary"),
signatureSummary: document.querySelector("#signatureSummary"),
signatureList: document.querySelector("#signatureList"),
annotationNote: document.querySelector("#annotationNote"),
@@ -8147,6 +8150,7 @@ function renderMeta(keepDetails = false) {
el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0);
renderMetaContent(el.attachmentList, [doc.attachments, doc.attachmentError, state.editing], renderAttachments);
renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures);
+ renderMetaContent(el.structureList, [doc.structures, doc.structureError, doc.documentIndex, doc.documentVersion, state.openSeq, state.editorInfo?.revision], renderStructures);
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
refreshEditorFonts();
updateLocalFontButton();
@@ -8223,6 +8227,87 @@ function renderAttachments() {
}
}
+function renderStructures() {
+ const structures = state.doc?.structures || [];
+ el.structurePanel.hidden = !structures.length && !state.doc?.structureError;
+ el.structureSummary.textContent = structures.length;
+ el.structureList.replaceChildren();
+ if (state.doc?.structureError) appendInfoLine(el.structureList, "错误", state.doc.structureError, "fail");
+ for (const [index, structure] of structures.entries()) {
+ if (structure.root) el.structureList.append(structureNode(structure.root, index, []));
+ if (structure.error) appendInfoLine(el.structureList, "错误", structure.error, "fail");
+ }
+}
+
+function structureNode(node, index, path) {
+ const details = document.createElement("details");
+ details.className = "structure-node";
+ const summary = document.createElement("summary");
+ summary.textContent = node.name;
+ details.append(summary);
+ let loaded = false;
+ details.addEventListener("toggle", async () => {
+ if (!details.open || loaded) return;
+ loaded = true;
+ const content = document.createElement("div");
+ content.className = "structure-content";
+ details.append(content);
+ for (const attr of node.attributes || []) appendInfoLine(content, attr.name, attr.value);
+ if (node.text && !node.references?.length) appendInfoLine(content, "原文", node.text);
+ const children = node.children || [];
+ const references = node.references?.length || children.some(child => child.references?.length);
+ for (const [i, child] of children.entries()) {
+ if (!child.references?.length) content.append(structureNode(child, index, [...path, i]));
+ }
+ if (!references) return;
+ const pending = document.createElement("div");
+ pending.className = "attachment-detail";
+ pending.textContent = "读取中";
+ pending.setAttribute("role", "status");
+ content.append(pending);
+ const sequence = state.openSeq;
+ try {
+ const targets = await callWASM("ofdgoStructureTargets", index, path);
+ if (sequence !== state.openSeq || !details.isConnected) return;
+ pending.remove();
+ appendInfoLine(content, "原文", targets.map(target => target.text || "").join(""));
+ const pages = new Map();
+ for (const target of targets) {
+ if (target.error) { appendInfoLine(content, "引用错误", `${target.pageId} · ${target.id}:${target.error}`, "fail"); continue; }
+ const b = target.bounds;
+ if (!(b.W > 0 && b.H > 0)) continue;
+ const region = pages.get(target.page);
+ if (region) {
+ const right = Math.max(region.x + region.width, b.X + b.W), bottom = Math.max(region.y + region.height, b.Y + b.H);
+ region.x = Math.min(region.x, b.X); region.y = Math.min(region.y, b.Y);
+ region.width = right - region.x; region.height = bottom - region.y;
+ } else pages.set(target.page, { page: target.page, x: b.X, y: b.Y, width: b.W, height: b.H });
+ }
+ if (pages.size) {
+ const line = document.createElement("div");
+ line.className = "info-line";
+ const label = document.createElement("span");
+ label.className = "info-label"; label.textContent = "位置";
+ const links = document.createElement("span"); links.className = "info-value structure-links";
+ for (const region of pages.values()) {
+ const button = document.createElement("button");
+ button.type = "button"; button.className = "signature-stamp-link";
+ button.textContent = `第${region.page}页`; button.title = "定位原文";
+ button.addEventListener("click", () => focusPageRegion(region, node.name));
+ links.append(button);
+ }
+ line.append(label, links); content.append(line);
+ }
+ } catch (err) {
+ pending.textContent = "读取失败";
+ appendInfoLine(content, "错误", err.message, "fail");
+ loaded = false;
+ details.addEventListener("toggle", () => { if (!details.open) content.remove(); }, { once: true });
+ }
+ });
+ return details;
+}
+
function renderSignatures() {
const signatures = state.doc?.signatures || [];
const showPanel = Boolean(state.doc?.signatureError || signatures.length);
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index 80a0998..0937d76 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -207,6 +207,7 @@ func RunWASM() {
registerCallback("ofdgoVerifySignatures", verifySignatures)
registerCallback("ofdgoRenderPage", renderPage)
registerCallback("ofdgoOutlineLinks", outlineLinks)
+ registerCallback("ofdgoStructureTargets", structureTargets)
registerCallback("ofdgoOpenLinks", openLinks)
registerCallback("ofdgoRenderBackends", renderBackends)
registerCallback("ofdgoSVGFontData", svgFontData)
@@ -922,6 +923,19 @@ func outlineLinks(args []js.Value) (any, error) {
return linkResults(links)
}
+// structureTargets 查询当前文档标引节点的原文与定位范围
+// 入参: args 标引清单索引和节点路径
+// 返回: any 标引目标, error 参数或解析错误
+func structureTargets(args []js.Value) (any, error) {
+ if currentSession == nil {
+ return nil, fmt.Errorf("ofd document is not opened")
+ }
+ if len(args) != 2 {
+ return nil, fmt.Errorf("missing structure path")
+ }
+ return currentSession.Renderer.StructureTargets(args[0].Int(), indexesFromJS(args[1]))
+}
+
// linkResults 将库层动作目标转换为浏览器数据
// 入参: source 动作目标
// 返回: []any 浏览器动作数据, error 附件读取错误
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index 6af1f02..72a6711 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -97,6 +97,8 @@ type DocumentInfo struct {
Signatures []SignatureInfo `json:"signatures"`
Pages []PageInfo `json:"pages"`
Outlines []OutlineInfo `json:"outlines,omitempty"`
+ Structures []ofdgo.Structure `json:"structures,omitempty"`
+ StructureError string `json:"structureError,omitempty"`
DetailsPending bool `json:"detailsPending,omitempty"`
}
@@ -122,13 +124,15 @@ type DocumentEntry struct {
// DocumentDetails 不含页面数组的文档补充信息
type DocumentDetails struct {
- FontCount int `json:"fontCount"`
- SignatureCount int `json:"signatureCount"`
- SignatureError string `json:"signatureError,omitempty"`
- AttachmentError string `json:"attachmentError,omitempty"`
- Attachments []AttachmentInfo `json:"attachments,omitempty"`
- Fonts []FontInfo `json:"fonts"`
- Signatures []SignatureInfo `json:"signatures"`
+ Structures []ofdgo.Structure `json:"structures,omitempty"`
+ StructureError string `json:"structureError,omitempty"`
+ FontCount int `json:"fontCount"`
+ SignatureCount int `json:"signatureCount"`
+ SignatureError string `json:"signatureError,omitempty"`
+ AttachmentError string `json:"attachmentError,omitempty"`
+ Attachments []AttachmentInfo `json:"attachments,omitempty"`
+ Fonts []FontInfo `json:"fonts"`
+ Signatures []SignatureInfo `json:"signatures"`
}
// OutlineInfo 目录节点信息
@@ -513,10 +517,15 @@ func (s *Session) Summary() DocumentInfo {
return info
}
-// Details 获取无需逐页扫描的附件、签名及字体匹配结果
+// Details 获取无需逐页扫描的标引、附件、签名及字体匹配结果
// 返回: DocumentDetails 文档补充信息
func (s *Session) Details() DocumentDetails {
info := DocumentDetails{}
+ if structures, err := s.Reader.Structure(); err == nil {
+ info.Structures = structures
+ } else {
+ info.StructureError = err.Error()
+ }
if attachments, err := s.Reader.Attachments(); err == nil {
for _, attachment := range attachments {
if attachment.Visible || s.editing {
@@ -551,6 +560,8 @@ func (s *Session) Details() DocumentDetails {
func (s *Session) Info() DocumentInfo {
info := s.Summary()
details := s.Details()
+ info.Structures = details.Structures
+ info.StructureError = details.StructureError
info.Attachments = details.Attachments
info.AttachmentError = details.AttachmentError
info.Signatures = details.Signatures
diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go
index 1fb0d28..25078bf 100644
--- a/ofdgo_editor_import.go
+++ b/ofdgo_editor_import.go
@@ -49,6 +49,7 @@ type editorPageImport struct {
evidence map[string]string
resourceXML []byte
pageParts []byte
+ structureXML []byte
documentXML []byte
documentRoot *editorXML
docRefs map[string]map[string]*editorXML
@@ -360,6 +361,10 @@ func (e *Editor) importPages(ctx context.Context, source *Reader, indexes []int,
return nil, err
}
beforeOutlines, outlines := e.outlines, e.outlines
+ m.structureXML, err = m.structures()
+ if err != nil {
+ return nil, err
+ }
if options.Outlines {
imported, err := m.outlines()
if err != nil {
@@ -1711,6 +1716,31 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
}
}
}
+ if len(m.structureXML) != 0 {
+ root, err = parseEditorXMLContext(m.ctx, data)
+ if err != nil {
+ return nil, err
+ }
+ if node := root.child("CustomTags"); node != nil && len(node.children) != 0 {
+ entries, err := editorXMLGenerated(m.structureXML, node.name.Space)
+ if err != nil {
+ return nil, err
+ }
+ content := append(bytes.Clone(data[node.open:node.close]), entries...)
+ data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)})
+ } else {
+ loc, err := mergeImportIndex(m.ctx, base.reader, files, base.document.CustomTags.Path, path.Join(m.directory, "CustomTags.xml"), "CustomTags", m.structureXML)
+ if err != nil {
+ return nil, err
+ }
+ if base.document.CustomTags.Path == "" {
+ data, err = editorXMLSetText(data, root, [][2]string{{"CustomTags", loc}})
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
files[name] = data
if len(signatures) != 0 {
if base.reader.OFD.DocBody[base.reader.documentIndex].versioned {
diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go
index dc2d52d..892522b 100644
--- a/ofdgo_editor_package.go
+++ b/ofdgo_editor_package.go
@@ -272,6 +272,9 @@ func (e *Editor) sourcePartsPrepared(progress editorProgress, generated map[stri
return nil, err
}
}
+ if err := e.pruneStructureReferences(parts); err != nil {
+ return nil, err
+ }
for name, data := range maps.Clone(parts) {
if file, ok := reader.packageFile(name); ok {
actual := cleanPackagePath(file.Name)
diff --git a/ofdgo_editor_structure.go b/ofdgo_editor_structure.go
new file mode 100644
index 0000000..b56a49e
--- /dev/null
+++ b/ofdgo_editor_structure.go
@@ -0,0 +1,248 @@
+// 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"
+ "context"
+ "encoding/xml"
+ "fmt"
+ "path"
+ "strings"
+)
+
+// pruneStructureReferences 清理本次编辑删除的公文图元引用,未知标引和原有悬空引用保持原样
+// 改动进入输出快照,由既有文档及版本隔离流程维护共享文件
+// 入参: parts 输出覆盖条目
+// 返回: error 读取、解析或取消错误
+func (e *Editor) pruneStructureReferences(parts map[string][]byte) error {
+ if e.source.document.CustomTags.Path == "" && len(e.source.document.CustomTags.CustomTag) == 0 {
+ return nil
+ }
+ ctx := context.Background()
+ if e.output != nil {
+ ctx = e.output.ctx
+ }
+ pages := make(map[string]bool, len(e.pages))
+ for _, page := range e.pages {
+ pages[editorResourceID(page.ID)] = true
+ }
+ removedPages := make(map[string]bool)
+ removedObjects := make(map[string]map[string]bool)
+ for id, source := range e.source.pages {
+ key := editorResourceID(id)
+ if !pages[key] {
+ removedPages[key] = true
+ continue
+ }
+ data, changed := parts[e.source.reader.ResPath(source.ref.BaseLoc)]
+ if !changed || source.original == nil {
+ continue
+ }
+ root, err := parseEditorXMLContext(ctx, data)
+ if err != nil {
+ return err
+ }
+ remaining := make(map[string]bool)
+ var scan func(*editorXML)
+ scan = func(node *editorXML) {
+ if classifyOFDNamespace(node.name.Space) == ofdXMLUnknown {
+ return
+ }
+ if id := editorResourceID(node.attr("ID")); id != "" {
+ remaining[id] = true
+ }
+ for _, child := range node.children {
+ scan(child)
+ }
+ }
+ scan(root)
+ for id := range source.nodes {
+ id = editorResourceID(id)
+ if id != "" && !remaining[id] {
+ if removedObjects[key] == nil {
+ removedObjects[key] = make(map[string]bool)
+ }
+ removedObjects[key][id] = true
+ }
+ }
+ }
+ if len(removedPages) == 0 && len(removedObjects) == 0 {
+ return nil
+ }
+ tags, err := e.source.reader.CustomTags()
+ if err != nil {
+ return err
+ }
+ for _, tag := range tags {
+ if !structureOfficial(tag, tag.NameSpace) {
+ continue
+ }
+ name := e.source.reader.ResPath(tag.FileLoc)
+ data, ok := parts[name]
+ if !ok {
+ data, err = e.source.reader.readFileViewContext(ctx, name)
+ if err != nil {
+ return err
+ }
+ }
+ updated, err := rewriteStructureReferences(ctx, data, tag, func(page string, ids []string) (string, []string) {
+ key := editorResourceID(page)
+ if removedPages[key] {
+ return page, nil
+ }
+ kept := make([]string, 0, len(ids))
+ for _, id := range ids {
+ if !removedObjects[key][editorResourceID(id)] {
+ kept = append(kept, id)
+ }
+ }
+ return page, kept
+ })
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(data, updated) {
+ parts[name] = updated
+ }
+ }
+ return nil
+}
+
+// rewriteStructureReferences 仅改写已识别公文命名空间的叶子引用,保留其他XML字节
+// 入参: ctx 取消上下文, data 原始标引, tag 标引入口, rewrite 引用映射,空标识列表表示删除引用
+// 返回: []byte 修改后的标引, error 解析或取消错误
+func rewriteStructureReferences(ctx context.Context, data []byte, tag CustomTag, rewrite func(string, []string) (string, []string)) ([]byte, error) {
+ root, err := parseEditorXMLContext(ctx, data)
+ if err != nil {
+ return nil, err
+ }
+ if !structureOfficial(tag, root.name.Space) {
+ return data, nil
+ }
+ var patches []editorXMLPatch
+ count := 0
+ var walk func(*editorXML, int) error
+ walk = func(node *editorXML, depth int) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ count++
+ if depth > 128 || count > 100000 {
+ return fmt.Errorf("custom tag structure limit exceeded")
+ }
+ if node.name.Space != root.name.Space {
+ return nil
+ }
+ if node.name.Local == "ObjectRef" && len(node.children) == 0 {
+ value := strings.TrimSpace(editorImportText(data, node))
+ ids := structureObjectIDs(value)
+ if len(ids) == 0 {
+ return nil
+ }
+ page, next := rewrite(node.attr("PageRef"), ids)
+ if len(next) == 0 {
+ patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
+ return nil
+ }
+ if strings.Join(next, ",") != strings.Join(ids, ",") {
+ var text bytes.Buffer
+ _ = xml.EscapeText(&text, []byte(strings.Join(next, ",")))
+ patches = append(patches, editorXMLContent(data, node, text.Bytes()))
+ }
+ if page != node.attr("PageRef") {
+ patch, err := editorXMLAttributePatch(data, node, "PageRef", page)
+ if err != nil {
+ return err
+ }
+ patches = append(patches, patch)
+ }
+ return nil
+ }
+ for _, child := range node.children {
+ if err := walk(child, depth+1); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ if err := walk(root, 1); err != nil {
+ return nil, err
+ }
+ if len(patches) == 0 {
+ return data, nil
+ }
+ return editorPatchXML(data, patches), nil
+}
+
+// structures 迁移所选页面的公文标引,按已迁移图元映射引用,不猜测其他行业的引用规则
+// 返回: []byte 标引索引条目, error 读取、解析或取消错误
+func (m *editorPageImport) structures() ([]byte, error) {
+ tags, err := m.reader.CustomTags()
+ if err != nil {
+ return nil, err
+ }
+ var entries []byte
+ for i, tag := range tags {
+ if !structureOfficial(tag, tag.NameSpace) {
+ continue
+ }
+ data, err := m.readFile(m.reader.ResPath(tag.FileLoc))
+ if err != nil {
+ return nil, err
+ }
+ count := 0
+ updated, err := rewriteStructureReferences(m.ctx, data, tag, func(page string, ids []string) (string, []string) {
+ key := editorImportKey(page)
+ if !m.pages[key] {
+ return page, nil
+ }
+ var mapped []string
+ for _, id := range ids {
+ if value := m.ids[editorImportKey(id)]; value != "" {
+ mapped = append(mapped, value)
+ }
+ }
+ count += len(mapped)
+ return m.ids[key], mapped
+ })
+ if err != nil {
+ return nil, err
+ }
+ if count == 0 {
+ continue
+ }
+ name := packageAvailableName(m.target, m.files, path.Join(m.directory, "Tags", fmt.Sprintf("Tag_%d.xml", i)))
+ m.files[name] = updated
+ var content []byte
+ if tag.SchemaLoc != "" {
+ schema, err := m.copyData(m.reader.ResPath(tag.SchemaLoc), path.Join(m.directory, "Tags", path.Base(tag.SchemaLoc)), false)
+ if err != nil {
+ return nil, err
+ }
+ content = append(content, editorXMLText("SchemaLoc", "/"+strings.TrimPrefix(schema, "/"))...)
+ }
+ content = append(content, editorXMLText("FileLoc", "/"+name)...)
+ var attrs ofdAttrs
+ attrs.add("TypeID", tag.TypeID)
+ attrs.add("NameSpace", tag.NameSpace)
+ entry, err := editorXMLContainer("CustomTag", attrs, content)
+ if err != nil {
+ return nil, err
+ }
+ entries = append(entries, entry...)
+ }
+ return entries, nil
+}
diff --git a/ofdgo_structure.go b/ofdgo_structure.go
new file mode 100644
index 0000000..769668f
--- /dev/null
+++ b/ofdgo_structure.go
@@ -0,0 +1,277 @@
+// 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"
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// Structure 保存自定义标引入口及结构树,单个文件解析失败时通过Error报告,不影响其他标引
+type Structure struct {
+ TypeID string `json:"typeId,omitempty"`
+ Namespace string `json:"namespace,omitempty"`
+ FileLoc string `json:"fileLoc"`
+ Root *StructureNode `json:"root,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// StructureNode 保存标引节点的名称、属性、直接文本和有序子节点,不改变原始XML
+// References仅解释公文标引中的ObjectRef;未知扩展保留结构,不推定其引用语义
+type StructureNode struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace,omitempty"`
+ Text string `json:"text,omitempty"`
+ Attributes []StructureAttribute `json:"attributes,omitempty"`
+ Children []StructureNode `json:"children,omitempty"`
+ References []StructureReference `json:"references,omitempty"`
+}
+
+// StructureAttribute 保存展开命名空间后的标引属性,不包含命名空间声明
+type StructureAttribute struct {
+ Name string `json:"name"`
+ Namespace string `json:"namespace,omitempty"`
+ Value string `json:"value"`
+}
+
+// StructureReference 记录同一页上按标引顺序排列的图元标识,不将页面标识当作页码
+type StructureReference struct {
+ PageID string `json:"pageId"`
+ ObjectIDs []string `json:"objectIds"`
+}
+
+// StructureTarget 保存单个标引图元的原文和可见范围,Page从1开始,Bounds单位为毫米
+// Error说明引用无法定位的原因;空Bounds表示图元存在但没有可见范围
+type StructureTarget struct {
+ Page int `json:"page"`
+ PageID string `json:"pageId"`
+ ID string `json:"id"`
+ Text string `json:"text,omitempty"`
+ Bounds Box `json:"bounds"`
+ Error string `json:"error,omitempty"`
+}
+
+// Structure 读取当前文档及版本的自定义标引,不扫描页面或访问外部Schema
+// 返回: []Structure 标引结构, error 清单读取错误
+func (r *Reader) Structure() ([]Structure, error) {
+ return r.StructureContext(context.Background())
+}
+
+// StructureContext 读取标引并响应取消,每次返回独立结构,未知命名空间不影响正文阅读
+// 入参: ctx 取消上下文
+// 返回: []Structure 标引结构, error 清单读取或取消错误
+func (r *Reader) StructureContext(ctx context.Context) ([]Structure, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ tags, err := r.CustomTags()
+ if err != nil {
+ return nil, err
+ }
+ result := make([]Structure, len(tags))
+ for i, tag := range tags {
+ item := &result[i]
+ *item = Structure{TypeID: tag.TypeID, Namespace: tag.NameSpace, FileLoc: tag.FileLoc}
+ data, err := r.readFileViewContext(ctx, r.ResPath(tag.FileLoc))
+ if err == nil {
+ item.Root, err = parseStructure(ctx, data, tag)
+ }
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ if err != nil {
+ item.Error = err.Error()
+ }
+ }
+ return result, nil
+}
+
+// parseStructure 解析有界XML结构,按GB/T33476.3的ObjectRef规则识别公文引用
+// 入参: ctx 取消上下文, data 标引XML, tag 标引入口
+// 返回: *StructureNode 根节点, error 解析、限额或取消错误
+func parseStructure(ctx context.Context, data []byte, tag CustomTag) (*StructureNode, error) {
+ d := xml.NewDecoder(bytes.NewReader(data))
+ var root *StructureNode
+ var stack []*StructureNode
+ count := 0
+ for {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ token, err := d.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ switch token := token.(type) {
+ case xml.StartElement:
+ count++
+ if len(stack) >= 128 || count > 100000 {
+ return nil, fmt.Errorf("custom tag structure limit exceeded")
+ }
+ node := StructureNode{Name: token.Name.Local, Namespace: token.Name.Space}
+ for _, attr := range token.Attr {
+ if attr.Name.Space != "xmlns" && !(attr.Name.Space == "" && attr.Name.Local == "xmlns") {
+ node.Attributes = append(node.Attributes, StructureAttribute{Name: attr.Name.Local, Namespace: attr.Name.Space, Value: attr.Value})
+ }
+ }
+ if len(stack) == 0 {
+ if root != nil {
+ return nil, fmt.Errorf("multiple custom tag roots")
+ }
+ root = &node
+ stack = append(stack, root)
+ } else {
+ parent := stack[len(stack)-1]
+ parent.Children = append(parent.Children, node)
+ stack = append(stack, &parent.Children[len(parent.Children)-1])
+ }
+ case xml.CharData:
+ if len(stack) > 0 {
+ stack[len(stack)-1].Text += string(token)
+ } else if strings.TrimSpace(string(token)) != "" {
+ return nil, fmt.Errorf("text outside custom tag root")
+ }
+ case xml.EndElement:
+ node := stack[len(stack)-1]
+ node.Text = strings.TrimSpace(node.Text)
+ official := structureOfficial(tag, root.Namespace)
+ for _, parent := range stack {
+ official = official && parent.Namespace == root.Namespace
+ }
+ if official && node.Name == "ObjectRef" && len(node.Children) == 0 {
+ var page string
+ for _, attr := range node.Attributes {
+ if attr.Name == "PageRef" && attr.Namespace == "" {
+ page = attr.Value
+ }
+ }
+ ids := structureObjectIDs(node.Text)
+ if editorResourceID(page) == "" || len(ids) == 0 {
+ return nil, fmt.Errorf("custom tag ObjectRef requires PageRef and object identifiers")
+ }
+ for _, id := range ids {
+ if editorResourceID(id) == "" {
+ return nil, fmt.Errorf("invalid custom tag object identifier: %s", id)
+ }
+ }
+ node.References = []StructureReference{{PageID: page, ObjectIDs: ids}}
+ }
+ stack = stack[:len(stack)-1]
+ }
+ }
+ if root == nil {
+ return nil, fmt.Errorf("empty custom tag document")
+ }
+ return root, nil
+}
+
+// structureOfficial 识别公文类型标识及既有公文命名空间,不以节点名称推断未知行业语义
+// 入参: tag 标引入口, namespace 内容根节点命名空间
+// 返回: bool 是否使用公文引用规则
+func structureOfficial(tag CustomTag, namespace string) bool {
+ return tag.TypeID == "od" || namespace == "http://www.nits.org.cn/eGovDoc/2013" && tag.NameSpace == namespace
+}
+
+// structureObjectIDs 按公文标引规则拆分图元标识,保留顺序和重复项
+// 入参: value ObjectRef文本
+// 返回: []string 图元标识
+func structureObjectIDs(value string) []string {
+ return strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\r' || r == '\t' })
+}
+
+// StructureTargets 按标引顺序定位指定节点及其后代引用,每个目标页面只遍历一次
+// 入参: index 标引清单索引, path 相对根节点的子节点索引,均从0开始
+// 返回: []StructureTarget 原文和页面范围,缺失引用单独报告, error 解析或渲染错误
+func (r *Renderer) StructureTargets(index int, path []int) ([]StructureTarget, error) {
+ return r.StructureTargetsContext(context.Background(), index, path)
+}
+
+// StructureTargetsContext 在当前文档和版本内解析标引目标,不搜索其他文档的同名图元
+// 入参: ctx 取消上下文, index 标引清单索引, path 子节点索引路径
+// 返回: []StructureTarget 有序目标, error 解析、渲染或取消错误
+func (r *Renderer) StructureTargetsContext(ctx context.Context, index int, path []int) ([]StructureTarget, error) {
+ structures, err := r.Reader.StructureContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if index < 0 || index >= len(structures) || structures[index].Root == nil {
+ return nil, fmt.Errorf("custom tag structure not found: %d", index)
+ }
+ node := structures[index].Root
+ for _, i := range path {
+ if i < 0 || i >= len(node.Children) {
+ return nil, fmt.Errorf("custom tag node not found")
+ }
+ node = &node.Children[i]
+ }
+ doc, err := r.Reader.DocContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+ pages := signaturePageNumbers(doc)
+ var result []StructureTarget
+ var collect func(*StructureNode)
+ collect = func(node *StructureNode) {
+ for _, ref := range node.References {
+ for _, id := range ref.ObjectIDs {
+ page, _ := resourceValue(pages, ref.PageID)
+ result = append(result, StructureTarget{Page: page, PageID: ref.PageID, ID: id})
+ }
+ }
+ for i := range node.Children {
+ collect(&node.Children[i])
+ }
+ }
+ collect(node)
+ byPage := make(map[int]map[string]*StructureTarget)
+ for i := range result {
+ target := &result[i]
+ if target.Page == 0 {
+ target.Error = "custom tag page not found"
+ continue
+ }
+ if byPage[target.Page] == nil {
+ byPage[target.Page] = make(map[string]*StructureTarget)
+ }
+ byPage[target.Page][editorResourceID(target.ID)] = target
+ target.Error = "custom tag object not found"
+ }
+ for page, targets := range byPage {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ content, err := r.Reader.PageContentByIndexContext(ctx, page-1)
+ if err != nil {
+ return nil, err
+ }
+ visitor := &structureVisitor{ctx: ctx, renderer: r, targets: targets}
+ if err := r.WalkPage(content, visitor); err != nil {
+ return nil, err
+ }
+ }
+ for i, target := range result {
+ if source := byPage[target.Page][editorResourceID(target.ID)]; source != nil {
+ result[i] = *source
+ }
+ }
+ return result, nil
+}
diff --git a/ofdgo_structure_target.go b/ofdgo_structure_target.go
new file mode 100644
index 0000000..b10f58e
--- /dev/null
+++ b/ofdgo_structure_target.go
@@ -0,0 +1,105 @@
+// 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"
+
+// structureVisitor 按引用收集图元及复合图元成员的原文和页面范围
+type structureVisitor struct {
+ ctx context.Context
+ renderer *Renderer
+ targets map[string]*StructureTarget
+ stack []*StructureTarget
+}
+
+// BeginObject 记录当前图元的标引目标,使复合图元引用覆盖其成员
+// 入参: object 页面图元
+// 返回: bool 是否需要配对结束通知
+func (v *structureVisitor) BeginObject(object *GraphicObject) bool {
+ target := v.targets[editorResourceID(editorObjectID(*object))]
+ if target != nil {
+ target.Error = ""
+ }
+ v.stack = append(v.stack, target)
+ return true
+}
+
+// EndObject 结束当前图元的标引收集
+func (v *structureVisitor) EndObject() {
+ v.stack = v.stack[:len(v.stack)-1]
+}
+
+// BeginAnnotation 不为注解容器建立额外标引层级
+// 入参: id 注解标识
+// 返回: bool 始终为false
+func (v *structureVisitor) BeginAnnotation(id string) bool { return false }
+
+// SkipObjects 不使用渲染对象序号,隐藏成员不占用额外状态
+// 入参: count 跳过的图元数
+func (v *structureVisitor) SkipObjects(count int) {}
+
+// IncludeHidden 保留隐藏图元的结构语义,但不为其生成可见范围
+// 返回: bool 始终为true
+func (v *structureVisitor) IncludeHidden() bool { return true }
+
+// SkipClips 保留裁剪语义,以可见范围定位图元
+// 返回: bool 始终为false
+func (v *structureVisitor) SkipClips() bool { return false }
+
+// DrawObject 按继承变换和裁剪度量被引用的图元,不解码图片像素
+// 入参: object 页面图元, state 继承状态
+// 返回: error 度量或取消错误
+func (v *structureVisitor) DrawObject(object *GraphicObject, state RenderState) error {
+ if err := v.ctx.Err(); err != nil {
+ return err
+ }
+ wanted := false
+ for _, target := range v.stack {
+ wanted = wanted || target != nil
+ }
+ if !wanted {
+ return nil
+ }
+ var box Box
+ if !state.Hidden {
+ result, err := v.renderer.MeasureObject(*object, MeasureOptions{Defaults: state.Defaults, Parent: state.Parent, BoundaryInCTM: state.BoundaryInCTM, Clip: state.Clip})
+ if err != nil {
+ return err
+ }
+ box = result.Bounds
+ }
+ var text string
+ if object.Type == "TextObject" {
+ text = object.TextObject.Text()
+ }
+ for _, target := range v.stack {
+ if target == nil {
+ continue
+ }
+ target.Bounds = unionTextBox(target.Bounds, box)
+ if text != "" {
+ if target.Text != "" {
+ target.Text += "\n"
+ }
+ target.Text += text
+ }
+ }
+ return nil
+}
+
+// DrawStamp 不将签章外观混入正文标引
+// 入参: stamp 签章外观
+// 返回: error 取消错误
+func (v *structureVisitor) DrawStamp(stamp Stamp) error { return v.ctx.Err() }