// PDF.js viewer using pdfjs-dist 6.1.200 legacy build. // Depends on global pdfjsLib (loaded in index.html). const ZOOM_MIN = 0.25; const ZOOM_MAX = 5; const ZOOM_STEP = 1.2; // multiplicative class PdfViewer { constructor(container, options = {}) { this.container = container; this.generation = 0; this.loadingTask = null; this.doc = null; this.numPages = 0; this.renderTasks = new Set(); this.lastUrl = null; this.lastSize = null; // multiplier on fit-to-width (1 = fit width); can be seeded from the // previous viewer instance so zoom survives switching documents this.zoom = Math.min( ZOOM_MAX, Math.max(ZOOM_MIN, options.initialZoom ?? 1), ); this._lastRenderWidth = 0; this._resizeTimer = null; this._zoomTimer = null; this.resizeObserver = null; this._createDOM(); this._updateZoomLabel(); this._observeResize(); } _createDOM() { // The pages container is always visible (never hidden). // Loading and error states are overlays on top. this.container.innerHTML = `
`; this.toolbar = this.container.querySelector(".pdf-v-toolbar"); this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label"); this.pagesDiv = this.container.querySelector(".pdf-v-pages"); this.loadingDiv = this.container.querySelector(".pdf-v-loading"); this.errorDiv = this.container.querySelector(".pdf-v-error"); this.errorMsg = this.container.querySelector(".pdf-v-error-msg"); this.retryBtn = this.container.querySelector(".pdf-v-retry"); this.retryBtn.addEventListener("click", () => { if (this.lastUrl != null) { this._setState("loading"); this._loadInternal(this.lastUrl, this.lastSize); } }); this.container.querySelector(".pdf-v-zoom-in").addEventListener( "click", () => this.setZoom(this.zoom * ZOOM_STEP), ); this.container.querySelector(".pdf-v-zoom-out").addEventListener( "click", () => this.setZoom(this.zoom / ZOOM_STEP), ); // Clicking the percentage resets to fit-width. this.zoomLabel.addEventListener("click", () => this.setZoom(1)); // Ctrl + mouse wheel zoom, like in browsers / PDF readers. this.pagesDiv.addEventListener("wheel", (e) => { if (!e.ctrlKey) return; e.preventDefault(); this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1)); }, { passive: false }); } // ---- Zoom ---- setZoom(zoom) { const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom)); if (Math.abs(clamped - this.zoom) < 0.001) return; this.zoom = clamped; this._updateZoomLabel(); if (!this.doc) return; // Debounce so repeated +/+/+ or wheel ticks trigger one re-render. clearTimeout(this._zoomTimer); this._zoomTimer = setTimeout(() => this._rerender(), 120); } _updateZoomLabel() { this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`; } // ---- Resize ---- _observeResize() { if (typeof ResizeObserver === "undefined") return; this.resizeObserver = new ResizeObserver(() => { if (!this.doc) return; const width = this._renderWidth(); if (Math.abs(width - this._lastRenderWidth) < 2) return; clearTimeout(this._resizeTimer); this._resizeTimer = setTimeout(() => { console.log( `[BizMatch] PDF viewer resized, re-rendering at ${this._renderWidth()}px`, ); this._rerender(); }, 150); }); this.resizeObserver.observe(this.container); } _renderWidth() { return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100); } // Re-render all pages of the already-loaded document (zoom / resize), // preserving the relative scroll position. async _rerender() { if (!this.doc) return; const generation = ++this.generation; for (const rt of this.renderTasks) { try { rt.cancel(); } catch { /* ok */ } } this.renderTasks.clear(); const scrollRatio = this.pagesDiv.scrollHeight > 0 ? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight : 0; await this._renderAllPages(this.doc, generation, false); if (generation !== this.generation) return; this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight; } // ---- Loading ---- async load(url, byteSize) { this.lastUrl = url; this.lastSize = byteSize; this._setState("loading"); await this._loadInternal(url, byteSize); } async _loadInternal(url, _byteSize) { const generation = ++this.generation; console.log( `[BizMatch] PDF viewer load started: generation ${generation}`, ); // Await cleanup of previous document (promises may be involved) await this._disposeCurrentDocument(); if (generation !== this.generation) return; this.pagesDiv.innerHTML = ""; const loadStart = performance.now(); try { const task = pdfjsLib.getDocument({ url, rangeChunkSize: 65536, disableAutoFetch: false, // PDF.js >= 5 decodes CCITT-G4/JBIG2 (B/W scans), JPEG2000 and ICC // color via WASM modules fetched from wasmUrl. Without these URLs // the decoders fail silently (ignoreErrors default) and scanned // pages render as blank white canvases of the correct size. wasmUrl: "/pdfjs/wasm/", standardFontDataUrl: "/pdfjs/standard_fonts/", iccUrl: "/pdfjs/iccs/", }); this.loadingTask = task; const doc = await task.promise; if (generation !== this.generation) { doc.destroy(); return; } this.doc = doc; this.numPages = doc.numPages; const loadMs = Math.round(performance.now() - loadStart); console.log( `[BizMatch] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`, ); this.toolbar.hidden = false; this._updateZoomLabel(); await this._renderAllPages(doc, generation, true); if (generation !== this.generation) return; const totalMs = Math.round(performance.now() - loadStart); console.log( `[BizMatch] PDF viewer load completed: generation ${generation}`, ); console.log( `[BizMatch] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`, ); } catch (err) { if (generation !== this.generation) return; // Distinguish cancellation from real errors if (isCancellationError(err)) { console.log( `[BizMatch] PDF generation ${generation} cancelled`, ); return; } const msg = errorMsg(err); console.error(`[BizMatch] PDF load failed:`, msg); this.errorMsg.textContent = `Unable to open PDF: ${msg}`; this._setState("error"); } } // Shared render loop for initial load, zoom and resize. // hideLoadingAfterFirstPage: true on initial load (removes the overlay // as soon as page 1 is visible). async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) { this.pagesDiv.innerHTML = ""; const viewerWidth = this._renderWidth(); this._lastRenderWidth = viewerWidth; console.log(`[BizMatch] PDF render width: ${viewerWidth}px`); for (let p = 1; p <= this.numPages; p++) { if (generation !== this.generation) return; const pStart = performance.now(); await this._renderPage(doc, p, viewerWidth, generation); if (generation !== this.generation) return; if (p === 1 && hideLoadingAfterFirstPage) { this._setState(null); // Remove loading overlay } console.log( `[BizMatch] PDF page ${p} rendered in ${ Math.round(performance.now() - pStart) }ms`, ); await new Promise((r) => requestAnimationFrame(r)); } } _setState(state) { this.loadingDiv.hidden = true; this.errorDiv.hidden = true; if (state === "loading") this.loadingDiv.hidden = false; if (state === "error") this.errorDiv.hidden = false; this._viewerState = state; } _getViewportWidth() { const style = globalThis.getComputedStyle ? getComputedStyle(this.pagesDiv) : null; let padding = 24; // default guess if (style) { padding = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0); } const raw = Math.round(this.pagesDiv.clientWidth || 600); return Math.max(raw - padding, 200); } async _disposeCurrentDocument() { for (const rt of this.renderTasks) { try { rt.cancel(); } catch { /* ok */ } } this.renderTasks.clear(); if (this.loadingTask) { try { await this.loadingTask.destroy(); } catch { /* ok */ } this.loadingTask = null; } if (this.doc) { try { await this.doc.destroy(); } catch { /* ok */ } this.doc = null; this.numPages = 0; } } async _renderPage(doc, pageNum, viewerWidth, generation) { const pageDiv = document.createElement("div"); pageDiv.className = "pdf-v-page"; pageDiv.dataset.page = String(pageNum); pageDiv.dataset.renderState = "idle"; const canvas = document.createElement("canvas"); pageDiv.appendChild(canvas); this.pagesDiv.appendChild(pageDiv); pageDiv.dataset.renderState = "rendering"; try { const page = await doc.getPage(pageNum); if (generation !== this.generation) return; const vp1 = page.getViewport({ scale: 1 }); const fitScale = viewerWidth / vp1.width; const viewport = page.getViewport({ scale: fitScale }); const dpr = Math.min(globalThis.devicePixelRatio || 1, 2); canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = `${Math.floor(viewport.width)}px`; canvas.style.height = `${Math.floor(viewport.height)}px`; const ctx = canvas.getContext("2d"); const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined; const renderTask = page.render({ canvasContext: ctx, viewport, transform, }); this.renderTasks.add(renderTask); await renderTask.promise; this.renderTasks.delete(renderTask); if (generation !== this.generation) return; pageDiv.dataset.renderState = "rendered"; } catch (err) { if (generation !== this.generation) return; if (isCancellationError(err)) return; pageDiv.dataset.renderState = "error"; console.error( `[BizMatch] Failed to render PDF page ${pageNum}`, err, ); if (pageNum === 1) { this.errorMsg.textContent = `Unable to open PDF: ${errorMsg(err)}`; this._setState("error"); } else { pageDiv.textContent = `Error rendering page ${pageNum}`; pageDiv.style.padding = "20px"; pageDiv.style.color = "#f88"; pageDiv.style.textAlign = "center"; } } } destroy() { this.generation = 0; clearTimeout(this._resizeTimer); clearTimeout(this._zoomTimer); if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } this._disposeCurrentDocument().catch(() => {}); this.pagesDiv.innerHTML = ""; this.toolbar.hidden = true; this._setState("loading"); } } function isCancellationError(err) { if (!(err instanceof Error)) return false; const msg = err.message || ""; return msg.includes("cancelled") || msg.includes("canceled") || msg.includes("destroyed") || msg.includes("Worker was destroyed"); } function errorMsg(err) { return err instanceof Error ? err.message : String(err); } globalThis.PdfViewer = PdfViewer;