diff --git a/assets/webui/index.html b/assets/webui/index.html index 6cb4c45..5dc586d 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -166,7 +166,7 @@ - 操作复制移动删除 + 操作复制移动删除另存 页码 位置页前页后首部末尾 取消复制 @@ -257,8 +257,8 @@ 标题 - 作者 主题 + 作者 取消确定 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 0932e81..4079e7e 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1155,11 +1155,17 @@ button.outline-link:hover { .page-list-item[aria-current] { border-color: var(--accent); - background: #e4f3ef; color: var(--accent-strong); font-weight: 700; } +.page-list-item[aria-current]:not([aria-pressed="false"]), +.page-list-item[aria-pressed="true"] { + border-color: var(--accent); + background: #e4f3ef; + color: var(--accent-strong); +} + .thumb-paper { position: relative; box-sizing: content-box; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index eb18d43..edd5c18 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -39,6 +39,9 @@ const state = { savedRevision: null, insertObject: null, importPageCount: 0, + importing: false, + pageSelection: new Set(), + pageSelectionAnchor: null, outlineSelection: null, outlineAction: "add", styleOriginal: null, @@ -571,7 +574,10 @@ el.addPageButton.addEventListener("change", async () => { const page = currentPageInfo(); return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); }); -el.importCancel.addEventListener("click", () => el.importPanel.close()); +el.importCancel.addEventListener("click", () => state.importing ? cancelExport() : el.importPanel.close()); +el.importPanel.addEventListener("cancel", event => { + if (state.importing) { event.preventDefault(); cancelExport(); } +}); el.importPanel.addEventListener("close", () => { state.importPageCount = 0; el.importForm.reset(); @@ -592,29 +598,41 @@ el.importForm.addEventListener("submit", async event => { const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position, el.importOutlines.checked); if (saved) el.importPanel.close(); }); -editorClick(el.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex)); -editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex)); -editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1)); -editorClick(el.movePageNextButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1)); +editorClick(el.copyPageButton, () => changeSelectedPages("copy")); +editorClick(el.deletePageButton, () => changeSelectedPages("delete")); +editorClick(el.movePagePrevButton, () => changeSelectedPages("move", -1)); +editorClick(el.movePageNextButton, () => changeSelectedPages("move", 1)); editorClick(el.pageSettingsButton, openPagePanel); -editorClick(el.batchPagesButton, () => { +editorClick(el.batchPagesButton, openBatchPages); +function openBatchPages() { el.batchPageAction.value = "copy"; el.batchPagesSubmit.textContent = "复制"; el.batchPagePositionRow.hidden = true; el.batchPagePosition.value = "after"; - el.batchPageRange.value = String(state.pageIndex + 1); + el.batchPageRange.value = selectedPageRange(); el.batchPagesStatus.textContent = ""; el.batchPagesPanel.showModal(); el.batchPageRange.select(); -}); +} el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close()); el.batchPageAction.addEventListener("change", () => { - el.batchPagesSubmit.textContent = { copy: "复制", move: "移动", delete: "删除" }[el.batchPageAction.value]; + el.batchPagesSubmit.textContent = { copy: "复制", move: "移动", delete: "删除", save: "另存" }[el.batchPageAction.value]; el.batchPagePositionRow.hidden = el.batchPageAction.value !== "move"; }); el.batchPagesForm.addEventListener("submit", async event => { event.preventDefault(); + if (document.body.hasAttribute("aria-busy") || !el.batchPagesForm.reportValidity()) return; const args = [el.batchPageAction.value, el.batchPageRange.value]; + if (args[0] === "save") { + const seq = state.openSeq; + try { + const indices = await callWASM("ofdgoParsePageRange", args[1]); + if (seq === state.openSeq && el.batchPagesPanel.open && await exportFile(true, indices, "ofd")) el.batchPagesPanel.close(); + } catch (err) { + if (seq === state.openSeq) el.batchPagesStatus.textContent = err.message; + } + return; + } if (args[0] === "move") args.push({ before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.batchPagePosition.value]); if (await changeDocument("ofdgoBatchPages", null, ...args)) el.batchPagesPanel.close(); }); @@ -1721,7 +1739,9 @@ async function changeDocument(name, item, ...args) { const toolbarFocus = el.editorTools.contains(active) ? active : null; const previous = currentPageInfo(); const { scrollLeft, scrollTop } = el.viewerPanel; - setBusy(true); + const importing = name === "ofdgoImportPages"; + if (importing) state.importing = true; + setBusy(true, importing ? "正在导入" : "", null); try { args = await Promise.all(args); if (openSeq !== state.openSeq) { @@ -1800,6 +1820,12 @@ async function changeDocument(name, item, ...args) { } } } finally { + if (importing) { + state.importing = false; + state.exportRequestID = 0; + el.cancelExportButton.hidden = true; + el.importCancel.disabled = false; + } if (openSeq === state.openSeq) { setBusy(false); if (toolbarFocus?.isConnected && !toolbarFocus.disabled) toolbarFocus.focus({ preventScroll: true }); @@ -2196,7 +2222,7 @@ async function loadWASM() { setProgress("正在保存", null); } else if (!el.cancelExportButton.disabled) { if (data.stage === "prepare") { - const label = { fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", write: "正在写入" }[data.phase]; + const label = { snapshot: "正在准备", ids: "正在检查", commit: "正在整理", fonts: "正在处理字体", pages: "正在处理页面", references: "正在检查引用", resources: "正在整理资源", write: "正在写入" }[data.phase]; setProgress(label, data.total ? data.completed / data.total * 100 : null); } else if (data.completed === data.total) { setProgress("正在收尾", null); @@ -2205,6 +2231,13 @@ async function loadWASM() { } } } + } else if (data.type === "import") { + if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.importing && !el.importCancel.disabled) { + const label = { ids: "正在检查", pages: "正在导入", resources: "正在读取", commit: "正在完成" }[data.phase]; + el.importStatus.textContent = data.total ? `${label}${data.completed}/${data.total}页` : label; + setProgress(label, data.total ? data.completed / data.total * 100 : null); + if (data.phase === "commit") el.importCancel.disabled = el.cancelExportButton.disabled = true; + } } else if (data.type === "ready") { state.ready = true; resolve(); @@ -2305,6 +2338,8 @@ async function openOFD(file) { state.ofdBytes = bytes; state.fileName = file.name || "ofdgo.ofd"; state.editing = false; + state.pageSelection.clear(); + state.pageSelectionAnchor = null; state.objectClipboard = null; state.styleClipboard = null; state.editorInfo = null; @@ -2737,6 +2772,10 @@ async function openDocument(options = {}) { const pageCount = doc.pageCount || 0; const pageIndex = Math.min(Math.max(options.pageIndex || 0, 0), Math.max(pageCount - 1, 0)); state.doc = doc; + if (options.resetScroll || !options.skipAutoFonts) { + state.pageSelection.clear(); + state.pageSelectionAnchor = null; + } state.pageIndex = pageIndex; state.scale = options.scale || 1; if (!options.fitMode) { @@ -2989,7 +3028,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) const extension = archive ? "zip" : format.extension; const mime = archive ? "application/zip" : format.mime; let openSeq = state.openSeq; - const fileName = whole ? `${baseFileName()}.${extension}` : pageFileName(extension); + const fileName = saving && indices !== null ? `${baseFileName()}_选页.ofd` : whole ? `${baseFileName()}.${extension}` : pageFileName(extension); const pageIndex = state.pageIndex; const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0; state.exporting = true; @@ -3011,7 +3050,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) openSeq = state.openSeq; setBusy(true, `正在生成 ${label}`, null, whole ? STATUS.exporting : STATUS.pageExporting); } - const result = saving ? await callWASM("ofdgoSaveDocument", file) : whole + const result = saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, file) : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file); if (openSeq !== state.openSeq) { @@ -3020,13 +3059,15 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) if (result.blob) { downloadBytes(result.blob, result.mime, fileName); } - if (saving) { + if (saving && indices === null) { state.savedRevision = state.editorInfo.revision; setDirty(false); } setStatus(`${result.label} ${saving ? "保存" : "导出"}完成 ${formatBytes(result.size)}`); + return true; } catch (err) { if (openSeq === state.openSeq) { + if (el.batchPagesPanel.open) el.batchPagesStatus.textContent = err.name === "AbortError" ? "导出已取消" : err.message; if (err.name === "AbortError") { setStatus("导出已取消"); } else { @@ -3049,6 +3090,10 @@ function cancelExport() { return; } el.cancelExportButton.disabled = true; + if (state.importing) { + el.importCancel.disabled = true; + el.importStatus.textContent = "正在取消"; + } setProgress("正在取消", null); wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID }); } @@ -3371,22 +3416,24 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { canvasEditor.mount(index, page, surface); } for (const link of page.links) { - let url; - try { - url = new URL(link.uri); - } catch { - continue; - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - continue; - } const anchor = document.createElement("a"); anchor.className = "page-link"; - anchor.href = url.href; - anchor.target = "_blank"; - anchor.rel = "noopener noreferrer"; - anchor.title = url.href; - anchor.setAttribute("aria-label", url.href); + if (link.dest) { + const target = state.doc.pages.findIndex(page => page.id === link.dest.pageID); + if (target < 0 || !["XYZ", "Fit", "FitH", "FitV", "FitR"].includes(link.dest.type)) continue; + anchor.href = `#page-${target + 1}`; + anchor.title = `第${target + 1}页`; + anchor.addEventListener("click", event => { event.preventDefault(); navigateDestination(link.dest); }); + } else { + let url; + try { url = new URL(link.uri); } catch { continue; } + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + anchor.href = url.href; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + anchor.title = url.href; + } + anchor.setAttribute("aria-label", anchor.title); anchor.style.left = `${link.x / page.width * 100}%`; anchor.style.top = `${link.y / page.height * 100}%`; anchor.style.width = `${link.width / page.width * 100}%`; @@ -3400,6 +3447,51 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { } } +function destinationPoint(page, x, y) { + if (state.rotation === 90) return { x: page.height - y, y: x }; + if (state.rotation === 180) return { x: page.width - x, y: page.height - y }; + if (state.rotation === 270) return { x: y, y: page.width - x }; + return { x, y }; +} + +async function navigateDestination(dest) { + if (document.body.hasAttribute("aria-busy")) return; + const index = state.doc.pages.findIndex(page => page.id === dest.pageID); + if (index < 0) return; + const seq = state.openSeq, scale = state.scale; + await renderPage(index, { fit: false, scroll: false }); + if (seq !== state.openSeq || state.pageIndex !== index) return; + const page = state.doc.pages[index], size = pageViewSize(page), space = pageSpace(); + const width = el.viewerPanel.clientWidth - space * 2, height = el.viewerPanel.clientHeight - space * 2; + let left = dest.left, top = dest.top; + if (dest.type === "Fit") { + fitHeight(false); + scrollToPage(index); + return; + } else if (dest.type === "FitH") { + setScale(fitWidthScale(page), false); + left = 0; + } else if (dest.type === "FitV") { + setScale(height / (size.height * MM_TO_PX), false); + top = 0; + } else if (dest.type === "FitR") { + const a = destinationPoint(page, dest.left, dest.top), b = destinationPoint(page, dest.right, dest.bottom); + if (dest.right <= dest.left || dest.bottom <= dest.top) return; + setScale(Math.min(width / Math.abs(b.x - a.x), height / Math.abs(b.y - a.y)) / MM_TO_PX, false); + } else { + setScale(dest.zoom > 0 ? dest.zoom : scale, false); + } + const point = destinationPoint(page, left, top); + if (dest.type === "FitR") { + const bottom = destinationPoint(page, dest.right, dest.bottom); + point.x = Math.min(point.x, bottom.x); + point.y = Math.min(point.y, bottom.y); + } + const shell = pageShell(index).getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect(); + el.viewerPanel.scrollLeft += shell.left - viewer.left + point.x * MM_TO_PX * state.scale - space; + el.viewerPanel.scrollTop += shell.top - viewer.top + point.y * MM_TO_PX * state.scale - pageBlockSpace(); +} + function createTextLayer(text) { const layer = document.createElement("div"); layer.className = "text-layer"; @@ -4168,6 +4260,71 @@ function createOutlineList(outlines, path = []) { return list; } +function selectedPageIndexes() { + if (!state.pageSelection.size) return [state.pageIndex]; + return state.doc.pages.filter(page => state.pageSelection.has(page.id)).map(page => page.index); +} + +function changeSelectedPages(action, direction = 0) { + const indexes = selectedPageIndexes(); + if (indexes.length === 1) return changeDocument("ofdgoChangePage", null, action, indexes[0], ...(direction ? [indexes[0] + direction] : [])); + const at = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 2; + return changeDocument("ofdgoBatchPages", null, action, selectedPageRange(), ...(direction ? [at] : [])); +} + +function selectedPageRange() { + const pages = selectedPageIndexes().map(index => index + 1); + const ranges = []; + for (let i = 0; i < pages.length; i++) { + const first = pages[i]; + while (pages[i + 1] === pages[i] + 1) i++; + ranges.push(first === pages[i] ? String(first) : `${first}-${pages[i]}`); + } + return ranges.join(","); +} + +function syncPageSelection() { + for (const button of el.pageList.children) { + if (state.editing) button.setAttribute("aria-pressed", String(state.pageSelection.has(state.doc.pages[Number(button.dataset.pageIndex)].id))); + else button.removeAttribute("aria-pressed"); + } +} + +function selectThumbnailPage(event, index) { + if (document.body.hasAttribute("aria-busy")) return; + if (state.editing) { + const page = state.doc.pages[index], additive = event.ctrlKey || event.metaKey; + if (!additive) state.pageSelection.clear(); + if (event.shiftKey) { + let anchor = state.doc.pages.findIndex(page => page.id === state.pageSelectionAnchor); + if (anchor < 0) anchor = state.pageIndex; + for (let i = Math.min(anchor, index); i <= Math.max(anchor, index); i++) state.pageSelection.add(state.doc.pages[i].id); + } else { + if (additive && state.pageSelection.has(page.id)) state.pageSelection.delete(page.id); + else state.pageSelection.add(page.id); + state.pageSelectionAnchor = page.id; + } + syncPageSelection(); + updateControls(); + if (additive || event.shiftKey) return; + } + return renderPage(index); +} + +el.pageList.addEventListener("keydown", event => { + if (!state.editing || event.isComposing || document.body.hasAttribute("aria-busy")) return; + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") { + state.pageSelection = new Set(state.doc.pages.map(page => page.id)); + } else if (event.key === "Escape") { + state.pageSelection.clear(); + state.pageSelectionAnchor = null; + } else return; + event.preventDefault(); + event.stopPropagation(); + syncPageSelection(); + updateControls(); +}); + function renderPageList() { el.pageList.replaceChildren(); if (!state.doc) { @@ -4176,6 +4333,8 @@ function renderPageList() { if (state.thumbnailObserver) { state.thumbnailObserver.disconnect(); } + const ids = new Set(state.doc.pages.map(page => page.id)); + for (const id of state.pageSelection) if (!ids.has(id) || !state.editing) state.pageSelection.delete(id); const fragment = document.createDocumentFragment(); const thumbnailTargets = []; const openSeq = state.openSeq; @@ -4203,11 +4362,12 @@ function renderPageList() { button.append(thumb, label, size); if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index); - button.addEventListener("click", () => renderPage(page.index)); + button.addEventListener("click", event => selectThumbnailPage(event, page.index)); fragment.append(button); thumbnailTargets.push([button, page.index]); } el.pageList.append(fragment); + syncPageSelection(); for (const [button, index] of thumbnailTargets) { observeThumbnail(button, index, openSeq); } @@ -5170,11 +5330,13 @@ function updateEditorTools() { el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert"); el.saveButton.disabled = el.addPageButton.disabled = pagesDisabled; el.batchPagesButton.disabled = pagesDisabled; - el.copyPageButton.disabled = pagesDisabled || !pageCan("copy"); + const selectedPages = state.editing ? selectedPageIndexes() : [state.pageIndex]; + el.copyPageButton.disabled = pagesDisabled || selectedPages.some(index => !pageCan("copy", index)); el.pageSettingsButton.disabled = pagesDisabled || !pageCan("resize"); - el.deletePageButton.disabled = pagesDisabled || !pageCan("delete") || state.doc?.pageCount <= 1; - el.movePagePrevButton.disabled = pagesDisabled || !pageCan("move") || state.pageIndex === 0; - el.movePageNextButton.disabled = pagesDisabled || !pageCan("move") || state.pageIndex === state.doc?.pageCount - 1; + el.deletePageButton.disabled = pagesDisabled || selectedPages.some(index => !pageCan("delete", index)) || selectedPages.length >= state.doc?.pageCount; + const moveDisabled = pagesDisabled || selectedPages.some(index => !pageCan("move", index)); + el.movePagePrevButton.disabled = moveDisabled || selectedPages[0] === 0; + el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1; const enabled = state.editing && state.selectObjects && !state.panMode; canvasEditor.setEnabled(enabled); updateObjectControls(canvasEditor.selected); @@ -5283,7 +5445,7 @@ async function callWASM(name, ...args) { return new Promise((resolve, reject) => { const id = ++wasmRequestID; wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq }); - if (name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument") { + if (name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoImportPages") { state.exportRequestID = id; el.cancelExportButton.hidden = false; el.cancelExportButton.disabled = false; @@ -5318,7 +5480,10 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.pageForm.inert = busy; el.paragraphForm.inert = busy; el.infoForm.inert = busy; - el.importForm.inert = busy; + el.importForm.inert = busy && !state.importing; + for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy; + el.importPages.disabled = busy || el.importRange.value !== "custom"; + el.importSubmit.disabled = busy || !state.importPageCount; el.batchPagesForm.inert = busy; el.objectStyleForm.inert = busy; el.objectBoundsForm.inert = busy; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 2d4b223..14b5486 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -1,66 +1,77 @@ importScripts("./wasm_exec.js"); let pending = Promise.resolve(); -const exports = new Map(); +const operations = new Map(); self.onmessage = ({ data }) => { if (data.type === "cancel") { - exports.get(data.id)?.abort(); + operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument") { - exports.set(data.id, new AbortController()); + if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoImportPages") { + operations.set(data.id, new AbortController()); } pending = pending.then(() => handleMessage(data)); }; async function handleMessage({ id, name, args }) { let output; let channel; - const signal = exports.get(id)?.signal; + const signal = operations.get(id)?.signal; + const importing = name === "ofdgoImportPages"; try { signal?.throwIfAborted(); const chunks = []; let size = 0; if (signal) { - const file = args.pop(); - if (file) { - output = await file.createWritable(); - } - signal.throwIfAborted(); channel = new MessageChannel(); - const finish = (done, err) => { - channel.port1.onmessage = () => done(err?.message || "", signal.aborted); + const finish = (done, err, commit = false) => { + channel.port1.onmessage = () => { + if (commit && !signal.aborted && !err) operations.delete(id); + done(err?.message || "", signal.aborted); + }; channel.port2.postMessage(null); }; - args.push((bytes, done) => { - if (signal.aborted) { - finish(done); - return; - } - size += bytes.length; - if (output) { - output.write(bytes).then(() => finish(done), (err) => finish(done, err)); - } else { - chunks.push(new Blob([bytes])); - finish(done); - } - }); - if (name === "ofdgoExportDocument") { - args.push((completed, total, done) => { - self.postMessage({ id, type: "export", stage: "pages", completed, total }); - finish(done); - }); - } else if (name === "ofdgoSaveDocument") { + if (importing) { args.push((phase, completed, total, done) => { - self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); - finish(done); + self.postMessage({ id, type: "import", phase, completed, total }); + finish(done, null, phase === "commit"); }); + } else { + const file = args.pop(); + const indices = name === "ofdgoSaveDocument" ? args.shift() : null; + if (file) output = await file.createWritable(); + signal.throwIfAborted(); + args.push((bytes, done) => { + if (signal.aborted) { + finish(done); + return; + } + size += bytes.length; + if (output) { + output.write(bytes).then(() => finish(done), (err) => finish(done, err)); + } else { + chunks.push(new Blob([bytes])); + finish(done); + } + }); + if (name === "ofdgoExportDocument") { + args.push((completed, total, done) => { + self.postMessage({ id, type: "export", stage: "pages", completed, total }); + finish(done); + }); + } else if (name === "ofdgoSaveDocument") { + args.push((phase, completed, total, done) => { + self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total }); + finish(done); + }); + if (indices != null) args.push(indices); + } } } const payload = await globalThis[name](...args); signal?.throwIfAborted(); const result = typeof payload === "string" ? JSON.parse(payload) : payload; - if (signal && result.ok) { - exports.delete(id); + if (signal && !importing && result.ok) { + operations.delete(id); result.data.size = size; self.postMessage({ id, type: "export", stage: "save" }); if (output) { @@ -78,14 +89,14 @@ async function handleMessage({ id, name, args }) { await output?.abort().catch(() => {}); const result = { id, ok: false, error: err.message }; if (signal?.aborted) { - result.error = "导出已取消"; + result.error = importing ? "导入已取消" : "导出已取消"; result.canceled = true; } self.postMessage(result); } finally { channel?.port1.close(); channel?.port2.close(); - exports.delete(id); + operations.delete(id); } } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 7e18059..a3803db 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -104,9 +104,9 @@ func RunWASM() { registerCallback("ofdgoPageTextString", pageTextString) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoParsePageRange", parsePageRange) - registerExportCallback("ofdgoExportPage", exportPage) - registerExportCallback("ofdgoExportDocument", exportDocument) - registerExportCallback("ofdgoExportAttachment", exportAttachment) + registerAsyncCallback("ofdgoExportPage", exportPage) + registerAsyncCallback("ofdgoExportDocument", exportDocument) + registerAsyncCallback("ofdgoExportAttachment", exportAttachment) registerCallback("ofdgoMatchFontFiles", matchFontFiles) registerCallback("ofdgoFontFaces", fontFaces) registerCallback("ofdgoFontFace", fontFace) @@ -122,7 +122,7 @@ func RunWASM() { registerCallback("ofdgoPasteStyle", pasteStyle) registerCallback("ofdgoResizeObjects", resizeObjects) registerCallback("ofdgoLoadImport", loadImport) - registerCallback("ofdgoImportPages", importPages) + registerAsyncCallback("ofdgoImportPages", importPages) registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoStyleText", styleText) @@ -154,7 +154,7 @@ func RunWASM() { registerCallback("ofdgoDeleteObject", deleteObject) registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) - registerExportCallback("ofdgoSaveDocument", saveDocument) + registerAsyncCallback("ofdgoSaveDocument", saveDocument) select {} } @@ -167,9 +167,9 @@ func registerCallback(name string, fn func([]js.Value) (any, error)) { js.Global().Set(name, cb) } -// registerExportCallback 异步导出,为浏览器处理数据块让出事件循环 +// registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环 // 入参: name 回调名称, fn 回调函数 -func registerExportCallback(name string, fn func([]js.Value) (any, error)) { +func registerAsyncCallback(name string, fn func([]js.Value) (any, error)) { cb := js.FuncOf(func(this js.Value, args []js.Value) any { executor := js.FuncOf(func(this js.Value, callbacks []js.Value) any { resolve := callbacks[0] @@ -349,13 +349,18 @@ func renderPage(args []js.Value) (any, error) { } links := make([]any, len(page.Links)) for i, link := range page.Links { - links[i] = map[string]any{ + item := map[string]any{ "uri": link.URI, "x": link.Box.X, "y": link.Box.Y, "width": link.Box.W, "height": link.Box.H, } + if dest := link.Dest; dest != nil { + item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top, + "right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom} + } + links[i] = item } fonts := make([]any, len(page.Fonts)) for i, font := range page.Fonts { @@ -1283,7 +1288,7 @@ func loadImport(args []js.Value) (any, error) { } // importPages 插入指定来源页面并保持目标文档元数据 -// 入参: args 页码表达式,空值为全部、目标零基插入位置和是否导入对应目录 +// 入参: args 页码表达式,空值为全部、目标零基插入位置、是否导入对应目录和可选进度回调 // 返回: any 文档信息及首个插入页, error 错误信息 func importPages(args []js.Value) (any, error) { if currentEditor == nil || pendingImport == nil { @@ -1306,6 +1311,9 @@ func importPages(args []js.Value) (any, error) { } at := args[1].Int() options := ofdgo.PageImportOptions{Outlines: len(args) > 2 && args[2].Bool()} + if len(args) > 3 { + options.OnProgress = editorOperationProgress(args[3]) + } if _, err := currentEditor.ImportPagesWithOptions(pendingImport, indexes, at, options); err != nil { return nil, err } @@ -1943,27 +1951,40 @@ func insertImage(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } -// saveDocument 分块写出当前编辑文档 -// 入参: args 数据写出回调和可选的准备进度回调 +// editorOperationProgress 节流长操作检查点并向浏览器让出执行,保证阶段开始和结束可取消 +// 入参: callback 浏览器进度回调 +// 返回: func(string, int, int) error 进度回调 +func editorOperationProgress(callback js.Value) func(string, int, int) error { + var lastStage string + var lastProgress time.Time + return func(stage string, completed, total int) error { + if stage == lastStage && (total == 0 || completed != total) && time.Since(lastProgress) < 32*time.Millisecond { + return nil + } + lastStage, lastProgress = stage, time.Now() + return awaitExport(callback, stage, completed, total) + } +} + +// saveDocument 分块写出当前编辑文档或指定页面 +// 入参: args 数据写出回调、可选准备进度回调和可选页面索引 // 返回: any 保存结果, error 错误信息 func saveDocument(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } if len(args) > 1 { - var lastStage string - var lastProgress time.Time - currentEditor.OnWriteProgress = func(stage string, completed, total int) error { - if stage == lastStage && completed != total && time.Since(lastProgress) < 32*time.Millisecond { - return nil - } - lastStage, lastProgress = stage, time.Now() - return awaitExport(args[1], stage, completed, total) - } + currentEditor.OnWriteProgress = editorOperationProgress(args[1]) defer func() { currentEditor.OnWriteProgress = nil }() } writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) - if _, err := currentEditor.WriteTo(writer); err != nil { + var err error + if len(args) > 2 { + _, err = currentEditor.WritePagesTo(writer, indexesFromJS(args[2])) + } else { + _, err = currentEditor.WriteTo(writer) + } + if err != nil { return nil, err } if err := writer.Flush(); err != nil { diff --git a/ofdgo_action.go b/ofdgo_action.go index 16fad48..458ad24 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -59,10 +59,11 @@ type URI struct { Base string `xml:"Base,attr"` } -// PageLink 页面矩形外链,Box使用页面毫米坐标 +// PageLink 页面矩形链接,URI与Dest分别表示外链和文档内跳转,Box使用页面毫米坐标 type PageLink struct { - URI string - Box Box + URI string + Box Box + Dest *Dest } // GotoA 附件动作 diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index 609b1ab..a0beee2 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -18,6 +18,7 @@ import ( "bytes" "encoding/xml" "fmt" + "io" "maps" "path" "slices" @@ -48,11 +49,15 @@ type editorPageImport struct { defaultCS string attachments map[string][]byte copyPage bool + progress editorProgress } // PageImportOptions 页面导入选项,零值仅导入页面及其关联资源 +// OnProgress按ids、pages、resources、commit阶段回报进度,total为0表示总量未知 +// 回调返回错误则取消导入,commit检查点之后统一提交;回调不可重入修改编辑器 type PageImportOptions struct { - Outlines bool + Outlines bool + OnProgress func(stage string, completed, total int) error } // ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 @@ -99,7 +104,9 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo if len(indexes) == 0 { return nil, nil } - if err := e.prepareSourceIDs(); err != nil { + progress := editorProgress(options.OnProgress) + maximum, err := e.sourceMaxID(progress) + if err != nil { return nil, err } base := e.source @@ -109,11 +116,11 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo return nil, err } } - prefix := path.Join(base.directory, "Import_"+strconv.Itoa(e.maxID+1)) + prefix := path.Join(base.directory, "Import_"+strconv.Itoa(maximum+1)) for editorDirectoryExists(base.reader, prefix) { prefix += "_" } - m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: e.maxID, copyPage: copyPage, + m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: maximum, copyPage: copyPage, progress: progress, ids: make(map[string]string), pages: selected, files: make(map[string][]byte), resources: make(map[string]editorImportEntry), used: make(map[string][]byte), templates: make(map[string]TemplatePage), templateParts: make(map[string][]byte), attachments: make(map[string][]byte)} if copyPage { for _, page := range doc.Pages.Page { @@ -143,6 +150,9 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo refs := make([]Page, len(indexes)) ids := make([]string, len(indexes)) for i, index := range indexes { + if err := progress.report("pages", i, len(indexes)); err != nil { + return nil, err + } page := doc.Pages.Page[index] ids[i] = m.id(page.ID) name, err := m.copyXML(source.ResPath(page.BaseLoc)) @@ -151,6 +161,9 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo } refs[i] = Page{ID: ids[i], BaseLoc: name} } + if err := progress.report("pages", len(indexes), len(indexes)); err != nil { + return nil, err + } annotations, err := m.annotations() if err != nil { return nil, err @@ -228,6 +241,9 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo } } } + if err := progress.report("commit", 0, 0); err != nil { + return nil, err + } if e.source == nil { for i := range e.resources { resource := &e.resources[i] @@ -242,6 +258,7 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo } } e.source, e.maxID = next, m.maximum + next.idsReady = true e.outlines = outlines e.pages = slices.Insert(e.pages, at, added...) if change := e.recordChange(); change != nil { @@ -266,11 +283,43 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo return ids, nil } +// readFile 分块读取导入条目,允许资源解压过程中取消,不共享来源内存 +// 入参: name 包内路径 +// 返回: []byte 条目内容, error 错误信息 +func (m *editorPageImport) readFile(name string) ([]byte, error) { + input, err := m.reader.openFile(name) + if err != nil { + return nil, err + } + defer input.Close() + return io.ReadAll(&editorProgressReader{Reader: input, progress: m.progress, stage: "resources"}) +} + +// editorProgressReader 为输入流提供可取消的分块读取检查点 +type editorProgressReader struct { + io.Reader + progress editorProgress + stage string + completed int +} + +// Read 读取数据并在下一块开始前检查取消 +// 入参: data 接收缓冲区 +// 返回: int 读取字节数, error 错误信息 +func (r *editorProgressReader) Read(data []byte) (int, error) { + if err := r.progress.report(r.stage, r.completed, 0); err != nil { + return 0, err + } + n, err := r.Reader.Read(data[:min(len(data), 1<<20)]) + r.completed += n + return n, err +} + // outlines 筛选指向导入页面的目录树并复用标准动作迁移 // 返回: []byte 目录片段, error 错误信息 func (m *editorPageImport) outlines() ([]byte, error) { name := m.reader.ResPath(m.reader.OFD.DocBody[0].DocRoot) - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return nil, err } @@ -422,7 +471,7 @@ func (m *editorPageImport) id(value string) string { // 入参: name 资源索引路径 // 返回: error 错误信息 func (m *editorPageImport) resourceIndex(name string) error { - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return err } @@ -505,11 +554,11 @@ func (m *editorPageImport) copyData(name string, evidence bool) (string, error) } target := path.Join(directory, name) if _, exists := m.files[target]; !exists { - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return "", err } - m.files[target] = bytes.Clone(data) + m.files[target] = data } return "/" + target, nil } @@ -522,7 +571,7 @@ func (m *editorPageImport) copyXML(name string) (string, error) { if _, exists := m.files[target]; exists { return "/" + target, nil } - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return "", err } @@ -752,7 +801,7 @@ func (m *editorPageImport) annotations() ([]byte, error) { return nil, nil } name := m.reader.ResPath(m.doc.Annotations) - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return nil, err } @@ -788,7 +837,7 @@ func (m *editorPageImport) attachment(id string) (string, error) { if m.doc.Attachments.Path != "" { name = m.reader.ResPath(m.doc.Attachments.Path) } - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return "", err } @@ -817,7 +866,7 @@ func (m *editorPageImport) signatures() ([]byte, error) { return nil, nil } name := m.reader.ResPath(m.doc.Signatures) - data, err := m.reader.readFile(name) + data, err := m.readFile(name) if err != nil { return nil, err } @@ -834,7 +883,7 @@ func (m *editorPageImport) signatures() ([]byte, error) { if err != nil { return nil, err } - value, err := m.reader.readFile(loc) + value, err := m.readFile(loc) if err != nil { return nil, err } diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 12c1541..3889847 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -163,8 +163,23 @@ func editorDirectoryExists(reader *Reader, directory string) bool { // prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID // 返回: error 错误信息 func (e *Editor) prepareSourceIDs() error { + maximum, err := e.sourceMaxID(nil) + if err != nil { + return err + } + e.maxID = maximum + if e.source != nil { + e.source.idsReady = true + } + return nil +} + +// sourceMaxID 核对最大标识,不修改文档或缓存,允许导入准备阶段取消 +// 入参: progress 进度回调 +// 返回: int 最大标识, error 错误信息 +func (e *Editor) sourceMaxID(progress editorProgress) (int, error) { if e.source == nil || e.source.idsReady { - return nil + return e.maxID, nil } reader := e.source.reader names := make(map[string]bool) @@ -181,17 +196,16 @@ func (e *Editor) prepareSourceIDs() error { } input, err := reader.openFile(name) if err != nil { - return err + return 0, err } - id, err := editorXMLMaxID(input) + id, err := editorXMLMaxID(&editorProgressReader{Reader: input, progress: progress, stage: "ids"}) input.Close() if err != nil { - return fmt.Errorf("scan IDs in %s: %w", name, err) + return 0, fmt.Errorf("scan IDs in %s: %w", name, err) } maximum = max(maximum, id) } - e.maxID, e.source.idsReady = maximum, true - return nil + return maximum, nil } // editorXMLMaxID 流式读取OFD结构中的最大标识,跳过非OFD的XML附件 @@ -202,7 +216,8 @@ func editorXMLMaxID(input io.Reader) (int, error) { maximum, started := 0, false for { token, err := decoder.Token() - if err == io.EOF || err != nil && !started { + var syntax *xml.SyntaxError + if err == io.EOF || !started && errors.As(err, &syntax) { return maximum, nil } if err != nil { diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index b33413b..94bb05e 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -475,9 +475,10 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress } // sourceReader 生成原包与修改条目组成的独立预览快照,不进行ZIP压缩 +// 入参: progress 准备进度回调 // 返回: *Reader 阅读器, error 错误信息 -func (e *Editor) sourceReader() (*Reader, error) { - parts, err := e.sourceParts(nil) +func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) { + parts, err := e.sourceParts(progress) if err != nil { return nil, err } diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 5a8cae8..ddb949d 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -180,9 +180,9 @@ func (r *Renderer) renderPageToContext(ctx *canvas.Context, page *PageContent, d return nil } -// PageLinks 获取页面及可见注释的矩形外链,不包含自定义Region +// PageLinks 获取页面及可见注释的矩形链接,解析书签目标,不包含自定义Region // 入参: page 页面内容 -// 返回: []PageLink 页面外链, error 错误信息 +// 返回: []PageLink 页面链接, error 错误信息 func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { box, err := r.GetPageBox(page) if err != nil { @@ -193,12 +193,31 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { sources = append(sources, annotationActionSources(r.Reader.Annots[page.ID])...) } var links []PageLink + var bookmarks map[string]Dest for _, source := range sources { if source.Box.W <= 0 || source.Box.H <= 0 { continue } for _, action := range source.Actions { - if action.Event == "CLICK" && action.URI != nil && action.URI.URI != "" && action.Region == nil { + if action.Event != "CLICK" || action.Region != nil { + continue + } + if action.Goto != nil { + if bookmarks == nil { + doc, err := r.Reader.Doc() + if err != nil { + return nil, err + } + bookmarks = make(map[string]Dest, len(doc.Bookmarks.Bookmark)) + for _, bookmark := range doc.Bookmarks.Bookmark { + bookmarks[bookmark.Name] = bookmark.Dest + } + } + if dest := gotoDest(action.Goto, bookmarks); dest != nil { + value := *dest + links = append(links, PageLink{Dest: &value, Box: source.Box}) + } + } else if action.URI != nil && action.URI.URI != "" { links = append(links, PageLink{URI: resolveActionURI(*action.URI), Box: source.Box}) } } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 138c346..cb7af60 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -86,21 +86,57 @@ func (e *Editor) WriteTo(writer io.Writer) (int64, error) { return output.count, err } +// WritePagesTo 按指定顺序将页面另存为独立OFD,不修改当前文档或撤销记录 +// 保留描述信息并生成新文档标识,迁移相关资源、目录、注释和签章外观,移除指向未选页面的跳转 +// 签名凭据不代表另存文件验签有效;进度与取消复用OnWriteProgress,另含snapshot、ids和commit阶段 +// 入参: writer 输出流, indexes 从0开始的页面索引,不可重复或为空 +// 返回: int64 已写入字节数, error 错误信息 +func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) { + if len(indexes) == 0 { + return 0, fmt.Errorf("no pages selected") + } + progress := editorProgress(e.OnWriteProgress) + if err := progress.report("snapshot", 0, 0); err != nil { + return 0, err + } + reader, err := e.reader(progress) + if err != nil { + return 0, err + } + defer reader.Close() + selected := NewEditor() + id := selected.Info.DocID + selected.Info = cloneEditorData(e.Info) + selected.Info.DocID = id + selected.OnWriteProgress = e.OnWriteProgress + if _, err := selected.ImportPagesWithOptions(reader, indexes, 0, PageImportOptions{Outlines: true, OnProgress: e.OnWriteProgress}); err != nil { + return 0, err + } + return selected.WriteTo(writer) +} + // Reader 获取当前文档的独立内存快照,不进行ZIP压缩,后续修改不影响已有快照 // 返回的Reader可用于现有渲染、搜索和导出接口,新增资源仅包含实际引用的部分,二进制数据内部共享只读 // 返回: *Reader 阅读器, error 错误信息 func (e *Editor) Reader() (*Reader, error) { + return e.reader(nil) +} + +// reader 生成内存快照,保存时复用准备进度,预览时不触发回调 +// 入参: progress 准备进度回调 +// 返回: *Reader 阅读器, error 错误信息 +func (e *Editor) reader(progress editorProgress) (*Reader, error) { if err := e.validate(); err != nil { return nil, err } if e.source != nil { - return e.sourceReader() + return e.sourceReader(progress) } r := &Reader{files: make(map[string][]byte)} if err := e.writeParts(func(name string, data []byte, _ bool) error { r.files[name] = data return nil - }, nil); err != nil { + }, progress); err != nil { return nil, err } if err := r.initRoot(); err != nil {