perf(pdf-server): lazy form extraction via range transport + incremental viewer scans (#639)

* perf(pdf-server): lazy form extraction via range transport + incremental viewer scans

Server: display_pdf now opens the document via PDFDataRangeTransport
(disableAutoFetch) and only runs the per-page form/annotation walk when
getFieldObjects() is non-empty, so form-free PDFs are probed with ~10-25%
of bytes instead of a full download. The unused viewFieldInfo Map is
removed.

Viewer: getDocument sets disableAutoFetch/disableStream; baseline
annotation scan and field-name mapping run lazily per rendered page
instead of walking every page after load, so first paint no longer
schedules a full-file pull.

E2E: new range-counting HTTPS fixture (W-9 for forms, generated
text+image PDF for no-forms) with stallAfterBytes control, and four
regression tests asserting form fields are returned, <30% served on
no-forms display_pdf, first page renders while later ranges are stalled,
and overlap stays bounded.

* fix(pdf-server): handle separated field/widget trees in extractFormSchema

pdfjs getFieldObjects() returns the full field-tree array. For PDFs with
a separated structure (pdf-lib, some authoring tools) the typed widget
sits at fields[1+] behind a typeless container at fields[0]; the previous
code only inspected fields[0] and skipped them all. Pick the first entry
with a non-empty type instead.

Makes the e2e forms.pdf fixture fully generated (no checked-in third-party
asset on the hot path); fw9.pdf stays as a unit-test fixture for the
hierarchical/XFA case.

* fix(pdf-server): address range-transport hangs and lazy-scan tombstone loss

PdfCacheRangeTransport:
- abort() is a no-op stub on PDFDataRangeTransport (it's the hook pdfjs
  calls *on* the transport, not an upstream error channel). Expose a
  .failed promise that rejects on the first fetch error and race every
  pdfjs await against it in display_pdf, so transient network errors
  surface into the existing catch instead of hanging the tool call.
- pdfjs coalesces adjacent missing chunks into one unbounded
  requestDataRange; readPdfRange clamps each call to MAX_CHUNK_BYTES.
  Loop and deliver in slices so every requested chunk is marked loaded.

Viewer (mcp-app.ts):
- The lazy per-page baseline scan left pdfBaselineAnnotations incomplete,
  so persistAnnotations and getAnnotatedPdfBytes silently dropped
  restoredRemovedIds tombstones for unvisited pages. Union those ids into
  the computed diff and removedRefs.

Test fixture: release stalled handlers before resetStats/close so a
failing stalled test doesn't hang afterAll; fail fast if started with
NODE_ENV=production. NODE_TLS_REJECT_UNAUTHORIZED scope documented (full
per-process scoping needs a validateUrl localhost allow, tracked
separately).

* test(pdf-server): bridge coverage gaps and switch fixture to gated loopback HTTP

PdfCacheRangeTransport.deliver(): pdf.js's reader is keyed by the
original begin and removed after one delivery, so accumulate slices and
call onDataRange once with the full buffer (the previous multi-call
approach threw inside pdfjs). Covered by a new integration test that
drives getDocument()/getPage(1) on a >1MB PDF through a clamping
in-memory readPdfRange.

validateUrl: allow http://127.0.0.1|localhost|[::1] only when
PDF_SERVER_ALLOW_LOOPBACK_HTTP is set, so a remote deploy can't be made
to probe its own ports. Covered by env-on/off unit tests.

Fixture switched to plain HTTP (no openssl, no
NODE_TLS_REJECT_UNAUTHORIZED). Adds /error.pdf (500s after 50KB) and two
e2e tests: page 2 renders after stall release (>512KB object path), and
display_pdf returns gracefully on mid-load 500. Existing <30% test now
samples stats before the iframe loads.

New unit tests: display_pdf returns (not hangs) on mid-load fetch
failure via in-memory MCP client; computeDiff/serializeDiff contract
tests pinning the restoredRemovedIds tombstone-preservation behaviour.

* test(pdf-server): e2e for restoredRemovedIds tombstone preservation through viewer

Adds /with-native-annot.pdf fixture (2 pages, native /Text annot on
page 2) and a Playwright test that drives delete on page 2 → iframe
reload → interact add_annotations on page 1 (persist with page 2
unscanned) → assert localStorage diff still contains the removed id →
page 2 still shows it gone. Covers the mcp-app.ts glue the unit tests
can't reach.

* refactor(pdf-server): simplify per round-2 review and fix false-pass test

server.ts: extract probeFormFields() so display_pdf calls one helper
instead of inlining 40 lines of transport/orFail plumbing; make
extractFormSchema's fieldObjects param required (the optional branch
only existed for a test helper).

server.test.ts: the >1MB integration test now forces the image XObject
fetch via getOperatorList() and asserts max requestDataRange span >
MAX_CHUNK_BYTES, so it can't go vacuous. Dedupe makeRandomJpeg by
importing from the fixture.

mcp-app.ts: restore per-annotation try/catch isolation in the lazy
baseline scan (a throw was skipping AnnotationLayer.render); only
carry forward restoredRemovedIds while the baseline scan is incomplete
so a stale id can't pin dirty=true once every page is scanned.

tests: bump fixture JPEG to 1.1MB; consolidate 7 e2e tests to 4 (merge
overlap into the byte-budget test, merge stall+page-2, drop the
error-e2e duplicate of the unit integration test); delete
run-fixture.mjs; drop /error.pdf and ERROR_AFTER_BYTES; rename
tombstone test's describe and clarify page-2 covers the viewer
transport.

* fix(pdf-server): preserve partial probeFormFields results when extractFormFieldInfo throws

* test(pdf-server): fix tombstone e2e — deleted native annotations become a cleared card, not removed from DOM

* test(pdf-server): mark tombstone e2e as fixme — needs basic-host iframe-reload replay support

* test(pdf-server): link tombstone fixme to tracking issue #642
This commit is contained in:
Olivier Chafik 2026-04-27 15:32:53 +01:00 • committed by GitHub
commit ba31be1f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1133 additions and 106 deletions

View file

@ -4,9 +4,15 @@ import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { PDFDocument } from "pdf-lib";
import { makeRandomJpeg } from "../../tests/helpers/range-counting-server";
import {
createPdfCache,
createServer,
extractFormSchema,
PdfCacheRangeTransport,
MAX_CHUNK_BYTES,
validateUrl,
isAncestorDir,
allowedLocalFiles,
@ -289,6 +295,266 @@ describe("PDF Cache with Timeouts", () => {
// through manual testing or E2E tests.
});
describe("PdfCacheRangeTransport", () => {
it("accumulates ranges larger than MAX_CHUNK_BYTES into one onDataRange call", async () => {
const big = MAX_CHUNK_BYTES * 2 + 100;
const reads: Array<[number, number]> = [];
const t = new PdfCacheRangeTransport("u", big, async (_u, off, n) => {
reads.push([off, n]);
return {
data: new Uint8Array(Math.min(n, MAX_CHUNK_BYTES)),
totalBytes: big,
};
});
const delivered: Array<[number, number]> = [];
t.addRangeListener((begin: number, chunk: Uint8Array) =>
delivered.push([begin, chunk.length]),
);
t.requestDataRange(0, big);
await new Promise((r) => setTimeout(r, 10));
// pdf.js's reader is keyed by the original begin and removed after one
// delivery, so deliver() must call onDataRange exactly once with the
// accumulated buffer — multiple calls would throw inside pdfjs.
expect(delivered).toEqual([[0, big]]);
expect(reads).toEqual([
[0, MAX_CHUNK_BYTES],
[MAX_CHUNK_BYTES, MAX_CHUNK_BYTES],
[MAX_CHUNK_BYTES * 2, 100],
]);
});
it("rejects .failed when a range fetch errors instead of hanging", async () => {
const t = new PdfCacheRangeTransport("u", 1000, async () => {
throw new Error("network down");
});
t.requestDataRange(0, 100);
await expect(
Promise.race([
t.failed,
new Promise((r) => setTimeout(() => r("timeout"), 200)),
]),
).rejects.toThrow("network down");
});
it("rejects .failed on zero-length response (would otherwise spin)", async () => {
const t = new PdfCacheRangeTransport("u", 1000, async () => ({
data: new Uint8Array(0),
totalBytes: 1000,
}));
t.requestDataRange(0, 100);
await expect(t.failed).rejects.toThrow(/empty range/);
});
it("getDocument resolves on a >1MB PDF when readPdfRange clamps to MAX_CHUNK_BYTES", async () => {
// pdfjs coalesces adjacent missing chunks into one requestDataRange that
// can exceed MAX_CHUNK_BYTES. deliver() must accumulate clamped reads and
// hand pdfjs a single onDataRange(begin, fullBuffer). This test fails if
// deliver() either truncates or calls onDataRange more than once per
// requestDataRange (pdf.mjs _onReceiveData matches by exact begin).
const d = await PDFDocument.create();
const img = await d.embedJpg(makeRandomJpeg(1_100_000));
const page = d.addPage([612, 792]);
page.drawImage(img, { x: 36, y: 36, width: 540, height: 720 });
const bytes = await d.save();
expect(bytes.length).toBeGreaterThan(2 * MAX_CHUNK_BYTES);
const readClamped: PdfCache["readPdfRange"] = async (_u, off, n) => {
const len = Math.min(n, MAX_CHUNK_BYTES, bytes.length - off);
return { data: bytes.slice(off, off + len), totalBytes: bytes.length };
};
// Record the spans pdfjs actually requests so the test fails fast if it
// never asks for >MAX_CHUNK_BYTES (i.e. can't go vacuously green).
const spans: number[] = [];
class RecordingTransport extends PdfCacheRangeTransport {
override requestDataRange(begin: number, end: number): void {
spans.push(end - begin);
super.requestDataRange(begin, end);
}
}
const transport = new RecordingTransport(
"mem://big",
bytes.length,
readClamped,
);
const orHang = <T>(p: Promise<T>, what: string): Promise<T> =>
Promise.race([
p,
transport.failed,
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error(`${what} hung`)), 5000),
),
]);
const doc = await orHang(
getDocument({
range: transport,
length: bytes.length,
disableAutoFetch: true,
disableStream: true,
rangeChunkSize: 64 * 1024,
}).promise,
"getDocument",
);
const p1 = await orHang(doc.getPage(1), "getPage");
// getPage() alone doesn't decode the image XObject; getOperatorList() does,
// which is what triggers the >512KB coalesced range request.
await orHang(p1.getOperatorList(), "getOperatorList");
expect(Math.max(...spans)).toBeGreaterThan(MAX_CHUNK_BYTES);
doc.destroy();
});
});
describe("display_pdf transport-error handling", () => {
it("returns (does not hang) when range fetches fail mid-load", async () => {
// First fetch = the 1-byte size probe → 206 with Content-Range so
// display_pdf gets totalBytes. Every subsequent fetch (made by
// PdfCacheRangeTransport via readPdfRange) rejects, which must surface
// through transport.failed → orFail() → outer catch, not hang.
let calls = 0;
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
async () => {
if (calls++ === 0) {
return new Response(new Uint8Array(1), {
status: 206,
headers: { "Content-Range": "bytes 0-0/50000" },
});
}
throw new Error("network down");
},
);
const server = createServer();
const client = new Client({ name: "t", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
try {
const result = await Promise.race([
client.callTool({
name: "display_pdf",
arguments: { url: "https://arxiv.org/pdf/err-test" },
}),
new Promise<never>((_, rej) =>
setTimeout(
() => rej(new Error("display_pdf hung on transport error")),
3000,
),
),
]);
expect(result.isError).toBeFalsy();
const sc = result.structuredContent as { formFields?: unknown };
expect(sc.formFields).toBeUndefined();
expect(calls).toBeGreaterThan(1);
} finally {
mockFetch.mockRestore();
await client.close();
await server.close();
}
});
});
describe("extractFormSchema field-tree handling", () => {
async function schemaFor(bytes: Uint8Array) {
const doc = await getDocument({ data: bytes }).promise;
try {
const fo = (await doc.getFieldObjects()) as Parameters<
typeof extractFormSchema
>[1];
return await extractFormSchema(doc, fo);
} finally {
doc.destroy();
}
}
it("handles pdf-lib separated field/widget structure", async () => {
const d = await PDFDocument.create();
const form = d.getForm();
d.addPage([612, 792]);
form
.createTextField("alpha")
.addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 });
form
.createCheckBox("agree")
.addToPage(d.getPage(0), { x: 50, y: 660, width: 20, height: 20 });
form
.createDropdown("choice")
.addToPage(d.getPage(0), { x: 50, y: 620, width: 100, height: 20 });
const schema = await schemaFor(await d.save());
expect(schema).not.toBeNull();
expect(schema!.properties.alpha).toEqual({
type: "string",
title: "alpha",
});
expect(schema!.properties.agree).toEqual({
type: "boolean",
title: "agree",
});
expect(schema!.properties.choice.type).toBe("string");
});
it("handles fields with multiple widgets across pages", async () => {
const d = await PDFDocument.create();
const form = d.getForm();
d.addPage([612, 792]);
d.addPage([612, 792]);
const tf = form.createTextField("shared");
tf.addToPage(d.getPage(0), { x: 50, y: 700, width: 200, height: 20 });
tf.addToPage(d.getPage(1), { x: 50, y: 700, width: 200, height: 20 });
const schema = await schemaFor(await d.save());
expect(schema?.properties.shared).toEqual({
type: "string",
title: "shared",
});
});
it("skips container nodes and finds leaf fields (W-9 style)", async () => {
const bytes = fs.readFileSync(
path.join(__dirname, "../../tests/helpers/assets/fw9.pdf"),
);
const doc = await getDocument({ data: new Uint8Array(bytes) }).promise;
try {
const fo = (await doc.getFieldObjects()) as Parameters<
typeof extractFormSchema
>[1];
// Container nodes (no leaf type) should not crash extraction
expect(fo!["topmostSubform[0]"]).toBeDefined();
// Schema is null for W-9 (mechanical names), but extraction must not throw
const schema = await extractFormSchema(doc, fo);
expect(schema).toBeNull();
} finally {
doc.destroy();
}
});
it("returns null when no AcroForm present", async () => {
const d = await PDFDocument.create();
d.addPage([612, 792]);
const schema = await schemaFor(await d.save());
expect(schema).toBeNull();
});
});
describe("validateUrl loopback HTTP allow (PDF_SERVER_ALLOW_LOOPBACK_HTTP)", () => {
it("rejects http://127.0.0.1 by default", () => {
expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(false);
});
it("accepts http://127.0.0.1 only when the env gate is set, and never non-loopback http", () => {
const prev = process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP;
process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = "1";
try {
expect(validateUrl("http://127.0.0.1:9999/x.pdf").valid).toBe(true);
expect(validateUrl("http://169.254.169.254/").valid).toBe(false);
} finally {
if (prev === undefined) delete process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP;
else process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP = prev;
}
});
});
describe("validateUrl with MCP roots (allowedLocalDirs)", () => {
const savedFiles = new Set(allowedLocalFiles);
const savedDirs = new Set(allowedLocalDirs);

View file

@ -31,6 +31,7 @@ import {
import "./pdfjs-polyfill.js";
import {
getDocument,
PDFDataRangeTransport,
VerbosityLevel,
version as PDFJS_VERSION,
} from "pdfjs-dist/legacy/build/pdf.mjs";
@ -368,9 +369,6 @@ export const viewSourcePaths = new Map<string, string>();
/** Valid form field names per viewer UUID (populated during display_pdf) */
const viewFieldNames = new Map<string, Set<string>>();
/** Detailed form field info per viewer UUID (populated during display_pdf) */
const viewFieldInfo = new Map<string, FormFieldInfo[]>();
/**
* Active fs.watch per view. Only created for local files when interact is
* enabled (stdio). Watcher is re-established on `rename` events to survive
@ -387,7 +385,7 @@ const viewFileWatches = new Map<string, ViewFileWatch>();
/**
* Per-view heartbeat. THIS is what the sweep iterates — not commandQueues.
*
* Why not commandQueues: display_pdf populates viewFieldNames/viewFieldInfo/
* Why not commandQueues: display_pdf populates viewFieldNames/
* viewFileWatches but never touches commandQueues (only enqueueCommand does,
* and it's triply gated). And dequeueCommands deletes the entry on every poll,
* so even when it exists the sweep's TTL window is ~200ms wide. Net effect:
@ -409,7 +407,6 @@ function pruneStaleQueues(): void {
viewLastActivity.delete(uuid);
commandQueues.delete(uuid);
viewFieldNames.delete(uuid);
viewFieldInfo.delete(uuid);
viewsPolled.delete(uuid);
viewSourcePaths.delete(uuid);
stopFileWatch(uuid);
@ -644,10 +641,19 @@ export function validateUrl(url: string): {
// Remote URL - require HTTPS
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
if (parsed.protocol === "https:") return { valid: true };
// Loopback HTTP is opt-in (test fixtures, local dev). Off by default so a
// remotely-deployed server can't be made to probe its own internal ports.
if (
process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP &&
parsed.protocol === "http:" &&
(parsed.hostname === "127.0.0.1" ||
parsed.hostname === "localhost" ||
parsed.hostname === "[::1]")
) {
return { valid: true };
}
return { valid: true };
return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
} catch {
return { valid: false, error: `Invalid URL: ${url}` };
}
@ -882,6 +888,61 @@ export function createPdfCache(
};
}
/**
* pdf.js range transport backed by {@link PdfCache.readPdfRange}. Lets
* getDocument() fetch only the byte ranges it needs (xref, /AcroForm dict)
* instead of the whole file. With disableAutoFetch, a PDF without form
* fields is opened with ~5% of bytes fetched.
*
* pdf.js has no upstream error channel on PDFDataRangeTransport (its
* `abort()` is a no-op stub it calls *on* us, not the other way). Callers
* must `Promise.race` their pdf.js awaits against {@link failed}, which
* rejects on the first fetch error.
*/
export class PdfCacheRangeTransport extends PDFDataRangeTransport {
/** Rejects on the first range-fetch error; never resolves. */
readonly failed: Promise<never>;
private fail!: (e: unknown) => void;
constructor(
private url: string,
length: number,
private readPdfRange: PdfCache["readPdfRange"],
) {
super(length, null);
this.failed = new Promise<never>((_, reject) => {
this.fail = reject;
});
// Don't crash the process if no one is racing yet.
this.failed.catch(() => {});
}
override requestDataRange(begin: number, end: number): void {
void this.deliver(begin, end).catch((e) => this.fail(e));
}
/**
* pdf.js coalesces adjacent missing chunks into one unbounded request, but
* readPdfRange clamps each call to MAX_CHUNK_BYTES. Its reader is keyed by
* the original `begin` and removed after one delivery, so we must accumulate
* slices and call onDataRange exactly once with the full buffer.
*/
private async deliver(begin: number, end: number): Promise<void> {
const buf = new Uint8Array(end - begin);
let off = 0;
while (off < buf.length) {
const want = Math.min(buf.length - off, MAX_CHUNK_BYTES);
const { data } = await this.readPdfRange(this.url, begin + off, want);
if (data.length === 0) {
throw new Error(`empty range at ${begin + off} for ${this.url}`);
}
buf.set(data.subarray(0, Math.min(data.length, buf.length - off)), off);
off += data.length;
}
this.onDataRange(begin, buf);
}
}
// =============================================================================
// MCP Roots
// =============================================================================
@ -957,6 +1018,60 @@ interface FormFieldInfo {
options?: string[];
}
/**
* Open `url` via {@link PdfCacheRangeTransport} and return form metadata.
* Uses `disableAutoFetch` so PDFs without an AcroForm are probed with only
* the trailer/xref/catalog (~5-25% of bytes); PDFs with forms still walk
* every page via {@link extractFormFieldInfo} but those are typically small.
* All errors (including range-fetch failures surfaced via
* {@link PdfCacheRangeTransport.failed}) resolve to empty results.
*/
async function probeFormFields(
url: string,
totalBytes: number,
readPdfRange: PdfCache["readPdfRange"],
): Promise<{
formSchema: Awaited<ReturnType<typeof extractFormSchema>>;
fieldInfo: FormFieldInfo[];
}> {
// Assigned sequentially below so a throw in extractFormFieldInfo (no per-page
// guard, unlike extractFormSchema) doesn't discard an already-computed schema.
let formSchema: Awaited<ReturnType<typeof extractFormSchema>> = null;
let fieldInfo: FormFieldInfo[] = [];
try {
const transport = new PdfCacheRangeTransport(url, totalBytes, readPdfRange);
const orFail = <T>(p: Promise<T>): Promise<T> =>
Promise.race([p, transport.failed]);
const pdfDoc = await orFail(
getDocument({
range: transport,
length: totalBytes,
disableAutoFetch: true,
disableStream: true,
rangeChunkSize: 64 * 1024,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
StandardFontDataFactory: FetchStandardFontDataFactory,
verbosity: VerbosityLevel.ERRORS,
}).promise,
);
try {
const fieldObjects = (await orFail(pdfDoc.getFieldObjects())) as Record<
string,
PdfJsFieldObject[]
> | null;
if (fieldObjects && Object.keys(fieldObjects).length > 0) {
formSchema = await orFail(extractFormSchema(pdfDoc, fieldObjects));
fieldInfo = await orFail(extractFormFieldInfo(pdfDoc));
}
} finally {
pdfDoc.destroy();
}
} catch {
// Non-fatal — return whatever was assigned before the throw.
}
return { formSchema, fieldInfo };
}
/**
* Extract detailed form field info (name, type, page, bounding box, label)
* from a PDF. Bounding boxes are converted to model coordinates (top-left origin).
@ -1020,27 +1135,25 @@ async function extractFormFieldInfo(
return fields;
}
async function extractFormSchema(pdfDoc: PDFDocumentProxy): Promise<{
export async function extractFormSchema(
pdfDoc: PDFDocumentProxy,
fieldObjects: Record<string, PdfJsFieldObject[]> | null,
): Promise<{
type: "object";
properties: Record<string, PrimitiveSchemaDefinition>;
required?: string[];
} | null> {
let fieldObjects: Record<string, PdfJsFieldObject[]> | null;
try {
fieldObjects = (await pdfDoc.getFieldObjects()) as Record<
string,
PdfJsFieldObject[]
> | null;
} catch {
return null;
}
if (!fieldObjects || Object.keys(fieldObjects).length === 0) {
return null;
}
const properties: Record<string, PrimitiveSchemaDefinition> = {};
for (const [name, fields] of Object.entries(fieldObjects)) {
const field = fields[0]; // first widget determines the type
// pdfjs returns the full field-tree array: for separated structures
// (pdf-lib) the typed widget is at [1+] behind a container at [0]; for
// merged/leaf entries (W-9, most authoring tools) it's at [0]. Pick the
// first entry that actually has a field type.
const field = fields.find((f) => f.type) ?? fields[0];
if (!field.editable) continue;
switch (field.type) {
@ -1434,7 +1547,7 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before
const { totalBytes } = await readPdfRange(normalized, 0, 1);
const uuid = randomUUID();
// Start the heartbeat now so the sweep can clean up viewFieldNames/
// viewFieldInfo/viewFileWatches even if no interact calls ever happen.
// viewFileWatches even if no interact calls ever happen.
if (!disableInteract) touchView(uuid);
// Check writability (governs save button; see isWritablePath doc).
@ -1462,38 +1575,19 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before
}
}
// Extract form field schema + detailed field info from a single
// download/parse pass.
let formSchema: Awaited<ReturnType<typeof extractFormSchema>> = null;
let fieldInfo: FormFieldInfo[] = [];
try {
const { data } = await readPdfRange(normalized, 0, totalBytes);
const pdfDoc = await getDocument({
data,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
StandardFontDataFactory: FetchStandardFontDataFactory,
verbosity: VerbosityLevel.ERRORS,
}).promise;
try {
formSchema = await extractFormSchema(pdfDoc);
fieldInfo = await extractFormFieldInfo(pdfDoc);
} finally {
pdfDoc.destroy();
}
} catch {
// Non-fatal — PDF may not have form fields or may fail to parse
}
const { formSchema, fieldInfo } = await probeFormFields(
normalized,
totalBytes,
readPdfRange,
);
if (formSchema) {
viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties)));
}
if (fieldInfo.length > 0) {
viewFieldInfo.set(uuid, fieldInfo);
if (!viewFieldNames.has(uuid)) {
viewFieldNames.set(
uuid,
new Set(fieldInfo.map((f) => f.name).filter(Boolean)),
);
}
if (fieldInfo.length > 0 && !viewFieldNames.has(uuid)) {
viewFieldNames.set(
uuid,
new Set(fieldInfo.map((f) => f.name).filter(Boolean)),
);
}
// Elicit form field values if requested and client supports it

View file

@ -150,6 +150,11 @@ const imageCache = new Map<string, HTMLImageElement>();
/** Annotations imported from the PDF file (baseline for diff computation). */
let pdfBaselineAnnotations: PdfAnnotationDef[] = [];
/** Pages whose native annotations have already been imported into the baseline. */
const baselineScannedPages = new Set<number>();
/** Native-annotation ids the user deleted (from restored localStorage diff) —
* the lazy per-page scan must NOT re-add these to annotationMap. */
const restoredRemovedIds = new Set<string>();
// Dirty flag — tracks unsaved local changes
let isDirty = false;
@ -2679,52 +2684,60 @@ function annotationStorageKey(): string | null {
}
/**
* Import annotations from the loaded PDF to establish the baseline.
* These are the annotations that exist in the PDF file itself.
* Import one page's native annotations into the baseline. Called lazily from
* renderPage() so we don't walk every page (and pull most of the file via
* range requests) before the user sees anything. Idempotent per page.
*/
async function loadBaselineAnnotations(
doc: pdfjsLib.PDFDocumentProxy,
): Promise<void> {
pdfBaselineAnnotations = [];
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
function scanPageBaselineAnnotations(
pageNum: number,
annotations: unknown[],
): void {
if (baselineScannedPages.has(pageNum)) return;
baselineScannedPages.add(pageNum);
let imported = 0;
for (let i = 0; i < annotations.length; i++) {
// Isolate each annotation: a malformed one must not bubble up to the
// caller's form-layer try in renderPage() (which would skip
// AnnotationLayer.render and hide form widgets for the whole page).
try {
const page = await doc.getPage(pageNum);
const annotations = await page.getAnnotations();
for (let i = 0; i < annotations.length; i++) {
const ann = annotations[i];
const def = importPdfjsAnnotation(ann, pageNum, i);
if (def) {
pdfBaselineAnnotations.push(def);
// Add to annotationMap if not already present (from localStorage restore)
if (!annotationMap.has(def.id)) {
annotationMap.set(def.id, { def, elements: [] });
}
} else if (ann.annotationType !== 20) {
// Widget (type 20) is expected to be skipped; anything else we
// don't import will still be painted by page.render() onto the
// canvas as unselectable pixels. Log so we can diagnose
// "ghost annotations" (visible but not in panel, not clickable).
log.info(
`[WARN] Baseline: skipped PDF annotation on page ${pageNum}`,
`type=${ann.annotationType}`,
`subtype=${ann.subtype ?? "?"}`,
`name=${ann.name ?? "?"}`,
`rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`,
);
const ann = annotations[i] as {
annotationType?: number;
subtype?: string;
name?: string;
rect?: number[];
};
const def = importPdfjsAnnotation(ann, pageNum, i);
if (def) {
pdfBaselineAnnotations.push(def);
imported++;
if (!annotationMap.has(def.id) && !restoredRemovedIds.has(def.id)) {
annotationMap.set(def.id, { def, elements: [] });
}
} else if (ann.annotationType !== 20) {
// Widget (type 20) is expected to be skipped; anything else we
// don't import will still be painted by page.render() onto the
// canvas as unselectable pixels. Log so we can diagnose
// "ghost annotations" (visible but not in panel, not clickable).
log.info(
`[WARN] Baseline: skipped PDF annotation on page ${pageNum}`,
`type=${ann.annotationType}`,
`subtype=${ann.subtype ?? "?"}`,
`name=${ann.name ?? "?"}`,
`rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`,
);
}
} catch (err) {
// Log the error — a thrown import for one annotation silently
// drops the REST of that page's annotations too.
log.info(
`[WARN] Baseline: page ${pageNum} annotation import failed:`,
err,
);
log.info(`Baseline: page ${pageNum} annotation import failed`, err);
}
}
if (imported > 0) {
try {
updateAnnotationsBadge();
renderAnnotationPanel();
} catch (err) {
log.info(`Baseline: page ${pageNum} panel update failed`, err);
}
}
log.info(
`Loaded ${pdfBaselineAnnotations.length} baseline annotations from PDF`,
);
}
function persistAnnotations(): void {
@ -2740,6 +2753,20 @@ function persistAnnotations(): void {
pdfBaselineFormValues,
);
// computeDiff only sees baseline ids from pages we've already scanned.
// Carry forward restored tombstones for unvisited pages so the first
// persist after restore doesn't drop them. Once every page is scanned the
// baseline is complete and computeDiff is authoritative on its own —
// dropping the carry-forward then also stops a stale id (no longer in the
// file) from pinning dirty=true forever.
if (baselineScannedPages.size < totalPages) {
for (const id of restoredRemovedIds) {
if (!annotationMap.has(id) && !diff.removed.includes(id)) {
diff.removed.push(id);
}
}
}
// Dirty tracks whether there are unsaved changes. Undoing back to baseline
// yields an empty diff → clean again → save button disables.
if (!isRestoring) setDirty(!isDiffEmpty(diff));
@ -2765,11 +2792,11 @@ function restoreAnnotations(): void {
const diff = deserializeDiff(raw);
// Merge baseline + diff. The loop below is add-only, so we MUST also
// delete: loadBaselineAnnotations() runs between the two restore calls
// and re-seeds annotationMap with every baseline id — including the
// ones in diff.removed. Without this, the zombie survives the restore,
// and the next persistAnnotations() sees it in currentIds → computeDiff
// produces removed=[] → the deletion is permanently lost from storage.
// delete: the per-page baseline scan re-seeds annotationMap with every
// native id it encounters — including ones in diff.removed. Without the
// deletes here AND the restoredRemovedIds tombstones below, the zombie
// survives, and the next persistAnnotations() sees it in currentIds →
// computeDiff produces removed=[] → the deletion is permanently lost.
const merged = mergeAnnotations(pdfBaselineAnnotations, diff);
for (const def of merged) {
if (!annotationMap.has(def.id)) {
@ -2778,6 +2805,9 @@ function restoreAnnotations(): void {
}
for (const id of diff.removed) {
annotationMap.delete(id);
// Tombstone so the lazy per-page baseline scan (which runs AFTER this
// restore) doesn't resurrect it.
restoredRemovedIds.add(id);
}
// Restore form fields
@ -2869,6 +2899,14 @@ async function buildFieldNameMap(
// getFieldObjects may fail on some PDFs
}
// No AcroForm → nothing to map. Skip the per-page widget walk so form-free
// PDFs (the common large case) don't pull every page after first paint.
// getFieldObjects() itself only reads the catalog/AcroForm dict via range
// transport, so this gate is cheap.
if (!cachedFieldObjects || Object.keys(cachedFieldObjects).length === 0) {
return false;
}
// Scan every page's widget annotations to collect the CORRECT storage keys,
// plus labels, pages, positions, AND fieldValue (what the widget renders
// — which can differ from getFieldObjects().value if the PDF is internally
@ -3074,11 +3112,19 @@ async function getAnnotatedPdfBytes(): Promise<Uint8Array> {
}
// Baseline annotations the user deleted: strip their refs from /Annots so
// they don't reappear on reload. Ids without a recoverable ref (page-index
// fallback) can't be removed by-ref and are skipped.
const removedRefs = pdfBaselineAnnotations
.filter((a) => !annotationMap.has(a.id))
.map((a) => parseAnnotationRef(a.id))
// they don't reappear on reload. Include restored tombstones for pages we
// haven't scanned yet — those ids aren't in pdfBaselineAnnotations but the
// ref is still parseable from the id string. Ids without a recoverable ref
// (page-index fallback) can't be removed by-ref and are skipped.
const removedIds = new Set<string>();
for (const a of pdfBaselineAnnotations) {
if (!annotationMap.has(a.id)) removedIds.add(a.id);
}
for (const id of restoredRemovedIds) {
if (!annotationMap.has(id)) removedIds.add(id);
}
const removedRefs = [...removedIds]
.map(parseAnnotationRef)
.filter((r): r is NonNullable<typeof r> => r !== null);
// Only write fields that actually changed vs. what's already in the PDF.
@ -3362,6 +3408,9 @@ async function renderPage() {
formLayerEl.style.setProperty("--total-scale-factor", `${scale}`);
try {
const annotations = await page.getAnnotations();
// Lazy baseline import — piggyback on the annotations we just fetched
// for this page instead of walking all pages upfront.
scanPageBaselineAnnotations(pageToRender, annotations);
if (annotations.length > 0) {
const linkService = {
getDestinationHash: () => "#",
@ -4406,6 +4455,8 @@ async function reloadPdf(): Promise<void> {
undoStack.length = 0;
redoStack.length = 0;
pdfBaselineAnnotations = [];
baselineScannedPages.clear();
restoredRemovedIds.clear();
pdfBaselineFormValues.clear();
pageTextCache.clear();
pageTextItemsCache.clear();
@ -4449,11 +4500,11 @@ async function reloadPdf(): Promise<void> {
log.info("PDF reloaded:", totalPages, "pages,", totalBytes, "bytes");
showViewer();
// Render immediately — annotation/form scans below are O(numPages) and
// do NOT block the canvas. See same pattern in the initial-load path.
// Render immediately — baseline-annotation scan now happens per-page
// inside renderPage(); buildFieldNameMap below early-returns when no
// AcroForm is present. See same pattern in the initial-load path.
await renderPage();
await loadBaselineAnnotations(document);
const seeded = await buildFieldNameMap(document);
syncFormValuesToStorage();
if (seeded) await renderPage();
@ -4509,6 +4560,11 @@ async function loadPdfProgressively(urlToLoad: string): Promise<{
const loadingTask = pdfjsLib.getDocument({
range: transport,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
// Only fetch ranges renderPage()/getFieldObjects() actually ask for.
// Without these pdfjs background-prefetches the whole file regardless of
// the per-page lazy scans below.
disableAutoFetch: true,
disableStream: true,
});
try {
@ -4673,12 +4729,13 @@ app.ontoolresult = async (result: CallToolResult) => {
scale = fitScale;
log.info("Initial fit scale:", scale);
}
await renderPage();
// Import annotations from the PDF to establish baseline
await loadBaselineAnnotations(document);
// Restore any persisted user diff
// Restore any persisted user diff BEFORE first render so the per-page
// baseline scan inside renderPage() can honour the removed-id tombstones
// and not resurrect annotations the user deleted last session.
// restoreAnnotations is sync (localStorage read) so first paint is not
// delayed.
restoreAnnotations();
await renderPage();
// Build field name → annotation ID mapping for form filling
const seeded = await buildFieldNameMap(document);

View file

@ -367,6 +367,92 @@ describe("computeDiff", () => {
current.map((a) => a.id).sort(),
);
});
// Backs the post-computeDiff union step in mcp-app.ts persistAnnotations /
// getAnnotatedPdfBytes: with the lazy per-page baseline scan, computeDiff
// alone cannot produce `removed` for natives on pages not yet visited, so
// callers must union restoredRemovedIds. These tests pin the contract those
// call sites depend on.
describe("partial baseline (lazy-scan tombstone preservation)", () => {
const tombstoned = "pdf-5-0";
const userNote: PdfAnnotationDef = {
type: "note",
id: "u1",
page: 1,
x: 10,
y: 10,
content: "unrelated edit",
};
/** Mirrors the union loop in mcp-app.ts persistAnnotations. */
function unionRestored(
diff: AnnotationDiff,
restored: Iterable<string>,
currentIds: Set<string>,
): void {
for (const id of restored) {
if (!currentIds.has(id) && !diff.removed.includes(id)) {
diff.removed.push(id);
}
}
}
it("computeDiff alone drops tombstones for unscanned pages; the union step preserves them", () => {
const baseline: PdfAnnotationDef[] = []; // page with the native not yet scanned
const current = [userNote];
const diff = computeDiff(baseline, current, new Map());
expect(diff.removed).toEqual([]); // proves the union step is load-bearing
unionRestored(diff, [tombstoned], new Set(current.map((a) => a.id)));
expect(diff.removed).toEqual([tombstoned]);
expect(isDiffEmpty(diff)).toBe(false);
expect(deserializeDiff(serializeDiff(diff)).removed).toEqual([
tombstoned,
]);
});
it("a tombstone the user re-added is excluded from the union", () => {
const reAdded: PdfAnnotationDef = {
type: "note",
id: tombstoned,
page: 3,
x: 0,
y: 0,
content: "back",
};
const current = [userNote, reAdded];
const diff = computeDiff([], current, new Map());
unionRestored(diff, [tombstoned], new Set(current.map((a) => a.id)));
expect(diff.removed).toEqual([]);
});
it("union does not duplicate ids once the page is scanned and computeDiff produces them", () => {
const native: PdfAnnotationDef = {
type: "note",
id: tombstoned,
page: 3,
x: 0,
y: 0,
content: "native",
};
const diff = computeDiff([native], [userNote], new Map());
expect(diff.removed).toEqual([tombstoned]);
unionRestored(diff, [tombstoned], new Set([userNote.id]));
expect(diff.removed).toEqual([tombstoned]);
});
it("removedRefs from restored tombstones parse for buildAnnotatedPdfBytes; non-ref ids are skipped", () => {
const restored = new Set(["pdf-5-0", "pdf-12R", "pdf-2-idx-7"]);
const removedRefs = [...restored]
.map(parseAnnotationRef)
.filter((r): r is NonNullable<typeof r> => r !== null);
expect(removedRefs).toEqual([
{ objectNumber: 5, generationNumber: 0 },
{ objectNumber: 12, generationNumber: 0 },
]);
});
});
});
// =============================================================================