From 0385e8c5fdbce31b00eb83469d21ac54551beb87 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 3 Oct 2026 14:39:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=87=E6=A1=A3=E5=A2=9E=E5=88=A0=E6=94=B9?= =?UTF-8?q?=E5=8F=8A=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 14 +- assets/webui/ofdgo.css | 23 ++- assets/webui/ofdgo.js | 83 +++++++- internal/webui/wasm.go | 50 ++++- internal/webui/webui.go | 4 +- ofdgo_editor_documents.go | 417 ++++++++++++++++++++++++++++++++++++++ ofdgo_editor_history.go | 62 ++++-- 7 files changed, 625 insertions(+), 28 deletions(-) create mode 100644 ofdgo_editor_documents.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 1c17597..1aa4870 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -594,10 +594,20 @@
文档
- +
- +
文件
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 5bfbb34..a0dc6de 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1516,11 +1516,28 @@ body[data-hide-meta] .meta-panel { white-space: nowrap; } +.document-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; +} + +.document-row .panel-actions { + flex-wrap: nowrap; +} + +.document-row .small-button { + width: 28px; + height: 28px; + padding: 0; +} + .document-select { - width: 100%; + flex: 1; + width: 0; min-width: 0; height: 28px; - margin-bottom: 10px; padding: 0 6px; font-size: 12px; } @@ -1914,6 +1931,8 @@ button.outline-link:hover { .editor-group > [hidden], .panel-row > [hidden], .panel-actions > [hidden], +.document-row[hidden], +.document-row > [hidden], .meta-list > [hidden], .navigation-tabs[hidden], .page-selection-tools[hidden], diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index fc36875..272381c 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -59,6 +59,7 @@ const state = { importing: false, imageImporting: false, createInsert: false, + createPackage: false, pageSelection: new Set(), pageSelectionAnchor: null, pageMultiSelect: false, @@ -233,6 +234,13 @@ const el = { paragraphButton: document.querySelector("#paragraphButton"), infoButton: document.querySelector("#infoButton"), documentSelect: document.querySelector("#documentSelect"), + documentActions: document.querySelector("#documentActions"), + documentRow: document.querySelector("#documentRow"), + documentOrder: document.querySelector("#documentOrder"), + documentAddButton: document.querySelector("#documentAddButton"), + documentDeleteButton: document.querySelector("#documentDeleteButton"), + documentUpButton: document.querySelector("#documentUpButton"), + documentDownButton: document.querySelector("#documentDownButton"), infoPanel: document.querySelector("#infoPanel"), infoForm: document.querySelector("#infoForm"), infoTitle: document.querySelector("#infoTitle"), @@ -670,6 +678,18 @@ editorClick(el.paragraphButton, () => { el.paragraphPanel.showModal(); }); el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); +editorClick(el.documentAddButton, () => openCreatePanel(false, true)); +editorClick(el.documentDeleteButton, () => { + if (window.confirm("删除当前文档?")) return changeDocument("ofdgoChangeDocument", null, "delete", state.doc.documentIndex || 0); +}); +editorClick(el.documentUpButton, () => { + const index = state.doc.documentIndex || 0; + return changeDocument("ofdgoChangeDocument", null, "move", index, index - 1); +}); +editorClick(el.documentDownButton, () => { + const index = state.doc.documentIndex || 0; + return changeDocument("ofdgoChangeDocument", null, "move", index, index + 1); +}); editorClick(el.infoButton, () => { for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || ""; el.infoCustomRows.replaceChildren(); @@ -1956,6 +1976,8 @@ async function switchDocument(index) { resetCompositeScope(); canvasEditor.clear(); canvasEditor.setTool(""); + delete state.textDefaults.fontChoice; + updateTextFonts(null, true); state.ofdBytes = null; setEditorInfo(doc); await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale }); @@ -2058,12 +2080,13 @@ function confirmTextReflow(item) { || window.confirm("将重新排版原文,是否继续?")); } -function openCreatePanel(insert = false) { +function openCreatePanel(insert = false, packageDocument = false) { if (document.body.hasAttribute("aria-busy")) { return; } el.createForm.reset(); state.createInsert = insert === true; + state.createPackage = packageDocument === true; el.createMode.value = state.createInsert ? "images" : "blank"; el.createSubmit.textContent = state.createInsert ? "插入" : "新建"; el.createPanel.setAttribute("aria-label", state.createInsert ? "插入图片" : "新建文档"); @@ -2076,7 +2099,7 @@ function openCreatePanel(insert = false) { function updateCreateOptions() { const images = el.createMode.value === "images", busy = Boolean(state.imageImporting); const toggle = (id, visible) => { document.getElementById(id).hidden = !visible; }; - toggle("createModeRow", !state.createInsert); + toggle("createModeRow", !state.createInsert && !state.createPackage); el.createName.closest(".form-row").hidden = Boolean(state.createInsert); el.createName.disabled = busy || Boolean(state.createInsert); el.createMode.disabled = busy; @@ -2105,6 +2128,11 @@ function imagePageRequest() { async function createDocument(event) { event.preventDefault(); + if (state.createPackage) { + if (await changeDocument("ofdgoChangeDocument", null, "add", el.createName.value.trim() || "未命名", + Number(el.createWidth.value), Number(el.createHeight.value), (state.doc.documentIndex || 0) + 1)) el.createPanel.close(); + return; + } if (state.createInsert) { const at = { before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.createPosition.value]; if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) el.createPanel.close(); @@ -2888,6 +2916,36 @@ async function changeDocument(name, item, ...args) { if (doc.revision === state.editorInfo?.revision) { return true; } + const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0]; + const nextDocument = doc.documents?.[doc.documentIndex || 0]; + const documentChanged = previousDocument?.root !== nextDocument?.root || previousDocument?.id !== nextDocument?.id; + const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring + && JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id])); + if (packageChanged) { + resetCompositeScope(); + state.objectClipboard = state.styleClipboard = null; + state.pageSelection.clear(); + state.pageSelectionAnchor = null; + state.pageMultiSelect = false; + state.outlineSelection = null; + state.outlineExpanded.clear(); + canvasEditor.clear(); + canvasEditor.setTool(""); + if (documentChanged) { + delete state.textDefaults.fontChoice; + updateTextFonts(null, true); + } + state.ofdBytes = null; + setEditorInfo(doc); + openSeq = ++state.openSeq; + await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: documentChanged, clearSelection: true, + pageIndex: documentChanged ? 0 : state.pageIndex, fitMode: state.fitMode, scale: state.scale }); + if (openSeq === state.openSeq) { + if (!restoring) rememberEditorView(before, revision); + return true; + } + return; + } const view = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null; const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath", "ofdgoGroupObjects"].includes(name) || inserting || name === "ofdgoPasteObjects" && !!args[4]); @@ -2907,7 +2965,8 @@ async function changeDocument(name, item, ...args) { } setEditorInfo(doc); if (name === "ofdgoUpdateInfo") { - Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData }); + Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData, + documents: doc.documents, documentIndex: doc.documentIndex }); renderMeta(); updateControls(); rememberEditorView(before, revision); @@ -7423,7 +7482,7 @@ function renderMeta(keepDetails = false) { })); }); const doc = state.doc || {}; - el.documentSelect.hidden = (doc.documents?.length || 0) < 2; + updateDocumentControls(); renderMetaContent(el.documentSelect, doc.documents || [], () => { el.documentSelect.replaceChildren(...(doc.documents || []).map(entry => { const option = document.createElement("option"); @@ -8245,12 +8304,24 @@ function updateFitSpace() { el.pageFrame.style.setProperty("--fit-gap", `${gap}px`); } +function updateDocumentControls() { + const count = state.doc?.documents?.length || 0, index = state.doc?.documentIndex || 0; + const disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); + el.documentActions.hidden = el.documentOrder.hidden = !state.editing; + el.documentRow.hidden = !count || !state.editing && count < 2; + el.documentSelect.disabled = disabled; + el.documentAddButton.disabled = el.infoButton.disabled = disabled || !state.editing; + el.documentDeleteButton.disabled = disabled || !state.editing || count <= 1; + el.documentUpButton.disabled = disabled || !state.editing || index === 0; + el.documentDownButton.disabled = disabled || !state.editing || index >= count - 1; +} + function updateControls() { renderSecurity(); 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"); + updateDocumentControls(); updateDisplayControls(); updateEditorTools(); const pageCount = state.doc ? state.doc.pageCount : 0; @@ -8454,7 +8525,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; + updateDocumentControls(); renderSecurity(); updateDisplayControls(); el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 6ca0626..343052b 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -185,6 +185,7 @@ func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { func RunWASM() { registerCallback("ofdgoOpen", openDocument) registerCallback("ofdgoSelectDocument", selectDocument) + registerCallback("ofdgoChangeDocument", changePackageDocument) registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) registerAsyncCallback("ofdgoConvertFile", convertFile) registerAsyncCallback("ofdgoPackFiles", packFiles) @@ -1153,7 +1154,7 @@ func editorSummary() editorInfo { if !currentSession.doc.Permissions.Edit { info.EditWarnings = append(info.EditWarnings, "原文件声明不允许编辑") } - if currentSession.doc.Signatures != "" { + if slices.ContainsFunc(currentSession.Reader.OFD.DocBody, func(body ofdgo.DocBody) bool { return body.Signatures != "" }) { info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效") } info.PageCapabilities = make([]uint, currentEditor.PageCount()) @@ -3360,6 +3361,47 @@ func changeAttachment(args []js.Value) (any, error) { }) } +// changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构 +// 入参: args 操作及标题、页面尺寸、文档索引或移动位置 +// 返回: any 文档信息, error 错误信息 +func changePackageDocument(args []js.Value) (any, error) { + if currentEditor == nil || len(args) == 0 { + return nil, fmt.Errorf("no document is being edited") + } + return changeAtomicObjects(func() error { + var err error + switch args[0].String() { + case "add": + if len(args) != 5 { + return fmt.Errorf("invalid document arguments") + } + _, err = currentEditor.AddDocument(args[1].String(), args[2].Float(), args[3].Float(), args[4].Int()) + case "delete": + if len(args) != 2 { + return fmt.Errorf("invalid document arguments") + } + err = currentEditor.DeleteDocument(args[1].Int()) + case "move": + if len(args) != 3 { + return fmt.Errorf("invalid document arguments") + } + err = currentEditor.MoveDocument(args[1].Int(), args[2].Int()) + case "rename": + if len(args) != 3 { + return fmt.Errorf("invalid document arguments") + } + err = currentEditor.RenameDocument(args[1].Int(), args[2].String()) + default: + return fmt.Errorf("invalid document operation") + } + if err == nil { + copiedObjects, copiedStyle = nil, nil + clearImport() + } + return err + }) +} + // selectDocument 切换包内文档,保留编辑快照并清除跨文档剪贴板 // 入参: args 文档索引 // 返回: any 文档信息, error 错误信息 @@ -3457,6 +3499,12 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { } editor.SetFontFS() if currentSession != nil { + previous := currentSession.Reader + oldBody, newBody := previous.OFD.DocBody[previous.DocumentIndex()], reader.OFD.DocBody[reader.DocumentIndex()] + if oldBody.DocRoot != newBody.DocRoot || oldBody.DocInfo.DocID != newBody.DocInfo.DocID { + copiedObjects, copiedStyle = nil, nil + clearImport() + } ofdgo.WithRenderBackends(currentSession.Renderer.Backends())(session.Renderer) if currentSession.fontFS != nil { session.fontFS = currentSession.fontFS diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 81f2c47..51056ba 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -102,6 +102,8 @@ type DocumentInfo struct { type DocumentEntry struct { Index int `json:"index"` Title string `json:"title"` + Root string `json:"root"` + ID string `json:"id"` } // DocumentDetails 不含页面数组的文档补充信息 @@ -433,7 +435,7 @@ func (s *Session) Summary() DocumentInfo { DetailsPending: true, } for index, body := range s.Reader.OFD.DocBody { - info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title}) + info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID}) } if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil { info.Title = docInfo.Title diff --git a/ofdgo_editor_documents.go b/ofdgo_editor_documents.go new file mode 100644 index 0000000..eec3353 --- /dev/null +++ b/ofdgo_editor_documents.go @@ -0,0 +1,417 @@ +// 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 ( + "archive/zip" + "bufio" + "bytes" + "encoding/xml" + "fmt" + "io" + "maps" + "path" + "slices" + "strings" +) + +// DocumentCount 获取编辑包内的文档数量,新建编辑器包含一个当前文档 +// 返回: int 文档数量 +func (e *Editor) DocumentCount() int { + if e.source == nil { + return 1 + } + return e.source.reader.DocumentCount() +} + +// DocumentIndex 获取当前编辑文档索引,从0开始 +// 返回: int 文档索引 +func (e *Editor) DocumentIndex() int { + if e.source == nil { + return 0 + } + return e.source.reader.DocumentIndex() +} + +// AddDocument 在包内插入含一页的空白文档并选中,不修改输入包 +// 当前文档的修改保留,新文档使用独立目录和文档标识;可通过Undo撤销 +// 入参: title 文档标题, width 页面宽度, height 页面高度, at 插入位置,从0开始,可等于文档数量 +// 返回: int 新文档索引, error 创建错误 +func (e *Editor) AddDocument(title string, width, height float64, at int) (int, error) { + if at < 0 || at > e.DocumentCount() { + return 0, fmt.Errorf("document index %d out of range", at) + } + created := NewEditor() + created.Info.Title = title + if _, err := created.AddPage(width, height); err != nil { + return 0, err + } + err := e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + directory := "" + for number := 0; ; number++ { + candidate := fmt.Sprintf("Doc_%d", number) + occupied := false + for name := range reader.fileNamesFold { + if name == strings.ToLower(candidate) || strings.HasPrefix(name, strings.ToLower(candidate)+"/") { + occupied = true + break + } + } + if !occupied { + directory = candidate + break + } + } + createdReader, err := created.Reader() + if err != nil { + return 0, err + } + for name, content := range createdReader.files { + if name == "OFD.xml" { + continue + } + reader.files[directory+strings.TrimPrefix(name, "Doc_0")] = content + } + body, err := encodeOFDXML(func(x *ofdXML) { + x.root("DocBody", nil) + x.start("DocInfo", nil) + for _, field := range [][2]string{{"DocID", created.Info.DocID}, {"Title", title}, {"CreationDate", created.Info.CreationDate}, {"Creator", ofdCreator}} { + if field[1] != "" { + x.text(field[0], field[1]) + } + } + x.end("DocInfo") + x.text("DocRoot", directory+"/Document.xml") + x.end("DocBody") + }) + if err != nil { + return 0, err + } + position := root.close + if existing := root.childAt("DocBody", at); existing != nil { + position = existing.start + } + reader.files[reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(body, []byte(xml.Header))}}) + return at, nil + }) + if err != nil { + return 0, err + } + return at, nil +} + +// RenameDocument 修改指定文档标题,保留其余元数据和内容,可通过Undo撤销 +// 入参: index 文档索引,从0开始, title 标题,空值移除标题 +// 返回: error 修改错误 +func (e *Editor) RenameDocument(index int, title string) error { + if index < 0 || index >= e.DocumentCount() { + return fmt.Errorf("document index %d out of range", index) + } + if index == e.DocumentIndex() { + info := e.Info + info.Title = title + e.SetInfo(info) + return nil + } + if e.source.reader.OFD.DocBody[index].DocInfo.Title == title { + return nil + } + return e.editDocuments(func(reader *Reader, data []byte, _ *editorXML) (int, error) { + data, info, err := editorDocInfoXML(data, NewEditor().Info.DocID, index) + if err != nil { + return 0, err + } + reader.files[reader.fileNamesFold["ofd.xml"]] = editorXMLSetText(data, info, [][2]string{{"Title", title}}) + return reader.DocumentIndex(), nil + }) +} + +// MoveDocument 调整包内文档顺序,当前文档随条目移动,不重写内容,可通过Undo撤销 +// 入参: from 原文档索引, to 目标索引,均从0开始 +// 返回: error 移动错误 +func (e *Editor) MoveDocument(from, to int) error { + if from < 0 || from >= e.DocumentCount() || to < 0 || to >= e.DocumentCount() { + return fmt.Errorf("document index out of range") + } + if from == to { + return nil + } + return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + bodies := make([][]byte, reader.DocumentCount()) + for index := range bodies { + body := root.childAt("DocBody", index) + bodies[index] = data[body.start:body.end] + } + moveEditorItem(bodies, from, to) + var patches []editorXMLPatch + for index, content := range bodies { + body := root.childAt("DocBody", index) + patches = append(patches, editorXMLPatch{body.start, body.end, content}) + } + reader.files[reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, patches) + selected := reader.DocumentIndex() + if selected == from { + selected = to + } else if from < selected && to >= selected { + selected-- + } else if from > selected && to <= selected { + selected++ + } + return selected, nil + }) +} + +// DeleteDocument 删除指定文档并清理独占文件,保留其他文档的共享资源,可通过Undo撤销 +// 包内至少保留一个文档;删除当前文档时选中后一个,末尾则选中前一个 +// 原签名保留,修改根索引或受保护文件可能使原签名失效 +// 入参: index 文档索引,从0开始 +// 返回: error 删除错误 +func (e *Editor) DeleteDocument(index int) error { + if index < 0 || index >= e.DocumentCount() { + return fmt.Errorf("document index %d out of range", index) + } + if e.DocumentCount() == 1 { + return fmt.Errorf("package must contain at least one document") + } + return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + removed, err := editorDocumentFiles(reader, index) + if err != nil { + return 0, err + } + directory := path.Dir(cleanPackagePath(reader.OFD.DocBody[index].DocRoot)) + if directory != "." && !slices.ContainsFunc(reader.OFD.DocBody, func(body DocBody) bool { + name := cleanPackagePath(body.DocRoot) + return name != cleanPackagePath(reader.OFD.DocBody[index].DocRoot) && strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/") + }) { + for name := range reader.fileNamesFold { + if strings.HasPrefix(name, strings.ToLower(directory)+"/") { + removed[name] = true + } + } + } + for other := range reader.OFD.DocBody { + if other == index { + continue + } + kept, err := editorDocumentFiles(reader, other) + if err != nil { + return 0, err + } + for name := range kept { + delete(removed, name) + } + } + delete(removed, "ofd.xml") + for name := range reader.files { + if removed[strings.ToLower(name)] { + delete(reader.files, name) + } + } + if reader.Zip != nil { + reader.Zip = &zip.Reader{Comment: reader.Zip.Comment, File: slices.DeleteFunc(slices.Clone(reader.Zip.File), func(file *zip.File) bool { + return removed[strings.ToLower(cleanPackagePath(file.Name))] + })} + } + body := root.childAt("DocBody", index) + reader.files[reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, []editorXMLPatch{{start: body.start, end: body.end}}) + selected := reader.DocumentIndex() + if selected > index { + selected-- + } else if selected == index { + selected = min(index, reader.DocumentCount()-2) + } + return selected, nil + }) +} + +// editDocuments 在独立包快照中修改文档入口,失败时不改变当前状态或历史 +// 入参: edit 修改包快照并返回新的当前文档索引 +// 返回: error 修改错误 +func (e *Editor) editDocuments(edit func(*Reader, []byte, *editorXML) (int, error)) error { + reader, err := e.Reader() + if err != nil { + return err + } + data, err := reader.readFile("OFD.xml") + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + reader.files = maps.Clone(reader.files) + index, err := edit(reader, data, root) + if err != nil { + return err + } + before := e.transactionSnapshot() + reader.documentIndex, reader.doc = index, nil + if err := reader.initRoot(); err != nil { + return err + } + if _, err := reader.Doc(); err != nil { + return err + } + if e.source != nil && e.documentRoot() == reader.OFD.DocBody[index].DocRoot && e.Info.DocID == reader.OFD.DocBody[index].DocInfo.DocID { + source := *e.source + source.reader = reader + e.source = &source + } else { + next, err := reader.Editor() + if err != nil { + return err + } + next.history, next.historyIndex, next.historyLimit = e.history, e.historyIndex, e.historyLimit + next.revision, next.serial = e.revision, e.serial + next.OnWriteProgress, next.output = e.OnWriteProgress, e.output + next.encryption = e.encryption + next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS + *e = *next + } + e.recordTransaction(before) + return nil +} + +// documentRoot 获取当前文档的包内入口,区分不同文档的资源和标识域 +// 返回: string 文档入口,新建单文档编辑器为空 +func (e *Editor) documentRoot() string { + if e.source == nil { + return "" + } + return e.source.reader.OFD.DocBody[e.DocumentIndex()].DocRoot +} + +// sameDocument 判断快照是否属于同一文档,区别删除后复用目录的新文档 +// 入参: other 编辑快照 +// 返回: bool 是否为同一文档 +func (e *Editor) sameDocument(other *Editor) bool { + if e.source == nil || other.source == nil { + return e.source == nil && other.source == nil + } + return e.documentRoot() == other.documentRoot() && e.source.info.DocID == other.source.info.DocID +} + +// editorDocumentFiles 按标准路径遍历文档依赖,签名摘要引用不作为内容依赖 +// 入参: reader 包快照, index 文档索引 +// 返回: map[string]bool 大小写折叠的文件路径, error 读取错误 +func editorDocumentFiles(reader *Reader, index int) (map[string]bool, error) { + body := reader.OFD.DocBody[index] + queue := []string{cleanPackagePath(body.DocRoot)} + data, err := reader.readFile("OFD.xml") + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var collect func(*editorXML) + collect = func(node *editorXML) { + if node.name.Local == "Cover" || node.name.Local == "Signatures" || node.name.Local == "DocRoot" { + if value := strings.TrimSpace(editorImportText(data, node)); value != "" { + queue = append(queue, resolveResourcePath("OFD.xml", "", value)) + } + } + if node.name.Local == "Version" && node.attr("BaseLoc") != "" { + queue = append(queue, resolveResourcePath("OFD.xml", "", node.attr("BaseLoc"))) + } + for _, child := range node.children { + collect(child) + } + } + collect(root.childAt("DocBody", index)) + seen := make(map[string]bool) + for len(queue) > 0 { + name := queue[0] + queue = queue[1:] + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + actual, exists := reader.fileNamesFold[key] + if !exists { + continue + } + input, err := reader.openFile(actual) + if err != nil { + return nil, err + } + buffer := bufio.NewReader(input) + if prefix, _ := buffer.Peek(3); bytes.Equal(prefix, []byte{0xef, 0xbb, 0xbf}) { + buffer.Discard(3) + } + header, _ := buffer.Peek(128) + for len(header) > 0 && len(bytes.TrimSpace(header)) == 0 { + buffer.Discard(len(header)) + header, _ = buffer.Peek(128) + } + if !bytes.HasPrefix(bytes.TrimSpace(header), []byte("<")) { + input.Close() + continue + } + decoder := xml.NewDecoder(buffer) + base, resource, depth := "", false, 0 + var text strings.Builder + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + input.Close() + return nil, err + } + switch node := token.(type) { + case xml.StartElement: + if depth == 0 && node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" { + input.Close() + goto nextFile + } + if node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" { + input.Close() + return nil, fmt.Errorf("cannot determine document dependencies in %s", actual) + } + if depth == 0 { + resource = node.Name.Local == "Res" + } + for _, attr := range node.Attr { + if resource && depth == 0 && attr.Name.Local == "BaseLoc" { + base = attr.Value + } else if attr.Name.Local == "BaseLoc" || attr.Name.Local == "FileLoc" || node.Name.Local == "DrawParam" && attr.Name.Local == "Link" { + queue = append(queue, resolveResourcePath(actual, base, attr.Value)) + } + } + depth++ + text.Reset() + case xml.CharData: + text.Write(node) + case xml.EndElement: + switch node.Name.Local { + case "DocRoot", "Signatures", "Cover", "PublicRes", "DocumentRes", "PageRes", "Annotations", "Attachments", "FileLoc", "Profile", "SignedValue", "SchemaLoc", "ExtendData", "File", "FontFile", "MediaFile", "CustomTags", "Extensions": + if value := strings.TrimSpace(text.String()); value != "" { + queue = append(queue, resolveResourcePath(actual, base, value)) + } + } + depth-- + text.Reset() + } + } + input.Close() + nextFile: + } + return seen, nil +} diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index d4da115..e5725f2 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -49,15 +49,14 @@ func (e *Editor) Transaction(edit func(*Editor) error) error { changed := e.revision != before.revision e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit e.backends, e.fontDirs, e.fontFS = before.backends, before.fontDirs, before.fontFS - e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics + if e.sameDocument(&before) { + e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics + } else { + e.fontRenderer, e.fontMetrics = nil, make(map[string]FontMetrics) + } if changed { e.serial, e.revision = before.serial, before.revision - if change := e.recordChange(); change != nil { - after := e.transactionSnapshot() - before.history, after.history = nil, nil - change.undo = func(e *Editor) { e.restoreTransaction(before) } - change.redo = func(e *Editor) { e.restoreTransaction(after) } - } + e.recordTransaction(before) } committed = true return nil @@ -65,7 +64,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error { // SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录 // 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识 -// 记录页面、对象及SetInfo操作,不包含Info的直接修改及字体、图片注册,资源继续共享 +// 记录文档、页面、对象及SetInfo操作,不包含Info的直接修改及字体、图片注册 // 入参: limit 最大记录数 func (e *Editor) SetHistoryLimit(limit int) { e.historyLimit = max(0, limit) @@ -149,23 +148,54 @@ func (e *Editor) transactionSnapshot() Editor { return next } -// restoreTransaction 恢复文档状态并保留历史容器、共享资源和递增标识 +// restoreTransaction 恢复文档状态,保留历史和配置,仅在同一文档内共享资源 // 入参: state 文档状态 func (e *Editor) restoreTransaction(state Editor) { state = state.transactionSnapshot() - if len(e.resources) > len(state.resources) { - state.resources = append(state.resources, e.resources[len(state.resources):]...) + state.retainDocumentResources(e) + if !e.sameDocument(&state) { + state.fontRenderer, state.fontMetrics = nil, make(map[string]FontMetrics) } - maps.Copy(state.fonts, e.fonts) - maps.Copy(state.images, e.images) - maps.Copy(state.resourceID, e.resourceID) state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit - state.serial, state.maxID = max(e.serial, state.serial), max(e.maxID, state.maxID) + state.serial = max(e.serial, state.serial) state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS - state.fontRenderer, state.fontMetrics = e.fontRenderer, e.fontMetrics + state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress *e = state } +// retainDocumentResources 保留同一文档后续注册的资源,不跨文档混用编号和字体缓存 +// 入参: source 当前文档状态 +func (e *Editor) retainDocumentResources(source *Editor) { + if !e.sameDocument(source) { + return + } + if len(source.resources) > len(e.resources) { + e.resources = append(e.resources, source.resources[len(e.resources):]...) + } + maps.Copy(e.fonts, source.fonts) + maps.Copy(e.images, source.images) + maps.Copy(e.resourceID, source.resourceID) + e.maxID = max(e.maxID, source.maxID) + e.fontRenderer, e.fontMetrics = source.fontRenderer, source.fontMetrics +} + +// recordTransaction 记录事务快照,跨文档撤销前保留各文档后续注册的资源 +// 入参: before 修改前的文档状态 +func (e *Editor) recordTransaction(before Editor) { + if change := e.recordChange(); change != nil { + after := e.transactionSnapshot() + before.history, after.history = nil, nil + change.undo = func(e *Editor) { + after.retainDocumentResources(e) + e.restoreTransaction(before) + } + change.redo = func(e *Editor) { + before.retainDocumentResources(e) + e.restoreTransaction(after) + } + } +} + // recordChange 分配修订标识并为已完成的有效修改预留记录 // 返回: *editorChange 记录,未启用历史时为nil func (e *Editor) recordChange() *editorChange {