dfgdf
This commit is contained in:
18
main.ts
18
main.ts
@@ -19,10 +19,10 @@ import STYLES_CSS from "./web/styles.css" with { type: "text" };
|
|||||||
import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with {
|
import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with {
|
||||||
type: "json",
|
type: "json",
|
||||||
};
|
};
|
||||||
import PDFJS_LIB_TEXT from "pdfjs-dist/build/pdf.min.mjs" with {
|
import PDFJS_LIB_TEXT from "pdfjs-dist/legacy/build/pdf.min.mjs" with {
|
||||||
type: "text",
|
type: "text",
|
||||||
};
|
};
|
||||||
import PDFJS_WORKER_TEXT from "pdfjs-dist/build/pdf.worker.min.mjs" with {
|
import PDFJS_WORKER_TEXT from "pdfjs-dist/legacy/build/pdf.worker.min.mjs" with {
|
||||||
type: "text",
|
type: "text",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -357,7 +357,10 @@ Deno.serve(async (request) => {
|
|||||||
// ---- PDF prepare endpoint ----
|
// ---- PDF prepare endpoint ----
|
||||||
if (url.pathname === "/api/pdf/prepare" && request.method === "POST") {
|
if (url.pathname === "/api/pdf/prepare" && request.method === "POST") {
|
||||||
try {
|
try {
|
||||||
const body = await request.json() as { index?: number };
|
const body = await request.json() as {
|
||||||
|
index?: number;
|
||||||
|
requestId?: string;
|
||||||
|
};
|
||||||
const index = body.index;
|
const index = body.index;
|
||||||
const doc = documents[index!];
|
const doc = documents[index!];
|
||||||
if (!Number.isInteger(index) || !doc) {
|
if (!Number.isInteger(index) || !doc) {
|
||||||
@@ -366,7 +369,8 @@ Deno.serve(async (request) => {
|
|||||||
|
|
||||||
const sourcePath = resolvePdfPath(settings.pdfBaseDirectory, doc);
|
const sourcePath = resolvePdfPath(settings.pdfBaseDirectory, doc);
|
||||||
const relative = doc._letter + "/" + doc.file_name;
|
const relative = doc._letter + "/" + doc.file_name;
|
||||||
console.log(`${PREFIX} PDF prepare requested: ${relative}`);
|
const reqTag = body.requestId ? `PDF ${body.requestId}` : "PDF";
|
||||||
|
console.log(`${PREFIX} ${reqTag} prepare requested: ${relative}`);
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const cacheResult = await cacheOrGetPdf(sourcePath);
|
const cacheResult = await cacheOrGetPdf(sourcePath);
|
||||||
@@ -389,7 +393,7 @@ Deno.serve(async (request) => {
|
|||||||
const elapsed = Math.round(performance.now() - startTime);
|
const elapsed = Math.round(performance.now() - startTime);
|
||||||
const status = cacheResult.stale ? "refresh" : "hit";
|
const status = cacheResult.stale ? "refresh" : "hit";
|
||||||
console.log(
|
console.log(
|
||||||
`${PREFIX} PDF disk cache ${status} in ${elapsed}ms, token created`,
|
`${PREFIX} ${reqTag} disk cache ${status} in ${elapsed}ms, token created`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
@@ -449,7 +453,7 @@ Deno.serve(async (request) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- PDF.js library assets ----
|
// ---- PDF.js library assets ----
|
||||||
if (url.pathname === "/pdfjs/pdf.min.mjs") {
|
if (url.pathname === "/pdfjs/legacy/pdf.min.mjs") {
|
||||||
return new Response(PDFJS_LIB_TEXT, {
|
return new Response(PDFJS_LIB_TEXT, {
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "text/javascript; charset=utf-8",
|
"content-type": "text/javascript; charset=utf-8",
|
||||||
@@ -458,7 +462,7 @@ Deno.serve(async (request) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/pdfjs/pdf.worker.min.mjs") {
|
if (url.pathname === "/pdfjs/legacy/pdf.worker.min.mjs") {
|
||||||
return new Response(PDFJS_WORKER_TEXT, {
|
return new Response(PDFJS_WORKER_TEXT, {
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "text/javascript; charset=utf-8",
|
"content-type": "text/javascript; charset=utf-8",
|
||||||
|
|||||||
3799
session-ses_0984.md
3799
session-ses_0984.md
File diff suppressed because it is too large
Load Diff
27
web/app.js
27
web/app.js
@@ -2,6 +2,7 @@ let state;
|
|||||||
let groups = [];
|
let groups = [];
|
||||||
let selectedIndex = -1;
|
let selectedIndex = -1;
|
||||||
let pdfViewer = null;
|
let pdfViewer = null;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const people = document.querySelector("#people");
|
const people = document.querySelector("#people");
|
||||||
const fields = document.querySelector("#fields");
|
const fields = document.querySelector("#fields");
|
||||||
@@ -112,6 +113,14 @@ function renderList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPdf(index) {
|
async function loadPdf(index) {
|
||||||
|
const doc = state.documents[index];
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
|
// Deduplicate: don't reload the same document
|
||||||
|
if (index === selectedIndex && pdfViewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pdfViewer) {
|
if (pdfViewer) {
|
||||||
pdfViewer.destroy();
|
pdfViewer.destroy();
|
||||||
}
|
}
|
||||||
@@ -120,12 +129,18 @@ async function loadPdf(index) {
|
|||||||
pdfMessage.hidden = false;
|
pdfMessage.hidden = false;
|
||||||
pdfMessage.textContent = "Loading PDF\u2026";
|
pdfMessage.textContent = "Loading PDF\u2026";
|
||||||
|
|
||||||
|
const reqId = "pdf-" + (++loadRequestId);
|
||||||
|
console.log(
|
||||||
|
`[BizMatch QC] PDF ${reqId} selection: ${doc._letter}/${doc.file_name}`,
|
||||||
|
);
|
||||||
|
console.log(`[BizMatch QC] PDF ${reqId} prepare requested`);
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/pdf/prepare", {
|
const response = await fetch("/api/pdf/prepare", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ index }),
|
body: JSON.stringify({ index, requestId: reqId }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
@@ -134,13 +149,21 @@ async function loadPdf(index) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const prepareMs = Math.round(performance.now() - startTime);
|
const prepareMs = Math.round(performance.now() - startTime);
|
||||||
console.log(
|
console.log(
|
||||||
`[BizMatch QC] PDF prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
|
`[BizMatch QC] PDF ${reqId} prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (index !== selectedIndex) {
|
||||||
|
// A newer selection happened while preparing, don't show stale result
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
viewer.classList.add("loaded");
|
viewer.classList.add("loaded");
|
||||||
pdfMessage.hidden = true;
|
pdfMessage.hidden = true;
|
||||||
|
console.log(`[BizMatch QC] PDF ${reqId} viewer load started`);
|
||||||
await pdfViewer.load(data.url, data.size);
|
await pdfViewer.load(data.url, data.size);
|
||||||
|
console.log(`[BizMatch QC] PDF ${reqId} rendering complete`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (index !== selectedIndex) return;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
||||||
pdfMessage.hidden = false;
|
pdfMessage.hidden = false;
|
||||||
|
|||||||
@@ -6,8 +6,13 @@
|
|||||||
<title>BizMatch QC</title>
|
<title>BizMatch QC</title>
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import * as pdfjsLib from "/pdfjs/pdf.min.mjs";
|
console.log(
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/pdf.worker.min.mjs";
|
"[BizMatch QC] Map.getOrInsertComputed supported:",
|
||||||
|
typeof Map.prototype.getOrInsertComputed === "function",
|
||||||
|
);
|
||||||
|
console.log("[BizMatch QC] PDF.js frontend build: legacy");
|
||||||
|
import * as pdfjsLib from "/pdfjs/legacy/pdf.min.mjs";
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/legacy/pdf.worker.min.mjs";
|
||||||
globalThis.pdfjsLib = pdfjsLib;
|
globalThis.pdfjsLib = pdfjsLib;
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/pdf_viewer.js"></script>
|
<script type="module" src="/pdf_viewer.js"></script>
|
||||||
|
|||||||
@@ -1,287 +1,262 @@
|
|||||||
// PDF.js viewer with memory LRU cache and lazy page rendering.
|
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
|
||||||
// Depends on global pdfjsLib (loaded via module import in index.html).
|
// Depends on global pdfjsLib (loaded 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 {
|
class PdfViewer {
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.lru = [];
|
this.generation = 0;
|
||||||
this.currentEntry = null;
|
this.loadingTask = null;
|
||||||
this.renderTasks = [];
|
this.doc = null;
|
||||||
this.observer = null;
|
this.renderTasks = new Set();
|
||||||
this.currentDocId = 0;
|
this.lastUrl = null;
|
||||||
this.loadingDiv = null;
|
this.lastSize = null;
|
||||||
this.errorDiv = null;
|
|
||||||
this.pagesDiv = null;
|
|
||||||
this._createDOM();
|
this._createDOM();
|
||||||
}
|
}
|
||||||
|
|
||||||
_createDOM() {
|
_createDOM() {
|
||||||
|
// The pages container is always visible (never hidden).
|
||||||
|
// Loading and error states are overlays on top.
|
||||||
this.container.innerHTML = `
|
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>
|
<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.loadingDiv = this.container.querySelector(".pdf-v-loading");
|
||||||
this.errorDiv = this.container.querySelector(".pdf-v-error");
|
this.errorDiv = this.container.querySelector(".pdf-v-error");
|
||||||
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_showError(msg) {
|
_setState(state) {
|
||||||
this.loadingDiv.hidden = true;
|
this.loadingDiv.hidden = true;
|
||||||
this.errorDiv.textContent = msg;
|
|
||||||
this.errorDiv.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_showLoading() {
|
|
||||||
this.errorDiv.hidden = true;
|
this.errorDiv.hidden = true;
|
||||||
this.loadingDiv.hidden = false;
|
if (state === "loading") this.loadingDiv.hidden = false;
|
||||||
}
|
if (state === "error") this.errorDiv.hidden = false;
|
||||||
|
this._viewerState = state;
|
||||||
_hideLoading() {
|
|
||||||
this.loadingDiv.hidden = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(url, byteSize) {
|
async load(url, byteSize) {
|
||||||
const docId = ++this.currentDocId;
|
this.lastUrl = url;
|
||||||
this._showLoading();
|
this.lastSize = byteSize;
|
||||||
this._cancelAllRenders();
|
this._setState("loading");
|
||||||
if (this.observer) this.observer.disconnect();
|
await this._loadInternal(url, byteSize);
|
||||||
|
}
|
||||||
|
|
||||||
// Check memory LRU
|
async _loadInternal(url, _byteSize) {
|
||||||
const cached = this.lru.find((e) => e.url === url);
|
const generation = ++this.generation;
|
||||||
if (cached) {
|
console.log(
|
||||||
cached.lastUsed = Date.now();
|
`[BizMatch QC] PDF viewer load started: generation ${generation}`,
|
||||||
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();
|
// 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 {
|
try {
|
||||||
const task = pdfjsLib.getDocument({ url, rangeChunkSize: 65536 });
|
const task = pdfjsLib.getDocument({
|
||||||
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,
|
url,
|
||||||
doc,
|
rangeChunkSize: 65536,
|
||||||
byteSize: byteSize || 0,
|
disableAutoFetch: false,
|
||||||
pageCount,
|
});
|
||||||
lastUsed: Date.now(),
|
this.loadingTask = task;
|
||||||
};
|
|
||||||
|
|
||||||
this._evictIfNeeded(entry.byteSize);
|
const doc = await task.promise;
|
||||||
this.lru.push(entry);
|
if (generation !== this.generation) {
|
||||||
if (docId !== this.currentDocId) {
|
|
||||||
doc.destroy();
|
doc.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentEntry = entry;
|
this.doc = doc;
|
||||||
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) {
|
const numPages = doc.numPages;
|
||||||
let totalBytes = incomingBytes;
|
const loadMs = Math.round(performance.now() - loadStart);
|
||||||
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(
|
console.log(
|
||||||
"[BizMatch QC] PDF memory cache evicted: " +
|
`[BizMatch QC] PDF document loaded: ${numPages} pages in ${loadMs}ms`,
|
||||||
victim.url.split("/").pop(),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_cancelAllRenders() {
|
const viewerWidth = this._getViewportWidth();
|
||||||
for (const t of this.renderTasks) {
|
console.log(`[BizMatch QC] PDF usable render width: ${viewerWidth}px`);
|
||||||
t.cancelled = true;
|
|
||||||
}
|
|
||||||
this.renderTasks = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async _renderDoc(docId, entry, startLoad = 0) {
|
const pageStart = performance.now();
|
||||||
this.pagesDiv.innerHTML = "";
|
await this._renderPage(doc, 1, viewerWidth, generation);
|
||||||
this._hideLoading();
|
if (generation !== this.generation) return;
|
||||||
|
|
||||||
const pageCount = entry.pageCount;
|
this._setState(null); // Remove loading overlay
|
||||||
const placeholderStyle = "pdf-v-placeholder";
|
const firstPageMs = Math.round(performance.now() - pageStart);
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
console.log(
|
||||||
const ph = document.createElement("div");
|
`[BizMatch QC] PDF page 1 rendered in ${firstPageMs}ms`,
|
||||||
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) {
|
console.log(
|
||||||
// Will be sized in CSS via aspect-ratio; set data attrs for CSS
|
`[BizMatch QC] First page visible after ${
|
||||||
ph.dataset.ratio = String(ratio);
|
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));
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Render first page immediately
|
const totalMs = Math.round(performance.now() - loadStart);
|
||||||
this._renderPage(docId, entry, 1);
|
console.log(
|
||||||
|
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
|
||||||
// Set up lazy rendering
|
);
|
||||||
this.observer = new IntersectionObserver(
|
console.log(
|
||||||
(entries) => {
|
`[BizMatch QC] PDF fully rendered: ${numPages} pages in ${totalMs}ms`,
|
||||||
for (const e of entries) {
|
);
|
||||||
if (e.isIntersecting) {
|
} catch (err) {
|
||||||
const pageNum = parseInt(e.target.dataset.page);
|
if (generation !== this.generation) return;
|
||||||
if (pageNum) {
|
// Distinguish cancellation from real errors
|
||||||
this._renderPage(docId, entry, pageNum);
|
if (isCancellationError(err)) {
|
||||||
// Pre-render nearby pages
|
console.log(
|
||||||
for (
|
`[BizMatch QC] PDF generation ${generation} cancelled`,
|
||||||
let p = pageNum - PRELOAD_PAGES_BEHIND;
|
);
|
||||||
p <= pageNum + PRELOAD_PAGES_AHEAD;
|
return;
|
||||||
p++
|
}
|
||||||
) {
|
const msg = errorMsg(err);
|
||||||
if (p >= 1 && p <= pageCount && p !== pageNum) {
|
console.error(`[BizMatch QC] PDF load failed:`, msg);
|
||||||
this._renderPage(docId, entry, p);
|
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
|
||||||
}
|
this._setState("error");
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ 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) {
|
_getViewportWidth() {
|
||||||
// Don't re-render if already rendered
|
const style = globalThis.getComputedStyle
|
||||||
const existing = this.pagesDiv.querySelector(
|
? getComputedStyle(this.pagesDiv)
|
||||||
`.pdf-v-page[data-page="${pageNum}"]`,
|
: null;
|
||||||
);
|
let padding = 24; // default guess
|
||||||
if (existing) return;
|
if (style) {
|
||||||
if (docId !== this.currentDocId) return;
|
padding = (parseFloat(style.paddingLeft) || 0) +
|
||||||
|
(parseFloat(style.paddingRight) || 0);
|
||||||
|
}
|
||||||
|
const raw = Math.round(this.pagesDiv.clientWidth || 600);
|
||||||
|
return Math.max(raw - padding, 200);
|
||||||
|
}
|
||||||
|
|
||||||
// Don't re-render if cancelled
|
async _disposeCurrentDocument() {
|
||||||
const existingTask = this.renderTasks.find((t) => t.pageNum === pageNum);
|
for (const rt of this.renderTasks) {
|
||||||
if (existingTask) return;
|
try {
|
||||||
|
rt.cancel();
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}
|
||||||
|
this.renderTasks.clear();
|
||||||
|
|
||||||
const placeholder = this.pagesDiv.querySelector(
|
if (this.loadingTask) {
|
||||||
`.pdf-v-placeholder[data-page="${pageNum}"]`,
|
try {
|
||||||
);
|
await this.loadingTask.destroy();
|
||||||
if (!placeholder) return;
|
} catch { /* ok */ }
|
||||||
|
this.loadingTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
const renderTask = { pageNum, cancelled: false, promise: null };
|
if (this.doc) {
|
||||||
this.renderTasks.push(renderTask);
|
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";
|
||||||
|
|
||||||
const startRender = performance.now();
|
|
||||||
try {
|
try {
|
||||||
const page = await entry.doc.getPage(pageNum);
|
const page = await doc.getPage(pageNum);
|
||||||
if (renderTask.cancelled || docId !== this.currentDocId) return;
|
if (generation !== this.generation) return;
|
||||||
|
|
||||||
const containerWidth = this.pagesDiv.clientWidth || 600;
|
|
||||||
const vp1 = page.getViewport({ scale: 1 });
|
const vp1 = page.getViewport({ scale: 1 });
|
||||||
const fitScale = containerWidth / vp1.width;
|
const fitScale = viewerWidth / 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 viewport = page.getViewport({ scale: fitScale });
|
||||||
|
|
||||||
const pageDiv = document.createElement("div");
|
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||||
pageDiv.className = "pdf-v-page";
|
canvas.width = Math.floor(viewport.width * dpr);
|
||||||
pageDiv.dataset.page = String(pageNum);
|
canvas.height = Math.floor(viewport.height * dpr);
|
||||||
pageDiv.style.width = `${viewport.width}px`;
|
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||||
pageDiv.style.height = `${viewport.height}px`;
|
canvas.style.height = `${Math.floor(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");
|
const ctx = canvas.getContext("2d");
|
||||||
if (ctx && !renderTask.cancelled && docId === this.currentDocId) {
|
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
|
||||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
||||||
if (pageNum === 1) {
|
const renderTask = page.render({
|
||||||
const renderMs = Math.round(performance.now() - startRender);
|
canvasContext: ctx,
|
||||||
console.log(
|
viewport,
|
||||||
`[BizMatch QC] First page rendered: ${renderMs}ms`,
|
transform,
|
||||||
);
|
});
|
||||||
}
|
this.renderTasks.add(renderTask);
|
||||||
}
|
|
||||||
|
await renderTask.promise;
|
||||||
|
this.renderTasks.delete(renderTask);
|
||||||
|
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
pageDiv.dataset.renderState = "rendered";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (renderTask.cancelled) return;
|
if (generation !== this.generation) return;
|
||||||
placeholder.textContent = `Error rendering page ${pageNum}: ${
|
if (isCancellationError(err)) return;
|
||||||
errorMsg(err)
|
pageDiv.dataset.renderState = "error";
|
||||||
}`;
|
console.error(
|
||||||
} finally {
|
`[BizMatch QC] Failed to render PDF page ${pageNum}`,
|
||||||
this.renderTasks = this.renderTasks.filter((t) => t !== renderTask);
|
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() {
|
destroy() {
|
||||||
this._cancelAllRenders();
|
this.generation = 0;
|
||||||
if (this.observer) this.observer.disconnect();
|
this._disposeCurrentDocument().catch(() => {});
|
||||||
this._evictAll();
|
this.pagesDiv.innerHTML = "";
|
||||||
|
this._setState("loading");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_evictAll() {
|
function isCancellationError(err) {
|
||||||
for (const entry of this.lru) {
|
if (!(err instanceof Error)) return false;
|
||||||
try {
|
const msg = err.message || "";
|
||||||
entry.doc.destroy();
|
return msg.includes("cancelled") ||
|
||||||
} catch { /* ok */ }
|
msg.includes("cancelled") ||
|
||||||
}
|
msg.includes("destroyed") ||
|
||||||
this.lru = [];
|
msg.includes("Worker was destroyed");
|
||||||
this.currentEntry = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMsg(err) {
|
function errorMsg(err) {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export global
|
|
||||||
globalThis.PdfViewer = PdfViewer;
|
globalThis.PdfViewer = PdfViewer;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ aside,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.viewer #pdfViewer {
|
.viewer #pdfViewer {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -190,6 +191,11 @@ hr.field-sep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* PDF.js viewer */
|
/* PDF.js viewer */
|
||||||
|
.pdf-v-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
.pdf-v-loading {
|
.pdf-v-loading {
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
@@ -198,29 +204,44 @@ hr.field-sep {
|
|||||||
}
|
}
|
||||||
.pdf-v-error {
|
.pdf-v-error {
|
||||||
padding: 30px 20px;
|
padding: 30px 20px;
|
||||||
color: #f88;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pdf-v-error-msg {
|
||||||
|
color: #f88;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
.pdf-v-retry {
|
||||||
|
padding: 8px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #888;
|
||||||
|
background: #444;
|
||||||
|
color: #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pdf-v-retry:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
.pdf-v-pages {
|
.pdf-v-pages {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 16px;
|
||||||
padding: 10px 0;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.pdf-v-page {
|
.pdf-v-page {
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.pdf-v-placeholder {
|
.pdf-v-page canvas {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.pdf-v-page canvas,
|
|
||||||
.pdf-v-placeholder canvas {
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user