Compare commits

..
2 Commits
Author SHA1 Message Date
xiaoqidun f2c71c5acf fix(编辑能力): 修复签名依赖与多文档显示
build.yaml / build (push) Successful in 46s
2026-10-27 20:26:23 +08:00
xiaoqidun b59d82a68a feat(编辑能力): 支持签名移除清空及撤销重做 2026-10-27 20:12:49 +08:00
4 changed files with 306 additions and 3 deletions

No files matched your search

+1 -1
View File
@@ -876,7 +876,7 @@
<section id="signaturePanel" class="signature-panel" hidden> <section id="signaturePanel" class="signature-panel" hidden>
<div class="panel-row"> <div class="panel-row">
<div id="signaturePanelTitle" class="panel-title compact">签名</div> <div id="signaturePanelTitle" class="panel-title compact">签名</div>
<span id="signatureSummary" class="count-pill">0</span> <div class="panel-actions"><span id="signatureSummary" class="count-pill">0</span><button id="signatureClear" class="small-button" type="button" hidden>清空</button></div>
</div> </div>
<div id="signatureList" class="signature-list"></div> <div id="signatureList" class="signature-list"></div>
</section> </section>
+26 -2
View File
@@ -638,6 +638,7 @@ const el = {
structureStatus: document.querySelector("#structureStatus"), structureStatus: document.querySelector("#structureStatus"),
structureSubmit: document.querySelector("#structureSubmit"), structureSubmit: document.querySelector("#structureSubmit"),
signatureSummary: document.querySelector("#signatureSummary"), signatureSummary: document.querySelector("#signatureSummary"),
signatureClear: document.querySelector("#signatureClear"),
signatureList: document.querySelector("#signatureList"), signatureList: document.querySelector("#signatureList"),
annotationNote: document.querySelector("#annotationNote"), annotationNote: document.querySelector("#annotationNote"),
annotationNoteTitle: document.querySelector("#annotationNoteTitle"), annotationNoteTitle: document.querySelector("#annotationNoteTitle"),
@@ -1476,6 +1477,7 @@ el.objectStyleForm.addEventListener("submit", async event => {
}); });
el.outlineCancel.addEventListener("click", () => el.outlinePanel.close()); el.outlineCancel.addEventListener("click", () => el.outlinePanel.close());
el.structureManage.addEventListener("click", openStructureDialog); el.structureManage.addEventListener("click", openStructureDialog);
el.signatureClear.addEventListener("click", () => changeDocument("ofdgoChangeSignature", null, "clear", ""));
document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close()); document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close());
el.structureEntry.addEventListener("change", refreshStructureNodes); el.structureEntry.addEventListener("change", refreshStructureNodes);
el.structureNodeSelect.addEventListener("change", refreshStructureFields); el.structureNodeSelect.addEventListener("change", refreshStructureFields);
@@ -9190,6 +9192,17 @@ function renderSecurity() {
el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || state.saving || document.body.hasAttribute("data-busy"); el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || state.saving || document.body.hasAttribute("data-busy");
el.verifyButton.disabled = el.encryptSaveButton.disabled; el.verifyButton.disabled = el.encryptSaveButton.disabled;
el.signButton.disabled = el.encryptSaveButton.disabled; el.signButton.disabled = el.encryptSaveButton.disabled;
el.signatureClear.hidden = !state.editing;
el.signatureClear.disabled = el.encryptSaveButton.disabled || !state.editing || !!state.doc?.detailsPending || !state.doc?.signatures?.length;
const scope = JSON.stringify([documentTab().id, documentContentKey(state)]);
if (!state.doc?.detailsPending || metaContents.get(el.signaturePanel) !== scope) {
renderMetaContent(el.signatureList, [scope, state.doc?.signatures, state.doc?.signatureError, state.editing], renderSignatures);
metaContents.set(el.signaturePanel, scope);
}
for (const button of el.signatureList.querySelectorAll(".signature-remove")) {
button.hidden = !state.editing;
button.disabled = el.signatureClear.disabled;
}
} }
function cancelSigning() { function cancelSigning() {
@@ -11540,7 +11553,6 @@ function renderMeta(keepDetails = false) {
el.pageTotal.textContent = String(doc.pageCount || 0); el.pageTotal.textContent = String(doc.pageCount || 0);
if (keepDetails && doc.detailsPending) return; if (keepDetails && doc.detailsPending) return;
el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0); el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0);
renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures);
updateStructureControls(); updateStructureControls();
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts); renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
refreshEditorFonts(); refreshEditorFonts();
@@ -11811,6 +11823,18 @@ function renderSignatures() {
badges.className = "signature-badges"; badges.className = "signature-badges";
badges.append(fontBadge(signature.trustedValid ? "可信" : signature.status === "valid" ? "完整" : signature.status === "invalid" ? "异常" : "未验", signature.status)); badges.append(fontBadge(signature.trustedValid ? "可信" : signature.status === "valid" ? "完整" : signature.status === "invalid" ? "异常" : "未验", signature.status));
if (state.editing) {
const tab = documentTab(), key = documentContentKey(state);
const remove = document.createElement("button");
remove.className = "small-button signature-remove";
remove.type = "button";
remove.textContent = "移除";
remove.disabled = el.signatureClear.disabled;
remove.addEventListener("click", () => {
if (isForegroundDocument(tab) && documentContentKey(state) === key) changeDocument("ofdgoChangeSignature", null, "remove", signature.id);
});
badges.append(remove);
}
head.append(badges, name); head.append(badges, name);
row.append(head); row.append(head);
appendInfoLine(row, "签署人", signature.signer); appendInfoLine(row, "签署人", signature.signer);
@@ -11913,7 +11937,7 @@ function signatureSummary(signatures) {
} }
function signatureNameNode(signature) { function signatureNameNode(signature) {
const stamps = signature.docIndex ? [] : signature.stamps || []; const stamps = (signature.docIndex || 0) === (state.doc?.documentIndex || 0) ? signature.stamps || [] : [];
const name = document.createElement("div"); const name = document.createElement("div");
name.className = "signature-name"; name.className = "signature-name";
if (!stamps.length) { if (!stamps.length) {
+22
View File
@@ -6746,6 +6746,25 @@ func (e *businessEngine) changeAttachment(args []platformValue) (any, error) {
}) })
} }
// changeSignature 移除当前文档签名,使用库层依赖检查和撤销历史
// 入参: args 操作及签名标识
// 返回: any 编辑状态, error 移除错误
func (e *businessEngine) changeSignature(args []platformValue) (any, error) {
if len(args) != 2 {
return nil, fmt.Errorf("签名参数无效")
}
return e.changeAtomicObjects(func() error {
switch args[0].String() {
case "remove":
return e.currentEditor.RemoveSignatureContext(e.operationContext(), args[1].String())
case "clear":
return e.currentEditor.ClearSignaturesContext(e.operationContext())
default:
return fmt.Errorf("签名操作无效")
}
})
}
// changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态 // changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态
// 入参: args 字体标识、保存方式及可选字体数据 // 入参: args 字体标识、保存方式及可选字体数据
// 返回: any 编辑状态, error 字体处理错误 // 返回: any 编辑状态, error 字体处理错误
@@ -7980,6 +7999,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
"ofdgoEditDocument": e.editDocument, "ofdgoEditDocument": e.editDocument,
"ofdgoUpdateInfo": e.updateInfo, "ofdgoUpdateInfo": e.updateInfo,
"ofdgoChangeAttachment": e.changeAttachment, "ofdgoChangeAttachment": e.changeAttachment,
"ofdgoChangeSignature": e.changeSignature,
"ofdgoChangePage": e.changePage, "ofdgoChangePage": e.changePage,
"ofdgoBatchPages": e.batchPages, "ofdgoBatchPages": e.batchPages,
"ofdgoChangeOutline": e.changeOutline, "ofdgoChangeOutline": e.changeOutline,
@@ -8068,6 +8088,8 @@ func (e *businessEngine) operationContext() context.Context {
func failureResult(err error) apiResult { func failureResult(err error) apiResult {
result := apiResult{Error: err.Error()} result := apiResult{Error: err.Error()}
switch { switch {
case errors.Is(err, ofdgo.ErrSignatureDependency):
result.Code, result.Error = "signatureDependency", "存在签名或版本依赖,无法移除"
case errors.Is(err, ofdgo.ErrIncompleteDocument): case errors.Is(err, ofdgo.ErrIncompleteDocument):
result.Code, result.Error = "documentPending", "文档尚未准备完成" result.Code, result.Error = "documentPending", "文档尚未准备完成"
case errors.Is(err, ofdgo.ErrPrintDenied): case errors.Is(err, ofdgo.ErrPrintDenied):
+257
View File
@@ -21,8 +21,10 @@ import (
"crypto" "crypto"
"encoding/base64" "encoding/base64"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"maps"
"path" "path"
"slices" "slices"
"strconv" "strconv"
@@ -40,6 +42,261 @@ const (
// SignatureWriteMode 签署方式 // SignatureWriteMode 签署方式
type SignatureWriteMode uint8 type SignatureWriteMode uint8
// ErrSignatureDependency 表示移除操作会改变保留签名或其他版本使用的入口和保护文件
var ErrSignatureDependency = errors.New("signature dependency conflict")
// RemoveSignature 移除当前文档版本的指定签名及外观,支持撤销重做
// 仅清理无其他引用的签名文件,依赖冲突时不修改文档
// 入参: id 签名标识
// 返回: error 标识、依赖或读取错误
func (e *Editor) RemoveSignature(id string) error {
return e.RemoveSignatureContext(context.Background(), id)
}
// RemoveSignatureContext 移除指定签名,取消或失败时不改变文档和历史
// 入参: ctx 取消上下文, id 签名标识
// 返回: error 标识、依赖、读取或取消错误
func (e *Editor) RemoveSignatureContext(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("empty signature ID")
}
return e.removeSignatures(ctx, id)
}
// ClearSignatures 清空当前文档版本的签名及外观,保留其他文档和版本的引用
// 无签名时不产生历史记录,依赖冲突时不修改文档
// 返回: error 依赖或读取错误
func (e *Editor) ClearSignatures() error {
return e.ClearSignaturesContext(context.Background())
}
// ClearSignaturesContext 清空当前签名,取消或失败时不改变文档和历史
// 入参: ctx 取消上下文
// 返回: error 依赖、读取或取消错误
func (e *Editor) ClearSignaturesContext(ctx context.Context) error {
return e.removeSignatures(ctx, "")
}
// removeSignatures 在独立快照中移除签名入口,校验保留依赖后提交历史
// 入参: ctx 取消上下文, id 签名标识,空值清空
// 返回: error 标识、依赖、结构或取消错误
func (e *Editor) removeSignatures(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
reader, err := e.readerContext(ctx, nil)
if err != nil {
return err
}
doc, err := reader.DocContext(ctx)
if err != nil {
return err
}
if doc.Signatures == "" {
if id != "" {
return fmt.Errorf("signature not found: %s", id)
}
return nil
}
name := reader.ResPath(doc.Signatures)
data, err := reader.readFileViewContext(ctx, name)
if err != nil {
return err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return err
}
removed := make(map[string]bool)
candidates := make(map[string]bool)
var patches []editorXMLPatch
remaining := 0
for _, node := range root.children {
if !node.matchesOFD("Signature") {
continue
}
if id != "" && node.attr("ID") != id {
remaining++
continue
}
location := signatureRefPath(name, node.attr("BaseLoc"))
files, _, err := reader.signatureRemovalFiles(ctx, location)
if err != nil {
return err
}
maps.Copy(candidates, files)
removed[node.attr("ID")] = true
patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
}
if id != "" && !removed[id] {
return fmt.Errorf("signature not found: %s", id)
}
protected, dependencies, err := reader.signatureRemovalProtected(ctx, name, removed)
if err != nil {
return err
}
parts := make(map[string][]byte)
if reader.versionInfo != nil {
if remaining != 0 {
return fmt.Errorf("%w: version shares signature list %s", ErrSignatureDependency, name)
}
candidates[name] = true
used, err := reader.versionFileDependencies(ctx, reader.documentRoot, nil, nil)
if err != nil {
return err
}
removedFiles := maps.Clone(candidates)
for file := range used {
delete(removedFiles, file)
}
shared, err := reader.versionSharedFiles(ctx)
if err != nil {
return err
}
if err := reader.versionManifest(ctx, parts, nil, used, shared, removedFiles); err != nil {
return err
}
} else {
if dependencies[name] {
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, name)
}
for file := range candidates {
if dependencies[file] {
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, file)
}
}
versions, err := reader.DocumentVersionsContext(ctx)
if err != nil {
return err
}
for _, version := range versions {
for _, file := range version.Files {
if file.Location == name {
return fmt.Errorf("%w: version %s uses %s", ErrSignatureDependency, version.ID, name)
}
}
}
entry, err := reader.readFileViewContext(ctx, "OFD.xml")
if err != nil {
return err
}
ofd, err := parseEditorXMLContext(ctx, entry)
if err != nil {
return err
}
node := ofd.childAt("DocBody", reader.documentIndex).child("Signatures")
if remaining == 0 {
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{{start: node.start, end: node.end}})
candidates[name] = true
} else {
target := name
shared := false
for index, body := range reader.OFD.DocBody {
shared = shared || index != reader.documentIndex && body.Signatures != "" && signatureRefPath("OFD.xml", body.Signatures) == name
}
if shared {
target = packageAvailableName(reader, parts, name)
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{editorXMLContent(entry, node, editorFontXMLText("/"+target))})
}
parts[target] = editorPatchXML(data, patches)
}
}
for file, updated := range parts {
if !protected[file] {
continue
}
same, err := packageDataEqual(ctx, reader, file, updated)
if err != nil {
return err
}
if !same {
return fmt.Errorf("%w: protected file %s", ErrSignatureDependency, file)
}
}
for file, data := range reader.files {
if _, changed := parts[file]; !changed {
parts[file] = data
}
}
reader.files = parts
for file := range protected {
delete(candidates, file)
}
if err := reader.retireVersionFiles(ctx, candidates); err != nil {
return err
}
return e.commitResourceSnapshot(ctx, reader)
}
// signatureRemovalFiles 区分签名自有文件和摘要保护内容,正文不作为删除候选
// 入参: ctx 取消上下文, name 签名描述路径
// 返回: map[string]bool 自有文件, map[string]bool 保护文件, error 读取或解析错误
func (r *Reader) signatureRemovalFiles(ctx context.Context, name string) (map[string]bool, map[string]bool, error) {
data, err := r.readFileViewContext(ctx, name)
if err != nil {
return nil, nil, err
}
var signature SignatureFile
if err := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}).Decode(&signature); err != nil {
return nil, nil, err
}
owned := map[string]bool{name: true}
for _, location := range []string{signature.SignedValue, signature.SignedInfo.Seal.BaseLoc} {
if location != "" {
owned[signatureRefPath(name, location)] = true
}
}
protected := make(map[string]bool)
for _, reference := range signature.SignedInfo.References.Reference {
protected[signatureRefPath(name, reference.FileRef)] = true
}
return owned, protected, ctx.Err()
}
// signatureRemovalProtected 汇总保留签名的原文及其他文档的签名入口,不要求证书或私钥
// 入参: ctx 取消上下文, selected 当前签名列表, removed 待移除标识
// 返回: map[string]bool 保留路径, map[string]bool 非共享的摘要保护路径, error 读取、结构或取消错误
func (r *Reader) signatureRemovalProtected(ctx context.Context, selected string, removed map[string]bool) (map[string]bool, map[string]bool, error) {
protected := make(map[string]bool)
dependencies := make(map[string]bool)
retained := make(map[string]bool)
for index, body := range r.OFD.DocBody {
if body.Signatures == "" {
continue
}
name := signatureRefPath("OFD.xml", body.Signatures)
current := index == r.documentIndex && r.versionInfo == nil
if !current {
protected[name] = true
}
data, err := r.readFileViewContext(ctx, name)
if err != nil {
return nil, nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, nil, err
}
for _, node := range root.children {
if !node.matchesOFD("Signature") || current && name == selected && removed[node.attr("ID")] {
continue
}
owned, refs, err := r.signatureRemovalFiles(ctx, signatureRefPath(name, node.attr("BaseLoc")))
if err != nil {
return nil, nil, err
}
maps.Copy(protected, owned)
maps.Copy(retained, owned)
maps.Copy(protected, refs)
maps.Copy(dependencies, refs)
}
}
for name := range retained {
delete(dependencies, name)
}
return protected, dependencies, ctx.Err()
}
// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件 // SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert // SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容 // Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容