Search / static /reader /pdf /reader-pdf-renderer.mjs
Reader automation
Stop refetching memory-evicted page images until demand changes
a729357
Raw History Blame Contribute Delete
35.9 kB
export function createReaderPdfRenderer(readerContext) {
function setPdfPageLoadingState(pageState, page) {
pageState.dataset.state = "loading";
pageState.setAttribute("role", "status");
pageState.setAttribute("aria-label", `正在加载第 ${page} 页`);
pageState.replaceChildren();
pageState.hidden = false;
}
function isPdfPageVisible(shell) {
const pageRect = shell?.getBoundingClientRect?.();
const viewportRect = readerContext.viewport?.getBoundingClientRect?.();
return !!pageRect && !!viewportRect && pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom;
}
function pdfPagePriority(shell) {
const pageRect = shell?.getBoundingClientRect?.();
const viewportRect = readerContext.viewport?.getBoundingClientRect?.();
if (!pageRect || !viewportRect) return 0;
if (pageRect.top >= viewportRect.top && pageRect.bottom <= viewportRect.bottom) return 3;
if (pageRect.bottom > viewportRect.top && pageRect.top < viewportRect.bottom) return 2;
return pageRect.bottom > viewportRect.top - readerContext.PDF_PREFETCH_MARGIN &&
pageRect.top < viewportRect.bottom + readerContext.PDF_PREFETCH_MARGIN ? 1 : 0;
}
function normalizePdfPriority(priority) {
return priority === true ? 3 : Math.max(0, Math.min(3, Number(priority) || 0));
}
function renderPdfInBackground(shell, force = false, priority = readerContext.pdfPagePriority(shell)) {
priority = readerContext.normalizePdfPriority(priority);
if (readerContext.readerAbortController.signal.aborted) return;
if (!force && shell.dataset.renderFailed === "1") return;
if (priority >= 2 && (force || shell.dataset.renderState !== "rendered") &&
readerContext.pdfActiveRenders >= (matchMedia("(max-width: 700px)").matches ? 1 : 2))
readerContext.cancelSpeculativePdfRenders(shell);
if (priority) {
shell.dataset.renderPriority = "1";
readerContext.promotePdfRenderWaiter(shell);
if (["pdf-pages", "image-pages"].includes(readerContext.capability.mode) && readerContext.pdfPageManifest)
readerContext.prioritizePdfManifestPrefetch(readerContext.pdfPageManifest.pageUrl(Number(shell.dataset.page)));
}
if (shell._backgroundRender) {
if (priority >= 2 && shell._pdfRenderCancelled) shell._resumePdfDemand = true;
// A pending hybrid page that just became visible re-evaluates at once
// instead of on its next polling tick.
if (priority >= 2) shell._hybridWake?.();
if (priority && ["pdf-pages", "image-pages"].includes(readerContext.capability.mode)) {
const image = shell.querySelector("img");
if (image) image.fetchPriority = priority >= 3 ? "high" : "auto";
}
if (force) {
shell.dataset.pendingRerender = "1";
if (readerContext.pdfHybridUrl) shell.dataset.pendingHybridRerender = "1";
}
return shell._backgroundRender;
}
const task =
["pdf-pages", "image-pages"].includes(readerContext.capability.mode)
? readerContext.renderPdfManifestShell(shell, force, priority)
: readerContext.renderPdfShell(shell, force, priority);
shell._backgroundRender = task;
task
.then(() => { delete shell.dataset.renderRetries; delete shell.dataset.renderFailed; })
.catch((error) => {
if (readerContext.readerAbortController.signal.aborted || error?.name === "AbortError") return;
// Hybrid preview cancellation after PDF canvas completion is not a user-visible failure
if (shell.dataset.pdfPresented === "1") return;
console.warn(`PDF page ${shell.dataset.page} render failed`, error);
const retries = Number(shell.dataset.renderRetries || 0);
const pageState = shell.querySelector(".reader-page-state");
if (pageState && !shell.querySelector("canvas.ready, img.ready")) {
pageState.dataset.state = "error";
pageState.removeAttribute("aria-label");
pageState.hidden = false;
pageState.textContent = retries < 3 ? "页面加载失败,正在重试..." : "页面加载失败";
}
if (retries < 3) {
shell.dataset.renderRetries = String(retries + 1);
readerContext.waitForReader(400 * (retries + 1))
.then(() => {
if (shell.isConnected) readerContext.renderPdfInBackground(shell);
})
.catch(() => {});
} else {
shell.dataset.renderFailed = "1";
if (pageState && !pageState.hidden) {
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "重试";
retry.title = "重试加载此页";
retry.addEventListener("click", () => {
delete shell.dataset.renderFailed;
delete shell.dataset.renderRetries;
readerContext.setPdfPageLoadingState(pageState, shell.dataset.page);
readerContext.renderPdfInBackground(shell, true, 3);
}, {once:true});
pageState.append(retry);
}
}
})
.finally(() => {
if (shell._backgroundRender === task) delete shell._backgroundRender;
delete shell._pdfRenderCancelled;
if (shell._resumePdfDemand || shell._pdfRenderNeedsRequeue) {
delete shell._resumePdfDemand;
if (!readerContext.readerAbortController.signal.aborted && shell.isConnected &&
readerContext.isPdfPageVisible(shell) && shell.dataset.renderState !== "rendered" &&
shell.dataset.renderFailed !== "1") {
delete shell._pdfRenderNeedsRequeue;
queueMicrotask(() => readerContext.renderPdfInBackground(shell, false, readerContext.pdfPagePriority(shell)));
}
}
});
return task;
}
function pdfPrimarySource() {
if (readerContext.capability.mode !== "pdf-pages") return "";
// A non-PDF (DjVu) primary keeps the image stream plus v3 text layer.
if (readerContext.pdfV3Manifest)
return readerContext.pdfV3Manifest.primary.kind === "pdf" ? readerContext.v3ResourceUrl(readerContext.pdfV3Manifest.primary.resource) : "";
const candidate = readerContext.normalizeSourceUrl(readerContext.params.get("pdf_document") || readerContext.resolvedReaderData?.pdf_document ||
(readerContext.resolvedReaderData?.original_extension === "pdf" || /\.pdf(?:$|[?#])/i.test(readerContext.downloadUrl)
? readerContext.downloadUrl : ""));
try {
const parsed = new URL(candidate, location.href);
const path = parsed.searchParams.get("path") || parsed.pathname;
return readerContext.validSource(parsed.href) && /\.pdf$/i.test(path) ? parsed.href : "";
} catch (_) { return ""; }
}
// Recent per-page latencies for this book: PDF.js admission-to-draw and
// page-image admission-to-decode. They choose the visible-page image grace.
const pdfLatencySamples = { pdf: [], preview: [] };
// When this book's first page image was shown (0: not yet). Lookahead before
// the primary PDF is ready starts from it.
let pdfOpeningShownAt = 0;
const PDF_HYBRID_LOOKAHEAD_DELAY_MS = 1000;
// Resets all per-book scheduling samples, including the opening timestamp.
function resetPdfLatencySamples() {
pdfLatencySamples.pdf.length = pdfLatencySamples.preview.length = 0;
pdfOpeningShownAt = 0;
}
function notePdfLatency(kind, milliseconds) {
const samples = pdfLatencySamples[kind];
if (!samples || !Number.isFinite(milliseconds) || milliseconds < 0) return;
samples.push(milliseconds);
if (samples.length > 12) samples.shift();
}
function pdfHybridPreviewGrace() {
// The image stream is a fallback once PDF.js is ready. Start it only when a
// draw is slower than this book's typical draw, not on every short wait:
// a duplicate image download competes with the PDF and swaps surfaces.
const median = (values, fallback) => {
if (!values.length) return fallback;
const sorted = [...values].sort((a, b) => a - b);
return sorted[sorted.length >> 1];
};
const pdf = median(pdfLatencySamples.pdf, 800), preview = median(pdfLatencySamples.preview, 2400);
// When this book's images are measurably faster than its PDF draws (small
// v3 previews, very heavy pages), keep the short fallback grace.
if (pdfLatencySamples.pdf.length >= 2 && pdfLatencySamples.preview.length >= 2 && preview < pdf * .8) return 150;
return Math.round(Math.max(400, Math.min(3000, pdf * 2)));
}
// Applies a page-geometry change (aspect ratio, surface swap) while keeping
// the reading anchor at the same visual position.
function applyPdfShellGeometry(change) {
const anchor = readerContext.content.querySelector(`.reader-page[data-page="${readerContext.documentState.page}"]`);
const viewportTop = readerContext.viewport.getBoundingClientRect().top;
const before = anchor?.getBoundingClientRect();
const atEnd = readerContext.viewport.scrollTop > 0 &&
readerContext.viewport.scrollHeight - readerContext.viewport.clientHeight - readerContext.viewport.scrollTop <= 2;
const fraction = before ? Math.max(0, (viewportTop - before.top) / Math.max(1, before.height)) : 0;
change();
if (!before || !anchor.isConnected) return;
if (atEnd) {
readerContext.viewport.scrollTop = readerContext.viewport.scrollHeight - readerContext.viewport.clientHeight;
return;
}
const after = anchor.getBoundingClientRect();
const delta = after.top - before.top + fraction * (after.height - before.height);
if (Math.abs(delta) >= .5) readerContext.viewport.scrollTop += delta;
}
// Reveals a complete page surface. Like the native PDF canvas, a page that
// is on screen and was blank fades in; offscreen pages, surface swaps over an
// already shown page and reduced-motion clients switch instantly.
function revealPdfSurface(shell, surface, fade) {
surface.classList.add("ready");
if (fade && readerContext.capability?.mode === "image-pages")
surface.animate?.([{ opacity: 0 }, { opacity: 1 }], { duration: 120, easing: "ease" });
}
function pdfSurfaceFades(shell) {
return !shell.querySelector("canvas.ready, img.ready") && readerContext.isPdfPageVisible(shell) &&
!globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
}
// Waits until a loaded page image is decoded, so its first painted frame is
// the complete page rather than rows arriving from the top. Bounded: a
// hidden (parked) Reader may defer decoding without blocking the page.
async function decodePdfImage(image, signal) {
if (typeof image.decode !== "function") return;
let timer = 0;
try {
await readerContext.awaitReader(Promise.race([
image.decode().catch(() => {}),
new Promise(resolve => { timer = setTimeout(resolve, 2000); })
]), signal);
} finally {
clearTimeout(timer);
}
}
function notePdfOpeningShown(at = performance.now()) {
pdfOpeningShownAt ||= at;
}
// Before a hybrid book's primary PDF is ready, page images are its only
// surface. Once the opening page is shown and the primary is still pending,
// the next pages in the reading direction (and the previous one) prepare
// their images ahead of visibility. Other pages keep waiting for the PDF.
function pdfHybridLookahead(shell) {
if (readerContext.pdfDocument || !pdfOpeningShownAt ||
performance.now() - pdfOpeningShownAt < PDF_HYBRID_LOOKAHEAD_DELAY_MS) return false;
const delta = (Number(shell.dataset.page) - readerContext.documentState.page) * (readerContext.pdfScrollDirection || 1);
return delta >= -1 && delta <= 2;
}
function renderPdfManifestShell(shell, force = false, priority = false) {
// Pages reached after the opening show the quiet delayed indicator while
// their surface is pending, like native PDF pages. The opening page keeps
// its hidden state.
const pageState = shell.querySelector(".reader-page-state");
if (pageState?.hidden && readerContext.documentState.restorationReady && !shell.querySelector("canvas.ready, img.ready"))
readerContext.setPdfPageLoadingState(pageState, shell.dataset.page);
if (!readerContext.pdfHybridUrl) return readerContext.renderPdfImageShell(shell, force, priority);
if (!force && shell.querySelector("canvas.ready")) return Promise.resolve();
if (shell._hybridRenderPromise) {
if (force) shell.dataset.pendingHybridRerender = "1";
return shell._hybridReadyPromise;
}
const controller = new AbortController();
shell._hybridCancel = () => { controller.abort(); shell._renderCancel?.(); };
// Visible demand wakes the polling waits below immediately.
let wake = null;
shell._hybridWake = () => { const resolve = wake; wake = null; resolve?.(); };
const pause = (ms) => new Promise((resolve, reject) => {
wake = resolve;
readerContext.waitForReader(ms).then(resolve, reject);
});
const pdfTask = readerContext.renderHybridPdfCanvas(shell, controller.signal, priority);
async function waitForPrimaryDocument() {
const pending = readerContext.pdfHybridDocumentPromise;
if (!pending) return false;
const documentReady = pending.then(() => true, () => false);
const deadline = performance.now() + readerContext.PDF_HYBRID_DOCUMENT_GRACE_MS;
while (!controller.signal.aborted && !readerContext.readerAbortController.signal.aborted) {
if (readerContext.pdfDocument) return true;
// A page promoted from speculation to visible demand should get its
// image fallback immediately instead of waiting out the grace period.
if (readerContext.isPdfPageVisible(shell) || readerContext.pdfHybridLookahead(shell)) return false;
const remaining = deadline - performance.now();
if (remaining <= 0) return false;
const result = await Promise.race([documentReady, pause(Math.min(100, remaining)).then(() => null)]);
if (result !== null) return result;
}
throw readerContext.readerAbortError();
}
// The page's live priority: a speculative task may have become visible.
const livePriority = () => Math.max(readerContext.normalizePdfPriority(priority), readerContext.pdfPagePriority(shell));
const previewTask = (async () => {
// Keep the visible page's fallback immediate; only speculative pages
// should wait for the primary-PDF grace period.
if (readerContext.isPdfPageVisible(shell) && !readerContext.pdfDocument)
return readerContext.renderPdfImageShell(shell, false, livePriority());
// Give a warm primary PDF a short chance to win before starting preview
// I/O. If drawing is slower, the image stream remains the fallback.
const documentReady = await waitForPrimaryDocument();
if (!documentReady) return readerContext.renderPdfImageShell(shell, false, livePriority());
// Keep speculation PDF-only, but do not permanently give up this page's
// fallback: scrolling may promote it while its getPage/draw is still slow.
let pdfSettled = false;
pdfTask.then(() => { pdfSettled = true; }, () => { pdfSettled = true; });
const pdfOutcome = pdfTask.then(() => "pdf", () => "preview");
while (true) {
while (!readerContext.isPdfPageVisible(shell) && !pdfSettled) {
if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
await pause(100);
}
if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
// The grace counts from visibility. A page flown past during fast
// scrolling therefore starts no image download of its own.
const winner = await Promise.race([
pdfOutcome,
readerContext.waitForReader(readerContext.pdfHybridPreviewGrace()).then(() => "preview")
]);
if (winner === "pdf") return pdfTask;
if (controller.signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
if (shell.querySelector("canvas.ready") || shell.dataset.pdfPresented === "1") return pdfTask;
if (pdfSettled || readerContext.isPdfPageVisible(shell)) break;
}
const preview = await readerContext.renderPdfImageShell(shell, false, livePriority());
if (controller.signal.aborted) throw readerContext.readerAbortError();
return preview;
})();
const ready = Promise.any([pdfTask, previewTask]).catch((error) => {
if (controller.signal.aborted || readerContext.readerAbortController.signal.aborted) throw readerContext.readerAbortError();
throw error.errors?.[1] || error.errors?.[0] || error;
});
shell._hybridReadyPromise = ready;
const complete = Promise.allSettled([pdfTask, previewTask]).finally(() => {
if (shell._hybridRenderPromise === complete) {
delete shell._hybridRenderPromise;
delete shell._hybridReadyPromise;
delete shell._hybridCancel;
delete shell._hybridWake;
if (shell.dataset.pendingHybridRerender && shell.isConnected && !readerContext.readerLifecycle.disposed) {
delete shell.dataset.pendingHybridRerender;
readerContext.renderPdfInBackground(shell, true);
}
}
});
shell._hybridRenderPromise = complete;
return ready;
}
async function renderHybridPdfCanvas(shell, signal, priority) {
let acquired = false, canvas = null, rendering = null;
const untrack = readerContext.trackReaderResource(() => { rendering?.cancel?.(); shell._hybridCancel?.(); });
const cancel = () => rendering?.cancel?.();
signal.addEventListener("abort", cancel, { once: true });
try {
const pdf = await readerContext.awaitReader(readerContext.pdfHybridDocumentPromise, signal);
let admittedAt = 0;
do {
const generation = readerContext.readerRuntime.currentGeneration("pdf");
if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
if (!acquired) {
// Admit page reads too: getPage can demand ranges before canvas rendering.
await readerContext.measurePdf(readerContext.acquirePdfRenderSlot(priority, shell, signal), "queue", Number(shell.dataset.page));
acquired = true;
admittedAt = performance.now();
shell._pdfRenderOwnsSlot = true;
}
if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
const page = await readerContext.measurePdf(readerContext.awaitReader(pdf.getPage(Number(shell.dataset.page)), signal), "page", Number(shell.dataset.page));
if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
const base = page.getViewport({ scale: 1 });
if (readerContext.pdfV3Map) {
const geometry = readerContext.pdfV3Map.pages[Number(shell.dataset.page) - 1];
if (Math.abs((base.width / base.height) / (geometry.width / geometry.height) - 1) > .01)
throw new Error("PDF_V3_DOCUMENT_GEOMETRY_MISMATCH");
}
const view = page.getViewport({ scale: Math.min(3, shell.clientWidth / base.width) });
const outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1));
canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.width = Math.ceil(view.width * outputScale);
canvas.height = Math.ceil(view.height * outputScale);
rendering = page.render({ canvasContext: canvas.getContext("2d"), viewport: view,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0] });
await readerContext.measurePdf(readerContext.awaitReader(rendering.promise, signal), "draw", Number(shell.dataset.page));
rendering = null;
while (!readerContext.pdfV3Manifest && readerContext.pdfSelectionIntersects(shell) && !signal.aborted) await readerContext.waitForReader(100);
if (signal.aborted || !shell.isConnected) throw readerContext.readerAbortError();
if (!readerContext.isReaderGenerationCurrent("pdf", generation)) {
canvas.width = canvas.height = 0;
continue;
}
const ready = canvas;
// A canvas replacing a shown image switches instantly; on a blank
// visible page it fades in like a native PDF page.
const fade = pdfSurfaceFades(shell);
readerContext.applyPdfShellGeometry(() => {
const previous = shell.querySelector("canvas");
if (previous) { previous.replaceWith(ready); previous.width = previous.height = 0; }
else shell.prepend(ready);
revealPdfSurface(shell, ready, fade);
shell.style.aspectRatio = `${base.width} / ${base.height}`;
shell.dataset.pageGeometry = "1";
shell._renderCancel?.();
shell.querySelector("img")?.remove();
shell._v3PreviewRelease?.();
readerContext.seedPdfPageGeometry?.(base.width, base.height);
});
if (admittedAt) readerContext.notePdfLatency("pdf", performance.now() - admittedAt);
const pageState = shell.querySelector(".reader-page-state");
if (pageState) pageState.hidden = true;
shell.dataset.pdfPresented = "1";
delete shell.dataset.pendingHybridRerender;
shell.dataset.renderState = "rendered";
delete shell.dataset.renderRetries;
delete shell.dataset.renderFailed;
shell.dataset.renderUsedAt = String(Date.now());
readerContext.pdfShellWindow?.remember(shell);
readerContext.trimPdfCanvases(shell);
(readerContext.ocrManifestUrl ? readerContext.renderPdfOcrText(shell) : readerContext.renderPdfText(page, shell, signal)).catch(() => {});
readerContext.scheduleMarkerSync();
canvas = null;
return;
} while (true);
} finally {
rendering?.cancel?.();
if (canvas) canvas.width = canvas.height = 0;
if (acquired) { delete shell._pdfRenderOwnsSlot; readerContext.releasePdfRenderSlot(); }
signal.removeEventListener("abort", cancel);
untrack();
}
}
function renderPdfImageShell(shell, force = false, priority = false) {
if (!shell) return Promise.reject(new Error("PDF page shell missing"));
if (readerContext.capability?.mode !== "image-pages" && readerContext.pdfV3Manifest?.primary?.kind === "pdf")
return Promise.reject(new Error("PDF_PRIMARY_REQUIRED"));
if (shell._renderPromise) return shell._renderPromise;
if (!force && shell.dataset.renderState === "rendered") return Promise.resolve();
priority = readerContext.normalizePdfPriority(priority || readerContext.pdfPagePriority(shell));
const evicted = shell._pdfImageEvictedAt;
if (!force && priority < 2 && evicted && !readerContext.isPdfPageVisible(shell) &&
evicted.page === readerContext.documentState.page && evicted.zoom === readerContext.documentState.zoom &&
evicted.direction === readerContext.pdfScrollDirection && evicted.budget === readerContext.pdfPixelBudget() &&
evicted.width === readerContext.viewport.clientWidth && evicted.height === readerContext.viewport.clientHeight)
return Promise.resolve();
delete shell._pdfImageEvictedAt;
shell.dataset.renderState = "rendering";
const renderController = new AbortController();
shell._renderCancel = () => renderController.abort();
shell._renderStarted = false;
shell._renderActivePriority = String(priority);
const task = (async () => {
let acquired = false;
try {
// Page images are network-bound, not canvas work: they use their own
// priority-ordered pool, where visible demand preempts speculation.
await readerContext.measurePdf(readerContext.acquirePdfPreviewSlot(priority, shell, renderController.signal), "queue", Number(shell.dataset.page));
acquired = true;
const admittedAt = performance.now();
shell._renderStarted = true;
readerContext.assertReaderActive();
// The page may have become visible while it waited for a slot.
priority = Math.max(priority, readerContext.pdfPagePriority(shell));
shell._renderActivePriority = String(priority);
const entry = readerContext.pdfPageEntry(Number(shell.dataset.page));
if (!entry) throw new Error("PDF page entry missing");
let target = readerContext.pdfPageManifest.pageUrl(entry.page);
let progressive = (readerContext.pdfPageManifest.pageCodec?.(entry.page) || readerContext.pdfPageManifest.codec) === "jxl";
if (readerContext.pdfV3Manifest) {
const preview = await readerContext.pdfV3Repository.preview(readerContext.pdfV3Manifest, readerContext.pdfV3Map, entry.page, renderController.signal);
shell._v3PreviewEntry = preview.entry;
readerContext.assertReaderActive();
if (renderController.signal.aborted) throw readerContext.readerAbortError();
shell._v3PreviewRelease?.();
progressive = preview.progressive === true;
if (progressive) target = preview.url;
else {
target = URL.createObjectURL(preview.blob);
const release = () => { URL.revokeObjectURL(target); readerContext.pdfV3BlobReleases.delete(release); delete shell._v3PreviewRelease; };
shell._v3PreviewRelease = release;
readerContext.pdfV3BlobReleases.add(release);
}
}
let loadText = null;
let image = shell.querySelector("img");
const pageState = shell.querySelector(".reader-page-state");
if (!image) {
image = readerContext.takePdfManifestPrefetch(target) || new Image();
image.alt = `第 ${entry.page} 页`;
image.decoding = "async";
shell.appendChild(image);
}
image.fetchPriority = priority >= 3 ? "high" : priority >= 1 ? "auto" : "low";
if (progressive) {
image.classList.add("progressive");
const geometry = readerContext.pdfV3Map?.pages[entry.page - 1];
if (geometry) readerContext.applyPdfShellGeometry(() => {
shell.style.aspectRatio = `${geometry.width} / ${geometry.height}`;
});
}
if (priority >= 2 && readerContext.ocrManifestUrl && !navigator.connection?.saveData)
loadText = readerContext.renderPdfOcrText(shell);
loadText?.catch(() => {});
await readerContext.imageLoader.load(image, target, renderController.signal);
readerContext.assertReaderActive();
if (shell.dataset.pdfPresented === "1") return;
if (readerContext.pdfV3Manifest) {
const geometry = readerContext.pdfV3Map.pages[entry.page - 1];
// Vector previews have fractional intrinsic sizes. The pipeline
// records math.ceil(width/height); browsers round naturalWidth.
const tolerance = shell._v3PreviewEntry.codec === "svg" ? 1 : 0;
if (Math.abs(image.naturalWidth - shell._v3PreviewEntry.width) > tolerance ||
Math.abs(image.naturalHeight - shell._v3PreviewEntry.height) > tolerance ||
Math.abs((image.naturalWidth / image.naturalHeight) / (geometry.width / geometry.height) - 1) > .01)
throw new Error("PDF_V3_PREVIEW_GEOMETRY_MISMATCH");
}
// Reveal only a fully decoded page, never one painting from the top.
if (!progressive) await decodePdfImage(image, renderController.signal);
readerContext.assertReaderActive();
if (shell.dataset.pdfPresented === "1") return;
readerContext.notePdfLatency("preview", performance.now() - admittedAt);
const fade = pdfSurfaceFades(shell);
readerContext.applyPdfShellGeometry(() => {
shell.style.aspectRatio = `${image.naturalWidth || 1} / ${image.naturalHeight || 1}`;
shell.dataset.pageGeometry = "1";
revealPdfSurface(shell, image, fade);
readerContext.seedPdfPageGeometry?.(image.naturalWidth, image.naturalHeight);
});
if (readerContext.pdfHybridUrl) notePdfOpeningShown();
// A speculative page may become visible while its image is loading.
// The visibility observer saw "rendering" then, so it could not attach
// text; do not keep the stale admission priority after image completion.
if ((priority >= 2 || readerContext.isPdfPageVisible(shell)) && readerContext.ocrManifestUrl && !navigator.connection?.saveData)
loadText ||= readerContext.renderPdfOcrText(shell);
loadText?.catch(() => {});
if (pageState) pageState.hidden = true;
shell.dataset.renderState = "rendered";
delete shell.dataset.renderRetries;
delete shell.dataset.renderFailed;
shell.dataset.renderUsedAt = String(Date.now());
readerContext.pdfShellWindow?.remember(shell);
if (readerContext.isPdfPageVisible(shell) || !readerContext.pdfManifestPrefetchOrigin) readerContext.schedulePdfManifestPrefetch(entry.page);
else if (!readerContext.pdfManifestPrefetchActive.size && !readerContext.pdfManifestPrefetchTimer) readerContext.queuePdfManifestPrefetch();
readerContext.trimPdfManifestImages(shell);
readerContext.scheduleMarkerSync();
} catch (error) {
shell.querySelector("img")?.remove();
shell._v3PreviewRelease?.();
// Hybrid handoff: if PDF canvas completed while this preview was loading,
// the cancellation is expected and the page is already successfully rendered.
if (shell.dataset.pdfPresented === "1") return;
// The outer background task owns user-visible error/retry state.
// Set idle so the reconciliation scheduler can retry if this becomes visible.
shell.dataset.renderState = "idle";
throw error;
} finally {
if (acquired) readerContext.releasePdfPreviewSlot(shell);
}
})().finally(() => {
if (shell._renderPromise === task) delete shell._renderPromise;
if (shell._renderCancel) delete shell._renderCancel;
delete shell._renderStarted;
delete shell._renderActivePriority;
});
shell._renderPromise = task;
return task;
}
function renderPdfShell(shell, force = false, priority = false) {
if (shell._renderPromise) {
if (force) shell.dataset.pendingRerender = "1";
return shell._renderPromise;
}
if (!force && shell.dataset.renderState === "rendered") return Promise.resolve();
shell.dataset.renderState = "rendering";
const loadingState = shell.querySelector(".reader-page-state");
if (loadingState && !shell.querySelector("canvas.ready, img.ready"))
readerContext.setPdfPageLoadingState(loadingState, shell.dataset.page);
const renderController = new AbortController();
priority = readerContext.normalizePdfPriority(priority || readerContext.pdfPagePriority(shell));
let activeRendering = null;
shell._renderCancel = () => {
renderController.abort();
activeRendering?.cancel?.();
};
const task = (async () => {
let acquired = false;
try {
await readerContext.measurePdf(readerContext.acquirePdfRenderSlot(priority, shell, renderController.signal), "queue", Number(shell.dataset.page));
acquired = true;
shell._pdfRenderOwnsSlot = true;
readerContext.assertReaderActive();
if (renderController.signal.aborted) throw readerContext.readerAbortError();
const pdf = readerContext.pdfDocument;
if (!pdf) throw readerContext.readerAbortError();
let canvas = null;
do {
delete shell.dataset.pendingRerender;
const generation = readerContext.readerRuntime.currentGeneration("pdf"),
pageNumber = Number(shell.dataset.page),
page = await readerContext.measurePdf(readerContext.awaitReader(pdf.getPage(pageNumber), renderController.signal), "page", pageNumber);
readerContext.assertReaderActive();
if (renderController.signal.aborted) throw readerContext.readerAbortError();
const base = page.getViewport({ scale: 1 }),
scale = Math.min(3, shell.clientWidth / base.width),
viewport = page.getViewport({ scale }),
outputScale = Math.min(2, Math.max(1, Number(window.devicePixelRatio) || 1));
if (readerContext.pdfV3Map) {
const geometry = readerContext.pdfV3Map.pages[pageNumber - 1];
if (Math.abs((base.width / base.height) / (geometry.width / geometry.height) - 1) > .01)
throw new Error("PDF_V3_DOCUMENT_GEOMETRY_MISMATCH");
}
canvas = shell.querySelector("canvas");
if (!canvas) {
canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
shell.prepend(canvas);
}
canvas.width = Math.ceil(viewport.width * outputScale);
canvas.height = Math.ceil(viewport.height * outputScale);
canvas.style.width = "100%";
canvas.style.height = "auto";
shell.style.aspectRatio = `${viewport.width} / ${viewport.height}`;
const rendering = page.render({
canvasContext: canvas.getContext("2d"),
viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0]
});
activeRendering = rendering;
const untrack = readerContext.trackReaderResource(() => rendering.cancel?.());
const textTask = readerContext.renderPdfText(page, shell, renderController.signal);
textTask.catch(() => {});
try {
// The canvas is the first visible result. Text extraction can need a
// separate range and must not delay the visual first page.
await readerContext.measurePdf(readerContext.awaitReader(rendering.promise, renderController.signal), "draw", Number(shell.dataset.page));
} finally {
untrack();
activeRendering = null;
}
readerContext.assertReaderActive();
if (renderController.signal.aborted) throw readerContext.readerAbortError();
if (!readerContext.isReaderGenerationCurrent("pdf", generation)) shell.dataset.pendingRerender = "1";
} while (shell.dataset.pendingRerender);
canvas.classList.add("ready");
const pageState = shell.querySelector(".reader-page-state");
if (pageState) pageState.hidden = true;
shell.dataset.renderState = "rendered";
shell.dataset.renderUsedAt = String(Date.now());
readerContext.pdfShellWindow?.remember(shell);
readerContext.trimPdfCanvases(shell);
delete shell.dataset.renderRetries;
delete shell.dataset.renderFailed;
} catch (error) {
shell.dataset.renderState = "idle";
throw error;
} finally {
if (acquired) { delete shell._pdfRenderOwnsSlot; readerContext.releasePdfRenderSlot(); }
}
})().finally(() => {
if (shell._renderPromise === task) delete shell._renderPromise;
if (shell._renderCancel) delete shell._renderCancel;
});
shell._renderPromise = task;
return task;
}
return {
setPdfPageLoadingState,
isPdfPageVisible,
pdfPagePriority,
normalizePdfPriority,
renderPdfInBackground,
pdfPrimarySource,
renderPdfManifestShell,
renderHybridPdfCanvas,
renderPdfImageShell,
renderPdfShell,
resetPdfLatencySamples,
notePdfLatency,
pdfHybridPreviewGrace,
applyPdfShellGeometry,
revealPdfSurface,
pdfSurfaceFades,
notePdfOpeningShown,
pdfHybridLookahead
};
}