feat(电子公文): 支持标引编辑与图元绑定
build.yaml / build (push) Successful in 50s

This commit is contained in:
xiaoqidun committed 2026-10-13 01:04:40 +08:00
1 parent e5685e5c7b
commit 261971cc41
9 files changed
+1072 -4

No files matched your search

+21 -1
View File
@@ -262,6 +262,26 @@
<div class="form-actions"><div id="objectStyleStatus" class="form-status" role="status"></div><button id="objectStyleCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="structureDialog" class="form-dialog structure-dialog" aria-label="管理标引">
<form id="structureForm">
<div class="form-row"><label for="structureEntry">标引</label><select id="structureEntry"></select></div>
<div id="structureNodeRow" class="form-row"><label for="structureNodeSelect">节点</label><select id="structureNodeSelect"></select></div>
<div id="structureActionRow" class="form-row"><label for="structureAction">操作</label><select id="structureAction"><option value="update">修改</option><option value="add">新增</option><option value="bind">绑定</option><option value="delete">删除</option></select></div>
<div id="structureFields">
<div id="structureTypeRow" class="form-row"><label for="structureType">类型</label><input id="structureType" type="text" required></div>
<div class="form-row"><label for="structureName">名称</label><input id="structureName" type="text" required></div>
<div class="form-row"><label for="structureNamespace">命名空间</label><input id="structureNamespace" type="text"></div>
<div class="form-row"><label for="structureText">内容</label><textarea id="structureText" rows="2"></textarea></div>
<div class="panel-row"><span>属性</span><button id="structureAttributeAdd" class="small-button" type="button" title="添加属性">添加</button></div>
<div id="structureAttributes" class="structure-edit-list"></div>
</div>
<div id="structureBinding" hidden>
<div class="panel-row"><span>引用</span><div class="panel-actions"><button id="structureSelection" class="small-button" type="button" title="绑定选区">选区</button><button id="structureReferenceAdd" class="small-button" type="button" title="添加引用">添加</button></div></div>
<div id="structureReferences" class="structure-edit-list"></div>
</div>
<div class="form-actions"><div id="structureStatus" class="form-status" role="status"></div><button id="structureCancel" class="button" type="button">取消</button><button id="structureSubmit" class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="outlinePanel" class="form-dialog" aria-label="编辑目录">
<form id="outlineForm">
<div class="form-row"><label for="outlineTitle">标题</label><input id="outlineTitle" type="text" required autofocus></div>
@@ -705,7 +725,7 @@
<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 class="panel-row"><div id="structurePanelTitle" class="panel-title compact">结构</div><div class="panel-actions"><span id="structureSummary" class="count-pill">0</span><button id="structureManage" class="small-button" type="button" title="管理标引" aria-haspopup="dialog" aria-controls="structureDialog" hidden>管理</button></div></div>
<div id="structureList" class="structure-list"></div>
</section>
<section id="signaturePanel" class="signature-panel" aria-labelledby="signaturePanelTitle" hidden>
+51
View File
@@ -3037,6 +3037,57 @@ audio.page-media {
gap: 8px;
}
.structure-dialog {
width: min(560px, calc(100vw - 32px));
}
.structure-dialog .form-row {
grid-template-columns: 4em minmax(0, 1fr);
}
.structure-dialog .form-row > label {
text-align-last: justify;
}
.structure-edit-list {
display: grid;
gap: 8px;
margin-top: 8px;
}
.structure-attribute {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px;
gap: 6px;
}
.structure-attribute .structure-attribute-value {
grid-column: 1 / 3;
}
.structure-attribute > button {
grid-column: 3;
grid-row: 1 / 3;
align-self: center;
}
.structure-reference + .structure-reference {
border-top: 1px solid var(--line);
padding-top: 8px;
}
.structure-reference .form-unit {
min-width: 0;
gap: 6px;
}
.structure-reference .form-unit > input,
.structure-reference .form-unit > select,
.structure-attribute > input {
min-width: 0;
width: 100%;
}
.info-line.ok .info-value {
color: var(--accent-strong);
}
+196 -3
View File
@@ -540,6 +540,22 @@ const el = {
structurePanel: document.querySelector("#structurePanel"),
structureList: document.querySelector("#structureList"),
structureSummary: document.querySelector("#structureSummary"),
structureManage: document.querySelector("#structureManage"),
structureDialog: document.querySelector("#structureDialog"),
structureForm: document.querySelector("#structureForm"),
structureEntry: document.querySelector("#structureEntry"),
structureNodeSelect: document.querySelector("#structureNodeSelect"),
structureAction: document.querySelector("#structureAction"),
structureFields: document.querySelector("#structureFields"),
structureType: document.querySelector("#structureType"),
structureName: document.querySelector("#structureName"),
structureNamespace: document.querySelector("#structureNamespace"),
structureText: document.querySelector("#structureText"),
structureAttributes: document.querySelector("#structureAttributes"),
structureBinding: document.querySelector("#structureBinding"),
structureReferences: document.querySelector("#structureReferences"),
structureStatus: document.querySelector("#structureStatus"),
structureSubmit: document.querySelector("#structureSubmit"),
signatureSummary: document.querySelector("#signatureSummary"),
signatureList: document.querySelector("#signatureList"),
annotationNote: document.querySelector("#annotationNote"),
@@ -1211,6 +1227,32 @@ el.objectStyleForm.addEventListener("submit", async event => {
if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close();
});
el.outlineCancel.addEventListener("click", () => el.outlinePanel.close());
el.structureManage.addEventListener("click", openStructureDialog);
document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close());
el.structureEntry.addEventListener("change", refreshStructureNodes);
el.structureNodeSelect.addEventListener("change", refreshStructureFields);
el.structureAction.addEventListener("change", refreshStructureFields);
document.querySelector("#structureAttributeAdd").addEventListener("click", () => addStructureAttribute());
document.querySelector("#structureReferenceAdd").addEventListener("click", () => addStructureReference());
document.querySelector("#structureSelection").addEventListener("click", () => {
const items = canvasEditor.items();
if (!items.length) { el.structureStatus.textContent = "尚未选择图元"; return; }
const page = state.doc.pages[items[0].index];
addStructureReference({ pageId: page.id, objectIds: items.map(item => item.id) });
});
el.structureForm.addEventListener("submit", async event => {
event.preventDefault();
if (!state.editing || document.body.hasAttribute("aria-busy")) return;
const index = Number(el.structureEntry.value), path = JSON.parse(el.structureNodeSelect.value || "[]");
const action = index < 0 ? "create" : el.structureAction.value;
const node = { name: el.structureName.value, namespace: el.structureNamespace.value, text: el.structureText.value,
attributes: [...el.structureAttributes.children].map(row => ({ name: row.querySelector('[name="name"]').value, namespace: row.querySelector('[name="namespace"]').value, value: row.querySelector('[name="value"]').value })) };
const references = [...el.structureReferences.children].map(row => ({ pageId: row.querySelector('[name="page"]').value,
objectIds: row.querySelector('[name="ids"]').value.split(/[,\s]+/u).filter(Boolean) })).filter(ref => ref.objectIds.length);
el.structureStatus.textContent = "";
const saved = await changeDocument("ofdgoChangeStructure", null, JSON.stringify({ action, index, path, typeId: el.structureType.value, node, references }));
if (saved) el.structureDialog.close();
});
el.outlineForm.addEventListener("submit", async event => {
event.preventDefault();
const path = state.outlineAction === "add" ? JSON.parse(el.outlineParent.value) : state.outlineSelection;
@@ -1657,7 +1699,7 @@ function handleKeyDown(event) {
function formDialogOpen() {
return Boolean(sealPreview) || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|| el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|| el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
}
function setObjectDimension(input, value) {
@@ -3414,6 +3456,8 @@ async function changeDocument(name, item, ...args) {
el.objectBoundsStatus.textContent = err.message;
} else if (el.outlinePanel.open) {
el.outlineStatus.textContent = err.message;
} else if (el.structureDialog.open) {
el.structureStatus.textContent = err.message;
} else if (el.infoPanel.open) {
el.infoStatus.textContent = err.message;
} else if (el.attachmentDialog.open) {
@@ -4188,6 +4232,7 @@ async function openOFD(file, remote = null) {
el.sourceTextPanel.close();
el.objectBoundsPanel.close();
el.outlinePanel.close();
el.structureDialog.close();
updateControls();
await openDocument({ doc, pageIndex: remote?.pageIndex || 0, resetScroll: true, openSeq, activateActions: true });
} catch (err) {
@@ -8150,7 +8195,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);
updateStructureControls();
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
refreshEditorFonts();
updateLocalFontButton();
@@ -8227,9 +8272,149 @@ function renderAttachments() {
}
}
function openStructureDialog() {
if (!state.editing || !state.doc || document.body.hasAttribute("aria-busy")) return;
el.structureEntry.replaceChildren();
for (const [index, structure] of (state.doc.structures || []).entries()) {
el.structureEntry.add(new Option(`${index + 1} · ${structure.root?.name || structure.typeId}`, index));
}
el.structureEntry.add(new Option("新建标引", -1));
el.structureAction.value = "update";
refreshStructureNodes();
el.structureDialog.showModal();
}
function refreshStructureNodes() {
const structure = state.doc.structures?.[Number(el.structureEntry.value)];
el.structureNodeSelect.replaceChildren();
const append = (node, path, depth) => {
if (node.references?.length) return;
el.structureNodeSelect.add(new Option(`${" ".repeat(depth)}${node.name}`, JSON.stringify(path)));
for (const [index, child] of (node.children || []).entries()) append(child, [...path, index], depth + 1);
};
if (structure?.root) append(structure.root, [], 0);
refreshStructureFields();
}
function refreshStructureFields() {
const index = Number(el.structureEntry.value), creating = index < 0;
const structure = state.doc.structures?.[index];
let node = structure?.root, canBind = Boolean(structure?.canBind);
for (const part of JSON.parse(el.structureNodeSelect.value || "[]")) {
node = node?.children?.[part];
canBind &&= node?.namespace === structure.root.namespace;
}
canBind &&= node?.name !== "ObjectRef";
el.structureAction.querySelector('[value="bind"]').disabled = !canBind;
if (!canBind && el.structureAction.value === "bind") el.structureAction.value = "update";
if (!creating && !node) el.structureAction.value = "delete";
for (const option of el.structureAction.options) option.disabled = option.value === "bind" ? !canBind : !creating && !node && option.value !== "delete";
const action = creating ? "create" : el.structureAction.value;
document.querySelector("#structureNodeRow").hidden = creating || !node;
document.querySelector("#structureActionRow").hidden = creating;
document.querySelector("#structureTypeRow").hidden = !creating;
el.structureType.value = creating ? "custom" : structure?.typeId || "";
el.structureFields.hidden = action === "bind" || action === "delete";
el.structureBinding.hidden = action !== "bind";
el.structureName.value = action === "update" ? node?.name || "" : "";
el.structureNamespace.value = creating ? "" : node?.namespace || "";
el.structureText.value = action === "update" ? node?.text || "" : "";
el.structureAttributes.replaceChildren();
el.structureReferences.replaceChildren();
if (action === "update") for (const attr of node?.attributes || []) addStructureAttribute(attr);
if (action === "bind") {
const references = [...(node?.references || []), ...(node?.children || []).flatMap(child => child.references || [])];
for (const ref of references) addStructureReference(ref);
if (!references.length) addStructureReference();
}
for (const control of el.structureFields.querySelectorAll("input,textarea,button")) control.disabled = el.structureFields.hidden;
el.structureType.disabled = !creating;
el.structureStatus.textContent = structure?.error || (action === "delete" ? "删除此节点及子节点" : "");
el.structureSubmit.textContent = ({ create: "新增", add: "新增", update: "修改", bind: "绑定", delete: "删除" })[action];
}
function addStructureAttribute(attribute = {}) {
const row = document.createElement("div");
row.className = "structure-attribute";
for (const [name, label] of [["name", "属性名称"], ["namespace", "命名空间"], ["value", "属性内容"]]) {
const input = document.createElement("input");
input.name = name;
input.value = attribute[name] || "";
input.placeholder = label;
input.setAttribute("aria-label", label);
input.required = name === "name";
if (name === "value") input.className = "structure-attribute-value";
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除属性";
remove.setAttribute("aria-label", remove.title);
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.structureAttributes.append(row);
}
function addStructureReference(reference = {}) {
const row = document.createElement("div");
row.className = "structure-reference";
const page = document.createElement("select"), object = document.createElement("select"), ids = document.createElement("input");
page.name = "page";
ids.name = "ids";
ids.value = (reference.objectIds || []).join(",");
for (const [index, item] of state.doc.pages.entries()) page.add(new Option(`第${index + 1}页`, item.id));
if (reference.pageId) page.value = reference.pageId;
const remove = document.createElement("button"), add = document.createElement("button");
for (const [button, label, text] of [[remove, "删除引用", "×"], [add, "添加图元", "添加"]]) {
button.type = "button";
button.className = "small-button";
button.textContent = text;
button.title = label;
button.setAttribute("aria-label", label);
}
remove.addEventListener("click", () => row.remove());
add.addEventListener("click", () => {
if (object.value) ids.value = [...ids.value.split(/[,\s]+/u).filter(Boolean), object.value].join(",");
});
for (const [label, control, button] of [["页码", page, remove], ["图元", object, add], ["顺序", ids, null]]) {
const line = document.createElement("div"), title = document.createElement("label"), unit = document.createElement("div");
line.className = "form-row";
title.textContent = label;
control.setAttribute("aria-label", label);
unit.className = "form-unit";
unit.append(control);
if (button) unit.append(button);
line.append(title, unit);
row.append(line);
}
el.structureReferences.append(row);
const load = async () => {
const value = page.value, sequence = state.openSeq;
object.replaceChildren(new Option("读取中", ""));
add.disabled = true;
try {
const index = state.doc.pages.findIndex(item => item.id === value);
const targets = await callWASM("ofdgoStructureObjects", index);
if (!row.isConnected || sequence !== state.openSeq || value !== page.value) return;
object.replaceChildren(new Option("选择图元", ""));
for (const target of targets || []) object.add(new Option(`${target.id} · ${(target.text || "图元").slice(0, 60)}`, target.id));
add.disabled = false;
} catch (err) {
if (!row.isConnected || sequence !== state.openSeq || value !== page.value) return;
object.replaceChildren(new Option("读取失败", ""));
el.structureStatus.textContent = err.message;
}
};
page.addEventListener("change", () => { ids.value = ""; load(); });
load();
}
function renderStructures() {
const structures = state.doc?.structures || [];
el.structurePanel.hidden = !structures.length && !state.doc?.structureError;
el.structurePanel.hidden = !structures.length && !state.doc?.structureError && !(state.doc && state.editing);
el.structureManage.hidden = !state.editing;
el.structureSummary.textContent = structures.length;
el.structureList.replaceChildren();
if (state.doc?.structureError) appendInfoLine(el.structureList, "错误", state.doc.structureError, "fail");
@@ -8239,6 +8424,11 @@ function renderStructures() {
}
}
function updateStructureControls() {
renderMetaContent(el.structureList, [state.doc?.structures, state.doc?.structureError, state.doc?.documentIndex, state.doc?.documentVersion, state.openSeq, state.editorInfo?.revision, state.editing], renderStructures);
el.structureManage.disabled = !state.ready || state.exporting || document.body.hasAttribute("aria-busy") || Boolean(state.doc?.detailsPending);
}
function structureNode(node, index, path) {
const details = document.createElement("details");
details.className = "structure-node";
@@ -9083,6 +9273,7 @@ function updateDocumentControls() {
function updateControls() {
renderSecurity();
updateStructureControls();
el.attachmentAdd.disabled = !state.editing || !state.ready || state.exporting;
renderMetaContent(el.attachmentList, [state.doc?.attachments, state.doc?.attachmentError, state.editing], renderAttachments);
const hasDoc = Boolean(state.doc);
@@ -9289,6 +9480,7 @@ function showError(err, empty = !state.doc) {
function setBusy(busy, text = "", percent = 0, status = "") {
document.body.toggleAttribute("aria-busy", busy);
updateStructureControls();
updateDocumentControls();
renderSecurity();
updateDisplayControls();
@@ -9312,6 +9504,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
el.objectStyleForm.inert = busy;
el.objectBoundsForm.inert = busy;
el.outlineForm.inert = busy;
el.structureForm.inert = busy;
el.outlineList.inert = busy;
el.navigationTabs.inert = busy;
el.pageList.inert = busy;
+53
View File
@@ -208,6 +208,8 @@ func RunWASM() {
registerCallback("ofdgoRenderPage", renderPage)
registerCallback("ofdgoOutlineLinks", outlineLinks)
registerCallback("ofdgoStructureTargets", structureTargets)
registerCallback("ofdgoStructureObjects", structureObjects)
registerCallback("ofdgoChangeStructure", changeStructure)
registerCallback("ofdgoOpenLinks", openLinks)
registerCallback("ofdgoRenderBackends", renderBackends)
registerCallback("ofdgoSVGFontData", svgFontData)
@@ -923,6 +925,57 @@ func outlineLinks(args []js.Value) (any, error) {
return linkResults(links)
}
// changeStructure 提交标引操作,XML、目标校验及历史维护均由库完成
// 入参: args JSON操作参数
// 返回: any 编辑状态, error 参数、校验或提交错误
func changeStructure(args []js.Value) (any, error) {
if len(args) != 1 || args[0].Type() != js.TypeString {
return nil, fmt.Errorf("invalid structure arguments")
}
var request struct {
Action string `json:"action"`
Index int `json:"index"`
Path []int `json:"path"`
TypeID string `json:"typeId"`
Node ofdgo.StructureNode `json:"node"`
References []ofdgo.StructureReference `json:"references"`
}
if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil {
return nil, err
}
return changeObjects(func() error {
switch request.Action {
case "create":
_, err := currentEditor.AddStructure(ofdgo.CustomTag{TypeID: request.TypeID, NameSpace: request.Node.Namespace}, request.Node)
return err
case "add":
_, err := currentEditor.AddStructureNode(request.Index, request.Path, request.Node)
return err
case "update":
return currentEditor.UpdateStructureNode(request.Index, request.Path, request.Node)
case "delete":
if len(request.Path) == 0 {
return currentEditor.DeleteStructure(request.Index)
}
return currentEditor.DeleteStructureNode(request.Index, request.Path)
case "bind":
return currentEditor.SetStructureReferences(request.Index, request.Path, request.References)
default:
return fmt.Errorf("unsupported structure action %q", request.Action)
}
})
}
// structureObjects 获取当前编辑文档的绑定候选,不允许阅读模式调用编辑入口
// 入参: args 从0开始的页面索引
// 返回: any 图元标识和原文, error 参数或读取错误
func structureObjects(args []js.Value) (any, error) {
if currentEditor == nil || currentSession == nil || len(args) != 1 {
return nil, fmt.Errorf("no document is being edited")
}
return currentSession.Renderer.StructureObjects(args[0].Int())
}
// structureTargets 查询当前文档标引节点的原文与定位范围
// 入参: args 标引清单索引和节点路径
// 返回: any 标引目标, error 参数或解析错误
+431
View File
@@ -0,0 +1,431 @@
// 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"
"slices"
"strings"
)
// Structure 获取当前编辑状态的标引,包括页面和图元删除后的引用调整
// 返回: []Structure 标引结构, error 快照或解析错误
func (e *Editor) Structure() ([]Structure, error) {
r, err := e.Reader()
if err != nil {
return nil, err
}
defer r.Close()
return r.Structure()
}
// AddStructure 添加独立标引及根节点,FileLoc由库分配,不接收外部内容路径
// SchemaLoc仅允许引用包内已有文件,不访问网络或验证业务Schema
// 入参: tag 类型、命名空间及可选Schema位置, root 根节点
// 返回: int 从0开始的标引索引, error 校验或提交错误
func (e *Editor) AddStructure(tag CustomTag, root StructureNode) (int, error) {
if strings.TrimSpace(tag.TypeID) == "" || tag.FileLoc != "" {
return 0, fmt.Errorf("custom tag requires TypeID and an automatically assigned FileLoc")
}
if !structureXMLText(tag.TypeID) || !structureXMLText(tag.NameSpace) || !structureXMLText(tag.SchemaLoc) {
return 0, fmt.Errorf("invalid custom tag metadata")
}
data, err := encodeStructureNode(root)
if err != nil {
return 0, err
}
tree, err := parseStructure(context.Background(), data, tag)
if err != nil {
return 0, err
}
if err := e.validateStructureReferences(tree, tag); err != nil {
return 0, err
}
base := e.source
if base == nil {
base, err = e.importBase()
if err != nil {
return 0, err
}
}
if tag.SchemaLoc != "" {
name := base.reader.ResPath(tag.SchemaLoc)
if _, err := base.reader.readFile(name); err != nil {
return 0, err
}
tag.SchemaLoc = "/" + name
}
name, index, container, entries, err := editorStructureIndex(base)
if err != nil {
return 0, err
}
tag.FileLoc = "/" + e.packageName("Tags/Tag.xml")
var attrs ofdAttrs
attrs.add("TypeID", tag.TypeID)
attrs.add("NameSpace", tag.NameSpace)
var content []byte
if tag.SchemaLoc != "" {
content = append(content, container.textXML("SchemaLoc", tag.SchemaLoc)...)
}
content = append(content, container.textXML("FileLoc", tag.FileLoc)...)
entry, err := container.containerXML("CustomTag", attrs, content)
if err != nil {
return 0, err
}
parts := map[string][]byte{strings.TrimPrefix(tag.FileLoc, "/"): data}
if container.open != container.end {
entry = append(bytes.Clone(index[container.open:container.close]), entry...)
}
parts[name] = editorPatchXML(index, []editorXMLPatch{editorXMLContent(index, container, entry)})
if base.document.CustomTags.Path == "" && name != base.reader.documentRoot {
docName := base.reader.documentRoot
docData, err := base.reader.readFile(docName)
if err != nil {
return 0, err
}
doc, err := parseEditorXML(docData)
if err != nil {
return 0, err
}
parts[docName], err = editorXMLSetText(docData, doc, [][2]string{{"CustomTags", "/" + name}})
if err != nil {
return 0, err
}
}
return len(entries), e.commitAnnotationParts(base, parts)
}
// DeleteStructure 删除标引入口,不删除正文或其他入口共享的资源
// 入参: index 从0开始的标引索引
// 返回: error 索引或提交错误
func (e *Editor) DeleteStructure(index int) error {
if e.source == nil {
return fmt.Errorf("custom tag structure not found: %d", index)
}
name, data, _, entries, err := editorStructureIndex(e.source)
if err != nil {
return err
}
if index < 0 || index >= len(entries) {
return fmt.Errorf("custom tag structure not found: %d", index)
}
node := entries[index]
return e.commitAnnotationParts(e.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}})})
}
// AddStructureNode 在指定父节点末尾添加子树,空路径表示根节点
// 入参: index 标引索引, parent 从0开始的节点路径, node 新节点及子树
// 返回: []int 新节点路径, error 校验或提交错误
func (e *Editor) AddStructureNode(index int, parent []int, node StructureNode) ([]int, error) {
encoded, err := encodeStructureNode(node)
if err != nil {
return nil, err
}
var result []int
err = e.editStructure(index, func(data []byte, root *editorXML, tag CustomTag) ([]byte, error) {
target, err := editorStructureNode(root, parent)
if err != nil {
return nil, err
}
if structureOfficial(tag, root.name.Space) && target.name.Space == root.name.Space && target.name.Local == "ObjectRef" {
return nil, fmt.Errorf("ObjectRef cannot contain child nodes")
}
content := bytes.Clone(encoded)
if target.open != target.end {
content = append(bytes.Clone(data[target.open:target.close]), content...)
}
result = append(slices.Clone(parent), len(target.children))
return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, target, content)}), nil
})
return result, err
}
// UpdateStructureNode 修改节点名称、命名空间、属性和直接文本,保留子节点、注释及处理指令
// node的Children和References必须为空,图元关联通过SetStructureReferences维护
// 入参: index 标引索引, path 从0开始的节点路径, node 节点字段
// 返回: error 校验或提交错误
func (e *Editor) UpdateStructureNode(index int, path []int, node StructureNode) error {
if len(node.Children) != 0 || len(node.References) != 0 {
return fmt.Errorf("use node operations to edit children or references")
}
return e.editStructure(index, func(data []byte, root *editorXML, _ CustomTag) ([]byte, error) {
target, err := editorStructureNode(root, path)
if err != nil {
return nil, err
}
updated, err := updateStructureNodeXML(data, target, node)
if err != nil {
return nil, err
}
return editorPatchXML(data, []editorXMLPatch{{target.start, target.end, updated}}), nil
})
}
// DeleteStructureNode 删除节点及其子树,根节点须通过DeleteStructure删除
// 入参: index 标引索引, path 从0开始的非空节点路径
// 返回: error 路径或提交错误
func (e *Editor) DeleteStructureNode(index int, path []int) error {
if len(path) == 0 {
return fmt.Errorf("delete the structure entry to remove its root")
}
return e.editStructure(index, func(data []byte, root *editorXML, _ CustomTag) ([]byte, error) {
node, err := editorStructureNode(root, path)
if err != nil {
return nil, err
}
return editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}}), nil
})
}
// SetStructureReferences 替换公文节点的直接ObjectRef,保留其他子节点和原文
// 按输入顺序绑定图元,允许跨页和重复引用;空列表解除绑定,不修改版式内容
// 入参: index 标引索引, path 节点路径, references 页面标识和有序图元标识
// 返回: error 类型、目标校验或提交错误
func (e *Editor) SetStructureReferences(index int, path []int, references []StructureReference) error {
return e.editStructure(index, func(data []byte, root *editorXML, tag CustomTag) ([]byte, error) {
node, err := editorStructureNode(root, path)
if err != nil {
return nil, err
}
if !structureOfficial(tag, root.name.Space) || node.name.Local == "ObjectRef" {
return nil, fmt.Errorf("custom tag does not support ObjectRef binding")
}
for parent := node; parent != nil; parent = parent.parent {
if parent.name.Space != root.name.Space {
return nil, fmt.Errorf("cannot bind objects inside an extension namespace")
}
}
var added []byte
for _, ref := range references {
if editorResourceID(ref.PageID) == "" || len(ref.ObjectIDs) == 0 {
return nil, fmt.Errorf("ObjectRef requires a page and object identifiers")
}
for _, id := range ref.ObjectIDs {
if editorResourceID(id) == "" {
return nil, fmt.Errorf("invalid custom tag object identifier: %s", id)
}
}
encoded, err := encodeStructureNode(StructureNode{Name: "ObjectRef", Namespace: root.name.Space, Attributes: []StructureAttribute{{Name: "PageRef", Value: ref.PageID}}, Text: strings.Join(ref.ObjectIDs, ",")})
if err != nil {
return nil, err
}
added = append(added, encoded...)
}
var content []byte
if node.open != node.end {
var patches []editorXMLPatch
for _, child := range node.children {
if child.name.Space == root.name.Space && child.name.Local == "ObjectRef" {
patches = append(patches, editorXMLPatch{child.start - node.open, child.end - node.open, nil})
}
}
content = editorPatchXML(data[node.open:node.close], patches)
}
content = append(content, added...)
return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)}), nil
})
}
// editorStructureIndex 读取或准备当前文档的标引索引,保留内联入口和未知扩展
// 入参: base 编辑包快照
// 返回: string 索引文件路径, []byte 原文, *editorXML 容器, []*editorXML 标引入口, error 读取错误
func editorStructureIndex(base *editorSource) (string, []byte, *editorXML, []*editorXML, error) {
name := base.reader.documentRoot
if base.document.CustomTags.Path != "" {
name = base.reader.ResPath(base.document.CustomTags.Path)
}
data, err := base.reader.readFile(name)
if err != nil {
return "", nil, nil, nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return "", nil, nil, nil, err
}
if base.document.CustomTags.Path == "" {
container := root.child("CustomTags")
if container == nil || len(container.children) == 0 {
name = packageAvailableName(base.reader, nil, base.reader.RootDir+"/CustomTags.xml")
data, err = root.containerXML("CustomTags", nil, nil)
if err == nil {
root, err = parseEditorXML(data)
}
if err != nil {
return "", nil, nil, nil, err
}
} else {
root = container
}
}
var entries []*editorXML
for _, child := range root.children {
if child.matchesOFD("CustomTag") {
entries = append(entries, child)
}
}
return name, data, root, entries, nil
}
// editorStructureNode 按完整XML子节点索引定位,与Reader.Structure的路径一致
// 入参: root 根节点, path 从0开始的路径
// 返回: *editorXML 节点, error 路径错误
func editorStructureNode(root *editorXML, path []int) (*editorXML, error) {
for _, index := range path {
if index < 0 || index >= len(root.children) {
return nil, fmt.Errorf("custom tag node not found")
}
root = root.children[index]
}
return root, nil
}
// editStructure 验证完整标引后原子提交,隔离共享入口并保留未修改的XML
// 入参: index 标引索引, edit 内容变换
// 返回: error 校验、解析或提交错误
func (e *Editor) editStructure(index int, edit func([]byte, *editorXML, CustomTag) ([]byte, error)) error {
if e.source == nil {
return fmt.Errorf("custom tag structure not found: %d", index)
}
tags, err := e.source.reader.CustomTags()
if err != nil {
return err
}
if index < 0 || index >= len(tags) {
return fmt.Errorf("custom tag structure not found: %d", index)
}
tag := tags[index]
name := e.source.reader.ResPath(tag.FileLoc)
snapshot, err := e.Reader()
if err != nil {
return err
}
defer snapshot.Close()
current, err := snapshot.CustomTags()
if err != nil {
return err
}
if index >= len(current) {
return fmt.Errorf("custom tag structure not found: %d", index)
}
data, err := snapshot.readFile(snapshot.ResPath(current[index].FileLoc))
if err != nil {
return err
}
if _, err := parseStructure(context.Background(), data, tag); err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
updated, err := edit(data, root, tag)
if err != nil || bytes.Equal(data, updated) {
return err
}
tree, err := parseStructure(context.Background(), updated, tag)
if err != nil {
return err
}
if err := e.validateStructureReferences(tree, tag); err != nil {
return err
}
parts := make(map[string][]byte)
for i, other := range tags {
if i != index && e.source.reader.ResPath(other.FileLoc) == name {
indexName, indexData, _, entries, err := editorStructureIndex(e.source)
if err != nil {
return err
}
name = e.packageName("Tags/Tag.xml")
loc := entries[index].child("FileLoc")
if loc == nil {
return fmt.Errorf("custom tag requires FileLoc")
}
var value bytes.Buffer
_ = xml.EscapeText(&value, []byte("/"+name))
parts[indexName] = editorPatchXML(indexData, []editorXMLPatch{editorXMLContent(indexData, loc, value.Bytes())})
break
}
}
parts[name] = updated
return e.commitAnnotationParts(e.source, parts)
}
// validateStructureReferences 检查标引目标是否位于当前文档和版本,不依赖字体或图像解码
// 入参: root 待提交标引树, tag 标引入口
// 返回: error 页面或图元缺失错误
func (e *Editor) validateStructureReferences(root *StructureNode, tag CustomTag) error {
var refs []StructureReference
var collect func(*StructureNode, bool) error
collect = func(node *StructureNode, official bool) error {
official = official && node.Namespace == root.Namespace
if official && node.Name == "ObjectRef" && len(node.Children) != 0 {
return fmt.Errorf("ObjectRef cannot contain child nodes")
}
refs = append(refs, node.References...)
for i := range node.Children {
if err := collect(&node.Children[i], official); err != nil {
return err
}
}
return nil
}
if err := collect(root, structureOfficial(tag, root.Namespace)); err != nil {
return err
}
if len(refs) == 0 {
return nil
}
r, err := e.Reader()
if err != nil {
return err
}
defer r.Close()
doc, err := r.Doc()
if err != nil {
return err
}
renderer := e.newRenderer(r)
pages := signaturePageNumbers(doc)
checked := make(map[int]map[string]bool)
for _, ref := range refs {
page, _ := resourceValue(pages, ref.PageID)
if page == 0 {
return fmt.Errorf("custom tag page not found: %s", ref.PageID)
}
objects := checked[page]
if objects == nil {
content, err := r.PageContentByIndex(page - 1)
if err != nil {
return err
}
visitor := &structureObjectVisitor{objects: make(map[string]bool)}
if err := renderer.WalkPage(content, visitor); err != nil {
return err
}
objects = visitor.objects
checked[page] = objects
}
for _, id := range ref.ObjectIDs {
if !objects[editorResourceID(id)] {
return fmt.Errorf("custom tag object not found: %s/%s", ref.PageID, id)
}
}
}
return nil
}
+9
View File
@@ -252,6 +252,15 @@ func editorXMLTextValue(data []byte, node *editorXML, before, after string) ([]b
content.Reset()
content.Write(escaped)
}
return editorXMLDirectText(data, node, content.Bytes())
}
// editorXMLDirectText 替换直接文本,保留子节点、注释和处理指令
// 入参: data 原XML, node 目标节点, text 已转义的文本
// 返回: []byte 节点片段, error XML解析错误
func editorXMLDirectText(data []byte, node *editorXML, text []byte) ([]byte, error) {
var content bytes.Buffer
content.Write(text)
if node.open < node.close {
inner := data[node.open:node.close]
decoder := xml.NewDecoder(bytes.NewReader(inner))
+5
View File
@@ -30,10 +30,12 @@ const structureTypeOfficial = "od"
const structureNamespaceEGov2013 = "http://www.nits.org.cn/eGovDoc/2013"
// Structure 保存自定义标引入口及结构树,单个文件解析失败时通过Error报告,不影响其他标引
// CanBind表示已识别公文引用语义,具体节点和目标仍由绑定接口校验
type Structure struct {
TypeID string `json:"typeId,omitempty"`
Namespace string `json:"namespace,omitempty"`
FileLoc string `json:"fileLoc"`
CanBind bool `json:"canBind,omitempty"`
Root *StructureNode `json:"root,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -97,6 +99,9 @@ func (r *Reader) StructureContext(ctx context.Context) ([]Structure, error) {
data, err := r.readFileViewContext(ctx, r.ResPath(tag.FileLoc))
if err == nil {
item.Root, err = parseStructure(ctx, data, tag)
if err == nil {
item.CanBind = structureOfficial(tag, item.Root.Namespace)
}
}
if ctx.Err() != nil {
return nil, ctx.Err()
+88
View File
@@ -0,0 +1,88 @@
// 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
// structureObjectVisitor 收集页面及复合图元标识,绑定校验不解码字体、图片或裁剪
type structureObjectVisitor struct {
objects map[string]bool
targets []StructureTarget
}
// StructureObjects 列出指定页可绑定的图元及原文,包含模板、隐藏内容和复合图元成员
// 不解码字体和图片,返回顺序遵循页面遍历顺序,同一标识仅列出一次
// 入参: page 从0开始的页面索引
// 返回: []StructureTarget 候选图元,不计算Bounds, error 页面或遍历错误
func (r *Renderer) StructureObjects(page int) ([]StructureTarget, error) {
content, err := r.Reader.PageContentByIndex(page)
if err != nil {
return nil, err
}
visitor := &structureObjectVisitor{objects: make(map[string]bool)}
if err := r.WalkPage(content, visitor); err != nil {
return nil, err
}
for i := range visitor.targets {
visitor.targets[i].Page, visitor.targets[i].PageID = page+1, content.ID
}
return visitor.targets, nil
}
// BeginObject 登记图元标识,不创建分组状态
// 入参: object 页面图元
// 返回: bool 始终为false
func (v *structureObjectVisitor) BeginObject(object *GraphicObject) bool {
id := editorResourceID(editorObjectID(*object))
if id != "" && !v.objects[id] {
v.objects[id] = true
target := StructureTarget{ID: id}
if object.Type == "TextObject" {
target.Text = object.TextObject.Text()
}
v.targets = append(v.targets, target)
}
return false
}
// EndObject 满足分组访问接口,不维护分组栈
func (v *structureObjectVisitor) EndObject() {}
// BeginAnnotation 不为注解容器分配正文图元标识
// 入参: id 注解标识
// 返回: bool 始终为false
func (v *structureObjectVisitor) BeginAnnotation(id string) bool { return false }
// SkipObjects 不依赖图元序号
// 入参: count 跳过数量
func (v *structureObjectVisitor) SkipObjects(count int) {}
// IncludeHidden 允许绑定隐藏图元
// 返回: bool 始终为true
func (v *structureObjectVisitor) IncludeHidden() bool { return true }
// SkipClips 绑定校验不需要裁剪轮廓
// 返回: bool 始终为true
func (v *structureObjectVisitor) SkipClips() bool { return true }
// DrawObject 不绘制图元,标识已由BeginObject登记
// 入参: object 页面图元, state 继承状态
// 返回: error 始终为nil
func (v *structureObjectVisitor) DrawObject(object *GraphicObject, state RenderState) error {
return nil
}
// DrawStamp 不将签章外观作为正文绑定目标
// 入参: stamp 签章外观
// 返回: error 始终为nil
func (v *structureObjectVisitor) DrawStamp(stamp Stamp) error { return nil }
+218
View File
@@ -0,0 +1,218 @@
// 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"
"slices"
"strconv"
"strings"
"unicode/utf8"
)
// encodeStructureNode 编码有界标引子树,引用由ObjectRef节点表达,不重复编码派生References字段
// 入参: node 根节点
// 返回: []byte XML片段, error 名称、属性或编码错误
func encodeStructureNode(node StructureNode) ([]byte, error) {
var output bytes.Buffer
encoder := xml.NewEncoder(&output)
count := 0
var write func(StructureNode, int) error
write = func(node StructureNode, depth int) error {
count++
if depth > 128 || count > 100000 {
return fmt.Errorf("custom tag structure limit exceeded")
}
if err := validateStructureNode(node); err != nil {
return err
}
start := xml.StartElement{Name: xml.Name{Space: node.Namespace, Local: node.Name}}
if node.Namespace == "" {
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Local: "xmlns"}, Value: ""})
}
for _, attr := range node.Attributes {
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: attr.Namespace, Local: attr.Name}, Value: attr.Value})
}
if err := encoder.EncodeToken(start); err != nil {
return err
}
if err := encoder.EncodeToken(xml.CharData(node.Text)); err != nil {
return err
}
for _, child := range node.Children {
if err := write(child, depth+1); err != nil {
return err
}
}
return encoder.EncodeToken(start.End())
}
if err := write(node, 1); err != nil {
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
return output.Bytes(), nil
}
// validateStructureNode 校验XML名称和文本,禁止重复属性及伪造命名空间声明
// 入参: node 节点字段
// 返回: error 字段校验错误
func validateStructureNode(node StructureNode) error {
if !ofdXMLIDValid(node.Name) || node.Name == "xmlns" {
return fmt.Errorf("invalid custom tag node name: %s", node.Name)
}
if !structureXMLText(node.Text) || !structureXMLText(node.Namespace) || node.Namespace == "http://www.w3.org/2000/xmlns/" {
return fmt.Errorf("invalid custom tag text or namespace")
}
seen := make(map[xml.Name]bool)
for _, attr := range node.Attributes {
name := xml.Name{Space: attr.Namespace, Local: attr.Name}
if !ofdXMLIDValid(attr.Name) || attr.Name == "xmlns" || attr.Namespace == "http://www.w3.org/2000/xmlns/" || !structureXMLText(attr.Namespace) || !structureXMLText(attr.Value) || seen[name] {
return fmt.Errorf("invalid or duplicate custom tag attribute: %s", attr.Name)
}
seen[name] = true
}
return nil
}
// structureXMLText 检查XML 1.0允许的字符,不以替换字符掩盖无效输入
// 入参: value 文本
// 返回: bool 是否可无损编码
func structureXMLText(value string) bool {
if !utf8.ValidString(value) {
return false
}
for _, r := range value {
if r != '\t' && r != '\n' && r != '\r' && (r < 0x20 || r == 0xfffe || r == 0xffff) {
return false
}
}
return true
}
// updateStructureNodeXML 替换节点字段,保留子树继承的命名空间和非文本XML内容
// 入参: data 原XML, source 原节点, node 新字段
// 返回: []byte 独立节点片段, error 校验或编码错误
func updateStructureNodeXML(data []byte, source *editorXML, node StructureNode) ([]byte, error) {
if err := validateStructureNode(node); err != nil {
return nil, err
}
fragment, err := editorXMLStandalone(data[source.start:source.end], source)
if err != nil {
return nil, err
}
before, err := parseStructure(context.Background(), fragment, CustomTag{})
if err != nil {
return nil, err
}
if node.Name == before.Name && node.Namespace == before.Namespace && node.Text == before.Text && slices.Equal(node.Attributes, before.Attributes) {
return data[source.start:source.end], nil
}
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
if before.Text != node.Text {
var text bytes.Buffer
if err := xml.EscapeText(&text, []byte(node.Text)); err != nil {
return nil, err
}
fragment, err = editorXMLDirectText(fragment, root, text.Bytes())
if err != nil {
return nil, err
}
}
root, err = parseEditorXML(fragment)
if err != nil {
return nil, err
}
var attrs []xml.Attr
used := make(map[string]bool)
for _, attr := range root.attrs {
if attr.Name.Space == "xmlns" {
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "xmlns:" + attr.Name.Local}, Value: attr.Value})
used[attr.Name.Local] = true
} else if attr.Name.Space == "" && attr.Name.Local == "xmlns" {
attrs = append(attrs, attr)
}
}
qualify := func(name, namespace string) string {
if namespace == "" {
return name
}
if namespace == "http://www.w3.org/XML/1998/namespace" {
return "xml:" + name
}
for _, attr := range attrs {
if strings.HasPrefix(attr.Name.Local, "xmlns:") && attr.Value == namespace {
return strings.TrimPrefix(attr.Name.Local, "xmlns:") + ":" + name
}
}
prefix := "tag"
for i := 1; used[prefix]; i++ {
prefix = "tag" + strconv.Itoa(i)
}
used[prefix] = true
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "xmlns:" + prefix}, Value: namespace})
return prefix + ":" + name
}
name := qualify(node.Name, node.Namespace)
if node.Namespace == "" {
for i := range attrs {
if attrs[i].Name.Local == "xmlns" {
attrs[i].Value = ""
}
}
}
for _, attr := range node.Attributes {
key := qualify(attr.Name, attr.Namespace)
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: key}, Value: attr.Value})
}
var output bytes.Buffer
encoder := xml.NewEncoder(&output)
start := xml.StartElement{Name: xml.Name{Local: name}, Attr: attrs}
if err := encoder.EncodeToken(start); err != nil {
return nil, err
}
if err := encoder.Flush(); err != nil {
return nil, err
}
if root.open != root.end {
if node.Namespace == "" && before.Namespace != "" {
var patches []editorXMLPatch
for _, child := range root.children {
standalone, err := editorXMLStandalone(fragment[child.start:child.end], child)
if err != nil {
return nil, err
}
patches = append(patches, editorXMLPatch{child.start - root.open, child.end - root.open, standalone})
}
output.Write(editorPatchXML(fragment[root.open:root.close], patches))
} else {
output.Write(fragment[root.open:root.close])
}
}
if err := encoder.EncodeToken(start.End()); err != nil {
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
return output.Bytes(), nil
}