diff --git a/assets/webui/index.html b/assets/webui/index.html index 5f2df40..1c1c744 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -344,9 +344,10 @@
- -
-
+ + +
+
@@ -397,6 +398,7 @@
+ diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 325ef31..3c29d33 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -64,6 +64,8 @@ const state = { encryptionDirty: false, insertObject: null, importPageCount: 0, + importDocumentCount: 0, + importDocumentMode: false, importEncrypted: false, importing: false, imageImporting: false, @@ -263,6 +265,7 @@ const el = { documentClose: document.querySelector("#documentClose"), documentRow: document.querySelector("#documentRow"), documentAddButton: document.querySelector("#documentAddButton"), + documentImportButton: document.querySelector("#documentImportButton"), documentDeleteButton: document.querySelector("#documentDeleteButton"), documentUpButton: document.querySelector("#documentUpButton"), documentDownButton: document.querySelector("#documentDownButton"), @@ -305,7 +308,11 @@ const el = { importRange: document.querySelector("#importRange"), importPagesRow: document.querySelector("#importPagesRow"), importPages: document.querySelector("#importPages"), + importPagesLabel: document.querySelector("#importPagesLabel"), + importDocuments: document.querySelector("#importDocuments"), importPosition: document.querySelector("#importPosition"), + importPositionRow: document.querySelector("#importPositionRow"), + importOutlinesRow: document.querySelector("#importOutlinesRow"), importOutlines: document.querySelector("#importOutlines"), importStatus: document.querySelector("#importStatus"), importCancel: document.querySelector("#importCancel"), @@ -757,6 +764,7 @@ editorClick(el.paragraphButton, () => { }); el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); editorClick(el.documentAddButton, () => openCreatePanel(false, true)); +editorClick(el.documentImportButton, () => openImportPanel(true)); editorClick(el.documentManageButton, openDocumentManagement); resourceElements.ManageButton.addEventListener("click", openResourceManagement); resourceElements.Close.addEventListener("click", () => resourceElements.Panel.close()); @@ -1149,6 +1157,8 @@ el.importPanel.addEventListener("cancel", event => { }); el.importPanel.addEventListener("close", () => { state.importPageCount = 0; + state.importDocumentCount = 0; + state.importDocumentMode = false; state.importEncrypted = false; el.importForm.reset(); if (state.ready) callWASM("ofdgoLoadImport", null).catch(err => showError(err, false)); @@ -1163,14 +1173,17 @@ el.importRange.addEventListener("change", () => { }); el.importForm.addEventListener("submit", async event => { event.preventDefault(); - if (document.body.hasAttribute("data-busy") || !state.importPageCount || !el.importForm.reportValidity()) return; - const position = { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value]; + if (document.body.hasAttribute("data-busy") || !(state.importDocumentMode ? state.importDocumentCount : state.importPageCount) || !el.importForm.reportValidity()) return; + const position = state.importDocumentMode ? managedDocumentIndex() + 1 : { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value]; const options = []; if (state.importEncrypted) { if (!window.confirm(state.doc.encryption?.encrypted ? "按目标文档的加密策略导入,是否继续?" : "目标未加密,导入内容将按明文保存,是否继续?")) return; options.push(true); } - const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position, el.importOutlines.checked, ...options); + const range = el.importRange.value === "custom" ? el.importPages.value : ""; + const saved = state.importDocumentMode + ? await changeDocument("ofdgoImportDocuments", null, range, position, state.importEncrypted) + : await changeDocument("ofdgoImportPages", null, range, position, el.importOutlines.checked, ...options); if (saved) el.importPanel.close(); }); editorClick(el.copyPageButton, () => changeSelectedPages("copy")); @@ -2149,11 +2162,21 @@ function remapOutlineExpansion(name, args, next) { walk(roots, (item, key) => state.outlineExpanded.set(key, Boolean(expanded.get(item)))); } -function openImportPanel() { +function openImportPanel(documents = false) { if (!state.editing || document.body.hasAttribute("data-busy")) return; state.importPageCount = 0; + state.importDocumentCount = 0; + state.importDocumentMode = documents; + state.importEncrypted = false; el.importForm.reset(); el.importStatus.textContent = ""; + el.importDocuments.replaceChildren(); + el.importDocuments.hidden = !documents; + el.importPagesLabel.textContent = documents ? "文档" : "页码"; + el.importPages.removeAttribute("title"); + el.importPositionRow.hidden = documents; + el.importOutlinesRow.hidden = documents; + el.importSubmit.textContent = documents ? "导入" : "插入"; el.importSubmit.disabled = true; el.importPagesRow.hidden = true; el.importPages.disabled = true; @@ -2165,6 +2188,8 @@ async function loadImportFile() { const file = el.importFile.files[0]; const openSeq = state.openSeq; state.importPageCount = 0; + state.importDocumentCount = 0; + el.importDocuments.replaceChildren(); state.importEncrypted = false; el.importSubmit.disabled = true; el.importStatus.textContent = ""; @@ -2178,10 +2203,20 @@ async function loadImportFile() { return; } state.importPageCount = info?.pageCount || 0; + state.importDocumentCount = info?.documentCount || 0; state.importEncrypted = Boolean(info?.encrypted); - el.importPages.title = state.importPageCount ? `页码 1-${state.importPageCount}` : "页码"; - el.importStatus.textContent = info?.signed ? "保留签章外观,原签名不再有效" : ""; - el.importSubmit.disabled = !state.importPageCount; + if (state.importDocumentMode) { + el.importDocuments.replaceChildren(...(info?.documents || []).map(entry => { + const row = document.createElement("div"); + row.textContent = `${entry.index + 1} · ${entry.title || "未命名"}`; + return row; + })); + el.importStatus.textContent = `共${state.importDocumentCount}个文档${info?.documentsSigned || state.doc.signatureCount ? " · 合并后需验签" : ""}`; + } else { + el.importPages.title = state.importPageCount ? `页码 1-${state.importPageCount}` : "页码"; + el.importStatus.textContent = info?.signed ? "保留签章外观,原签名不再有效" : ""; + } + el.importSubmit.disabled = !(state.importDocumentMode ? state.importDocumentCount : state.importPageCount); } catch (err) { if (openSeq === state.openSeq && el.importPanel.open) el.importStatus.textContent = err.message; } finally { @@ -3590,12 +3625,12 @@ async function changeDocument(name, item, ...args) { const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; if (restoring) canvasEditor.richClipboard = null; const { scrollLeft, scrollTop } = el.viewerPanel; - const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; + const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const changingFont = name === "ofdgoChangeFontBinding"; let refreshManagement = false; state.imageImporting = name === "ofdgoImportImages"; if (importing) state.importing = true; - setBusy(true, importing ? "正在导入页面" : "", null); + setBusy(true, importing ? name === "ofdgoImportDocuments" ? "正在导入文档" : "正在导入页面" : "", null); const progressTimer = changingFont ? window.setTimeout(() => { if (openSeq === state.openSeq && document.body.hasAttribute("data-busy")) { el.progressPanel.hidden = false; @@ -3647,17 +3682,17 @@ async function changeDocument(name, item, ...args) { } const restoredView = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null; if (restoredView?.reading && state.savedRevision === revision) state.savedRevision = doc.revision; - refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoUpdateInfo" || restoring); + refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoImportDocuments" || name === "ofdgoUpdateInfo" || restoring); const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0]; const nextDocument = doc.documents?.[doc.documentIndex || 0]; const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex; const documentChanged = (state.doc.documentVersion || "") !== (doc.documentVersion || "") || previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root && !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring)); - const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring + const packageChanged = documentChanged || name === "ofdgoChangeDocument" || name === "ofdgoImportDocuments" || restoring && JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id])); if (packageChanged) { - if (state.documentManagement && name === "ofdgoChangeDocument" && args[0] === "add") { + if (state.documentManagement && (name === "ofdgoImportDocuments" || name === "ofdgoChangeDocument" && args[0] === "add")) { state.documentManagement.key = documentEntryKey(nextDocument); } resetCompositeScope(); @@ -4326,8 +4361,8 @@ async function loadWASM() { return; } if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) { - const label = { ids: "正在检查标识", pages: "正在导入页面", resources: "正在读取资源", commit: "正在完成导入" }[data.phase]; - el.importStatus.textContent = data.total ? `${label} · ${data.completed} / ${data.total} 页` : label; + const label = { ids: "正在检查标识", documents: "正在导入文档", pages: "正在导入页面", resources: "正在读取资源", commit: "正在完成导入" }[data.phase] || "正在导入文档"; + el.importStatus.textContent = data.total ? `${label} · ${data.completed} / ${data.total}${state.importDocumentMode ? "" : " 页"}` : label; setProgress(label, data.total ? data.completed / data.total * 100 : null); if (data.phase === "commit") el.importCancel.disabled = el.cancelExportButton.disabled = true; } @@ -11717,6 +11752,7 @@ function updateDocumentControls() { const actionsDisabled = disabled || !state.editing || Boolean(manager?.loading || manager?.failed); el.documentList.inert = busy || Boolean(manager?.loading); el.documentAddButton.disabled = actionsDisabled; + el.documentImportButton.disabled = actionsDisabled; el.infoButton.disabled = actionsDisabled || Boolean(manager && !manager.entries); el.documentDeleteButton.disabled = actionsDisabled || count <= 1; el.documentUpButton.disabled = actionsDisabled || index === 0; @@ -11916,7 +11952,7 @@ async function callWASM(name, ...args) { request.fontData = new Map(); args.push([...request.localFonts.keys()]); } - if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { + if (name === "ofdgoImportDocuments" || name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { state.exportRequestID = id; el.cancelExportButton.hidden = false; el.cancelExportButton.disabled = false; @@ -11964,7 +12000,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.importForm.inert = busy && !state.importing; for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy; el.importPages.disabled = busy || el.importRange.value !== "custom"; - el.importSubmit.disabled = busy || !state.importPageCount; + el.importSubmit.disabled = busy || !(state.importDocumentMode ? state.importDocumentCount : state.importPageCount); el.batchPagesForm.inert = busy; el.objectStyleForm.inert = busy; el.objectBoundsForm.inert = busy; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 0ad7db4..e3fbcf5 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -30,7 +30,7 @@ self.onmessage = ({ data }) => { return; } if (data.name === "ofdgoResources") operations.set(data.id, new AbortController()); - if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportResource" || data.name === "ofdgoExportMedia" || data.name === "ofdgoExportVersion" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { + if (data.name === "ofdgoImportDocuments" || data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportResource" || data.name === "ofdgoExportMedia" || data.name === "ofdgoExportVersion" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { operations.set(data.id, new AbortController()); } pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); @@ -166,7 +166,7 @@ async function handleMessage({ id, name, args }) { let output; let channel; const signal = operations.get(id)?.signal; - const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; + const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const converting = name === "ofdgoConvertPDF"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned"; const resources = name === "ofdgoResources"; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index cde6737..c2f7a0d 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -302,6 +302,7 @@ func RunWASM() { registerCallback("ofdgoResizeObjects", resizeObjects) registerCallback("ofdgoLoadImport", loadImport) registerAsyncCallback("ofdgoImportPages", importPages) + registerAsyncCallback("ofdgoImportDocuments", importDocuments) registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoPreviewText", previewText) @@ -4640,7 +4641,7 @@ func objectStyle(value js.Value) ofdgo.ObjectStyle { return style } -// clearImport 释放待插页文档 +// clearImport 释放待导入文档 func clearImport() { if pendingImport != nil { _ = pendingImport.Close() @@ -4648,9 +4649,9 @@ func clearImport() { } } -// loadImport 加载待插页文件的索引,null参数清除待处理文件 +// loadImport 加载待导入文件的索引,null参数清除待处理文件 // 入参: args OFD字节数组或null -// 返回: any 页数及签名提示, error 错误信息 +// 返回: any 文档列表、页数及签名提示, error 错误信息 func loadImport(args []js.Value) (any, error) { clearImport() if args[0].IsNull() { @@ -4683,7 +4684,13 @@ func loadImport(args []js.Value) (any, error) { return nil, fmt.Errorf("document has no pages") } pendingImport = reader - return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != "", "encrypted": reader.Encryption().Encrypted}, nil + entries := make([]DocumentEntry, reader.DocumentCount()) + signed := doc.Signatures != "" + for index, body := range reader.OFD.DocBody { + entries[index] = DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID} + signed = signed || body.Signatures != "" + } + return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != "", "documentsSigned": signed, "encrypted": reader.Encryption().Encrypted, "documents": entries, "documentCount": len(entries)}, nil } // importPages 插入指定来源页面并保持目标文档元数据 @@ -4727,6 +4734,37 @@ func importPages(args []js.Value) (any, error) { return editorPageInfo{info, at}, err } +// importDocuments 导入指定文档及历史版本并选中首个导入文档 +// 入参: args 文档序号表达式、插入位置、解密确认和可选进度回调 +// 返回: any 文档信息及当前页, error 导入错误 +func importDocuments(args []js.Value) (any, error) { + if currentEditor == nil || pendingImport == nil { + return nil, fmt.Errorf("no document is ready for document import") + } + if len(args) < 3 { + return nil, fmt.Errorf("invalid document import arguments") + } + var indexes []int + var err error + if value := strings.TrimSpace(args[0].String()); value != "" { + indexes, err = ofdgo.ParsePageRange(value, pendingImport.DocumentCount()) + if err != nil { + return nil, err + } + } + options := ofdgo.DocumentImportOptions{AllowDecrypted: args[2].Bool()} + ctx := context.Background() + if len(args) > 3 { + ctx, options.OnProgress = newEditorOperationContext(args[3]) + } + if _, err := currentEditor.ImportDocumentsContext(ctx, pendingImport, indexes, args[1].Int(), options); err != nil { + return nil, err + } + clearImport() + info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + return editorPageInfo{info, 0}, err +} + // importImages 逐张读取图片并建页,负数位置表示新建文档 // 入参: args 文件名、选项、插入位置、标题、注解设置、读取与进度回调 // 返回: any 文档信息, error 错误信息 diff --git a/ofdgo_editor_document_import.go b/ofdgo_editor_document_import.go new file mode 100644 index 0000000..5691ec0 --- /dev/null +++ b/ofdgo_editor_document_import.go @@ -0,0 +1,363 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "context" + "fmt" + "maps" + "slices" + "strings" +) + +// DocumentImportOptions 整文档导入选项 +// AllowDecrypted显式允许解密来源导入未加密目标 +// OnProgress报告documents、resources、commit阶段,total为0表示总量未知 +// 回调返回错误则放弃导入,回调不可重入修改编辑器 +type DocumentImportOptions struct { + AllowDecrypted bool + OnProgress func(stage string, completed, total int) error +} + +// editorDocumentImport 保存文档依赖及原始引用位置 +type editorDocumentImport struct { + ctx context.Context + source *Reader + files map[string]*versionEditFile + kinds map[string]int + queue []string + progress editorProgress +} + +// ImportDocuments 导入整文档及历史版本并选中首项 +// 入参: source 来源阅读器, indexes 从0开始的文档索引,nil表示全部,空切片不导入, at 从0开始的插入位置,可等于文档数 +// 返回: []int 新文档索引, error 导入错误 +func (e *Editor) ImportDocuments(source *Reader, indexes []int, at int) ([]int, error) { + return e.ImportDocumentsContext(context.Background(), source, indexes, at, DocumentImportOptions{}) +} + +// ImportDocumentsWithOptions 按指定选项导入整文档 +// 入参: source 来源阅读器, indexes 从0开始的文档索引,nil表示全部,空切片不导入, at 从0开始的插入位置,可等于文档数, options 导入选项 +// 返回: []int 新文档索引, error 导入错误 +func (e *Editor) ImportDocumentsWithOptions(source *Reader, indexes []int, at int, options DocumentImportOptions) ([]int, error) { + return e.ImportDocumentsContext(context.Background(), source, indexes, at, options) +} + +// ImportDocumentsContext 原子导入文档并保留一次撤销记录 +// 保持输入索引顺序,拒绝重复索引,返回后可关闭来源阅读器 +// 保留签名数据和证书,入口或引用迁移后应重新验签 +// 入参: ctx 取消上下文, source 来源阅读器, indexes 从0开始的文档索引,nil表示全部,空切片不导入, at 从0开始的插入位置,可等于文档数, options 导入选项 +// 返回: []int 新文档索引, error 校验、导入或取消错误 +func (e *Editor) ImportDocumentsContext(ctx context.Context, source *Reader, indexes []int, at int, options DocumentImportOptions) ([]int, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if source == nil { + return nil, fmt.Errorf("document import requires a source") + } + if at < 0 || at > e.DocumentCount() { + return nil, fmt.Errorf("document index %d out of range", at) + } + if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted { + return nil, ErrEncryptionPolicyRequired + } + if indexes == nil { + indexes = make([]int, source.DocumentCount()) + for index := range indexes { + indexes[index] = index + } + } else { + indexes = slices.Clone(indexes) + } + seen := make(map[int]bool, len(indexes)) + for _, index := range indexes { + if index < 0 || index >= source.DocumentCount() || seen[index] { + return nil, fmt.Errorf("invalid or duplicate document index %d", index) + } + seen[index] = true + } + if len(indexes) == 0 { + return []int{}, ctx.Err() + } + migration := &editorDocumentImport{ctx: ctx, source: source, files: make(map[string]*versionEditFile), + kinds: make(map[string]int), progress: editorProgress(options.OnProgress).withContext(ctx)} + if err := migration.progress.report("documents", 0, len(indexes)); err != nil { + return nil, err + } + data, err := source.readFileViewContext(ctx, "OFD.xml") + if err != nil { + return nil, err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return nil, err + } + entry := &versionEditFile{reader: source, name: "OFD.xml", data: data} + for position, index := range indexes { + body := root.childAt("DocBody", index) + if body == nil { + return nil, fmt.Errorf("document body %d not found", index) + } + if err := migration.collect(entry, root, body, ""); err != nil { + return nil, err + } + if err := migration.progress.report("documents", position+1, len(indexes)); err != nil { + return nil, err + } + } + if err := migration.load(false); err != nil { + return nil, err + } + err = e.editDocumentsContext(ctx, func(reader *Reader, target []byte, targetRoot *editorXML) (int, error) { + version, err := documentImportVersion(targetRoot.attr("Version"), root.attr("Version")) + if err != nil { + return 0, err + } + if version != targetRoot.attr("Version") { + target, err = editorXMLAttribute(target, targetRoot, "Version", version) + if err != nil { + return 0, err + } + targetRoot, err = parseEditorXMLContext(ctx, target) + if err != nil { + return 0, err + } + } + paths := make(map[string]string) + occupied, err := documentImportOccupied(ctx, reader, target, targetRoot) + if err != nil { + return 0, err + } + reserved := maps.Clone(occupied) + for name := range migration.kinds { + reserved[name] = nil + } + for _, name := range slices.Sorted(maps.Keys(migration.kinds)) { + if err := ctx.Err(); err != nil { + return 0, err + } + candidate := packageAvailableName(reader, occupied, name) + if candidate != name { + candidate = packageAvailableName(reader, reserved, name) + paths[name] = candidate + reserved[candidate] = nil + } + } + for _, name := range slices.Sorted(maps.Keys(migration.files)) { + file := migration.files[name] + content, err := file.rewrite(ctx, paths) + if err != nil { + return 0, err + } + location := name + if paths[name] != "" { + location = paths[name] + } + reader.files[location] = bytes.Clone(content) + } + updated, err := entry.rewrite(ctx, paths) + if err != nil { + return 0, err + } + updatedRoot, err := parseEditorXMLContext(ctx, updated) + if err != nil { + return 0, err + } + var inserted bytes.Buffer + for _, index := range indexes { + body := updatedRoot.childAt("DocBody", index) + content, err := editorXMLStandaloneContext(ctx, updated[body.start:body.end], body) + if err != nil { + return 0, err + } + inserted.Write(content) + } + position := targetRoot.close + if body := targetRoot.childAt("DocBody", at); body != nil { + position = body.start + } + reader.files["OFD.xml"] = editorPatchXML(target, []editorXMLPatch{{start: position, end: position, data: inserted.Bytes()}}) + if err := migration.progress.report("commit", 0, 0); err != nil { + return 0, err + } + return at, nil + }) + if err != nil { + return nil, err + } + result := make([]int, len(indexes)) + for index := range result { + result[index] = at + index + } + return result, nil +} + +// documentImportVersion 合并OFD格式版本 +// 入参: target 目标版本, source 来源版本 +// 返回: string 合并版本, error 版本错误 +func documentImportVersion(target, source string) (string, error) { + if source == "" || source == target { + return target, nil + } + if target == "" { + return source, nil + } + if (source == "1.0" || source == "1.1") && (target == "1.0" || target == "1.1") { + return "1.1", nil + } + return "", fmt.Errorf("cannot merge OFD versions %q and %q", target, source) +} + +// documentImportOccupied 保留目标全部文档及版本的已声明路径 +// 入参: ctx 取消上下文, reader 目标阅读器, data 根索引原文, root 根索引节点 +// 返回: map[string][]byte 占用路径, error 扫描错误 +func documentImportOccupied(ctx context.Context, reader *Reader, data []byte, root *editorXML) (map[string][]byte, error) { + migration := &editorDocumentImport{ctx: ctx, source: reader, files: make(map[string]*versionEditFile), + kinds: make(map[string]int), progress: editorProgress(nil).withContext(ctx)} + file := &versionEditFile{reader: reader, name: "OFD.xml", data: data} + for _, body := range root.children { + if body.matchesOFD("DocBody") { + if err := migration.collect(file, root, body, ""); err != nil { + return nil, err + } + } + } + if err := migration.load(true); err != nil { + return nil, err + } + occupied := make(map[string][]byte, len(migration.kinds)) + for name := range migration.kinds { + occupied[name] = nil + } + return occupied, nil +} + +// reference 记录已解析的包内引用并调度结构文件 +// 入参: file 来源文件, node 引用节点, attribute 属性名, base 资源目录, value 引用值, kind 引用类别 +// 返回: error 路径错误 +func (m *editorDocumentImport) reference(file *versionEditFile, node *editorXML, attribute, base, value string, kind int) error { + if strings.TrimSpace(value) == "" { + return nil + } + target := resolveResourcePath(file.name, base, value) + if !validPackagePath(target) { + return fmt.Errorf("invalid document import path %q", value) + } + if target == "OFD.xml" && attribute == "FileRef" && node.matchesOFD("Reference") { + return nil + } + file.links = append(file.links, versionEditLink{node: node, attribute: attribute, target: target}) + if previous := m.kinds[target]; previous < kind { + m.kinds[target] = kind + m.queue = append(m.queue, target) + } + return nil +} + +// collect 收集标准引用并保持未知扩展载荷原文 +// 入参: file 来源文件, root 文件根节点, node 当前节点, base 资源目录 +// 返回: error 引用或取消错误 +func (m *editorDocumentImport) collect(file *versionEditFile, root, node *editorXML, base string) error { + if err := m.ctx.Err(); err != nil { + return err + } + if !node.matchesOFD(node.name.Local) { + return nil + } + parent := "" + if node.parent != nil { + parent = node.parent.name.Local + } + if parent == "Extension" && node.name.Local == "Data" { + return nil + } + if root.matchesOFD("Res") { + base = root.attr("BaseLoc") + } + for _, attribute := range node.attrs { + if attribute.Name.Space != "" { + continue + } + kind := packageReference(root.name.Local, parent, node.name.Local, attribute.Name.Local) + if root.matchesOFD("Signature") && parent == "References" && node.matchesOFD("Reference") && attribute.Name.Local == "FileRef" { + kind = packageReferenceData + } + if attribute.Name.Local == "FileLoc" && (parent == "Action" || parent == "Actions" || node.matchesOFD("GotoA") || node.matchesOFD("URI") || node.matchesOFD("Sound") || node.matchesOFD("Movie")) { + kind = packageReferenceData + } + if kind != packageReferenceNone { + if err := m.reference(file, node, attribute.Name.Local, base, attribute.Value, kind); err != nil { + return err + } + } + } + kind := packageReference(root.name.Local, parent, node.name.Local, "") + if node.matchesOFD("FileLoc") && (parent == "GotoA" || parent == "URI" || parent == "Sound" || parent == "Movie" || parent == "Action") { + kind = packageReferenceData + } + if kind != packageReferenceNone { + if err := m.reference(file, node, "", base, editorImportText(file.data, node), kind); err != nil { + return err + } + } + for _, child := range node.children { + if err := m.collect(file, root, child, base); err != nil { + return err + } + } + return nil +} + +// load 读取依赖并仅解析标准结构文件 +// 入参: structuresOnly 是否只读取结构文件 +// 返回: error 读取、解析或取消错误 +func (m *editorDocumentImport) load(structuresOnly bool) error { + parsed := make(map[string]bool) + for position := 0; position < len(m.queue); position++ { + if err := m.progress.report("resources", position, 0); err != nil { + return err + } + name := m.queue[position] + if structuresOnly && m.kinds[name] != packageReferenceXML { + continue + } + file := m.files[name] + if file == nil { + if _, cached := m.source.files[name]; !cached { + if _, exists := m.source.packageFile(name); !exists { + continue + } + } + data, err := m.source.readFileViewContext(m.ctx, name) + if err != nil { + return fmt.Errorf("import document dependency %s: %w", name, err) + } + file = &versionEditFile{reader: m.source, name: name, data: data} + m.files[name] = file + } + if m.kinds[name] == packageReferenceXML && !parsed[name] { + parsed[name] = true + root, err := parseEditorXMLContext(m.ctx, file.data) + if err != nil { + return err + } + if err := m.collect(file, root, root, ""); err != nil { + return err + } + } + } + return m.progress.report("resources", len(m.files), len(m.files)) +}