From 151ddb0a201311cb1555ac091c6854fcda951be2 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 18 Oct 2026 23:46:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BF=9D=E5=AD=98=E5=8F=A6=E5=AD=98=E4=B8=8E?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 3 +- assets/webui/ofdgo.css | 5 + assets/webui/ofdgo.js | 197 +++++++++++++++++++------ assets/webui/ofdgo_host.js | 4 +- assets/webui/ofdgo_wasm.js | 25 +++- internal/base/file/file.go | 216 ++++++++++++++++++++++++++++ internal/base/file/file_other.go | 33 +++++ internal/base/file/file_windows.go | 81 +++++++++++ internal/webui/webui.go | 52 +++++++ internal/webui/webui_native.go | 27 +++- internal/webui/webui_wasm.go | 2 + internal/webview/webview.go | 4 +- internal/webview/webview_files.go | 135 +++++++++-------- internal/webview/webview_other.go | 12 -- internal/webview/webview_windows.go | 55 ------- ofdgo.go | 18 ++- ofdgo_editor.go | 4 +- ofdgo_editor_documents.go | 1 + ofdgo_editor_font_binding.go | 1 + ofdgo_editor_history.go | 1 + ofdgo_writer.go | 138 ++++++++++++++++++ 21 files changed, 819 insertions(+), 195 deletions(-) create mode 100644 internal/base/file/file.go create mode 100644 internal/base/file/file_other.go create mode 100644 internal/base/file/file_windows.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 1b9b291..2a53083 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -22,6 +22,7 @@ + @@ -206,7 +207,7 @@
-
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 901ffd8..43ed82d 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -563,6 +563,7 @@ button:not(:disabled):hover { .close-icon, .new-icon, .save-icon, +.save-as-icon, .edit-icon, .convert-icon, .pages-icon, @@ -593,6 +594,10 @@ button:not(:disabled):hover { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 4 5 5M3 21l2-7L16 3a2 2 0 0 1 5 5L10 19Z'/%3E%3C/svg%3E") center / contain no-repeat; } +.save-as-icon { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v3M7 3v5h8M7 21v-8h4m3 8 7-7m-7 0h7v7'/%3E%3C/svg%3E") center / contain no-repeat; +} + .convert-icon { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7h18l-4-4M21 17H3l4 4'/%3E%3C/svg%3E") center / contain no-repeat; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 39d74c3..749f9ec 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -52,6 +52,8 @@ const state = { wasmRecovering: false, wasmRecoveries: 0, exporting: false, + saving: false, + saveRequestID: 0, signing: false, signCanceled: false, exportRequestID: 0, @@ -61,6 +63,8 @@ const state = { editorInfo: null, editorViews: new Map(), savedRevision: null, + saveHandle: null, + saveStamp: null, packageDirty: false, encryptionDirty: false, insertObject: null, @@ -428,6 +432,7 @@ const el = { pageStatus: document.querySelector("#pageStatus"), pageCancel: document.querySelector("#pageCancel"), saveButton: document.querySelector("#saveButton"), + saveAsButton: document.querySelector("#saveAsButton"), createPanel: document.querySelector("#createPanel"), createMode: document.querySelector("#createMode"), createImages: document.querySelector("#createImages"), @@ -607,7 +612,7 @@ const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Cont el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { - textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.fontInput, document.querySelector("#textReflow")], + textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], keepTextFocus: () => fontPicker.open || el.closePanel.open || canvasEditor.input?.fontDialog, textClipboardScope: () => `${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`, busy: () => document.body.hasAttribute("data-busy"), @@ -1475,7 +1480,8 @@ el.insertCancel.addEventListener("click", () => el.insertPanel.close()); el.insertPanel.addEventListener("close", () => { state.insertObject = null; }); el.insertForm.addEventListener("submit", insertObject); el.textFontAdd.addEventListener("click", () => openFontFile(el.fontInput)); -el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd")); +el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd", false)); +el.saveAsButton.addEventListener("click", () => exportFile(true, null, "ofd", true)); editorClick(el.encryptSaveButton, openEncryptionPanel); el.signButton.addEventListener("click", () => { if (!state.doc || document.body.hasAttribute("data-busy")) return; @@ -1744,10 +1750,10 @@ function handleKeyDown(event) { } const key = event.key; const target = event.target; - if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "s" && state.doc + if ((event.ctrlKey || event.metaKey) && key.toLowerCase() === "s" && state.doc && (state.editorInfo || state.ofdBytes)) { event.preventDefault(); - if (!document.body.hasAttribute("data-busy") && !formDialogOpen()) exportFile(true, null, "ofd"); + if (!document.body.hasAttribute("data-busy") && !formDialogOpen()) exportFile(true, null, "ofd", event.shiftKey); return; } if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "a" && state.doc @@ -2320,20 +2326,24 @@ function setDirty(dirty) { } function discardChanges() { + if (state.saving) return false; return !state.dirty || window.confirm("文档尚未保存,放弃更改?"); } async function closeFile() { + if (state.saving) return; if (!state.doc || state.exporting || batch.unlocking || document.body.hasAttribute("data-busy")) return; let openSeq = state.openSeq; if (state.dirty || canvasEditor.input || canvasEditor.nudge || canvasEditor.nudgeCommit || canvasEditor.crop) { + el.closePanel.querySelector('[value="save"]').textContent = state.saveHandle ? "保存" : "另存"; el.closePanel.returnValue = "cancel"; const choice = new Promise(resolve => el.closePanel.addEventListener("close", () => resolve(el.closePanel.returnValue), { once: true })); el.closePanel.showModal(); const action = await choice; if (openSeq !== state.openSeq || action === "cancel") return; if (action === "save") { - if (!await exportFile(true, null, "ofd")) return; + if (!await exportFile(true, null, "ofd", false)) return; + if (state.dirty) return; openSeq = state.openSeq; } } @@ -2345,6 +2355,8 @@ async function closeFile() { state.ofdBytes = null; state.doc = null; state.fileName = "ofdgo.ofd"; + state.saveHandle = null; + state.saveStamp = null; state.editing = false; state.editorInfo = null; state.savedRevision = null; @@ -2497,6 +2509,7 @@ function selectManagedVersion(id, focus = false) { } async function changeVersion(action) { + if (state.saving) return; const manager = state.versionManagement; if (!state.ready || state.exporting || !state.editing && action !== "select" || !manager?.entries || manager.loading || manager.failed || document.body.hasAttribute("data-busy")) return; const entry = manager.entries.find(entry => entry.id === manager.id); @@ -2518,6 +2531,7 @@ async function changeVersion(action) { } async function switchReadingVersion(id) { + if (state.saving) return; if (!state.doc || state.editing || !state.ready || state.exporting || document.body.hasAttribute("data-busy")) return; if (id === (state.doc.documentVersion || "")) return true; const dirty = state.dirty; @@ -2556,7 +2570,7 @@ async function switchReadingVersion(id) { function updateVersionControls() { const manager = state.versionManagement, busy = document.body.hasAttribute("data-busy"); - const disabled = !state.doc || !state.ready || state.exporting || busy; + const disabled = !state.doc || !state.ready || state.exporting || state.saving || busy; const title = state.editing ? "版本管理" : "版本浏览"; el.versionManageButton.hidden = !state.editing && !(state.doc?.versionCount > 0); el.versionTitle.textContent = title; @@ -2691,6 +2705,7 @@ function selectManagedDocument(index, focus = false) { } async function switchDocument(index) { + if (state.saving) return; if (!state.doc || document.body.hasAttribute("data-busy") || state.exporting || formDialogOpen()) return; const previous = state.doc.documentIndex || 0; if (index === previous) return; @@ -2819,6 +2834,7 @@ function confirmTextReflow(item) { } function openCreatePanel(insert = false, packageDocument = false) { + if (state.saving && insert !== true) return; if (document.body.hasAttribute("data-busy")) { return; } @@ -2912,6 +2928,8 @@ async function createDocument(event) { setPan(false); state.ofdBytes = null; state.fileName = `${title.replace(/[\\/:*?"<>|]+/g, "_").replace(/\.ofd$/i, "")}.ofd`; + state.saveHandle = null; + state.saveStamp = null; state.conversionWarnings = []; state.savedRevision = null; state.packageDirty = false; @@ -4112,9 +4130,19 @@ function releasePageResources() { } async function openOFDFile() { + if (state.saving) return; if (document.body.hasAttribute("data-busy")) { return; } + if (!nativeHost && window.showOpenFilePicker) { + try { + const [handle] = await window.showOpenFilePicker({types: [{description: "OFD、PDF", accept: {"application/ofd": [".ofd"], "application/pdf": [".pdf"]}}]}); + const file = await handle.getFile(); + await requestLocalFontsBeforeOpen(); + await openOFD(file, null, handle); + } catch (error) { if (error.name !== "AbortError") showError(error, !state.doc); } + return; + } await requestLocalFontsBeforeOpen(); el.ofdInput.value = ""; el.ofdInput.click(); @@ -4139,11 +4167,12 @@ async function openOFDFromDrop(event) { setStatus("一次拖入一个文件"); return; } + const handlePromise = event.dataTransfer.items[0]?.getAsFileSystemHandle?.(); const file = event.dataTransfer.files[0]; if (file && isDocumentFile(file)) { await requestLocalFontsBeforeOpen(); } - await openOFD(file); + await openOFD(file, null, await handlePromise?.catch(() => null)); } function openFontFile(input) { @@ -4249,7 +4278,7 @@ async function boot() { return; } opening = opening.then(async () => { - await openOFD(await files[0].getFile()); + await openOFD(await files[0].getFile(), null, files[0]); }).catch((err) => showError(err, !state.doc)); }); } @@ -4557,11 +4586,15 @@ async function loadWASM() { else setProgress("正在生成文档", null); } } else if (data.type === "export") { + if (data.id === state.saveRequestID && state.saving && !state.exporting) { + if (data.stage === "save") el.cancelExportButton.disabled = true; + return; + } if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) { if (data.stage === "save") { el.cancelExportButton.disabled = true; if (state.signing) el.signCancel.disabled = true; - setProgress("正在保存文件", null); + setProgress("正在写入文件", null); } else if (!el.cancelExportButton.disabled) { if (data.stage === "prepare") { const label = { snapshot: "正在准备文档", ids: "正在检查标识", commit: "正在整理文档", fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", compress: "正在压缩文件", write: "正在写入文件", sign: "正在签署文档", encrypt: "正在加密文档" }[data.phase]; @@ -4600,6 +4633,7 @@ async function loadWASM() { } else { const err = new Error(missingGlyphMessage(data.missingGlyphs) || (data.reasonCode === "layoutRequired" ? "跨段或换行修改需先设置段落排版" : data.error)); err.code = data.code; + if (data.errorName) err.name = data.errorName; err.missingGlyphs = data.missingGlyphs; if (data.canceled) { err.name = "AbortError"; @@ -4729,7 +4763,8 @@ async function downloadRemoteDocument(options) { } } -async function openOFD(file, remote = null) { +async function openOFD(file, remote = null, handle = file?.handle) { + if (state.saving) return; if (!file) { return; } @@ -4785,6 +4820,8 @@ async function openOFD(file, remote = null) { const doc = convertedDoc || await openWithCredentials(await fontManager.files(fontManager.userFonts), openSeq, bytes); if (openSeq !== state.openSeq) return; state.ofdBytes = bytes; + state.saveHandle = format === "ofd" && !remote ? handle || null : null; + state.saveStamp = state.saveHandle && !nativeHost ? {size: file.size, modified: file.lastModified} : null; state.conversionWarnings = warnings; state.fileName = (name || "ofdgo.ofd").replace(/\.pdf$/i, ".ofd"); if (remote && !/\.ofd$/i.test(state.fileName)) state.fileName += ".ofd"; @@ -6712,11 +6749,11 @@ async function commitInvoice(draft, request) { const key = invoicePanel.key; settings.busy = true; settingsElements.Form.inert = true; - settingsElements.Status.textContent = "正在保存修改"; + settingsElements.Status.textContent = "正在应用修改"; try { invoicePanel.result = null; if (!await changeDocument("ofdgoChangeInvoice", null, JSON.stringify({ ...draft.scope, source: draft.entry?.source, index: draft.entry?.index || 0, digest: draft.detail.digest, ...request }))) { - if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent; + if (settingsElements.Status.textContent === "正在应用修改") settingsElements.Status.textContent = el.statusText.textContent; return; } const result = invoicePanel.result; @@ -6742,7 +6779,7 @@ async function commitInvoice(draft, request) { await loadDocumentSettings(); const warning = invoiceIssues(result?.issues).replaceAll("\n", " · "); if (warning) { - const status = "已保存 · " + warning; + const status = "已应用 · " + warning; if (invoiceCurrent()) invoiceCurrent().status = status; settingsElements.Status.textContent = status; } @@ -6861,11 +6898,11 @@ async function commitSettings(name, ...args) { const pending = settings.drafts.get(settings.view), baseline = settings.baseline; settings.busy = true; settingsElements.Form.inert = true; - settingsElements.Status.textContent = "正在保存修改"; + settingsElements.Status.textContent = "正在应用修改"; const view = settings.view, focus = document.activeElement?.id, body = document.querySelector("#settingsBody"), scroll = body?.scrollTop || 0; try { if (!await changeDocument(name, null, ...args)) { - if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent; + if (settingsElements.Status.textContent === "正在应用修改") settingsElements.Status.textContent = el.statusText.textContent; return false; } settings.drafts.delete(view); @@ -7803,6 +7840,7 @@ function updateEncryptionType() { } async function applyEncryption(event) { + if (state.saving) { event.preventDefault(); return; } event.preventDefault(); if (!state.doc || document.body.hasAttribute("data-busy") || el.encryptionSubmit.disabled) return; const mode = el.encryptionType.value; @@ -7884,7 +7922,7 @@ function renderSecurity() { el.securitySchemeRow.hidden = !encryption?.encrypted; el.securityMethod.textContent = scheme ? "SM4-CBC" : ""; el.securityMethodRow.hidden = !encryption?.encrypted || !scheme; - el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("data-busy"); + el.encryptSaveButton.disabled = !state.doc || !state.ready || state.exporting || state.saving || document.body.hasAttribute("data-busy"); el.verifyButton.disabled = el.encryptSaveButton.disabled; el.signButton.disabled = el.encryptSaveButton.disabled; } @@ -8034,6 +8072,7 @@ function updateSignPlacement() { } async function signDocument(event) { + if (state.saving) { event.preventDefault(); return; } event.preventDefault(); if (!state.doc || document.body.hasAttribute("data-busy")) return; if (!el.signCertificate.files?.length || !el.signKey.files?.length || !el.signRoots.files?.length) { @@ -8587,8 +8626,8 @@ function compressionOptions(value) { return preset > 2 ? { mode: 2, level: preset - 2 } : { mode: preset }; } -async function exportFile(whole, indices = null, value = el.exportFormat.value) { - if (!state.doc || document.body.hasAttribute("data-busy")) { +async function exportFile(whole, indices = null, value = el.exportFormat.value, saveAs = true) { + if (!state.doc || state.saving || state.exporting || document.body.hasAttribute("data-busy")) { return; } const saving = value === "ofd"; @@ -8608,14 +8647,36 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) const mime = archive ? "application/zip" : format.mime; let openSeq = state.openSeq; const fileName = saving && indices !== null ? `${baseFileName()}_选页.ofd` : whole ? `${baseFileName()}.${extension}` : pageFileName(extension); + const wasmSeq = state.wasmSeq; const pageIndex = state.pageIndex; const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0; - const status = saving ? "正在保存文档" : whole ? STATUS.exporting : STATUS.pageExporting; + let action = saving ? saveAs || !state.saveHandle || indices !== null ? "另存" : "保存" : "导出"; + const status = saving ? `正在${action}文档` : whole ? STATUS.exporting : STATUS.pageExporting; + let retrySaveAs = false; + let background = false; + let savedRevision = null; + let snapshotPrepared = false; state.exporting = true; + state.saving = saving && indices === null; updateControls(); setBusy(true, "正在生成文件", null, status); try { - const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({ + let file = saving && indices === null && !saveAs ? state.saveHandle : null; + if (file && !nativeHost) { + try { + const permission = await file.queryPermission({mode: "readwrite"}); + if (permission !== "granted" && await file.requestPermission({mode: "readwrite"}) !== "granted") file = null; + } catch (error) { + if (!["NotAllowedError", "SecurityError"].includes(error.name)) throw error; + file = null; + } + if (file && state.saveStamp) { + const current = await file.getFile(); + if (current.size !== state.saveStamp.size || current.lastModified !== state.saveStamp.modified) throw new Error("文件已被修改,请另存"); + } + } + if (saving && !file) action = "另存"; + file ||= nativeHost || window.showSaveFilePicker ? await saveFilePicker({ suggestedName: fileName, types: [{ description: label, accept: { [mime]: [`.${extension}`] } }], }) : null; @@ -8631,34 +8692,63 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) setBusy(true, "正在生成文件", null, status); } const readingSave = saving && !state.editorInfo && indices === null && compression.mode === 0; + const destination = !nativeHost && file && file === state.saveHandle && state.saveStamp ? {fileHandle: file, saveStamp: state.saveStamp} : file; + if (saving && indices === null && !readingSave) { + snapshotPrepared = true; + await callWASM("ofdgoPrepareSave"); + savedRevision = state.editorInfo?.revision; + background = true; + state.exporting = false; + setBusy(false); + updateControls(); + } const result = readingSave ? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes } - : saving ? await callWASM("ofdgoSaveDocument", indices, ...outputOptions, file) : whole + : saving ? await callWASM(background ? "ofdgoSaveSnapshot" : "ofdgoSaveDocument", indices, ...outputOptions, destination) : whole ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file) : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file); - if (openSeq !== state.openSeq) { + if ((!background && openSeq !== state.openSeq) || wasmSeq !== state.wasmSeq) { return; } if (readingSave && file) { const writer = await file.createWritable(); - await writer.write(result.blob); - await writer.close(); + try { + await writer.write(result.blob); + await writer.close(); + } catch (error) { + await writer.abort().catch(() => {}); + throw error; + } delete result.blob; } if (result.blob) { downloadBytes(result.blob, result.mime, fileName); } - if (saving && indices === null && state.editorInfo) { - state.savedRevision = state.editorInfo.revision; + if (saving && indices === null && state.editorInfo && file) { + state.savedRevision = background ? savedRevision : state.editorInfo.revision; state.packageDirty = false; state.encryptionDirty = false; - setDirty(false); + updatePendingChanges(); } - setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`); + if (saving && indices === null && file) { + state.saveHandle = file; + state.fileName = file.name; + state.saveStamp = null; + if (!nativeHost) { + try { + const current = await file.getFile(); + state.saveStamp = {size: current.size, modified: current.lastModified}; + } catch { state.saveHandle = null; } + } + document.title = `${nativeHost ? "OFDGo" : "OFDGo WebUI"} - ${state.fileName}`; + el.metaFile.textContent = state.fileName; + } + setStatus(`${result.label}已${action}(${formatBytes(result.size)})`); return true; } catch (err) { - if (openSeq === state.openSeq) { - const canceled = saving ? "保存已取消" : "导出已取消"; + retrySaveAs = saving && !saveAs && !nativeHost && !!state.saveHandle && ["NotAllowedError", "SecurityError"].includes(err.name); + if ((background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq && !retrySaveAs) { + const canceled = `${action}已取消`; if (el.batchPagesPanel.open) el.batchPagesStatus.textContent = err.name === "AbortError" ? canceled : err.message; if (err.name === "AbortError") { setStatus(canceled); @@ -8667,14 +8757,21 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) } } } finally { - state.exporting = false; - state.exportRequestID = 0; - el.cancelExportButton.hidden = true; - if (openSeq === state.openSeq) { - setBusy(false); + if (snapshotPrepared) await callWASM("ofdgoDiscardSave").catch(() => {}); + state.saving = false; + state.saveRequestID = 0; + if (!background) { + state.exporting = false; + state.exportRequestID = 0; + } + el.cancelExportButton.hidden = !state.exportRequestID; + if (!document.body.hasAttribute("data-busy")) el.progressPanel.hidden = true; + if ((background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) { + if (!background) setBusy(false); updateControls(); } } + if (retrySaveAs && (background || openSeq === state.openSeq) && wasmSeq === state.wasmSeq) return exportFile(whole, indices, value, true); } function cancelExport() { @@ -8682,7 +8779,8 @@ function cancelExport() { remoteDownload.abort(); return; } - if (!state.exportRequestID || el.cancelExportButton.disabled) { + const requestID = state.exportRequestID || state.saveRequestID; + if (!requestID || el.cancelExportButton.disabled) { return; } el.cancelExportButton.disabled = true; @@ -8694,7 +8792,7 @@ function cancelExport() { el.importStatus.textContent = "正在取消操作"; } setProgress("正在取消操作", null); - wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID }); + wasmWorker.postMessage({ type: "cancel", id: requestID }); } function virtualIndex(offsets, top) { @@ -12023,14 +12121,14 @@ function updateDocumentControls() { const busy = document.body.hasAttribute("data-busy"); const disabled = !state.doc || !state.ready || state.exporting || busy; el.documentManageButton.hidden = !state.editing; - el.documentManageButton.disabled = disabled; + el.documentManageButton.disabled = disabled || state.saving; resourceElements.ManageButton.disabled = disabled; settingsElements.Button.disabled = disabled; updateInvoiceControl(disabled); updateResourceControls(); el.documentRow.hidden = !count || !state.editing && count < 2; - el.documentSelect.disabled = disabled; - const actionsDisabled = disabled || !state.editing || Boolean(manager?.loading || manager?.failed); + el.documentSelect.disabled = disabled || state.saving; + const actionsDisabled = disabled || state.saving || !state.editing || Boolean(manager?.loading || manager?.failed); el.documentList.inert = busy || Boolean(manager?.loading); el.documentAddButton.disabled = actionsDisabled; el.documentImportButton.disabled = actionsDisabled; @@ -12045,7 +12143,8 @@ function updateControls() { renderSecurity(); updateStructureControls(); const hasDoc = Boolean(state.doc); - el.closeButton.disabled = !hasDoc || state.exporting || document.body.hasAttribute("data-busy"); + el.newButton.disabled = state.saving; + el.closeButton.disabled = !hasDoc || state.exporting || state.saving || document.body.hasAttribute("data-busy"); updateDocumentControls(); updateDisplayControls(); updateEditorTools(); @@ -12083,7 +12182,8 @@ function updateEditorTools() { el.editorTools.hidden = !state.editing; const pagesDisabled = !state.editing || !state.ready || state.exporting; el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert"); - el.saveButton.disabled = !state.doc || !state.ready || state.exporting || (!state.editorInfo && !state.ofdBytes); + el.saveButton.disabled = !state.doc || !state.ready || state.exporting || state.saving || (!state.editorInfo && !state.ofdBytes); + el.saveAsButton.disabled = el.saveButton.disabled; el.addPageButton.disabled = pagesDisabled; const selectedPages = state.editing ? selectedPageIndexes() : [state.pageIndex]; const selectionDisabled = pagesDisabled || !selectedPages.length; @@ -12225,6 +12325,11 @@ async function callWASM(name, ...args) { } return new Promise((resolve, reject) => { const id = ++wasmRequestID; + if (name === "ofdgoPrepareSave" || name === "ofdgoSaveSnapshot") { + state.saveRequestID = id; + el.cancelExportButton.hidden = false; + el.cancelExportButton.disabled = false; + } if (name === "ofdgoResources" && resourceElements.Panel.open) resourceManager.requestID = id; wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq }); if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0]; @@ -12264,7 +12369,7 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("data-busy", busy); - el.closeButton.disabled = busy || !state.doc || state.exporting; + el.closeButton.disabled = busy || !state.doc || state.exporting || state.saving; el.refreshAppButton.disabled = busy; updateStructureControls(); updateDocumentControls(); @@ -12296,7 +12401,11 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.fontList.inert = busy; el.docFontList.inert = busy; if (!busy) { - el.progressPanel.hidden = true; + el.progressPanel.hidden = !state.saving; + if (state.saving) { + el.cancelExportButton.hidden = false; + setProgress("正在保存文档", null); + } if (state.fontSyncPending) { window.setTimeout(syncUserFonts, 0); } diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index e0bd0aa..06219bd 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -27,6 +27,7 @@ export function directoryPicker() { class NativeFile { constructor(value) { Object.assign(this, value); + if (value.handle) this.handle = new NativeHandle(nativeHost, value.handle); this.byteLength = this.size; this.type ||= "application/octet-stream"; if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile); @@ -157,7 +158,7 @@ class NativeHost { if (data.type === "font") return; const controller = new AbortController(); requests.set(data.id, controller); - pending = pending.then(async () => { + const operation = pending.then(async () => { const engine = await ready; if (closed) return; controller.signal.throwIfAborted(); @@ -188,6 +189,7 @@ class NativeHost { if (closed) { await host.release(result); return; } emit({id:data.id, ...decoded}); }).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => requests.delete(data.id)); + if (data.name !== "ofdgoSaveSnapshot") pending = operation; }, terminate() { if (closed) return; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 7be2113..6918377 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -35,7 +35,11 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoResources") operations.set(data.id, new AbortController()); + if (["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot"].includes(data.name)) operations.set(data.id, new AbortController()); + if (data.name === "ofdgoSaveSnapshot") { + pending = pending.then(() => { void handleMessage(data); }); + return; + } 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()); } @@ -173,12 +177,14 @@ async function handleBatchMessage({ id, name, args }) { } async function handleMessage({ id, name, args }) { let output; + let saveFile; + let saveStamp; let channel; const signal = operations.get(id)?.signal; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const converting = name === "ofdgoConvertPDF"; - const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned"; - const resources = name === "ofdgoResources"; + const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned"; + const resources = name === "ofdgoResources" || name === "ofdgoPrepareSave"; const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null; const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null; @@ -230,7 +236,10 @@ async function handleMessage({ id, name, args }) { }); if (converting) args.push((name, done) => requestFont(id, signal, name, done)); } else { - const file = args.pop(); + const destination = args.pop(); + const file = destination?.fileHandle || destination; + saveFile = file; + saveStamp = destination?.saveStamp; const compression = saving && name !== "ofdgoSaveSigned" && args.at(-1)?.mode !== undefined ? args.pop() : null; const indices = saving ? args.shift() : null; if (file) output = await file.createWritable(); @@ -263,7 +272,7 @@ async function handleMessage({ id, name, args }) { self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); finish(done); }); - if (compression) args.push(indices, compression, done => finish(done)); + if (compression || name === "ofdgoSaveSnapshot") args.push(indices, compression || {mode: 0}, done => finish(done)); else if (indices != null) args.push(indices); } if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done)); @@ -277,6 +286,10 @@ async function handleMessage({ id, name, args }) { result.data.size = size; if (name !== "ofdgoExportMedia") self.postMessage({ id, type: "export", stage: "save" }); if (output) { + if (saveStamp) { + const current = await saveFile.getFile(); + if (current.size !== saveStamp.size || current.lastModified !== saveStamp.modified) throw new Error("文件已被修改,请另存"); + } await output.close(); } else { result.data.blob = new Blob(chunks, { type: result.data.mime }); @@ -289,7 +302,7 @@ async function handleMessage({ id, name, args }) { self.postMessage({ id, ...result }, transfer); } catch (err) { await output?.abort().catch(() => {}); - const result = { id, ok: false, error: err.message }; + const result = { id, ok: false, error: err.message, errorName: err.name }; if (signal?.aborted) { result.error = resources ? "查询已取消" : importing ? "导入已取消" : converting ? "转换已取消" : "导出已取消"; result.canceled = true; diff --git a/internal/base/file/file.go b/internal/base/file/file.go new file mode 100644 index 0000000..235a7e8 --- /dev/null +++ b/internal/base/file/file.go @@ -0,0 +1,216 @@ +// 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 file + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "sync" +) + +// ErrChanged 表示目标文件已被其他操作修改 +var ErrChanged = errors.New("file changed since opening") + +// ErrBusy 表示目标文件正在写入 +var ErrBusy = errors.New("file save is in progress") + +// activeFiles 协调当前进程内的目标写入 +var activeFiles sync.Map + +// Target 保存文件路径及最近确认的文件状态,可跨次写入复用 +type Target struct { + mu sync.Mutex + name string + info os.FileInfo +} + +// Writer 写入同目录临时文件,提交前检查取消及目标冲突 +type Writer struct { + mu sync.Mutex + target *Target + file *os.File + ctx context.Context + err error + closed bool +} + +// NewTarget 记录目标当前状态,允许目标尚不存在 +// 入参: name 目标路径 +// 返回: *Target 保存目标, error 路径或文件错误 +func NewTarget(name string) (*Target, error) { + if name == "" { + return nil, os.ErrInvalid + } + name, err := filepath.Abs(name) + if err != nil { + return nil, err + } + info, err := os.Lstat(name) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if info != nil && !info.Mode().IsRegular() { + return nil, fmt.Errorf("save target is not a regular file") + } + return &Target{name: name, info: info}, nil +} + +// Name 返回目标绝对路径 +// 返回: string 文件路径 +func (t *Target) Name() string { + return t.name +} + +// check 检查目标是否保持最近确认的状态 +// 返回: error 文件状态错误 +func (t *Target) check() error { + info, err := os.Lstat(t.name) + if errors.Is(err, os.ErrNotExist) { + if t.info == nil { + return nil + } + return ErrChanged + } + if err != nil { + return err + } + if t.info == nil || !os.SameFile(t.info, info) || t.info.Size() != info.Size() || !t.info.ModTime().Equal(info.ModTime()) { + return ErrChanged + } + if info.Mode().Perm()&0222 == 0 { + return os.ErrPermission + } + return nil +} + +// Begin 创建独占写入事务,调用方必须提交或终止 +// 入参: ctx 取消上下文 +// 返回: *Writer 事务, error 创建错误 +func (t *Target) Begin(ctx context.Context) (*Writer, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + t.mu.Lock() + defer t.mu.Unlock() + if _, loaded := activeFiles.LoadOrStore(t.name, true); loaded { + return nil, ErrBusy + } + if err := t.check(); err != nil { + activeFiles.Delete(t.name) + return nil, err + } + for range 100 { + n, err := rand.Int(rand.Reader, big.NewInt(10000000)) + if err != nil { + activeFiles.Delete(t.name) + return nil, err + } + name := fmt.Sprintf("%s.%07d.tmp", t.name, n.Int64()) + f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + activeFiles.Delete(t.name) + return nil, err + } + return &Writer{target: t, file: f, ctx: ctx}, nil + } + activeFiles.Delete(t.name) + return nil, os.ErrExist +} + +// Write 追加数据并记录写入错误 +// 入参: data 数据 +// 返回: int 写入长度, error 写入错误 +func (w *Writer) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return 0, os.ErrClosed + } + if w.err == nil { + w.err = w.ctx.Err() + } + if w.err != nil { + return 0, w.err + } + n, err := w.file.Write(data) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + w.err = err + return n, err +} + +// Close 完成写入并替换目标,成功后刷新目标状态 +// 返回: error 提交错误 +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return os.ErrClosed + } + w.closed = true + defer activeFiles.Delete(w.target.name) + defer os.Remove(w.file.Name()) + w.target.mu.Lock() + defer w.target.mu.Unlock() + if err := errors.Join(w.err, w.ctx.Err(), w.target.check()); err != nil { + return errors.Join(err, w.file.Close()) + } + if w.target.info != nil { + if err := w.file.Chmod(w.target.info.Mode().Perm()); err != nil { + return errors.Join(err, w.file.Close()) + } + } + if err := w.file.Sync(); err != nil { + return errors.Join(err, w.file.Close()) + } + info, err := w.file.Stat() + if err = errors.Join(err, w.file.Close(), w.ctx.Err()); err != nil { + return err + } + if err := w.target.check(); err != nil { + return err + } + if err := Replace(w.file.Name(), w.target.name); err != nil { + return err + } + w.target.info = info + if current, err := os.Stat(w.target.name); err == nil { + w.target.info = current + } + return nil +} + +// Abort 丢弃临时输出,不改变目标 +// 返回: error 清理错误 +func (w *Writer) Abort() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return nil + } + w.closed = true + defer activeFiles.Delete(w.target.name) + return errors.Join(w.file.Close(), os.Remove(w.file.Name())) +} diff --git a/internal/base/file/file_other.go b/internal/base/file/file_other.go new file mode 100644 index 0000000..698f155 --- /dev/null +++ b/internal/base/file/file_other.go @@ -0,0 +1,33 @@ +// 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. + +//go:build !windows + +package file + +import "os" + +// Open 打开文件,路径替换后已有读取句柄仍有效 +// 入参: name 文件路径 +// 返回: *os.File 文件, error 打开错误 +func Open(name string) (*os.File, error) { + return os.Open(name) +} + +// Replace 替换目标路径 +// 入参: source 临时路径, target 目标路径 +// 返回: error 替换错误 +func Replace(source, target string) error { + return os.Rename(source, target) +} diff --git a/internal/base/file/file_windows.go b/internal/base/file/file_windows.go new file mode 100644 index 0000000..ac46da1 --- /dev/null +++ b/internal/base/file/file_windows.go @@ -0,0 +1,81 @@ +// 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 file + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" +) + +// replaceFileProc 保留目标文件属性并替换文件内容 +var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW") + +// Open 保留替换共享权限,路径替换后已有读取句柄仍有效 +// 入参: name 文件路径 +// 返回: *os.File 文件, error 打开错误 +func Open(name string) (*os.File, error) { + path, err := nativePath(name) + if err != nil { + return nil, err + } + handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + return os.NewFile(uintptr(handle), name), nil +} + +// Replace 替换目标,目标不存在时重命名 +// 入参: source 临时路径, target 目标路径 +// 返回: error 替换错误 +func Replace(source, target string) error { + from, err := nativePath(source) + if err != nil { + return err + } + to, err := nativePath(target) + if err != nil { + return err + } + result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0) + if result != 0 { + return nil + } + if err == syscall.ERROR_FILE_NOT_FOUND { + return os.Rename(source, target) + } + return &os.LinkError{Op: "replace", Old: source, New: target, Err: err} +} + +// nativePath 转换绝对长路径 +// 入参: name 路径 +// 返回: *uint16 原生路径, error 转换错误 +func nativePath(name string) (*uint16, error) { + path, err := filepath.Abs(name) + if err != nil { + return nil, err + } + if !strings.HasPrefix(path, `\\?\`) { + if strings.HasPrefix(path, `\\`) { + path = `\\?\UNC\` + path[2:] + } else { + path = `\\?\` + path + } + } + return syscall.UTF16PtrFromString(path) +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go index c5ba69f..cee41f7 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -419,6 +419,7 @@ type outputContext struct { type businessEngine struct { currentSession *Session currentEditor *ofdgo.Editor + saveSnapshot *ofdgo.Editor pendingImport *ofdgo.Reader copiedObjects *editorClipboard copiedStyle *ofdgo.GraphicObject @@ -7162,6 +7163,54 @@ func (e *businessEngine) saveSigned(args []platformValue) (any, error) { return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil } +// prepareSave 创建独立保存快照,后续写出不占用当前编辑文档 +// 入参: args 取消检查回调 +// 返回: any 快照修订, error 准备错误 +func (e *businessEngine) prepareSave(args []platformValue) (any, error) { + e.saveSnapshot = nil + editor := e.currentEditor + if editor == nil { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + var err error + editor, err = e.currentSession.Reader.Editor() + if err != nil { + return nil, err + } + } + ctx := e.operationContext() + if len(args) > 0 { + ctx = e.newOutputContext(args[0]) + } + snapshot, err := editor.Snapshot(ctx) + if err != nil { + return nil, err + } + e.saveSnapshot = snapshot + return successResult(map[string]any{"revision": editor.Revision()}), nil +} + +// savePrepared 写出并释放已准备的快照,不修改当前编辑状态 +// 入参: args 写出回调、进度回调及输出配置 +// 返回: any 保存结果, error 保存错误 +func (e *businessEngine) savePrepared(args []platformValue) (any, error) { + snapshot := e.saveSnapshot + e.saveSnapshot = nil + if snapshot == nil { + return nil, fmt.Errorf("save snapshot is not prepared") + } + return e.saveEditor(snapshot, args) +} + +// discardSave 释放尚未写出的保存快照 +// 入参: args 空参数 +// 返回: any 操作结果, error 错误信息 +func (e *businessEngine) discardSave(args []platformValue) (any, error) { + e.saveSnapshot = nil + return successResult(nil), nil +} + // saveDocument 分块写出当前编辑文档或指定页面 // 入参: args 数据写出回调、可选准备进度回调和可选页面索引 // 返回: any 保存结果, error 错误信息 @@ -7403,6 +7452,9 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoUndo": func([]platformValue) (any, error) { return e.restoreEditor(false) }, "ofdgoRedo": func([]platformValue) (any, error) { return e.restoreEditor(true) }, "ofdgoSaveDocument": e.saveDocument, + "ofdgoPrepareSave": e.prepareSave, + "ofdgoSaveSnapshot": e.savePrepared, + "ofdgoDiscardSave": e.discardSave, "ofdgoSetEncryption": e.setEncryption, "ofdgoSaveSigned": e.saveSigned, "ofdgoPreviewSignatureSeal": e.previewSignatureSeal, diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go index 4f29ecf..6109ee3 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -82,6 +82,7 @@ type NativeEngine struct { CleanupOutput func(context.Context, NativeOutputTarget) error gate chan struct{} once sync.Once + saves sync.WaitGroup closed bool } @@ -149,13 +150,33 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f case <-ctx.Done(): return nativeFailure(ctx.Err()) } - defer func() { <-e.gate }() + locked := true + defer func() { + if locked { + <-e.gate + } + }() if err := ctx.Err(); err != nil { return nativeFailure(err) } if e.closed { return nativeFailure(fmt.Errorf("native engine is closed")) } + if name == "ofdgoSaveSnapshot" { + snapshot := e.saveSnapshot + e.saveSnapshot = nil + if snapshot == nil { + return nativeFailure(fmt.Errorf("save snapshot is not prepared")) + } + output := &NativeEngine{OpenWriter: e.OpenWriter} + output.currentEditor = snapshot + e.saves.Add(1) + defer e.saves.Done() + <-e.gate + locked = false + defer output.Close() + return output.Call(ctx, "ofdgoSaveDocument", args, emit) + } e.ctx = ctx if e.currentSession != nil { e.currentSession.Renderer.OutputContext = ctx @@ -219,6 +240,8 @@ func (e *NativeEngine) Close() error { e.gate <- struct{}{} defer func() { <-e.gate }() e.closed = true + e.saveSnapshot = nil + e.saves.Wait() var err error if e.currentSession != nil { err = e.currentSession.Close() @@ -728,7 +751,7 @@ func (c *nativeCall) prepare(name string, args []any) ([]any, error) { args = append(args, c.fileCallback(files)) } switch name { - case "ofdgoResources": + case "ofdgoResources", "ofdgoPrepareSave": return append(args, checkpoint), nil case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages": return append(args, c.progress("import", "", true)), nil diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go index b009094..4e5e04b 100644 --- a/internal/webui/webui_wasm.go +++ b/internal/webui/webui_wasm.go @@ -56,6 +56,8 @@ func RunWASM() { "ofdgoImportPages": true, "ofdgoImportDocuments": true, "ofdgoSaveDocument": true, + "ofdgoPrepareSave": true, + "ofdgoSaveSnapshot": true, "ofdgoSetEncryption": true, "ofdgoSaveSigned": true, } diff --git a/internal/webview/webview.go b/internal/webview/webview.go index de5bebc..f48b20d 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -450,7 +450,7 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { case "remove": return nil, h.files.remove(text("handle"), text("name"), flag("recursive")) case "write.begin": - writer, err := h.files.begin(text("handle")) + writer, err := h.files.begin(guigo.PageContext(ctx), text("handle")) if err != nil { return nil, err } @@ -660,7 +660,7 @@ func (h *Host) openWriter(ctx context.Context, value any, name string) (io.Write return nil, fmt.Errorf("输出文件已存在") } } - return h.files.begin(id) + return h.files.begin(ctx, id) } // reserveOutput 为一次转换预留独立输出文件或目录 diff --git a/internal/webview/webview_files.go b/internal/webview/webview_files.go index afe37e3..ed8ead0 100644 --- a/internal/webview/webview_files.go +++ b/internal/webview/webview_files.go @@ -29,18 +29,23 @@ import ( "strings" "sync" "time" + + basefile "github.com/xiaoqidun/ofdgo/internal/base/file" ) // fileEntry 保存窗口授权的文件或目录 type fileEntry struct { - path string - name string - data []byte - file *os.File - stop func() bool - directory bool - owned bool - temporary bool + path string + name string + data []byte + file *os.File + stop func() bool + directory bool + owned bool + temporary bool + saveTarget *basefile.Target + readers int + retired bool } // fileStore 管理窗口范围内的文件授权和临时数据 @@ -55,12 +60,8 @@ type fileStore struct { // fileWriter 在同目录临时文件中保存输出 type fileWriter struct { id string - mu sync.Mutex store *fileStore - file *os.File - target string - err error - closed bool + output *basefile.Writer } // newFileStore 创建独立文件授权表 @@ -124,7 +125,14 @@ func (s *fileStore) add(name string, directory bool) (map[string]any, error) { s.mu.Lock() defer s.mu.Unlock() id := rand.Text() - s.entries[id] = &fileEntry{path: name, name: filepath.Base(name), directory: directory} + entry := &fileEntry{path: name, name: filepath.Base(name), directory: directory} + if !directory { + entry.saveTarget, err = basefile.NewTarget(name) + if err != nil { + return nil, err + } + } + s.entries[id] = entry return s.descriptor(id), nil } @@ -161,9 +169,10 @@ func (s *fileStore) file(id string) (map[string]any, error) { if entry == nil || entry.directory { return nil, fmt.Errorf("文件授权无效") } + handle := s.descriptor(id) size := int64(len(entry.data)) if entry.data == nil { - file, err := openReadFile(entry.path) + file, err := basefile.Open(entry.path) if err != nil { return nil, err } @@ -177,7 +186,7 @@ func (s *fileStore) file(id string) (map[string]any, error) { entry = &fileEntry{path: entry.path, name: entry.name, file: file} s.entries[id] = entry } - return map[string]any{"nativeFile": id, "name": entry.name, "size": size, "url": "/__native/data/" + id}, nil + return map[string]any{"nativeFile": id, "name": entry.name, "size": size, "url": "/__native/data/" + id, "handle": handle}, nil } // resolve 按授权标识读取文件并保持随机读取句柄 @@ -194,7 +203,7 @@ func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) { return bytes.NewReader(entry.data), int64(len(entry.data)), nil } if entry.file == nil { - file, err := openReadFile(entry.path) + file, err := basefile.Open(entry.path) if err != nil { return nil, 0, err } @@ -237,7 +246,27 @@ func (s *fileStore) resource(id string) (io.ReaderAt, int64, func() error, error if entry.data != nil { return bytes.NewReader(entry.data), int64(len(entry.data)), nil, nil } - file, err := openReadFile(entry.path) + if entry.file != nil && !entry.temporary { + info, err := entry.file.Stat() + if err != nil { + return nil, 0, nil, err + } + entry.readers++ + var once sync.Once + var closeErr error + return entry.file, info.Size(), func() error { + once.Do(func() { + s.mu.Lock() + defer s.mu.Unlock() + entry.readers-- + if entry.retired && entry.readers == 0 { + closeErr = entry.file.Close() + } + }) + return closeErr + }, nil + } + file, err := basefile.Open(entry.path) if err != nil { return nil, 0, nil, err } @@ -374,9 +403,9 @@ func (s *fileStore) reserve(id, base, extension string, directory bool) (string, } // begin 创建文件写入事务 -// 入参: id 输出授权标识 +// 入参: ctx 取消上下文, id 输出授权标识 // 返回: *fileWriter 事务写入器, error 创建错误 -func (s *fileStore) begin(id string) (*fileWriter, error) { +func (s *fileStore) begin(ctx context.Context, id string) (*fileWriter, error) { s.mu.Lock() defer s.mu.Unlock() if s.closed { @@ -386,11 +415,18 @@ func (s *fileStore) begin(id string) (*fileWriter, error) { if entry == nil || entry.directory || entry.path == "" { return nil, fmt.Errorf("输出授权无效") } - file, err := temporaryFile(filepath.Dir(entry.path), filepath.Base(entry.path)+".") + if entry.saveTarget == nil { + var err error + entry.saveTarget, err = basefile.NewTarget(entry.path) + if err != nil { + return nil, err + } + } + output, err := entry.saveTarget.Begin(ctx) if err != nil { return nil, err } - writer := &fileWriter{id: rand.Text(), store: s, file: file, target: entry.path} + writer := &fileWriter{id: rand.Text(), store: s, output: output} s.writers[writer.id] = writer return writer, nil } @@ -429,7 +465,8 @@ func (s *fileStore) remove(id, name string, recursive bool) error { defer root.Close() for key, entry := range s.entries { if entry.path == target || recursive && strings.HasPrefix(entry.path, target+string(filepath.Separator)) { - if entry.file != nil { + entry.retired = true + if entry.file != nil && entry.readers == 0 { entry.file.Close() } delete(s.entries, key) @@ -445,62 +482,21 @@ func (s *fileStore) remove(id, name string, recursive bool) error { // 入参: data 输出数据 // 返回: int 写入字节数, error 写入错误 func (w *fileWriter) Write(data []byte) (int, error) { - w.mu.Lock() - defer w.mu.Unlock() - if w.closed { - return 0, os.ErrClosed - } - if w.err != nil { - return 0, w.err - } - n, err := w.file.Write(data) - if err == nil && n != len(data) { - err = io.ErrShortWrite - } - w.err = err - return n, err + return w.output.Write(data) } // Close 同步并替换目标文件 // 返回: error 提交错误 func (w *fileWriter) Close() error { defer w.release() - w.mu.Lock() - defer w.mu.Unlock() - if w.closed { - return os.ErrClosed - } - w.closed = true - if w.err != nil { - return errors.Join(w.err, w.file.Close(), os.Remove(w.file.Name())) - } - if err := w.file.Sync(); err != nil { - w.file.Close() - os.Remove(w.file.Name()) - return err - } - if err := w.file.Close(); err != nil { - os.Remove(w.file.Name()) - return err - } - if err := replaceFile(w.file.Name(), w.target); err != nil { - os.Remove(w.file.Name()) - return err - } - return nil + return w.output.Close() } // Abort 删除未提交的临时输出 // 返回: error 清理错误 func (w *fileWriter) Abort() error { defer w.release() - w.mu.Lock() - defer w.mu.Unlock() - if w.closed { - return nil - } - w.closed = true - return errors.Join(w.file.Close(), os.Remove(w.file.Name())) + return w.output.Abort() } // release 移除已结束的写入事务 @@ -556,9 +552,12 @@ func (s *fileStore) close() error { if entry.stop != nil { entry.stop() } - if entry.file != nil { + s.mu.Lock() + entry.retired = true + if entry.file != nil && entry.readers == 0 { err = errors.Join(err, entry.file.Close()) } + s.mu.Unlock() } return errors.Join(err, os.RemoveAll(s.temporary)) } diff --git a/internal/webview/webview_other.go b/internal/webview/webview_other.go index 80ea1f2..d6d8e33 100644 --- a/internal/webview/webview_other.go +++ b/internal/webview/webview_other.go @@ -16,21 +16,9 @@ package webview -import "os" - // conversionMemory 未提供系统内存探测时仅按并行数调度 var conversionMemory func() int64 -// openReadFile 打开独立读取句柄 -func openReadFile(name string) (*os.File, error) { - return os.Open(name) -} - -// replaceFile 替换目标文件并保留已有读取句柄 -func replaceFile(source, target string) error { - return os.Rename(source, target) -} - // fontNameLanguage 未指定系统显示语言时沿用字体名称表顺序 // 返回: uint16 未指定平台标识, uint16 未指定语言标识 func fontNameLanguage() (uint16, uint16) { diff --git a/internal/webview/webview_windows.go b/internal/webview/webview_windows.go index 613d554..5bf4606 100644 --- a/internal/webview/webview_windows.go +++ b/internal/webview/webview_windows.go @@ -15,68 +15,13 @@ package webview import ( - "os" - "path/filepath" - "strings" "syscall" "unsafe" ) -// replaceFileProc 保留目标文件属性并替换文件内容 -var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW") - // memoryStatusProc 查询系统物理内存和提交额度 var memoryStatusProc = syscall.NewLazyDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx") -// openReadFile 允许原子替换路径,同时保持已有读取句柄有效 -func openReadFile(name string) (*os.File, error) { - path, err := nativeFilePath(name) - if err != nil { - return nil, &os.PathError{Op: "open", Path: name, Err: err} - } - handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) - if err != nil { - return nil, &os.PathError{Op: "open", Path: name, Err: err} - } - return os.NewFile(uintptr(handle), name), nil -} - -// replaceFile 替换已有文件,目标不存在时直接重命名 -func replaceFile(source, target string) error { - from, err := nativeFilePath(source) - if err != nil { - return err - } - to, err := nativeFilePath(target) - if err != nil { - return err - } - result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0) - if result != 0 { - return nil - } - if err == syscall.ERROR_FILE_NOT_FOUND { - return os.Rename(source, target) - } - return &os.LinkError{Op: "replace", Old: source, New: target, Err: err} -} - -// nativeFilePath 转换绝对路径并支持长路径与网络共享 -func nativeFilePath(name string) (*uint16, error) { - path, err := filepath.Abs(name) - if err != nil { - return nil, err - } - if !strings.HasPrefix(path, `\\?\`) { - if strings.HasPrefix(path, `\\`) { - path = `\\?\UNC\` + path[2:] - } else { - path = `\\?\` + path - } - } - return syscall.UTF16PtrFromString(path) -} - // fontNameLanguage 获取系统显示语言对应的OpenType名称表标识 // 返回: uint16 名称表平台标识, uint16 系统显示语言标识 func fontNameLanguage() (uint16, uint16) { diff --git a/ofdgo.go b/ofdgo.go index f28e8fd..718a4ef 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -20,6 +20,8 @@ import ( "image" "io" "io/fs" + + basefile "github.com/xiaoqidun/ofdgo/internal/base/file" ) // Open 打开OFD文件 @@ -27,14 +29,24 @@ import ( // 入参: path 文件路径, options 阅读选项 // 返回: *Reader 阅读器实例, error 错误信息 func Open(path string, options ...ReaderOption) (*Reader, error) { - r, err := zip.OpenReader(path) + f, err := basefile.Open(path) if err != nil { return nil, err } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + r, err := zip.NewReader(f, info.Size()) + if err != nil { + f.Close() + return nil, err + } reader := &Reader{ Path: path, - Zip: &r.Reader, - Closer: r, + Zip: r, + Closer: f, } if err := reader.open(options); err != nil { reader.Close() diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 4ee88a2..45f73cd 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -33,6 +33,7 @@ import ( "time" "unicode/utf8" + basefile "github.com/xiaoqidun/ofdgo/internal/base/file" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" ) @@ -40,7 +41,7 @@ import ( const editorPathValidationLimit = 1024 // Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用 -// Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo另存结果,不覆盖输入 +// Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo输出副本,Save和SaveAs通过绑定路径保存 // OnWriteProgress可选,按fonts、pages、references、resources、compress、write阶段同步回报进度,返回错误则停止保存 // completed和total为当前阶段已处理及总工作项,compress累计完成条目数且total为0,write阶段不计数 // 阶段可重复,准备完成不代表保存成功 @@ -73,6 +74,7 @@ type Editor struct { outlines []byte encryption *encryptionState output *outputOptimization + saveTarget *basefile.Target validatedPaths *renderCache[string, bool] richIndex *richTextIndex } diff --git a/ofdgo_editor_documents.go b/ofdgo_editor_documents.go index b6d240a..e64ddcb 100644 --- a/ofdgo_editor_documents.go +++ b/ofdgo_editor_documents.go @@ -451,6 +451,7 @@ func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error next.revision, next.serial = e.revision, e.serial next.OnWriteProgress, next.output = e.OnWriteProgress, e.output next.encryption = e.encryption + next.saveTarget = e.saveTarget next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS next.validatedPaths = e.validatedPaths *e = *next diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go index 945511d..81bc808 100644 --- a/ofdgo_editor_font_binding.go +++ b/ofdgo_editor_font_binding.go @@ -908,6 +908,7 @@ func (e *Editor) restoreResourceSnapshot(state Editor) { } state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress + state.saveTarget = e.saveTarget state.fontRenderer, state.fontSourcesCache = nil, nil state.fontMetrics = make(map[string]FontMetrics) *e = state diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index e0988e4..e1c3191 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -171,6 +171,7 @@ func (e *Editor) restoreTransaction(state Editor) { state.fontSourcesCache = e.fontSourcesCache state.validatedPaths = e.validatedPaths state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress + state.saveTarget = e.saveTarget *e = state } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index e42a6b7..d2e6c06 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -20,6 +20,7 @@ import ( "context" "encoding/hex" "encoding/xml" + "errors" "fmt" "io" "maps" @@ -29,6 +30,8 @@ import ( "strconv" "strings" "time" + + basefile "github.com/xiaoqidun/ofdgo/internal/base/file" ) // editorProgress 编辑与保存阶段的进度回调,nil表示不回报进度 @@ -54,6 +57,141 @@ type ofdXML struct { // ofdAttrs OFD节点属性 type ofdAttrs []xml.Attr +// ErrSaveTarget 表示编辑器尚未绑定保存目标 +var ErrSaveTarget = errors.New("save target is not set") + +// ErrSaveConflict 表示保存目标在打开或最近保存后被其他操作修改 +var ErrSaveConflict = basefile.ErrChanged + +// ErrSaveBusy 表示目标文件正在提交其他写入事务 +var ErrSaveBusy = basefile.ErrBusy + +// Snapshot 创建独立编辑快照,完成后可与原编辑器并行使用 +// 创建过程需与原实例串行,保留修订及保存目标,不复制撤销记录和进度回调 +// 原包压缩数据直接复制,快照不依赖原阅读器及文件句柄,内存随快照释放 +// 自定义后端及字体文件系统仍由调用方提供,需支持并行读取 +// 入参: ctx 取消上下文 +// 返回: *Editor 独立快照, error 准备或取消错误 +func (e *Editor) Snapshot(ctx context.Context) (*Editor, error) { + reader, err := e.readerContext(ctx, nil) + if err != nil { + return nil, err + } + defer reader.Close() + next := &Reader{files: make(map[string][]byte, len(reader.files)), encryption: reader.encryption, documentIndex: reader.documentIndex, selectedVersion: cloneEditorData(reader.selectedVersion)} + if reader.encryption != nil { + next.encryption = &encryptionState{info: cloneEditorData(reader.encryption.info), options: cloneEncryptionOptions(reader.encryption.options)} + } + if reader.Zip != nil { + var data bytes.Buffer + var count int64 + archive := zip.NewWriter(convertWriter{context: ctx, writer: &data, count: &count}) + for _, file := range reader.Zip.File { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := archive.Copy(file); err != nil { + return nil, err + } + } + if err := archive.Close(); err != nil { + return nil, err + } + next.Zip, err = zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len())) + if err != nil { + return nil, err + } + } + for name, data := range reader.files { + if err := ctx.Err(); err != nil { + return nil, err + } + next.files[name] = bytes.Clone(data) + } + if err := next.initRootContext(ctx); err != nil { + return nil, err + } + snapshot, err := next.EditorContext(ctx) + if err != nil { + return nil, err + } + snapshot.revision, snapshot.serial, snapshot.saveTarget = e.revision, e.serial, e.saveTarget + snapshot.backends, snapshot.fontDirs, snapshot.fontFS = e.backends, slices.Clone(e.fontDirs), slices.Clone(e.fontFS) + return snapshot, nil +} + +// SetSavePath 绑定保存目标并记录当前文件状态,不读取或修改编辑内容 +// 绑定已有文件时应在打开后立即调用;空路径解除绑定,调用需与编辑操作串行 +// 入参: name 目标路径 +// 返回: error 路径或文件状态错误 +func (e *Editor) SetSavePath(name string) error { + if name == "" { + e.saveTarget = nil + return nil + } + target, err := basefile.NewTarget(name) + if err != nil { + return err + } + e.saveTarget = target + return nil +} + +// SavePath 返回当前保存目标,未绑定时返回空字符串 +// 返回: string 绝对文件路径 +func (e *Editor) SavePath() string { + if e.saveTarget == nil { + return "" + } + return e.saveTarget.Name() +} + +// Save 将当前完整文档写回已绑定目标,不改变编辑状态及撤销记录 +// 调用需与编辑操作串行,支持取消;失败保留原文件,成功后可继续编辑和保存 +// 入参: ctx 取消上下文, options 输出配置 +// 返回: int64 输出字节数, error 保存错误 +func (e *Editor) Save(ctx context.Context, options WriteOptions) (int64, error) { + if e.saveTarget == nil { + return 0, ErrSaveTarget + } + return e.saveFile(ctx, e.saveTarget, options) +} + +// SaveAs 将完整文档另存,成功后切换保存目标,不改变撤销记录 +// 调用需与编辑操作串行,取消或失败时保持原目标 +// 入参: ctx 取消上下文, name 目标路径, options 输出配置 +// 返回: int64 输出字节数, error 保存错误 +func (e *Editor) SaveAs(ctx context.Context, name string, options WriteOptions) (int64, error) { + target, err := basefile.NewTarget(name) + if err != nil { + return 0, err + } + if e.saveTarget != nil && target.Name() == e.saveTarget.Name() { + target = e.saveTarget + } + n, err := e.saveFile(ctx, target, options) + if err == nil { + e.saveTarget = target + } + return n, err +} + +// saveFile 使用共用文件事务提交文档 +// 入参: ctx 取消上下文, target 保存目标, options 输出配置 +// 返回: int64 输出字节数, error 保存错误 +func (e *Editor) saveFile(ctx context.Context, target *basefile.Target, options WriteOptions) (int64, error) { + writer, err := target.Begin(ctx) + if err != nil { + return 0, err + } + defer writer.Abort() + n, err := e.WriteToWithOptions(ctx, writer, options) + if err != nil { + return n, err + } + return n, writer.Close() +} + // WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出 // 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集 // 原有字体在全包引用可确定时保留字形编号裁剪;编辑资源、复杂字体及无法确定的引用保持原样