This commit is contained in:
2026-07-15 18:27:24 -05:00
parent 741b38b4f6
commit 5c69c40453
6 changed files with 3338 additions and 979 deletions

18
main.ts
View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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%;
} }