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:
parent
90ba41720e
commit
d185099ead
6 changed files with 417 additions and 143 deletions
|
|
@ -11,14 +11,6 @@
|
||||||
<div id="loading" class="loading">
|
<div id="loading" class="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p id="loading-text">Loading PDF...</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
|
|
@ -33,6 +25,13 @@
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span id="pdf-title" class="pdf-title">Document</span>
|
<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>
|
||||||
<div class="toolbar-center">
|
<div class="toolbar-center">
|
||||||
<button id="prev-btn" class="nav-btn" title="Previous page (←)">
|
<button id="prev-btn" class="nav-btn" title="Previous page (←)">
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it("should reject PDFs larger than max size limit", async () => {
|
||||||
const hugeUrl = "https://arxiv.org/pdf/huge-pdf";
|
const hugeUrl = "https://arxiv.org/pdf/huge-pdf";
|
||||||
// Create data larger than the limit
|
// Create data larger than the limit
|
||||||
|
|
|
||||||
|
|
@ -292,17 +292,21 @@ export function createPdfCache(): PdfCache {
|
||||||
return sliceToChunk(cached, offset, clampedByteCount);
|
return sliceToChunk(cached, offset, clampedByteCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote URL - Range request
|
// Remote URL - try Range request, fall back to full GET if not supported
|
||||||
const response = await fetch(normalized, {
|
let response = await fetch(normalized, {
|
||||||
headers: {
|
headers: {
|
||||||
Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
|
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) {
|
if (!response.ok && response.status !== 206) {
|
||||||
throw new Error(
|
response = await fetch(normalized);
|
||||||
`Range request failed: ${response.status} ${response.statusText}`,
|
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.
|
// HTTP 200 means the server ignored our Range header and sent the full body.
|
||||||
|
|
@ -551,6 +555,7 @@ Accepts:
|
||||||
outputSchema: z.object({
|
outputSchema: z.object({
|
||||||
url: z.string(),
|
url: z.string(),
|
||||||
initialPage: z.number(),
|
initialPage: z.number(),
|
||||||
|
totalBytes: z.number(),
|
||||||
}),
|
}),
|
||||||
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
_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 {
|
return {
|
||||||
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
|
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
url: normalized,
|
url: normalized,
|
||||||
initialPage: page,
|
initialPage: page,
|
||||||
|
totalBytes,
|
||||||
},
|
},
|
||||||
_meta: {
|
_meta: {
|
||||||
viewUUID: randomUUID(),
|
viewUUID: randomUUID(),
|
||||||
|
|
|
||||||
|
|
@ -68,28 +68,6 @@ body {
|
||||||
color: var(--text100);
|
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 State */
|
||||||
.error {
|
.error {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -138,6 +116,8 @@ body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-title {
|
.pdf-title {
|
||||||
|
|
@ -281,6 +261,47 @@ body {
|
||||||
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
|
--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) {
|
.text-layer :is(span, br) {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -460,3 +481,30 @@ body {
|
||||||
mix-blend-mode: screen;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,6 @@ import "./mcp-app.css";
|
||||||
|
|
||||||
const MAX_MODEL_CONTEXT_LENGTH = 15000;
|
const MAX_MODEL_CONTEXT_LENGTH = 15000;
|
||||||
const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max screenshot dimension
|
const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max screenshot dimension
|
||||||
const CHUNK_SIZE = 500 * 1024; // 500KB chunks
|
|
||||||
|
|
||||||
// Configure PDF.js worker
|
// Configure PDF.js worker
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
"pdfjs-dist/build/pdf.worker.mjs",
|
"pdfjs-dist/build/pdf.worker.mjs",
|
||||||
|
|
@ -36,7 +34,6 @@ const log = {
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
|
let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
|
||||||
let pdfBytes: Uint8Array | null = null;
|
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 0;
|
let totalPages = 0;
|
||||||
let scale = 1.0;
|
let scale = 1.0;
|
||||||
|
|
@ -66,9 +63,6 @@ const zoomLevelEl = document.getElementById("zoom-level")!;
|
||||||
const fullscreenBtn = document.getElementById(
|
const fullscreenBtn = document.getElementById(
|
||||||
"fullscreen-btn",
|
"fullscreen-btn",
|
||||||
) as HTMLButtonElement;
|
) 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;
|
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>`;
|
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")!;
|
const searchBarEl = document.getElementById("search-bar")!;
|
||||||
|
|
@ -102,11 +96,19 @@ const pageTextItemsCache = new Map<number, string[]>();
|
||||||
let allMatches: SearchMatch[] = [];
|
let allMatches: SearchMatch[] = [];
|
||||||
let currentMatchIndex = -1;
|
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
|
// Track current display mode
|
||||||
let currentDisplayMode: "inline" | "fullscreen" = "inline";
|
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.
|
* Request the host to resize the app to fit the current PDF page.
|
||||||
* Only applies in inline mode - fullscreen mode uses scrolling.
|
* Only applies in inline mode - fullscreen mode uses scrolling.
|
||||||
|
|
@ -138,42 +140,19 @@ function requestFitToContent() {
|
||||||
const paddingBottom = parseFloat(containerStyle.paddingBottom);
|
const paddingBottom = parseFloat(containerStyle.paddingBottom);
|
||||||
|
|
||||||
// Calculate required height:
|
// 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 toolbarHeight = toolbarEl.offsetHeight;
|
||||||
const searchBarHeight = searchOpen ? searchBarEl.offsetHeight : 0;
|
|
||||||
const pageWrapperHeight = pageWrapperEl.offsetHeight;
|
const pageWrapperHeight = pageWrapperEl.offsetHeight;
|
||||||
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
|
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
|
||||||
const totalHeight =
|
const totalHeight =
|
||||||
toolbarHeight +
|
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
|
||||||
searchBarHeight +
|
|
||||||
paddingTop +
|
|
||||||
pageWrapperHeight +
|
|
||||||
paddingBottom +
|
|
||||||
BUFFER;
|
|
||||||
|
|
||||||
app.sendSizeChanged({ height: totalHeight });
|
app.sendSizeChanged({ height: totalHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search Functions ---
|
// --- 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) {
|
function performSearch(query: string) {
|
||||||
allMatches = [];
|
allMatches = [];
|
||||||
currentMatchIndex = -1;
|
currentMatchIndex = -1;
|
||||||
|
|
@ -320,10 +299,12 @@ function clearHighlights() {
|
||||||
|
|
||||||
function updateSearchUI() {
|
function updateSearchUI() {
|
||||||
const hasQuery = searchQuery.length > 0;
|
const hasQuery = searchQuery.length > 0;
|
||||||
|
const stillLoading = totalPages > 0 && pagesLoaded < totalPages;
|
||||||
|
const suffix = stillLoading ? " (loading\u2026)" : "";
|
||||||
if (allMatches.length === 0) {
|
if (allMatches.length === 0) {
|
||||||
searchMatchCountEl.textContent = hasQuery ? "No matches" : "";
|
searchMatchCountEl.textContent = hasQuery ? `No matches${suffix}` : "";
|
||||||
} else {
|
} else {
|
||||||
searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}`;
|
searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}${suffix}`;
|
||||||
}
|
}
|
||||||
searchPrevBtn.disabled = allMatches.length === 0;
|
searchPrevBtn.disabled = allMatches.length === 0;
|
||||||
searchNextBtn.disabled = allMatches.length === 0;
|
searchNextBtn.disabled = allMatches.length === 0;
|
||||||
|
|
@ -344,8 +325,7 @@ function openSearch() {
|
||||||
searchBarEl.style.display = "flex";
|
searchBarEl.style.display = "flex";
|
||||||
updateSearchUI();
|
updateSearchUI();
|
||||||
searchInputEl.focus();
|
searchInputEl.focus();
|
||||||
requestFitToContent();
|
// Text extraction is handled by the background preloader
|
||||||
extractAllPageText();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSearch() {
|
function closeSearch() {
|
||||||
|
|
@ -358,7 +338,6 @@ function closeSearch() {
|
||||||
currentMatchIndex = -1;
|
currentMatchIndex = -1;
|
||||||
clearHighlights();
|
clearHighlights();
|
||||||
updateSearchUI();
|
updateSearchUI();
|
||||||
requestFitToContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSearch() {
|
function toggleSearch() {
|
||||||
|
|
@ -645,6 +624,9 @@ async function renderPage() {
|
||||||
isRendering = true;
|
isRendering = true;
|
||||||
pendingPage = null;
|
pendingPage = null;
|
||||||
|
|
||||||
|
// Show loading overlay while page data is being fetched
|
||||||
|
showPageLoadingOverlay();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pageToRender = currentPage;
|
const pageToRender = currentPage;
|
||||||
const page = await pdfDocument.getPage(pageToRender);
|
const page = await pdfDocument.getPage(pageToRender);
|
||||||
|
|
@ -737,6 +719,8 @@ async function renderPage() {
|
||||||
log.error("Error rendering page:", err);
|
log.error("Error rendering page:", err);
|
||||||
showError(`Failed to render page ${currentPage}`);
|
showError(`Failed to render page ${currentPage}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
hidePageLoadingOverlay();
|
||||||
|
preloadPaused = false;
|
||||||
isRendering = false;
|
isRendering = false;
|
||||||
|
|
||||||
// If there's a pending page, render it now
|
// If there's a pending page, render it now
|
||||||
|
|
@ -786,6 +770,7 @@ function loadSavedPage(): number | null {
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
const targetPage = Math.max(1, Math.min(page, totalPages));
|
const targetPage = Math.max(1, Math.min(page, totalPages));
|
||||||
if (targetPage !== currentPage) {
|
if (targetPage !== currentPage) {
|
||||||
|
preloadPaused = true;
|
||||||
currentPage = targetPage;
|
currentPage = targetPage;
|
||||||
saveCurrentPage();
|
saveCurrentPage();
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
@ -1002,12 +987,14 @@ function parseToolResult(result: CallToolResult): {
|
||||||
title?: string;
|
title?: string;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
initialPage: number;
|
initialPage: number;
|
||||||
|
totalBytes: number;
|
||||||
} | null {
|
} | null {
|
||||||
return result.structuredContent as {
|
return result.structuredContent as {
|
||||||
url: string;
|
url: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
initialPage: number;
|
initialPage: number;
|
||||||
|
totalBytes: number;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1021,74 +1008,273 @@ interface PdfBytesChunk {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress bar
|
// Range request caching — avoid duplicate fetches for the same range
|
||||||
function updateProgress(loaded: number, total: number) {
|
type RangeResult = { bytes: Uint8Array; totalBytes: number };
|
||||||
const percent = Math.round((loaded / total) * 100);
|
const rangeCache = new Map<string, RangeResult>();
|
||||||
progressBarEl.style.width = `${percent}%`;
|
const inflightRequests = new Map<string, Promise<RangeResult>>();
|
||||||
progressTextEl.textContent = `${(loaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB (${percent}%)`;
|
|
||||||
|
// 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
|
function hidePageLoadingOverlay() {
|
||||||
async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> {
|
if (pageLoadingTimer) {
|
||||||
const chunks: Uint8Array[] = [];
|
clearTimeout(pageLoadingTimer);
|
||||||
let offset = 0;
|
pageLoadingTimer = null;
|
||||||
let totalBytes = 0;
|
}
|
||||||
let hasMore = true;
|
if (pageLoadingOverlay) {
|
||||||
|
pageLoadingOverlay.remove();
|
||||||
|
pageLoadingOverlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show progress UI
|
// Max bytes per server request (must match server's MAX_CHUNK_BYTES)
|
||||||
progressContainerEl.style.display = "block";
|
const MAX_CHUNK_BYTES = 512 * 1024;
|
||||||
updateProgress(0, 1);
|
|
||||||
|
|
||||||
while (hasMore) {
|
/**
|
||||||
const result = await app.callServerTool({
|
* Fetch a single chunk from the server (up to MAX_CHUNK_BYTES).
|
||||||
name: "read_pdf_bytes",
|
* Deduplicates concurrent requests for the same range via inflightRequests.
|
||||||
arguments: { url: urlToLoad, offset, byteCount: CHUNK_SIZE },
|
*/
|
||||||
|
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(
|
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
|
// Handle tool result
|
||||||
app.ontoolresult = async (result) => {
|
app.ontoolresult = async (result: CallToolResult) => {
|
||||||
log.info("Received tool result:", result);
|
log.info("Received tool result:", result);
|
||||||
|
|
||||||
const parsed = parseToolResult(result);
|
const parsed = parseToolResult(result);
|
||||||
|
|
@ -1099,7 +1285,8 @@ app.ontoolresult = async (result) => {
|
||||||
|
|
||||||
pdfUrl = parsed.url;
|
pdfUrl = parsed.url;
|
||||||
pdfTitle = parsed.title;
|
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;
|
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
|
||||||
|
|
||||||
// Restore saved page or use initial page
|
// Restore saved page or use initial page
|
||||||
|
|
@ -1107,36 +1294,41 @@ app.ontoolresult = async (result) => {
|
||||||
currentPage =
|
currentPage =
|
||||||
savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage;
|
savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage;
|
||||||
|
|
||||||
log.info(
|
log.info("URL:", pdfUrl, "Starting at page:", currentPage);
|
||||||
"URL:",
|
|
||||||
pdfUrl,
|
|
||||||
"Pages:",
|
|
||||||
parsed.pageCount,
|
|
||||||
"Starting:",
|
|
||||||
currentPage,
|
|
||||||
);
|
|
||||||
|
|
||||||
showLoading("Loading PDF...");
|
showLoading("Loading PDF...");
|
||||||
|
|
||||||
try {
|
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;
|
// Reset preload state for new PDF
|
||||||
totalPages = pdfDocument.numPages;
|
pagesLoaded = 0;
|
||||||
|
preloadErrors = [];
|
||||||
log.info("PDF loaded, pages:", totalPages);
|
pageTextCache.clear();
|
||||||
|
pageTextItemsCache.clear();
|
||||||
|
loadingIndicatorEl.classList.remove("error");
|
||||||
|
loadingIndicatorEl.style.opacity = "";
|
||||||
|
loadingIndicatorEl.style.display = "none";
|
||||||
|
|
||||||
showViewer();
|
showViewer();
|
||||||
renderPage();
|
renderPage();
|
||||||
|
// Start background preloading of all pages for text extraction
|
||||||
|
startPreloading();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Error loading PDF:", err);
|
log.error("Error loading PDF:", err);
|
||||||
showError(err instanceof Error ? err.message : String(err));
|
showError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
app.onerror = (err) => {
|
app.onerror = (err: unknown) => {
|
||||||
log.error("App error:", err);
|
log.error("App error:", err);
|
||||||
showError(err instanceof Error ? err.message : String(err));
|
showError(err instanceof Error ? err.message : String(err));
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue