Skip to content

Commit d185099

Browse files
authored
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)
1 parent 90ba417 commit d185099

File tree

6 files changed

+417
-143
lines changed

6 files changed

+417
-143
lines changed

examples/pdf-server/mcp-app.html

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@
1111
<div id="loading" class="loading">
1212
<div class="spinner"></div>
1313
<p id="loading-text">Loading PDF...</p>
14-
<div
15-
id="progress-container"
16-
class="progress-container"
17-
style="display: none"
18-
>
19-
<div id="progress-bar" class="progress-bar"></div>
20-
</div>
21-
<p id="progress-text" class="progress-text"></p>
2214
</div>
2315

2416
<!-- Error State -->
@@ -33,6 +25,13 @@
3325
<div class="toolbar">
3426
<div class="toolbar-left">
3527
<span id="pdf-title" class="pdf-title">Document</span>
28+
<div id="loading-indicator" class="loading-indicator" style="display: none"
29+
title="Loading pages...">
30+
<svg viewBox="0 0 20 20" width="14" height="14">
31+
<circle class="loading-indicator-bg" cx="10" cy="10" r="8"/>
32+
<circle class="loading-indicator-arc" cx="10" cy="10" r="8"/>
33+
</svg>
34+
</div>
3635
</div>
3736
<div class="toolbar-center">
3837
<button id="prev-btn" class="nav-btn" title="Previous page (←)">

examples/pdf-server/server.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,33 @@ describe("PDF Cache with Timeouts", () => {
131131
}
132132
});
133133

134+
it("should fall back to GET when server returns 501 for Range request", async () => {
135+
const fullData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
136+
137+
const mockFetch = spyOn(globalThis, "fetch")
138+
// First call: Range request returns 501
139+
.mockResolvedValueOnce(
140+
new Response("Unsupported client Range", { status: 501 }),
141+
)
142+
// Second call: plain GET returns full body
143+
.mockResolvedValueOnce(
144+
new Response(fullData, {
145+
status: 200,
146+
headers: { "Content-Type": "application/pdf" },
147+
}),
148+
);
149+
150+
try {
151+
const result = await pdfCache.readPdfRange(testUrl, 0, 1024);
152+
expect(result.data).toEqual(fullData);
153+
expect(result.totalBytes).toBe(fullData.length);
154+
expect(pdfCache.getCacheSize()).toBe(1);
155+
expect(mockFetch).toHaveBeenCalledTimes(2);
156+
} finally {
157+
mockFetch.mockRestore();
158+
}
159+
});
160+
134161
it("should reject PDFs larger than max size limit", async () => {
135162
const hugeUrl = "https://arxiv.org/pdf/huge-pdf";
136163
// Create data larger than the limit

examples/pdf-server/server.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,17 +292,21 @@ export function createPdfCache(): PdfCache {
292292
return sliceToChunk(cached, offset, clampedByteCount);
293293
}
294294

295-
// Remote URL - Range request
296-
const response = await fetch(normalized, {
295+
// Remote URL - try Range request, fall back to full GET if not supported
296+
let response = await fetch(normalized, {
297297
headers: {
298298
Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
299299
},
300300
});
301301

302+
// If server doesn't support Range (501, 416, etc.), fall back to plain GET
302303
if (!response.ok && response.status !== 206) {
303-
throw new Error(
304-
`Range request failed: ${response.status} ${response.statusText}`,
305-
);
304+
response = await fetch(normalized);
305+
if (!response.ok) {
306+
throw new Error(
307+
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
308+
);
309+
}
306310
}
307311

308312
// HTTP 200 means the server ignored our Range header and sent the full body.
@@ -551,6 +555,7 @@ Accepts:
551555
outputSchema: z.object({
552556
url: z.string(),
553557
initialPage: z.number(),
558+
totalBytes: z.number(),
554559
}),
555560
_meta: { ui: { resourceUri: RESOURCE_URI } },
556561
},
@@ -565,11 +570,15 @@ Accepts:
565570
};
566571
}
567572

573+
// Probe file size so the client can set up range transport without an extra fetch
574+
const { totalBytes } = await readPdfRange(normalized, 0, 1);
575+
568576
return {
569577
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
570578
structuredContent: {
571579
url: normalized,
572580
initialPage: page,
581+
totalBytes,
573582
},
574583
_meta: {
575584
viewUUID: randomUUID(),

examples/pdf-server/src/mcp-app.css

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -68,28 +68,6 @@ body {
6868
color: var(--text100);
6969
}
7070

71-
/* Progress Bar */
72-
.progress-container {
73-
width: 100%;
74-
max-width: 200px;
75-
height: 3px;
76-
background: var(--bg200);
77-
border-radius: 2px;
78-
overflow: hidden;
79-
}
80-
81-
.progress-bar {
82-
height: 100%;
83-
background: var(--text100);
84-
width: 0%;
85-
transition: width 0.15s ease-out;
86-
}
87-
88-
.progress-text {
89-
font-size: 0.7rem;
90-
color: var(--text200);
91-
}
92-
9371
/* Error State */
9472
.error {
9573
display: flex;
@@ -138,6 +116,8 @@ body {
138116
flex: 1;
139117
min-width: 0;
140118
overflow: hidden;
119+
display: flex;
120+
align-items: center;
141121
}
142122

143123
.pdf-title {
@@ -281,6 +261,47 @@ body {
281261
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
282262
}
283263

264+
/* Page Loading Overlay - shown while page data is being fetched */
265+
.page-loading-overlay {
266+
position: absolute;
267+
top: 0;
268+
left: 0;
269+
right: 0;
270+
bottom: 0;
271+
display: flex;
272+
align-items: center;
273+
justify-content: center;
274+
background: rgba(255, 255, 255, 0.9);
275+
border-radius: 4px;
276+
z-index: 10;
277+
}
278+
279+
.page-loading-content {
280+
display: flex;
281+
flex-direction: column;
282+
align-items: center;
283+
gap: 0.75rem;
284+
padding: 1.5rem 2rem;
285+
background: var(--bg000);
286+
border: 1px solid var(--bg200);
287+
border-radius: 8px;
288+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
289+
}
290+
291+
.page-loading-spinner {
292+
width: 32px;
293+
height: 32px;
294+
border: 3px solid var(--bg200);
295+
border-top-color: var(--text100);
296+
border-radius: 50%;
297+
animation: spin 0.8s linear infinite;
298+
}
299+
300+
.page-loading-text {
301+
font-size: 0.85rem;
302+
color: var(--text100);
303+
}
304+
284305
.text-layer :is(span, br) {
285306
color: transparent;
286307
position: absolute;
@@ -460,3 +481,30 @@ body {
460481
mix-blend-mode: screen;
461482
}
462483
}
484+
485+
/* Loading Indicator (pie) */
486+
.loading-indicator {
487+
display: inline-flex;
488+
align-items: center;
489+
margin-left: 0.5rem;
490+
flex-shrink: 0;
491+
transition: opacity 0.3s;
492+
}
493+
.loading-indicator-bg {
494+
fill: none;
495+
stroke: var(--bg200);
496+
stroke-width: 3;
497+
}
498+
.loading-indicator-arc {
499+
fill: none;
500+
stroke: var(--text100);
501+
stroke-width: 3;
502+
stroke-dasharray: 50.27; /* 2*pi*8 */
503+
stroke-dashoffset: 50.27;
504+
transform: rotate(-90deg);
505+
transform-origin: center;
506+
transition: stroke-dashoffset 0.3s;
507+
}
508+
.loading-indicator.error .loading-indicator-arc {
509+
stroke: #e74c3c;
510+
}

0 commit comments

Comments
 (0)