pdf.js
This commit is contained in:
59
web/app.js
59
web/app.js
@@ -1,12 +1,12 @@
|
||||
let state;
|
||||
let groups = [];
|
||||
let selectedIndex = -1;
|
||||
let currentPdfUrl = "";
|
||||
let pdfViewer = null;
|
||||
|
||||
const people = document.querySelector("#people");
|
||||
const fields = document.querySelector("#fields");
|
||||
const pdf = document.querySelector("#pdf");
|
||||
const viewer = document.querySelector(".viewer");
|
||||
const pdfContainer = document.querySelector("#pdfViewer");
|
||||
const pdfMessage = document.querySelector("#pdfMessage");
|
||||
const status = document.querySelector("#status");
|
||||
const search = document.querySelector("#search");
|
||||
@@ -23,6 +23,7 @@ const fieldDefs = [
|
||||
null,
|
||||
["Address", "address"],
|
||||
["State", "state"],
|
||||
null,
|
||||
["Businesses from Notes", "notes_business_raw"],
|
||||
["Types of Businesses", "types_of_business_raw"],
|
||||
["Background Experience", "background_experience"],
|
||||
@@ -111,30 +112,34 @@ function renderList() {
|
||||
}
|
||||
|
||||
async function loadPdf(index) {
|
||||
if (currentPdfUrl) {
|
||||
URL.revokeObjectURL(currentPdfUrl);
|
||||
currentPdfUrl = "";
|
||||
if (pdfViewer) {
|
||||
pdfViewer.destroy();
|
||||
}
|
||||
pdf.removeAttribute("src");
|
||||
pdfViewer = new PdfViewer(pdfContainer);
|
||||
viewer.classList.remove("loaded");
|
||||
pdfMessage.hidden = false;
|
||||
pdfMessage.textContent = "Loading PDF\u2026";
|
||||
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const response = await fetch(`/api/pdf?index=${index}`, {
|
||||
cache: "no-store",
|
||||
const response = await fetch("/api/pdf/prepare", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ index }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
body.error || `PDF request failed with HTTP ${response.status}.`,
|
||||
);
|
||||
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
currentPdfUrl = URL.createObjectURL(blob);
|
||||
pdf.src = currentPdfUrl;
|
||||
const data = await response.json();
|
||||
const prepareMs = Math.round(performance.now() - startTime);
|
||||
console.log(
|
||||
`[BizMatch QC] PDF prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
|
||||
);
|
||||
|
||||
viewer.classList.add("loaded");
|
||||
pdfMessage.hidden = true;
|
||||
await pdfViewer.load(data.url, data.size);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pdfMessage.textContent = `Cannot open PDF: ${message}`;
|
||||
@@ -222,7 +227,6 @@ saveSettings.onclick = async (event) => {
|
||||
event.preventDefault();
|
||||
saveSettings.disabled = true;
|
||||
settingsError.hidden = true;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/config", {
|
||||
method: "POST",
|
||||
@@ -247,31 +251,6 @@ saveSettings.onclick = async (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
let resizeTimer;
|
||||
globalThis.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
fetch("/api/window-size", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
width: globalThis.innerWidth,
|
||||
height: globalThis.innerHeight,
|
||||
}),
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
globalThis.addEventListener("beforeunload", () => {
|
||||
navigator.sendBeacon(
|
||||
"/api/window-size",
|
||||
JSON.stringify({
|
||||
width: globalThis.innerWidth,
|
||||
height: globalThis.innerHeight,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch("/api/state", { cache: "no-store" });
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>BizMatch QC</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script type="module">
|
||||
import * as pdfjsLib from "/pdfjs/pdf.min.mjs";
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/pdf.worker.min.mjs";
|
||||
globalThis.pdfjsLib = pdfjsLib;
|
||||
</script>
|
||||
<script type="module" src="/pdf_viewer.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -22,7 +28,7 @@
|
||||
<div id="fields"></div>
|
||||
</section>
|
||||
<section class="viewer">
|
||||
<iframe id="pdf" title="PDF document"></iframe>
|
||||
<div id="pdfViewer"></div>
|
||||
<div id="pdfMessage">Select a document</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -30,15 +36,15 @@
|
||||
<form method="dialog">
|
||||
<h2>Settings</h2>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="useAnonData">
|
||||
Use anonymized sample data
|
||||
</label>
|
||||
<input type="checkbox" id="useAnonData">
|
||||
Use anonymized sample data
|
||||
</label>
|
||||
<label>buyers_vision.json
|
||||
<input id="jsonPath" autocomplete="off">
|
||||
</label>
|
||||
<input id="jsonPath" autocomplete="off">
|
||||
</label>
|
||||
<label>PDF base directory
|
||||
<input id="pdfBase" autocomplete="off">
|
||||
</label>
|
||||
<input id="pdfBase" autocomplete="off">
|
||||
</label>
|
||||
<div id="settingsError" class="dialog-error" hidden></div>
|
||||
<div class="actions">
|
||||
<button value="cancel">Cancel</button>
|
||||
|
||||
287
web/pdf_viewer.js
Normal file
287
web/pdf_viewer.js
Normal file
@@ -0,0 +1,287 @@
|
||||
// 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;
|
||||
@@ -3,7 +3,7 @@
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px system-ui,sans-serif;
|
||||
font: 14px system-ui, sans-serif;
|
||||
color: #202124;
|
||||
}
|
||||
header {
|
||||
@@ -29,7 +29,7 @@ header span {
|
||||
main {
|
||||
height: calc(100vh - 52px);
|
||||
display: grid;
|
||||
grid-template-columns: 330px 430px minmax(500px,1fr);
|
||||
grid-template-columns: 330px 430px minmax(500px, 1fr);
|
||||
}
|
||||
aside,
|
||||
.details {
|
||||
@@ -77,12 +77,14 @@ aside,
|
||||
.viewer {
|
||||
position: relative;
|
||||
background: #555;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewer iframe {
|
||||
.viewer #pdfViewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: white;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #525659;
|
||||
}
|
||||
.viewer #pdfMessage {
|
||||
position: absolute;
|
||||
@@ -91,12 +93,13 @@ aside,
|
||||
place-items: center;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.viewer.loaded #pdfMessage {
|
||||
display: none;
|
||||
}
|
||||
dialog {
|
||||
width: min(720px,90vw);
|
||||
width: min(720px, 90vw);
|
||||
}
|
||||
dialog label {
|
||||
display: block;
|
||||
@@ -185,3 +188,39 @@ hr.field-sep {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* PDF.js viewer */
|
||||
.pdf-v-loading {
|
||||
padding: 40px 20px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
.pdf-v-error {
|
||||
padding: 30px 20px;
|
||||
color: #f88;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.pdf-v-pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.pdf-v-page {
|
||||
background: white;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdf-v-placeholder {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdf-v-page canvas,
|
||||
.pdf-v-placeholder canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user