diff --git a/examples/basic-server-react/grid-cell.png b/examples/basic-server-react/grid-cell.png
index e6a309d7..2136c161 100644
Binary files a/examples/basic-server-react/grid-cell.png and b/examples/basic-server-react/grid-cell.png differ
diff --git a/examples/basic-server-react/screenshot.png b/examples/basic-server-react/screenshot.png
index 31df3010..b9a9245a 100644
Binary files a/examples/basic-server-react/screenshot.png and b/examples/basic-server-react/screenshot.png differ
diff --git a/examples/budget-allocator-server/grid-cell.png b/examples/budget-allocator-server/grid-cell.png
index dee5a313..b2019ff0 100644
Binary files a/examples/budget-allocator-server/grid-cell.png and b/examples/budget-allocator-server/grid-cell.png differ
diff --git a/examples/budget-allocator-server/screenshot.png b/examples/budget-allocator-server/screenshot.png
index 1bd9783c..b50b9ac9 100644
Binary files a/examples/budget-allocator-server/screenshot.png and b/examples/budget-allocator-server/screenshot.png differ
diff --git a/examples/cohort-heatmap-server/grid-cell.png b/examples/cohort-heatmap-server/grid-cell.png
index 4349361f..21d188e2 100644
Binary files a/examples/cohort-heatmap-server/grid-cell.png and b/examples/cohort-heatmap-server/grid-cell.png differ
diff --git a/examples/cohort-heatmap-server/screenshot.png b/examples/cohort-heatmap-server/screenshot.png
index e469da81..21d9ecd8 100644
Binary files a/examples/cohort-heatmap-server/screenshot.png and b/examples/cohort-heatmap-server/screenshot.png differ
diff --git a/examples/customer-segmentation-server/grid-cell.png b/examples/customer-segmentation-server/grid-cell.png
index 992f95b7..70b2edb6 100644
Binary files a/examples/customer-segmentation-server/grid-cell.png and b/examples/customer-segmentation-server/grid-cell.png differ
diff --git a/examples/customer-segmentation-server/screenshot.png b/examples/customer-segmentation-server/screenshot.png
index e643013d..f75c8893 100644
Binary files a/examples/customer-segmentation-server/screenshot.png and b/examples/customer-segmentation-server/screenshot.png differ
diff --git a/examples/debug-server/grid-cell.png b/examples/debug-server/grid-cell.png
index 2f1814f7..fe8d0647 100644
Binary files a/examples/debug-server/grid-cell.png and b/examples/debug-server/grid-cell.png differ
diff --git a/examples/debug-server/screenshot.png b/examples/debug-server/screenshot.png
index 869fec08..4be854d9 100644
Binary files a/examples/debug-server/screenshot.png and b/examples/debug-server/screenshot.png differ
diff --git a/examples/map-server/grid-cell.png b/examples/map-server/grid-cell.png
index 41ebc224..f680d997 100644
Binary files a/examples/map-server/grid-cell.png and b/examples/map-server/grid-cell.png differ
diff --git a/examples/map-server/screenshot.png b/examples/map-server/screenshot.png
index 97c91912..edd01512 100644
Binary files a/examples/map-server/screenshot.png and b/examples/map-server/screenshot.png differ
diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md
index 5b518d5f..b68fe529 100644
--- a/examples/pdf-server/README.md
+++ b/examples/pdf-server/README.md
@@ -174,34 +174,146 @@ When roots are ignored the server logs:
## Tools
-| Tool | Visibility | Purpose |
-| ---------------- | ---------- | -------------------------------------- |
-| `list_pdfs` | Model | List available local files and origins |
-| `display_pdf` | Model + UI | Display interactive viewer |
-| `read_pdf_bytes` | App only | Stream PDF data in chunks |
+| Tool | Visibility | Purpose |
+| ---------------- | ---------- | ----------------------------------------------------- |
+| `list_pdfs` | Model | List available local files and origins |
+| `display_pdf` | Model + UI | Display interactive viewer |
+| `interact` | Model | Navigate, annotate, search, extract pages, fill forms |
+| `read_pdf_bytes` | App only | Stream PDF data in chunks |
+| `save_pdf` | App only | Save annotated PDF back to local file |
+
+## Example Prompts
+
+After the model calls `display_pdf`, it receives the `viewUUID` and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features:
+
+### Annotating
+
+> **User:** Show me the Attention Is All You Need paper
+>
+> _Model calls `display_pdf` → viewer opens_
+>
+> **User:** Highlight the title and add an APPROVED stamp on the first page.
+>
+> _Model calls `interact` with `highlight_text` for the title and `add_annotations` with a stamp_
+
+> **User:** Can you annotate this PDF? Mark important sections for me.
+>
+> _Model calls `interact` with `get_pages` to read content first, then `add_annotations` with highlights/notes_
+
+> **User:** Add a note on page 1 saying "Key contribution" at position (200, 500), and highlight the abstract.
+>
+> _Model calls `interact` with `add_annotations` containing a `note` and either `highlight_text` or a `highlight` annotation_
+
+### Navigation & Search
+
+> **User:** Search for "self-attention" in the paper.
+>
+> _Model calls `interact` with action `search`, query `"self-attention"`_
+
+> **User:** Go to page 5.
+>
+> _Model calls `interact` with action `navigate`, page `5`_
+
+### Page Extraction
+
+> **User:** Give me the text of pages 1–3.
+>
+> _Model calls `interact` with action `get_pages`, intervals `[{start:1, end:3}]`, getText `true`_
+
+> **User:** Take a screenshot of the first page.
+>
+> _Model calls `interact` with action `get_pages`, intervals `[{start:1, end:1}]`, getScreenshots `true`_
+
+### Stamps & Form Filling
+
+> **User:** Stamp this document as CONFIDENTIAL on every page.
+>
+> _Model calls `interact` with `add_annotations` containing `stamp` annotations on each page_
+
+> **User:** Fill in the "Name" field with "Alice" and "Date" with "2026-02-26".
+>
+> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_
+
+## Testing
+
+### E2E Tests (Playwright)
+
+```bash
+# Run annotation E2E tests (renders annotations in a real browser)
+npx playwright test tests/e2e/pdf-annotations.spec.ts
+
+# Run all PDF server tests
+npx playwright test -g "PDF Server"
+```
+
+### API Prompt Discovery Tests
+
+These tests verify that Claude can discover and use annotation capabilities by calling the Anthropic Messages API with the tool schemas. They are **disabled by default** — skipped unless `ANTHROPIC_API_KEY` is set:
+
+```bash
+ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts
+```
+
+The API tests simulate a conversation where `display_pdf` has already been called, then send a follow-up user message and verify the model uses annotation actions (or at least the `interact` tool). Three scenarios are tested:
+
+| Scenario | User prompt | Expected model behavior |
+| -------------------- | ----------------------------------------------------------------- | ------------------------------------------ |
+| Direct annotation | "Highlight the title and add an APPROVED stamp" | Uses `highlight_text` or `add_annotations` |
+| Capability discovery | "Can you annotate this PDF?" | Uses interact or mentions annotations |
+| Specific notes | "Add a note saying 'Key contribution' and highlight the abstract" | Uses `interact` tool |
## Architecture
```
-server.ts # MCP server + tools
-main.ts # CLI entry point
+server.ts # MCP server + tools
+main.ts # CLI entry point
src/
-└── mcp-app.ts # Interactive viewer UI (PDF.js)
+├── mcp-app.ts # Interactive viewer UI (PDF.js)
+├── pdf-annotations.ts # Annotation types, diff model, PDF import/export
+└── pdf-annotations.test.ts # Unit tests for annotation module
```
## Key Patterns Shown
-| Pattern | Implementation |
-| ----------------- | ------------------------------------------- |
-| App-only tools | `_meta: { ui: { visibility: ["app"] } }` |
-| Chunked responses | `hasMore` + `offset` pagination |
-| Model context | `app.updateModelContext()` |
-| Display modes | `app.requestDisplayMode()` |
-| External links | `app.openLink()` |
-| View persistence | `viewUUID` + localStorage |
-| Theming | `applyDocumentTheme()` + CSS `light-dark()` |
+| Pattern | Implementation |
+| ----------------------------- | -------------------------------------------------------------- |
+| App-only tools | `_meta: { ui: { visibility: ["app"] } }` |
+| Chunked responses | `hasMore` + `offset` pagination |
+| Model context | `app.updateModelContext()` |
+| Display modes | `app.requestDisplayMode()` |
+| External links | `app.openLink()` |
+| View persistence | `viewUUID` + localStorage |
+| Theming | `applyDocumentTheme()` + CSS `light-dark()` |
+| Annotations | DOM overlays synced with proper PDF annotation dicts |
+| Annotation import | Load existing PDF annotations via PDF.js `getAnnotations()` |
+| Diff-based persistence | localStorage stores only additions/removals vs PDF baseline |
+| Proper PDF export | pdf-lib low-level API creates real `/Type /Annot` dictionaries |
+| Save to file | App-only `save_pdf` tool writes annotated bytes back to disk |
+| Dirty flag | `*` prefix on title when unsaved local changes exist |
+| Command queue | Server enqueues → client polls + processes |
+| File download | `app.downloadFile()` for annotated PDF |
+| Floating panel with anchoring | Magnetic corner-snapping panel for annotation list |
+| Drag, resize, rotate | Interactive annotation handles with undo/redo |
+| Keyboard shortcuts | Ctrl+Z/Y (undo/redo), Ctrl+S (save), Ctrl+F (search), ⌘Enter |
+
+### Annotation Types
+
+Supported annotation types (synced with PDF.js):
+
+| Type | Properties | PDF Subtype |
+| --------------- | ------------------------------------------- | ------------ |
+| `highlight` | `rects`, `color?`, `content?` | `/Highlight` |
+| `underline` | `rects`, `color?` | `/Underline` |
+| `strikethrough` | `rects`, `color?` | `/StrikeOut` |
+| `note` | `x`, `y`, `content`, `color?` | `/Text` |
+| `rectangle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Square` |
+| `circle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Circle` |
+| `line` | `x1`, `y1`, `x2`, `y2`, `color?` | `/Line` |
+| `freetext` | `x`, `y`, `content`, `fontSize?`, `color?` | `/FreeText` |
+| `stamp` | `x`, `y`, `label`, `color?`, `rotation?` | `/Stamp` |
## Dependencies
-- `pdfjs-dist`: PDF rendering (frontend only)
+- `pdfjs-dist`: PDF rendering and annotation import (frontend only)
+- `pdf-lib`: Client-side PDF modification — creates proper PDF annotation dictionaries for export
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK
diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png
index 5acd2227..0e6e571d 100644
Binary files a/examples/pdf-server/grid-cell.png and b/examples/pdf-server/grid-cell.png differ
diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts
index 2ff4979f..c637f00c 100644
--- a/examples/pdf-server/main.ts
+++ b/examples/pdf-server/main.ts
@@ -153,9 +153,17 @@ async function main() {
if (stdio) {
// stdio → client is local (e.g. Claude Desktop), roots are safe
- await startStdioServer(() => createServer({ useClientRoots: true }));
+ await startStdioServer(() =>
+ createServer({ enableInteract: true, useClientRoots: true }),
+ );
} else {
// HTTP → client is remote, only honour roots with explicit opt-in
+ if (!useClientRoots) {
+ console.error(
+ "[pdf-server] Client roots are ignored (default for remote transports). " +
+ "Pass --use-client-roots to allow the client to expose local directories.",
+ );
+ }
await startStreamableHTTPServer(() => createServer({ useClientRoots }));
}
}
diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html
index c18345d2..cf1545a7 100644
--- a/examples/pdf-server/mcp-app.html
+++ b/examples/pdf-server/mcp-app.html
@@ -19,6 +19,16 @@
An error occurred
+
+
+
@@ -60,6 +70,31 @@
+
+
+
@@ -107,12 +143,31 @@
-
-
-
diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json
index 56cb7c8c..ca8924f7 100644
--- a/examples/pdf-server/package.json
+++ b/examples/pdf-server/package.json
@@ -28,6 +28,7 @@
"@modelcontextprotocol/sdk": "^1.24.0",
"cors": "^2.8.5",
"express": "^5.1.0",
+ "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.0.0",
"zod": "^4.1.13"
},
diff --git a/examples/pdf-server/screenshot.png b/examples/pdf-server/screenshot.png
index bdb8f176..fd87f8f5 100644
Binary files a/examples/pdf-server/screenshot.png and b/examples/pdf-server/screenshot.png differ
diff --git a/examples/pdf-server/server.test.ts b/examples/pdf-server/server.test.ts
index 4ea1ecf4..694a4df1 100644
--- a/examples/pdf-server/server.test.ts
+++ b/examples/pdf-server/server.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
+import fs from "node:fs";
+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 {
createPdfCache,
createServer,
@@ -8,6 +12,8 @@ import {
allowedLocalFiles,
allowedLocalDirs,
pathToFileUrl,
+ startFileWatch,
+ stopFileWatch,
CACHE_INACTIVITY_TIMEOUT_MS,
CACHE_MAX_LIFETIME_MS,
CACHE_MAX_PDF_SIZE_BYTES,
@@ -446,3 +452,157 @@ describe("createServer useClientRoots option", () => {
server.close();
});
});
+
+describe("file watching", () => {
+ let tmpDir: string;
+ let tmpFile: string;
+ const uuid = "test-watch-uuid";
+
+ // Long-poll timeout is 30s — tests that poll must complete sooner.
+ const pollWithTimeout = async (
+ client: Client,
+ timeoutMs = 5000,
+ ): Promise<{ type: string; mtimeMs?: number }[]> => {
+ const result = await Promise.race([
+ client.callTool({
+ name: "poll_pdf_commands",
+ arguments: { viewUUID: uuid },
+ }),
+ new Promise
((_, reject) =>
+ setTimeout(() => reject(new Error("poll timeout")), timeoutMs),
+ ),
+ ]);
+ return (
+ ((result as { structuredContent?: { commands?: unknown[] } })
+ .structuredContent?.commands as { type: string; mtimeMs?: number }[]) ??
+ []
+ );
+ };
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-watch-"));
+ tmpFile = path.join(tmpDir, "test.pdf");
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%test\n"));
+ allowedLocalFiles.add(tmpFile);
+ });
+
+ afterEach(() => {
+ stopFileWatch(uuid);
+ allowedLocalFiles.delete(tmpFile);
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("enqueues file_changed after external write", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50)); // let watcher settle
+
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%changed\n"));
+
+ const cmds = await pollWithTimeout(client);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("file_changed");
+ expect(cmds[0].mtimeMs).toBeGreaterThan(0);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("debounces rapid writes into one command", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50));
+
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%a\n"));
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%b\n"));
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%c\n"));
+
+ const cmds = await pollWithTimeout(client);
+ expect(cmds).toHaveLength(1);
+
+ await client.close();
+ await server.close();
+ });
+
+ it("stopFileWatch prevents further commands", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50));
+ stopFileWatch(uuid);
+
+ fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%x\n"));
+
+ // Debounce window + margin — no event should fire
+ await new Promise((r) => setTimeout(r, 300));
+
+ // Poll should block (long-poll) → timeout here means no command was queued
+ await expect(pollWithTimeout(client, 500)).rejects.toThrow("poll timeout");
+
+ await client.close();
+ await server.close();
+ });
+
+ it("save_pdf returns mtimeMs in structuredContent", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ const before = fs.statSync(tmpFile).mtimeMs;
+ // Ensure mtime will differ on coarse-granularity filesystems
+ await new Promise((r) => setTimeout(r, 10));
+
+ const r = await client.callTool({
+ name: "save_pdf",
+ arguments: {
+ url: tmpFile,
+ data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
+ },
+ });
+ expect(r.isError).toBeFalsy();
+ const sc = r.structuredContent as { filePath: string; mtimeMs: number };
+ expect(sc.filePath).toBe(tmpFile);
+ expect(sc.mtimeMs).toBeGreaterThanOrEqual(before);
+
+ await client.close();
+ await server.close();
+ });
+
+ // fs.watch rename semantics differ between kqueue (macOS) and inotify
+ // (Linux) — on Linux, the watcher on the replaced inode may not receive
+ // further events, and the re-attach race is inherently flaky in CI.
+ // Only assert the rename itself is detected; re-attach is best-effort.
+ it("detects atomic rename", async () => {
+ const server = createServer({ enableInteract: true });
+ const client = new Client({ name: "t", version: "1" });
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await Promise.all([server.connect(st), client.connect(ct)]);
+
+ startFileWatch(uuid, tmpFile);
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Simulate vim/vscode: write to temp, rename over original
+ const tmpWrite = tmpFile + ".swp";
+ fs.writeFileSync(tmpWrite, Buffer.from("%PDF-1.4\n%atomic\n"));
+ fs.renameSync(tmpWrite, tmpFile);
+
+ const cmds = await pollWithTimeout(client);
+ expect(cmds).toHaveLength(1);
+ expect(cmds[0].type).toBe("file_changed");
+
+ await client.close();
+ await server.close();
+ });
+});
diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts
index 0f835b76..0191a5e4 100644
--- a/examples/pdf-server/server.ts
+++ b/examples/pdf-server/server.ts
@@ -25,6 +25,42 @@ import {
type CallToolResult,
type ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
+// Use the legacy build to avoid DOMMatrix dependency in Node.js
+import {
+ getDocument,
+ VerbosityLevel,
+ version as PDFJS_VERSION,
+} from "pdfjs-dist/legacy/build/pdf.mjs";
+
+/**
+ * PDF Standard-14 fonts from CDN. Used by both server and viewer so we
+ * declare a single well-known origin in CSP connectDomains.
+ *
+ * pdf.js in Node defaults to NodeStandardFontDataFactory (fs.readFile) which
+ * can't fetch URLs, so we pass {@link FetchStandardFontDataFactory} alongside.
+ * The browser viewer uses the DOM factory by default and just needs the URL.
+ */
+export const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${PDFJS_VERSION}/standard_fonts/`;
+const STANDARD_FONT_ORIGIN = "https://unpkg.com";
+
+/** pdf.js font factory that uses fetch() instead of fs.readFile. */
+class FetchStandardFontDataFactory {
+ baseUrl: string | null;
+ constructor({ baseUrl = null }: { baseUrl?: string | null }) {
+ this.baseUrl = baseUrl;
+ }
+ async fetch({ filename }: { filename: string }): Promise {
+ if (!this.baseUrl) throw new Error("standardFontDataUrl not provided");
+ const url = `${this.baseUrl}${filename}`;
+ const res = await globalThis.fetch(url);
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
+ return new Uint8Array(await res.arrayBuffer());
+ }
+}
+import type {
+ PrimitiveSchemaDefinition,
+ ElicitResult,
+} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// =============================================================================
@@ -55,6 +91,402 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
+// =============================================================================
+// Command Queue (shared across stateless server instances)
+// =============================================================================
+
+/** Commands expire after this many ms if never polled */
+const COMMAND_TTL_MS = 60_000; // 60 seconds
+
+/** Periodic sweep interval to drop stale queues */
+const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
+
+/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */
+const POLL_BATCH_WAIT_MS = 200;
+const LONG_POLL_TIMEOUT_MS = 30_000; // Max time to hold a long-poll request open
+
+// =============================================================================
+// Annotation Types
+// =============================================================================
+
+/** Rectangle in coordinate space (top-left origin for model-facing API, in PDF points) */
+const RectSchema = z.object({
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+});
+
+/** Any text is valid. Common labels: APPROVED, DRAFT, CONFIDENTIAL, FINAL, VOID, REJECTED. */
+const StampLabel = z
+ .string()
+ .describe(
+ "Stamp label text (e.g. APPROVED, DRAFT, CONFIDENTIAL, FINAL, VOID, REJECTED, or any custom text)",
+ );
+
+const AnnotationBase = z.object({
+ id: z.string(),
+ page: z.number().min(1),
+});
+
+const HighlightAnnotation = AnnotationBase.extend({
+ type: z.literal("highlight"),
+ rects: z.array(RectSchema).min(1),
+ color: z.string().optional(),
+ content: z.string().optional(),
+});
+
+const UnderlineAnnotation = AnnotationBase.extend({
+ type: z.literal("underline"),
+ rects: z.array(RectSchema).min(1),
+ color: z.string().optional(),
+});
+
+const StrikethroughAnnotation = AnnotationBase.extend({
+ type: z.literal("strikethrough"),
+ rects: z.array(RectSchema).min(1),
+ color: z.string().optional(),
+});
+
+const NoteAnnotation = AnnotationBase.extend({
+ type: z.literal("note"),
+ x: z.number(),
+ y: z.number(),
+ content: z.string(),
+ color: z.string().optional(),
+});
+
+const RectangleAnnotation = AnnotationBase.extend({
+ type: z.literal("rectangle"),
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+ color: z.string().optional(),
+ fillColor: z.string().optional(),
+ rotation: z.number().optional(),
+});
+
+const CircleAnnotation = AnnotationBase.extend({
+ type: z.literal("circle"),
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+ color: z.string().optional(),
+ fillColor: z.string().optional(),
+});
+
+const LineAnnotation = AnnotationBase.extend({
+ type: z.literal("line"),
+ x1: z.number(),
+ y1: z.number(),
+ x2: z.number(),
+ y2: z.number(),
+ color: z.string().optional(),
+});
+
+const FreetextAnnotation = AnnotationBase.extend({
+ type: z.literal("freetext"),
+ x: z.number(),
+ y: z.number(),
+ content: z.string(),
+ fontSize: z.number().optional(),
+ color: z.string().optional(),
+});
+
+const StampAnnotation = AnnotationBase.extend({
+ type: z.literal("stamp"),
+ x: z.number(),
+ y: z.number(),
+ label: StampLabel,
+ color: z.string().optional(),
+ rotation: z.number().optional(),
+});
+
+const ImageAnnotation = AnnotationBase.extend({
+ type: z.literal("image"),
+ x: z.number().optional(),
+ y: z.number().optional(),
+ width: z.number().optional(),
+ height: z.number().optional(),
+ imageUrl: z
+ .string()
+ .describe(
+ "File path or HTTPS URL to a PNG/JPEG image (e.g. signature, logo). NO data: URIs. The user can also drag & drop images directly onto the viewer.",
+ ),
+ mimeType: z
+ .string()
+ .optional()
+ .describe("image/png or image/jpeg (auto-detected if omitted)"),
+ rotation: z.number().optional(),
+ aspect: z
+ .enum(["preserve", "ignore"])
+ .optional()
+ .describe(
+ 'Aspect ratio behavior on resize: "preserve" (default) keeps proportions, "ignore" allows free resize',
+ ),
+});
+
+const PdfAnnotationDef = z.discriminatedUnion("type", [
+ HighlightAnnotation,
+ UnderlineAnnotation,
+ StrikethroughAnnotation,
+ NoteAnnotation,
+ RectangleAnnotation,
+ CircleAnnotation,
+ LineAnnotation,
+ FreetextAnnotation,
+ StampAnnotation,
+ ImageAnnotation,
+]);
+
+/** Partial annotation update — id + type required, rest optional */
+const PdfAnnotationUpdate = z.union([
+ HighlightAnnotation.partial().required({ id: true, type: true }),
+ UnderlineAnnotation.partial().required({ id: true, type: true }),
+ StrikethroughAnnotation.partial().required({ id: true, type: true }),
+ NoteAnnotation.partial().required({ id: true, type: true }),
+ RectangleAnnotation.partial().required({ id: true, type: true }),
+ FreetextAnnotation.partial().required({ id: true, type: true }),
+ StampAnnotation.partial().required({ id: true, type: true }),
+ CircleAnnotation.partial().required({ id: true, type: true }),
+ LineAnnotation.partial().required({ id: true, type: true }),
+ ImageAnnotation.partial().required({ id: true, type: true }),
+]);
+
+const FormField = z.object({
+ name: z.string(),
+ value: z.union([z.string(), z.boolean()]),
+});
+
+const PageInterval = z.object({
+ start: z.number().min(1).optional(),
+ end: z.number().min(1).optional(),
+});
+
+// =============================================================================
+// Command Queue (shared across stateless server instances)
+// =============================================================================
+
+export type PdfCommand =
+ | { type: "navigate"; page: number }
+ | { type: "search"; query: string }
+ | { type: "find"; query: string }
+ | { type: "search_navigate"; matchIndex: number }
+ | { type: "zoom"; scale: number }
+ | {
+ type: "add_annotations";
+ annotations: z.infer[];
+ }
+ | {
+ type: "update_annotations";
+ annotations: z.infer[];
+ }
+ | { type: "remove_annotations"; ids: string[] }
+ | {
+ type: "highlight_text";
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+ }
+ | {
+ type: "fill_form";
+ fields: z.infer[];
+ }
+ | {
+ type: "get_pages";
+ requestId: string;
+ intervals: Array<{ start?: number; end?: number }>;
+ getText: boolean;
+ getScreenshots: boolean;
+ }
+ | { type: "file_changed"; mtimeMs: number };
+
+// =============================================================================
+// Pending get_pages Requests (request-response bridge via client)
+// =============================================================================
+
+const GET_PAGES_TIMEOUT_MS = 60_000; // 60s — rendering many pages can be slow
+
+interface PageDataEntry {
+ page: number;
+ text?: string;
+ image?: string; // base64 PNG
+}
+
+interface PendingPageRequest {
+ resolve: (data: PageDataEntry[]) => void;
+ reject: (error: Error) => void;
+ timer: ReturnType;
+}
+
+const pendingPageRequests = new Map();
+
+/** Wait for the client to render and submit page data for a given request. */
+function waitForPageData(requestId: string): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingPageRequests.delete(requestId);
+ reject(new Error("Timeout waiting for page data from viewer"));
+ }, GET_PAGES_TIMEOUT_MS);
+ pendingPageRequests.set(requestId, { resolve, reject, timer });
+ });
+}
+
+interface QueueEntry {
+ commands: PdfCommand[];
+ /** Timestamp of the most recent enqueue or dequeue */
+ lastActivity: number;
+}
+
+const commandQueues = new Map();
+
+/** Waiters for long-poll: resolve callback wakes up a blocked poll_pdf_commands */
+const pollWaiters = new Map void>();
+
+/** Valid form field names per viewer UUID (populated during display_pdf) */
+const viewFieldNames = new Map>();
+
+/** Detailed form field info per viewer UUID (populated during display_pdf) */
+const viewFieldInfo = new Map();
+
+/**
+ * Active fs.watch per view. Only created for local files when interact is
+ * enabled (stdio). Watcher is re-established on `rename` events to survive
+ * atomic writes (vim/vscode write-to-tmp-then-rename changes the inode).
+ */
+interface ViewFileWatch {
+ filePath: string;
+ watcher: fs.FSWatcher;
+ lastMtimeMs: number;
+ debounce: ReturnType | null;
+}
+const viewFileWatches = new Map();
+
+function pruneStaleQueues(): void {
+ const now = Date.now();
+ for (const [uuid, entry] of commandQueues) {
+ if (now - entry.lastActivity > COMMAND_TTL_MS) {
+ commandQueues.delete(uuid);
+ viewFieldNames.delete(uuid);
+ viewFieldInfo.delete(uuid);
+ stopFileWatch(uuid);
+ }
+ }
+ // Clean up empty queues with no active pollers
+ for (const [uuid, entry] of commandQueues) {
+ if (entry.commands.length === 0 && !pollWaiters.has(uuid)) {
+ commandQueues.delete(uuid);
+ }
+ }
+}
+
+// Periodic sweep so abandoned queues don't leak
+setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref();
+
+function enqueueCommand(viewUUID: string, command: PdfCommand): void {
+ let entry = commandQueues.get(viewUUID);
+ if (!entry) {
+ entry = { commands: [], lastActivity: Date.now() };
+ commandQueues.set(viewUUID, entry);
+ }
+ entry.commands.push(command);
+ entry.lastActivity = Date.now();
+
+ // Wake up any long-polling request waiting for this viewUUID
+ const waiter = pollWaiters.get(viewUUID);
+ if (waiter) {
+ pollWaiters.delete(viewUUID);
+ waiter();
+ }
+}
+
+function dequeueCommands(viewUUID: string): PdfCommand[] {
+ const entry = commandQueues.get(viewUUID);
+ if (!entry) return [];
+ const commands = entry.commands;
+ commandQueues.delete(viewUUID);
+ return commands;
+}
+
+// =============================================================================
+// File Watching (local files, stdio only)
+// =============================================================================
+
+const FILE_WATCH_DEBOUNCE_MS = 150;
+
+export function startFileWatch(viewUUID: string, filePath: string): void {
+ const resolved = path.resolve(filePath);
+ let stat: fs.Stats;
+ try {
+ stat = fs.statSync(resolved);
+ } catch {
+ return; // vanished between validation and here
+ }
+
+ // Replace any existing watcher for this view
+ stopFileWatch(viewUUID);
+
+ const entry: ViewFileWatch = {
+ filePath: resolved,
+ watcher: null as unknown as fs.FSWatcher,
+ lastMtimeMs: stat.mtimeMs,
+ debounce: null,
+ };
+
+ const onEvent = (eventType: string): void => {
+ if (entry.debounce) clearTimeout(entry.debounce);
+ entry.debounce = setTimeout(() => {
+ entry.debounce = null;
+ let s: fs.Stats;
+ try {
+ s = fs.statSync(resolved);
+ } catch {
+ return; // gone mid-atomic-write; next rename will re-attach
+ }
+ if (s.mtimeMs === entry.lastMtimeMs) return; // spurious / already sent
+ entry.lastMtimeMs = s.mtimeMs;
+ enqueueCommand(viewUUID, { type: "file_changed", mtimeMs: s.mtimeMs });
+ }, FILE_WATCH_DEBOUNCE_MS);
+
+ // Atomic saves replace the inode — old watcher stops firing. Re-attach.
+ if (eventType === "rename") {
+ try {
+ entry.watcher.close();
+ } catch {
+ /* already closed */
+ }
+ try {
+ entry.watcher = fs.watch(resolved, onEvent);
+ } catch {
+ // File removed, not replaced. Leave closed; pruneStaleQueues cleans up.
+ }
+ }
+ };
+
+ try {
+ entry.watcher = fs.watch(resolved, onEvent);
+ } catch {
+ return; // fs.watch unsupported (e.g. some network filesystems)
+ }
+ viewFileWatches.set(viewUUID, entry);
+}
+
+export function stopFileWatch(viewUUID: string): void {
+ const entry = viewFileWatches.get(viewUUID);
+ if (!entry) return;
+ if (entry.debounce) clearTimeout(entry.debounce);
+ try {
+ entry.watcher.close();
+ } catch {
+ /* ignore */
+ }
+ viewFileWatches.delete(viewUUID);
+}
+
// =============================================================================
// URL Validation & Normalization
// =============================================================================
@@ -439,11 +871,233 @@ async function refreshRoots(server: Server): Promise {
}
}
+// =============================================================================
+// PDF Form Field Extraction
+// =============================================================================
+
+/**
+ * Extract form fields from a PDF and build an elicitation schema.
+ * Returns null if the PDF has no form fields.
+ */
+/** Shape of field objects returned by pdfjs-dist's getFieldObjects(). */
+interface PdfJsFieldObject {
+ type: string;
+ name: string;
+ editable: boolean;
+ exportValues?: string;
+ items?: Array<{ exportValue: string; displayValue: string }>;
+}
+
+/** Detailed info about a form field, including its location on the page. */
+interface FormFieldInfo {
+ name: string;
+ type: string;
+ page: number;
+ label?: string;
+ /** Bounding box in model coordinates (top-left origin) */
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Extract detailed form field info (name, type, page, bounding box, label)
+ * from a PDF. Bounding boxes are converted to model coordinates (top-left origin).
+ */
+async function extractFormFieldInfo(
+ url: string,
+ readRange: (
+ url: string,
+ offset: number,
+ byteCount: number,
+ ) => Promise<{ data: Uint8Array; totalBytes: number }>,
+): Promise {
+ const { totalBytes } = await readRange(url, 0, 1);
+ const { data } = await readRange(url, 0, totalBytes);
+
+ const loadingTask = getDocument({
+ data,
+ standardFontDataUrl: STANDARD_FONT_DATA_URL,
+ StandardFontDataFactory: FetchStandardFontDataFactory,
+ // We only introspect form fields (never render) — silence residual
+ // warnings like "Unimplemented border style: inset".
+ verbosity: VerbosityLevel.ERRORS,
+ });
+ const pdfDoc = await loadingTask.promise;
+
+ const fields: FormFieldInfo[] = [];
+ try {
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
+ const page = await pdfDoc.getPage(i);
+ const pageHeight = page.getViewport({ scale: 1.0 }).height;
+ const annotations = await page.getAnnotations();
+ for (const ann of annotations) {
+ // Only include form widgets (annotationType 20)
+ if (ann.annotationType !== 20) continue;
+ if (!ann.rect) continue;
+
+ const fieldName = ann.fieldName || "";
+ const fieldType = ann.fieldType || "unknown";
+
+ // PDF rect is [x1, y1, x2, y2] in bottom-left origin
+ const x1 = Math.min(ann.rect[0], ann.rect[2]);
+ const y1 = Math.min(ann.rect[1], ann.rect[3]);
+ const x2 = Math.max(ann.rect[0], ann.rect[2]);
+ const y2 = Math.max(ann.rect[1], ann.rect[3]);
+ const width = x2 - x1;
+ const height = y2 - y1;
+
+ // Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height
+ const modelY = pageHeight - y2;
+
+ fields.push({
+ name: fieldName,
+ type: fieldType,
+ page: i,
+ x: Math.round(x1),
+ y: Math.round(modelY),
+ width: Math.round(width),
+ height: Math.round(height),
+ ...(ann.alternativeText ? { label: ann.alternativeText } : undefined),
+ });
+ }
+ }
+ } finally {
+ pdfDoc.destroy();
+ }
+
+ return fields;
+}
+
+async function extractFormSchema(
+ url: string,
+ readRange: (
+ url: string,
+ offset: number,
+ byteCount: number,
+ ) => Promise<{ data: Uint8Array; totalBytes: number }>,
+): Promise<{
+ type: "object";
+ properties: Record;
+ required?: string[];
+} | null> {
+ // Read full PDF bytes
+ const { totalBytes } = await readRange(url, 0, 1);
+ const { data } = await readRange(url, 0, totalBytes);
+
+ const loadingTask = getDocument({
+ data,
+ standardFontDataUrl: STANDARD_FONT_DATA_URL,
+ StandardFontDataFactory: FetchStandardFontDataFactory,
+ // We only introspect form fields (never render) — silence residual
+ // warnings like "Unimplemented border style: inset".
+ verbosity: VerbosityLevel.ERRORS,
+ });
+ const pdfDoc = await loadingTask.promise;
+
+ let fieldObjects: Record | null;
+ try {
+ fieldObjects = (await pdfDoc.getFieldObjects()) as Record<
+ string,
+ PdfJsFieldObject[]
+ > | null;
+ } catch {
+ pdfDoc.destroy();
+ return null;
+ }
+ if (!fieldObjects || Object.keys(fieldObjects).length === 0) {
+ pdfDoc.destroy();
+ return null;
+ }
+
+ const properties: Record = {};
+ for (const [name, fields] of Object.entries(fieldObjects)) {
+ const field = fields[0]; // first widget determines the type
+ if (!field.editable) continue;
+
+ switch (field.type) {
+ case "text":
+ properties[name] = { type: "string", title: name };
+ break;
+ case "checkbox":
+ properties[name] = { type: "boolean", title: name };
+ break;
+ case "radiobutton": {
+ const options = fields
+ .map((f) => f.exportValues)
+ .filter((v): v is string => !!v && v !== "Off");
+ properties[name] =
+ options.length > 0
+ ? { type: "string", title: name, enum: options }
+ : { type: "string", title: name };
+ break;
+ }
+ case "combobox":
+ case "listbox": {
+ const items = field.items?.map((i) => i.exportValue).filter(Boolean);
+ properties[name] =
+ items && items.length > 0
+ ? { type: "string", title: name, enum: items }
+ : { type: "string", title: name };
+ break;
+ }
+ // Skip "button" (push buttons) and unknown types
+ }
+ }
+
+ // Collect alternativeText labels from per-page annotations
+ // (getFieldObjects doesn't include them)
+ const fieldLabels = new Map();
+ try {
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
+ const page = await pdfDoc.getPage(i);
+ const annotations = await page.getAnnotations();
+ for (const ann of annotations) {
+ if (ann.fieldName && ann.alternativeText) {
+ fieldLabels.set(ann.fieldName, ann.alternativeText);
+ }
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ // Use labels as titles where available
+ for (const [name, prop] of Object.entries(properties)) {
+ const label = fieldLabels.get(name);
+ if (label) {
+ prop.title = label;
+ }
+ }
+
+ // If any editable field has a mechanical name (no human-readable label),
+ // elicitation would be confusing — return null to skip it.
+ const hasMechanicalNames = Object.keys(properties).some((name) => {
+ if (fieldLabels.has(name)) return false;
+ return /[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name);
+ });
+
+ pdfDoc.destroy();
+ if (Object.keys(properties).length === 0) return null;
+ if (hasMechanicalNames) return null;
+
+ return { type: "object", properties };
+}
+
// =============================================================================
// MCP Server Factory
// =============================================================================
export interface CreateServerOptions {
+ /**
+ * Enable the `interact` tool and related command-queue infrastructure
+ * (in-memory command queue, `poll_pdf_commands`, `submit_page_data`).
+ * Only suitable for single-instance deployments (e.g. stdio transport).
+ * Defaults to false — server exposes only `list_pdfs` and `display_pdf` (read-only).
+ */
+ enableInteract?: boolean;
+
/**
* Whether to honour MCP roots sent by the client.
*
@@ -466,7 +1120,8 @@ export interface CreateServerOptions {
}
export function createServer(options: CreateServerOptions = {}): McpServer {
- const { useClientRoots = false } = options;
+ const { enableInteract = false, useClientRoots = false } = options;
+ const disableInteract = !enableInteract;
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
if (useClientRoots) {
@@ -480,11 +1135,6 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
await refreshRoots(server.server);
},
);
- } else {
- console.error(
- "[pdf-server] Client roots are ignored (default for remote transports). " +
- "Pass --use-client-roots to allow the client to expose local directories.",
- );
}
// Create session-local cache (isolated per server instance)
@@ -613,27 +1263,78 @@ export function createServer(options: CreateServerOptions = {}): McpServer {
"display_pdf",
{
title: "Display PDF",
- description: `Display an interactive PDF viewer.
+ description: disableInteract
+ ? `Show and render a PDF in a read-only viewer.
+
+Use this tool when the user wants to view or read a PDF. The renderer displays the document for viewing.
+
+Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.`
+ : `Show and render a PDF in an interactive viewer. Use this to display, annotate, edit, and fill form fields in PDF documents.
+
+Use this tool when the user wants to view, read, annotate, edit, sign, stamp, or fill out a PDF. The renderer displays the document with full annotation, signature/image placement, and form support.
+
+**CRITICAL — DO NOT call display_pdf again on an already-displayed PDF.** Use the \`interact\` tool with the viewUUID from the result instead. Calling display_pdf again discards the existing viewer and all its state.
+
+Returns a viewUUID in structuredContent. Use it with \`interact\` for follow-up actions:
+- navigate, search, find, search_navigate, zoom
+- add_annotations, update_annotations, remove_annotations, highlight_text
+- fill_form (fill PDF form fields)
+- get_text, get_screenshot (extract content)
-Accepts:
-- Local files explicitly added to the server (use list_pdfs to see available files)
-- Local files under directories provided by the client as MCP roots
-- Any remote PDF accessible via HTTPS`,
+Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.
+Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`,
inputSchema: {
url: z
.string()
.default(DEFAULT_PDF)
.describe("PDF URL or local file path"),
page: z.number().min(1).default(1).describe("Initial page"),
+ ...(disableInteract
+ ? {}
+ : {
+ elicit_form_inputs: z
+ .boolean()
+ .default(false)
+ .describe(
+ "If true and the PDF has form fields, prompt the user to fill them before displaying",
+ ),
+ }),
},
outputSchema: z.object({
+ viewUUID: z
+ .string()
+ .describe(
+ "UUID for this viewer instance" +
+ (disableInteract ? "" : " — pass to interact tool"),
+ ),
url: z.string(),
initialPage: z.number(),
totalBytes: z.number(),
+ formFieldValues: z
+ .record(z.string(), z.union([z.string(), z.boolean()]))
+ .optional()
+ .describe("Form field values filled by the user via elicitation"),
+ formFields: z
+ .array(
+ z.object({
+ name: z.string(),
+ type: z.string(),
+ page: z.number(),
+ label: z.string().optional(),
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+ }),
+ )
+ .optional()
+ .describe(
+ "Form fields with bounding boxes in model coordinates (top-left origin)",
+ ),
}),
_meta: { ui: { resourceUri: RESOURCE_URI } },
},
- async ({ url, page }): Promise => {
+ async ({ url, page, elicit_form_inputs }): Promise => {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const validation = validateUrl(normalized);
@@ -646,21 +1347,1021 @@ Accepts:
// Probe file size so the client can set up range transport without an extra fetch
const { totalBytes } = await readPdfRange(normalized, 0, 1);
+ const uuid = randomUUID();
+
+ // Check writability for local files (governs save button visibility).
+ // Writable only if: (a) the file is explicitly in allowedLocalFiles
+ // (passed as a CLI arg, so the user clearly opted in), OR the file
+ // is STRICTLY UNDER an allowed directory root (isAncestorDir already
+ // excludes rel === "", so a root itself doesn't count); AND
+ // (b) the process has OS write permission (fs.access W_OK).
+ let writable = false;
+ if (isFileUrl(normalized) || isLocalPath(normalized)) {
+ const localPath = isFileUrl(normalized)
+ ? fileUrlToPath(normalized)
+ : decodeURIComponent(normalized);
+ const resolved = path.resolve(localPath);
+ const inAllowedScope =
+ allowedLocalFiles.has(resolved) ||
+ [...allowedLocalDirs].some((dir) => isAncestorDir(dir, resolved));
+ if (inAllowedScope) {
+ try {
+ await fs.promises.access(resolved, fs.constants.W_OK);
+ writable = true;
+ } catch {
+ // Not writable — leave false
+ }
+ }
+ // Watch for external changes (stdio only — needs the poll channel)
+ if (!disableInteract) {
+ startFileWatch(uuid, localPath);
+ }
+ }
+
+ // Extract form field schema (used for elicitation and field name validation)
+ let formSchema: Awaited> = null;
+ try {
+ formSchema = await extractFormSchema(normalized, readPdfRange);
+ } catch {
+ // Non-fatal — PDF may not have form fields
+ }
+ if (formSchema) {
+ viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties)));
+ }
+
+ // Extract detailed form field info (page, bounding box, label)
+ let fieldInfo: FormFieldInfo[] = [];
+ try {
+ fieldInfo = await extractFormFieldInfo(normalized, readPdfRange);
+ if (fieldInfo.length > 0) {
+ viewFieldInfo.set(uuid, fieldInfo);
+ // Also populate viewFieldNames from field info if not already set
+ if (!viewFieldNames.has(uuid)) {
+ viewFieldNames.set(
+ uuid,
+ new Set(fieldInfo.map((f) => f.name).filter(Boolean)),
+ );
+ }
+ }
+ } catch {
+ // Non-fatal
+ }
+
+ // Elicit form field values if requested and client supports it
+ let formFieldValues: Record | undefined;
+ let elicitResult: ElicitResult | undefined;
+ if (elicit_form_inputs && formSchema) {
+ const clientCaps = server.server.getClientCapabilities();
+ if (clientCaps?.elicitation?.form) {
+ try {
+ elicitResult = await server.server.elicitInput({
+ message: `Please fill in the PDF form fields for "${normalized.split("/").pop() || normalized}":`,
+ requestedSchema: formSchema,
+ });
+ if (elicitResult.action === "accept" && elicitResult.content) {
+ formFieldValues = {};
+ for (const [k, v] of Object.entries(elicitResult.content)) {
+ if (typeof v === "string" || typeof v === "boolean") {
+ formFieldValues[k] = v;
+ }
+ }
+ // Queue fill_form command so the viewer picks it up
+ enqueueCommand(uuid, {
+ type: "fill_form",
+ fields: Object.entries(formFieldValues).map(
+ ([name, value]) => ({ name, value }),
+ ),
+ });
+ }
+ } catch (err) {
+ // Elicitation failed — continue without form values
+ console.error("[pdf-server] Form elicitation failed:", err);
+ }
+ }
+ }
+
+ const contentParts: Array<{ type: "text"; text: string }> = [
+ {
+ type: "text",
+ text: `Displaying PDF: ${normalized} (viewUUID: ${uuid})`,
+ },
+ ];
+
+ if (formFieldValues && Object.keys(formFieldValues).length > 0) {
+ const fieldSummary = Object.entries(formFieldValues)
+ .map(
+ ([name, value]) =>
+ ` ${name}: ${typeof value === "boolean" ? (value ? "checked" : "unchecked") : value}`,
+ )
+ .join("\n");
+ contentParts.push({
+ type: "text",
+ text: `\nUser-provided form field values:\n${fieldSummary}`,
+ });
+ } else if (
+ elicit_form_inputs &&
+ elicitResult &&
+ elicitResult.action !== "accept"
+ ) {
+ contentParts.push({
+ type: "text",
+ text: `\nForm elicitation was ${elicitResult.action}d by the user.`,
+ });
+ }
+
+ // Include detailed form field info so the model can locate and fill fields
+ if (fieldInfo.length > 0) {
+ // Group by page
+ const byPage = new Map();
+ for (const f of fieldInfo) {
+ let list = byPage.get(f.page);
+ if (!list) {
+ list = [];
+ byPage.set(f.page, list);
+ }
+ list.push(f);
+ }
+ const lines: string[] = [
+ `\nForm fields (${fieldInfo.length})${disableInteract ? "" : " — use fill_form with {name, value}"}:`,
+ ];
+ for (const [pg, fields] of [...byPage.entries()].sort(
+ (a, b) => a[0] - b[0],
+ )) {
+ lines.push(` Page ${pg}:`);
+ for (const f of fields) {
+ const label = f.label ? ` "${f.label}"` : "";
+ const nameStr = f.name || "(unnamed)";
+ lines.push(
+ ` ${nameStr}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}`,
+ );
+ }
+ }
+ contentParts.push({ type: "text", text: lines.join("\n") });
+ } else {
+ // Fallback to simple field name listing if detailed info unavailable
+ const fieldNames = viewFieldNames.get(uuid);
+ if (fieldNames && fieldNames.size > 0) {
+ contentParts.push({
+ type: "text",
+ text: `\nForm fields${disableInteract ? "" : " available for fill_form"}: ${[...fieldNames].join(", ")}`,
+ });
+ }
+ }
return {
- content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
+ content: contentParts,
structuredContent: {
+ viewUUID: uuid,
url: normalized,
initialPage: page,
totalBytes,
+ ...(formFieldValues ? { formFieldValues } : {}),
+ ...(fieldInfo.length > 0 ? { formFields: fieldInfo } : {}),
},
_meta: {
- viewUUID: randomUUID(),
+ viewUUID: uuid,
+ interactEnabled: !disableInteract,
+ writable,
},
};
},
);
+ if (!disableInteract) {
+ // Schema for a single interact command (used in commands array)
+ const InteractCommandSchema = z.object({
+ action: z
+ .enum([
+ "navigate",
+ "search",
+ "find",
+ "search_navigate",
+ "zoom",
+ "add_annotations",
+ "update_annotations",
+ "remove_annotations",
+ "highlight_text",
+ "fill_form",
+ "get_text",
+ "get_screenshot",
+ ])
+ .describe("Action to perform"),
+ page: z
+ .number()
+ .min(1)
+ .optional()
+ .describe(
+ "Page number (for navigate, highlight_text, get_screenshot, get_text)",
+ ),
+ query: z
+ .string()
+ .optional()
+ .describe("Search text (for search / find / highlight_text)"),
+ matchIndex: z
+ .number()
+ .min(0)
+ .optional()
+ .describe("Match index (for search_navigate)"),
+ scale: z
+ .number()
+ .min(0.5)
+ .max(3.0)
+ .optional()
+ .describe("Zoom scale, 1.0 = 100% (for zoom)"),
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .optional()
+ .describe(
+ "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
+ ),
+ ids: z
+ .array(z.string())
+ .optional()
+ .describe("Annotation IDs (for remove_annotations)"),
+ color: z
+ .string()
+ .optional()
+ .describe("Color override (for highlight_text)"),
+ content: z
+ .string()
+ .optional()
+ .describe("Tooltip/note content (for highlight_text)"),
+ fields: z
+ .array(FormField)
+ .optional()
+ .describe(
+ "Form fields to fill (for fill_form): { name, value } where value is string or boolean",
+ ),
+ intervals: z
+ .array(PageInterval)
+ .optional()
+ .describe(
+ "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
+ ),
+ });
+
+ type InteractCommand = z.infer;
+ type ContentPart =
+ | { type: "text"; text: string }
+ | { type: "image"; data: string; mimeType: string };
+
+ /**
+ * Resolve an image annotation: fetch imageUrl → imageData if needed,
+ * auto-detect dimensions, and set defaults for x/y.
+ */
+ async function resolveImageAnnotation(
+ ann: Record,
+ ): Promise {
+ // Fetch image data from URL if no imageData provided
+ if (!ann.imageData && ann.imageUrl) {
+ try {
+ let imgBytes: Uint8Array;
+ const url = ann.imageUrl as string;
+ if (url.startsWith("http://") || url.startsWith("https://")) {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ imgBytes = new Uint8Array(await resp.arrayBuffer());
+ } else {
+ // Treat as file path
+ imgBytes = await fs.promises.readFile(url);
+ }
+ ann.imageData = Buffer.from(imgBytes).toString("base64");
+ } catch (err) {
+ console.error(`Failed to fetch image from ${ann.imageUrl}:`, err);
+ }
+ }
+
+ // Auto-detect mimeType from magic bytes if not set
+ if (ann.imageData && !ann.mimeType) {
+ const bytes = Buffer.from(ann.imageData, "base64");
+ if (
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47
+ ) {
+ ann.mimeType = "image/png";
+ } else {
+ ann.mimeType = "image/jpeg";
+ }
+ }
+
+ // Auto-detect dimensions from image if not specified
+ if (ann.imageData && (ann.width == null || ann.height == null)) {
+ const dims = detectImageDimensions(
+ Buffer.from(ann.imageData, "base64"),
+ );
+ if (dims) {
+ const maxWidth = 200; // default max width in PDF points
+ const aspectRatio = dims.height / dims.width;
+ ann.width = ann.width ?? Math.min(dims.width, maxWidth);
+ ann.height = ann.height ?? ann.width * aspectRatio;
+ } else {
+ ann.width = ann.width ?? 200;
+ ann.height = ann.height ?? 200;
+ }
+ }
+
+ // Default position if not specified
+ ann.x = ann.x ?? 72;
+ ann.y = ann.y ?? 72;
+ }
+
+ /**
+ * Detect image dimensions from PNG or JPEG bytes.
+ */
+ function detectImageDimensions(
+ bytes: Buffer,
+ ): { width: number; height: number } | null {
+ // PNG: width at offset 16 (4 bytes BE), height at offset 20 (4 bytes BE)
+ if (
+ bytes[0] === 0x89 &&
+ bytes[1] === 0x50 &&
+ bytes[2] === 0x4e &&
+ bytes[3] === 0x47
+ ) {
+ if (bytes.length >= 24) {
+ const width = bytes.readUInt32BE(16);
+ const height = bytes.readUInt32BE(20);
+ return { width, height };
+ }
+ }
+ // JPEG: scan for SOF0/SOF2 markers (0xFF 0xC0 / 0xFF 0xC2)
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
+ let offset = 2;
+ while (offset < bytes.length - 8) {
+ if (bytes[offset] !== 0xff) break;
+ const marker = bytes[offset + 1];
+ if (marker === 0xc0 || marker === 0xc2) {
+ const height = bytes.readUInt16BE(offset + 5);
+ const width = bytes.readUInt16BE(offset + 7);
+ return { width, height };
+ }
+ const segLen = bytes.readUInt16BE(offset + 2);
+ offset += 2 + segLen;
+ }
+ }
+ return null;
+ }
+
+ /** Process a single interact command. Returns content parts and an isError flag. */
+ async function processInteractCommand(
+ uuid: string,
+ cmd: InteractCommand,
+ ): Promise<{ content: ContentPart[]; isError?: boolean }> {
+ const {
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ } = cmd;
+
+ let description: string;
+ switch (action) {
+ case "navigate":
+ if (page == null)
+ return {
+ content: [{ type: "text", text: "navigate requires `page`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "navigate", page });
+ description = `navigate to page ${page}`;
+ break;
+ case "search":
+ if (!query)
+ return {
+ content: [{ type: "text", text: "search requires `query`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "search", query });
+ description = `search for "${query}"`;
+ break;
+ case "find":
+ if (!query)
+ return {
+ content: [{ type: "text", text: "find requires `query`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "find", query });
+ description = `find "${query}" (silent)`;
+ break;
+ case "search_navigate":
+ if (matchIndex == null)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "search_navigate requires `matchIndex`",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "search_navigate", matchIndex });
+ description = `go to match #${matchIndex}`;
+ break;
+ case "zoom":
+ if (scale == null)
+ return {
+ content: [{ type: "text", text: "zoom requires `scale`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "zoom", scale });
+ description = `zoom to ${Math.round(scale * 100)}%`;
+ break;
+ case "add_annotations":
+ if (!annotations || annotations.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "add_annotations requires `annotations` array",
+ },
+ ],
+ isError: true,
+ };
+ // Resolve image annotations: fetch imageUrl → imageData, auto-detect dimensions
+ for (const ann of annotations) {
+ if ((ann as any).type === "image") {
+ await resolveImageAnnotation(ann as any);
+ }
+ }
+ enqueueCommand(uuid, {
+ type: "add_annotations",
+ annotations: annotations as z.infer[],
+ });
+ description = `add ${annotations.length} annotation(s)`;
+ break;
+ case "update_annotations":
+ if (!annotations || annotations.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "update_annotations requires `annotations` array",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, {
+ type: "update_annotations",
+ annotations: annotations as z.infer[],
+ });
+ description = `update ${annotations.length} annotation(s)`;
+ break;
+ case "remove_annotations":
+ if (!ids || ids.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "remove_annotations requires `ids` array",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "remove_annotations", ids });
+ description = `remove ${ids.length} annotation(s)`;
+ break;
+ case "highlight_text": {
+ if (!query)
+ return {
+ content: [
+ { type: "text", text: "highlight_text requires `query`" },
+ ],
+ isError: true,
+ };
+ const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ enqueueCommand(uuid, {
+ type: "highlight_text",
+ id,
+ query,
+ page,
+ color,
+ content,
+ });
+ description = `highlight text "${query}"${page ? ` on page ${page}` : ""}`;
+ break;
+ }
+ case "fill_form": {
+ if (!fields || fields.length === 0)
+ return {
+ content: [
+ { type: "text", text: "fill_form requires `fields` array" },
+ ],
+ isError: true,
+ };
+ const knownFields = viewFieldNames.get(uuid);
+ const validFields: typeof fields = [];
+ const unknownNames: string[] = [];
+ for (const f of fields) {
+ if (knownFields && !knownFields.has(f.name)) {
+ unknownNames.push(f.name);
+ } else {
+ validFields.push(f);
+ }
+ }
+ if (validFields.length > 0) {
+ enqueueCommand(uuid, { type: "fill_form", fields: validFields });
+ }
+ const parts: string[] = [];
+ if (validFields.length > 0) {
+ parts.push(
+ `Filled ${validFields.length} field(s): ${validFields.map((f) => f.name).join(", ")}`,
+ );
+ }
+ if (unknownNames.length > 0) {
+ parts.push(`Unknown field(s) skipped: ${unknownNames.join(", ")}`);
+ }
+ // Include full field listing so the model can correct its fill_form calls
+ const info = viewFieldInfo.get(uuid);
+ if (info && info.length > 0) {
+ const fieldLines = info.map((f) => {
+ const label = f.label ? ` "${f.label}"` : "";
+ const nameStr = f.name || "(unnamed)";
+ return ` p${f.page}: ${nameStr}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}`;
+ });
+ parts.push(`All form fields:\n${fieldLines.join("\n")}`);
+ } else if (knownFields && knownFields.size > 0) {
+ parts.push(`Valid field names: ${[...knownFields].join(", ")}`);
+ }
+ description = parts.join(". ");
+ if (unknownNames.length > 0 && validFields.length === 0) {
+ return {
+ content: [{ type: "text", text: description }],
+ isError: true,
+ };
+ }
+ break;
+ }
+ case "get_text": {
+ const resolvedIntervals =
+ intervals ?? (page ? [{ start: page, end: page }] : [{}]);
+
+ const requestId = randomUUID();
+
+ enqueueCommand(uuid, {
+ type: "get_pages",
+ requestId,
+ intervals: resolvedIntervals,
+ getText: true,
+ getScreenshots: false,
+ });
+
+ let pageData: PageDataEntry[];
+ try {
+ pageData = await waitForPageData(requestId);
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const textParts: ContentPart[] = [];
+ for (const entry of pageData) {
+ if (entry.text != null) {
+ textParts.push({
+ type: "text",
+ text: `--- Page ${entry.page} ---\n${entry.text}`,
+ });
+ }
+ }
+ if (textParts.length === 0) {
+ textParts.push({ type: "text", text: "No text content returned" });
+ }
+ return { content: textParts };
+ }
+ case "get_screenshot": {
+ if (page == null)
+ return {
+ content: [
+ { type: "text", text: "get_screenshot requires `page`" },
+ ],
+ isError: true,
+ };
+
+ const requestId = randomUUID();
+
+ enqueueCommand(uuid, {
+ type: "get_pages",
+ requestId,
+ intervals: [{ start: page, end: page }],
+ getText: false,
+ getScreenshots: true,
+ });
+
+ let pageData: PageDataEntry[];
+ try {
+ pageData = await waitForPageData(requestId);
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const entry = pageData[0];
+ if (entry?.image) {
+ return {
+ content: [
+ {
+ type: "image",
+ data: entry.image,
+ mimeType: "image/jpeg",
+ },
+ ],
+ };
+ }
+ return {
+ content: [{ type: "text", text: "No screenshot returned" }],
+ isError: true,
+ };
+ }
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${action}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text", text: `Queued: ${description}` }],
+ };
+ }
+
+ // Tool: interact - Interact with an existing PDF viewer
+ server.registerTool(
+ "interact",
+ {
+ title: "Interact with PDF",
+ description: `Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms.
+IMPORTANT: viewUUID must be the exact UUID returned by display_pdf (e.g. "a1b2c3d4-..."). Do NOT use arbitrary strings.
+
+**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially. TIP: End with \`get_screenshot\` to verify your changes.
+
+**ANNOTATION** — add_annotations with array of annotation objects. Each needs: id (unique string), type, page (1-indexed).
+
+**COORDINATE SYSTEM**: PDF points (1pt = 1/72in), origin at page TOP-LEFT corner. X increases rightward, Y increases downward.
+- US Letter = 612×792pt. Margins: top≈y=50, bottom≈y=742, left≈x=72, right≈x=540, center≈(306, 396).
+- Rectangle/circle/stamp x,y is the TOP-LEFT corner. To place a 200×30 box at the TOP of the page: x=72, y=50, width=200, height=30.
+- For highlights/underlines, each rect's y is the TOP of the highlighted region.
+
+Annotation types:
+• highlight: rects:[{x,y,width,height}], color?, content? • underline: rects:[{x,y,w,h}], color?
+• strikethrough: rects:[{x,y,w,h}], color? • note: x, y, content, color?
+• rectangle: x, y, width, height, color?, fillColor?, rotation? • circle: x, y, width, height, color?, fillColor?
+• line: x1, y1, x2, y2, color? • freetext: x, y, content, fontSize?, color?
+• stamp: x, y, label (any text, e.g. APPROVED, DRAFT, CONFIDENTIAL), color?, rotation?
+• image: imageUrl (required), x?, y?, width?, height?, mimeType?, rotation?, aspect? — places an image (signature, logo, etc.) on the page. Pass a local file path or HTTPS URL (NO data: URIs, NO base64). Width/height auto-detected if omitted. Users can also drag & drop images directly onto the viewer.
+
+TIP: For text annotations, prefer highlight_text (auto-finds text) over manual rects.
+
+Example — add a signature image and a stamp, then screenshot to verify:
+\`\`\`json
+{"viewUUID":"…","commands":[
+ {"action":"add_annotations","annotations":[
+ {"id":"sig1","type":"image","page":1,"x":72,"y":700,"imageUrl":"/path/to/signature.png"},
+ {"id":"s1","type":"stamp","page":1,"x":300,"y":400,"label":"APPROVED"}
+ ]},
+ {"action":"get_screenshot","page":1}
+]}
+\`\`\`
+
+• highlight_text: auto-find and highlight text (query, page?, color?, content?)
+• update_annotations: partial update (id+type required) • remove_annotations: remove by ids
+
+**NAVIGATION**: navigate (page), search (query), find (query, silent), search_navigate (matchIndex), zoom (scale 0.5–3.0)
+
+**TEXT/SCREENSHOTS**:
+• get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages.
+• get_screenshot: capture a single page as PNG image. Requires \`page\`.
+
+**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`,
+ inputSchema: {
+ viewUUID: z
+ .string()
+ .describe(
+ "The viewUUID of the PDF viewer (from display_pdf result)",
+ ),
+ // Single-command mode (backwards-compatible)
+ action: z
+ .enum([
+ "navigate",
+ "search",
+ "find",
+ "search_navigate",
+ "zoom",
+ "add_annotations",
+ "update_annotations",
+ "remove_annotations",
+ "highlight_text",
+ "fill_form",
+ "get_text",
+ "get_screenshot",
+ ])
+ .optional()
+ .describe(
+ "Action to perform (for single command). Use `commands` array for batching.",
+ ),
+ page: z
+ .number()
+ .min(1)
+ .optional()
+ .describe(
+ "Page number (for navigate, highlight_text, get_screenshot, get_text)",
+ ),
+ query: z
+ .string()
+ .optional()
+ .describe("Search text (for search / find / highlight_text)"),
+ matchIndex: z
+ .number()
+ .min(0)
+ .optional()
+ .describe("Match index (for search_navigate)"),
+ scale: z
+ .number()
+ .min(0.5)
+ .max(3.0)
+ .optional()
+ .describe("Zoom scale, 1.0 = 100% (for zoom)"),
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .optional()
+ .describe(
+ "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
+ ),
+ ids: z
+ .array(z.string())
+ .optional()
+ .describe("Annotation IDs (for remove_annotations)"),
+ color: z
+ .string()
+ .optional()
+ .describe("Color override (for highlight_text)"),
+ content: z
+ .string()
+ .optional()
+ .describe("Tooltip/note content (for highlight_text)"),
+ fields: z
+ .array(FormField)
+ .optional()
+ .describe(
+ "Form fields to fill (for fill_form): { name, value } where value is string or boolean",
+ ),
+ intervals: z
+ .array(PageInterval)
+ .optional()
+ .describe(
+ "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
+ ),
+ // Batch mode
+ commands: z
+ .array(InteractCommandSchema)
+ .optional()
+ .describe(
+ "Array of commands to execute sequentially. More efficient than separate calls. Tip: end with get_pages+getScreenshots to verify changes.",
+ ),
+ },
+ },
+ async ({
+ viewUUID: uuid,
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ commands,
+ }): Promise => {
+ // Build the list of commands to process
+ const commandList: InteractCommand[] = commands
+ ? commands
+ : action
+ ? [
+ {
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ },
+ ]
+ : [];
+
+ if (commandList.length === 0) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: "No action or commands specified. Provide either `action` (single command) or `commands` (batch).",
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ // Process commands sequentially, collecting all content parts
+ const allContent: ContentPart[] = [];
+ let hasError = false;
+
+ for (let i = 0; i < commandList.length; i++) {
+ const result = await processInteractCommand(uuid, commandList[i]);
+ if (result.isError) {
+ hasError = true;
+ }
+ allContent.push(...result.content);
+ if (hasError) break; // Stop on first error
+ }
+
+ return {
+ content: allContent,
+ ...(hasError ? { isError: true } : {}),
+ };
+ },
+ );
+
+ // Tool: submit_page_data (app-only) - Client submits rendered page data
+ registerAppTool(
+ server,
+ "submit_page_data",
+ {
+ title: "Submit Page Data",
+ description:
+ "Submit rendered page data for a get_pages request (used by viewer)",
+ inputSchema: {
+ requestId: z
+ .string()
+ .describe("The request ID from the get_pages command"),
+ pages: z
+ .array(
+ z.object({
+ page: z.number(),
+ text: z.string().optional(),
+ image: z.string().optional().describe("Base64 PNG image data"),
+ }),
+ )
+ .describe("Page data entries"),
+ },
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ requestId, pages }): Promise => {
+ const pending = pendingPageRequests.get(requestId);
+ if (pending) {
+ clearTimeout(pending.timer);
+ pendingPageRequests.delete(requestId);
+ pending.resolve(pages);
+ return {
+ content: [
+ { type: "text", text: `Submitted ${pages.length} page(s)` },
+ ],
+ };
+ }
+ return {
+ content: [
+ { type: "text", text: `No pending request for ${requestId}` },
+ ],
+ isError: true,
+ };
+ },
+ );
+
+ // Tool: poll_pdf_commands (app-only) - Poll for pending commands
+ registerAppTool(
+ server,
+ "poll_pdf_commands",
+ {
+ title: "Poll PDF Commands",
+ description: "Poll for pending commands for a PDF viewer",
+ inputSchema: {
+ viewUUID: z.string().describe("The viewUUID of the PDF viewer"),
+ },
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ viewUUID: uuid }): Promise => {
+ // If commands are already queued, wait briefly to let more accumulate
+ if (commandQueues.has(uuid)) {
+ await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
+ } else {
+ // Long-poll: wait for commands to arrive or timeout
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ pollWaiters.delete(uuid);
+ resolve();
+ }, LONG_POLL_TIMEOUT_MS);
+ // Cancel any existing waiter for this uuid
+ const prev = pollWaiters.get(uuid);
+ if (prev) prev();
+ pollWaiters.set(uuid, () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ // After waking, wait briefly for batching
+ if (commandQueues.has(uuid)) {
+ await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
+ }
+ }
+ const commands = dequeueCommands(uuid);
+ return {
+ content: [{ type: "text", text: `${commands.length} command(s)` }],
+ structuredContent: { commands },
+ };
+ },
+ );
+ } // end if (!disableInteract)
+
+ // Tool: save_pdf (app-only) - Save annotated PDF back to local file
+ registerAppTool(
+ server,
+ "save_pdf",
+ {
+ title: "Save PDF",
+ description: "Save annotated PDF bytes back to a local file",
+ inputSchema: {
+ url: z.string().describe("Original PDF URL or local file path"),
+ data: z.string().describe("Base64-encoded PDF bytes"),
+ },
+ outputSchema: z.object({
+ filePath: z.string(),
+ mtimeMs: z.number(),
+ }),
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ url, data }): Promise => {
+ const validation = validateUrl(url);
+ if (!validation.valid) {
+ return {
+ content: [{ type: "text", text: validation.error! }],
+ isError: true,
+ };
+ }
+ const filePath = isFileUrl(url)
+ ? fileUrlToPath(url)
+ : isLocalPath(url)
+ ? decodeURIComponent(url)
+ : null;
+ if (!filePath) {
+ return {
+ content: [
+ { type: "text", text: "Save is only supported for local files" },
+ ],
+ isError: true,
+ };
+ }
+ try {
+ const bytes = Buffer.from(data, "base64");
+ const resolved = path.resolve(filePath);
+ await fs.promises.writeFile(resolved, bytes);
+ const { mtimeMs } = await fs.promises.stat(resolved);
+ // Don't suppress file_changed here — the saving viewer will recognise
+ // its own mtime, while other viewers on the same file correctly get
+ // notified that their content is stale.
+ return {
+ content: [{ type: "text", text: `Saved to ${filePath}` }],
+ structuredContent: { filePath: resolved, mtimeMs },
+ };
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Failed to save: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ },
+ );
+
// Resource: UI HTML
registerAppResource(
server,
@@ -674,7 +2375,21 @@ Accepts:
);
return {
contents: [
- { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
+ {
+ uri: RESOURCE_URI,
+ mimeType: RESOURCE_MIME_TYPE,
+ text: html,
+ _meta: {
+ ui: {
+ permissions: { clipboardWrite: {} },
+ csp: {
+ // pdf.js fetches the PDF Standard-14 fonts via fetch(),
+ // mapped to CSP connect-src.
+ connectDomains: [STANDARD_FONT_ORIGIN],
+ },
+ },
+ },
+ },
],
};
},
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css
index 008b22a6..1d7769c0 100644
--- a/examples/pdf-server/src/mcp-app.css
+++ b/examples/pdf-server/src/mcp-app.css
@@ -13,10 +13,7 @@
--text200: light-dark(#999999, #888888);
/* Shadows */
- --shadow-page: light-dark(
- 0 2px 8px rgba(0, 0, 0, 0.15),
- 0 2px 8px rgba(0, 0, 0, 0.4)
- );
+ --shadow-page-color: light-dark(rgba(0 0 0 / 0.15), rgba(0 0 0 / 0.4));
--selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4));
}
@@ -222,7 +219,8 @@ body {
/* Single Page Canvas Container */
.canvas-container {
flex: 1;
- overflow: visible;
+ overflow-x: auto; /* Allow horizontal scrolling when zoomed in inline mode */
+ overflow-y: visible;
display: flex;
justify-content: center;
align-items: flex-start;
@@ -232,7 +230,9 @@ body {
.page-wrapper {
position: relative;
- box-shadow: var(--shadow-page);
+ box-shadow:
+ 0 1px 4px var(--shadow-page-color),
+ 0 4px 16px var(--shadow-page-color);
background: white;
}
@@ -313,6 +313,10 @@ body {
min-height: 0; /* Allow flex item to shrink below content size */
}
+.main.fullscreen .viewer-body {
+ min-height: 0;
+}
+
.main.fullscreen .canvas-container {
min-height: 0; /* Allow flex item to shrink below content size */
overflow: auto; /* Scroll within the document area only */
@@ -408,6 +412,355 @@ body {
cursor: not-allowed;
}
+/* Annotation Layer */
+.annotation-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 3; /* above text-layer (z-index: 2) so notes are clickable */
+}
+
+.annotation-highlight {
+ position: absolute;
+ background: rgba(255, 255, 0, 0.35);
+ mix-blend-mode: multiply;
+ border-radius: 1px;
+ pointer-events: auto;
+ cursor: pointer;
+}
+
+.annotation-underline {
+ position: absolute;
+ border-bottom: 2px solid #ff0000;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: pointer;
+}
+
+.annotation-strikethrough {
+ position: absolute;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: pointer;
+}
+.annotation-strikethrough::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ border-top: 2px solid #ff0000;
+}
+
+.annotation-note {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+ pointer-events: auto;
+}
+.annotation-note::after {
+ content: "";
+ display: block;
+ width: 16px;
+ height: 16px;
+ margin: 2px;
+ background: currentColor;
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain;
+}
+.annotation-note .annotation-tooltip {
+ display: none;
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ background: var(--bg000, #fff);
+ color: var(--text000, #000);
+ border: 1px solid var(--bg200, #ccc);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ max-width: 200px;
+ z-index: 10;
+ pointer-events: none;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+.annotation-note:hover .annotation-tooltip {
+ display: block;
+}
+
+.annotation-rectangle {
+ position: absolute;
+ border: 2px solid #0066cc;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-circle {
+ position: absolute;
+ border: 2px solid #0066cc;
+ border-radius: 50%;
+ box-sizing: border-box;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-line {
+ position: absolute;
+ height: 0;
+ border-top: 2px solid #333;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-freetext {
+ position: absolute;
+ font-family: Helvetica, Arial, sans-serif;
+ white-space: pre-wrap;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-stamp {
+ position: absolute;
+ font-family: Helvetica, Arial, sans-serif;
+ font-weight: bold;
+ font-size: 24px;
+ border: 3px solid currentColor;
+ padding: 4px 12px;
+ opacity: 0.6;
+ text-transform: uppercase;
+ white-space: nowrap;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.annotation-image {
+ position: absolute;
+ pointer-events: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+/* Selection visuals */
+.annotation-selected {
+ outline: 2px solid var(--accent, #2563eb);
+ outline-offset: 2px;
+ z-index: 10;
+}
+
+.annotation-handle {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ background: white;
+ border: 2px solid var(--accent, #2563eb);
+ border-radius: 50%;
+ z-index: 11;
+ pointer-events: auto;
+}
+.annotation-handle.nw { top: -5px; left: -5px; cursor: nwse-resize; }
+.annotation-handle.ne { top: -5px; right: -5px; cursor: nesw-resize; }
+.annotation-handle.sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
+.annotation-handle.se { bottom: -5px; right: -5px; cursor: nwse-resize; }
+
+.annotation-handle-rotate {
+ position: absolute;
+ top: -20px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 10px;
+ height: 10px;
+ background: white;
+ border: 2px solid var(--accent, #2563eb);
+ border-radius: 50%;
+ z-index: 11;
+ pointer-events: auto;
+ cursor: grab;
+}
+
+.annotation-card.selected {
+ background: var(--accent-bg, rgba(37, 99, 235, 0.08));
+ border-left-color: var(--accent, #2563eb);
+}
+
+.annotation-dragging {
+ cursor: grabbing !important;
+}
+
+@media (prefers-color-scheme: dark) {
+ .annotation-highlight {
+ background: rgba(255, 255, 0, 0.3);
+ mix-blend-mode: screen;
+ }
+}
+
+/* Save / Download Buttons */
+.save-btn,
+.download-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ background: var(--bg000);
+ color: var(--text000);
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.15s ease;
+}
+
+.save-btn:hover:not(:disabled),
+.download-btn:hover:not(:disabled) {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+
+.save-btn:disabled,
+.download-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/*
+ * Confirmation Dialog
+ *
+ * Styled to match the host's native dialogs (e.g. the downloadFile prompt).
+ * Uses host-provided CSS variables (set via applyHostStyleVariables) with
+ * local fallbacks so it still looks reasonable in standalone dev.
+ */
+.confirm-dialog {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6));
+ font-family: var(--font-sans, inherit);
+}
+
+.confirm-box {
+ background: var(--color-background-primary, var(--bg000));
+ border-radius: var(--border-radius-xl, 20px);
+ padding: 1.75rem;
+ min-width: 360px;
+ max-width: 520px;
+ box-shadow: var(--shadow-lg, 0 20px 50px rgba(0, 0, 0, 0.3));
+}
+
+.confirm-title {
+ font-weight: var(--font-weight-bold, 700);
+ font-size: var(--font-heading-md-size, 1.5rem);
+ line-height: var(--font-heading-md-line-height, 1.25);
+ margin-bottom: 0.5rem;
+ color: var(--color-text-primary, var(--text000));
+}
+
+.confirm-body {
+ font-size: var(--font-text-md-size, 1rem);
+ line-height: var(--font-text-md-line-height, 1.5);
+ color: var(--color-text-secondary, var(--text100));
+ margin-bottom: 1.5rem;
+ word-break: break-word;
+}
+
+/* Optional monospace detail box (filename, path, etc.) */
+.confirm-detail {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: var(--font-text-sm-size, 0.9rem);
+ padding: 0.75rem 1rem;
+ margin-bottom: 1.5rem;
+ border: var(--border-width-regular, 1px) solid
+ var(--color-border-secondary, var(--bg200));
+ border-radius: var(--border-radius-md, 8px);
+ background: var(--color-background-secondary, var(--bg100));
+ color: var(--color-text-primary, var(--text000));
+ word-break: break-all;
+}
+
+.confirm-detail:empty {
+ display: none;
+}
+
+.confirm-buttons {
+ display: flex;
+ gap: 0.75rem;
+ justify-content: flex-end;
+}
+
+.confirm-btn {
+ padding: 0.625rem 1.25rem;
+ border: var(--border-width-regular, 1px) solid
+ var(--color-border-primary, var(--bg200));
+ border-radius: var(--border-radius-md, 8px);
+ background: var(--color-background-primary, var(--bg000));
+ color: var(--color-text-primary, var(--text000));
+ cursor: pointer;
+ font: inherit;
+ font-size: var(--font-text-md-size, 0.95rem);
+ font-weight: var(--font-weight-medium, 500);
+}
+
+.confirm-btn:hover {
+ background: var(--color-background-secondary, var(--bg100));
+}
+
+.confirm-btn-primary {
+ /* Host's native primary buttons use inverse colors (dark bg, light text) */
+ background: var(--color-background-inverse, var(--text000));
+ border-color: var(--color-background-inverse, var(--text000));
+ color: var(--color-text-inverse, var(--bg000));
+}
+
+.confirm-btn-primary:hover {
+ /* .confirm-btn:hover above would otherwise reset background */
+ background: var(--color-background-inverse, var(--text000));
+ filter: brightness(1.15);
+}
+
+/* Form Layer (PDF.js AnnotationLayer for interactive form widgets) */
+#form-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 4; /* above annotation-layer (z-index: 3) so form inputs are clickable */
+}
+
+/*
+ * PDF.js AnnotationLayer renders interactive form inputs on top of the canvas.
+ * When the widget is transparent the canvas's static appearance bleeds through,
+ * causing double-text (the widget font never matches the PDF's font metrics).
+ * Fix: force widgets opaque. PDF.js also reads the annotation's /BC entry and
+ * writes it as an inline `background-color` on the element (typically
+ * `transparent` for unstyled forms) — inline style beats any selector, hence
+ * !important.
+ */
+#form-layer .textWidgetAnnotation :is(input, textarea),
+#form-layer .choiceWidgetAnnotation select {
+ background-image: none !important;
+ background-color: light-dark(#fff, #2a2a2a) !important;
+}
+
+#form-layer select option:checked {
+ background: light-dark(#d0e0ff, #335);
+}
+
+
/* Highlight Layer */
.highlight-layer {
position: absolute;
@@ -442,6 +795,346 @@ body {
}
}
+/* Viewer Body (flex row for canvas + panel) */
+.viewer-body {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: visible;
+ position: relative; /* Anchor for floating annotation panel */
+}
+
+/* Annotations Toolbar Button */
+.annotations-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ background: var(--bg000);
+ color: var(--text000);
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.15s ease;
+ position: relative;
+}
+.annotations-btn:hover {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+.annotations-btn.active {
+ background: var(--bg200);
+ border-color: var(--bg300);
+}
+
+.annotations-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ min-width: 16px;
+ height: 16px;
+ background: var(--accent, #2563eb);
+ color: #fff;
+ font-size: 0.65rem;
+ font-weight: 600;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 3px;
+ line-height: 1;
+}
+
+/* Annotation Side Panel */
+.annotation-panel {
+ width: 250px;
+ min-width: 120px;
+ max-width: 50vw;
+ background: var(--bg000);
+ border-left: 1px solid var(--bg200);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+}
+
+/* Resize handle on left edge */
+.annotation-panel-resize {
+ position: absolute;
+ left: -3px;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ cursor: col-resize;
+ z-index: 10;
+}
+.annotation-panel-resize:hover,
+.annotation-panel-resize.dragging {
+ background: var(--text100);
+ opacity: 0.3;
+}
+
+.annotation-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--bg200);
+ flex-shrink: 0;
+}
+
+.annotation-panel-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text000);
+}
+
+.annotation-panel-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.annotation-panel-reset,
+.annotation-panel-clear-all {
+ border: none;
+ background: transparent;
+ color: var(--text100);
+ cursor: pointer;
+ border-radius: 4px;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.annotation-panel-reset:hover {
+ color: var(--text000);
+ background: var(--bg100);
+}
+.annotation-panel-clear-all:hover {
+ color: #e74c3c;
+ background: var(--bg100);
+}
+.annotation-panel-reset:disabled,
+.annotation-panel-clear-all:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.annotation-panel-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text100);
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: all 0.15s ease;
+}
+.annotation-panel-close:hover {
+ background: var(--bg100);
+ color: var(--text000);
+}
+
+.annotation-panel-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.25rem 0;
+}
+
+/* Floating annotation panel (used in both inline and fullscreen) */
+.annotation-panel.floating {
+ position: absolute;
+ z-index: 20;
+ border: 1px solid var(--bg200);
+ border-radius: 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ max-height: 60%;
+ width: 135px;
+ transition: top 0.15s ease, left 0.15s ease, right 0.15s ease, bottom 0.15s ease;
+}
+.annotation-panel.floating .annotation-panel-header {
+ cursor: grab;
+}
+.annotation-panel.floating.dragging .annotation-panel-header {
+ cursor: grabbing;
+}
+
+/* Accordion section headers */
+.annotation-section-header {
+ font-size: 0.7rem;
+ font-weight: 600;
+ color: var(--text100);
+ padding: 0.3rem 0.6rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--bg200);
+ user-select: none;
+ transition: background 0.1s ease;
+}
+.annotation-section-header:hover {
+ background: var(--bg100);
+}
+.annotation-section-header.current-page {
+ color: var(--text000);
+}
+.annotation-section-header.open {
+ color: var(--text000);
+}
+.annotation-section-chevron {
+ font-size: 0.55rem;
+ transition: transform 0.15s ease;
+}
+.annotation-section-body {
+ display: none;
+}
+.annotation-section-body.open {
+ display: block;
+}
+
+/* Legacy page group headers (fullscreen mode) */
+.annotation-page-group {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text100);
+ padding: 0.5rem 0.75rem 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+.annotation-page-group.current-page {
+ color: var(--text000);
+}
+
+/* Annotation cards */
+.annotation-card {
+ display: flex;
+ flex-direction: column;
+ padding: 0.4rem 0.75rem;
+ cursor: pointer;
+ transition: background 0.1s ease;
+ border-left: 3px solid transparent;
+}
+.annotation-card:hover {
+ background: var(--bg100);
+}
+.annotation-card.highlighted {
+ background: var(--bg100);
+ border-left-color: var(--text100);
+}
+
+.annotation-card-row {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 24px;
+}
+
+.annotation-card-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ flex-shrink: 0;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+}
+
+/* Swatch for baseline items the user cleared — outlined with a cross
+ instead of solid fill, so the panel still shows "this existed in the
+ file and you removed it" rather than vanishing. */
+.annotation-card-swatch-cleared {
+ background: transparent;
+ border-color: #4a90d9;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.annotation-card-cleared .annotation-card-type,
+.annotation-card-cleared .annotation-card-preview {
+ opacity: 0.55;
+ text-decoration: line-through;
+}
+
+.annotation-card-type {
+ font-size: 0.7rem;
+ color: var(--text200);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ flex-shrink: 0;
+}
+
+.annotation-card-preview {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.8rem;
+ color: var(--text000);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.annotation-card-delete {
+ flex-shrink: 0;
+ margin-left: auto;
+ background: none;
+ border: none;
+ padding: 2px;
+ cursor: pointer;
+ color: var(--text200);
+ opacity: 0;
+ transition: opacity 0.1s ease, color 0.1s ease;
+ display: flex;
+ align-items: center;
+}
+.annotation-card:hover .annotation-card-delete {
+ opacity: 1;
+}
+.annotation-card-delete:hover {
+ color: #d32f2f;
+}
+
+.annotation-card-expand {
+ flex-shrink: 0;
+ font-size: 0.65rem;
+ color: var(--text200);
+ transition: transform 0.15s ease;
+}
+.annotation-card.expanded .annotation-card-expand {
+ transform: rotate(180deg);
+}
+
+.annotation-card-content {
+ display: none;
+ font-size: 0.8rem;
+ color: var(--text100);
+ padding: 0.25rem 0 0.25rem 14px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.4;
+}
+.annotation-card.expanded .annotation-card-content {
+ display: block;
+}
+
+/* Pulse animation for annotation elements on PDF */
+@keyframes annotation-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.6); }
+ 70% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0); }
+}
+.annotation-pulse {
+ animation: annotation-pulse 0.6s ease-out 2;
+}
+
/* Loading Indicator (pie) */
.loading-indicator {
display: inline-flex;
@@ -468,3 +1161,5 @@ body {
.loading-indicator.error .loading-indicator-arc {
stroke: #e74c3c;
}
+
+
diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts
index 4278d928..c929bd90 100644
--- a/examples/pdf-server/src/mcp-app.ts
+++ b/examples/pdf-server/src/mcp-app.ts
@@ -15,18 +15,43 @@ import {
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
import * as pdfjsLib from "pdfjs-dist";
-import { TextLayer } from "pdfjs-dist";
+import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist";
+import "pdfjs-dist/web/pdf_viewer.css";
+import {
+ type PdfAnnotationDef,
+ type Rect,
+ type RectangleAnnotation,
+ type CircleAnnotation,
+ type LineAnnotation,
+ type StampAnnotation,
+ type ImageAnnotation,
+ type NoteAnnotation,
+ type FreetextAnnotation,
+ serializeDiff,
+ deserializeDiff,
+ mergeAnnotations,
+ computeDiff,
+ isDiffEmpty,
+ buildAnnotatedPdfBytes,
+ importPdfjsAnnotation,
+ uint8ArrayToBase64,
+ convertFromModelCoords,
+ convertToModelCoords,
+} from "./pdf-annotations.js";
import "./global.css";
import "./mcp-app.css";
const MAX_MODEL_CONTEXT_LENGTH = 15000;
-const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max screenshot dimension
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).href;
+// PDF Standard-14 fonts from CDN (requires unpkg.com in CSP connectDomains).
+// Pinned to the bundled pdfjs-dist version so font glyph indices match.
+const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/standard_fonts/`;
+
const log = {
info: console.log.bind(console, "[PDF-VIEWER]"),
error: console.error.bind(console, "[PDF-VIEWER]"),
@@ -40,8 +65,74 @@ let scale = 1.0;
let pdfUrl = "";
let pdfTitle: string | undefined;
let viewUUID: string | undefined;
+let interactEnabled = false;
+/** Server-reported writability of the underlying file (fs.access W_OK). */
+let fileWritable = false;
let currentRenderTask: { cancel: () => void } | null = null;
+// Annotation types imported from ./pdf-annotations.ts
+
+interface TrackedAnnotation {
+ def: PdfAnnotationDef;
+ elements: HTMLElement[];
+}
+
+// Annotation state
+const annotationMap = new Map();
+const formFieldValues = new Map();
+/** Cache loaded HTMLImageElement instances by annotation ID for canvas painting. */
+const imageCache = new Map();
+
+/** Annotations imported from the PDF file (baseline for diff computation). */
+let pdfBaselineAnnotations: PdfAnnotationDef[] = [];
+/** Form field values stored in the PDF file itself (baseline for diff computation). */
+const pdfBaselineFormValues = new Map();
+
+// Dirty flag — tracks unsaved local changes
+let isDirty = false;
+/** Whether we're currently restoring annotations (suppress dirty flag). */
+let isRestoring = false;
+/** Once the save button is shown, it stays visible (possibly disabled) until reload. */
+let saveBtnEverShown = false;
+/** True between save_pdf call and resolution; suppresses file_changed handling. */
+let saveInProgress = false;
+/** mtime returned by our most recent successful save_pdf. Compare against
+ * incoming file_changed.mtimeMs to suppress our own write's echo. */
+let lastSavedMtime: number | null = null;
+/** Incremented on every reload. Fetches/preloads from an older generation are
+ * discarded — prevents stale rangeCache entries and stale page renders. */
+let loadGeneration = 0;
+
+// Selection & interaction state
+const selectedAnnotationIds = new Set();
+let focusedFieldName: string | null = null;
+
+// Undo/Redo
+interface EditEntry {
+ type: "update" | "add" | "remove";
+ id: string;
+ before: PdfAnnotationDef | null;
+ after: PdfAnnotationDef | null;
+}
+const undoStack: EditEntry[] = [];
+const redoStack: EditEntry[] = [];
+
+// PDF.js form field name → annotation IDs mapping (for annotationStorage)
+const fieldNameToIds = new Map();
+// Radio widget annotation ID → its export value (buttonValue). pdf.js
+// creates without setting .value, so target.value
+// defaults to "on"; this map lets the input listener report the real value.
+const radioButtonValues = new Map();
+// PDF.js form field name → page number mapping
+const fieldNameToPage = new Map();
+// PDF.js form field name → human-readable label (from PDF TU / alternativeText)
+const fieldNameToLabel = new Map();
+// PDF.js form field name → intrinsic order index (page, then top-to-bottom Y position)
+const fieldNameToOrder = new Map();
+// Cached result of doc.getFieldObjects() — needed for AnnotationLayer reset button support
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let cachedFieldObjects: Record | null = null;
+
// DOM Elements
const mainEl = document.querySelector(".main") as HTMLElement;
const loadingEl = document.getElementById("loading")!;
@@ -80,6 +171,48 @@ const searchCloseBtn = document.getElementById(
"search-close-btn",
) as HTMLButtonElement;
const highlightLayerEl = document.getElementById("highlight-layer")!;
+const annotationLayerEl = document.getElementById("annotation-layer")!;
+const formLayerEl = document.getElementById("form-layer") as HTMLDivElement;
+const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
+const downloadBtn = document.getElementById(
+ "download-btn",
+) as HTMLButtonElement;
+const confirmDialogEl = document.getElementById(
+ "confirm-dialog",
+) as HTMLDivElement;
+const confirmTitleEl = document.getElementById("confirm-title")!;
+const confirmBodyEl = document.getElementById("confirm-body")!;
+const confirmDetailEl = document.getElementById("confirm-detail")!;
+const confirmButtonsEl = document.getElementById("confirm-buttons")!;
+
+// Annotation Panel DOM Elements
+const annotationsPanelEl = document.getElementById("annotation-panel")!;
+const annotationsPanelListEl = document.getElementById(
+ "annotation-panel-list",
+)!;
+const annotationsPanelCountEl = document.getElementById(
+ "annotation-panel-count",
+)!;
+const annotationsPanelCloseBtn = document.getElementById(
+ "annotation-panel-close",
+) as HTMLButtonElement;
+const annotationsPanelResetBtn = document.getElementById(
+ "annotation-panel-reset",
+) as HTMLButtonElement;
+const annotationsPanelClearAllBtn = document.getElementById(
+ "annotation-panel-clear-all",
+) as HTMLButtonElement;
+const annotationsBtn = document.getElementById(
+ "annotations-btn",
+) as HTMLButtonElement;
+const annotationsBadgeEl = document.getElementById(
+ "annotations-badge",
+) as HTMLElement;
+
+// Annotation panel state
+let annotationPanelOpen = false;
+/** null = user hasn't manually toggled; true/false = manual preference */
+let annotationPanelUserPref: boolean | null = null;
// Search state
interface SearchMatch {
@@ -148,7 +281,10 @@ function requestFitToContent() {
const totalHeight =
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
- app.sendSizeChanged({ height: totalHeight });
+ // In inline mode (this function early-returns for fullscreen) the side panel is hidden
+ const totalWidth = pageWrapperEl.offsetWidth + BUFFER;
+
+ app.sendSizeChanged({ width: totalWidth, height: totalHeight });
}
// --- Search Functions ---
@@ -194,6 +330,46 @@ function performSearch(query: string) {
goToPage(match.pageNum);
}
}
+
+ // Update model context with search results
+ updatePageContext();
+}
+
+/**
+ * Silent search: populate matches and report via model context
+ * without opening the search bar or rendering highlights.
+ */
+function performSilentSearch(query: string) {
+ allMatches = [];
+ currentMatchIndex = -1;
+ searchQuery = query;
+
+ if (!query) {
+ updatePageContext();
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+ for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
+ const pageText = pageTextCache.get(pageNum);
+ if (!pageText) continue;
+ const lowerText = pageText.toLowerCase();
+ let startIdx = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, startIdx);
+ if (idx === -1) break;
+ allMatches.push({ pageNum, index: idx, length: query.length });
+ startIdx = idx + 1;
+ }
+ }
+
+ if (allMatches.length > 0) {
+ const idx = allMatches.findIndex((m) => m.pageNum >= currentPage);
+ currentMatchIndex = idx >= 0 ? idx : 0;
+ }
+
+ log.info(`Silent search "${query}": ${allMatches.length} matches`);
+ updatePageContext();
}
function renderHighlights() {
@@ -325,6 +501,12 @@ function openSearch() {
searchBarEl.style.display = "flex";
updateSearchUI();
searchInputEl.focus();
+ if (
+ annotationPanelOpen &&
+ annotationsPanelEl.classList.contains("floating")
+ ) {
+ applyFloatingPanelPosition();
+ }
// Text extraction is handled by the background preloader
}
@@ -332,6 +514,12 @@ function closeSearch() {
if (!searchOpen) return;
searchOpen = false;
searchBarEl.style.display = "none";
+ if (
+ annotationPanelOpen &&
+ annotationsPanelEl.classList.contains("floating")
+ ) {
+ applyFloatingPanelPosition();
+ }
searchQuery = "";
searchInputEl.value = "";
allMatches = [];
@@ -402,10 +590,116 @@ function showViewer() {
viewerEl.style.display = "flex";
}
+// ---------------------------------------------------------------------------
+// Confirm dialog
+// ---------------------------------------------------------------------------
+
+interface ConfirmButton {
+ label: string;
+ primary?: boolean;
+}
+
+let activeConfirmResolve: ((i: number) => void) | null = null;
+
+/**
+ * In-app confirmation overlay. Resolves to the clicked button index, the
+ * cancel index on Escape, or `-1` if pre-empted by another dialog. Callers
+ * should treat anything but the expected button index as "cancel".
+ *
+ * Button ordering follows the host's native convention: Cancel first,
+ * primary action last.
+ *
+ * @param detail Optional monospace string shown in a bordered box (e.g.
+ * a filename), matching the host's native dialog style.
+ */
+function showConfirmDialog(
+ title: string,
+ body: string,
+ buttons: ConfirmButton[],
+ detail?: string,
+): Promise {
+ // Pre-empt any open dialog: resolve it as cancelled
+ if (activeConfirmResolve) {
+ activeConfirmResolve(-1);
+ activeConfirmResolve = null;
+ }
+
+ // Escape → first non-primary button (native Cancel-first ordering)
+ const nonPrimary = buttons.findIndex((b) => !b.primary);
+ const escIndex = nonPrimary >= 0 ? nonPrimary : buttons.length - 1;
+
+ confirmTitleEl.textContent = title;
+ confirmBodyEl.textContent = body;
+ confirmDetailEl.textContent = detail ?? "";
+ confirmButtonsEl.innerHTML = "";
+ confirmDialogEl.style.display = "flex";
+
+ return new Promise((resolve) => {
+ activeConfirmResolve = resolve;
+
+ const done = (i: number): void => {
+ if (activeConfirmResolve !== resolve) return; // already pre-empted
+ activeConfirmResolve = null;
+ confirmDialogEl.style.display = "none";
+ document.removeEventListener("keydown", onKey, true);
+ resolve(i);
+ };
+
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ done(escIndex);
+ }
+ };
+ document.addEventListener("keydown", onKey, true);
+
+ buttons.forEach((btn, i) => {
+ const el = document.createElement("button");
+ el.textContent = btn.label;
+ el.className = btn.primary
+ ? "confirm-btn confirm-btn-primary"
+ : "confirm-btn";
+ el.addEventListener("click", () => done(i));
+ confirmButtonsEl.appendChild(el);
+ if (btn.primary) setTimeout(() => el.focus(), 0);
+ });
+ });
+}
+
+function setDirty(dirty: boolean): void {
+ if (isDirty === dirty) return;
+ isDirty = dirty;
+ updateTitleDisplay();
+ updateSaveBtn();
+}
+
+function updateSaveBtn(): void {
+ if (!fileWritable) {
+ saveBtn.style.display = "none";
+ return;
+ }
+ if (isDirty) {
+ saveBtn.style.display = "";
+ saveBtn.disabled = false;
+ saveBtnEverShown = true;
+ } else if (saveBtnEverShown) {
+ saveBtn.style.display = "";
+ saveBtn.disabled = true;
+ } else {
+ saveBtn.style.display = "none";
+ }
+}
+
+function updateTitleDisplay(): void {
+ const display = pdfTitle || pdfUrl;
+ titleEl.textContent = (isDirty ? "* " : "") + display;
+ titleEl.title = pdfUrl;
+}
+
function updateControls() {
// Show URL with CSS ellipsis, full URL as tooltip, clickable to open
- titleEl.textContent = pdfUrl;
- titleEl.title = pdfUrl;
+ updateTitleDisplay();
titleEl.style.textDecoration = "underline";
titleEl.style.cursor = "pointer";
titleEl.onclick = () => app.openLink({ url: pdfUrl });
@@ -511,6 +805,50 @@ function findSelectionInText(
return undefined;
}
+/**
+ * Format search results with excerpts for model context.
+ * Limits to first 20 matches to avoid overwhelming the context.
+ */
+function formatSearchResults(): string {
+ const MAX_RESULTS = 20;
+ const EXCERPT_RADIUS = 40; // characters around the match
+
+ const lines: string[] = [];
+ const totalMatchCount = allMatches.length;
+ const currentIdx = currentMatchIndex >= 0 ? currentMatchIndex : -1;
+
+ lines.push(
+ `\nSearch: "${searchQuery}" (${totalMatchCount} match${totalMatchCount !== 1 ? "es" : ""} across ${new Set(allMatches.map((m) => m.pageNum)).size} page${new Set(allMatches.map((m) => m.pageNum)).size !== 1 ? "s" : ""})`,
+ );
+
+ const displayed = allMatches.slice(0, MAX_RESULTS);
+ for (let i = 0; i < displayed.length; i++) {
+ const match = displayed[i];
+ const pageText = pageTextCache.get(match.pageNum) || "";
+ const start = Math.max(0, match.index - EXCERPT_RADIUS);
+ const end = Math.min(
+ pageText.length,
+ match.index + match.length + EXCERPT_RADIUS,
+ );
+ const before = pageText.slice(start, match.index).replace(/\n/g, " ");
+ const matched = pageText.slice(match.index, match.index + match.length);
+ const after = pageText
+ .slice(match.index + match.length, end)
+ .replace(/\n/g, " ");
+ const prefix = start > 0 ? "..." : "";
+ const suffix = end < pageText.length ? "..." : "";
+ const current = i === currentIdx ? " (current)" : "";
+ lines.push(
+ ` [${i}] p.${match.pageNum}, offset ${match.index}${current}: ${prefix}${before}«${matched}»${after}${suffix}`,
+ );
+ }
+ if (totalMatchCount > MAX_RESULTS) {
+ lines.push(` ... and ${totalMatchCount - MAX_RESULTS} more matches`);
+ }
+
+ return lines.join("\n");
+}
+
// Extract text from current page and update model context
async function updatePageContext() {
if (!pdfDocument) return;
@@ -547,59 +885,2818 @@ async function updatePageContext() {
selection,
);
+ // Get page dimensions in PDF points for model context
+ const viewport = page.getViewport({ scale: 1.0 });
+ const pageWidthPt = Math.round(viewport.width);
+ const pageHeightPt = Math.round(viewport.height);
+
// Build context with tool ID for multi-tool disambiguation
const toolId = app.getHostContext()?.toolInfo?.id;
const header = [
`PDF viewer${toolId ? ` (${toolId})` : ""}`,
+ viewUUID ? `viewUUID: ${viewUUID}` : null,
pdfTitle ? `"${pdfTitle}"` : pdfUrl,
`Current Page: ${currentPage}/${totalPages}`,
- ].join(" | ");
+ `Page size: ${pageWidthPt}×${pageHeightPt}pt (coordinates: origin at top-left, Y increases downward)`,
+ ]
+ .filter(Boolean)
+ .join(" | ");
+
+ // Include search status if active
+ let searchSection = "";
+ if (searchOpen && searchQuery && allMatches.length > 0) {
+ searchSection = formatSearchResults();
+ } else if (searchOpen && searchQuery) {
+ searchSection = `\nSearch: "${searchQuery}" (no matches found)`;
+ }
+
+ // Include annotation details if any exist
+ let annotationSection = "";
+ if (annotationMap.size > 0) {
+ const onThisPage = [...annotationMap.values()].filter(
+ (t) => t.def.page === currentPage,
+ );
+ annotationSection = `\nAnnotations: ${onThisPage.length} on this page, ${annotationMap.size} total`;
+ if (formFieldValues.size > 0) {
+ annotationSection += ` | ${formFieldValues.size} form field(s) filled`;
+ }
+ // List annotations on current page with their coordinates (in model space)
+ if (onThisPage.length > 0) {
+ annotationSection +=
+ "\nAnnotations on this page (visible in screenshot):";
+ for (const t of onThisPage) {
+ const d = convertToModelCoords(t.def, pageHeightPt);
+ const selected = selectedAnnotationIds.has(d.id) ? " (SELECTED)" : "";
+ if ("rects" in d && d.rects.length > 0) {
+ const r = d.rects[0];
+ annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(r.x)},${Math.round(r.y)}) ${Math.round(r.width)}x${Math.round(r.height)}${selected}`;
+ } else if ("x" in d && "y" in d) {
+ annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(d.x)},${Math.round(d.y)})${selected}`;
+ }
+ }
+ }
+ }
+
+ // Include focused field or selected annotation info
+ let focusSection = "";
+ if (selectedAnnotationIds.size > 0) {
+ const ids = [...selectedAnnotationIds];
+ const descs = ids.map((selId) => {
+ const tracked = annotationMap.get(selId);
+ if (!tracked) return selId;
+ return `[${selId}] (${tracked.def.type})`;
+ });
+ focusSection = `\nSelected: ${descs.join(", ")}`;
+ }
+ if (focusedFieldName) {
+ const label = getFormFieldLabel(focusedFieldName);
+ const value = formFieldValues.get(focusedFieldName);
+ focusSection += `\nFocused field: "${label}" (name="${focusedFieldName}")`;
+ if (value !== undefined) {
+ focusSection += ` = ${JSON.stringify(value)}`;
+ }
+ }
+
+ const contextText = `${header}${searchSection}${annotationSection}${focusSection}\n\nPage content:\n${content}`;
+
+ // Build content array with text and optional screenshot
+ const contentBlocks: ContentBlock[] = [{ type: "text", text: contextText }];
+
+ // Add screenshot if host supports image content
+ if (app.getHostCapabilities()?.updateModelContext?.image) {
+ try {
+ // Render offscreen with ENABLE_STORAGE so filled form fields are visible
+ const base64Data = await renderPageOffscreen(currentPage);
+ if (base64Data) {
+ contentBlocks.push({
+ type: "image",
+ data: base64Data,
+ mimeType: "image/jpeg",
+ });
+ log.info("Added screenshot to model context");
+ }
+ } catch (err) {
+ log.info("Failed to capture screenshot:", err);
+ }
+ }
+
+ app.updateModelContext({ content: contentBlocks });
+ } catch (err) {
+ log.error("Error updating context:", err);
+ }
+}
+
+// =============================================================================
+// Annotation Rendering
+// =============================================================================
+
+/**
+ * Convert PDF coordinates (bottom-left origin) to screen coordinates
+ * relative to the page wrapper. PDF.js viewport handles rotation and scale.
+ */
+function pdfRectToScreen(
+ rect: Rect,
+ viewport: { width: number; height: number; scale: number },
+): { left: number; top: number; width: number; height: number } {
+ const s = viewport.scale;
+ // PDF origin is bottom-left, screen origin is top-left
+ const left = rect.x * s;
+ const top = viewport.height - (rect.y + rect.height) * s;
+ const width = rect.width * s;
+ const height = rect.height * s;
+ return { left, top, width, height };
+}
+
+function pdfPointToScreen(
+ x: number,
+ y: number,
+ viewport: { width: number; height: number; scale: number },
+): { left: number; top: number } {
+ const s = viewport.scale;
+ return { left: x * s, top: viewport.height - y * s };
+}
+
+/** Convert a screen-space delta (pixels) to a PDF-space delta. */
+function screenToPdfDelta(dx: number, dy: number): { dx: number; dy: number } {
+ return { dx: dx / scale, dy: -dy / scale };
+}
+
+// =============================================================================
+// Undo / Redo
+// =============================================================================
+
+function pushEdit(entry: EditEntry): void {
+ undoStack.push(entry);
+ redoStack.length = 0;
+}
+
+function undo(): void {
+ const entry = undoStack.pop();
+ if (!entry) return;
+ redoStack.push(entry);
+ applyEdit(entry, true);
+}
+
+function redo(): void {
+ const entry = redoStack.pop();
+ if (!entry) return;
+ undoStack.push(entry);
+ applyEdit(entry, false);
+}
+
+function applyEdit(entry: EditEntry, reverse: boolean): void {
+ const state = reverse ? entry.before : entry.after;
+ if (entry.type === "add") {
+ if (reverse) {
+ removeAnnotation(entry.id, true);
+ } else {
+ addAnnotation(state!, true);
+ }
+ } else if (entry.type === "remove") {
+ if (reverse) {
+ addAnnotation(state!, true);
+ } else {
+ removeAnnotation(entry.id, true);
+ }
+ } else {
+ if (state) {
+ const tracked = annotationMap.get(entry.id);
+ if (tracked) {
+ tracked.def = { ...state };
+ } else {
+ annotationMap.set(entry.id, { def: { ...state }, elements: [] });
+ }
+ }
+ renderAnnotationsForPage(currentPage);
+ renderAnnotationPanel();
+ }
+ persistAnnotations();
+}
+
+// =============================================================================
+// Selection
+// =============================================================================
+
+/**
+ * Select annotation(s). Pass null to deselect all.
+ * If additive is true, toggle the given id without clearing existing selection.
+ */
+function selectAnnotation(id: string | null, additive = false): void {
+ if (!additive) {
+ // Clear all existing selection visuals
+ for (const prevId of selectedAnnotationIds) {
+ const tracked = annotationMap.get(prevId);
+ if (tracked) {
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-selected");
+ }
+ }
+ }
+ // Remove handles
+ for (const h of annotationLayerEl.querySelectorAll(
+ ".annotation-handle, .annotation-handle-rotate",
+ )) {
+ h.remove();
+ }
+ selectedAnnotationIds.clear();
+ }
+
+ if (id) {
+ if (additive && selectedAnnotationIds.has(id)) {
+ // Toggle off
+ selectedAnnotationIds.delete(id);
+ const tracked = annotationMap.get(id);
+ if (tracked) {
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-selected");
+ }
+ }
+ } else {
+ selectedAnnotationIds.add(id);
+ }
+ }
+
+ // Apply selection visuals + handles on all selected
+ // Only show handles when exactly one annotation is selected
+ for (const selId of selectedAnnotationIds) {
+ const tracked = annotationMap.get(selId);
+ if (tracked) {
+ for (const el of tracked.elements) {
+ el.classList.add("annotation-selected");
+ }
+ if (selectedAnnotationIds.size === 1) {
+ showHandles(tracked);
+ }
+ }
+ }
+
+ // Auto-expand the accordion section for the selected annotation's page
+ if (id) {
+ const tracked = annotationMap.get(id);
+ if (tracked) {
+ openAccordionSection = `page-${tracked.def.page}`;
+ }
+ }
+
+ // Sync sidebar
+ syncSidebarSelection();
+ // Auto-dock floating panel away from selected annotation
+ if (
+ selectedAnnotationIds.size > 0 &&
+ annotationsPanelEl.classList.contains("floating") &&
+ annotationPanelOpen
+ ) {
+ autoDockPanel();
+ }
+ // Update model context with selection info
+ updatePageContext();
+}
+
+function syncSidebarSelection(): void {
+ for (const card of annotationsPanelListEl.querySelectorAll(
+ ".annotation-card",
+ )) {
+ const cardId = (card as HTMLElement).dataset.annotationId;
+ card.classList.toggle(
+ "selected",
+ !!cardId && selectedAnnotationIds.has(cardId),
+ );
+ }
+}
+
+/** Types that support resize handles (need width/height). */
+const RESIZABLE_TYPES = new Set(["rectangle", "circle", "image"]);
+/** Types that support rotation. */
+const ROTATABLE_TYPES = new Set(["rectangle", "stamp", "image"]);
+
+function showHandles(tracked: TrackedAnnotation): void {
+ const def = tracked.def;
+ if (tracked.elements.length === 0) return;
+ if (!RESIZABLE_TYPES.has(def.type) && !ROTATABLE_TYPES.has(def.type)) return;
+
+ const el = tracked.elements[0];
+
+ // Resize handles (corners) for types with width/height
+ if (RESIZABLE_TYPES.has(def.type) && "width" in def && "height" in def) {
+ for (const corner of ["nw", "ne", "sw", "se"] as const) {
+ const handle = document.createElement("div");
+ handle.className = `annotation-handle ${corner}`;
+ handle.dataset.corner = corner;
+ const isImagePreserve =
+ def.type === "image" &&
+ ((def as ImageAnnotation).aspect ?? "preserve") === "preserve";
+ handle.title = isImagePreserve
+ ? "Drag to resize (Shift for free resize)"
+ : "Drag to resize (Shift to keep proportions)";
+ setupResizeHandle(handle, tracked, corner);
+ el.appendChild(handle);
+ }
+ }
+
+ // Rotate handle for rotatable types
+ if (ROTATABLE_TYPES.has(def.type)) {
+ const handle = document.createElement("div");
+ handle.className = "annotation-handle-rotate";
+ handle.title = "Drag to rotate";
+ setupRotateHandle(handle, tracked);
+ el.appendChild(handle);
+ }
+}
+
+// =============================================================================
+// Drag (move)
+// =============================================================================
+
+const DRAGGABLE_TYPES = new Set([
+ "rectangle",
+ "circle",
+ "line",
+ "freetext",
+ "stamp",
+ "note",
+ "image",
+]);
+
+function setupAnnotationInteraction(
+ el: HTMLElement,
+ tracked: TrackedAnnotation,
+): void {
+ // Click to select (Shift+click for additive multi-select)
+ el.addEventListener("mousedown", (e) => {
+ // Ignore if clicking on a handle
+ if (
+ (e.target as HTMLElement).classList.contains("annotation-handle") ||
+ (e.target as HTMLElement).classList.contains("annotation-handle-rotate")
+ ) {
+ return;
+ }
+ e.stopPropagation();
+ selectAnnotation(tracked.def.id, e.shiftKey);
+
+ // Start drag for draggable types (only single-select)
+ if (DRAGGABLE_TYPES.has(tracked.def.type) && !e.shiftKey) {
+ startDrag(e, tracked);
+ }
+ });
+
+ // Double-click to send message to modify annotation (same as sidebar card)
+ el.addEventListener("dblclick", (e) => {
+ e.stopPropagation();
+ selectAnnotation(tracked.def.id);
+ const label = getAnnotationLabel(tracked.def);
+ const previewText = getAnnotationPreview(tracked.def);
+ const desc = previewText ? `${label}: ${previewText}` : label;
+ app.sendMessage({
+ role: "user",
+ content: [{ type: "text", text: `update ${desc}: ` }],
+ });
+ });
+}
+
+function startDrag(e: MouseEvent, tracked: TrackedAnnotation): void {
+ const def = tracked.def;
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const beforeDef = { ...def } as PdfAnnotationDef;
+ let moved = false;
+
+ // Store original element positions
+ const originalPositions = tracked.elements.map((el) => ({
+ left: parseFloat(el.style.left),
+ top: parseFloat(el.style.top),
+ }));
+
+ document.body.style.cursor = "grabbing";
+ for (const el of tracked.elements) {
+ el.classList.add("annotation-dragging");
+ }
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
+ // Move elements directly for smooth feedback
+ for (let i = 0; i < tracked.elements.length; i++) {
+ tracked.elements[i].style.left = `${originalPositions[i].left + dx}px`;
+ tracked.elements[i].style.top = `${originalPositions[i].top + dy}px`;
+ }
+ };
+
+ const onMouseUp = (ev: MouseEvent) => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ document.body.style.cursor = "";
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-dragging");
+ }
+
+ if (!moved) return;
+
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+ const pdfDelta = screenToPdfDelta(dx, dy);
+
+ // Apply move to def
+ applyMoveToDef(
+ tracked.def as PdfAnnotationDef & { x: number; y: number },
+ pdfDelta.dx,
+ pdfDelta.dy,
+ );
+
+ const afterDef = { ...tracked.def } as PdfAnnotationDef;
+ pushEdit({
+ type: "update",
+ id: def.id,
+ before: beforeDef,
+ after: afterDef,
+ });
+ persistAnnotations();
+ // Re-render to get correct positions
+ renderAnnotationsForPage(currentPage);
+ // Re-select to show handles
+ selectAnnotation(def.id);
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+}
+
+function applyMoveToDef(
+ def: PdfAnnotationDef & { x?: number; y?: number },
+ dx: number,
+ dy: number,
+): void {
+ if (def.type === "line") {
+ def.x1 += dx;
+ def.y1 += dy;
+ def.x2 += dx;
+ def.y2 += dy;
+ } else if ("x" in def && "y" in def) {
+ def.x! += dx;
+ def.y! += dy;
+ }
+}
+
+// =============================================================================
+// Resize (rectangle, circle, image)
+// =============================================================================
+
+function setupResizeHandle(
+ handle: HTMLElement,
+ tracked: TrackedAnnotation,
+ corner: "nw" | "ne" | "sw" | "se",
+): void {
+ handle.addEventListener("mousedown", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const def = tracked.def as
+ | RectangleAnnotation
+ | CircleAnnotation
+ | ImageAnnotation;
+ const beforeDef = { ...def };
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const aspectRatio = beforeDef.height / beforeDef.width;
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dxScreen = ev.clientX - startX;
+ const dyScreen = ev.clientY - startY;
+ const pdfD = screenToPdfDelta(dxScreen, dyScreen);
+
+ // Reset to before state then apply delta
+ let newX = beforeDef.x;
+ let newY = beforeDef.y;
+ let newW = beforeDef.width;
+ let newH = beforeDef.height;
+
+ // In PDF coords: x goes right, y goes up
+ if (corner.includes("w")) {
+ newX += pdfD.dx;
+ newW -= pdfD.dx;
+ } else {
+ newW += pdfD.dx;
+ }
+ if (corner.includes("s")) {
+ newY += pdfD.dy;
+ newH -= pdfD.dy;
+ } else {
+ newH += pdfD.dy;
+ }
+
+ // Constrain aspect ratio:
+ // - For images: preserve by default (Shift to ignore), unless aspect="ignore"
+ // - For other shapes: Shift to preserve
+ const isImage = def.type === "image";
+ const imageAspect = isImage
+ ? ((def as ImageAnnotation).aspect ?? "preserve")
+ : undefined;
+ const constrainAspect = isImage
+ ? imageAspect === "preserve"
+ ? !ev.shiftKey // preserve by default, Shift to free-resize
+ : ev.shiftKey // ignore by default, Shift to constrain
+ : ev.shiftKey; // non-image: Shift to constrain
+
+ if (constrainAspect) {
+ // Use the wider dimension to drive the other
+ const candidateH = newW * aspectRatio;
+ newH = candidateH;
+ // Adjust origin for corners that anchor at bottom/left
+ if (corner.includes("s")) {
+ newY = beforeDef.y + beforeDef.height - newH;
+ }
+ if (corner.includes("w")) {
+ // width changed by resize, x was already adjusted above
+ }
+ }
+
+ // Enforce minimum size
+ if (newW < 5) {
+ newW = 5;
+ }
+ if (newH < 5) {
+ newH = 5;
+ }
+
+ def.x = newX;
+ def.y = newY;
+ def.width = newW;
+ def.height = newH;
+
+ // Re-render for live feedback
+ renderAnnotationsForPage(currentPage);
+ selectAnnotation(def.id);
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+
+ const afterDef = { ...def };
+ pushEdit({
+ type: "update",
+ id: def.id,
+ before: beforeDef,
+ after: afterDef,
+ });
+ persistAnnotations();
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+}
+
+// =============================================================================
+// Rotate (stamp, rectangle)
+// =============================================================================
+
+function setupRotateHandle(
+ handle: HTMLElement,
+ tracked: TrackedAnnotation,
+): void {
+ handle.addEventListener("mousedown", (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const def = tracked.def as
+ | StampAnnotation
+ | RectangleAnnotation
+ | ImageAnnotation;
+ const beforeDef = { ...def };
+ const el = tracked.elements[0];
+ const rect = el.getBoundingClientRect();
+ const centerX = rect.left + rect.width / 2;
+ const centerY = rect.top + rect.height / 2;
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const angle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX);
+ // Convert to degrees, offset so 0 = pointing up
+ let degrees = (angle * 180) / Math.PI + 90;
+ // Normalize
+ if (degrees < 0) degrees += 360;
+ if (degrees > 360) degrees -= 360;
+ // Snap to 15-degree increments when close
+ const snapped = Math.round(degrees / 15) * 15;
+ if (Math.abs(degrees - snapped) < 3) degrees = snapped;
+
+ def.rotation = Math.round(degrees);
+ renderAnnotationsForPage(currentPage);
+ selectAnnotation(def.id);
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+
+ const afterDef = { ...def };
+ pushEdit({
+ type: "update",
+ id: def.id,
+ before: beforeDef,
+ after: afterDef,
+ });
+ persistAnnotations();
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+}
+
+/**
+ * Paint annotations for a page onto a 2D canvas context.
+ * Used to include annotations in screenshots sent to the model.
+ */
+function paintAnnotationsOnCanvas(
+ ctx: CanvasRenderingContext2D,
+ pageNum: number,
+ viewport: { width: number; height: number; scale: number },
+): void {
+ for (const tracked of annotationMap.values()) {
+ const def = tracked.def;
+ if (def.page !== pageNum) continue;
+
+ const color = getAnnotationColor(def);
+
+ switch (def.type) {
+ case "highlight":
+ ctx.save();
+ ctx.globalAlpha = 0.35;
+ ctx.fillStyle = def.color || "rgba(255, 255, 0, 1)";
+ for (const rect of def.rects) {
+ const s = pdfRectToScreen(rect, viewport);
+ ctx.fillRect(s.left, s.top, s.width, s.height);
+ }
+ ctx.restore();
+ break;
+
+ case "underline":
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ for (const rect of def.rects) {
+ const s = pdfRectToScreen(rect, viewport);
+ ctx.beginPath();
+ ctx.moveTo(s.left, s.top + s.height);
+ ctx.lineTo(s.left + s.width, s.top + s.height);
+ ctx.stroke();
+ }
+ ctx.restore();
+ break;
+
+ case "strikethrough":
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ for (const rect of def.rects) {
+ const s = pdfRectToScreen(rect, viewport);
+ const midY = s.top + s.height / 2;
+ ctx.beginPath();
+ ctx.moveTo(s.left, midY);
+ ctx.lineTo(s.left + s.width, midY);
+ ctx.stroke();
+ }
+ ctx.restore();
+ break;
+
+ case "note": {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ ctx.save();
+ ctx.fillStyle = color;
+ ctx.globalAlpha = 0.8;
+ ctx.fillRect(pos.left, pos.top - 16, 16, 16);
+ ctx.restore();
+ break;
+ }
+
+ case "rectangle": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ ctx.save();
+ if (def.rotation) {
+ const cx = s.left + s.width / 2;
+ const cy = s.top + s.height / 2;
+ ctx.translate(cx, cy);
+ ctx.rotate((def.rotation * Math.PI) / 180);
+ ctx.translate(-cx, -cy);
+ }
+ if (def.fillColor) {
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = def.fillColor;
+ ctx.fillRect(s.left, s.top, s.width, s.height);
+ }
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.strokeRect(s.left, s.top, s.width, s.height);
+ ctx.restore();
+ break;
+ }
+
+ case "freetext": {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ ctx.save();
+ ctx.fillStyle = color;
+ ctx.font = `${(def.fontSize || 12) * viewport.scale}px Helvetica, Arial, sans-serif`;
+ ctx.fillText(def.content, pos.left, pos.top);
+ ctx.restore();
+ break;
+ }
+
+ case "stamp": {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ ctx.save();
+ ctx.translate(pos.left, pos.top);
+ if (def.rotation) ctx.rotate((def.rotation * Math.PI) / 180);
+ ctx.strokeStyle = color;
+ ctx.fillStyle = color;
+ ctx.lineWidth = 3;
+ ctx.globalAlpha = 0.6;
+ ctx.font = `bold ${24 * viewport.scale}px Helvetica, Arial, sans-serif`;
+ const metrics = ctx.measureText(def.label);
+ const pad = 8 * viewport.scale;
+ ctx.strokeRect(
+ -pad,
+ -24 * viewport.scale - pad,
+ metrics.width + pad * 2,
+ 24 * viewport.scale + pad * 2,
+ );
+ ctx.fillText(def.label, 0, 0);
+ ctx.restore();
+ break;
+ }
+
+ case "circle": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ ctx.save();
+ if (def.fillColor) {
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = def.fillColor;
+ ctx.beginPath();
+ ctx.ellipse(
+ s.left + s.width / 2,
+ s.top + s.height / 2,
+ s.width / 2,
+ s.height / 2,
+ 0,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.ellipse(
+ s.left + s.width / 2,
+ s.top + s.height / 2,
+ s.width / 2,
+ s.height / 2,
+ 0,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.stroke();
+ ctx.restore();
+ break;
+ }
+
+ case "line": {
+ const p1 = pdfPointToScreen(def.x1, def.y1, viewport);
+ const p2 = pdfPointToScreen(def.x2, def.y2, viewport);
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(p1.left, p1.top);
+ ctx.lineTo(p2.left, p2.top);
+ ctx.stroke();
+ ctx.restore();
+ break;
+ }
+
+ case "image": {
+ const s = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ // Try to draw from cache
+ const cachedImg = imageCache.get(def.id);
+ if (cachedImg) {
+ ctx.save();
+ if (def.rotation) {
+ const cx = s.left + s.width / 2;
+ const cy = s.top + s.height / 2;
+ ctx.translate(cx, cy);
+ ctx.rotate((def.rotation * Math.PI) / 180);
+ ctx.translate(-cx, -cy);
+ }
+ ctx.drawImage(cachedImg, s.left, s.top, s.width, s.height);
+ ctx.restore();
+ } else {
+ // Load image asynchronously into cache for next paint
+ const src = def.imageData
+ ? `data:${def.mimeType || "image/png"};base64,${def.imageData}`
+ : def.imageUrl;
+ if (src) {
+ const img = new Image();
+ img.onload = () => {
+ imageCache.set(def.id, img);
+ };
+ img.src = src;
+ }
+ // Draw placeholder border
+ ctx.save();
+ ctx.strokeStyle = "#999";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([4, 4]);
+ ctx.strokeRect(s.left, s.top, s.width, s.height);
+ ctx.restore();
+ }
+ break;
+ }
+ }
+ }
+}
+
+function renderAnnotationsForPage(pageNum: number): void {
+ // Clear existing annotation elements
+ annotationLayerEl.innerHTML = "";
+
+ // Remove tracked element refs for all annotations
+ for (const tracked of annotationMap.values()) {
+ tracked.elements = [];
+ }
+
+ if (!pdfDocument) return;
+
+ // Get viewport for coordinate conversion
+ const vp = {
+ width: parseFloat(annotationLayerEl.style.width) || 0,
+ height: parseFloat(annotationLayerEl.style.height) || 0,
+ scale,
+ };
+ if (vp.width === 0 || vp.height === 0) return;
+
+ for (const tracked of annotationMap.values()) {
+ const def = tracked.def;
+ if (def.page !== pageNum) continue;
+
+ const elements = renderAnnotation(def, vp);
+ tracked.elements = elements;
+ for (const el of elements) {
+ // Set up selection + drag/resize/rotate interactions
+ setupAnnotationInteraction(el, tracked);
+ annotationLayerEl.appendChild(el);
+ }
+ // Restore selection state after re-render
+ if (selectedAnnotationIds.has(def.id)) {
+ for (const el of elements) {
+ el.classList.add("annotation-selected");
+ }
+ if (selectedAnnotationIds.size === 1) {
+ showHandles(tracked);
+ }
+ }
+ }
+
+ // Refresh panel to update current-page highlighting
+ renderAnnotationPanel();
+}
+
+function renderAnnotation(
+ def: PdfAnnotationDef,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement[] {
+ switch (def.type) {
+ case "highlight":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-highlight",
+ viewport,
+ def.color ? { background: def.color } : {},
+ );
+ case "underline":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-underline",
+ viewport,
+ def.color ? { borderBottomColor: def.color } : {},
+ );
+ case "strikethrough":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-strikethrough",
+ viewport,
+ {},
+ def.color,
+ );
+ case "note":
+ return [renderNoteAnnotation(def, viewport)];
+ case "rectangle":
+ return [renderRectangleAnnotation(def, viewport)];
+ case "freetext":
+ return [renderFreetextAnnotation(def, viewport)];
+ case "stamp":
+ return [renderStampAnnotation(def, viewport)];
+ case "circle":
+ return [renderCircleAnnotation(def, viewport)];
+ case "line":
+ return [renderLineAnnotation(def, viewport)];
+ case "image":
+ return [renderImageAnnotation(def, viewport)];
+ }
+}
+
+function renderRectsAnnotation(
+ rects: Rect[],
+ className: string,
+ viewport: { width: number; height: number; scale: number },
+ extraStyles: Record,
+ strikeColor?: string,
+): HTMLElement[] {
+ return rects.map((rect) => {
+ const screen = pdfRectToScreen(rect, viewport);
+ const el = document.createElement("div");
+ el.className = className;
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ for (const [k, v] of Object.entries(extraStyles)) {
+ (el.style as unknown as Record)[k] = v;
+ }
+ if (strikeColor) {
+ // Set color for the ::after pseudo-element via CSS custom property
+ el.style.setProperty("--strike-color", strikeColor);
+ el.querySelector("::after"); // no-op, style via CSS instead
+ // Actually use inline style on a child element for the line
+ const line = document.createElement("div");
+ line.style.position = "absolute";
+ line.style.left = "0";
+ line.style.right = "0";
+ line.style.top = "50%";
+ line.style.borderTop = `2px solid ${strikeColor}`;
+ el.appendChild(line);
+ }
+ return el;
+ });
+}
+
+function renderNoteAnnotation(
+ def: NoteAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-note";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top - 20}px`; // offset up so note icon is at the point
+ if (def.color) el.style.color = def.color;
+
+ const tooltip = document.createElement("div");
+ tooltip.className = "annotation-tooltip";
+ tooltip.textContent = def.content;
+ el.appendChild(tooltip);
+
+ return el;
+}
+
+function renderRectangleAnnotation(
+ def: RectangleAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-rectangle";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.color) el.style.borderColor = def.color;
+ if (def.fillColor) el.style.backgroundColor = def.fillColor;
+ if (def.rotation) {
+ el.style.transform = `rotate(${def.rotation}deg)`;
+ el.style.transformOrigin = "center center";
+ }
+ return el;
+}
+
+function renderFreetextAnnotation(
+ def: FreetextAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-freetext";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ el.style.fontSize = `${(def.fontSize || 12) * viewport.scale}px`;
+ if (def.color) el.style.color = def.color;
+ el.textContent = def.content;
+ return el;
+}
+
+function renderStampAnnotation(
+ def: StampAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-stamp";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ el.style.fontSize = `${24 * viewport.scale}px`;
+ if (def.color) el.style.color = def.color;
+ if (def.rotation) {
+ el.style.transform = `rotate(${def.rotation}deg)`;
+ el.style.transformOrigin = "center center";
+ }
+ el.textContent = def.label;
+ return el;
+}
+
+function renderCircleAnnotation(
+ def: CircleAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-circle";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.color) el.style.borderColor = def.color;
+ if (def.fillColor) el.style.backgroundColor = def.fillColor;
+ return el;
+}
+
+function renderLineAnnotation(
+ def: LineAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const p1 = pdfPointToScreen(def.x1, def.y1, viewport);
+ const p2 = pdfPointToScreen(def.x2, def.y2, viewport);
+ const dx = p2.left - p1.left;
+ const dy = p2.top - p1.top;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angle = Math.atan2(dy, dx);
+
+ const el = document.createElement("div");
+ el.className = "annotation-line";
+ el.style.left = `${p1.left}px`;
+ el.style.top = `${p1.top}px`;
+ el.style.width = `${length}px`;
+ el.style.transform = `rotate(${angle}rad)`;
+ el.style.transformOrigin = "0 0";
+ if (def.color) el.style.borderColor = def.color;
+ return el;
+}
+
+function renderImageAnnotation(
+ def: ImageAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-image";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.rotation) {
+ el.style.transform = `rotate(${def.rotation}deg)`;
+ el.style.transformOrigin = "center center";
+ }
+
+ const imgSrc = def.imageData
+ ? `data:${def.mimeType || "image/png"};base64,${def.imageData}`
+ : def.imageUrl;
+ if (imgSrc) {
+ const img = document.createElement("img");
+ img.src = imgSrc;
+ img.style.width = "100%";
+ img.style.height = "100%";
+ img.style.display = "block";
+ img.style.pointerEvents = "none";
+ img.draggable = false;
+ el.appendChild(img);
+ }
+ return el;
+}
+
+// =============================================================================
+// Annotation CRUD
+// =============================================================================
+
+function addAnnotation(def: PdfAnnotationDef, skipUndo = false): void {
+ // Remove existing if same id (without pushing to undo)
+ removeAnnotation(def.id, true);
+ annotationMap.set(def.id, { def, elements: [] });
+ if (!skipUndo) {
+ pushEdit({ type: "add", id: def.id, before: null, after: { ...def } });
+ }
+ // Re-render if on current page
+ if (def.page === currentPage) {
+ renderAnnotationsForPage(currentPage);
+ }
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+}
+
+function updateAnnotation(
+ update: Partial & { id: string; type: string },
+ skipUndo = false,
+): void {
+ const tracked = annotationMap.get(update.id);
+ if (!tracked) return;
+
+ const before = { ...tracked.def } as PdfAnnotationDef;
+
+ // Merge partial update into existing def
+ const merged = { ...tracked.def, ...update } as PdfAnnotationDef;
+ tracked.def = merged;
+
+ if (!skipUndo) {
+ pushEdit({ type: "update", id: update.id, before, after: { ...merged } });
+ }
+
+ // Re-render if on current page
+ if (merged.page === currentPage) {
+ renderAnnotationsForPage(currentPage);
+ }
+ renderAnnotationPanel();
+}
+
+function removeAnnotation(id: string, skipUndo = false): void {
+ const tracked = annotationMap.get(id);
+ if (!tracked) return;
+ if (!skipUndo) {
+ pushEdit({ type: "remove", id, before: { ...tracked.def }, after: null });
+ }
+ for (const el of tracked.elements) el.remove();
+ annotationMap.delete(id);
+ selectedAnnotationIds.delete(id);
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+}
+
+// =============================================================================
+// Annotation Panel
+// =============================================================================
+
+/** Get inset margins for the floating panel (safe area + padding). */
+function getFloatingPanelInsets(): {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+} {
+ const insets = { top: 4, right: 4, bottom: 4, left: 4 };
+ const ctx = app.getHostContext();
+ if (ctx?.safeAreaInsets) {
+ insets.top += ctx.safeAreaInsets.top;
+ insets.right += ctx.safeAreaInsets.right;
+ insets.bottom += ctx.safeAreaInsets.bottom;
+ insets.left += ctx.safeAreaInsets.left;
+ }
+ return insets;
+}
+
+/** Position the floating panel based on its anchored corner. */
+function applyFloatingPanelPosition(): void {
+ const el = annotationsPanelEl;
+ // Reset all position props
+ el.style.top = "";
+ el.style.bottom = "";
+ el.style.left = "";
+ el.style.right = "";
+
+ const insets = getFloatingPanelInsets();
+
+ // When search bar is visible and panel is anchored top-right, offset below it
+ const searchBarExtra =
+ searchOpen && floatingPanelCorner === "top-right"
+ ? searchBarEl.offsetHeight + 2
+ : 0;
+
+ const isRight = floatingPanelCorner.includes("right");
+ const isBottom = floatingPanelCorner.includes("bottom");
+
+ if (isBottom) {
+ el.style.bottom = `${insets.bottom}px`;
+ } else {
+ el.style.top = `${insets.top + searchBarExtra}px`;
+ }
+ if (isRight) {
+ el.style.right = `${insets.right}px`;
+ } else {
+ el.style.left = `${insets.left}px`;
+ }
+
+ // Update resize handle position based on anchorage
+ updateResizeHandlePosition();
+}
+
+/** Position the resize handle on the correct edge based on panel anchorage. */
+function updateResizeHandlePosition(): void {
+ const resizeHandle = document.getElementById("annotation-panel-resize");
+ if (!resizeHandle) return;
+ const isRight = floatingPanelCorner.includes("right");
+ if (isRight) {
+ // Panel is on the right → resize handle on the left edge
+ resizeHandle.style.left = "-3px";
+ resizeHandle.style.right = "";
+ } else {
+ // Panel is on the left → resize handle on the right edge
+ resizeHandle.style.left = "";
+ resizeHandle.style.right = "-3px";
+ }
+}
+
+/** Auto-dock the floating panel to the opposite side if it overlaps selected annotations. */
+function autoDockPanel(): void {
+ const panelRect = annotationsPanelEl.getBoundingClientRect();
+ let overlaps = false;
+ for (const selId of selectedAnnotationIds) {
+ const tracked = annotationMap.get(selId);
+ if (!tracked) continue;
+ for (const el of tracked.elements) {
+ const elRect = el.getBoundingClientRect();
+ // Check overlap
+ if (
+ panelRect.left < elRect.right &&
+ panelRect.right > elRect.left &&
+ panelRect.top < elRect.bottom &&
+ panelRect.bottom > elRect.top
+ ) {
+ overlaps = true;
+ break;
+ }
+ }
+ if (overlaps) break;
+ }
+ if (overlaps) {
+ // Swap left ↔ right
+ if (floatingPanelCorner.includes("right")) {
+ floatingPanelCorner = floatingPanelCorner.replace(
+ "right",
+ "left",
+ ) as PanelCorner;
+ } else {
+ floatingPanelCorner = floatingPanelCorner.replace(
+ "left",
+ "right",
+ ) as PanelCorner;
+ }
+ applyFloatingPanelPosition();
+ }
+}
+
+function setAnnotationPanelOpen(open: boolean): void {
+ annotationPanelOpen = open;
+ annotationsBtn.classList.toggle("active", open);
+ updateAnnotationsBadge();
+
+ // Always use floating panel (both inline and fullscreen)
+ annotationsPanelEl.classList.toggle("floating", true);
+ annotationsPanelEl.style.display = open ? "" : "none";
+ if (open) {
+ applyFloatingPanelPosition();
+ renderAnnotationPanel();
+ }
+ requestFitToContent();
+}
+
+function toggleAnnotationPanel(): void {
+ annotationPanelUserPref = !annotationPanelOpen;
+ try {
+ localStorage.setItem(
+ "pdf-annotation-panel",
+ annotationPanelUserPref ? "open" : "closed",
+ );
+ } catch {
+ /* ignore */
+ }
+ setAnnotationPanelOpen(annotationPanelUserPref);
+}
+
+/**
+ * Derived state of a form field relative to the PDF baseline.
+ * Not stored — computed on demand by comparing formFieldValues to
+ * pdfBaselineFormValues.
+ */
+type FieldState =
+ | "unchanged" // current === baseline (came from the PDF, untouched)
+ | "modified" // baseline exists but current differs
+ | "cleared" // baseline exists but current is absent/empty
+ | "added"; // no baseline — user-filled or fill_form
+
+function fieldState(name: string): FieldState {
+ const cur = formFieldValues.get(name);
+ const base = pdfBaselineFormValues.get(name);
+ if (base === undefined) return "added";
+ if (cur === undefined || cur === "" || cur === false) return "cleared";
+ return cur === base ? "unchanged" : "modified";
+}
+
+/** All field names that should appear in the panel: current ∪ baseline.
+ * Cleared baseline fields remain visible (crossed out) so they can be
+ * reverted individually. */
+function panelFieldNames(): Set {
+ return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]);
+}
+
+/** Total count of annotations + form fields for the sidebar badge.
+ * Uses the union so cleared baseline items still contribute. */
+function sidebarItemCount(): number {
+ return annotationMap.size + panelFieldNames().size;
+}
+
+function updateAnnotationsBadge(): void {
+ const count = sidebarItemCount();
+ if (count > 0 && !annotationPanelOpen) {
+ annotationsBadgeEl.textContent = String(count);
+ annotationsBadgeEl.style.display = "";
+ } else {
+ annotationsBadgeEl.style.display = "none";
+ }
+ // Show/hide the toolbar button based on whether items exist
+ annotationsBtn.style.display = count > 0 ? "" : "none";
+ // Auto-close panel when all items are gone
+ if (count === 0 && annotationPanelOpen) {
+ setAnnotationPanelOpen(false);
+ }
+}
+
+/** Human-readable label for an annotation type (used in sidebar). */
+function getAnnotationLabel(def: PdfAnnotationDef): string {
+ switch (def.type) {
+ case "highlight":
+ return def.content ? "Highlight" : "Highlight";
+ case "underline":
+ return "Underline";
+ case "strikethrough":
+ return "Strikethrough";
+ case "note":
+ return "Note";
+ case "freetext":
+ return "Text";
+ case "rectangle":
+ return "Rectangle";
+ case "stamp":
+ return `Stamp: ${def.label}`;
+ case "circle":
+ return "Circle";
+ case "line":
+ return "Line";
+ case "image":
+ return "Image";
+ }
+}
+
+/** Preview text for an annotation (shown after the label). */
+function getAnnotationPreview(def: PdfAnnotationDef): string {
+ switch (def.type) {
+ case "note":
+ case "freetext":
+ return def.content || "";
+ case "highlight":
+ return def.content || "";
+ case "stamp":
+ return "";
+ case "image":
+ return "";
+ default:
+ return "";
+ }
+}
+
+function getAnnotationColor(def: PdfAnnotationDef): string {
+ if ("color" in def && def.color) return def.color;
+ switch (def.type) {
+ case "highlight":
+ return "rgba(255, 255, 0, 0.7)";
+ case "underline":
+ return "#ff0000";
+ case "strikethrough":
+ return "#ff0000";
+ case "note":
+ return "#f5a623";
+ case "rectangle":
+ return "#0066cc";
+ case "freetext":
+ return "#333";
+ case "stamp":
+ return "#cc0000";
+ case "circle":
+ return "#0066cc";
+ case "line":
+ return "#333";
+ case "image":
+ return "#999";
+ }
+}
+
+/** Return a human-readable label for a form field name. */
+function getFormFieldLabel(name: string): string {
+ // Prefer the PDF's TU (alternativeText) if available
+ const alt = fieldNameToLabel.get(name);
+ if (alt) return alt;
+ // If the name looks mechanical (contains brackets, dots, or is all-caps with underscores),
+ // just show "Field" as a generic fallback
+ if (/[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name)) {
+ return "Field";
+ }
+ return name;
+}
+
+function getAnnotationY(def: PdfAnnotationDef): number {
+ if ("y" in def && typeof def.y === "number") return def.y;
+ if ("rects" in def && def.rects.length > 0) return def.rects[0].y;
+ return 0;
+}
+
+/** Track which accordion section is open (e.g. "page-3" or "formFields"). null = all collapsed. */
+let openAccordionSection: string | null = null;
+/** Whether the user has ever interacted with accordion sections (prevents auto-open after explicit collapse). */
+let accordionUserInteracted = false;
+
+/** Which corner the floating panel is anchored to. */
+type PanelCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left";
+let floatingPanelCorner: PanelCorner = "top-right";
+
+function renderAnnotationPanel(): void {
+ if (!annotationPanelOpen) return;
+
+ annotationsPanelCountEl.textContent = String(sidebarItemCount());
+ annotationsPanelResetBtn.disabled = !isDirty;
+ annotationsPanelClearAllBtn.disabled = sidebarItemCount() === 0;
+
+ // Group annotations by page, sorted by Y position within each page
+ const byPage = new Map();
+ for (const tracked of annotationMap.values()) {
+ const page = tracked.def.page;
+ if (!byPage.has(page)) byPage.set(page, []);
+ byPage.get(page)!.push(tracked);
+ }
+
+ // Group form fields by page — iterate the UNION so cleared baseline
+ // fields remain visible (crossed out) with a per-item revert button.
+ const fieldsByPage = new Map();
+ for (const name of panelFieldNames()) {
+ const page = fieldNameToPage.get(name) ?? 1;
+ if (!fieldsByPage.has(page)) fieldsByPage.set(page, []);
+ fieldsByPage.get(page)!.push(name);
+ }
+ // Sort fields by their intrinsic document order within each page
+ for (const names of fieldsByPage.values()) {
+ names.sort(
+ (a, b) => (fieldNameToOrder.get(a) ?? 0) - (fieldNameToOrder.get(b) ?? 0),
+ );
+ }
+
+ // Collect all pages that have annotations or form fields
+ const allPages = new Set([...byPage.keys(), ...fieldsByPage.keys()]);
+ const sortedPages = [...allPages].sort((a, b) => a - b);
+
+ // Sort annotations within each page by Y position (descending = top-first in PDF coords)
+ for (const annotations of byPage.values()) {
+ annotations.sort((a, b) => getAnnotationY(b.def) - getAnnotationY(a.def));
+ }
+
+ annotationsPanelListEl.innerHTML = "";
+
+ // Auto-open section for current page only on first render (before user interaction)
+ if (openAccordionSection === null && !accordionUserInteracted) {
+ if (allPages.has(currentPage)) {
+ openAccordionSection = `page-${currentPage}`;
+ } else if (sortedPages.length > 0) {
+ openAccordionSection = `page-${sortedPages[0]}`;
+ }
+ }
+
+ for (const pageNum of sortedPages) {
+ const sectionKey = `page-${pageNum}`;
+ const isOpen = openAccordionSection === sectionKey;
+ const annotations = byPage.get(pageNum) ?? [];
+ const fields = fieldsByPage.get(pageNum) ?? [];
+ const itemCount = annotations.length + fields.length;
+
+ appendAccordionSection(
+ `Page ${pageNum} (${itemCount})`,
+ sectionKey,
+ isOpen,
+ pageNum === currentPage,
+ (body) => {
+ // Form fields first
+ for (const name of fields) {
+ body.appendChild(createFormFieldCard(name));
+ }
+ // Then annotations
+ for (const tracked of annotations) {
+ body.appendChild(createAnnotationCard(tracked));
+ }
+ },
+ );
+ }
+}
+
+function appendAccordionSection(
+ title: string,
+ sectionKey: string,
+ isOpen: boolean,
+ isCurrent: boolean,
+ populateBody: (body: HTMLElement) => void,
+): void {
+ const header = document.createElement("div");
+ header.className =
+ "annotation-section-header" +
+ (isCurrent ? " current-page" : "") +
+ (isOpen ? " open" : "");
+
+ const titleSpan = document.createElement("span");
+ titleSpan.textContent = title;
+ header.appendChild(titleSpan);
+
+ const chevron = document.createElement("span");
+ chevron.className = "annotation-section-chevron";
+ chevron.textContent = isOpen ? "▼" : "▶";
+ header.appendChild(chevron);
+
+ header.addEventListener("click", () => {
+ accordionUserInteracted = true;
+ const opening = openAccordionSection !== sectionKey;
+ openAccordionSection = opening ? sectionKey : null;
+ renderAnnotationPanel();
+ // Navigate to the page when expanding a page section
+ if (opening) {
+ const pageMatch = sectionKey.match(/^page-(\d+)$/);
+ if (pageMatch) {
+ goToPage(Number(pageMatch[1]));
+ }
+ }
+ });
+
+ annotationsPanelListEl.appendChild(header);
+
+ const body = document.createElement("div");
+ body.className = "annotation-section-body" + (isOpen ? " open" : "");
+ if (isOpen) {
+ populateBody(body);
+ }
+ annotationsPanelListEl.appendChild(body);
+}
+
+function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement {
+ const def = tracked.def;
+ const card = document.createElement("div");
+ card.className =
+ "annotation-card" + (selectedAnnotationIds.has(def.id) ? " selected" : "");
+ card.dataset.annotationId = def.id;
+
+ const row = document.createElement("div");
+ row.className = "annotation-card-row";
+
+ // Color swatch
+ const swatch = document.createElement("div");
+ swatch.className = "annotation-card-swatch";
+ swatch.style.background = getAnnotationColor(def);
+ row.appendChild(swatch);
+
+ // Type label
+ const typeLabel = document.createElement("span");
+ typeLabel.className = "annotation-card-type";
+ typeLabel.textContent = getAnnotationLabel(def);
+ row.appendChild(typeLabel);
+
+ // Preview text
+ const preview = getAnnotationPreview(def);
+ if (preview) {
+ const previewEl = document.createElement("span");
+ previewEl.className = "annotation-card-preview";
+ previewEl.textContent = preview;
+ row.appendChild(previewEl);
+ }
+
+ // Delete button
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "annotation-card-delete";
+ deleteBtn.title = "Delete annotation";
+ deleteBtn.innerHTML = ``;
+ deleteBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ removeAnnotation(def.id);
+ persistAnnotations();
+ });
+ row.appendChild(deleteBtn);
+
+ // Expand chevron (only for annotations with content)
+ const hasContent = "content" in def && def.content;
+ if (hasContent) {
+ const expand = document.createElement("span");
+ expand.className = "annotation-card-expand";
+ expand.textContent = "▼";
+ row.appendChild(expand);
+ }
+
+ card.appendChild(row);
+
+ // Expandable content area
+ if (hasContent) {
+ const contentEl = document.createElement("div");
+ contentEl.className = "annotation-card-content";
+ contentEl.textContent = (def as { content: string }).content;
+ card.appendChild(contentEl);
+ }
+
+ // Click handler: select + expand/collapse + navigate to page + pulse annotation
+ card.addEventListener("click", () => {
+ if (hasContent) {
+ card.classList.toggle("expanded");
+ }
+ if (def.page !== currentPage) {
+ goToPage(def.page);
+ setTimeout(() => {
+ selectAnnotation(def.id);
+ pulseAnnotation(def.id);
+ }, 300);
+ } else {
+ selectAnnotation(def.id);
+ pulseAnnotation(def.id);
+ if (tracked.elements.length > 0) {
+ tracked.elements[0].scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+ }
+ });
+
+ // Hover handler: pulse annotation on PDF
+ card.addEventListener("mouseenter", () => {
+ if (def.page === currentPage) {
+ pulseAnnotation(def.id);
+ }
+ });
+
+ // Double-click handler: send message to modify annotation
+ card.addEventListener("dblclick", (e) => {
+ e.stopPropagation();
+ // Select this annotation + update model context before sending message
+ selectAnnotation(def.id);
+ const label = getAnnotationLabel(def);
+ const previewText = getAnnotationPreview(def);
+ const desc = previewText ? `${label}: ${previewText}` : label;
+ app.sendMessage({
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: `update ${desc}: `,
+ },
+ ],
+ });
+ });
+
+ return card;
+}
+
+const TRASH_SVG = ``;
+const REVERT_SVG = ``;
+
+/** Revert one field to its PDF-stored baseline value. */
+function revertFieldToBaseline(name: string): void {
+ const base = pdfBaselineFormValues.get(name);
+ if (base === undefined) return;
+ formFieldValues.set(name, base);
+ // Remove our storage override → widget falls back to PDF's /V = baseline
+ if (pdfDocument) {
+ const ids = fieldNameToIds.get(name);
+ if (ids) for (const id of ids) pdfDocument.annotationStorage.remove(id);
+ }
+}
+
+function createFormFieldCard(name: string): HTMLElement {
+ const state = fieldState(name);
+ const value = formFieldValues.get(name);
+ const baseValue = pdfBaselineFormValues.get(name);
+
+ const card = document.createElement("div");
+ card.className = "annotation-card";
+ if (state === "cleared") card.classList.add("annotation-card-cleared");
+
+ const row = document.createElement("div");
+ row.className = "annotation-card-row";
+
+ // Swatch: solid blue normally; crossed-out for cleared baseline fields
+ const swatch = document.createElement("div");
+ swatch.className = "annotation-card-swatch";
+ if (state === "cleared") {
+ swatch.classList.add("annotation-card-swatch-cleared");
+ swatch.innerHTML = ``;
+ } else {
+ swatch.style.background = "#4a90d9";
+ }
+ // Subtle modified marker
+ if (state === "modified") swatch.title = "Modified from file";
+ row.appendChild(swatch);
+
+ // Field label
+ const nameEl = document.createElement("span");
+ nameEl.className = "annotation-card-type";
+ nameEl.textContent = getFormFieldLabel(name);
+ row.appendChild(nameEl);
+
+ // Value preview: show current, or struck-out baseline when cleared
+ const shown = state === "cleared" ? baseValue : value;
+ const displayValue =
+ typeof shown === "boolean" ? (shown ? "checked" : "unchecked") : shown;
+ if (displayValue) {
+ const valueEl = document.createElement("span");
+ valueEl.className = "annotation-card-preview";
+ valueEl.textContent = displayValue;
+ row.appendChild(valueEl);
+ }
+
+ // Action button: revert for modified/cleared baseline fields, trash otherwise
+ const isRevertable = state === "modified" || state === "cleared";
+ const actionBtn = document.createElement("button");
+ actionBtn.className = "annotation-card-delete";
+ actionBtn.title = isRevertable
+ ? "Revert to value stored in file"
+ : "Clear field";
+ actionBtn.innerHTML = isRevertable ? REVERT_SVG : TRASH_SVG;
+ actionBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ if (isRevertable) {
+ revertFieldToBaseline(name);
+ } else {
+ formFieldValues.delete(name);
+ clearFieldInStorage(name);
+ }
+ updateAnnotationsBadge();
+ renderAnnotationPanel();
+ renderPage();
+ persistAnnotations();
+ });
+ row.appendChild(actionBtn);
+
+ // Click handler: navigate to page and focus form input
+ card.addEventListener("click", () => {
+ const fieldPage = fieldNameToPage.get(name) ?? 1;
+ // Auto-expand the page's accordion section
+ openAccordionSection = `page-${fieldPage}`;
+ const focusField = () => {
+ const input = formLayerEl.querySelector(
+ `[name="${CSS.escape(name)}"]`,
+ ) as HTMLElement | null;
+ if (input) {
+ input.focus();
+ input.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ };
+ if (fieldPage !== currentPage) {
+ goToPage(fieldPage);
+ setTimeout(focusField, 300);
+ } else {
+ focusField();
+ }
+ });
+
+ // Double-click handler: send message to fill field
+ card.addEventListener("dblclick", (e) => {
+ e.stopPropagation();
+ // Focus field + update model context before sending message
+ focusedFieldName = name;
+ updatePageContext();
+ const fieldLabel = getFormFieldLabel(name);
+ app.sendMessage({
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: `update ${fieldLabel}: `,
+ },
+ ],
+ });
+ });
+
+ card.appendChild(row);
+ return card;
+}
+
+function pulseAnnotation(id: string): void {
+ const tracked = annotationMap.get(id);
+ if (!tracked) return;
+ for (const el of tracked.elements) {
+ el.classList.remove("annotation-pulse");
+ // Force reflow to restart animation
+ void el.offsetWidth;
+ el.classList.add("annotation-pulse");
+ el.addEventListener(
+ "animationend",
+ () => {
+ el.classList.remove("annotation-pulse");
+ },
+ { once: true },
+ );
+ }
+}
+
+function initAnnotationPanel(): void {
+ // Restore user preference
+ try {
+ const pref = localStorage.getItem("pdf-annotation-panel");
+ if (pref === "open") annotationPanelUserPref = true;
+ else if (pref === "closed") annotationPanelUserPref = false;
+ } catch {
+ /* ignore */
+ }
+
+ // Restore saved panel width
+ try {
+ const savedWidth = localStorage.getItem("pdf-annotation-panel-width");
+ if (savedWidth) {
+ const w = parseInt(savedWidth, 10);
+ if (w >= 120) {
+ annotationsPanelEl.style.width = `${w}px`;
+ }
+ }
+ } catch {
+ /* ignore */
+ }
+
+ // Resize handle — direction-aware based on anchorage
+ const resizeHandle = document.getElementById("annotation-panel-resize")!;
+ resizeHandle.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ resizeHandle.classList.add("dragging");
+ const startX = e.clientX;
+ const startWidth = annotationsPanelEl.offsetWidth;
+ const isRight = floatingPanelCorner.includes("right");
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startX;
+ // If panel is on the right, dragging left (negative dx) increases width
+ // If panel is on the left, dragging right (positive dx) increases width
+ const newWidth = Math.max(120, startWidth + (isRight ? -dx : dx));
+ annotationsPanelEl.style.width = `${newWidth}px`;
+ };
+ const onMouseUp = () => {
+ resizeHandle.classList.remove("dragging");
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ try {
+ localStorage.setItem(
+ "pdf-annotation-panel-width",
+ String(annotationsPanelEl.offsetWidth),
+ );
+ } catch {
+ /* ignore */
+ }
+ requestFitToContent();
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+
+ // Floating panel drag-to-reposition
+ const panelHeader = annotationsPanelEl.querySelector(
+ ".annotation-panel-header",
+ ) as HTMLElement;
+ if (panelHeader) {
+ panelHeader.addEventListener("mousedown", (e) => {
+ if (!annotationsPanelEl.classList.contains("floating")) return;
+ // Ignore clicks on buttons within header
+ if ((e.target as HTMLElement).closest("button")) return;
+ e.preventDefault();
+ const startX = e.clientX;
+ const startY = e.clientY;
+ const container = annotationsPanelEl.parentElement!;
+ const containerRect = container.getBoundingClientRect();
+ let moved = false;
+
+ // Temporarily position absolutely during drag
+ const panelRect = annotationsPanelEl.getBoundingClientRect();
+ let curLeft = panelRect.left - containerRect.left;
+ let curTop = panelRect.top - containerRect.top;
+
+ // Switch to left/top positioning for free drag
+ annotationsPanelEl.style.right = "";
+ annotationsPanelEl.style.bottom = "";
+ annotationsPanelEl.style.left = `${curLeft}px`;
+ annotationsPanelEl.style.top = `${curTop}px`;
+ annotationsPanelEl.style.transition = "none";
+ annotationsPanelEl.classList.add("dragging");
+
+ const onMouseMove = (ev: MouseEvent) => {
+ const dx = ev.clientX - startX;
+ const dy = ev.clientY - startY;
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
+ const newLeft = Math.max(
+ 0,
+ Math.min(
+ curLeft + dx,
+ containerRect.width - annotationsPanelEl.offsetWidth,
+ ),
+ );
+ const newTop = Math.max(
+ 0,
+ Math.min(
+ curTop + dy,
+ containerRect.height - annotationsPanelEl.offsetHeight,
+ ),
+ );
+ annotationsPanelEl.style.left = `${newLeft}px`;
+ annotationsPanelEl.style.top = `${newTop}px`;
+ };
+
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ annotationsPanelEl.classList.remove("dragging");
+ annotationsPanelEl.style.transition = "";
+
+ if (!moved) return;
+
+ // Snap to nearest corner (magnetic anchor)
+ const finalRect = annotationsPanelEl.getBoundingClientRect();
+ const cx = finalRect.left + finalRect.width / 2 - containerRect.left;
+ const cy = finalRect.top + finalRect.height / 2 - containerRect.top;
+ const midX = containerRect.width / 2;
+ const midY = containerRect.height / 2;
+
+ const isRight = cx > midX;
+ const isBottom = cy > midY;
+ floatingPanelCorner = isBottom
+ ? isRight
+ ? "bottom-right"
+ : "bottom-left"
+ : isRight
+ ? "top-right"
+ : "top-left";
+
+ applyFloatingPanelPosition();
+ try {
+ localStorage.setItem("pdf-panel-corner", floatingPanelCorner);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ });
+ }
+
+ // Restore saved corner
+ try {
+ const saved = localStorage.getItem("pdf-panel-corner");
+ if (
+ saved &&
+ ["top-right", "top-left", "bottom-right", "bottom-left"].includes(saved)
+ ) {
+ floatingPanelCorner = saved as PanelCorner;
+ }
+ } catch {
+ /* ignore */
+ }
+
+ // Toggle button
+ annotationsBtn.addEventListener("click", toggleAnnotationPanel);
+ annotationsPanelCloseBtn.addEventListener("click", toggleAnnotationPanel);
+ annotationsPanelResetBtn.addEventListener("click", resetToBaseline);
+ annotationsPanelClearAllBtn.addEventListener("click", clearAllItems);
+
+ updateAnnotationsBadge();
+}
+
+/** Remove the DOM elements backing every annotation and clear the map. */
+function clearAnnotationMap(): void {
+ for (const [, tracked] of annotationMap) {
+ for (const el of tracked.elements) el.remove();
+ }
+ annotationMap.clear();
+}
+
+/**
+ * Push a field's defaultValue (/DV) into annotationStorage so the widget
+ * renders cleared. annotationStorage.remove() only drops our override —
+ * the widget reverts to the PDF's /V (the stored value), not /DV.
+ *
+ * Widget IDs come from page.getAnnotations(); field metadata (types,
+ * defaultValue) comes from getFieldObjects(). We match them by field name.
+ */
+function clearFieldInStorage(name: string): void {
+ if (!pdfDocument) return;
+ const ids = fieldNameToIds.get(name);
+ if (!ids) return;
+ const storage = pdfDocument.annotationStorage;
+ const meta = cachedFieldObjects?.[name];
+ // defaultValue is per-field, not per-widget — take from first non-parent entry
+ const dv =
+ meta?.find((f) => f.defaultValue != null)?.defaultValue ??
+ meta?.[0]?.defaultValue ??
+ "";
+ const type = meta?.find((f) => f.type)?.type;
+ const clearValue =
+ type === "checkbox" || type === "radiobutton" ? (dv ?? "Off") : (dv ?? "");
+ for (const id of ids) storage.setValue(id, { value: clearValue });
+}
+
+/**
+ * Revert to what's in the PDF file: restore baseline annotations, restore
+ * baseline form values, discard all user edits. Result: diff is empty, clean.
+ *
+ * Form fields: remove ALL storage overrides — every field reverts to the
+ * PDF's /V (which IS baseline). We can't skip baseline-named fields: if the
+ * user edited one, our override is in storage under that name, and skipping
+ * it leaves the widget showing the stale edit.
+ */
+function resetToBaseline(): void {
+ clearAnnotationMap();
+ for (const def of pdfBaselineAnnotations) {
+ annotationMap.set(def.id, { def: { ...def }, elements: [] });
+ }
+
+ if (pdfDocument) {
+ const storage = pdfDocument.annotationStorage;
+ for (const name of new Set([
+ ...formFieldValues.keys(),
+ ...pdfBaselineFormValues.keys(),
+ ])) {
+ const ids = fieldNameToIds.get(name);
+ if (ids) for (const id of ids) storage.remove(id);
+ }
+ }
+ formFieldValues.clear();
+ for (const [name, value] of pdfBaselineFormValues) {
+ formFieldValues.set(name, value);
+ }
+
+ undoStack.length = 0;
+ redoStack.length = 0;
+ selectedAnnotationIds.clear();
+
+ updateAnnotationsBadge();
+ persistAnnotations(); // diff is now empty → setDirty(false)
+ renderPage();
+ renderAnnotationPanel();
+}
+
+/**
+ * Remove everything, including annotations and form values that came from
+ * the PDF file. Result: diff is non-empty (baseline items are "removed"),
+ * dirty — saving writes a stripped PDF.
+ *
+ * Form fields: annotationStorage.remove() only drops our override, so the
+ * widget reverts to the PDF's stored /V. To actually CLEAR we must push
+ * each field's defaultValue (/DV) — which is what the PDF's own Reset
+ * button would do.
+ *
+ * Note: baseline annotations are still baked into the canvas appearance
+ * stream — we can only remove them from our overlay and the panel. Saving
+ * will omit them from the output (getAnnotatedPdfBytes skips baseline).
+ */
+function clearAllItems(): void {
+ clearAnnotationMap();
+
+ for (const name of new Set([
+ ...formFieldValues.keys(),
+ ...pdfBaselineFormValues.keys(),
+ ])) {
+ clearFieldInStorage(name);
+ }
+ formFieldValues.clear();
+
+ undoStack.length = 0;
+ redoStack.length = 0;
+ selectedAnnotationIds.clear();
+
+ updateAnnotationsBadge();
+ persistAnnotations();
+ renderPage();
+ renderAnnotationPanel();
+}
+
+// =============================================================================
+// highlight_text Command
+// =============================================================================
+
+function handleHighlightText(cmd: {
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+}): void {
+ const pagesToSearch: number[] = [];
+ if (cmd.page) {
+ pagesToSearch.push(cmd.page);
+ } else {
+ // Search all pages that have cached text
+ for (const [pageNum, text] of pageTextCache) {
+ if (text.toLowerCase().includes(cmd.query.toLowerCase())) {
+ pagesToSearch.push(pageNum);
+ }
+ }
+ }
+
+ let annotationIndex = 0;
+ for (const pageNum of pagesToSearch) {
+ // Find text positions using the text layer DOM if on current page,
+ // otherwise create approximate rects from text cache positions
+ const rects = findTextRects(cmd.query, pageNum);
+ if (rects.length > 0) {
+ const id =
+ pagesToSearch.length > 1
+ ? `${cmd.id}_p${pageNum}_${annotationIndex++}`
+ : cmd.id;
+ addAnnotation({
+ type: "highlight",
+ id,
+ page: pageNum,
+ rects,
+ color: cmd.color,
+ content: cmd.content,
+ });
+ }
+ }
+}
+
+/**
+ * Find text in a page and return PDF-coordinate rects.
+ * Uses the TextLayer DOM when the page is currently rendered,
+ * otherwise falls back to approximate character-based positioning.
+ */
+function findTextRects(query: string, pageNum: number): Rect[] {
+ if (pageNum !== currentPage) {
+ // For non-current pages, create approximate rects from page dimensions
+ // The text will be properly positioned when the user navigates to that page
+ return findTextRectsFromCache(query, pageNum);
+ }
+
+ // Use text layer DOM for current page
+ const spans = Array.from(
+ textLayerEl.querySelectorAll("span"),
+ ) as HTMLElement[];
+ if (spans.length === 0) return findTextRectsFromCache(query, pageNum);
+
+ const lowerQuery = query.toLowerCase();
+ const rects: Rect[] = [];
+ const wrapperEl = textLayerEl.parentElement!;
+ const wrapperRect = wrapperEl.getBoundingClientRect();
+
+ for (const span of spans) {
+ const text = span.textContent || "";
+ if (text.length === 0) continue;
+ const lowerText = text.toLowerCase();
+
+ let pos = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, pos);
+ if (idx === -1) break;
+ pos = idx + 1;
+
+ const textNode = span.firstChild;
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
+
+ try {
+ const range = document.createRange();
+ range.setStart(textNode, idx);
+ range.setEnd(textNode, Math.min(idx + lowerQuery.length, text.length));
+ const clientRects = range.getClientRects();
+
+ for (let ri = 0; ri < clientRects.length; ri++) {
+ const r = clientRects[ri];
+ // Convert screen coords back to PDF coords
+ const screenLeft = r.left - wrapperRect.left;
+ const screenTop = r.top - wrapperRect.top;
+ const pdfX = screenLeft / scale;
+ const pdfHeight = r.height / scale;
+ const pdfWidth = r.width / scale;
+ const pageHeight = parseFloat(annotationLayerEl.style.height) / scale;
+ const pdfY = pageHeight - (screenTop + r.height) / scale;
+ rects.push({
+ x: pdfX,
+ y: pdfY,
+ width: pdfWidth,
+ height: pdfHeight,
+ });
+ }
+ } catch {
+ // Range API errors with stale nodes
+ }
+ }
+ }
+
+ return rects;
+}
+
+function findTextRectsFromCache(query: string, pageNum: number): Rect[] {
+ const text = pageTextCache.get(pageNum);
+ if (!text) return [];
+ const lowerText = text.toLowerCase();
+ const lowerQuery = query.toLowerCase();
+ const idx = lowerText.indexOf(lowerQuery);
+ if (idx === -1) return [];
+
+ // Approximate: place a highlight rect in the middle of the page
+ // This will be re-computed accurately when the user visits the page
+ return [{ x: 72, y: 400, width: 200, height: 14 }];
+}
+
+// =============================================================================
+// get_pages — Offscreen rendering for model analysis
+// =============================================================================
+
+const MAX_GET_PAGES = 20;
+const SCREENSHOT_MAX_DIM = 768; // Max pixel dimension for screenshots
+
+/**
+ * Expand intervals into a sorted deduplicated list of page numbers,
+ * clamped to [1, totalPages].
+ */
+function expandIntervals(
+ intervals: Array<{ start?: number; end?: number }>,
+): number[] {
+ const pages = new Set();
+ for (const iv of intervals) {
+ const s = Math.max(1, iv.start ?? 1);
+ const e = Math.min(totalPages, iv.end ?? totalPages);
+ for (let p = s; p <= e; p++) pages.add(p);
+ }
+ return [...pages].sort((a, b) => a - b);
+}
+
+/**
+ * Render a single page to an offscreen canvas and return base64 JPEG.
+ * Does not affect the visible canvas or text layer.
+ */
+async function renderPageOffscreen(pageNum: number): Promise {
+ if (!pdfDocument) throw new Error("No PDF loaded");
+ const page = await pdfDocument.getPage(pageNum);
+ const baseViewport = page.getViewport({ scale: 1.0 });
+
+ // Scale down to fit within SCREENSHOT_MAX_DIM
+ const maxDim = Math.max(baseViewport.width, baseViewport.height);
+ const renderScale =
+ maxDim > SCREENSHOT_MAX_DIM ? SCREENSHOT_MAX_DIM / maxDim : 1.0;
+ const viewport = page.getViewport({ scale: renderScale });
+
+ const canvas = document.createElement("canvas");
+ const dpr = 1; // No retina scaling for model screenshots
+ canvas.width = viewport.width * dpr;
+ canvas.height = viewport.height * dpr;
+ const ctx = canvas.getContext("2d")!;
+ ctx.scale(dpr, dpr);
+
+ // Render with ENABLE_STORAGE so filled form fields appear on the canvas
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (page.render as any)({
+ canvasContext: ctx,
+ viewport,
+ annotationMode: AnnotationMode.ENABLE_STORAGE,
+ annotationStorage: pdfDocument.annotationStorage,
+ }).promise;
+
+ // Paint annotations on top so the model can see them
+ paintAnnotationsOnCanvas(ctx, pageNum, {
+ width: viewport.width,
+ height: viewport.height,
+ scale: renderScale,
+ });
+
+ // Extract base64 JPEG (much smaller than PNG, well within body limits)
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
+ return dataUrl.split(",")[1];
+}
+
+async function handleGetPages(cmd: {
+ requestId: string;
+ intervals: Array<{ start?: number; end?: number }>;
+ getText: boolean;
+ getScreenshots: boolean;
+}): Promise {
+ const allPages = expandIntervals(cmd.intervals);
+ const pages = allPages.slice(0, MAX_GET_PAGES);
+
+ log.info(
+ `get_pages: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${cmd.getText}, screenshots=${cmd.getScreenshots}`,
+ );
+
+ const results: Array<{
+ page: number;
+ text?: string;
+ image?: string;
+ }> = [];
+
+ for (const pageNum of pages) {
+ const entry: { page: number; text?: string; image?: string } = {
+ page: pageNum,
+ };
+
+ if (cmd.getText) {
+ // Use cached text if available, otherwise extract on the fly
+ let text = pageTextCache.get(pageNum);
+ if (text == null && pdfDocument) {
+ try {
+ const pg = await pdfDocument.getPage(pageNum);
+ const tc = await pg.getTextContent();
+ text = (tc.items as Array<{ str?: string }>)
+ .map((item) => item.str || "")
+ .join(" ");
+ pageTextCache.set(pageNum, text);
+ } catch (err) {
+ log.error(
+ `get_pages: text extraction failed for page ${pageNum}:`,
+ err,
+ );
+ text = "";
+ }
+ }
+ entry.text = text ?? "";
+ }
+
+ if (cmd.getScreenshots) {
+ try {
+ entry.image = await renderPageOffscreen(pageNum);
+ } catch (err) {
+ log.error(`get_pages: screenshot failed for page ${pageNum}:`, err);
+ }
+ }
+
+ results.push(entry);
+ }
+
+ // Submit results back to server
+ try {
+ await app.callServerTool({
+ name: "submit_page_data",
+ arguments: { requestId: cmd.requestId, pages: results },
+ });
+ log.info(
+ `get_pages: submitted ${results.length} page(s) for ${cmd.requestId}`,
+ );
+ } catch (err) {
+ log.error("get_pages: failed to submit results:", err);
+ }
+}
+
+// =============================================================================
+// Annotation Persistence
+// =============================================================================
+
+/** Storage key for annotations — uses toolInfo.id (available early) with viewUUID fallback */
+function annotationStorageKey(): string | null {
+ const toolId = app.getHostContext()?.toolInfo?.id;
+ if (toolId) return `pdf-annot:${toolId}`;
+ if (viewUUID) return `${viewUUID}:annotations`;
+ return null;
+}
+
+/**
+ * Import annotations from the loaded PDF to establish the baseline.
+ * These are the annotations that exist in the PDF file itself.
+ */
+async function loadBaselineAnnotations(
+ doc: pdfjsLib.PDFDocumentProxy,
+): Promise {
+ pdfBaselineAnnotations = [];
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
+ 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: [] });
+ }
+ }
+ }
+ } catch {
+ // Skip pages that fail to load annotations
+ }
+ }
+ log.info(
+ `Loaded ${pdfBaselineAnnotations.length} baseline annotations from PDF`,
+ );
+}
+
+function persistAnnotations(): void {
+ // Compute diff relative to PDF baseline
+ const currentAnnotations: PdfAnnotationDef[] = [];
+ for (const tracked of annotationMap.values()) {
+ currentAnnotations.push(tracked.def);
+ }
+ const diff = computeDiff(
+ pdfBaselineAnnotations,
+ currentAnnotations,
+ formFieldValues,
+ pdfBaselineFormValues,
+ );
+
+ // 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));
+
+ const key = annotationStorageKey();
+ if (!key) return;
+ try {
+ localStorage.setItem(key, serializeDiff(diff));
+ } catch {
+ // localStorage may be full or unavailable
+ }
+}
+
+function restoreAnnotations(): void {
+ const key = annotationStorageKey();
+ if (!key) return;
+ isRestoring = true;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return;
+
+ // Try new diff-based format first
+ const diff = deserializeDiff(raw);
+
+ // Merge baseline + diff
+ const merged = mergeAnnotations(pdfBaselineAnnotations, diff);
+ for (const def of merged) {
+ if (!annotationMap.has(def.id)) {
+ annotationMap.set(def.id, { def, elements: [] });
+ }
+ }
+
+ // Restore form fields
+ for (const [k, v] of Object.entries(diff.formFields)) {
+ formFieldValues.set(k, v);
+ }
+
+ // If we have user changes (diff is not empty), mark dirty
+ if (
+ diff.added.length > 0 ||
+ diff.removed.length > 0 ||
+ Object.keys(diff.formFields).length > 0
+ ) {
+ setDirty(true);
+ }
+ log.info(
+ `Restored ${annotationMap.size} annotations (${diff.added.length} added, ${diff.removed.length} removed), ${formFieldValues.size} form fields`,
+ );
+ } catch {
+ // Parse error or unavailable
+ } finally {
+ isRestoring = false;
+ }
+}
+
+// =============================================================================
+// PDF.js Form Field Name → ID Mapping
+// =============================================================================
+
+/**
+ * Normalise a raw form field value into our string|boolean model.
+ * Returns null for empty/unfilled/button values so they don't clutter the
+ * panel or count as baseline.
+ *
+ * `type` is from getFieldObjects() (which knows field types); `raw` is
+ * preferably from page.getAnnotations().fieldValue (which is what the
+ * widget actually renders). A PDF can have the field-dict /V out of sync
+ * with the widget — AnnotationLayer trusts the widget, so we must too.
+ */
+function normaliseFieldValue(
+ type: string | undefined,
+ raw: unknown,
+): string | boolean | null {
+ if (type === "button") return null;
+ // Checkbox/radio: fieldValue is the export string (e.g. "Yes"), "Off" = unset
+ if (type === "checkbox") {
+ return raw != null && raw !== "" && raw !== "Off" ? true : null;
+ }
+ if (type === "radiobutton") {
+ return raw != null && raw !== "" && raw !== "Off" ? String(raw) : null;
+ }
+ // Text/choice: fieldValue may be a string or an array of selections
+ if (Array.isArray(raw)) {
+ const joined = raw.filter(Boolean).join(", ");
+ return joined || null;
+ }
+ if (raw == null || raw === "") return null;
+ return String(raw);
+}
+
+/**
+ * Build mapping from field names (used by fill_form) to widget annotation IDs
+ * (used by annotationStorage).
+ *
+ * CRITICAL: getFieldObjects() returns field-dictionary IDs (the /T tree),
+ * but annotationStorage is keyed by WIDGET annotation IDs (what
+ * page.getAnnotations() returns). The two differ for PDFs where fields and
+ * their widget /Kids are separate objects. Using the wrong key makes all
+ * storage writes silently miss.
+ */
+async function buildFieldNameMap(
+ doc: pdfjsLib.PDFDocumentProxy,
+): Promise {
+ fieldNameToIds.clear();
+ radioButtonValues.clear();
+ fieldNameToPage.clear();
+ fieldNameToLabel.clear();
+ fieldNameToOrder.clear();
+ cachedFieldObjects = null;
+ pdfBaselineFormValues.clear();
+
+ // getFieldObjects() gives us types, current values (/V), and defaults (/DV).
+ // We DON'T use its .id — that's the field dict ref, not the widget annot ref.
+ try {
+ cachedFieldObjects =
+ ((await doc.getFieldObjects()) as Record | null) ?? null;
+ } catch {
+ // getFieldObjects may fail on some PDFs
+ }
+
+ // 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
+ // inconsistent, e.g. after a pdf-lib setText silently failed).
+ const fieldPositions: Array<{ name: string; page: number; y: number }> = [];
+ const widgetFieldValues = new Map();
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
+ let annotations;
+ try {
+ const page = await doc.getPage(pageNum);
+ annotations = await page.getAnnotations();
+ } catch {
+ continue;
+ }
+ for (const ann of annotations) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const a = ann as any;
+ if (!a.fieldName || !a.id) continue;
+
+ // Widget annotation ID — this is what annotationStorage keys by
+ const ids = fieldNameToIds.get(a.fieldName) ?? [];
+ ids.push(a.id);
+ fieldNameToIds.set(a.fieldName, ids);
+
+ // Radio buttons: pdf.js creates WITHOUT setting
+ // .value, so reading target.value gives the HTML default "on".
+ // Remember each widget's export value so the input listener can
+ // report it instead.
+ if (a.radioButton && a.buttonValue != null) {
+ radioButtonValues.set(a.id, String(a.buttonValue));
+ }
+
+ if (!fieldNameToPage.has(a.fieldName)) {
+ fieldNameToPage.set(a.fieldName, pageNum);
+ }
+ if (a.alternativeText) {
+ fieldNameToLabel.set(a.fieldName, a.alternativeText);
+ }
+ if (a.rect) {
+ fieldPositions.push({ name: a.fieldName, page: pageNum, y: a.rect[3] });
+ }
+ // Capture the value the widget will actually render. First widget wins
+ // (radio groups share the field's /V so they all match anyway).
+ if (!widgetFieldValues.has(a.fieldName) && a.fieldValue !== undefined) {
+ widgetFieldValues.set(a.fieldName, a.fieldValue);
+ }
+ }
+ }
+
+ // Ordering: page ascending, then Y descending (top-to-bottom on page)
+ fieldPositions.sort((a, b) => a.page - b.page || b.y - a.y);
+ const seen = new Set();
+ let idx = 0;
+ for (const fp of fieldPositions) {
+ if (!seen.has(fp.name)) {
+ seen.add(fp.name);
+ fieldNameToOrder.set(fp.name, idx++);
+ }
+ }
+
+ // Import baseline values AND remap cachedFieldObjects to widget IDs.
+ //
+ // Baseline: prefer the widget's fieldValue (what AnnotationLayer renders)
+ // over getFieldObjects().value. A PDF can have the field-dict /V out of
+ // sync with the widget — if we import the field-dict value, the panel
+ // disagrees with what's on screen.
+ //
+ // Remap: pdf.js _bindResetFormAction (the PDF's in-document Reset button)
+ // iterates this structure, using .id to key storage and find DOM elements
+ // via [data-element-id=...]. Both use WIDGET ids. pdf-lib's save splits
+ // merged field+widget objects, so we rebuild with widget ids.
+ if (cachedFieldObjects) {
+ const remapped: Record = {};
+ for (const [name, fieldArr] of Object.entries(cachedFieldObjects)) {
+ const widgetIds = fieldNameToIds.get(name);
+ if (!widgetIds) continue; // no widget → not rendered anyway
+
+ // Type comes from getFieldObjects (widget annot data doesn't have it).
+ // Value comes from the widget annotation (fall back to field-dict if
+ // the widget didn't expose one).
+ const type = fieldArr.find((f) => f.type)?.type;
+ const raw = widgetFieldValues.has(name)
+ ? widgetFieldValues.get(name)
+ : fieldArr.find((f) => f.value != null)?.value;
+ const v = normaliseFieldValue(type, raw);
+ if (v !== null) {
+ pdfBaselineFormValues.set(name, v);
+ // Seed current state from baseline so the panel shows it. A
+ // restored localStorage diff (applied in restoreAnnotations) will
+ // overwrite specific fields the user changed.
+ if (!formFieldValues.has(name)) formFieldValues.set(name, v);
+ }
+
+ // Skip parent entries with no concrete id (radio groups: the /T tree
+ // has a parent with the export value, plus one child per widget).
+ const concrete = fieldArr.filter((f) => f.id && f.type);
+ remapped[name] = widgetIds.map((wid, i) => ({
+ ...(concrete[i] ?? concrete[0] ?? fieldArr[0]),
+ id: wid,
+ }));
+ }
+ cachedFieldObjects = remapped;
+ }
+
+ log.info(`Built field name map: ${fieldNameToIds.size} fields`);
+}
+
+/** Sync formFieldValues into pdfDocument.annotationStorage so AnnotationLayer renders pre-filled values.
+ * Skips values that match the PDF's baseline — those are already in storage
+ * in pdf.js's native format (which may differ from our string/bool repr,
+ * e.g. checkbox stores "Yes" not `true`). Overwriting with our normalised
+ * form can break the Reset button's ability to restore defaults. */
+function syncFormValuesToStorage(): void {
+ if (!pdfDocument || fieldNameToIds.size === 0) return;
+ const storage = pdfDocument.annotationStorage;
+ for (const [name, value] of formFieldValues) {
+ if (pdfBaselineFormValues.get(name) === value) continue;
+ const ids = fieldNameToIds.get(name);
+ if (ids) {
+ for (const id of ids) {
+ storage.setValue(id, {
+ value: typeof value === "boolean" ? value : String(value),
+ });
+ }
+ }
+ }
+}
+
+// =============================================================================
+// PDF Save / Download with Annotations
+// =============================================================================
+
+/** Build annotated PDF bytes from the current state. */
+async function getAnnotatedPdfBytes(): Promise {
+ if (!pdfDocument) throw new Error("No PDF loaded");
+ const fullBytes = await pdfDocument.getData();
+
+ // Only export user-added annotations; baseline ones are already in the PDF
+ const annotations: PdfAnnotationDef[] = [];
+ const baselineIds = new Set(pdfBaselineAnnotations.map((a) => a.id));
+ for (const tracked of annotationMap.values()) {
+ if (!baselineIds.has(tracked.def.id)) {
+ annotations.push(tracked.def);
+ }
+ }
+
+ return buildAnnotatedPdfBytes(
+ fullBytes as Uint8Array,
+ annotations,
+ formFieldValues,
+ );
+}
- const contextText = `${header}\n\nPage content:\n${content}`;
+async function savePdf(): Promise {
+ if (!pdfDocument || !isDirty || saveInProgress) return;
+
+ const fileName =
+ pdfUrl
+ .replace(/^(file|computer):\/\//, "")
+ .split(/[/\\]/)
+ .pop() || pdfUrl;
+ const choice = await showConfirmDialog(
+ "Save PDF",
+ "Overwrite this file with your annotations and form edits?",
+ [{ label: "Cancel" }, { label: "Save", primary: true }],
+ fileName,
+ );
+ if (choice !== 1) return;
- // Build content array with text and optional screenshot
- const contentBlocks: ContentBlock[] = [{ type: "text", text: contextText }];
+ saveInProgress = true;
+ saveBtn.disabled = true;
+ saveBtn.title = "Saving...";
- // Add screenshot if host supports image content
- if (app.getHostCapabilities()?.updateModelContext?.image) {
- try {
- // Scale down to reduce token usage (tokens depend on dimensions)
- const sourceCanvas = canvasEl;
- const scale = Math.min(
- 1,
- MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION /
- Math.max(sourceCanvas.width, sourceCanvas.height),
- );
- const targetWidth = Math.round(sourceCanvas.width * scale);
- const targetHeight = Math.round(sourceCanvas.height * scale);
-
- const tempCanvas = document.createElement("canvas");
- tempCanvas.width = targetWidth;
- tempCanvas.height = targetHeight;
- const ctx = tempCanvas.getContext("2d");
- if (ctx) {
- ctx.drawImage(sourceCanvas, 0, 0, targetWidth, targetHeight);
- const dataUrl = tempCanvas.toDataURL("image/png");
- const base64Data = dataUrl.split(",")[1];
- if (base64Data) {
- contentBlocks.push({
- type: "image",
- data: base64Data,
- mimeType: "image/png",
- });
- log.info(
- `Added screenshot to model context (${targetWidth}x${targetHeight})`,
- );
- }
+ try {
+ const pdfBytes = await getAnnotatedPdfBytes();
+ const base64 = uint8ArrayToBase64(pdfBytes);
+
+ const result = await app.callServerTool({
+ name: "save_pdf",
+ arguments: { url: pdfUrl, data: base64 },
+ });
+
+ if (result.isError) {
+ log.error("Save failed:", result.content);
+ saveBtn.disabled = false; // let user retry
+ } else {
+ log.info("PDF saved");
+ // Record mtime so we recognize our own write in file_changed
+ const sc = result.structuredContent as { mtimeMs?: number } | undefined;
+ lastSavedMtime = sc?.mtimeMs ?? null;
+
+ setDirty(false); // → updateSaveBtn() disables button
+ const key = annotationStorageKey();
+ if (key) {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ /* ignore */
}
- } catch (err) {
- log.info("Failed to capture screenshot:", err);
}
}
+ } catch (err) {
+ log.error("Save failed:", err);
+ saveBtn.disabled = false;
+ } finally {
+ saveInProgress = false;
+ saveBtn.title = "Save to file (overwrites original)";
+ }
+}
- app.updateModelContext({ content: contentBlocks });
+async function downloadAnnotatedPdf(): Promise {
+ if (!pdfDocument) return;
+ downloadBtn.disabled = true;
+ downloadBtn.title = "Preparing download...";
+
+ try {
+ const pdfBytes = await getAnnotatedPdfBytes();
+
+ const hasEdits = annotationMap.size > 0 || formFieldValues.size > 0;
+ const baseName = (pdfTitle || "document").replace(/\.pdf$/i, "");
+ const fileName = hasEdits ? `${baseName} - edited.pdf` : `${baseName}.pdf`;
+
+ const base64 = uint8ArrayToBase64(pdfBytes);
+
+ if (app.getHostCapabilities()?.downloadFile) {
+ const { isError } = await app.downloadFile({
+ contents: [
+ {
+ type: "resource",
+ resource: {
+ uri: `file:///${fileName}`,
+ mimeType: "application/pdf",
+ blob: base64,
+ },
+ },
+ ],
+ });
+ if (isError) {
+ log.info("Download was cancelled or denied by host");
+ }
+ } else {
+ // Fallback: create blob URL and trigger download
+ const blob = new Blob([pdfBytes.buffer as ArrayBuffer], {
+ type: "application/pdf",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
} catch (err) {
- log.error("Error updating context:", err);
+ log.error("Download error:", err);
+ } finally {
+ downloadBtn.disabled = false;
+ downloadBtn.title = "Download PDF";
}
}
@@ -698,15 +3795,122 @@ async function renderPage() {
pageTextCache.set(pageToRender, items.join(""));
}
- // Size highlight layer to match canvas
+ // Size overlay layers to match canvas
highlightLayerEl.style.width = `${viewport.width}px`;
highlightLayerEl.style.height = `${viewport.height}px`;
+ annotationLayerEl.style.width = `${viewport.width}px`;
+ annotationLayerEl.style.height = `${viewport.height}px`;
+
+ // Render PDF.js AnnotationLayer for interactive form widgets
+ formLayerEl.innerHTML = "";
+ formLayerEl.style.width = `${viewport.width}px`;
+ formLayerEl.style.height = `${viewport.height}px`;
+ // Set CSS custom properties so AnnotationLayer font-size rules work correctly
+ formLayerEl.style.setProperty("--scale-factor", `${scale}`);
+ formLayerEl.style.setProperty("--total-scale-factor", `${scale}`);
+ try {
+ const annotations = await page.getAnnotations();
+ if (annotations.length > 0) {
+ const linkService = {
+ getDestinationHash: () => "#",
+ getAnchorUrl: () => "#",
+ addLinkAttributes: () => {},
+ isPageVisible: () => true,
+ isPageCached: () => true,
+ externalLinkEnabled: true,
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const annotationLayer = new AnnotationLayer({
+ div: formLayerEl,
+ page,
+ viewport,
+ annotationStorage: pdfDocument.annotationStorage,
+ linkService,
+ accessibilityManager: null,
+ annotationCanvasMap: null,
+ annotationEditorUIManager: null,
+ structTreeLayer: null,
+ commentManager: null,
+ } as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await annotationLayer.render({
+ annotations,
+ div: formLayerEl,
+ page,
+ viewport,
+ renderForms: true,
+ linkService,
+ annotationStorage: pdfDocument.annotationStorage,
+ fieldObjects: cachedFieldObjects,
+ } as any);
+
+ // Fix combo reset: pdf.js's resetform handler sets all
+ // option.selected = (option.value === defaultFieldValue), and
+ // defaultFieldValue is typically null — nothing matches. On a
+ // non-multiple