263 lines
7.9 KiB
JavaScript
263 lines
7.9 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 = true;
|
|
this.errorDiv.hidden = true;
|
|
if (state === "loading") this.loadingDiv.hidden = false;
|
|
if (state === "error") this.errorDiv.hidden = false;
|
|
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 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,
|
|
});
|
|
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`);
|
|
|
|
const pageStart = performance.now();
|
|
await this._renderPage(doc, 1, viewerWidth, generation);
|
|
if (generation !== this.generation) return;
|
|
|
|
this._setState(null); // Remove loading overlay
|
|
const firstPageMs = Math.round(performance.now() - pageStart);
|
|
console.log(
|
|
`[BizMatch QC] PDF page 1 rendered in ${firstPageMs}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;
|
|
// Distinguish cancellation from real errors
|
|
if (isCancellationError(err)) {
|
|
console.log(
|
|
`[BizMatch QC] PDF generation ${generation} cancelled`,
|
|
);
|
|
return;
|
|
}
|
|
const msg = errorMsg(err);
|
|
console.error(`[BizMatch QC] PDF load failed:`, msg);
|
|
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 });
|
|
|
|
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 QC] 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;
|
|
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;
|