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轮廓字体按实际文字生成子集
// 原有字体在全包引用可确定时保留字形编号裁剪;编辑资源、复杂字体及无法确定的引用保持原样