Files
bizmatch-qc-desktop/web/pdf_viewer.js
2026-07-15 18:40:04 -05:00

264 lines
7.7 KiB
JavaScript

// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
// Depends on global pdfjsLib (loaded in index.html).
class PdfViewer {
constructor(container) {
this.container = container;
this.generation = 0;
this.loadingTask = null;
this.doc = null;
this.renderTasks = new Set();
this.lastUrl = null;
this.lastSize = null;
this._createDOM();
}
_createDOM() {
// The pages container is always visible (never hidden).
// Loading and error states are overlays on top.
this.container.innerHTML = `
<div class="pdf-v-pages"></div>
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
<div class="pdf-v-overlay pdf-v-error" hidden>
<div class="pdf-v-error-msg"></div>
<button class="pdf-v-retry">Retry</button>
</div>
`;
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);
}
});
}
_setState(state) {
this.loadingDiv.hidden = state !== "loading";
this.errorDiv.hidden = state !== "error";
this._viewerState = state;
}
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 QC] PDF viewer load started: generation ${generation}`,
);
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,
});
this.loadingTask = task;
const doc = await task.promise;
if (generation !== this.generation) {
doc.destroy();
return;
}
this.doc = doc;
const numPages = doc.numPages;
const loadMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch QC] PDF document loaded: ${numPages} pages in ${loadMs}ms`,
);
const viewerWidth = this._getViewportWidth();
console.log(`[BizMatch QC] PDF usable render width: ${viewerWidth}px`);
// Render page 1 -- MUST succeed before we show ready state
await this._renderPage(doc, 1, viewerWidth, generation);
if (generation !== this.generation) return;
this._setState("ready");
console.log(
`[BizMatch QC] PDF page 1 rendered in ${
Math.round(performance.now() - loadStart)
}ms`,
);
console.log(
`[BizMatch QC] First page visible after ${
Math.round(performance.now() - loadStart)
}ms`,
);
for (let p = 2; p <= numPages; p++) {
if (generation !== this.generation) return;
const pStart = performance.now();
await this._renderPage(doc, p, viewerWidth, generation);
if (generation !== this.generation) return;
console.log(
`[BizMatch QC] PDF page ${p} rendered in ${
Math.round(performance.now() - pStart)
}ms`,
);
await new Promise((r) => requestAnimationFrame(r));
}
const totalMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
);
console.log(
`[BizMatch QC] PDF fully rendered: ${numPages} pages in ${totalMs}ms`,
);
} catch (err) {
if (generation !== this.generation) return;
if (isCancellationError(err)) {
console.log(`[BizMatch QC] PDF generation ${generation} cancelled`);
return;
}
const msg = errorMsg(err);
console.error(`[BizMatch QC] PDF load failed:`, msg, err?.stack);
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
this._setState("error");
}
}
_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;
}
}
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 });
// Use DPR 1 for initial diagnosis; restore DPR scaling after content renders
const dpr = 1;
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");
if (!ctx) throw new Error("2D canvas context is unavailable");
const renderTask = page.render({
canvas,
viewport,
transform: undefined,
});
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)) throw err;
console.error(
`[BizMatch QC] Page ${pageNum} render failed`,
err,
err?.stack,
);
if (pageNum === 1) {
throw new Error(
`Failed to render page 1: ${errorMsg(err)}`,
{ cause: err },
);
}
// Inline error for later pages
pageDiv.textContent = `Error rendering page ${pageNum}`;
pageDiv.style.padding = "20px";
pageDiv.style.color = "#f88";
pageDiv.style.textAlign = "center";
pageDiv.dataset.renderState = "error";
}
}
destroy() {
this.generation = 0;
this._disposeCurrentDocument().catch(() => {});
this.pagesDiv.innerHTML = "";
this._setState("loading");
}
}
function isCancellationError(err) {
if (!(err instanceof Error)) return false;
const msg = err.message || "";
return msg.includes("cancelled") ||
msg.includes("cancelled") ||
msg.includes("destroyed") ||
msg.includes("Worker was destroyed");
}
function errorMsg(err) {
return err instanceof Error ? err.message : String(err);
}
globalThis.PdfViewer = PdfViewer;