feat(电子公文): 支持标引解析与原文定位
build.yaml / build (push) Successful in 49s

This commit is contained in:
xiaoqidun committed 2026-10-13 00:30:27 +08:00
1 parent 3780a25140
commit e86fc6a4e0
10 files changed
+816 -1

No files matched your search

+4
View File
@@ -704,6 +704,10 @@
<div class="panel-row"><div id="attachmentPanelTitle" class="panel-title compact">附件</div><div class="panel-actions"><button id="attachmentAdd" class="small-button" type="button" title="添加附件" aria-haspopup="dialog" aria-controls="attachmentDialog" hidden>添加</button></div></div>
<div id="attachmentList" class="attachment-list"></div>
</section>
<section id="structurePanel" class="structure-panel" aria-labelledby="structurePanelTitle" hidden>
<div class="panel-row"><div id="structurePanelTitle" class="panel-title compact">结构</div><span id="structureSummary" class="count-pill">0</span></div>
<div id="structureList" class="structure-list"></div>
</section>
<section id="signaturePanel" class="signature-panel" aria-labelledby="signaturePanelTitle" hidden>
<div class="panel-row">
<div id="signaturePanelTitle" class="panel-title compact">签名</div>
+38
View File
@@ -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);
}
+85
View File
@@ -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);
+14
View File
@@ -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 附件读取错误
+12 -1
View File
@@ -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,6 +124,8 @@ type DocumentEntry struct {
// DocumentDetails 不含页面数组的文档补充信息
type DocumentDetails struct {
Structures []ofdgo.Structure `json:"structures,omitempty"`
StructureError string `json:"structureError,omitempty"`
FontCount int `json:"fontCount"`
SignatureCount int `json:"signatureCount"`
SignatureError string `json:"signatureError,omitempty"`
@@ -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
+30
View File
@@ -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 {
+3
View File
@@ -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)
+248
View File
@@ -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
}
+277
View File
@@ -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
}
+105
View File
@@ -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() }