feat(pdf-server): lazy range-based loading + eager background preloading (#480)

* lazy PDF loading w/ PDFDataRangeTransport

* lint

* feat: lazy PDF loading via range transport + eager background preloading

Replace full-file download with PDFDataRangeTransport so PDF.js fetches
byte ranges on demand. First page renders immediately; remaining pages
load in background for search.

**Range transport (server + client)**
- server.ts: add read_pdf_bytes tool returning base64 chunks with offset
- mcp-app.ts: AppRangeTransport feeds PDF.js via fetchRange/fetchChunk
  with parallel sub-requests for large ranges, Promise-based dedup of
  concurrent requests, and a persistent range cache

**Background preloader**
- Sequential page 1→N text extraction, pauses during interactive
  navigation (preloadPaused flag set in goToPage, cleared in renderPage)
- Debounced live search refresh (500ms) as pages load
- Search UI shows '(loading…)' suffix until all pages processed

**Loading indicators**
- SVG pie in toolbar: fills as pages load, fades on completion,
  turns red on errors with tooltip listing failed pages
- Debounced page-loading overlay (150ms) for uncached page navigation

**Cleanup**
- Remove old full-file chunked download and progress bar
- Remove extractAllPageText (replaced by preloader)
- Remove verbose debug logs (Coverage, RangeReq, cache hit)
This commit is contained in:
Olivier Chafik 2026-02-21 16:33:48 -08:00 • committed by GitHub
commit d185099ead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 417 additions and 143 deletions

View file

@ -11,14 +11,6 @@
<div id="loading" class="loading">
<div class="spinner"></div>
<p id="loading-text">Loading PDF...</p>
<div
id="progress-container"
class="progress-container"
style="display: none"
>
<div id="progress-bar" class="progress-bar"></div>
</div>
<p id="progress-text" class="progress-text"></p>
</div>
<!-- Error State -->
@ -33,6 +25,13 @@
<div class="toolbar">
<div class="toolbar-left">
<span id="pdf-title" class="pdf-title">Document</span>
<div id="loading-indicator" class="loading-indicator" style="display: none"
title="Loading pages...">
<svg viewBox="0 0 20 20" width="14" height="14">
<circle class="loading-indicator-bg" cx="10" cy="10" r="8"/>
<circle class="loading-indicator-arc" cx="10" cy="10" r="8"/>
</svg>
</div>
</div>
<div class="toolbar-center">
<button id="prev-btn" class="nav-btn" title="Previous page (←)">

View file

@ -131,6 +131,33 @@ describe("PDF Cache with Timeouts", () => {
}
});
it("should fall back to GET when server returns 501 for Range request", async () => {
const fullData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
const mockFetch = spyOn(globalThis, "fetch")
// First call: Range request returns 501
.mockResolvedValueOnce(
new Response("Unsupported client Range", { status: 501 }),
)
// Second call: plain GET returns full body
.mockResolvedValueOnce(
new Response(fullData, {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
try {
const result = await pdfCache.readPdfRange(testUrl, 0, 1024);
expect(result.data).toEqual(fullData);
expect(result.totalBytes).toBe(fullData.length);
expect(pdfCache.getCacheSize()).toBe(1);
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
mockFetch.mockRestore();
}
});
it("should reject PDFs larger than max size limit", async () => {
const hugeUrl = "https://arxiv.org/pdf/huge-pdf";
// Create data larger than the limit

View file

@ -292,17 +292,21 @@ export function createPdfCache(): PdfCache {
return sliceToChunk(cached, offset, clampedByteCount);
}
// Remote URL - Range request
const response = await fetch(normalized, {
// Remote URL - try Range request, fall back to full GET if not supported
let response = await fetch(normalized, {
headers: {
Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
},
});
// If server doesn't support Range (501, 416, etc.), fall back to plain GET
if (!response.ok && response.status !== 206) {
throw new Error(
`Range request failed: ${response.status} ${response.statusText}`,
);
response = await fetch(normalized);
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
);
}
}
// HTTP 200 means the server ignored our Range header and sent the full body.
@ -551,6 +555,7 @@ Accepts:
outputSchema: z.object({
url: z.string(),
initialPage: z.number(),
totalBytes: z.number(),
}),
_meta: { ui: { resourceUri: RESOURCE_URI } },
},
@ -565,11 +570,15 @@ Accepts:
};
}
// Probe file size so the client can set up range transport without an extra fetch
const { totalBytes } = await readPdfRange(normalized, 0, 1);
return {
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
structuredContent: {
url: normalized,
initialPage: page,
totalBytes,
},
_meta: {
viewUUID: randomUUID(),

View file

@ -68,28 +68,6 @@ body {
color: var(--text100);
}
/* Progress Bar */
.progress-container {
width: 100%;
max-width: 200px;
height: 3px;
background: var(--bg200);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--text100);
width: 0%;
transition: width 0.15s ease-out;
}
.progress-text {
font-size: 0.7rem;
color: var(--text200);
}
/* Error State */
.error {
display: flex;
@ -138,6 +116,8 @@ body {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
align-items: center;
}
.pdf-title {
@ -281,6 +261,47 @@ body {
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
}
/* Page Loading Overlay - shown while page data is being fetched */
.page-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
z-index: 10;
}
.page-loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 2rem;
background: var(--bg000);
border: 1px solid var(--bg200);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.page-loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--bg200);
border-top-color: var(--text100);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.page-loading-text {
font-size: 0.85rem;
color: var(--text100);
}
.text-layer :is(span, br) {
color: transparent;
position: absolute;
@ -460,3 +481,30 @@ body {
mix-blend-mode: screen;
}
}
/* Loading Indicator (pie) */
.loading-indicator {
display: inline-flex;
align-items: center;
margin-left: 0.5rem;
flex-shrink: 0;
transition: opacity 0.3s;
}
.loading-indicator-bg {
fill: none;
stroke: var(--bg200);
stroke-width: 3;
}
.loading-indicator-arc {
fill: none;
stroke: var(--text100);
stroke-width: 3;
stroke-dasharray: 50.27; /* 2*pi*8 */
stroke-dashoffset: 50.27;
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 0.3s;
}
.loading-indicator.error .loading-indicator-arc {
stroke: #e74c3c;
}

View file

@ -21,8 +21,6 @@ import "./mcp-app.css";
const MAX_MODEL_CONTEXT_LENGTH = 15000;
const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max screenshot dimension
const CHUNK_SIZE = 500 * 1024; // 500KB chunks
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
@ -36,7 +34,6 @@ const log = {
// State
let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
let pdfBytes: Uint8Array | null = null;
let currentPage = 1;
let totalPages = 0;
let scale = 1.0;
@ -66,9 +63,6 @@ const zoomLevelEl = document.getElementById("zoom-level")!;
const fullscreenBtn = document.getElementById(
"fullscreen-btn",
) as HTMLButtonElement;
const progressContainerEl = document.getElementById("progress-container")!;
const progressBarEl = document.getElementById("progress-bar")!;
const progressTextEl = document.getElementById("progress-text")!;
const searchBtn = document.getElementById("search-btn") as HTMLButtonElement;
searchBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="6.5" cy="6.5" r="4.5"/><line x1="10" y1="10" x2="14" y2="14"/></svg>`;
const searchBarEl = document.getElementById("search-bar")!;
@ -102,11 +96,19 @@ const pageTextItemsCache = new Map<number, string[]>();
let allMatches: SearchMatch[] = [];
let currentMatchIndex = -1;
// Preload state — goToPage sets preloadPaused=true, renderPage's finally resets it.
// The preloader's while(preloadPaused) loop yields so interactive loads always win.
let preloadPaused = false;
let pagesLoaded = 0;
let preloadErrors: Array<{ page: number; err: unknown }> = [];
const loadingIndicatorEl = document.getElementById("loading-indicator")!;
const loadingIndicatorArc = loadingIndicatorEl.querySelector(
".loading-indicator-arc",
) as SVGCircleElement;
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
// Layout constants are no longer used - we calculate dynamically from actual element dimensions
/**
* Request the host to resize the app to fit the current PDF page.
* Only applies in inline mode - fullscreen mode uses scrolling.
@ -138,42 +140,19 @@ function requestFitToContent() {
const paddingBottom = parseFloat(containerStyle.paddingBottom);
// Calculate required height:
// toolbar + search-bar + padding-top + page-wrapper height + padding-bottom + buffer
// toolbar + padding-top + page-wrapper height + padding-bottom + buffer
// Note: search bar is absolutely positioned over the document area, so excluded
const toolbarHeight = toolbarEl.offsetHeight;
const searchBarHeight = searchOpen ? searchBarEl.offsetHeight : 0;
const pageWrapperHeight = pageWrapperEl.offsetHeight;
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
const totalHeight =
toolbarHeight +
searchBarHeight +
paddingTop +
pageWrapperHeight +
paddingBottom +
BUFFER;
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
app.sendSizeChanged({ height: totalHeight });
}
// --- Search Functions ---
async function extractAllPageText() {
if (!pdfDocument) return;
for (let i = 1; i <= totalPages; i++) {
if (pageTextCache.has(i)) continue;
try {
const page = await pdfDocument.getPage(i);
const textContent = await page.getTextContent();
const items = (textContent.items as Array<{ str?: string }>).map(
(item) => item.str || "",
);
pageTextItemsCache.set(i, items);
pageTextCache.set(i, items.join(""));
} catch (err) {
log.error("Error extracting text for page", i, err);
}
}
}
function performSearch(query: string) {
allMatches = [];
currentMatchIndex = -1;
@ -320,10 +299,12 @@ function clearHighlights() {
function updateSearchUI() {
const hasQuery = searchQuery.length > 0;
const stillLoading = totalPages > 0 && pagesLoaded < totalPages;
const suffix = stillLoading ? " (loading\u2026)" : "";
if (allMatches.length === 0) {
searchMatchCountEl.textContent = hasQuery ? "No matches" : "";
searchMatchCountEl.textContent = hasQuery ? `No matches${suffix}` : "";
} else {
searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}`;
searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}${suffix}`;
}
searchPrevBtn.disabled = allMatches.length === 0;
searchNextBtn.disabled = allMatches.length === 0;
@ -344,8 +325,7 @@ function openSearch() {
searchBarEl.style.display = "flex";
updateSearchUI();
searchInputEl.focus();
requestFitToContent();
extractAllPageText();
// Text extraction is handled by the background preloader
}
function closeSearch() {
@ -358,7 +338,6 @@ function closeSearch() {
currentMatchIndex = -1;
clearHighlights();
updateSearchUI();
requestFitToContent();
}
function toggleSearch() {
@ -645,6 +624,9 @@ async function renderPage() {
isRendering = true;
pendingPage = null;
// Show loading overlay while page data is being fetched
showPageLoadingOverlay();
try {
const pageToRender = currentPage;
const page = await pdfDocument.getPage(pageToRender);
@ -737,6 +719,8 @@ async function renderPage() {
log.error("Error rendering page:", err);
showError(`Failed to render page ${currentPage}`);
} finally {
hidePageLoadingOverlay();
preloadPaused = false;
isRendering = false;
// If there's a pending page, render it now
@ -786,6 +770,7 @@ function loadSavedPage(): number | null {
function goToPage(page: number) {
const targetPage = Math.max(1, Math.min(page, totalPages));
if (targetPage !== currentPage) {
preloadPaused = true;
currentPage = targetPage;
saveCurrentPage();
renderPage();
@ -1002,12 +987,14 @@ function parseToolResult(result: CallToolResult): {
title?: string;
pageCount: number;
initialPage: number;
totalBytes: number;
} | null {
return result.structuredContent as {
url: string;
title?: string;
pageCount: number;
initialPage: number;
totalBytes: number;
} | null;
}
@ -1021,74 +1008,273 @@ interface PdfBytesChunk {
hasMore: boolean;
}
// Update progress bar
function updateProgress(loaded: number, total: number) {
const percent = Math.round((loaded / total) * 100);
progressBarEl.style.width = `${percent}%`;
progressTextEl.textContent = `${(loaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB (${percent}%)`;
// Range request caching — avoid duplicate fetches for the same range
type RangeResult = { bytes: Uint8Array; totalBytes: number };
const rangeCache = new Map<string, RangeResult>();
const inflightRequests = new Map<string, Promise<RangeResult>>();
// Page loading overlay state — shown after a delay so cached pages never flash it
let pageLoadingOverlay: HTMLElement | null = null;
let pageLoadingTimer: ReturnType<typeof setTimeout> | null = null;
const PAGE_LOADING_DELAY_MS = 150;
function showPageLoadingOverlay() {
if (pageLoadingOverlay || pageLoadingTimer) return; // Already showing or scheduled
pageLoadingTimer = setTimeout(() => {
pageLoadingTimer = null;
const overlay = document.createElement("div");
overlay.className = "page-loading-overlay";
overlay.innerHTML = `
<div class="page-loading-content">
<div class="page-loading-spinner"></div>
<span class="page-loading-text">Loading page...</span>
</div>
`;
canvasContainerEl.appendChild(overlay);
pageLoadingOverlay = overlay;
}, PAGE_LOADING_DELAY_MS);
}
// Load PDF in chunks with progress
async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let offset = 0;
let totalBytes = 0;
let hasMore = true;
function hidePageLoadingOverlay() {
if (pageLoadingTimer) {
clearTimeout(pageLoadingTimer);
pageLoadingTimer = null;
}
if (pageLoadingOverlay) {
pageLoadingOverlay.remove();
pageLoadingOverlay = null;
}
}
// Show progress UI
progressContainerEl.style.display = "block";
updateProgress(0, 1);
// Max bytes per server request (must match server's MAX_CHUNK_BYTES)
const MAX_CHUNK_BYTES = 512 * 1024;
while (hasMore) {
const result = await app.callServerTool({
name: "read_pdf_bytes",
arguments: { url: urlToLoad, offset, byteCount: CHUNK_SIZE },
/**
* Fetch a single chunk from the server (up to MAX_CHUNK_BYTES).
* Deduplicates concurrent requests for the same range via inflightRequests.
*/
async function fetchChunk(
url: string,
begin: number,
end: number,
): Promise<RangeResult> {
const cacheKey = `${url}:${begin}-${end}`;
const cached = rangeCache.get(cacheKey);
if (cached) return cached;
// Deduplicate: reuse in-flight request for the same range
const inflight = inflightRequests.get(cacheKey);
if (inflight) return inflight;
const request = (async (): Promise<RangeResult> => {
try {
const result = await app.callServerTool({
name: "read_pdf_bytes",
arguments: { url, offset: begin, byteCount: end - begin },
});
if (result.isError) {
const errorText =
result.content?.map((c) => ("text" in c ? c.text : "")).join(" ") ||
"";
throw new Error(`Tool error: ${errorText}`);
}
if (!result.structuredContent) {
throw new Error("No structuredContent in tool response");
}
const chunk = result.structuredContent as unknown as PdfBytesChunk;
const binaryString = atob(chunk.bytes);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const entry: RangeResult = { bytes, totalBytes: chunk.totalBytes };
rangeCache.set(cacheKey, entry);
return entry;
} finally {
inflightRequests.delete(cacheKey);
}
})();
inflightRequests.set(cacheKey, request);
return request;
}
/**
* Fetch a byte range from the PDF, splitting into parallel sub-requests
* if the range exceeds MAX_CHUNK_BYTES.
*/
async function fetchRange(
url: string,
begin: number,
end: number,
): Promise<RangeResult> {
const size = end - begin;
// Single chunk — no splitting needed
if (size <= MAX_CHUNK_BYTES) {
return fetchChunk(url, begin, end);
}
// Split into parallel sub-requests
const chunks: Array<{ begin: number; end: number }> = [];
for (let offset = begin; offset < end; offset += MAX_CHUNK_BYTES) {
chunks.push({
begin: offset,
end: Math.min(offset + MAX_CHUNK_BYTES, end),
});
// Check for errors
if (result.isError) {
const errorText = result.content
?.map((c) => ("text" in c ? c.text : ""))
.join(" ");
throw new Error(`Tool error: ${errorText}`);
}
if (!result.structuredContent) {
throw new Error("No structuredContent in tool response");
}
const chunk = result.structuredContent as unknown as PdfBytesChunk;
totalBytes = chunk.totalBytes;
hasMore = chunk.hasMore;
// Decode base64 chunk
const binaryString = atob(chunk.bytes);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
chunks.push(bytes);
offset += chunk.byteCount;
updateProgress(offset, totalBytes);
}
// Combine all chunks
const fullPdf = new Uint8Array(totalBytes);
let pos = 0;
for (const chunk of chunks) {
fullPdf.set(chunk, pos);
pos += chunk.length;
}
log.info(
`PDF loaded: ${(totalBytes / 1024).toFixed(0)} KB in ${chunks.length} chunks`,
`Splitting range ${begin}-${end} (${(size / 1024) | 0} KB) into ${chunks.length} parallel chunks`,
);
return fullPdf;
const results = await Promise.all(
chunks.map((c) => fetchChunk(url, c.begin, c.end)),
);
// Reassemble into a single buffer
const totalLen = results.reduce((sum, r) => sum + r.bytes.length, 0);
const combined = new Uint8Array(totalLen);
let pos = 0;
for (const r of results) {
combined.set(r.bytes, pos);
pos += r.bytes.length;
}
const entry = { bytes: combined, totalBytes: results[0].totalBytes };
rangeCache.set(`${url}:${begin}-${end}`, entry);
return entry;
}
/**
* Load PDF progressively using PDFDataRangeTransport.
* PDF.js will request ranges as needed to render pages.
*/
async function loadPdfProgressively(
urlToLoad: string,
fileSizeBytes: number,
): Promise<{
document: pdfjsLib.PDFDocumentProxy;
totalBytes: number;
}> {
const fileTotalBytes = fileSizeBytes;
log.info(`PDF file size: ${(fileTotalBytes / 1024) | 0} KB`);
class AppRangeTransport extends pdfjsLib.PDFDataRangeTransport {
requestDataRange(begin: number, end: number) {
fetchRange(urlToLoad, begin, end)
.then((result) => {
this.onDataRange(begin, result.bytes);
})
.catch((err: unknown) => {
log.error(`Error fetching range ${begin}-${end}:`, err);
});
}
}
// Create transport with total file size, no initial data — PDF.js will request what it needs
const transport = new AppRangeTransport(fileTotalBytes, null);
const loadingTask = pdfjsLib.getDocument({ range: transport });
try {
const document = await loadingTask.promise;
log.info(
`PDF document ready, ${document.numPages} pages, ${fileTotalBytes} bytes`,
);
return { document, totalBytes: fileTotalBytes };
} catch (err: unknown) {
log.error("Error loading PDF document:", err);
throw err;
}
}
// --- Loading indicator ---
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 8; // ~50.27
function updateLoadingIndicator() {
if (totalPages <= 0) return;
const pct = pagesLoaded / totalPages;
const offset = CIRCLE_CIRCUMFERENCE * (1 - pct);
loadingIndicatorArc.style.strokeDashoffset = String(offset);
loadingIndicatorEl.style.display = "inline-flex";
loadingIndicatorEl.title = `${pagesLoaded}/${totalPages} pages loaded`;
if (preloadErrors.length > 0) {
loadingIndicatorEl.classList.add("error");
const failedPages = preloadErrors.map((e) => e.page).join(", ");
loadingIndicatorEl.title += ` (errors on pages: ${failedPages})`;
}
}
function finalizeLoadingIndicator() {
updateLoadingIndicator();
if (preloadErrors.length > 0) return; // Keep visible with error state
setTimeout(() => {
loadingIndicatorEl.style.opacity = "0";
setTimeout(() => {
loadingIndicatorEl.style.display = "none";
loadingIndicatorEl.style.opacity = "";
}, 300);
}, 500);
}
// --- Background preloader ---
let preloadSearchTimer: ReturnType<typeof setTimeout> | null = null;
/** Schedule a debounced search refresh while preloading */
function scheduleSearchRefresh() {
if (!searchOpen || !searchQuery) return;
if (preloadSearchTimer) return; // already scheduled
preloadSearchTimer = setTimeout(() => {
preloadSearchTimer = null;
if (searchOpen && searchQuery) performSearch(searchQuery);
}, 500);
}
async function startPreloading() {
if (!pdfDocument) return;
log.info("Starting background preload of", totalPages, "pages");
for (let i = 1; i <= totalPages; i++) {
if (pageTextCache.has(i)) {
pagesLoaded++;
updateLoadingIndicator();
continue;
}
// Yield to interactive navigation
while (preloadPaused) await new Promise((r) => setTimeout(r, 50));
try {
const page = await pdfDocument.getPage(i);
const textContent = await page.getTextContent();
const items = (textContent.items as Array<{ str?: string }>).map(
(item) => item.str || "",
);
pageTextItemsCache.set(i, items);
pageTextCache.set(i, items.join(""));
pagesLoaded++;
updateLoadingIndicator();
scheduleSearchRefresh();
} catch (err) {
preloadErrors.push({ page: i, err });
log.error("Preload error page", i, err);
updateLoadingIndicator();
}
}
log.info("Background preload complete:", pagesLoaded, "pages loaded");
finalizeLoadingIndicator();
// Final search update
if (searchOpen && searchQuery) performSearch(searchQuery);
}
// Handle tool result
app.ontoolresult = async (result) => {
app.ontoolresult = async (result: CallToolResult) => {
log.info("Received tool result:", result);
const parsed = parseToolResult(result);
@ -1099,7 +1285,8 @@ app.ontoolresult = async (result) => {
pdfUrl = parsed.url;
pdfTitle = parsed.title;
totalPages = parsed.pageCount;
// Note: pageCount may not be accurate until document loads
totalPages = parsed.pageCount || 1;
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
// Restore saved page or use initial page
@ -1107,36 +1294,41 @@ app.ontoolresult = async (result) => {
currentPage =
savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage;
log.info(
"URL:",
pdfUrl,
"Pages:",
parsed.pageCount,
"Starting:",
currentPage,
);
log.info("URL:", pdfUrl, "Starting at page:", currentPage);
showLoading("Loading PDF...");
try {
pdfBytes = await loadPdfInChunks(pdfUrl);
// Use progressive loading - document available as soon as initial data arrives
const { document, totalBytes } = await loadPdfProgressively(
pdfUrl,
parsed.totalBytes,
);
pdfDocument = document;
totalPages = document.numPages;
showLoading("Rendering PDF...");
log.info("PDF loaded, pages:", totalPages, "bytes:", totalBytes);
pdfDocument = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
totalPages = pdfDocument.numPages;
log.info("PDF loaded, pages:", totalPages);
// Reset preload state for new PDF
pagesLoaded = 0;
preloadErrors = [];
pageTextCache.clear();
pageTextItemsCache.clear();
loadingIndicatorEl.classList.remove("error");
loadingIndicatorEl.style.opacity = "";
loadingIndicatorEl.style.display = "none";
showViewer();
renderPage();
// Start background preloading of all pages for text extraction
startPreloading();
} catch (err) {
log.error("Error loading PDF:", err);
showError(err instanceof Error ? err.message : String(err));
}
};
app.onerror = (err) => {
app.onerror = (err: unknown) => {
log.error("App error:", err);
showError(err instanceof Error ? err.message : String(err));
};

View file

@ -6,7 +6,6 @@
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,