From d37f41cd902a62e0ace149d22780715c277e3fe5 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 3 Oct 2026 13:42:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8A=A0=E5=BC=BA=E5=85=BC=E5=AE=B9):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 1 + assets/webui/ofdgo.css | 9 ++++++ assets/webui/ofdgo.js | 56 ++++++++++++++++++++++++++++++++-- assets/webui/ofdgo_edit.js | 6 ++++ internal/webui/wasm.go | 25 ++++++++++++++- internal/webui/webui.go | 53 +++++++++++++++++++++++++++++++- ofdgo_editor_annot.go | 2 +- ofdgo_editor_annot_content.go | 4 +-- ofdgo_editor_annot_ops.go | 2 +- ofdgo_editor_attachment.go | 4 +-- ofdgo_editor_import.go | 14 ++++----- ofdgo_editor_metadata.go | 20 ++++++------ ofdgo_editor_open.go | 4 +-- ofdgo_editor_outline.go | 2 +- ofdgo_editor_package.go | 8 ++--- ofdgo_editor_pages.go | 4 +-- ofdgo_editor_resources.go | 3 +- ofdgo_editor_xml.go | 12 +++++++- ofdgo_reader.go | 44 ++++++++++++++++++++++++--- ofdgo_sign_verify.go | 7 +++-- ofdgo_sign_write.go | 57 +++++++++++++++++++++++++++-------- 21 files changed, 280 insertions(+), 57 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index 7901094..1c17597 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -597,6 +597,7 @@ +
文件
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index f758863..5bfbb34 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1516,6 +1516,15 @@ body[data-hide-meta] .meta-panel { white-space: nowrap; } +.document-select { + width: 100%; + min-width: 0; + height: 28px; + margin-bottom: 10px; + padding: 0 6px; + font-size: 12px; +} + .app-panel { margin-bottom: 12px; padding-bottom: 12px; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 32e4e60..fc36875 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -51,6 +51,7 @@ const state = { editorInfo: null, editorViews: new Map(), savedRevision: null, + packageDirty: false, encryptionDirty: false, insertObject: null, importPageCount: 0, @@ -231,6 +232,7 @@ const el = { textFontAdd: document.querySelector("#textFontAdd"), paragraphButton: document.querySelector("#paragraphButton"), infoButton: document.querySelector("#infoButton"), + documentSelect: document.querySelector("#documentSelect"), infoPanel: document.querySelector("#infoPanel"), infoForm: document.querySelector("#infoForm"), infoTitle: document.querySelector("#infoTitle"), @@ -611,10 +613,12 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { }); function updatePendingChanges() { - setDirty(state.encryptionDirty || Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision + setDirty(state.packageDirty || state.encryptionDirty || Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision || canvasEditor.textChanged() || canvasEditor.cropChanged() || canvasEditor.nudgeChanged())); } +el.documentSelect.addEventListener("change", () => switchDocument(Number(el.documentSelect.value))); + el.editorTools.addEventListener("pointerdown", (event) => { if ((canvasEditor.input || canvasEditor.nudge) && event.target.closest("button")) event.preventDefault(); }); @@ -1929,7 +1933,40 @@ function discardChanges() { function setEditorInfo(doc) { state.editorInfo = { revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo, pageCapabilities: doc.pageCapabilities, editWarnings: doc.editWarnings || [] }; - setDirty(state.encryptionDirty || doc.revision !== state.savedRevision); + setDirty(state.packageDirty || state.encryptionDirty || doc.revision !== state.savedRevision); +} + +async function switchDocument(index) { + if (!state.doc || document.body.hasAttribute("aria-busy") || state.exporting || formDialogOpen()) return; + const previous = state.doc.documentIndex || 0; + if (index === previous) return; + if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) { + el.documentSelect.value = String(previous); + return; + } + const openSeq = ++state.openSeq; + setBusy(true, "正在切换文档"); + try { + const doc = await callWASM("ofdgoSelectDocument", index); + if (openSeq !== state.openSeq) return; + state.packageDirty ||= state.dirty; + state.savedRevision = doc.revision; + state.editorViews.clear(); + state.objectClipboard = state.styleClipboard = null; + resetCompositeScope(); + canvasEditor.clear(); + canvasEditor.setTool(""); + state.ofdBytes = null; + setEditorInfo(doc); + await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale }); + } catch (error) { + if (openSeq === state.openSeq) { + el.documentSelect.value = String(previous); + showError(error, false); + } + } finally { + if (openSeq === state.openSeq) setBusy(false); + } } function editorLocation() { @@ -2110,6 +2147,7 @@ async function createDocument(event) { state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`; state.conversionWarnings = []; state.savedRevision = null; + state.packageDirty = false; state.encryptionDirty = false; setEditorInfo(doc); el.createPanel.close(); @@ -3616,6 +3654,7 @@ async function openOFD(file, remote = null) { state.editorViews.clear(); state.fontRenderPending = false; state.savedRevision = null; + state.packageDirty = false; state.encryptionDirty = false; canvasEditor.clear(); setDirty(false); @@ -5299,6 +5338,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) } if (saving && indices === null && state.editorInfo) { state.savedRevision = state.editorInfo.revision; + state.packageDirty = false; state.encryptionDirty = false; setDirty(false); } @@ -7383,6 +7423,16 @@ function renderMeta(keepDetails = false) { })); }); const doc = state.doc || {}; + el.documentSelect.hidden = (doc.documents?.length || 0) < 2; + renderMetaContent(el.documentSelect, doc.documents || [], () => { + el.documentSelect.replaceChildren(...(doc.documents || []).map(entry => { + const option = document.createElement("option"); + option.value = String(entry.index); + option.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`; + return option; + })); + }); + el.documentSelect.value = String(doc.documentIndex || 0); el.metaPanel.setAttribute("aria-busy", String(!!doc.detailsPending)); document.title = `OFDGo WebUI - ${state.fileName}`; el.metaFile.textContent = state.fileName; @@ -8200,6 +8250,7 @@ function updateControls() { 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); + el.documentSelect.disabled = !hasDoc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); updateDisplayControls(); updateEditorTools(); const pageCount = state.doc ? state.doc.pageCount : 0; @@ -8403,6 +8454,7 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("aria-busy", busy); + el.documentSelect.disabled = busy || !state.doc || !state.ready || state.exporting; renderSecurity(); updateDisplayControls(); el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 0cd77d0..dad9ab8 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -536,6 +536,12 @@ export class CanvasEditor { } start(event) { + if (event.target === this.viewer) { + const rect = this.viewer.getBoundingClientRect(); + const left = rect.left + (this.viewer.clientLeft || 0), top = rect.top + (this.viewer.clientTop || 0); + if (event.clientX < left || event.clientX >= left + this.viewer.clientWidth + || event.clientY < top || event.clientY >= top + this.viewer.clientHeight) return; + } if (this.nudge) { this.commitNudge(); return; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 320f930..6ca0626 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -184,6 +184,7 @@ func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { registerCallback("ofdgoOpen", openDocument) + registerCallback("ofdgoSelectDocument", selectDocument) registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) registerAsyncCallback("ofdgoConvertFile", convertFile) registerAsyncCallback("ofdgoPackFiles", packFiles) @@ -3359,6 +3360,27 @@ func changeAttachment(args []js.Value) (any, error) { }) } +// selectDocument 切换包内文档,保留编辑快照并清除跨文档剪贴板 +// 入参: args 文档索引 +// 返回: any 文档信息, error 错误信息 +func selectDocument(args []js.Value) (any, error) { + if currentSession == nil || len(args) == 0 { + return nil, fmt.Errorf("ofd document is not opened") + } + session, err := currentSession.Document(args[0].Int()) + if err != nil { + return nil, err + } + _ = currentSession.Close() + currentSession, currentEditor = session, session.editor + if pendingImport != nil { + _ = pendingImport.Close() + pendingImport = nil + } + copiedObjects, copiedStyle = nil, nil + return editorSummary(), nil +} + // editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置 // 入参: args 当前页索引,省略时使用首页 // 返回: any 编辑状态, error 错误信息 @@ -3367,6 +3389,7 @@ func editDocument(args []js.Value) (any, error) { return nil, fmt.Errorf("ofd document is not opened") } if currentEditor != nil { + currentSession.editing = true return editorSummary(), nil } editor, err := currentSession.Reader.Editor() @@ -3952,7 +3975,7 @@ func verifySignatures(args []js.Value) (any, error) { Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now, })) } - reports, err := currentSession.Reader.VerifySignatures(options...) + reports, err := currentSession.Reader.VerifyDocumentSignatures(currentSession.Reader.DocumentIndex(), options...) if err != nil { return nil, err } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index b9351ec..81f2c47 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -72,6 +72,8 @@ type pageBoxInfo struct { // DocumentInfo 文档信息 type DocumentInfo struct { + DocumentIndex int `json:"documentIndex"` + Documents []DocumentEntry `json:"documents,omitempty"` Encryption EncryptionInfo `json:"encryption"` Version string `json:"version"` DocType string `json:"docType"` @@ -96,6 +98,12 @@ type DocumentInfo struct { DetailsPending bool `json:"detailsPending,omitempty"` } +// DocumentEntry 包内文档入口,不预先解析其他文档的页面和资源 +type DocumentEntry struct { + Index int `json:"index"` + Title string `json:"title"` +} + // DocumentDetails 不含页面数组的文档补充信息 type DocumentDetails struct { FontCount int `json:"fontCount"` @@ -287,6 +295,45 @@ func (s *Session) Close() error { return nil } +// Document 创建指定文档的会话,保留编辑内容和字体配置,不复用页面缓存 +// 不关闭原会话,输入文件的生命周期由原会话管理 +// 入参: index 文档索引,从0开始 +// 返回: *Session 文档会话, error 错误信息 +func (s *Session) Document(index int) (*Session, error) { + reader := s.Reader + if s.editor != nil { + var err error + reader, err = s.editor.Reader() + if err != nil { + return nil, err + } + } + reader, err := reader.Document(index) + if err != nil { + return nil, err + } + next, err := newSession(reader, OpenOptions{RenderAnnotations: s.Renderer.RenderAnnotations}) + if err != nil { + return nil, err + } + ofdgo.WithRenderBackends(s.Renderer.Backends())(next.Renderer) + next.fontFS = s.fontFS + if next.fontFS != nil { + next.Renderer.SetFontFS(next.fontFS) + } + next.editor, err = reader.Editor() + if err != nil { + return nil, err + } + next.editor.SetHistoryLimit(100) + if next.fontFS != nil { + next.editor.SetFontFS(next.fontFS) + } + next.editor.SetRenderBackends(next.Renderer.Backends()) + next.editing = true + return next, nil +} + // SetFonts 更新字体配置并保留文档、页面和验签结果 // 入参: fonts 字体文件列表 // 返回: error 错误信息 @@ -376,6 +423,7 @@ func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) // 返回: DocumentInfo 文档信息 func (s *Session) Summary() DocumentInfo { info := DocumentInfo{ + DocumentIndex: s.Reader.DocumentIndex(), Encryption: s.encryptionInfo(), Version: s.Reader.Version(), DocType: s.Reader.DocType(), @@ -384,6 +432,9 @@ func (s *Session) Summary() DocumentInfo { Outlines: s.doc.OutlineInfos(), DetailsPending: true, } + for index, body := range s.Reader.OFD.DocBody { + info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title}) + } if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil { info.Title = docInfo.Title info.Author = docInfo.Author @@ -788,7 +839,7 @@ func (s *Session) signatureInfos() ([]SignatureInfo, error) { return s.signatures, s.signatureError } s.signaturesRead = true - reports, err := s.Reader.VerifySignatures() + reports, err := s.Reader.VerifyDocumentSignatures(s.Reader.DocumentIndex()) if err != nil { s.signatureError = err return nil, err diff --git a/ofdgo_editor_annot.go b/ofdgo_editor_annot.go index 0266faa..0b8d8d6 100644 --- a/ofdgo_editor_annot.go +++ b/ofdgo_editor_annot.go @@ -265,7 +265,7 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi if len(patches) != 0 { parts[name] = editorPatchXML(data, patches) if remaining == 0 { - docName, err := editorPageLocation(reader, nil, "", "/"+cleanPackagePath(reader.OFD.DocBody[0].DocRoot)) + docName, err := editorPageLocation(reader, nil, "", "/"+cleanPackagePath(reader.OFD.DocBody[reader.documentIndex].DocRoot)) if err != nil { return err } diff --git a/ofdgo_editor_annot_content.go b/ofdgo_editor_annot_content.go index 2bc2011..1587672 100644 --- a/ofdgo_editor_annot_content.go +++ b/ofdgo_editor_annot_content.go @@ -313,7 +313,7 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error { } parts := map[string][]byte{name: data, file: content} if base.document.Annotations == "" { - docName := cleanPackagePath(reader.OFD.DocBody[0].DocRoot) + docName := cleanPackagePath(reader.OFD.DocBody[reader.documentIndex].DocRoot) docData, err := reader.readFile(docName) if err != nil { return err @@ -391,7 +391,7 @@ func (e *Editor) commitAnnotationParts(base *editorSource, parts map[string][]by } files[name] = data } - reader := &Reader{Zip: base.reader.Zip, files: files} + reader := &Reader{Zip: base.reader.Zip, files: files, encryption: base.reader.encryption, documentIndex: base.reader.documentIndex} if err := reader.initRoot(); err != nil { return err } diff --git a/ofdgo_editor_annot_ops.go b/ofdgo_editor_annot_ops.go index 994fd2d..937a772 100644 --- a/ofdgo_editor_annot_ops.go +++ b/ofdgo_editor_annot_ops.go @@ -140,7 +140,7 @@ func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx, edit.source.annotationStates = states if len(selection.resources) != 0 { reader := edit.source.reader - name := cleanPackagePath(reader.OFD.DocBody[0].DocRoot) + name := cleanPackagePath(reader.OFD.DocBody[reader.documentIndex].DocRoot) data, err := reader.readFile(name) if err != nil { return err diff --git a/ofdgo_editor_attachment.go b/ofdgo_editor_attachment.go index 589ce6a..4b168cd 100644 --- a/ofdgo_editor_attachment.go +++ b/ofdgo_editor_attachment.go @@ -65,7 +65,7 @@ func (e *Editor) AddAttachment(name string, data []byte) (string, error) { return "", err } entry = bytes.TrimPrefix(entry, []byte(xml.Header)) - docName := cleanPackagePath(base.reader.OFD.DocBody[0].DocRoot) + docName := cleanPackagePath(base.reader.OFD.DocBody[base.reader.documentIndex].DocRoot) docData, err := base.reader.readFile(docName) if err != nil { return "", err @@ -190,7 +190,7 @@ func (e *Editor) editAttachment(id string, change func([]byte, *editorXML, map[s if base == nil { return fmt.Errorf("attachment not found: %s", id) } - name := cleanPackagePath(base.reader.OFD.DocBody[0].DocRoot) + name := cleanPackagePath(base.reader.OFD.DocBody[base.reader.documentIndex].DocRoot) external := strings.TrimSpace(base.document.Attachments.Path) != "" if external { name = base.reader.ResPath(base.document.Attachments.Path) diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index c3f6e45..0ba966d 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -224,7 +224,7 @@ func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, pag return copied, err } -// ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 +// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 // 页面索引从0开始,at可等于当前页数;复制关联模板、注释、签章外观和实际引用的资源 // 不导入来源元数据和目录,指向未选页面的跳转被移除;签名数据仅保留原始凭据,不代表合并后文档有效 // 返回后可关闭来源Reader,目标原Reader的生命周期要求不变 @@ -450,7 +450,7 @@ func (e *Editor) prepareImport(source *Reader, doc *Document, selected map[strin for _, resource := range e.resources { m.files[resource.name] = nil } - m.documentXML, err = m.readFile(source.ResPath(source.OFD.DocBody[0].DocRoot)) + m.documentXML, err = m.readFile(source.ResPath(source.OFD.DocBody[source.documentIndex].DocRoot)) if err != nil { return nil, nil, err } @@ -530,7 +530,7 @@ func (m *editorPageImport) readFile(name string) ([]byte, error) { // outlines 筛选指向导入页面的目录树并复用标准动作迁移 // 返回: []byte 目录片段, error 错误信息 func (m *editorPageImport) outlines() ([]byte, error) { - name := m.reader.ResPath(m.reader.OFD.DocBody[0].DocRoot) + name := m.reader.ResPath(m.reader.OFD.DocBody[m.reader.documentIndex].DocRoot) data, err := m.readFile(name) if err != nil { return nil, err @@ -1132,7 +1132,7 @@ func (m *editorPageImport) attachment(id string) (string, error) { if _, exists := m.attachments[id]; exists { return m.id(id), nil } - name := m.reader.ResPath(m.reader.OFD.DocBody[0].DocRoot) + name := m.reader.ResPath(m.reader.OFD.DocBody[m.reader.documentIndex].DocRoot) if m.doc.Attachments.Path != "" { name = m.reader.ResPath(m.doc.Attachments.Path) } @@ -1215,7 +1215,7 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s files = make(map[string][]byte) } maps.Copy(files, m.files) - name := base.reader.ResPath(base.reader.OFD.DocBody[0].DocRoot) + name := base.reader.ResPath(base.reader.OFD.DocBody[base.reader.documentIndex].DocRoot) if file, ok := base.reader.packageFile(name); ok { name = cleanPackagePath(file.Name) } @@ -1318,10 +1318,10 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s if file, ok := base.reader.packageFile(name); ok { name = cleanPackagePath(file.Name) } - files[name] = editorXMLSetText(ofd, root.child("DocBody"), [][2]string{{"Signatures", loc}}) + files[name] = editorXMLSetText(ofd, root.childAt("DocBody", base.reader.documentIndex), [][2]string{{"Signatures", loc}}) } } - reader := &Reader{Zip: base.reader.Zip, files: files} + reader := &Reader{Zip: base.reader.Zip, files: files, encryption: base.reader.encryption, documentIndex: base.reader.documentIndex} if err := reader.initRoot(); err != nil { return nil, err } diff --git a/ofdgo_editor_metadata.go b/ofdgo_editor_metadata.go index 9fe2a70..9e3eb69 100644 --- a/ofdgo_editor_metadata.go +++ b/ofdgo_editor_metadata.go @@ -29,7 +29,7 @@ func (e *Editor) sourceInfoXML() ([]byte, error) { if err != nil { return nil, err } - data, info, err := editorDocInfoXML(data, e.source.fallbackDocID) + data, info, err := editorDocInfoXML(data, e.source.fallbackDocID, e.source.reader.documentIndex) if err != nil { return nil, err } @@ -50,16 +50,16 @@ func (e *Editor) sourceInfoXML() ([]byte, error) { if err != nil { return nil, err } - return editorCustomDatas(data, root.child("DocBody").child("DocInfo"), after.CustomDatas) + return editorCustomDatas(data, root.childAt("DocBody", e.source.reader.documentIndex).child("DocInfo"), after.CustomDatas) } return data, nil } -// editorCreatorXML 更新首份文档的制作软件,移除原软件版本,保留其余根索引内容 -// 入参: data 当前根索引XML, docID 缺少文档描述时使用的标识 +// editorCreatorXML 更新指定文档的制作软件,移除原软件版本,保留其余根索引内容 +// 入参: data 当前根索引XML, docID 缺少文档描述时使用的标识, index 文档索引 // 返回: []byte 修改后的XML, error 解析错误 -func editorCreatorXML(data []byte, docID string) ([]byte, error) { - data, info, err := editorDocInfoXML(data, docID) +func editorCreatorXML(data []byte, docID string, index int) ([]byte, error) { + data, info, err := editorDocInfoXML(data, docID, index) if err != nil { return nil, err } @@ -67,14 +67,14 @@ func editorCreatorXML(data []byte, docID string) ([]byte, error) { } // editorDocInfoXML 获取文档描述,修改缺少描述的源文件时补建标准容器及标识 -// 入参: data 根索引XML, docID 缺少文档描述时使用的标识 +// 入参: data 根索引XML, docID 缺少文档描述时使用的标识, index 文档索引 // 返回: []byte 根索引XML, *editorXML 描述节点, error 解析错误 -func editorDocInfoXML(data []byte, docID string) ([]byte, *editorXML, error) { +func editorDocInfoXML(data []byte, docID string, index int) ([]byte, *editorXML, error) { root, err := parseEditorXML(data) if err != nil { return nil, nil, err } - body := root.child("DocBody") + body := root.childAt("DocBody", index) if body == nil { return nil, nil, fmt.Errorf("document has no DocBody") } @@ -90,7 +90,7 @@ func editorDocInfoXML(data []byte, docID string) ([]byte, *editorXML, error) { if err != nil { return nil, nil, err } - return data, root.child("DocBody").child("DocInfo"), nil + return data, root.childAt("DocBody", index).child("DocInfo"), nil } // editorCustomDatas 更新自定义字段,保留原字段及容器的扩展内容 diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 1dedf4f..b1e062d 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -108,7 +108,7 @@ type PageCapabilities struct { Resize bool } -// Editor 打开主文档进行保真编辑,不修改输入包,不以权限声明或签名限制编辑 +// Editor 打开当前文档进行保真编辑,不修改输入包,不以权限声明或签名限制编辑 // 原权限和签名数据保留,修改受保护内容会使原签名失效,不重新签名 // 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader // 返回: *Editor 编辑器, error 错误信息 @@ -205,7 +205,7 @@ func (c ObjectCapabilities) editError() error { } // cloneEditorReader 复用已验证的只读包索引,独立保存可变文档与读取缓存 -// 入参: reader 已加载主文档的阅读器 +// 入参: reader 已加载当前文档的阅读器 // 返回: *Reader 不拥有输入文件的独立阅读器 func cloneEditorReader(reader *Reader) *Reader { next := *reader diff --git a/ofdgo_editor_outline.go b/ofdgo_editor_outline.go index 07082dd..c55ebe9 100644 --- a/ofdgo_editor_outline.go +++ b/ofdgo_editor_outline.go @@ -264,7 +264,7 @@ func (e *Editor) outlineXML() ([]byte, error) { } if e.source != nil { reader := e.source.reader - data, err := reader.readFile(reader.ResPath(reader.OFD.DocBody[0].DocRoot)) + data, err := reader.readFile(reader.ResPath(reader.OFD.DocBody[reader.documentIndex].DocRoot)) if err != nil { return nil, err } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 76164ca..bc7a4af 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -99,7 +99,7 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) } pagesChanged := !slices.Equal(pageRefs, source.document.Pages.Page) if pagesChanged || len(resourceFiles) != 0 || len(parts) != 0 && e.maxID != source.document.CommonData.MaxUnitID { - name := reader.ResPath(reader.OFD.DocBody[0].DocRoot) + name := reader.ResPath(reader.OFD.DocBody[reader.documentIndex].DocRoot) data, err := reader.readFile(name) if err != nil { return nil, err @@ -199,7 +199,7 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) parts["OFD.xml"] = data } if e.outlines != nil { - name := reader.ResPath(reader.OFD.DocBody[0].DocRoot) + name := reader.ResPath(reader.OFD.DocBody[reader.documentIndex].DocRoot) data, ok := parts[name] if !ok { var err error @@ -468,7 +468,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress return 0, err } } - data, err = editorCreatorXML(data, e.source.fallbackDocID) + data, err = editorCreatorXML(data, e.source.fallbackDocID, reader.documentIndex) if err != nil { return 0, err } @@ -592,7 +592,7 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) { files = make(map[string][]byte) } maps.Copy(files, parts) - reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption} + reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex} if err := reader.initRoot(); err != nil { return nil, err } diff --git a/ofdgo_editor_pages.go b/ofdgo_editor_pages.go index 4b562a5..d5fab85 100644 --- a/ofdgo_editor_pages.go +++ b/ofdgo_editor_pages.go @@ -21,7 +21,7 @@ import ( ) // prunePageReferences 在输出快照中删除已移除页面的标准引用,不修改历史快照或其他文档 -// 沿主文档、页面、模板、资源、注释和签名索引检查,不进入附件、私有扩展和签名原始凭据 +// 沿当前文档、页面、模板、资源、注释和签名索引检查,不进入附件、私有扩展和签名原始凭据 // 入参: parts 输出覆盖条目 // 返回: error 读取或解析错误 func (e *Editor) prunePageReferences(parts map[string][]byte) error { @@ -48,7 +48,7 @@ func (e *Editor) prunePageReferences(parts map[string][]byte) error { bookmarks[bookmark.Name] = true } } - queue := []string{reader.ResPath(reader.OFD.DocBody[0].DocRoot)} + queue := []string{reader.ResPath(reader.OFD.DocBody[reader.documentIndex].DocRoot)} if name := e.source.document.Signatures; name != "" { queue = append(queue, reader.ResPath(name)) } diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go index 37cae65..d051f7b 100644 --- a/ofdgo_editor_resources.go +++ b/ofdgo_editor_resources.go @@ -37,10 +37,11 @@ type editorResourceRefs struct { // compactSourceResources 在保存副本中清理无引用字体、图片和已删除附件,并裁剪可完整确认用字的原有字体 // 按GB/T33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档 // 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过 +// 多文档包保留原资源,避免独立文档的同号资源和共享文件被误删或裁剪 // 入参: parts 已修改和新增的包内条目, progress 保存进度回调 // 返回: map[string]bool 可移除的二进制条目, error 读取错误 func (e *Editor) compactSourceResources(parts map[string][]byte, progress editorProgress) (map[string]bool, error) { - if e.output != nil && e.output.protected { + if e.source.reader.DocumentCount() > 1 || e.output != nil && e.output.protected { return nil, nil } if len(parts) == 0 && e.revision == 0 && (e.output == nil || e.output.options.Mode == CompressionUnchanged) { diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index edc7e89..6a87bee 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -84,9 +84,19 @@ func parseEditorXML(data []byte) (*editorXML, error) { // 入参: name 节点名称 // 返回: *editorXML 节点,不存在时为nil func (n *editorXML) child(name string) *editorXML { + return n.childAt(name, 0) +} + +// childAt 查找指定序号的OFD直接子节点 +// 入参: name 节点名称, index 从0开始的节点序号 +// 返回: *editorXML 节点,不存在时为nil +func (n *editorXML) childAt(name string, index int) *editorXML { for _, child := range n.children { if child.name.Local == name && (child.name.Space == n.name.Space || child.name.Space == ofdNamespace || child.name.Space == "") { - return child + if index == 0 { + return child + } + index-- } } return nil diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 7267d63..56626ac 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -46,6 +46,7 @@ type Reader struct { compositeGraphicUnitCache map[string]*CompositeGraphicUnit pageHeaderCache map[string]PageContent doc *Document + documentIndex int Stamps map[string][]Stamp Annots map[string][]Annotation annotationFiles map[string][]string @@ -67,16 +68,49 @@ func (r *Reader) Close() error { return nil } -// Doc 获取主文档结构 +// DocumentCount 获取包内文档数量 +// 返回: int 文档数量 +func (r *Reader) DocumentCount() int { + if r.OFD == nil { + return 0 + } + return len(r.OFD.DocBody) +} + +// DocumentIndex 获取当前文档索引,从0开始 +// 返回: int 文档索引 +func (r *Reader) DocumentIndex() int { + return r.documentIndex +} + +// Document 创建指定文档的独立阅读器,不改变当前文档或混用资源缓存 +// 返回的阅读器不关闭输入文件,使用期间不得关闭持有输入文件的原阅读器 +// 入参: index 文档索引,从0开始 +// 返回: *Reader 文档阅读器, error 错误信息 +func (r *Reader) Document(index int) (*Reader, error) { + if index < 0 || index >= r.DocumentCount() { + return nil, fmt.Errorf("document index %d out of range", index) + } + next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index} + if err := next.initRoot(); err != nil { + return nil, err + } + if _, err := next.Doc(); err != nil { + return nil, err + } + return next, nil +} + +// Doc 获取当前文档结构 // 返回: *Document 文档结构, error 错误信息 func (r *Reader) Doc() (*Document, error) { if r.doc != nil { return r.doc, nil } - if r.OFD == nil || len(r.OFD.DocBody) == 0 { + if r.OFD == nil || r.documentIndex < 0 || r.documentIndex >= len(r.OFD.DocBody) { return nil, fmt.Errorf("no docbody found") } - docAttr := r.OFD.DocBody[0] + docAttr := r.OFD.DocBody[r.documentIndex] docRootPath := docAttr.DocRoot r.RootDir = path.Dir(docRootPath) data, err := r.readFile(docRootPath) @@ -197,10 +231,10 @@ func (r *Reader) DocType() string { // DocInfo 获取文档元数据 // 返回: *DocInfo 元数据, error 错误信息 func (r *Reader) DocInfo() (*DocInfo, error) { - if r.OFD == nil || len(r.OFD.DocBody) == 0 { + if r.OFD == nil || r.documentIndex < 0 || r.documentIndex >= len(r.OFD.DocBody) { return nil, fmt.Errorf("no docbody found") } - return &r.OFD.DocBody[0].DocInfo, nil + return &r.OFD.DocBody[r.documentIndex].DocInfo, nil } // Permissions 获取文档权限信息 diff --git a/ofdgo_sign_verify.go b/ofdgo_sign_verify.go index 1d5ab1d..7caacb3 100644 --- a/ofdgo_sign_verify.go +++ b/ofdgo_sign_verify.go @@ -299,7 +299,7 @@ func (r *Reader) VerifyDocumentSignatures(index int, opts ...SignatureVerifyOpti } body := r.OFD.DocBody[index] options.DocIndex, options.DocRoot = index, r.signatureCoveragePath(body.DocRoot) - if index == 0 && r.doc != nil && r.doc.Signatures == "" { + if index == r.documentIndex && r.doc != nil && r.doc.Signatures == "" { return nil, nil } data, err := r.readFile(body.DocRoot) @@ -316,7 +316,10 @@ func (r *Reader) VerifyDocumentSignatures(index int, opts ...SignatureVerifyOpti if doc.Signatures == "" { return nil, nil } - view := *r + view := &Reader{Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index} + if err := view.initRoot(); err != nil { + return nil, err + } view.doc, view.RootDir = &doc, path.Dir(body.DocRoot) sigListPath := view.ResPath(doc.Signatures) data, err = view.readFile(sigListPath) diff --git a/ofdgo_sign_write.go b/ofdgo_sign_write.go index 54acc81..f7e579e 100644 --- a/ofdgo_sign_write.go +++ b/ofdgo_sign_write.go @@ -109,6 +109,12 @@ func SignEditorTo(writer io.Writer, editor *Editor, options SignatureWriteOption if err != nil { return 0, err } + if editor.source != nil { + r, err = r.Document(editor.source.reader.DocumentIndex()) + if err != nil { + return 0, err + } + } r.encryption = editor.encryption options.progress = editorProgress(editor.OnWriteProgress) return r.SignTo(writer, options) @@ -167,7 +173,7 @@ func signatureWriteOutput(r *Reader, options SignatureWriteOptions) ([]byte, err if err := options.progress.report("sign", 0, 1); err != nil { return nil, err } - data, err := signatureWritePackage(parts, options) + data, err := signatureWritePackage(parts, options, r.DocumentIndex()) if err != nil { return nil, err } @@ -213,27 +219,49 @@ func signatureWriteParts(r *Reader) (map[string][]byte, error) { // signatureWritePackage 完成输出优化后计算签名,不改写已有签名保护的资源 // 签名目录采用Sign_N兼容OFDRW容器,标识使用独立的sN签章域 -// 入参: parts 独立包文件, options 签署选项 +// 入参: parts 独立包文件, options 签署选项, index 文档索引 // 返回: []byte 完整包, error 错误信息 -func signatureWritePackage(parts map[string][]byte, options SignatureWriteOptions) ([]byte, error) { +func signatureWritePackage(parts map[string][]byte, options SignatureWriteOptions, index int) ([]byte, error) { if err := options.Compression.Validate(); err != nil { return nil, err } if options.Mode != SignatureAppend && options.Mode != SignatureReplace { return nil, fmt.Errorf("unsupported signature write mode") } - r := &Reader{files: parts} + r := &Reader{files: parts, documentIndex: index} if err := r.initRoot(); err != nil { return nil, err } - if len(r.OFD.DocBody) != 1 { - return nil, fmt.Errorf("signature writing requires exactly one document") - } doc, err := r.Doc() if err != nil { return nil, err } - if options.Compression.Mode != CompressionUnchanged && doc.Signatures == "" { + protected := make(map[string][]byte) + for other := range r.OFD.DocBody { + if other == index { + continue + } + view, err := r.Document(other) + if err != nil { + return nil, err + } + if view.doc.Signatures == "" { + continue + } + reports, err := r.VerifyDocumentSignatures(other) + if err != nil { + return nil, err + } + for _, report := range reports { + if report.Error != "" { + return nil, fmt.Errorf("cannot preserve document %d signature: %s", other, report.Error) + } + for _, ref := range report.References { + protected[ref.Path] = bytes.Clone(parts[ref.Path]) + } + } + } + if options.Compression.Mode != CompressionUnchanged && doc.Signatures == "" && len(protected) == 0 { images, err := r.Images(context.Background()) if err != nil { return nil, err @@ -271,7 +299,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption delete(parts, name) } } - r = &Reader{files: parts} + r = &Reader{files: parts, documentIndex: index} if err := r.initRoot(); err != nil { return nil, err } @@ -307,7 +335,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption verifyOptions := append([]SignatureVerifyOption(nil), options.ExistingVerifyOptions...) verifyOptions = append(verifyOptions, WithSignatureCert(identity.cert.Raw)) if options.Mode == SignatureAppend && oldCount > 0 { - reports, err := r.VerifySignatures(verifyOptions...) + reports, err := r.VerifyDocumentSignatures(index, verifyOptions...) if err != nil { return nil, err } @@ -330,7 +358,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption if err != nil { return nil, err } - body := root.child("DocBody") + body := root.childAt("DocBody", index) if body == nil { return nil, fmt.Errorf("missing document body") } @@ -411,6 +439,11 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption if err != nil { return nil, err } + for name, before := range protected { + if !bytes.Equal(before, parts[name]) { + return nil, fmt.Errorf("another document signature protects %s", name) + } + } output, err := signatureWriteZIP(parts, options.Compression) if err != nil { return nil, err @@ -419,7 +452,7 @@ func signatureWritePackage(parts map[string][]byte, options SignatureWriteOption if err != nil { return nil, err } - reports, err := check.VerifySignatures(verifyOptions...) + reports, err := check.VerifyDocumentSignatures(index, verifyOptions...) if err != nil { return nil, err }