mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
1 parent
3780a25140
commit
e86fc6a4e0
10 files changed
+823
-8
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 附件读取错误
|
||||
|
||||
+19
-8
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() }
|
||||
Reference in new issue
Block a user