From 1a75a8b0254435dfb8f817545d784baf7d690185 Mon Sep 17 00:00:00 2001 From: cwchen <1048842385@qq.com> Date: Mon, 10 Nov 2025 13:33:20 +0800 Subject: [PATCH] =?UTF-8?q?pdf=E9=A1=B5=E9=9D=A2=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/common/DocumentSearch.vue | 367 +++++++++++++++++++++------- 1 file changed, 279 insertions(+), 88 deletions(-) diff --git a/src/views/common/DocumentSearch.vue b/src/views/common/DocumentSearch.vue index b5db855..a2ea3cb 100644 --- a/src/views/common/DocumentSearch.vue +++ b/src/views/common/DocumentSearch.vue @@ -98,6 +98,11 @@ export default { isDocumentReady: false, renderQueue: [], isProcessingQueue: false, + pageCache: new Map(), + isPrefetching: false, + prefetchHandle: null, + prefetchScheduled: false, + initialPreloadedCount: 0, } }, mounted() { @@ -167,12 +172,16 @@ export default { this.totalPages = this.pdfDoc.numPages this.renderQueue = [] this.isProcessingQueue = false + this.pageCache.clear() + this.isPrefetching = false + this.initialPreloadedCount = 0 const fragment = document.createDocumentFragment() for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber += 1) { const placeholder = document.createElement('div') placeholder.className = 'pdf-page placeholder' placeholder.dataset.page = pageNumber + placeholder.dataset.status = 'placeholder' fragment.appendChild(placeholder) this.pageContainers.push(placeholder) } @@ -180,97 +189,149 @@ export default { container.scrollTop = 0 this.setupIntersectionObserver() + await this.preloadInitialPages() this.observeInitialPages() + this.schedulePrefetch() this.isDocumentReady = true }, - async renderSinglePage(pageNumber) { - if (!this.pdfDoc) return - if (this.renderedPages.has(pageNumber)) return + ensureContainerDimensions(pageNumber, viewport) { + const index = pageNumber - 1 + const container = this.pageContainers[index] + if (!container) return + + container.style.width = `${viewport.width}px` + container.style.height = `${viewport.height}px` + if (!container.dataset.status || container.dataset.status === 'placeholder') { + container.dataset.status = 'prefetched' + } + container.classList.remove('placeholder') + if (container.dataset.status !== 'rendered') { + container.classList.add('prefetched') + } + + if (this.$refs.pdfWrapper && !this.$refs.pdfWrapper.style.minWidth) { + this.$refs.pdfWrapper.style.minWidth = `${Math.ceil(viewport.width)}px` + } + }, + + async ensurePageCached(pageNumber) { + if (!this.pdfDoc) return null + if (this.pageCache.has(pageNumber)) { + return this.pageCache.get(pageNumber) + } + const page = await this.pdfDoc.getPage(pageNumber) + this.pageCache.set(pageNumber, page) + return page + }, + + async renderCanvas(pageNumber) { + const page = await this.ensurePageCached(pageNumber) + if (!page) return null const index = pageNumber - 1 const container = this.pageContainers[index] - if (!container || container.classList.contains('rendering')) return + if (!container) return null - container.classList.add('rendering') - try { - const page = await this.pdfDoc.getPage(pageNumber) - const viewport = page.getViewport({ scale: this.scale }) - container.classList.remove('placeholder') - container.style.width = `${viewport.width}px` - container.style.height = `${viewport.height}px` + const viewport = page.getViewport({ scale: this.scale }) + this.ensureContainerDimensions(pageNumber, viewport) - const canvas = document.createElement('canvas') - canvas.className = 'pdf-canvas' - const deviceScale = window.devicePixelRatio || 1 - const outputScale = Math.min(deviceScale, 1) - canvas.width = viewport.width * outputScale - canvas.height = viewport.height * outputScale - canvas.style.width = `${viewport.width}px` - canvas.style.height = `${viewport.height}px` - container.appendChild(canvas) - - if (this.$refs.pdfWrapper && !this.$refs.pdfWrapper.style.minWidth) { - this.$refs.pdfWrapper.style.minWidth = `${Math.ceil(viewport.width)}px` - } - - const canvasContext = canvas.getContext('2d') - const renderContext = { - canvasContext, - viewport, - } - if (outputScale !== 1) { - renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0] - } - await page.render(renderContext).promise - - this.pageTextDivs[index] = [] - - const scheduleTextLayer = () => { - const textLayerDiv = document.createElement('div') - textLayerDiv.className = 'textLayer' - textLayerDiv.style.width = `${viewport.width}px` - textLayerDiv.style.height = `${viewport.height}px` - container.appendChild(textLayerDiv) - - const textLayer = new TextLayerBuilder({ - textLayerDiv, - pageIndex: index, - viewport, - eventBus: this.eventBus, - }) - - page.getTextContent() - .then((textContent) => { - textLayer.setTextContent(textContent) - textLayer.render() - - const textDivs = [...textLayer.textDivs] - textDivs.forEach((div) => { - div.dataset.originalText = div.textContent - }) - this.pageTextDivs[index] = textDivs - }) - .catch((err) => { - console.warn(`获取第 ${pageNumber} 页文本失败`, err) - }) - } - - if (typeof window.requestIdleCallback === 'function') { - window.requestIdleCallback(scheduleTextLayer, { timeout: 500 }) - } else { - setTimeout(scheduleTextLayer, 40) - } - - this.renderedPages.set(pageNumber, { container, viewport }) - if (this.observer) { - this.observer.unobserve(container) - } - } catch (error) { - console.error(`渲染第 ${pageNumber} 页失败`, error) - } finally { - container.classList.remove('rendering') + const oldCanvas = container.querySelector('.pdf-canvas') + if (oldCanvas) { + oldCanvas.remove() } + + const canvas = document.createElement('canvas') + canvas.className = 'pdf-canvas' + const deviceScale = window.devicePixelRatio || 1 + const outputScale = Math.min(deviceScale, 1.25) + canvas.width = viewport.width * outputScale + canvas.height = viewport.height * outputScale + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + container.appendChild(canvas) + + const canvasContext = canvas.getContext('2d') + const renderContext = { + canvasContext, + viewport, + } + if (outputScale !== 1) { + renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0] + } + await page.render(renderContext).promise + + container.dataset.status = 'rendered' + container.classList.remove('prefetched') + + const textLayerDiv = container.querySelector('.textLayer') + if (textLayerDiv) { + textLayerDiv.style.display = '' + } + + this.renderedPages.set(pageNumber, { container, viewport }) + return { page, viewport, container } + }, + + async renderTextLayer(pageNumber, { visible = true, force = false } = {}) { + const page = await this.ensurePageCached(pageNumber) + if (!page) return + + const index = pageNumber - 1 + const container = this.pageContainers[index] + if (!container) return + + const viewport = page.getViewport({ scale: this.scale }) + this.ensureContainerDimensions(pageNumber, viewport) + + const existing = container.querySelector('.textLayer') + if (existing && !force && this.pageTextDivs[index]?.length) { + existing.style.display = visible ? '' : 'none' + return + } + if (existing) { + existing.remove() + } + + const textLayerDiv = document.createElement('div') + textLayerDiv.className = 'textLayer' + textLayerDiv.style.width = `${viewport.width}px` + textLayerDiv.style.height = `${viewport.height}px` + textLayerDiv.style.display = visible ? '' : 'none' + container.appendChild(textLayerDiv) + + const textLayer = new TextLayerBuilder({ + textLayerDiv, + pageIndex: index, + viewport, + eventBus: this.eventBus, + }) + + try { + const textContent = await page.getTextContent() + textLayer.setTextContent(textContent) + textLayer.render() + + const textDivs = [...textLayer.textDivs] + textDivs.forEach((div) => { + div.dataset.originalText = div.textContent + }) + this.pageTextDivs[index] = textDivs + } catch (error) { + console.warn(`加载第 ${pageNumber} 页文本失败`, error) + } + + if (container.dataset.status !== 'rendered') { + container.dataset.status = visible ? 'rendered' : 'text-ready' + if (!visible) { + container.classList.add('prefetched') + } + } + }, + + async renderSinglePage(pageNumber) { + await this.renderCanvas(pageNumber) + await this.renderTextLayer(pageNumber, { visible: true }) }, scheduleRender(pageNumber, { priority = false } = {}) { @@ -281,10 +342,6 @@ export default { } else { this.renderQueue.push(pageNumber) } - const MAX_QUEUE_LENGTH = 4 - if (this.renderQueue.length > MAX_QUEUE_LENGTH) { - this.renderQueue.splice(MAX_QUEUE_LENGTH) - } this.processRenderQueue() }, @@ -302,6 +359,106 @@ export default { } }, + schedulePrefetch() { + this.cancelPrefetch() + if (!this.totalPages) return + const startPage = Math.min(this.totalPages, Math.max(1, this.initialPreloadedCount + 1)) + let pageNumber = startPage + + const fetchNext = async () => { + this.prefetchScheduled = false + this.prefetchHandle = null + if (pageNumber > this.totalPages) { + this.isPrefetching = false + return + } + if (!this.pageCache.has(pageNumber)) { + try { + const page = await this.pdfDoc.getPage(pageNumber) + this.pageCache.set(pageNumber, page) + const viewport = page.getViewport({ scale: this.scale }) + this.ensureContainerDimensions(pageNumber, viewport) + } catch (error) { + console.warn(`预取第 ${pageNumber} 页失败`, error) + } + } + pageNumber += 1 + if (pageNumber <= this.totalPages) { + const schedule = () => { + this.prefetchScheduled = true + if (typeof window.requestIdleCallback === 'function') { + this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 1000 }) + } else { + this.prefetchHandle = window.setTimeout(fetchNext, 80) + } + } + schedule() + } else { + this.isPrefetching = false + } + } + + this.isPrefetching = true + if (typeof window.requestIdleCallback === 'function') { + this.prefetchScheduled = true + this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 800 }) + } else { + this.prefetchScheduled = true + this.prefetchHandle = window.setTimeout(fetchNext, 120) + } + }, + + cancelPrefetch() { + if (this.prefetchHandle) { + if (typeof window.cancelIdleCallback === 'function' && this.prefetchScheduled) { + window.cancelIdleCallback(this.prefetchHandle) + } else { + clearTimeout(this.prefetchHandle) + } + } + this.prefetchHandle = null + this.prefetchScheduled = false + this.isPrefetching = false + }, + + async preloadInitialPages() { + const preloadCount = Math.min(3, this.totalPages) + if (!preloadCount) { + this.initialPreloadedCount = 0 + return + } + + for (let pageNumber = 1; pageNumber <= preloadCount; pageNumber += 1) { + await this.renderTextLayer(pageNumber, { visible: pageNumber === 1 }) + } + + if (preloadCount >= 1) { + await this.renderCanvas(1) + } + + this.initialPreloadedCount = preloadCount + }, + + unloadPage(pageNumber) { + if (!pageNumber) return + const index = pageNumber - 1 + const container = this.pageContainers[index] + if (!container || container.dataset.status !== 'rendered') return + + const canvas = container.querySelector('.pdf-canvas') + if (canvas) { + canvas.remove() + } + const textLayer = container.querySelector('.textLayer') + if (textLayer) { + textLayer.style.display = 'none' + } + + container.dataset.status = this.pageTextDivs[index]?.length ? 'text-ready' : 'prefetched' + container.classList.add('prefetched') + this.renderedPages.delete(pageNumber) + }, + handleSearch: debounce(function () { if (!this.keyword) { this.resetSearch() @@ -432,6 +589,8 @@ export default { this.totalPages = 0 this.isDocumentReady = false this.disconnectObserver() + this.cancelPrefetch() + this.pageCache.clear() const wrapper = this.$refs.pdfWrapper if (wrapper) { wrapper.innerHTML = '' @@ -459,7 +618,17 @@ export default { if (entry.isIntersecting) { const page = Number(entry.target.dataset.page) if (page) { - this.scheduleRender(page) + if (page > this.initialPreloadedCount) { + this.renderTextLayer(page, { visible: true }) + } else if (page !== 1) { + this.renderTextLayer(page, { visible: true }) + } + this.scheduleRender(page, { priority: true }) + } + } else { + const page = Number(entry.target.dataset.page) + if (page) { + this.unloadPage(page) } } }) @@ -470,11 +639,19 @@ export default { observeInitialPages() { if (!this.pageContainers.length) return - const initial = this.pageContainers.slice(0, 2) + const initial = this.pageContainers.slice(0, 3) initial.forEach((container) => { const page = Number(container.dataset.page) if (page) { - this.scheduleRender(page, { priority: true }) + if (page === 1) { + // 已在预处理中完成 + return + } + if (page <= this.initialPreloadedCount) { + this.scheduleRender(page, { priority: true }) + } else { + this.scheduleRender(page) + } } }) }, @@ -486,6 +663,7 @@ export default { } this.renderQueue = [] this.isProcessingQueue = false + this.cancelPrefetch() }, }, } @@ -586,6 +764,19 @@ export default { content: '正在准备页面...'; } +.pdf-page.prefetched { + background: #fdfdfd; + box-shadow: inset 0 0 0 1px rgba(112, 143, 255, 0.2); + position: relative; +} + +.pdf-page.prefetched::after { + content: '滚动到此处以加载内容'; + color: rgba(112, 143, 255, 0.7); + font-size: 12px; + letter-spacing: 0.4px; +} + .pdf-canvas { width: 100%; display: block;