// PDF.js viewer with memory LRU cache and lazy page rendering. // Depends on global pdfjsLib (loaded via module import in index.html). // Uses pdfjs-dist 6.1.200. const MAX_CACHED_DOCS = 3; const MAX_CACHED_BYTES = 150 * 1024 * 1024; const PRELOAD_PAGES_AHEAD = 2; const PRELOAD_PAGES_BEHIND = 1; const MAX_RENDER_SCALE = 2.0; class PdfViewer { constructor(container) { this.container = container; this.lru = []; this.currentEntry = null; this.renderTasks = []; this.observer = null; this.currentDocId = 0; this.loadingDiv = null; this.errorDiv = null; this.pagesDiv = null; this._createDOM(); } _createDOM() { this.container.innerHTML = `
`; this.loadingDiv = this.container.querySelector(".pdf-v-loading"); this.errorDiv = this.container.querySelector(".pdf-v-error"); this.pagesDiv = this.container.querySelector(".pdf-v-pages"); } _showError(msg) { this.loadingDiv.hidden = true; this.errorDiv.textContent = msg; this.errorDiv.hidden = false; } _showLoading() { this.errorDiv.hidden = true; this.loadingDiv.hidden = false; } _hideLoading() { this.loadingDiv.hidden = true; } async load(url, byteSize) { const docId = ++this.currentDocId; this._showLoading(); this._cancelAllRenders(); if (this.observer) this.observer.disconnect(); // Check memory LRU const cached = this.lru.find((e) => e.url === url); if (cached) { cached.lastUsed = Date.now(); this.lru = this.lru.filter((e) => e !== cached); this.lru.push(cached); this.currentEntry = cached; this._renderDoc(docId, cached); console.log("[BizMatch QC] PDF memory cache hit"); return; } const startLoad = performance.now(); try { const task = pdfjsLib.getDocument({ url, rangeChunkSize: 65536 }); const doc = await task.promise; const loadMs = Math.round(performance.now() - startLoad); console.log( `[BizMatch QC] PDF.js document loaded: ${loadMs}ms`, ); const pageCount = doc.numPages; const entry = { url, doc, byteSize: byteSize || 0, pageCount, lastUsed: Date.now(), }; this._evictIfNeeded(entry.byteSize); this.lru.push(entry); if (docId !== this.currentDocId) { doc.destroy(); return; } this.currentEntry = entry; console.log("[BizMatch QC] PDF memory cache miss"); this._renderDoc(docId, entry, startLoad); } catch (err) { if (docId !== this.currentDocId) return; this._showError( `Cannot open PDF: ${err instanceof Error ? err.message : String(err)}`, ); } } _evictIfNeeded(incomingBytes) { let totalBytes = incomingBytes; for (const e of this.lru) totalBytes += e.byteSize; while ( this.lru.length > 0 && (this.lru.length >= MAX_CACHED_DOCS || totalBytes > MAX_CACHED_BYTES) ) { const victim = this.lru.shift(); totalBytes -= victim.byteSize; try { victim.doc.destroy(); } catch { /* ok */ } console.log( "[BizMatch QC] PDF memory cache evicted: " + victim.url.split("/").pop(), ); } } _cancelAllRenders() { for (const t of this.renderTasks) { t.cancelled = true; } this.renderTasks = []; } async _renderDoc(docId, entry, startLoad = 0) { this.pagesDiv.innerHTML = ""; this._hideLoading(); const pageCount = entry.pageCount; const placeholderStyle = "pdf-v-placeholder"; for (let i = 1; i <= pageCount; i++) { const ph = document.createElement("div"); ph.className = placeholderStyle; ph.dataset.page = String(i); // Pre-allocate aspect ratio from first page this.pagesDiv.appendChild(ph); } // Get first page dimensions for placeholder sizing try { const firstPage = await entry.doc.getPage(1); const vp = firstPage.getViewport({ scale: 1 }); const ratio = vp.height / vp.width; const placeholders = this.pagesDiv.querySelectorAll( `.${placeholderStyle}`, ); for (const ph of placeholders) { // Will be sized in CSS via aspect-ratio; set data attrs for CSS ph.dataset.ratio = String(ratio); } } catch { /* ignore */ } // Render first page immediately this._renderPage(docId, entry, 1); // Set up lazy rendering this.observer = new IntersectionObserver( (entries) => { for (const e of entries) { if (e.isIntersecting) { const pageNum = parseInt(e.target.dataset.page); if (pageNum) { this._renderPage(docId, entry, pageNum); // Pre-render nearby pages for ( let p = pageNum - PRELOAD_PAGES_BEHIND; p <= pageNum + PRELOAD_PAGES_AHEAD; p++ ) { if (p >= 1 && p <= pageCount && p !== pageNum) { this._renderPage(docId, entry, p); } } } } } }, { root: this.pagesDiv, rootMargin: "200px" }, ); const placeholders = this.pagesDiv.querySelectorAll(`.${placeholderStyle}`); for (const ph of placeholders) { this.observer.observe(ph); } if (startLoad) { const readyMs = Math.round(performance.now() - startLoad); console.log(`[BizMatch QC] PDF ready: ${readyMs}ms`); } } async _renderPage(docId, entry, pageNum) { // Don't re-render if already rendered const existing = this.pagesDiv.querySelector( `.pdf-v-page[data-page="${pageNum}"]`, ); if (existing) return; if (docId !== this.currentDocId) return; // Don't re-render if cancelled const existingTask = this.renderTasks.find((t) => t.pageNum === pageNum); if (existingTask) return; const placeholder = this.pagesDiv.querySelector( `.pdf-v-placeholder[data-page="${pageNum}"]`, ); if (!placeholder) return; const renderTask = { pageNum, cancelled: false, promise: null }; this.renderTasks.push(renderTask); const startRender = performance.now(); try { const page = await entry.doc.getPage(pageNum); if (renderTask.cancelled || docId !== this.currentDocId) return; const containerWidth = this.pagesDiv.clientWidth || 600; const vp1 = page.getViewport({ scale: 1 }); const fitScale = containerWidth / vp1.width; const dpr = Math.min(globalThis.devicePixelRatio || 1, MAX_RENDER_SCALE); const scale = Math.min(fitScale * dpr, MAX_RENDER_SCALE); const viewport = page.getViewport({ scale: fitScale }); const pageDiv = document.createElement("div"); pageDiv.className = "pdf-v-page"; pageDiv.dataset.page = String(pageNum); pageDiv.style.width = `${viewport.width}px`; pageDiv.style.height = `${viewport.height}px`; const canvas = document.createElement("canvas"); canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; canvas.width = Math.floor(viewport.width * (scale / fitScale)); canvas.height = Math.floor(viewport.height * (scale / fitScale)); pageDiv.appendChild(canvas); placeholder.replaceWith(pageDiv); const ctx = canvas.getContext("2d"); if (ctx && !renderTask.cancelled && docId === this.currentDocId) { await page.render({ canvasContext: ctx, viewport }).promise; if (pageNum === 1) { const renderMs = Math.round(performance.now() - startRender); console.log( `[BizMatch QC] First page rendered: ${renderMs}ms`, ); } } } catch (err) { if (renderTask.cancelled) return; placeholder.textContent = `Error rendering page ${pageNum}: ${ errorMsg(err) }`; } finally { this.renderTasks = this.renderTasks.filter((t) => t !== renderTask); } } destroy() { this._cancelAllRenders(); if (this.observer) this.observer.disconnect(); this._evictAll(); } _evictAll() { for (const entry of this.lru) { try { entry.doc.destroy(); } catch { /* ok */ } } this.lru = []; this.currentEntry = null; } } function errorMsg(err) { return err instanceof Error ? err.message : String(err); } // Export global globalThis.PdfViewer = PdfViewer;