From 46e6d5b622fee0ed25f515addc1f95262370abda Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 24 Sep 2026 00:27:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=85=E8=AF=BB=E7=BC=96=E8=BE=91):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=A7=E6=96=87=E4=BB=B6=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E3=80=81=E9=A1=B5=E7=A0=81=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=BC=82=E5=B8=B8=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.css | 42 ++- assets/webui/ofdgo.js | 479 +++++++++++++++++++++++++++------- internal/webui/wasm.go | 4 +- internal/webui/webui.go | 75 ++++-- ofdgo_editor_annot_content.go | 3 + ofdgo_editor_history.go | 11 +- ofdgo_editor_open.go | 64 ++++- ofdgo_editor_package.go | 75 +++++- ofdgo_reader.go | 10 +- 9 files changed, 611 insertions(+), 152 deletions(-) diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index ce688f1..ff230e2 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -568,7 +568,7 @@ button:not(:disabled):hover { .page-control { display: inline-grid; - grid-template-columns: 4ch auto 4ch; + grid-template-columns: max(4ch, var(--page-width, 4ch)) auto max(4ch, var(--page-width, 4ch)); align-items: center; gap: 6px; height: var(--control-height); @@ -1303,6 +1303,39 @@ button.outline-link:hover { gap: 12px; } +.virtual-pages { + position: relative; + display: block; + overflow-y: clip; + overflow-anchor: none; +} + +.page-list.virtual-pages > .page-list-item, +.svg-host.virtual-pages > .page-shell { + position: absolute; +} + +.svg-host.virtual-pages > .page-shell { + left: 50%; + transform: translateX(-50%); +} + +.page-list.virtual-pages > .page-list-item { + grid-template-rows: minmax(0, 1fr) 20px 18px; +} + +.page-list.virtual-pages .thumb-paper { + aspect-ratio: auto; +} + +.page-list.virtual-pages .thumb-label { + line-height: 20px; +} + +.page-list.virtual-pages .thumb-size { + line-height: 18px; +} + .page-list-item { display: grid; gap: 6px; @@ -1398,6 +1431,7 @@ button.outline-link:hover { min-width: 0; min-height: 0; overflow: auto; + overflow-anchor: none; background: linear-gradient(90deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px), linear-gradient(0deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px), @@ -1555,6 +1589,10 @@ button.outline-link:hover { margin: 0 auto; } +.svg-host.virtual-pages { + display: block; +} + .page-shell { position: relative; overflow: hidden; @@ -2613,7 +2651,7 @@ button.outline-link:hover { } .page-control { - grid-template-columns: 3ch auto 3ch; + grid-template-columns: max(3ch, var(--page-width, 3ch)) auto max(3ch, var(--page-width, 3ch)); gap: 4px; padding: 0 6px; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 9037a36..81acfc1 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -6,6 +6,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); const DEFAULT_IMAGE_DPI = 300; const PAGE_CACHE_LIMIT = 16; const PAGE_CACHE_BYTES = 32 * 1024 * 1024; +const SCROLL_SEGMENT_SIZE = 1000000; const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects"; const STATUS = { ready: "选择 OFD 文件", @@ -100,6 +101,11 @@ const state = { pageObserver: null, visiblePages: new Set(), scrollFrame: 0, + pageWindow: null, + thumbnailWindow: null, + thumbnailFrame: 0, + pageDragIndex: null, + pageListCurrent: null, thumbnailInFlight: new Set(), thumbnailObserver: null, visibleThumbnails: new Set(), @@ -334,6 +340,7 @@ const el = { localFontButton: document.querySelector("#localFontButton"), prevButton: document.querySelector("#prevButton"), nextButton: document.querySelector("#nextButton"), + pageControl: document.querySelector(".page-control"), pageInput: document.querySelector("#pageInput"), pageTotal: document.querySelector("#pageTotal"), zoomOutButton: document.querySelector("#zoomOutButton"), @@ -1172,6 +1179,28 @@ COMPACT_LAYOUT.addEventListener("change", syncLayoutMode); el.viewerPanel.addEventListener("scroll", () => { schedulePageSync(); }); +el.navigationContent.addEventListener("scroll", () => { + if (state.thumbnailFrame) return; + state.thumbnailFrame = requestAnimationFrame(() => { + state.thumbnailFrame = 0; + syncThumbnailWindow(); + }); +}); +for (const [viewport, current] of [[el.viewerPanel, () => state.pageWindow], [el.navigationContent, () => el.pageList.hidden ? null : state.thumbnailWindow]]) { + viewport.addEventListener("wheel", event => { + const view = current(); + if (!view || view.total <= SCROLL_SEGMENT_SIZE || event.ctrlKey || event.shiftKey || !event.deltaY) return; + event.preventDefault(); + view.relativeScroll = false; + const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1; + scrollPageWindowBy(view, event.deltaY * unit); + viewport.scrollLeft += event.deltaX * unit; + }, { passive: false }); + viewport.addEventListener("pointerdown", event => { + const view = current(); + if (view) view.relativeScroll = event.pointerType === "touch" || state.editing && !!event.target.closest(".page-shell"); + }); +} el.viewerPanel.addEventListener("pointerdown", startPan); el.viewerPanel.addEventListener("pointermove", movePan); el.viewerPanel.addEventListener("pointerup", endPan); @@ -1229,6 +1258,24 @@ function handleKeyDown(event) { if (document.body.hasAttribute("aria-busy") || formDialogOpen()) { return; } + if (state.pageWindow && (!event.shiftKey || key === " ") && el.viewerPanel.contains(target) + && !target.closest("input, textarea, select, button, a, [contenteditable]")) { + const view = state.pageWindow; + const delta = { ArrowUp: -40, ArrowDown: 40, PageUp: -el.viewerPanel.clientHeight * 0.9, PageDown: el.viewerPanel.clientHeight * 0.9, + " ": el.viewerPanel.clientHeight * 0.9 * (event.shiftKey ? -1 : 1) }[key]; + if (key === "Home" || key === "End") { + event.preventDefault(); + view.relativeScroll = false; + syncPageWindow(view, key === "Home" ? -windowInset(view) : view.total); + syncCurrentPageFromScroll(); + return; + } + if (delta !== undefined && !event.ctrlKey && !event.metaKey && view.total > SCROLL_SEGMENT_SIZE) { + event.preventDefault(); + scrollPageWindowBy(view, delta); + return; + } + } if (event.ctrlKey || event.metaKey) { if (state.editing && !target.closest("input, textarea, select, [contenteditable]") && (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) { @@ -2519,11 +2566,8 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) { renderMeta(true); updateControls(); loadDocumentDetails(openSeq); - for (const page of doc.pages) { - observeFlowPage(pageShell(page.index)); - const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`); - observeThumbnail(button, page.index, openSeq); - } + for (const shell of el.svgHost.children) observeFlowPage(shell); + for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq); } } } @@ -2627,6 +2671,7 @@ function syncLayoutMode(event) { function resizeViewer() { if (state.doc) { + syncThumbnailWindow(); applyFit(false); if (state.fitMode !== "free") { scrollToPage(state.pageIndex); @@ -3551,6 +3596,7 @@ async function openDocument(options = {}) { return; } const openSeq = options.openSeq || (state.openSeq += 1); + const previewPosition = options.previewScroll && state.pageWindow ? pageWindowAnchor(state.pageWindow) : null; const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : []; if (!state.ready || state.wasmExited) { await ensureWASM(); @@ -3658,6 +3704,8 @@ async function openDocument(options = {}) { restoreScaleAnchor(options.previewAnchor); } else if (options.previewScroll) { Object.assign(el.viewerPanel, options.previewScroll); + if (previewPosition && state.pageWindow) syncPageWindow(state.pageWindow, + state.pageWindow.offsets[previewPosition.index] + previewPosition.offset); } else { scrollToPage(pageIndex); } @@ -3742,6 +3790,8 @@ async function renderPage(index, options = {}) { } if (options.scroll !== false) { scrollToPage(index); + } else if (state.pageWindow && (!pageShell(index) || pageShell(index).style.visibility === "hidden")) { + scrollToPage(index); } const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 }); if (openSeq !== state.openSeq || index !== state.pageIndex) { @@ -4096,8 +4146,123 @@ function cancelExport() { wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID }); } +function virtualIndex(offsets, top) { + let low = 0, high = offsets.length - 1; + while (low + 1 < high) { + const mid = (low + high) >>> 1; + if (offsets[mid] <= top) low = mid; + else high = mid; + } + return low; +} + +function createPageWindow(host, viewport, overscan, create, release, observe) { + host.classList.add("virtual-pages"); + return { host, viewport, overscan, create, release, observe, nodes: new Map(), offsets: new Float64Array(1), origin: 0, total: 0, gap: 0 }; +} + +function windowInset(view) { + return view.host.getBoundingClientRect().top - view.viewport.getBoundingClientRect().top + + view.viewport.scrollTop - view.viewport.clientTop; +} + +function pageWindowTop(view) { + const inset = windowInset(view); + if (view.total <= SCROLL_SEGMENT_SIZE) return view.viewport.scrollTop - inset; + const delta = view.viewport.scrollTop - view.scrollTop; + if (!delta) return view.logicalTop; + if (view.relativeScroll) return view.logicalTop + delta; + const max = view.viewport.scrollHeight - view.viewport.clientHeight; + return view.viewport.scrollTop / Math.max(1, max) * (max + view.total - SCROLL_SEGMENT_SIZE) - inset; +} + +function scrollPageWindowBy(view, delta) { + if (view) syncPageWindow(view, pageWindowTop(view) + delta); +} + +function scrollViewerBy(delta) { + if (state.pageWindow) scrollPageWindowBy(state.pageWindow, delta); + else el.viewerPanel.scrollTop += delta; +} + +function syncPageWindow(view, target = null) { + if (!view || view.offsets.length < 2) return; + const { host, viewport, offsets, nodes } = view; + const extent = Math.min(SCROLL_SEGMENT_SIZE, view.total); + host.style.height = `${extent}px`; + const inset = windowInset(view), height = viewport.clientHeight; + const maxTop = Math.max(0, viewport.scrollHeight - height); + const logicalMax = maxTop + view.total - extent; + const logical = Math.max(-inset, Math.min(logicalMax - inset, target ?? pageWindowTop(view))); + viewport.scrollTop = logicalMax > 0 ? (logical + inset) * maxTop / logicalMax : 0; + view.scrollTop = viewport.scrollTop; + view.logicalTop = logical; + view.origin = logical - viewport.scrollTop + inset; + const first = virtualIndex(offsets, Math.max(view.origin, logical - view.overscan)); + const last = virtualIndex(offsets, Math.min(view.origin + extent, logical + height + view.overscan)); + const selection = host === el.svgHost && state.documentSelection ? document.getSelection() : null; + const selected = selection?.rangeCount && isDocumentSelection(selection.getRangeAt(0)); + let changed = false; + for (const [index, node] of nodes) { + if ((index < first || index > last) && view.release(node, index)) { + node.remove(); + nodes.delete(index); + changed = true; + } + } + for (let index = first; index <= last; index++) { + if (nodes.has(index)) continue; + const node = view.create(state.doc.pages[index]); + nodes.set(index, node); + const next = [...host.children].find(child => Number(child.dataset.pageIndex) > index); + host.insertBefore(node, next || null); + changed = true; + node.style.top = `${offsets[index] - view.origin}px`; + view.observe(node, index); + } + for (const [index, node] of nodes) { + const top = offsets[index] - view.origin; + const itemHeight = offsets[index + 1] - offsets[index] - view.gap; + node.style.top = `${Math.max(-itemHeight, Math.min(extent, top))}px`; + node.style.visibility = top + itemHeight < 0 || top >= extent ? "hidden" : ""; + } + if (selected && changed) selection.getRangeAt(0).selectNodeContents(host); + view.anchor = pageWindowAnchor(view); +} + +function pageWindowAnchor(view) { + const top = pageWindowTop(view); + const index = virtualIndex(view.offsets, top); + return { index, offset: top - view.offsets[index] }; +} + +function releaseFlowShell(shell, index) { + if (shell.contains(document.activeElement) || canvasEditor.drag?.item?.index === index) return false; + unmountPage(index); + if (state.selectedPages.has(index)) return false; + state.pageObserver?.unobserve(shell); + state.visiblePages.delete(index); + return true; +} + +function createFlowShell(page) { + const shell = document.createElement("div"); + shell.className = "page-shell"; + shell.dataset.pageIndex = String(page.index); + const surface = document.createElement("div"); + surface.className = "page-surface"; + const placeholder = document.createElement("div"); + placeholder.className = "page-placeholder"; + placeholder.textContent = `第 ${page.index + 1} 页`; + shell.append(surface, placeholder); + layoutPageShell(shell, page); + return shell; +} + function renderPageFlow() { el.svgHost.replaceChildren(); + state.pageWindow = null; + state.visiblePages.clear(); el.viewerPanel.classList.remove("single-page-fits-height"); if (!state.doc) { return; @@ -4105,31 +4270,10 @@ function renderPageFlow() { if (state.pageObserver) { state.pageObserver.disconnect(); } - const fragment = document.createDocumentFragment(); - const targets = []; - for (const page of state.doc.pages || []) { - const shell = document.createElement("div"); - shell.className = "page-shell"; - shell.dataset.pageIndex = String(page.index); - - const surface = document.createElement("div"); - surface.className = "page-surface"; - - const placeholder = document.createElement("div"); - placeholder.className = "page-placeholder"; - placeholder.textContent = `第 ${page.index + 1} 页`; - - shell.append(surface, placeholder); - fragment.append(shell); - targets.push(shell); - } - el.svgHost.append(fragment); - layoutPages(); - for (const shell of targets) { - observeFlowPage(shell); - } el.emptyState.hidden = true; el.pageFrame.hidden = false; + state.pageWindow = createPageWindow(el.svgHost, el.viewerPanel, 600, createFlowShell, releaseFlowShell, observeFlowPage); + layoutPages(); } function resetPageFlow(keepCache = false) { @@ -4157,9 +4301,14 @@ function resetPageLoading() { } function observeFlowPage(shell) { + if (!shell) return; const observer = flowPageObserver(); if (observer) { observer.observe(shell); + } else { + const index = Number(shell.dataset.pageIndex); + state.visiblePages.add(index); + renderFlowPage(index, { priority: 4 }); } } @@ -4175,6 +4324,7 @@ function flowPageObserver() { } for (const entry of entries) { const index = Number.parseInt(entry.target.dataset.pageIndex, 10); + if (state.pageWindow && state.pageWindow.nodes.get(index) !== entry.target) continue; if (!entry.isIntersecting) { state.visiblePages.delete(index); unmountPage(index); @@ -4195,6 +4345,7 @@ function flowPageObserver() { function unmountPage(index) { const shell = pageShell(index); + if (!shell) return; const selection = document.getSelection(); const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null; if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index @@ -4219,7 +4370,7 @@ async function renderFlowPage(index, options = {}) { } if (page) { const shell = pageShell(index); - if (!shell?.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) { + if (shell && !shell.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) { mountPageSVG(index, page, openSeq); } updateThumbnail(index, openSeq); @@ -4337,6 +4488,9 @@ async function processPageRenderQueue() { function trimPageCache() { const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]); + for (const view of [state.pageWindow, state.thumbnailWindow]) { + for (const index of view?.nodes.keys() || []) protectedPages.add(index); + } for (const task of state.pageInFlight.values()) { protectedPages.add(task.index); } @@ -4547,9 +4701,10 @@ async function navigateDestination(dest) { point.x = Math.min(point.x, bottom.x); point.y = Math.min(point.y, bottom.y); } + if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]); 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(); + scrollViewerBy(shell.top - viewer.top + point.y * MM_TO_PX * state.scale - pageBlockSpace()); } function createTextLayer(text) { @@ -4834,13 +4989,19 @@ function queueNearbyPages(index, openSeq = state.openSeq) { } function pageShell(index) { - return el.svgHost.children.item(index); + return state.pageWindow?.nodes.get(index) || null; } function scrollToPage(index) { + if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]); const shell = pageShell(index); if (shell) { - if (state.fitMode === "height" && !state.continuous) { + if (state.pageWindow) { + const shellRect = shell.getBoundingClientRect(), viewerRect = el.viewerPanel.getBoundingClientRect(); + const space = state.fitMode === "height" && !state.continuous + ? (el.viewerPanel.clientHeight - shellRect.height) / 2 : pageBlockSpace(); + scrollViewerBy(shellRect.top - viewerRect.top - space); + } else if (state.fitMode === "height" && !state.continuous) { shell.scrollIntoView({ block: "center", inline: "nearest" }); } else { const viewerRect = el.viewerPanel.getBoundingClientRect(); @@ -4870,6 +5031,7 @@ function schedulePageSync() { } function syncCurrentPageFromScroll() { + syncPageWindow(state.pageWindow); const shell = pageShellFromView(); if (!shell) { return; @@ -4879,6 +5041,17 @@ function syncCurrentPageFromScroll() { setCurrentPage(nextIndex); queueNearbyPages(nextIndex); } + prunePageRenderQueue(); +} + +function prunePageRenderQueue() { + state.pageRenderQueue = state.pageRenderQueue.filter(task => { + if (task.priority < 2 || Math.abs(task.index - state.pageIndex) <= 2 + || state.pageWindow?.nodes.has(task.index) || state.thumbnailWindow?.nodes.has(task.index)) return true; + state.pageInFlight.delete(task.key); + task.resolve(null); + return false; + }); } function pageShellFromView() { @@ -4917,6 +5090,11 @@ function setCurrentPage(index) { } function updatePageListCurrent(force = false) { + if (state.thumbnailWindow) { + if (!force && state.pageListCurrent === state.pageIndex) return; + state.pageListCurrent = state.pageIndex; + if (state.showPages && !el.pageList.hidden) revealThumbnail(state.pageIndex); + } const current = el.pageList.querySelector(".page-list-item[aria-current]"); if (current) { if (!force && Number.parseInt(current.dataset.pageIndex, 10) === state.pageIndex) { @@ -4927,7 +5105,7 @@ function updatePageListCurrent(force = false) { const next = el.pageList.querySelector(`.page-list-item[data-page-index="${state.pageIndex}"]`); if (next) { setPageItemCurrent(next, true); - if (state.showPages && !el.pageList.hidden) { + if (state.showPages && !el.pageList.hidden && !state.thumbnailWindow) { const panel = el.navigationContent.getBoundingClientRect(); const item = next.getBoundingClientRect(); if (item.top < panel.top) { @@ -4952,12 +5130,25 @@ function layoutPages() { return; } canvasEditor.cancel(); - for (const page of state.doc.pages || []) { - const shell = pageShell(page.index); - if (shell) { - layoutPageShell(shell, page); - } + const view = state.pageWindow; + if (!view) return; + const anchor = view.offsets.length > 1 ? pageWindowAnchor(view) : { index: state.pageIndex, offset: 0 }; + const gap = Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0; + const offsets = new Float64Array(state.doc.pages.length + 1); + const fittedWidth = state.scale * pageViewSize(currentPageInfo()).width; + let width = 1; + for (let index = 0; index < state.doc.pages.length; index++) { + const size = pageViewSize(state.doc.pages[index]); + const scale = state.fitMode === "width" ? fittedWidth / size.width : state.scale; + offsets[index + 1] = offsets[index] + Math.max(1, size.height * MM_TO_PX) * scale + gap; + width = Math.max(width, Math.max(1, size.width * MM_TO_PX) * scale); } + view.offsets = offsets; + view.gap = gap; + view.total = Math.max(0, offsets.at(-1) - gap); + view.host.style.width = `${width}px`; + for (const [index, shell] of view.nodes) layoutPageShell(shell, state.doc.pages[index]); + syncPageWindow(view, offsets[anchor.index] + anchor.offset); } function layoutPageShell(shell, page) { @@ -5057,6 +5248,7 @@ function showNavigation(selected) { } el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab; el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0; + if (selected === el.pagesTab) syncThumbnailWindow(); } function renderOutlines(reset = true) { @@ -5212,7 +5404,7 @@ async function selectSearchMatch(index) { if (mark) { const box = mark.getBoundingClientRect(); const viewer = el.viewerPanel.getBoundingClientRect(); - el.viewerPanel.scrollTop += box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2; + scrollViewerBy(box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2); if (box.left < viewer.left) { el.viewerPanel.scrollLeft += box.left - viewer.left; } else if (box.right > viewer.left + el.viewerPanel.clientWidth) { @@ -5456,6 +5648,18 @@ el.pageList.addEventListener("contextmenu", event => { }); el.pageList.addEventListener("keydown", event => { + const button = event.target?.closest(".page-list-item"); + if (state.thumbnailWindow && button && !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing) { + const index = Number(button.dataset.pageIndex); + const next = { ArrowUp: index - 1, ArrowDown: index + 1, Home: 0, End: state.doc.pageCount - 1, + Tab: index + (event.shiftKey ? -1 : 1) }[event.key]; + if (next >= 0 && next < state.doc.pageCount) { + event.preventDefault(); + revealThumbnail(next); + state.thumbnailWindow.nodes.get(next)?.focus({ preventScroll: true }); + return; + } + } 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)); @@ -5472,6 +5676,9 @@ el.pageList.addEventListener("keydown", event => { function renderPageList() { el.pageList.replaceChildren(); + state.thumbnailWindow = null; + state.pageListCurrent = null; + state.visibleThumbnails.clear(); if (!state.doc) { return; } @@ -5480,41 +5687,98 @@ function renderPageList() { } 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; - for (const page of state.doc.pages || []) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "page-list-item"; - button.dataset.pageIndex = String(page.index); - button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`; - setPageItemCurrent(button, page.index === state.pageIndex); - layoutThumbnail(button, page); - - const thumb = document.createElement("span"); - thumb.className = "thumb-paper"; - thumb.setAttribute("aria-hidden", "true"); - setThumbnailContent(thumb, state.pageCache.get(page.index)?.svg, page.index, openSeq); - - const label = document.createElement("span"); - label.className = "thumb-label"; - label.textContent = `第 ${page.index + 1} 页`; - - const size = document.createElement("span"); - size.className = "thumb-size"; - size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`; - - button.append(thumb, label, size); - if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index); - button.addEventListener("click", event => selectThumbnailPage(event, page.index)); - fragment.append(button); - thumbnailTargets.push([button, page.index]); - } - el.pageList.append(fragment); + state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail); + layoutThumbnails(); syncPageSelection(); - for (const [button, index] of thumbnailTargets) { - observeThumbnail(button, index, openSeq); +} + +function createThumbnail(page) { + const openSeq = state.openSeq; + const button = document.createElement("button"); + button.type = "button"; + button.className = "page-list-item"; + button.dataset.pageIndex = String(page.index); + button.setAttribute("aria-posinset", String(page.index + 1)); + button.setAttribute("aria-setsize", String(state.doc.pageCount)); + if (state.editing) button.setAttribute("aria-pressed", String(state.pageSelection.has(page.id))); + button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`; + setPageItemCurrent(button, page.index === state.pageIndex); + layoutThumbnail(button, page); + + const thumb = document.createElement("span"); + thumb.className = "thumb-paper"; + thumb.setAttribute("aria-hidden", "true"); + setThumbnailContent(thumb, state.pageCache.get(page.index)?.svg, page.index, openSeq); + + const label = document.createElement("span"); + label.className = "thumb-label"; + label.textContent = `第 ${page.index + 1} 页`; + + const size = document.createElement("span"); + size.className = "thumb-size"; + size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`; + + button.append(thumb, label, size); + if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index); + button.addEventListener("click", event => selectThumbnailPage(event, page.index)); + button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`; + return button; +} + +function releaseThumbnail(button, index) { + if (state.pageDragIndex === index || button.contains(document.activeElement)) return false; + state.thumbnailObserver?.unobserve(button); + state.visibleThumbnails.delete(index); + return true; +} + +function layoutThumbnails() { + const view = state.thumbnailWindow; + if (!view) return; + const visible = !el.pageList.hidden && state.showPages; + const anchor = visible && view.offsets.length > 1 ? pageWindowAnchor(view) : view.anchor || { index: state.pageIndex, offset: 0 }; + const width = el.navigationContent.clientWidth || view.width || 180; + view.width = width; + view.rotation = state.rotation; + const offsets = new Float64Array(state.doc.pages.length + 1); + for (let index = 0; index < state.doc.pages.length; index++) { + const size = pageViewSize(state.doc.pages[index]); + const ratio = size.width > 0 && size.height > 0 ? size.height / size.width : 1 / 0.707; + offsets[index + 1] = offsets[index] + Math.max(1, width - 20) * ratio + 70 + 12; + } + view.offsets = offsets; + view.gap = 12; + view.total = Math.max(0, offsets.at(-1) - 12); + for (const [index, button] of view.nodes) { + layoutThumbnail(button, state.doc.pages[index]); + button.style.height = `${offsets[index + 1] - offsets[index] - 12}px`; + } + view.pendingTop = offsets[anchor.index] + anchor.offset; + view.host.style.height = `${Math.min(SCROLL_SEGMENT_SIZE, view.total)}px`; + if (visible) { + syncPageWindow(view, view.pendingTop); + view.pendingTop = null; + } +} + +function syncThumbnailWindow() { + const view = state.thumbnailWindow; + if (!view || el.pageList.hidden || !state.showPages) return; + if (el.navigationContent.clientWidth && (view.width !== el.navigationContent.clientWidth || view.rotation !== state.rotation)) layoutThumbnails(); + syncPageWindow(view, view.pendingTop ?? null); + view.pendingTop = null; + prunePageRenderQueue(); +} + +function revealThumbnail(index) { + syncThumbnailWindow(); + const view = state.thumbnailWindow; + if (!view) return; + const top = pageWindowTop(view); + const start = view.offsets[index], end = view.offsets[index + 1] - view.gap; + if (start < top || end > top + el.navigationContent.clientHeight) { + const target = start < top ? Math.floor(start) : Math.min(start, Math.ceil(end - el.navigationContent.clientHeight)); + syncPageWindow(view, target); } } @@ -5555,6 +5819,7 @@ function enablePageDrag(button, index) { event.stopPropagation(); const seq = state.openSeq, pointer = event.pointerId; const indexes = selectDragPages(index); + state.pageDragIndex = index; let x = event.clientX, y = event.clientY, position = null, marker = null, frame = 0, dragging = false; const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; }; const locate = () => { @@ -5575,7 +5840,11 @@ function enablePageDrag(button, index) { if (dragging) { const rect = el.navigationContent.getBoundingClientRect(); const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0; - if (delta) el.navigationContent.scrollTop += delta; + if (delta) { + if (state.thumbnailWindow) scrollPageWindowBy(state.thumbnailWindow, delta); + else el.navigationContent.scrollTop += delta; + } + syncThumbnailWindow(); locate(); } frame = requestAnimationFrame(tick); @@ -5588,6 +5857,7 @@ function enablePageDrag(button, index) { if (dragging) locate(); }; const finish = async commit => { + state.pageDragIndex = null; cancelAnimationFrame(frame); grip.removeEventListener("pointermove", move); grip.removeEventListener("pointerup", up); @@ -5616,7 +5886,10 @@ function enablePageDrag(button, index) { const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1; if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) { const focus = (direction < 0 ? target : target - indexes.length + 1) + indexes.indexOf(index); - if (await changeSelectedPages("move", direction)) el.pageList.querySelector(`[data-page-index="${focus}"]`)?.focus(); + if (await changeSelectedPages("move", direction)) { + revealThumbnail(focus); + el.pageList.querySelector(`[data-page-index="${focus}"]`)?.focus({ preventScroll: true }); + } } }); } @@ -5658,11 +5931,13 @@ function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) } function observeThumbnail(button, index, openSeq = state.openSeq) { + if (!button) return; const observer = thumbnailObserver(); if (observer) { observer.observe(button); } - if (index === state.pageIndex || !observer && index < 8) { + if (index === state.pageIndex || !observer) { + if (!observer) state.visibleThumbnails.add(index); renderThumbnail(index, openSeq); } } @@ -5679,6 +5954,7 @@ function thumbnailObserver() { } for (const entry of entries) { const index = Number.parseInt(entry.target.dataset.pageIndex, 10); + if (state.thumbnailWindow && state.thumbnailWindow.nodes.get(index) !== entry.target) continue; if (!entry.isIntersecting) { state.visibleThumbnails.delete(index); const thumb = entry.target.querySelector(".thumb-paper"); @@ -6014,8 +6290,8 @@ function signatureReferenceStatus(signature) { } function formatDocumentTime(value) { - const text = String(value || "").trim(); - return text.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " "); + const text = String(value || "").trim().replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " "); + return /^0001[-/]0?1[-/]0?1(?: 0{1,2}:00:00(?:\.0+)?(?:Z|[+-]00:?00)?)?$/.test(text) ? "" : text; } function appendInfoLine(row, label, value, status = "") { @@ -6079,7 +6355,13 @@ function highlightPageRegion(region) { mark.style.width = `${region.width * MM_TO_PX}px`; mark.style.height = `${region.height * MM_TO_PX}px`; shell.querySelector(".page-surface").append(mark); - mark.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (state.pageWindow) { + const box = mark.getBoundingClientRect(), viewer = el.viewerPanel.getBoundingClientRect(); + if (box.top < viewer.top) scrollViewerBy(box.top - viewer.top); + else if (box.bottom > viewer.top + el.viewerPanel.clientHeight) scrollViewerBy(Math.min(box.top - viewer.top, box.bottom - viewer.top - el.viewerPanel.clientHeight)); + if (box.left < viewer.left) el.viewerPanel.scrollLeft += box.left - viewer.left; + else if (box.right > viewer.left + el.viewerPanel.clientWidth) el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth; + } else mark.scrollIntoView({ block: "nearest", inline: "nearest" }); window.setTimeout(() => mark.remove(), 1800); } @@ -6249,6 +6531,10 @@ function setContinuous(continuous) { function toggleContinuous() { const anchor = state.fitMode === "free" ? scaleAnchor() : null; setContinuous(!state.continuous); + if (state.pageWindow) { + updateFitSpace(); + layoutPages(); + } clearRegionHighlights(); if (state.fitMode === "free") { restoreScaleAnchor(anchor); @@ -6266,9 +6552,7 @@ function rotatePages() { clearRegionHighlights(); el.viewerPanel.classList.remove("single-page-fits-height"); layoutPages(); - for (const page of state.doc.pages) { - layoutThumbnail(el.pageList.children.item(page.index), page); - } + layoutThumbnails(); if (state.fitMode === "free") { updateFitSpace(); } else { @@ -6305,7 +6589,8 @@ function startPan(event) { return; } event.preventDefault(); - state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft, y: event.clientY + el.viewerPanel.scrollTop }; + state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft, + y: event.clientY + (state.pageWindow ? pageWindowTop(state.pageWindow) : el.viewerPanel.scrollTop) }; el.viewerPanel.setPointerCapture(event.pointerId); el.viewerPanel.classList.add("panning"); canvasEditor.focus(); @@ -6317,7 +6602,8 @@ function movePan(event) { return; } el.viewerPanel.scrollLeft = pan.x - event.clientX; - el.viewerPanel.scrollTop = pan.y - event.clientY; + if (state.pageWindow) syncPageWindow(state.pageWindow, pan.y - event.clientY); + else el.viewerPanel.scrollTop = pan.y - event.clientY; } function endPan(event) { @@ -6349,13 +6635,17 @@ function fitWidthScale(page) { const width = Math.max(1, page.width * MM_TO_PX); let available = Math.max(1, el.viewerPanel.clientWidth - space * 2); if (!viewerHasVerticalScrollbar()) { - const height = state.continuous - ? state.doc.pages.reduce((total, item) => { + const limit = Math.max(1, el.viewerPanel.clientHeight - space * 2); + let height = Math.max(1, page.height * MM_TO_PX) * (available / width); + if (state.continuous) { + height = 0; + for (const item of state.doc.pages) { const size = pageViewSize(item); - return total + available * size.height / size.width; - }, 0) - : Math.max(1, page.height * MM_TO_PX) * (available / width); - if (height > Math.max(1, el.viewerPanel.clientHeight - space * 2)) { + height += available * size.height / size.width; + if (height > limit) break; + } + } + if (height > limit) { available = Math.max(1, available - scrollbarWidth()); } } @@ -6423,6 +6713,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") { layoutPages(); } updateFitSpace(); + if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages(); if (layoutChanged) { restoreScaleAnchor(anchor); } @@ -6456,6 +6747,11 @@ function restoreScaleAnchor(anchor) { scrollToPage(anchor.index); return; } + if (state.pageWindow) { + const view = state.pageWindow; + const height = view.offsets[anchor.index + 1] - view.offsets[anchor.index] - view.gap; + syncPageWindow(view, view.offsets[anchor.index] + height * anchor.y - el.viewerPanel.clientHeight * anchor.viewY); + } const shell = pageShell(anchor.index); if (!shell) { return; @@ -6463,7 +6759,7 @@ function restoreScaleAnchor(anchor) { const viewerRect = el.viewerPanel.getBoundingClientRect(); const shellRect = shell.getBoundingClientRect(); el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2; - el.viewerPanel.scrollTop += shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY; + scrollViewerBy(shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY); } function currentPageInfo() { @@ -6525,6 +6821,7 @@ function updateControls() { el.pageInput.disabled = !hasDoc; el.pageInput.max = String(pageCount || 1); el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0); + el.pageControl.style.setProperty("--page-width", `${String(pageCount || 1).length + 0.5}ch`); el.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2; el.zoomInButton.disabled = !hasDoc || state.scale >= 4; el.fitButton.disabled = !hasDoc; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 3d9d0e5..77c56c8 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -365,14 +365,14 @@ func configureDocument(args []js.Value) (any, error) { return currentSession.Summary(), nil } -// documentInfo 获取字体统计和验签结果,首个参数为true时逐页扫描 +// documentInfo 获取字体统计和验签结果,首个参数为true时分批扫描 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 func documentInfo(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } - if len(args) > 0 && args[0].Bool() && !currentSession.scanFontInfo() { + if len(args) > 0 && args[0].Bool() && !currentSession.scanFontInfoBatch() { return map[string]bool{"detailsPending": true}, nil } return currentSession.Info(), nil diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 71e1837..2aa4d19 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -151,7 +151,7 @@ type Session struct { fontFS *ofdgo.FontFS doc *ofdgo.Document pageCache map[int]*ofdgo.PageContent - boxCache map[int]ofdgo.Box + boxCache []pageBoxInfo textCache map[int]*ofdgo.PageText svgFonts map[string][]byte fontScan *ofdgo.FontInfoScanner @@ -164,6 +164,12 @@ type Session struct { editing bool } +// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域 +type pageBoxInfo struct { + box ofdgo.Box + valid bool +} + // DocumentInfo 文档信息 type DocumentInfo struct { Encryption EncryptionInfo `json:"encryption"` @@ -318,7 +324,7 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) { fontFS: fontFS, doc: doc, pageCache: make(map[int]*ofdgo.PageContent), - boxCache: make(map[int]ofdgo.Box), + boxCache: make([]pageBoxInfo, len(doc.Pages.Page)), textCache: make(map[int]*ofdgo.PageText), svgFonts: make(map[string][]byte), }, nil @@ -388,6 +394,25 @@ func (s *Session) scanFontInfo() bool { return true } +// scanFontInfoBatch 为大文档批量统计字体,限制每次处理的页数与耗时 +// 返回: bool 是否完成 +func (s *Session) scanFontInfoBatch() bool { + limit := 1 + if len(s.doc.Pages.Page) > 256 { + limit = 64 + } + start := time.Now() + for range limit { + if s.scanFontInfo() { + return true + } + if time.Since(start) >= 8*time.Millisecond { + break + } + } + return false +} + // PageText 获取指定页面文字,复用当前会话的文字索引 // 入参: index 页面索引 // 返回: *ofdgo.PageText 页面文字, error 错误信息 @@ -448,7 +473,7 @@ func (s *Session) Summary() DocumentInfo { Version: s.Reader.Version(), DocType: s.Reader.DocType(), PageCount: len(s.doc.Pages.Page), - Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), + Pages: s.pageInfos(), Outlines: s.doc.OutlineInfos(), DetailsPending: true, } @@ -459,15 +484,6 @@ func (s *Session) Summary() DocumentInfo { info.CreationDate = docInfo.CreationDate info.ModDate = docInfo.ModDate } - for index, page := range s.doc.Pages.Page { - box, ok := s.boxCache[index] - if !ok { - if area, err := s.Reader.PageArea(page); err == nil { - box, _ = s.pageBox(index, &ofdgo.PageContent{Area: area}) - } - } - info.Pages = append(info.Pages, PageInfo{Index: index, ID: page.ID, Width: box.W, Height: box.H}) - } if fonts, err := s.Reader.Fonts(); err == nil { for _, font := range fonts { info.Fonts = append(info.Fonts, FontInfo{ @@ -485,6 +501,23 @@ func (s *Session) Summary() DocumentInfo { return info } +// pageInfos 获取全部真实页面尺寸,首屏、配置及详情共用区域缓存 +// 返回: []PageInfo 独立页面信息列表 +func (s *Session) pageInfos() []PageInfo { + pages := make([]PageInfo, len(s.doc.Pages.Page)) + for index, page := range s.doc.Pages.Page { + cached := s.boxCache[index] + box := cached.box + if !cached.valid { + if area, err := s.Reader.PageArea(page); err == nil { + box, _ = s.pageBox(index, &ofdgo.PageContent{Area: area}) + } + } + pages[index] = PageInfo{Index: index, ID: page.ID, Width: box.W, Height: box.H} + } + return pages +} + // Info 获取完整文档信息 // 返回: DocumentInfo 文档信息 func (s *Session) Info() DocumentInfo { @@ -493,7 +526,7 @@ func (s *Session) Info() DocumentInfo { Version: s.Reader.Version(), DocType: s.Reader.DocType(), PageCount: len(s.doc.Pages.Page), - Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), + Pages: s.pageInfos(), Outlines: s.doc.OutlineInfos(), } if attachments, err := s.Reader.Attachments(); err == nil { @@ -524,16 +557,6 @@ func (s *Session) Info() DocumentInfo { info.CreationDate = docInfo.CreationDate info.ModDate = docInfo.ModDate } - for index, pageRef := range s.doc.Pages.Page { - pageInfo := PageInfo{Index: index, ID: pageRef.ID} - if area, err := s.Reader.PageArea(pageRef); err == nil { - if box, err := s.pageBox(index, &ofdgo.PageContent{Area: area}); err == nil { - pageInfo.Width = box.W - pageInfo.Height = box.H - } - } - info.Pages = append(info.Pages, pageInfo) - } for !s.scanFontInfo() { } info.Fonts = s.fontInfos @@ -891,13 +914,13 @@ func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) { // 入参: index 页面索引, page 页面内容 // 返回: ofdgo.Box 页面物理区域, error 错误信息 func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) { - if box, ok := s.boxCache[index]; ok { - return box, nil + if cached := s.boxCache[index]; cached.valid { + return cached.box, nil } box, err := s.Renderer.GetPageBox(page) if err != nil { return ofdgo.Box{}, err } - s.boxCache[index] = box + s.boxCache[index] = pageBoxInfo{box: box, valid: true} return box, nil } diff --git a/ofdgo_editor_annot_content.go b/ofdgo_editor_annot_content.go index 61212f3..fe9b432 100644 --- a/ofdgo_editor_annot_content.go +++ b/ofdgo_editor_annot_content.go @@ -313,6 +313,9 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error { // annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用 // 返回: []string 文件路径 func (e *Editor) annotationFiles() []string { + if len(e.source.reader.annotationFiles) == 0 { + return nil + } removed := make(map[string]bool) for id := range e.source.pages { removed[id] = true diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index 2decd23..7be5bcf 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -24,7 +24,8 @@ import ( // 入参: edit 批量修改回调 // 返回: error 修改错误 func (e *Editor) Transaction(edit func(*Editor) error) error { - before := e.transactionSnapshot() + before := *e + before.Info = cloneEditorData(e.Info) next := e.transactionSnapshot() next.history, next.historyIndex, next.historyLimit = nil, 0, 0 *e = next @@ -32,10 +33,10 @@ func (e *Editor) Transaction(edit func(*Editor) error) error { *e = before return err } - next = e.transactionSnapshot() - *e = before - changed := next.revision != before.revision - e.restoreTransaction(next) + changed := e.revision != before.revision + e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit + e.backends, e.fontFS = before.backends, before.fontFS + e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics if changed { e.serial, e.revision = before.serial, before.revision if change := e.recordChange(); change != nil { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 88fbdac..0245c59 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -21,8 +21,10 @@ import ( "fmt" "image" "io" + "maps" "path" "reflect" + "slices" "strconv" "strings" ) @@ -117,14 +119,12 @@ type PageCapabilities struct { // 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader // 返回: *Editor 编辑器, error 错误信息 func (r *Reader) Editor() (*Editor, error) { - reader := &Reader{Zip: r.Zip, files: r.files, encryption: r.encryption} - if err := reader.initRoot(); err != nil { - return nil, err - } - doc, err := reader.Doc() + _, err := r.Doc() if err != nil { return nil, err } + reader := cloneEditorReader(r) + doc := reader.doc maximum := doc.CommonData.MaxUnitID if maximum < 0 { return nil, fmt.Errorf("invalid MaxUnitID") @@ -137,17 +137,60 @@ func (r *Reader) Editor() (*Editor, error) { e.encryption = r.encryption e.Info, e.maxID = cloneEditorData(*info), maximum directory := cleanPackagePath(reader.RootDir) - e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage), origins: make(map[string]*editorObjectOrigin)} - for _, page := range doc.Pages.Page { + e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage, len(doc.Pages.Page)), origins: make(map[string]*editorObjectOrigin)} + e.pages = make([]PageContent, len(doc.Pages.Page)) + sourcePages := make([]editorSourcePage, len(doc.Pages.Page)) + for i, page := range doc.Pages.Page { if page.ID == "" || e.source.pages[page.ID] != nil { return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID) } - e.source.pages[page.ID] = &editorSourcePage{ref: page} - e.pages = append(e.pages, PageContent{ID: page.ID}) + sourcePages[i].ref = page + e.source.pages[page.ID] = &sourcePages[i] + e.pages[i] = PageContent{ID: page.ID} } return e, nil } +// cloneEditorReader 复用已验证的只读包索引,独立保存可变文档与读取缓存 +// 入参: reader 已加载主文档的阅读器 +// 返回: *Reader 不拥有输入文件的独立阅读器 +func cloneEditorReader(reader *Reader) *Reader { + next := *reader + next.Closer = nil + next.OFD = cloneEditorData(reader.OFD) + doc := *reader.doc + pages := doc.Pages.Page + doc.Pages.Page = nil + doc = cloneEditorData(doc) + doc.Pages.Page = slices.Clone(pages) + next.doc = &doc + next.files = maps.Clone(reader.files) + next.ResMap = maps.Clone(reader.ResMap) + next.resourcesRead = maps.Clone(reader.resourcesRead) + next.resourceFiles = maps.Clone(reader.resourceFiles) + next.fontCache = cloneEditorMap(reader.fontCache) + next.fontFaces = maps.Clone(reader.fontFaces) + next.colorSpaceCache = cloneEditorMap(reader.colorSpaceCache) + next.drawParamCache = cloneEditorMap(reader.drawParamCache) + next.compositeGraphicUnitCache = cloneEditorMap(reader.compositeGraphicUnitCache) + next.pageHeaderCache = maps.Clone(reader.pageHeaderCache) + next.Stamps = cloneEditorMap(reader.Stamps) + next.Annots = cloneEditorMap(reader.Annots) + next.annotationFiles = cloneEditorMap(reader.annotationFiles) + return &next +} + +// cloneEditorMap 复制对外可变缓存值,避免快照之间共享嵌套切片或指针 +// 入参: values 原缓存 +// 返回: map[string]T 独立缓存 +func cloneEditorMap[T any](values map[string]T) map[string]T { + result := maps.Clone(values) + for key, value := range result { + result[key] = cloneEditorData(value) + } + return result +} + // prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID // 返回: error 错误信息 func (e *Editor) prepareSourceIDs() error { @@ -366,6 +409,8 @@ func (e *Editor) loadSourcePage(index int) error { if source == nil || source.original != nil { return nil } + loaded := *source + source = &loaded reader := e.source.reader data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc)) if err != nil { @@ -426,6 +471,7 @@ func (e *Editor) loadSourcePage(index int) error { } } source.original = page + e.source.pages[source.ref.ID] = source e.pages[index] = copyEditorPage(*page) return nil } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 62282e6..ab746d0 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -35,12 +35,14 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) source := e.source reader := source.reader pageRefs := make([]Page, len(e.pages)) + originalCount := 0 for i, page := range e.pages { if err := progress.report("pages", i, len(e.pages)); err != nil { return nil, err } original := source.pages[page.ID] if original != nil { + originalCount++ pageRefs[i] = original.ref if original.original == nil { continue @@ -95,8 +97,8 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) } } } - pagesChanged := !reflect.DeepEqual(pageRefs, source.document.Pages.Page) - if len(parts) != 0 || pagesChanged { + pagesChanged := !slices.Equal(pageRefs, source.document.Pages.Page) + if pagesChanged || len(resourceFiles) != 0 || len(parts) != 0 && e.maxID != source.document.CommonData.MaxUnitID { name := reader.ResPath(reader.OFD.DocBody[0].DocRoot) data, err := reader.readFile(name) if err != nil { @@ -138,15 +140,18 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) if pages == nil { return nil, fmt.Errorf("document has no Pages") } - var entries [][]byte - for _, page := range pageRefs { - var original *editorXML - for _, child := range pages.children { - if packageOFDNode(child, "Page") && child.attr("ID") == page.ID { - original = child - break + originals := make(map[string]*editorXML, len(pages.children)) + for _, child := range pages.children { + if packageOFDNode(child, "Page") { + id := child.attr("ID") + if originals[id] == nil { + originals[id] = child } } + } + entries := make([][]byte, 0, len(pageRefs)) + for _, page := range pageRefs { + original := originals[page.ID] if original != nil { entries = append(entries, data[original.start:original.end]) } else { @@ -209,8 +214,10 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error) } parts[name] = updated } - if err := e.prunePageReferences(parts); err != nil { - return nil, err + if originalCount != len(source.pages) || len(source.annotationPages) != 0 { + if err := e.prunePageReferences(parts); err != nil { + return nil, err + } } for name, data := range maps.Clone(parts) { if file, ok := reader.packageFile(name); ok { @@ -481,6 +488,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress archive := zip.NewWriter(output) if reader.Zip != nil { _ = archive.SetComment(reader.Zip.Comment) + buffer := make([]byte, 32*1024) for _, file := range reader.Zip.File { if file.FileInfo().IsDir() { header := file.FileHeader @@ -503,8 +511,19 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress return output.count, err } delete(remaining, name) - } else if err := archive.Copy(file); err != nil { - return output.count, err + } else { + input, err := file.OpenRaw() + if err != nil { + return output.count, err + } + header := file.FileHeader + entry, err := archive.CreateRaw(&header) + if err != nil { + return output.count, err + } + if _, err := io.CopyBuffer(entry, input, buffer); err != nil { + return output.count, err + } } } } @@ -533,6 +552,33 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) { if err != nil { return nil, err } + otherParts := maps.Clone(parts) + if len(parts) != 0 { + for _, page := range e.source.pages { + if page.original != nil { + name := e.source.reader.ResPath(page.ref.BaseLoc) + if file, ok := e.source.reader.packageFile(name); ok { + name = cleanPackagePath(file.Name) + } + delete(otherParts, name) + } + } + } + if len(otherParts) == 0 { + reader := cloneEditorReader(e.source.reader) + if reader.files == nil { + reader.files = make(map[string][]byte) + } + maps.Copy(reader.files, parts) + reader.encryption = e.encryption + for name := range reader.pageHeaderCache { + actual := reader.fileNamesFold[strings.ToLower(name)] + if _, changed := parts[actual]; changed { + delete(reader.pageHeaderCache, name) + } + } + return reader, nil + } files := maps.Clone(e.source.reader.files) if files == nil { files = make(map[string][]byte) @@ -546,7 +592,8 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) { return nil, err } for name, header := range e.source.reader.pageHeaderCache { - if _, changed := parts[name]; !changed { + actual := e.source.reader.fileNamesFold[strings.ToLower(name)] + if _, changed := parts[actual]; !changed { reader.pageHeaderCache[name] = header } } diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 054531e..690f6f5 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -92,9 +92,13 @@ func (r *Reader) initRoot() error { // indexPackage 建立包路径索引,拒绝会使显示与验签产生歧义的同名条目 // 返回: error 错误信息 func (r *Reader) indexPackage() error { - r.fileIndex = make(map[string]*zip.File) - r.fileIndexFold = make(map[string]*zip.File) - r.fileNamesFold = make(map[string]string) + count := 0 + if r.Zip != nil { + count = len(r.Zip.File) + } + r.fileIndex = make(map[string]*zip.File, count) + r.fileIndexFold = make(map[string]*zip.File, count) + r.fileNamesFold = make(map[string]string, count+len(r.files)) if r.Zip != nil { for _, f := range r.Zip.File { if f.FileInfo().IsDir() {