diff --git a/assets/webui/index.html b/assets/webui/index.html
index 28d8786..0e01433 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -876,7 +876,7 @@
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 9ff0050..2be4858 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -638,6 +638,7 @@ const el = {
structureStatus: document.querySelector("#structureStatus"),
structureSubmit: document.querySelector("#structureSubmit"),
signatureSummary: document.querySelector("#signatureSummary"),
+ signatureClear: document.querySelector("#signatureClear"),
signatureList: document.querySelector("#signatureList"),
annotationNote: document.querySelector("#annotationNote"),
annotationNoteTitle: document.querySelector("#annotationNoteTitle"),
@@ -1476,6 +1477,7 @@ el.objectStyleForm.addEventListener("submit", async event => {
});
el.outlineCancel.addEventListener("click", () => el.outlinePanel.close());
el.structureManage.addEventListener("click", openStructureDialog);
+el.signatureClear.addEventListener("click", () => changeDocument("ofdgoChangeSignature", null, "clear", ""));
document.querySelector("#structureCancel").addEventListener("click", () => el.structureDialog.close());
el.structureEntry.addEventListener("change", refreshStructureNodes);
el.structureNodeSelect.addEventListener("change", refreshStructureFields);
@@ -9190,6 +9192,10 @@ function renderSecurity() {
el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || state.saving || document.body.hasAttribute("data-busy");
el.verifyButton.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;
+ if (!state.doc?.detailsPending) renderMetaContent(el.signatureList, [documentTab().id, documentContentKey(state), state.doc?.signatures, state.doc?.signatureError, state.editing], renderSignatures);
+ for (const button of el.signatureList.querySelectorAll(".signature-remove")) button.disabled = el.signatureClear.disabled;
}
function cancelSigning() {
@@ -11540,7 +11546,6 @@ function renderMeta(keepDetails = false) {
el.pageTotal.textContent = String(doc.pageCount || 0);
if (keepDetails && doc.detailsPending) return;
el.metaSignatures.textContent = doc.detailsPending ? "正在检查签名" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0);
- renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures);
updateStructureControls();
renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts);
refreshEditorFonts();
@@ -11811,6 +11816,18 @@ function renderSignatures() {
badges.className = "signature-badges";
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);
row.append(head);
appendInfoLine(row, "签署人", signature.signer);
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index d744479..5d20e70 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -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 调用库层字体资源操作,统一更新预览与撤销状态
// 入参: args 字体标识、保存方式及可选字体数据
// 返回: any 编辑状态, error 字体处理错误
@@ -7980,6 +7999,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
"ofdgoEditDocument": e.editDocument,
"ofdgoUpdateInfo": e.updateInfo,
"ofdgoChangeAttachment": e.changeAttachment,
+ "ofdgoChangeSignature": e.changeSignature,
"ofdgoChangePage": e.changePage,
"ofdgoBatchPages": e.batchPages,
"ofdgoChangeOutline": e.changeOutline,
@@ -8068,6 +8088,8 @@ func (e *businessEngine) operationContext() context.Context {
func failureResult(err error) apiResult {
result := apiResult{Error: err.Error()}
switch {
+ case errors.Is(err, ofdgo.ErrSignatureDependency):
+ result.Code, result.Error = "signatureDependency", "存在签名或版本依赖,无法移除"
case errors.Is(err, ofdgo.ErrIncompleteDocument):
result.Code, result.Error = "documentPending", "文档尚未准备完成"
case errors.Is(err, ofdgo.ErrPrintDenied):
diff --git a/ofdgo_sign_write.go b/ofdgo_sign_write.go
index 8cc7009..ea8f4ee 100644
--- a/ofdgo_sign_write.go
+++ b/ofdgo_sign_write.go
@@ -21,8 +21,10 @@ import (
"crypto"
"encoding/base64"
"encoding/xml"
+ "errors"
"fmt"
"io"
+ "maps"
"path"
"slices"
"strconv"
@@ -40,6 +42,256 @@ const (
// SignatureWriteMode 签署方式
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, references, 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 {
+ for file := range candidates {
+ if references[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 {
+ if references[name] {
+ return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, name)
+ }
+ 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)
+ references := 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(protected, refs)
+ maps.Copy(references, refs)
+ }
+ }
+ return protected, references, ctx.Err()
+}
+
// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容