feat(阅读编辑): 优化大文件阅读编辑、页码显示,修复异常日期
build.yaml / build (push) Successful in 41s

This commit is contained in:
xiaoqidun committed 2026-09-24 00:27:02 +08:00
1 parent e3908c1149
commit 46e6d5b622
9 files changed
+582 -123

No files matched your search

+40 -2
View File
@@ -568,7 +568,7 @@ button:not(:disabled):hover {
.page-control { .page-control {
display: inline-grid; 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; align-items: center;
gap: 6px; gap: 6px;
height: var(--control-height); height: var(--control-height);
@@ -1303,6 +1303,39 @@ button.outline-link:hover {
gap: 12px; 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 { .page-list-item {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -1398,6 +1431,7 @@ button.outline-link:hover {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
overflow-anchor: none;
background: background:
linear-gradient(90deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(31, 36, 40, 0.05) 1px, transparent 1px),
linear-gradient(0deg, 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; margin: 0 auto;
} }
.svg-host.virtual-pages {
display: block;
}
.page-shell { .page-shell {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -2613,7 +2651,7 @@ button.outline-link:hover {
} }
.page-control { .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; gap: 4px;
padding: 0 6px; padding: 0 6px;
} }
+363 -66
View File
@@ -6,6 +6,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)");
const DEFAULT_IMAGE_DPI = 300; const DEFAULT_IMAGE_DPI = 300;
const PAGE_CACHE_LIMIT = 16; const PAGE_CACHE_LIMIT = 16;
const PAGE_CACHE_BYTES = 32 * 1024 * 1024; const PAGE_CACHE_BYTES = 32 * 1024 * 1024;
const SCROLL_SEGMENT_SIZE = 1000000;
const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects"; const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects";
const STATUS = { const STATUS = {
ready: "选择 OFD 文件", ready: "选择 OFD 文件",
@@ -100,6 +101,11 @@ const state = {
pageObserver: null, pageObserver: null,
visiblePages: new Set(), visiblePages: new Set(),
scrollFrame: 0, scrollFrame: 0,
pageWindow: null,
thumbnailWindow: null,
thumbnailFrame: 0,
pageDragIndex: null,
pageListCurrent: null,
thumbnailInFlight: new Set(), thumbnailInFlight: new Set(),
thumbnailObserver: null, thumbnailObserver: null,
visibleThumbnails: new Set(), visibleThumbnails: new Set(),
@@ -334,6 +340,7 @@ const el = {
localFontButton: document.querySelector("#localFontButton"), localFontButton: document.querySelector("#localFontButton"),
prevButton: document.querySelector("#prevButton"), prevButton: document.querySelector("#prevButton"),
nextButton: document.querySelector("#nextButton"), nextButton: document.querySelector("#nextButton"),
pageControl: document.querySelector(".page-control"),
pageInput: document.querySelector("#pageInput"), pageInput: document.querySelector("#pageInput"),
pageTotal: document.querySelector("#pageTotal"), pageTotal: document.querySelector("#pageTotal"),
zoomOutButton: document.querySelector("#zoomOutButton"), zoomOutButton: document.querySelector("#zoomOutButton"),
@@ -1172,6 +1179,28 @@ COMPACT_LAYOUT.addEventListener("change", syncLayoutMode);
el.viewerPanel.addEventListener("scroll", () => { el.viewerPanel.addEventListener("scroll", () => {
schedulePageSync(); 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("pointerdown", startPan);
el.viewerPanel.addEventListener("pointermove", movePan); el.viewerPanel.addEventListener("pointermove", movePan);
el.viewerPanel.addEventListener("pointerup", endPan); el.viewerPanel.addEventListener("pointerup", endPan);
@@ -1229,6 +1258,24 @@ function handleKeyDown(event) {
if (document.body.hasAttribute("aria-busy") || formDialogOpen()) { if (document.body.hasAttribute("aria-busy") || formDialogOpen()) {
return; 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 (event.ctrlKey || event.metaKey) {
if (state.editing && !target.closest("input, textarea, select, [contenteditable]") if (state.editing && !target.closest("input, textarea, select, [contenteditable]")
&& (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) { && (key.toLowerCase() === "z" || (!event.shiftKey && key.toLowerCase() === "y"))) {
@@ -2519,11 +2566,8 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
renderMeta(true); renderMeta(true);
updateControls(); updateControls();
loadDocumentDetails(openSeq); loadDocumentDetails(openSeq);
for (const page of doc.pages) { for (const shell of el.svgHost.children) observeFlowPage(shell);
observeFlowPage(pageShell(page.index)); for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq);
const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`);
observeThumbnail(button, page.index, openSeq);
}
} }
} }
} }
@@ -2627,6 +2671,7 @@ function syncLayoutMode(event) {
function resizeViewer() { function resizeViewer() {
if (state.doc) { if (state.doc) {
syncThumbnailWindow();
applyFit(false); applyFit(false);
if (state.fitMode !== "free") { if (state.fitMode !== "free") {
scrollToPage(state.pageIndex); scrollToPage(state.pageIndex);
@@ -3551,6 +3596,7 @@ async function openDocument(options = {}) {
return; return;
} }
const openSeq = options.openSeq || (state.openSeq += 1); 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) : []; const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : [];
if (!state.ready || state.wasmExited) { if (!state.ready || state.wasmExited) {
await ensureWASM(); await ensureWASM();
@@ -3658,6 +3704,8 @@ async function openDocument(options = {}) {
restoreScaleAnchor(options.previewAnchor); restoreScaleAnchor(options.previewAnchor);
} else if (options.previewScroll) { } else if (options.previewScroll) {
Object.assign(el.viewerPanel, options.previewScroll); Object.assign(el.viewerPanel, options.previewScroll);
if (previewPosition && state.pageWindow) syncPageWindow(state.pageWindow,
state.pageWindow.offsets[previewPosition.index] + previewPosition.offset);
} else { } else {
scrollToPage(pageIndex); scrollToPage(pageIndex);
} }
@@ -3742,6 +3790,8 @@ async function renderPage(index, options = {}) {
} }
if (options.scroll !== false) { if (options.scroll !== false) {
scrollToPage(index); 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 }); const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
if (openSeq !== state.openSeq || index !== state.pageIndex) { if (openSeq !== state.openSeq || index !== state.pageIndex) {
@@ -4096,8 +4146,123 @@ function cancelExport() {
wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID }); 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() { function renderPageFlow() {
el.svgHost.replaceChildren(); el.svgHost.replaceChildren();
state.pageWindow = null;
state.visiblePages.clear();
el.viewerPanel.classList.remove("single-page-fits-height"); el.viewerPanel.classList.remove("single-page-fits-height");
if (!state.doc) { if (!state.doc) {
return; return;
@@ -4105,31 +4270,10 @@ function renderPageFlow() {
if (state.pageObserver) { if (state.pageObserver) {
state.pageObserver.disconnect(); 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.emptyState.hidden = true;
el.pageFrame.hidden = false; el.pageFrame.hidden = false;
state.pageWindow = createPageWindow(el.svgHost, el.viewerPanel, 600, createFlowShell, releaseFlowShell, observeFlowPage);
layoutPages();
} }
function resetPageFlow(keepCache = false) { function resetPageFlow(keepCache = false) {
@@ -4157,9 +4301,14 @@ function resetPageLoading() {
} }
function observeFlowPage(shell) { function observeFlowPage(shell) {
if (!shell) return;
const observer = flowPageObserver(); const observer = flowPageObserver();
if (observer) { if (observer) {
observer.observe(shell); 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) { for (const entry of entries) {
const index = Number.parseInt(entry.target.dataset.pageIndex, 10); const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
if (state.pageWindow && state.pageWindow.nodes.get(index) !== entry.target) continue;
if (!entry.isIntersecting) { if (!entry.isIntersecting) {
state.visiblePages.delete(index); state.visiblePages.delete(index);
unmountPage(index); unmountPage(index);
@@ -4195,6 +4345,7 @@ function flowPageObserver() {
function unmountPage(index) { function unmountPage(index) {
const shell = pageShell(index); const shell = pageShell(index);
if (!shell) return;
const selection = document.getSelection(); const selection = document.getSelection();
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null; const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index if (canvasEditor.input?.item.index === index || canvasEditor.crop?.item.index === index
@@ -4219,7 +4370,7 @@ async function renderFlowPage(index, options = {}) {
} }
if (page) { if (page) {
const shell = pageShell(index); 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); mountPageSVG(index, page, openSeq);
} }
updateThumbnail(index, openSeq); updateThumbnail(index, openSeq);
@@ -4337,6 +4488,9 @@ async function processPageRenderQueue() {
function trimPageCache() { function trimPageCache() {
const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]); 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()) { for (const task of state.pageInFlight.values()) {
protectedPages.add(task.index); protectedPages.add(task.index);
} }
@@ -4547,9 +4701,10 @@ async function navigateDestination(dest) {
point.x = Math.min(point.x, bottom.x); point.x = Math.min(point.x, bottom.x);
point.y = Math.min(point.y, bottom.y); 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(); 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.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) { function createTextLayer(text) {
@@ -4834,13 +4989,19 @@ function queueNearbyPages(index, openSeq = state.openSeq) {
} }
function pageShell(index) { function pageShell(index) {
return el.svgHost.children.item(index); return state.pageWindow?.nodes.get(index) || null;
} }
function scrollToPage(index) { function scrollToPage(index) {
if (state.pageWindow) syncPageWindow(state.pageWindow, state.pageWindow.offsets[index]);
const shell = pageShell(index); const shell = pageShell(index);
if (shell) { 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" }); shell.scrollIntoView({ block: "center", inline: "nearest" });
} else { } else {
const viewerRect = el.viewerPanel.getBoundingClientRect(); const viewerRect = el.viewerPanel.getBoundingClientRect();
@@ -4870,6 +5031,7 @@ function schedulePageSync() {
} }
function syncCurrentPageFromScroll() { function syncCurrentPageFromScroll() {
syncPageWindow(state.pageWindow);
const shell = pageShellFromView(); const shell = pageShellFromView();
if (!shell) { if (!shell) {
return; return;
@@ -4879,6 +5041,17 @@ function syncCurrentPageFromScroll() {
setCurrentPage(nextIndex); setCurrentPage(nextIndex);
queueNearbyPages(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() { function pageShellFromView() {
@@ -4917,6 +5090,11 @@ function setCurrentPage(index) {
} }
function updatePageListCurrent(force = false) { 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]"); const current = el.pageList.querySelector(".page-list-item[aria-current]");
if (current) { if (current) {
if (!force && Number.parseInt(current.dataset.pageIndex, 10) === state.pageIndex) { 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}"]`); const next = el.pageList.querySelector(`.page-list-item[data-page-index="${state.pageIndex}"]`);
if (next) { if (next) {
setPageItemCurrent(next, true); setPageItemCurrent(next, true);
if (state.showPages && !el.pageList.hidden) { if (state.showPages && !el.pageList.hidden && !state.thumbnailWindow) {
const panel = el.navigationContent.getBoundingClientRect(); const panel = el.navigationContent.getBoundingClientRect();
const item = next.getBoundingClientRect(); const item = next.getBoundingClientRect();
if (item.top < panel.top) { if (item.top < panel.top) {
@@ -4952,12 +5130,25 @@ function layoutPages() {
return; return;
} }
canvasEditor.cancel(); canvasEditor.cancel();
for (const page of state.doc.pages || []) { const view = state.pageWindow;
const shell = pageShell(page.index); if (!view) return;
if (shell) { const anchor = view.offsets.length > 1 ? pageWindowAnchor(view) : { index: state.pageIndex, offset: 0 };
layoutPageShell(shell, page); 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) { function layoutPageShell(shell, page) {
@@ -5057,6 +5248,7 @@ function showNavigation(selected) {
} }
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab; el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0; el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
if (selected === el.pagesTab) syncThumbnailWindow();
} }
function renderOutlines(reset = true) { function renderOutlines(reset = true) {
@@ -5212,7 +5404,7 @@ async function selectSearchMatch(index) {
if (mark) { if (mark) {
const box = mark.getBoundingClientRect(); const box = mark.getBoundingClientRect();
const viewer = el.viewerPanel.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) { if (box.left < viewer.left) {
el.viewerPanel.scrollLeft += box.left - viewer.left; el.viewerPanel.scrollLeft += box.left - viewer.left;
} else if (box.right > viewer.left + el.viewerPanel.clientWidth) { } else if (box.right > viewer.left + el.viewerPanel.clientWidth) {
@@ -5456,6 +5648,18 @@ el.pageList.addEventListener("contextmenu", event => {
}); });
el.pageList.addEventListener("keydown", 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 (!state.editing || event.isComposing || document.body.hasAttribute("aria-busy")) return;
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") { if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "a") {
state.pageSelection = new Set(state.doc.pages.map(page => page.id)); state.pageSelection = new Set(state.doc.pages.map(page => page.id));
@@ -5472,6 +5676,9 @@ el.pageList.addEventListener("keydown", event => {
function renderPageList() { function renderPageList() {
el.pageList.replaceChildren(); el.pageList.replaceChildren();
state.thumbnailWindow = null;
state.pageListCurrent = null;
state.visibleThumbnails.clear();
if (!state.doc) { if (!state.doc) {
return; return;
} }
@@ -5480,14 +5687,20 @@ function renderPageList() {
} }
const ids = new Set(state.doc.pages.map(page => page.id)); 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); for (const id of state.pageSelection) if (!ids.has(id) || !state.editing) state.pageSelection.delete(id);
const fragment = document.createDocumentFragment(); state.thumbnailWindow = createPageWindow(el.pageList, el.navigationContent, 180, createThumbnail, releaseThumbnail, observeThumbnail);
const thumbnailTargets = []; layoutThumbnails();
syncPageSelection();
}
function createThumbnail(page) {
const openSeq = state.openSeq; const openSeq = state.openSeq;
for (const page of state.doc.pages || []) {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
button.className = "page-list-item"; button.className = "page-list-item";
button.dataset.pageIndex = String(page.index); 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`; button.title = `${formatSize(page.width)} x ${formatSize(page.height)} mm`;
setPageItemCurrent(button, page.index === state.pageIndex); setPageItemCurrent(button, page.index === state.pageIndex);
layoutThumbnail(button, page); layoutThumbnail(button, page);
@@ -5508,13 +5721,64 @@ function renderPageList() {
button.append(thumb, label, size); button.append(thumb, label, size);
if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index); if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index);
button.addEventListener("click", event => selectThumbnailPage(event, page.index)); button.addEventListener("click", event => selectThumbnailPage(event, page.index));
fragment.append(button); button.style.height = `${state.thumbnailWindow.offsets[page.index + 1] - state.thumbnailWindow.offsets[page.index] - 12}px`;
thumbnailTargets.push([button, page.index]); 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;
} }
el.pageList.append(fragment); view.offsets = offsets;
syncPageSelection(); view.gap = 12;
for (const [button, index] of thumbnailTargets) { view.total = Math.max(0, offsets.at(-1) - 12);
observeThumbnail(button, index, openSeq); 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(); event.stopPropagation();
const seq = state.openSeq, pointer = event.pointerId; const seq = state.openSeq, pointer = event.pointerId;
const indexes = selectDragPages(index); const indexes = selectDragPages(index);
state.pageDragIndex = index;
let x = event.clientX, y = event.clientY, position = null, marker = null, frame = 0, dragging = false; 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 clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; };
const locate = () => { const locate = () => {
@@ -5575,7 +5840,11 @@ function enablePageDrag(button, index) {
if (dragging) { if (dragging) {
const rect = el.navigationContent.getBoundingClientRect(); const rect = el.navigationContent.getBoundingClientRect();
const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0; 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(); locate();
} }
frame = requestAnimationFrame(tick); frame = requestAnimationFrame(tick);
@@ -5588,6 +5857,7 @@ function enablePageDrag(button, index) {
if (dragging) locate(); if (dragging) locate();
}; };
const finish = async commit => { const finish = async commit => {
state.pageDragIndex = null;
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
grip.removeEventListener("pointermove", move); grip.removeEventListener("pointermove", move);
grip.removeEventListener("pointerup", up); grip.removeEventListener("pointerup", up);
@@ -5616,7 +5886,10 @@ function enablePageDrag(button, index) {
const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1; const target = direction < 0 ? indexes[0] - 1 : indexes.at(-1) + 1;
if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) { 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); 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) { function observeThumbnail(button, index, openSeq = state.openSeq) {
if (!button) return;
const observer = thumbnailObserver(); const observer = thumbnailObserver();
if (observer) { if (observer) {
observer.observe(button); observer.observe(button);
} }
if (index === state.pageIndex || !observer && index < 8) { if (index === state.pageIndex || !observer) {
if (!observer) state.visibleThumbnails.add(index);
renderThumbnail(index, openSeq); renderThumbnail(index, openSeq);
} }
} }
@@ -5679,6 +5954,7 @@ function thumbnailObserver() {
} }
for (const entry of entries) { for (const entry of entries) {
const index = Number.parseInt(entry.target.dataset.pageIndex, 10); const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
if (state.thumbnailWindow && state.thumbnailWindow.nodes.get(index) !== entry.target) continue;
if (!entry.isIntersecting) { if (!entry.isIntersecting) {
state.visibleThumbnails.delete(index); state.visibleThumbnails.delete(index);
const thumb = entry.target.querySelector(".thumb-paper"); const thumb = entry.target.querySelector(".thumb-paper");
@@ -6014,8 +6290,8 @@ function signatureReferenceStatus(signature) {
} }
function formatDocumentTime(value) { function formatDocumentTime(value) {
const text = String(value || "").trim(); 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 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", " "); return /^0001[-/]0?1[-/]0?1(?: 0{1,2}:00:00(?:\.0+)?(?:Z|[+-]00:?00)?)?$/.test(text) ? "" : text;
} }
function appendInfoLine(row, label, value, status = "") { function appendInfoLine(row, label, value, status = "") {
@@ -6079,7 +6355,13 @@ function highlightPageRegion(region) {
mark.style.width = `${region.width * MM_TO_PX}px`; mark.style.width = `${region.width * MM_TO_PX}px`;
mark.style.height = `${region.height * MM_TO_PX}px`; mark.style.height = `${region.height * MM_TO_PX}px`;
shell.querySelector(".page-surface").append(mark); 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); window.setTimeout(() => mark.remove(), 1800);
} }
@@ -6249,6 +6531,10 @@ function setContinuous(continuous) {
function toggleContinuous() { function toggleContinuous() {
const anchor = state.fitMode === "free" ? scaleAnchor() : null; const anchor = state.fitMode === "free" ? scaleAnchor() : null;
setContinuous(!state.continuous); setContinuous(!state.continuous);
if (state.pageWindow) {
updateFitSpace();
layoutPages();
}
clearRegionHighlights(); clearRegionHighlights();
if (state.fitMode === "free") { if (state.fitMode === "free") {
restoreScaleAnchor(anchor); restoreScaleAnchor(anchor);
@@ -6266,9 +6552,7 @@ function rotatePages() {
clearRegionHighlights(); clearRegionHighlights();
el.viewerPanel.classList.remove("single-page-fits-height"); el.viewerPanel.classList.remove("single-page-fits-height");
layoutPages(); layoutPages();
for (const page of state.doc.pages) { layoutThumbnails();
layoutThumbnail(el.pageList.children.item(page.index), page);
}
if (state.fitMode === "free") { if (state.fitMode === "free") {
updateFitSpace(); updateFitSpace();
} else { } else {
@@ -6305,7 +6589,8 @@ function startPan(event) {
return; return;
} }
event.preventDefault(); 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.setPointerCapture(event.pointerId);
el.viewerPanel.classList.add("panning"); el.viewerPanel.classList.add("panning");
canvasEditor.focus(); canvasEditor.focus();
@@ -6317,7 +6602,8 @@ function movePan(event) {
return; return;
} }
el.viewerPanel.scrollLeft = pan.x - event.clientX; 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) { function endPan(event) {
@@ -6349,13 +6635,17 @@ function fitWidthScale(page) {
const width = Math.max(1, page.width * MM_TO_PX); const width = Math.max(1, page.width * MM_TO_PX);
let available = Math.max(1, el.viewerPanel.clientWidth - space * 2); let available = Math.max(1, el.viewerPanel.clientWidth - space * 2);
if (!viewerHasVerticalScrollbar()) { if (!viewerHasVerticalScrollbar()) {
const height = state.continuous const limit = Math.max(1, el.viewerPanel.clientHeight - space * 2);
? state.doc.pages.reduce((total, item) => { 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); const size = pageViewSize(item);
return total + available * size.height / size.width; height += available * size.height / size.width;
}, 0) if (height > limit) break;
: Math.max(1, page.height * MM_TO_PX) * (available / width); }
if (height > Math.max(1, el.viewerPanel.clientHeight - space * 2)) { }
if (height > limit) {
available = Math.max(1, available - scrollbarWidth()); available = Math.max(1, available - scrollbarWidth());
} }
} }
@@ -6423,6 +6713,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") {
layoutPages(); layoutPages();
} }
updateFitSpace(); updateFitSpace();
if (state.pageWindow && state.pageWindow.gap !== (Number.parseFloat(getComputedStyle(el.svgHost).rowGap) || 0)) layoutPages();
if (layoutChanged) { if (layoutChanged) {
restoreScaleAnchor(anchor); restoreScaleAnchor(anchor);
} }
@@ -6456,6 +6747,11 @@ function restoreScaleAnchor(anchor) {
scrollToPage(anchor.index); scrollToPage(anchor.index);
return; 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); const shell = pageShell(anchor.index);
if (!shell) { if (!shell) {
return; return;
@@ -6463,7 +6759,7 @@ function restoreScaleAnchor(anchor) {
const viewerRect = el.viewerPanel.getBoundingClientRect(); const viewerRect = el.viewerPanel.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect(); const shellRect = shell.getBoundingClientRect();
el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2; 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() { function currentPageInfo() {
@@ -6525,6 +6821,7 @@ function updateControls() {
el.pageInput.disabled = !hasDoc; el.pageInput.disabled = !hasDoc;
el.pageInput.max = String(pageCount || 1); el.pageInput.max = String(pageCount || 1);
el.pageInput.value = String(hasDoc ? state.pageIndex + 1 : 0); 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.zoomOutButton.disabled = !hasDoc || state.scale <= 0.2;
el.zoomInButton.disabled = !hasDoc || state.scale >= 4; el.zoomInButton.disabled = !hasDoc || state.scale >= 4;
el.fitButton.disabled = !hasDoc; el.fitButton.disabled = !hasDoc;
+2 -2
View File
@@ -365,14 +365,14 @@ func configureDocument(args []js.Value) (any, error) {
return currentSession.Summary(), nil return currentSession.Summary(), nil
} }
// documentInfo 获取字体统计和验签结果,首个参数为true时逐页扫描 // documentInfo 获取字体统计和验签结果,首个参数为true时分批扫描
// 入参: args 浏览器参数 // 入参: args 浏览器参数
// 返回: any 文档信息, error 错误信息 // 返回: any 文档信息, error 错误信息
func documentInfo(args []js.Value) (any, error) { func documentInfo(args []js.Value) (any, error) {
if currentSession == nil { if currentSession == nil {
return nil, fmt.Errorf("ofd document is not opened") 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 map[string]bool{"detailsPending": true}, nil
} }
return currentSession.Info(), nil return currentSession.Info(), nil
+49 -26
View File
@@ -151,7 +151,7 @@ type Session struct {
fontFS *ofdgo.FontFS fontFS *ofdgo.FontFS
doc *ofdgo.Document doc *ofdgo.Document
pageCache map[int]*ofdgo.PageContent pageCache map[int]*ofdgo.PageContent
boxCache map[int]ofdgo.Box boxCache []pageBoxInfo
textCache map[int]*ofdgo.PageText textCache map[int]*ofdgo.PageText
svgFonts map[string][]byte svgFonts map[string][]byte
fontScan *ofdgo.FontInfoScanner fontScan *ofdgo.FontInfoScanner
@@ -164,6 +164,12 @@ type Session struct {
editing bool editing bool
} }
// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域
type pageBoxInfo struct {
box ofdgo.Box
valid bool
}
// DocumentInfo 文档信息 // DocumentInfo 文档信息
type DocumentInfo struct { type DocumentInfo struct {
Encryption EncryptionInfo `json:"encryption"` Encryption EncryptionInfo `json:"encryption"`
@@ -318,7 +324,7 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
fontFS: fontFS, fontFS: fontFS,
doc: doc, doc: doc,
pageCache: make(map[int]*ofdgo.PageContent), 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), textCache: make(map[int]*ofdgo.PageText),
svgFonts: make(map[string][]byte), svgFonts: make(map[string][]byte),
}, nil }, nil
@@ -388,6 +394,25 @@ func (s *Session) scanFontInfo() bool {
return true 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 获取指定页面文字,复用当前会话的文字索引 // PageText 获取指定页面文字,复用当前会话的文字索引
// 入参: index 页面索引 // 入参: index 页面索引
// 返回: *ofdgo.PageText 页面文字, error 错误信息 // 返回: *ofdgo.PageText 页面文字, error 错误信息
@@ -448,7 +473,7 @@ func (s *Session) Summary() DocumentInfo {
Version: s.Reader.Version(), Version: s.Reader.Version(),
DocType: s.Reader.DocType(), DocType: s.Reader.DocType(),
PageCount: len(s.doc.Pages.Page), PageCount: len(s.doc.Pages.Page),
Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), Pages: s.pageInfos(),
Outlines: s.doc.OutlineInfos(), Outlines: s.doc.OutlineInfos(),
DetailsPending: true, DetailsPending: true,
} }
@@ -459,15 +484,6 @@ func (s *Session) Summary() DocumentInfo {
info.CreationDate = docInfo.CreationDate info.CreationDate = docInfo.CreationDate
info.ModDate = docInfo.ModDate 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 { if fonts, err := s.Reader.Fonts(); err == nil {
for _, font := range fonts { for _, font := range fonts {
info.Fonts = append(info.Fonts, FontInfo{ info.Fonts = append(info.Fonts, FontInfo{
@@ -485,6 +501,23 @@ func (s *Session) Summary() DocumentInfo {
return info 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 获取完整文档信息 // Info 获取完整文档信息
// 返回: DocumentInfo 文档信息 // 返回: DocumentInfo 文档信息
func (s *Session) Info() DocumentInfo { func (s *Session) Info() DocumentInfo {
@@ -493,7 +526,7 @@ func (s *Session) Info() DocumentInfo {
Version: s.Reader.Version(), Version: s.Reader.Version(),
DocType: s.Reader.DocType(), DocType: s.Reader.DocType(),
PageCount: len(s.doc.Pages.Page), PageCount: len(s.doc.Pages.Page),
Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), Pages: s.pageInfos(),
Outlines: s.doc.OutlineInfos(), Outlines: s.doc.OutlineInfos(),
} }
if attachments, err := s.Reader.Attachments(); err == nil { if attachments, err := s.Reader.Attachments(); err == nil {
@@ -524,16 +557,6 @@ func (s *Session) Info() DocumentInfo {
info.CreationDate = docInfo.CreationDate info.CreationDate = docInfo.CreationDate
info.ModDate = docInfo.ModDate 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() { for !s.scanFontInfo() {
} }
info.Fonts = s.fontInfos info.Fonts = s.fontInfos
@@ -891,13 +914,13 @@ func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) {
// 入参: index 页面索引, page 页面内容 // 入参: index 页面索引, page 页面内容
// 返回: ofdgo.Box 页面物理区域, error 错误信息 // 返回: ofdgo.Box 页面物理区域, error 错误信息
func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) { func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) {
if box, ok := s.boxCache[index]; ok { if cached := s.boxCache[index]; cached.valid {
return box, nil return cached.box, nil
} }
box, err := s.Renderer.GetPageBox(page) box, err := s.Renderer.GetPageBox(page)
if err != nil { if err != nil {
return ofdgo.Box{}, err return ofdgo.Box{}, err
} }
s.boxCache[index] = box s.boxCache[index] = pageBoxInfo{box: box, valid: true}
return box, nil return box, nil
} }
+3
View File
@@ -313,6 +313,9 @@ func (e *Editor) appendAnnotations(index int, annotations []byte) error {
// annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用 // annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用
// 返回: []string 文件路径 // 返回: []string 文件路径
func (e *Editor) annotationFiles() []string { func (e *Editor) annotationFiles() []string {
if len(e.source.reader.annotationFiles) == 0 {
return nil
}
removed := make(map[string]bool) removed := make(map[string]bool)
for id := range e.source.pages { for id := range e.source.pages {
removed[id] = true removed[id] = true
+6 -5
View File
@@ -24,7 +24,8 @@ import (
// 入参: edit 批量修改回调 // 入参: edit 批量修改回调
// 返回: error 修改错误 // 返回: error 修改错误
func (e *Editor) Transaction(edit func(*Editor) error) error { func (e *Editor) Transaction(edit func(*Editor) error) error {
before := e.transactionSnapshot() before := *e
before.Info = cloneEditorData(e.Info)
next := e.transactionSnapshot() next := e.transactionSnapshot()
next.history, next.historyIndex, next.historyLimit = nil, 0, 0 next.history, next.historyIndex, next.historyLimit = nil, 0, 0
*e = next *e = next
@@ -32,10 +33,10 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
*e = before *e = before
return err return err
} }
next = e.transactionSnapshot() changed := e.revision != before.revision
*e = before e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
changed := next.revision != before.revision e.backends, e.fontFS = before.backends, before.fontFS
e.restoreTransaction(next) e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics
if changed { if changed {
e.serial, e.revision = before.serial, before.revision e.serial, e.revision = before.serial, before.revision
if change := e.recordChange(); change != nil { if change := e.recordChange(); change != nil {
+55 -9
View File
@@ -21,8 +21,10 @@ import (
"fmt" "fmt"
"image" "image"
"io" "io"
"maps"
"path" "path"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
) )
@@ -117,14 +119,12 @@ type PageCapabilities struct {
// 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader // 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader
// 返回: *Editor 编辑器, error 错误信息 // 返回: *Editor 编辑器, error 错误信息
func (r *Reader) Editor() (*Editor, error) { func (r *Reader) Editor() (*Editor, error) {
reader := &Reader{Zip: r.Zip, files: r.files, encryption: r.encryption} _, err := r.Doc()
if err := reader.initRoot(); err != nil {
return nil, err
}
doc, err := reader.Doc()
if err != nil { if err != nil {
return nil, err return nil, err
} }
reader := cloneEditorReader(r)
doc := reader.doc
maximum := doc.CommonData.MaxUnitID maximum := doc.CommonData.MaxUnitID
if maximum < 0 { if maximum < 0 {
return nil, fmt.Errorf("invalid MaxUnitID") return nil, fmt.Errorf("invalid MaxUnitID")
@@ -137,17 +137,60 @@ func (r *Reader) Editor() (*Editor, error) {
e.encryption = r.encryption e.encryption = r.encryption
e.Info, e.maxID = cloneEditorData(*info), maximum e.Info, e.maxID = cloneEditorData(*info), maximum
directory := cleanPackagePath(reader.RootDir) 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)} 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)}
for _, page := range doc.Pages.Page { 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 { if page.ID == "" || e.source.pages[page.ID] != nil {
return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID) return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID)
} }
e.source.pages[page.ID] = &editorSourcePage{ref: page} sourcePages[i].ref = page
e.pages = append(e.pages, PageContent{ID: page.ID}) e.source.pages[page.ID] = &sourcePages[i]
e.pages[i] = PageContent{ID: page.ID}
} }
return e, nil 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 // prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) prepareSourceIDs() error { func (e *Editor) prepareSourceIDs() error {
@@ -366,6 +409,8 @@ func (e *Editor) loadSourcePage(index int) error {
if source == nil || source.original != nil { if source == nil || source.original != nil {
return nil return nil
} }
loaded := *source
source = &loaded
reader := e.source.reader reader := e.source.reader
data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc)) data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc))
if err != nil { if err != nil {
@@ -426,6 +471,7 @@ func (e *Editor) loadSourcePage(index int) error {
} }
} }
source.original = page source.original = page
e.source.pages[source.ref.ID] = source
e.pages[index] = copyEditorPage(*page) e.pages[index] = copyEditorPage(*page)
return nil return nil
} }
+57 -10
View File
@@ -35,12 +35,14 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
source := e.source source := e.source
reader := source.reader reader := source.reader
pageRefs := make([]Page, len(e.pages)) pageRefs := make([]Page, len(e.pages))
originalCount := 0
for i, page := range e.pages { for i, page := range e.pages {
if err := progress.report("pages", i, len(e.pages)); err != nil { if err := progress.report("pages", i, len(e.pages)); err != nil {
return nil, err return nil, err
} }
original := source.pages[page.ID] original := source.pages[page.ID]
if original != nil { if original != nil {
originalCount++
pageRefs[i] = original.ref pageRefs[i] = original.ref
if original.original == nil { if original.original == nil {
continue continue
@@ -95,8 +97,8 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
} }
} }
} }
pagesChanged := !reflect.DeepEqual(pageRefs, source.document.Pages.Page) pagesChanged := !slices.Equal(pageRefs, source.document.Pages.Page)
if len(parts) != 0 || pagesChanged { if pagesChanged || len(resourceFiles) != 0 || len(parts) != 0 && e.maxID != source.document.CommonData.MaxUnitID {
name := reader.ResPath(reader.OFD.DocBody[0].DocRoot) name := reader.ResPath(reader.OFD.DocBody[0].DocRoot)
data, err := reader.readFile(name) data, err := reader.readFile(name)
if err != nil { if err != nil {
@@ -138,15 +140,18 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
if pages == nil { if pages == nil {
return nil, fmt.Errorf("document has no Pages") return nil, fmt.Errorf("document has no Pages")
} }
var entries [][]byte originals := make(map[string]*editorXML, len(pages.children))
for _, page := range pageRefs {
var original *editorXML
for _, child := range pages.children { for _, child := range pages.children {
if packageOFDNode(child, "Page") && child.attr("ID") == page.ID { if packageOFDNode(child, "Page") {
original = child id := child.attr("ID")
break if originals[id] == nil {
originals[id] = child
} }
} }
}
entries := make([][]byte, 0, len(pageRefs))
for _, page := range pageRefs {
original := originals[page.ID]
if original != nil { if original != nil {
entries = append(entries, data[original.start:original.end]) entries = append(entries, data[original.start:original.end])
} else { } else {
@@ -209,9 +214,11 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
} }
parts[name] = updated parts[name] = updated
} }
if originalCount != len(source.pages) || len(source.annotationPages) != 0 {
if err := e.prunePageReferences(parts); err != nil { if err := e.prunePageReferences(parts); err != nil {
return nil, err return nil, err
} }
}
for name, data := range maps.Clone(parts) { for name, data := range maps.Clone(parts) {
if file, ok := reader.packageFile(name); ok { if file, ok := reader.packageFile(name); ok {
actual := cleanPackagePath(file.Name) actual := cleanPackagePath(file.Name)
@@ -481,6 +488,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
archive := zip.NewWriter(output) archive := zip.NewWriter(output)
if reader.Zip != nil { if reader.Zip != nil {
_ = archive.SetComment(reader.Zip.Comment) _ = archive.SetComment(reader.Zip.Comment)
buffer := make([]byte, 32*1024)
for _, file := range reader.Zip.File { for _, file := range reader.Zip.File {
if file.FileInfo().IsDir() { if file.FileInfo().IsDir() {
header := file.FileHeader header := file.FileHeader
@@ -503,9 +511,20 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte, progress
return output.count, err return output.count, err
} }
delete(remaining, name) delete(remaining, name)
} else if err := archive.Copy(file); err != nil { } else {
input, err := file.OpenRaw()
if err != nil {
return output.count, err 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)) { for _, name := range slices.Sorted(maps.Keys(remaining)) {
@@ -533,6 +552,33 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) {
if err != nil { if err != nil {
return nil, err 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) files := maps.Clone(e.source.reader.files)
if files == nil { if files == nil {
files = make(map[string][]byte) files = make(map[string][]byte)
@@ -546,7 +592,8 @@ func (e *Editor) sourceReader(progress editorProgress) (*Reader, error) {
return nil, err return nil, err
} }
for name, header := range e.source.reader.pageHeaderCache { 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 reader.pageHeaderCache[name] = header
} }
} }
+7 -3
View File
@@ -92,9 +92,13 @@ func (r *Reader) initRoot() error {
// indexPackage 建立包路径索引,拒绝会使显示与验签产生歧义的同名条目 // indexPackage 建立包路径索引,拒绝会使显示与验签产生歧义的同名条目
// 返回: error 错误信息 // 返回: error 错误信息
func (r *Reader) indexPackage() error { func (r *Reader) indexPackage() error {
r.fileIndex = make(map[string]*zip.File) count := 0
r.fileIndexFold = make(map[string]*zip.File) if r.Zip != nil {
r.fileNamesFold = make(map[string]string) 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 { if r.Zip != nil {
for _, f := range r.Zip.File { for _, f := range r.Zip.File {
if f.FileInfo().IsDir() { if f.FileInfo().IsDir() {