mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
This commit is contained in:
1 parent
e3908c1149
commit
46e6d5b622
9 files changed
+582
-123
No files matched your search
+40
-2
@@ -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;
|
||||
}
|
||||
|
||||
+363
-66
@@ -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,14 +5687,20 @@ 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 = [];
|
||||
state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail);
|
||||
layoutThumbnails();
|
||||
syncPageSelection();
|
||||
}
|
||||
|
||||
function createThumbnail(page) {
|
||||
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.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);
|
||||
@@ -5508,13 +5721,64 @@ function renderPageList() {
|
||||
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]);
|
||||
button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`;
|
||||
return button;
|
||||
}
|
||||
el.pageList.append(fragment);
|
||||
syncPageSelection();
|
||||
for (const [button, index] of thumbnailTargets) {
|
||||
observeThumbnail(button, index, openSeq);
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+49
-26
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+55
-9
@@ -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
|
||||
}
|
||||
|
||||
+57
-10
@@ -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
|
||||
originals := make(map[string]*editorXML, len(pages.children))
|
||||
for _, child := range pages.children {
|
||||
if packageOFDNode(child, "Page") && child.attr("ID") == page.ID {
|
||||
original = child
|
||||
break
|
||||
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,9 +214,11 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
|
||||
}
|
||||
parts[name] = updated
|
||||
}
|
||||
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 {
|
||||
actual := cleanPackagePath(file.Name)
|
||||
@@ -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,9 +511,20 @@ 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 {
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(remaining)) {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user