288 lines
8.4 KiB
JavaScript
288 lines
8.4 KiB
JavaScript
// 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 = `
|
|
<div class="pdf-v-loading" hidden>Loading PDF\u2026</div>
|
|
<div class="pdf-v-error" hidden></div>
|
|
<div class="pdf-v-pages"></div>
|
|
`;
|
|
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;
|