diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 5b518d5f..cd265f86 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -149,6 +149,20 @@ bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf bun examples/pdf-server/main.ts --stdio ./papers/ ``` +### Additional Flags + +- `--debug` — Enable verbose server-side logging. +- `--enable-interact` — Enable the `interact` tool in HTTP mode (see [Deployment](#deployment)). Not needed for stdio. +- `--writeable-uploads-root` — Allow saving annotated PDFs back to files under client roots named `uploads` (Claude Desktop mounts attachments there; writes are refused by default). + +## Deployment + +The `interact` tool relies on an in-memory command queue (server enqueues → viewer polls). This constrains how the server can be deployed: + +- **stdio** (Claude Desktop) — `interact` is always enabled. The server runs as a single long-lived process, so the in-memory queue works. +- **HTTP, single instance** — Pass `--enable-interact` to opt in. Works as long as all requests land on the same process. +- **HTTP, stateless / multi-instance** — `interact` will not work. Commands enqueued on one instance are invisible to viewers polling another. Leave the flag off; the tool will not be registered. + ## Security: Client Roots MCP clients may advertise **roots** — `file://` URIs pointing to directories on the client's file system. The server uses these to allow access to local files under those directories. @@ -174,34 +188,149 @@ 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 | + +¹ stdio only by default; in HTTP mode requires `--enable-interact` — see [Deployment](#deployment). + +## 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_text` 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_text`, intervals `[{start:1, end:3}]`_ + +> **User:** Take a screenshot of the first page. +> +> _Model calls `interact` with action `get_screenshot`, page `1`_ + +### 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` | +| `image` | `x`, `y`, `width`, `height`, `imageData?`/`imageUrl?`, `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..1ff848a3 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -20,8 +20,10 @@ import { pathToFileUrl, fileUrlToPath, allowedLocalFiles, + cliLocalFiles, DEFAULT_PDF, allowedLocalDirs, + writeFlags, } from "./server.js"; /** @@ -93,17 +95,33 @@ function parseArgs(): { urls: string[]; stdio: boolean; useClientRoots: boolean; + enableInteract: boolean; + debug: boolean; } { const args = process.argv.slice(2); const urls: string[] = []; let stdio = false; let useClientRoots = false; + let enableInteract = false; + let debug = false; for (const arg of args) { if (arg === "--stdio") { stdio = true; } else if (arg === "--use-client-roots") { useClientRoots = true; + } else if (arg === "--enable-interact") { + // Force-enable interact for HTTP mode. Only use when running a + // single long-lived server process (e.g. the e2e test harness) — + // the command queue is in-memory per-process, so stateless + // multi-instance deployments will drop commands. + enableInteract = true; + } else if (arg === "--debug") { + debug = true; + } else if (arg === "--writeable-uploads-root") { + // Claude Desktop mounts attachments under a dir root named "uploads"; + // by default we refuse to write there. This flag opts back in. + writeFlags.allowUploadsRoot = true; } else if (!arg.startsWith("-")) { // Convert local paths to file:// URLs, normalize arxiv URLs let url = arg; @@ -124,11 +142,13 @@ function parseArgs(): { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio, useClientRoots, + enableInteract, + debug, }; } async function main() { - const { urls, stdio, useClientRoots } = parseArgs(); + const { urls, stdio, useClientRoots, enableInteract, debug } = parseArgs(); // Register local files in whitelist for (const url of urls) { @@ -138,6 +158,7 @@ async function main() { const s = fs.statSync(filePath); if (s.isFile()) { allowedLocalFiles.add(filePath); + cliLocalFiles.add(filePath); console.error(`[pdf-server] Registered local file: ${filePath}`); } else if (s.isDirectory()) { allowedLocalDirs.add(filePath); @@ -153,10 +174,20 @@ 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, debug }), + ); } else { // HTTP → client is remote, only honour roots with explicit opt-in - await startStreamableHTTPServer(() => createServer({ useClientRoots })); + 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, enableInteract, debug }), + ); } } 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

+ + + @@ -107,12 +143,31 @@ - -
-
- -
-
+ +
+ +
+
+ +
+
+
+
+
+
+ + +
diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json index 09687ece..bbf13adc 100644 --- a/examples/pdf-server/package.json +++ b/examples/pdf-server/package.json @@ -16,7 +16,7 @@ "scripts": { "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node --external pdfjs-dist && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --external pdfjs-dist --banner \"#!/usr/bin/env node\"", "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve": "bun --watch main.ts", + "serve": "bun --watch main.ts --enable-interact", "serve:stdio": "bun main.ts --stdio", "start": "cross-env NODE_ENV=development npm run build && npm run serve", "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", @@ -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..db6c56ba 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,11 @@ import { allowedLocalFiles, allowedLocalDirs, pathToFileUrl, + startFileWatch, + stopFileWatch, + cliLocalFiles, + isWritablePath, + writeFlags, CACHE_INACTIVITY_TIMEOUT_MS, CACHE_MAX_LIFETIME_MS, CACHE_MAX_PDF_SIZE_BYTES, @@ -446,3 +455,603 @@ describe("createServer useClientRoots option", () => { server.close(); }); }); + +describe("isWritablePath", () => { + let savedFiles: Set; + let savedDirs: Set; + let savedCli: Set; + let savedAllowUploadsRoot: boolean; + + beforeEach(() => { + savedFiles = new Set(allowedLocalFiles); + savedDirs = new Set(allowedLocalDirs); + savedCli = new Set(cliLocalFiles); + allowedLocalFiles.clear(); + allowedLocalDirs.clear(); + cliLocalFiles.clear(); + savedAllowUploadsRoot = writeFlags.allowUploadsRoot; + writeFlags.allowUploadsRoot = false; + }); + + afterEach(() => { + allowedLocalFiles.clear(); + allowedLocalDirs.clear(); + cliLocalFiles.clear(); + for (const x of savedFiles) allowedLocalFiles.add(x); + for (const x of savedDirs) allowedLocalDirs.add(x); + for (const x of savedCli) cliLocalFiles.add(x); + writeFlags.allowUploadsRoot = savedAllowUploadsRoot; + }); + + it("nothing is writable when no roots and no CLI files", () => { + expect(isWritablePath("/any/path/file.pdf")).toBe(false); + }); + + it("CLI file is writable", () => { + allowedLocalFiles.add("/tmp/explicit.pdf"); + cliLocalFiles.add("/tmp/explicit.pdf"); + expect(isWritablePath("/tmp/explicit.pdf")).toBe(true); + }); + + it("MCP file root is NOT writable", () => { + allowedLocalFiles.add("/tmp/uploaded.pdf"); // from refreshRoots, no CLI + expect(isWritablePath("/tmp/uploaded.pdf")).toBe(false); + }); + + it("file under a directory root at any depth is writable", () => { + allowedLocalDirs.add("/home/user/docs"); + expect(isWritablePath("/home/user/docs/file.pdf")).toBe(true); + expect(isWritablePath("/home/user/docs/sub/deep/file.pdf")).toBe(true); + }); + + it("the directory root itself is NOT writable", () => { + allowedLocalDirs.add("/home/user/docs"); + expect(isWritablePath("/home/user/docs")).toBe(false); + }); + + it("MCP file root stays read-only even when under a directory root", () => { + // Client sent BOTH the directory and a file inside it as roots. + // The explicit file-root is the stronger signal: treat as upload. + allowedLocalDirs.add("/home/user/docs"); + allowedLocalFiles.add("/home/user/docs/uploaded.pdf"); + expect(isWritablePath("/home/user/docs/uploaded.pdf")).toBe(false); + // Siblings not sent as file roots remain writable + expect(isWritablePath("/home/user/docs/other.pdf")).toBe(true); + }); + + it("CLI file wins even if also in allowedLocalFiles", () => { + // CLI file added to both sets (main.ts does this) + allowedLocalFiles.add("/tmp/cli.pdf"); + cliLocalFiles.add("/tmp/cli.pdf"); + expect(isWritablePath("/tmp/cli.pdf")).toBe(true); + }); + + it("file outside any directory root is not writable", () => { + allowedLocalDirs.add("/home/user/docs"); + expect(isWritablePath("/home/user/other/file.pdf")).toBe(false); + expect(isWritablePath("/home/user/docsevil/file.pdf")).toBe(false); + }); + + it("dir root named 'uploads' is read-only by default", () => { + // Claude Desktop mounts the conversation's attachment drop folder as a + // directory root literally named 'uploads'. The attached PDF lives + // directly under it. + allowedLocalDirs.add("/var/folders/xy/T/claude/uploads"); + expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe( + false, + ); + // Deep nesting under the uploads root — still the same root, still no. + expect( + isWritablePath("/var/folders/xy/T/claude/uploads/sub/deep.pdf"), + ).toBe(false); + }); + + it("uploads-root guard matches basename, not substring", () => { + allowedLocalDirs.add("/home/user/my-uploads"); // contains 'uploads' but ≠ + allowedLocalDirs.add("/home/user/uploads-archive"); + expect(isWritablePath("/home/user/my-uploads/f.pdf")).toBe(true); + expect(isWritablePath("/home/user/uploads-archive/f.pdf")).toBe(true); + }); + + it("--writeable-uploads-root opts back in", () => { + allowedLocalDirs.add("/var/folders/xy/T/claude/uploads"); + writeFlags.allowUploadsRoot = true; + expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe( + true, + ); + }); + + it("CLI file under an uploads root is still writable", () => { + // Explicit CLI intent beats the uploads-basename heuristic. + allowedLocalDirs.add("/tmp/uploads"); + allowedLocalFiles.add("/tmp/uploads/explicit.pdf"); + cliLocalFiles.add("/tmp/uploads/explicit.pdf"); + expect(isWritablePath("/tmp/uploads/explicit.pdf")).toBe(true); + }); +}); + +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); + cliLocalFiles.add(tmpFile); // save_pdf test needs write scope + }); + + afterEach(() => { + stopFileWatch(uuid); + allowedLocalFiles.delete(tmpFile); + cliLocalFiles.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(); + }); + + it("save_pdf refuses file roots from MCP client (not CLI)", async () => { + // Simulate: file is readable (in allowedLocalFiles via refreshRoots) + // but NOT in cliLocalFiles — it came from the client, not a CLI arg. + cliLocalFiles.delete(tmpFile); + + 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 original = fs.readFileSync(tmpFile); + const r = await client.callTool({ + name: "save_pdf", + arguments: { + url: tmpFile, + data: Buffer.from("%PDF-1.4\nshould-not-write").toString("base64"), + }, + }); + expect(r.isError).toBe(true); + const text = (r.content as { text: string }[])[0].text; + expect(text).toContain("read-only"); + // Verify the file was NOT modified + expect(fs.readFileSync(tmpFile)).toEqual(original); + + await client.close(); + await server.close(); + }); + + it("save_pdf allows files under a directory root", async () => { + // File is under a mounted directory root — but NOT itself a file root + // (a file root, even under a mounted dir, is read-only per isWritablePath). + cliLocalFiles.delete(tmpFile); + allowedLocalFiles.delete(tmpFile); + allowedLocalDirs.add(tmpDir); + + 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 r = await client.callTool({ + name: "save_pdf", + arguments: { + url: tmpFile, + data: Buffer.from("%PDF-1.4\nvia-dir-root").toString("base64"), + }, + }); + expect(r.isError).toBeFalsy(); + expect(fs.readFileSync(tmpFile, "utf8")).toBe("%PDF-1.4\nvia-dir-root"); + + allowedLocalDirs.delete(tmpDir); + await client.close(); + await server.close(); + }); + + // fs.watch on a file that gets replaced via rename: on macOS (kqueue) + // the watcher reliably fires a "rename" event which our re-attach logic + // handles. On Linux (inotify), a watcher on the old inode often gets no + // event at all — inotify watches inodes, and the rename just atomically + // swaps the directory entry to a NEW inode. Directory-level watching + // would fix this but isn't what we do. Skip on non-darwin. + it.skipIf(process.platform !== "darwin")( + "detects atomic rename (macOS kqueue only)", + 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(); + }, + ); +}); + +describe("interact tool", () => { + // Helper: connected server+client pair with interact enabled. + // Command queues are MODULE-LEVEL (shared across server instances), so each + // test uses a distinct viewUUID to avoid cross-test interference. + async function connect() { + 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)]); + return { server, client }; + } + + // Helper: poll with an outer deadline so a failing test doesn't hang for the + // full 30s long-poll. Safe ONLY when a command is already enqueued — poll + // then returns after the 200ms batch window. + async function poll(client: Client, uuid: string, timeoutMs = 2000) { + 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 Array>; + } + + function firstText(r: Awaited>): string { + return (r.content as Array<{ type: string; text: string }>)[0].text; + } + + it("enqueue → poll roundtrip delivers the command", async () => { + const { server, client } = await connect(); + const uuid = "test-interact-roundtrip"; + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "navigate", page: 5 }, + }); + expect(r.isError).toBeFalsy(); + expect(firstText(r)).toContain("Queued"); + expect(firstText(r)).toContain("page 5"); + + // Core mechanism: the viewer polls for what the model enqueued. + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("navigate"); + expect(cmds[0].page).toBe(5); + + await client.close(); + await server.close(); + }); + + it("navigate without `page` returns isError with a helpful message", async () => { + const { server, client } = await connect(); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "test-err-nav", action: "navigate" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("navigate"); + expect(firstText(r)).toContain("page"); + + await client.close(); + await server.close(); + }); + + it("fill_form without `fields` returns isError with a helpful message", async () => { + const { server, client } = await connect(); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "test-err-fill", action: "fill_form" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("fill_form"); + expect(firstText(r)).toContain("fields"); + + await client.close(); + await server.close(); + }); + + it("add_annotations without `annotations` returns isError with a helpful message", async () => { + const { server, client } = await connect(); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "test-err-ann", action: "add_annotations" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("add_annotations"); + expect(firstText(r)).toContain("annotations"); + + await client.close(); + await server.close(); + }); + + it("isolates command queues across distinct viewUUIDs", async () => { + const { server, client } = await connect(); + const uuidA = "test-isolate-A"; + const uuidB = "test-isolate-B"; + + await client.callTool({ + name: "interact", + arguments: { viewUUID: uuidA, action: "navigate", page: 3 }, + }); + await client.callTool({ + name: "interact", + arguments: { viewUUID: uuidB, action: "search", query: "quantum" }, + }); + + const cmdsA = await poll(client, uuidA); + expect(cmdsA).toHaveLength(1); + expect(cmdsA[0].type).toBe("navigate"); + expect(cmdsA[0].page).toBe(3); + + const cmdsB = await poll(client, uuidB); + expect(cmdsB).toHaveLength(1); + expect(cmdsB[0].type).toBe("search"); + expect(cmdsB[0].query).toBe("quantum"); + + await client.close(); + await server.close(); + }); + + // SKIPPED: the unknown-UUID path enters the long-poll branch and blocks for + // the full LONG_POLL_TIMEOUT_MS (30s, module-local const, not configurable). + // The handler does dequeue [] at the end, so the return value IS + // {commands: []} — but there's no fast path to reach it without waiting. + // See the `stopFileWatch prevents further commands` test above for indirect + // coverage of the same blocking behaviour. + it.skip("poll with unknown viewUUID returns {commands: []} after long-poll", () => {}); + + it("fill_form passes all fields through when viewFieldNames is not registered", async () => { + const { server, client } = await connect(); + // Fresh UUID never seen by display_pdf → viewFieldNames.get(uuid) is + // undefined → the known-fields guard (`knownFields && !knownFields.has()`) + // is falsy for every field → everything is enqueued. + const uuid = "test-fillform-passthrough"; + + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: uuid, + action: "fill_form", + fields: [ + { name: "anything", value: "goes" }, + { name: "unchecked", value: true }, + ], + }, + }); + expect(r.isError).toBeFalsy(); + expect(firstText(r)).toContain("Filled 2 field(s)"); + // No rejection complaint — the registry has no entry for this UUID + expect(firstText(r)).not.toContain("Unknown"); + + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("fill_form"); + const fields = cmds[0].fields as Array<{ name: string; value: unknown }>; + expect(fields).toHaveLength(2); + expect(fields.map((f) => f.name).sort()).toEqual(["anything", "unchecked"]); + + // Note: the "registered → reject unknown" branch needs viewFieldNames + // populated, which only happens inside display_pdf (requires a real PDF). + // That map isn't exported, so the rejection path is covered by e2e only. + + await client.close(); + await server.close(); + }); + + // SECURITY: resolveImageAnnotation must not read arbitrary local files. + // The model controls imageUrl; without validation it's an exfil primitive + // (readFile → base64 → iframe → get_screenshot reads it back). + describe("add_annotations image: imageUrl validation", () => { + let savedDirs: Set; + beforeEach(() => { + savedDirs = new Set(allowedLocalDirs); + allowedLocalDirs.clear(); + }); + afterEach(() => { + allowedLocalDirs.clear(); + for (const d of savedDirs) allowedLocalDirs.add(d); + }); + + it("rejects local path outside allowed roots", async () => { + const { server, client } = await connect(); + // Whitelist a harmless temp dir; target a path clearly outside it. + allowedLocalDirs.add(os.tmpdir()); + const target = path.join(os.homedir(), ".ssh", "id_rsa"); + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "sec-local", + action: "add_annotations", + annotations: [{ type: "image", id: "i1", page: 1, imageUrl: target }], + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("imageUrl rejected"); + await client.close(); + await server.close(); + }); + + it("rejects http:// URL (SSRF)", async () => { + const { server, client } = await connect(); + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "sec-http", + action: "add_annotations", + annotations: [ + { + type: "image", + id: "i1", + page: 1, + imageUrl: "http://169.254.169.254/latest/meta-data/", + }, + ], + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("imageUrl rejected"); + await client.close(); + await server.close(); + }); + + it("accepts path under an allowed dir (reaches readFile)", async () => { + const { server, client } = await connect(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-imgurl-")); + allowedLocalDirs.add(dir); + // Minimal valid PNG (1x1 transparent): 8-byte sig + IHDR + IDAT + IEND. + const png = Buffer.from( + "89504E470D0A1A0A0000000D49484452000000010000000108060000001F15C4890000000A49444154789C6360000000000200015E9AFE400000000049454E44AE426082", + "hex", + ); + const imgPath = path.join(dir, "sig.png"); + fs.writeFileSync(imgPath, png); + try { + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "sec-ok", + action: "add_annotations", + annotations: [ + { type: "image", id: "i1", page: 1, imageUrl: imgPath }, + ], + }, + }); + // No security rejection; readFile succeeds; command enqueued. + expect(r.isError).toBeFalsy(); + const cmds = await poll(client, "sec-ok"); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("add_annotations"); + const anns = cmds[0].annotations as Array>; + // validateUrl passed → readFile ran → imageData populated. + expect(typeof anns[0].imageData).toBe("string"); + expect((anns[0].imageData as string).length).toBeGreaterThan(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + await client.close(); + await server.close(); + }); + }); +}); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 0f835b76..5bc2502f 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"; // ============================================================================= @@ -44,17 +80,326 @@ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds /** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */ export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB -/** Allowed local file paths (populated from CLI args) */ +/** Allowed local file paths (CLI args + file roots — read access). */ export const allowedLocalFiles = new Set(); -/** Allowed local directories (populated from MCP roots) */ +/** Allowed local directories (CLI args + directory roots — read access). */ export const allowedLocalDirs = new Set(); +/** + * Subset of allowedLocalFiles that came from CLI args (not MCP roots). + * Only these individual files are writable. File roots from the client + * are uploaded copies in ad-hoc hidden folders — treat as read-only. + * Directory roots are mounted folders; files UNDER them are writable. + */ +export const cliLocalFiles = new Set(); + +/** + * Write-permission flags. Object wrapper (not a bare `let`) so main.ts can + * mutate via the exported binding without re-import gymnastics — same + * pattern as the Sets above. + */ +export const writeFlags = { + /** + * Claude Desktop mounts its per-conversation drop folder as a directory + * root whose basename is literally `uploads`. Files in there are one-shot + * copies the client doesn't expect us to overwrite. Default: read-only. + * `--writeable-uploads-root` flips this for local testing. + */ + allowUploadsRoot: false, +}; + +/** + * Saving is allowed iff: + * (a) the file was passed as a CLI arg — the user explicitly named it + * when starting the server, so overwriting is clearly intentional; OR + * (b) the file is STRICTLY UNDER a directory root at any depth + * (isAncestorDir excludes rel === "", so the root itself never + * counts), AND the client did not ALSO send it as a file root. + * A file root is the client's way of saying "here's an upload" — + * treat that signal as authoritative even when the path happens + * to fall inside a mounted directory. + * + * EXCEPTION to (b): a dir root whose basename is `uploads` is treated + * as read-only unless `writeFlags.allowUploadsRoot` is set. This is how + * Claude Desktop surfaces attached files — writing back to them + * surprises the user (the attachment doesn't update). + * + * With no directory roots and no CLI files, nothing is writable. + */ +export function isWritablePath(resolved: string): boolean { + if (cliLocalFiles.has(resolved)) return true; + // MCP file root → always read-only, regardless of ancestry + if (allowedLocalFiles.has(resolved)) return false; + return [...allowedLocalDirs].some((dir) => { + if (!isAncestorDir(dir, resolved)) return false; + if (!writeFlags.allowUploadsRoot && path.basename(dir) === "uploads") { + return false; + } + return true; + }); +} + // Works both from source (server.ts) and compiled (dist/server.js) 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 + +// ============================================================================= +// Interact Tool Input Schemas (runtime validators) +// ============================================================================= +// +// Annotation structure docs live in src/pdf-annotations.ts (the TS +// interfaces) and in the interact tool description. The inputSchema +// for `annotations` accepts z.record(z.any()) to keep the model-facing +// API forgiving; adding strict validation here would be a behavior change. + +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 — wire protocol shared with the viewer +// ============================================================================= + +// PdfCommand is the single source of truth for what flows through the +// poll queue. Defined once in src/commands.ts; both sides import it. +// (`import type` → no pdf-lib bundled into the server.) +import type { PdfCommand } from "./src/commands.js"; +export type { PdfCommand }; + +// ============================================================================= +// Pending get_pages Requests (request-response bridge via client) +// ============================================================================= + +// Keep well under the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC (60s) so we +// reject first and return a real error instead of the client cancelling us. +const GET_PAGES_TIMEOUT_MS = 45_000; + +interface PageDataEntry { + page: number; + text?: string; + image?: string; // base64 PNG +} + +const pendingPageRequests = new Map< + string, + (data: PageDataEntry[] | Error) => void +>(); + +/** + * Wait for the viewer to render and submit page data. + * Rejects on timeout or when the interact request is aborted upstream. + */ +function waitForPageData( + requestId: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const settle = (v: PageDataEntry[] | Error) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + pendingPageRequests.delete(requestId); + v instanceof Error ? reject(v) : resolve(v); + }; + const onAbort = () => settle(new Error("interact request cancelled")); + const timer = setTimeout( + () => settle(new Error("Timeout waiting for page data from viewer")), + GET_PAGES_TIMEOUT_MS, + ); + signal?.addEventListener("abort", onAbort); + pendingPageRequests.set(requestId, settle); + }); +} + +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(); + +/** + * Per-view heartbeat. THIS is what the sweep iterates — not commandQueues. + * + * Why not commandQueues: display_pdf populates viewFieldNames/viewFieldInfo/ + * viewFileWatches but never touches commandQueues (only enqueueCommand does, + * and it's triply gated). And dequeueCommands deletes the entry on every poll, + * so even when it exists the sweep's TTL window is ~200ms wide. Net effect: + * the sweep found nothing and the aux maps leaked every display_pdf call. + * viewFileWatches entries hold an fs.StatWatcher (FD + timer) — slow FD + * exhaustion on HTTP --enable-interact. + */ +const viewLastActivity = new Map(); + +/** Register or refresh the heartbeat for a view. */ +function touchView(uuid: string): void { + viewLastActivity.set(uuid, Date.now()); +} + +function pruneStaleQueues(): void { + const now = Date.now(); + for (const [uuid, lastActivity] of viewLastActivity) { + if (now - lastActivity > COMMAND_TTL_MS) { + viewLastActivity.delete(uuid); + commandQueues.delete(uuid); + viewFieldNames.delete(uuid); + viewFieldInfo.delete(uuid); + stopFileWatch(uuid); + } + } +} + +// Periodic sweep so abandoned views 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(); + touchView(viewUUID); + + // 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[] { + // Poll is activity — keep the view alive even when the queue is empty + // (the common case: viewer polls every ~30s with nothing to receive). + touchView(viewUUID); + 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 +784,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. * @@ -463,10 +1030,18 @@ export interface CreateServerOptions { * @default false */ useClientRoots?: boolean; + + /** + * Emit debug metadata to the viewer (currently: allowed roots shown + * in a floating bubble). Toggled by the `--debug` CLI flag. + */ + debug?: boolean; } export function createServer(options: CreateServerOptions = {}): McpServer { - const { useClientRoots = false } = options; + const { enableInteract = false, useClientRoots = false } = options; + const debug = options.debug ?? false; + const disableInteract = !enableInteract; const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); if (useClientRoots) { @@ -480,11 +1055,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) @@ -496,23 +1066,61 @@ export function createServer(options: CreateServerOptions = {}): McpServer { "List available PDFs that can be displayed", {}, async (): Promise => { - const pdfs: Array<{ url: string; type: "local" | "remote" }> = []; + const seen = new Set(); + const localFiles: string[] = []; + const addLocal = (filePath: string) => { + const url = pathToFileUrl(filePath); + if (seen.has(url)) return; + seen.add(url); + localFiles.push(url); + }; - // Add local files - for (const filePath of allowedLocalFiles) { - pdfs.push({ url: pathToFileUrl(filePath), type: "local" }); - } + // Explicitly registered files (CLI args + file roots) + for (const filePath of allowedLocalFiles) addLocal(filePath); + + // Walk directory roots for *.pdf files + const WALK_MAX_DEPTH = 8; + const WALK_MAX_FILES = 500; + let truncated = false; + const walk = async (dir: string, depth: number): Promise => { + if (depth > WALK_MAX_DEPTH || localFiles.length >= WALK_MAX_FILES) { + truncated ||= localFiles.length >= WALK_MAX_FILES; + return; + } + let entries; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; // unreadable — skip silently + } + for (const e of entries) { + if (localFiles.length >= WALK_MAX_FILES) { + truncated = true; + return; + } + // Skip dotfiles/dirs and common noise + if (e.name.startsWith(".") || e.name === "node_modules") continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + await walk(full, depth + 1); + } else if (e.isFile() && /\.pdf$/i.test(e.name)) { + addLocal(full); + } + } + }; + for (const dir of allowedLocalDirs) await walk(dir, 0); // Build text const parts: string[] = []; - if (pdfs.length > 0) { - parts.push( - `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}`, - ); + if (localFiles.length > 0) { + const header = truncated + ? `Available PDFs (showing first ${WALK_MAX_FILES}):` + : `Available PDFs:`; + parts.push(`${header}\n${localFiles.map((u) => `- ${u}`).join("\n")}`); } if (allowedLocalDirs.size > 0) { parts.push( - `Allowed local directories (from client roots):\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`, + `Allowed local directories:\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`, ); } parts.push( @@ -522,8 +1130,9 @@ export function createServer(options: CreateServerOptions = {}): McpServer { return { content: [{ type: "text", text: parts.join("\n\n") }], structuredContent: { - localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url), + localFiles, allowedDirectories: [...allowedLocalDirs], + truncated, }, }; }, @@ -613,27 +1222,76 @@ 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.` + : `Open a PDF in an interactive viewer. Call this ONCE per PDF. -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`, +**All follow-up actions go through the \`interact\` tool** with the returned viewUUID — annotating, signing, stamping, filling forms, navigating, searching, extracting text/screenshots. Calling display_pdf again creates a SEPARATE viewer with a different viewUUID — interact calls using the new UUID will not reach the viewer the user already sees. + +Returns a viewUUID in structuredContent. Pass it to \`interact\`: +- add_annotations, update_annotations, remove_annotations, highlight_text +- fill_form (fill PDF form fields) +- navigate, search, find, search_navigate, zoom +- get_text, get_screenshot (extract content) + +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 +1304,1096 @@ 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(); + // Start the heartbeat now so the sweep can clean up viewFieldNames/ + // viewFieldInfo/viewFileWatches even if no interact calls ever happen. + if (!disableInteract) touchView(uuid); + + // Check writability (governs save button; see isWritablePath doc). + // Also requires OS-level W_OK so we don't lie on read-only mounts. + let writable = false; + let debugResolved: string | undefined; // only used when --debug + if (isFileUrl(normalized) || isLocalPath(normalized)) { + const localPath = isFileUrl(normalized) + ? fileUrlToPath(normalized) + : decodeURIComponent(normalized); + const resolved = path.resolve(localPath); + debugResolved = resolved; + if (isWritablePath(resolved)) { + 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: disableInteract + ? `Displaying PDF: ${normalized}` + : `PDF opened. viewUUID: ${uuid} + +→ To annotate, sign, stamp, fill forms, navigate, or extract: call \`interact\` with this viewUUID. +→ DO NOT call display_pdf again — that spawns a separate viewer with a different viewUUID; your interact calls would target the new empty one, not the one the user is looking at. + +URL: ${normalized}`, + }, + ]; + + 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, + // Debug: viewer renders this in a floating bubble (--debug flag). + ...(debug + ? { + _debug: { + resolved: debugResolved, + writable, + isWritablePath: debugResolved + ? isWritablePath(debugResolved) + : undefined, + cliLocalFiles: [...cliLocalFiles], + allowedLocalFiles: [...allowedLocalFiles], + allowedLocalDirs: [...allowedLocalDirs], + }, + } + : {}), }, }; }, ); + 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. + * + * SECURITY: imageUrl is model-controlled. It must pass the same + * validateUrl() gate as display_pdf/save_pdf — otherwise the model + * can request `{imageUrl:"/Users/x/.ssh/id_rsa"}`, we'd readFile it, + * base64 the bytes, ship them to the iframe, and get_screenshot (or + * any future echo path) reads them back. Throws on rejection so the + * tool result carries the error; silent skip hides the attack attempt. + */ + async function resolveImageAnnotation( + ann: Record, + ): Promise { + // Fetch image data from URL if no imageData provided + if (!ann.imageData && ann.imageUrl) { + const url = String(ann.imageUrl); + // Same gate as every other local/remote read in this server. + // Local: must be in allowedLocalFiles or under allowedLocalDirs. + // Remote: must be https://. + const check = validateUrl(url); + if (!check.valid) { + throw new Error( + `imageUrl rejected by validateUrl: ${check.error ?? url}`, + ); + } + let imgBytes: Uint8Array; + if (url.startsWith("https://")) { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`); + imgBytes = new Uint8Array(await resp.arrayBuffer()); + } else { + // validateUrl already confirmed this path is under an allowed root. + const filePath = isFileUrl(url) + ? fileUrlToPath(url) + : decodeURIComponent(url); + imgBytes = await fs.promises.readFile(path.resolve(filePath)); + } + ann.imageData = Buffer.from(imgBytes).toString("base64"); + } + + // 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, + signal?: AbortSignal, + ): 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. + // Rejection (path not allowed, not https, fetch failed) surfaces as + // a tool error so the model sees it — don't silently skip. + try { + for (const ann of annotations) { + if ((ann as any).type === "image") { + await resolveImageAnnotation(ann as any); + } + } + } catch (err) { + return { + content: [ + { + type: "text", + text: `add_annotations: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + enqueueCommand(uuid, { + type: "add_annotations", + // resolveImageAnnotation populates optional x/y/width/height; + // input is validated as Record[] so this cast is + // the wire-protocol promise, not a compiler guarantee. + annotations: annotations as Extract< + PdfCommand, + { type: "add_annotations" } + >["annotations"], + }); + 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 Extract< + PdfCommand, + { type: "update_annotations" } + >["annotations"], + }); + 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(", ")}`); + // Only list valid names when the model got something wrong — + // display_pdf already returned the full field info on open. + 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, signal); + } 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, signal); + } 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, + }, + extra, + ): 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], + extra.signal, + ); + 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 settle = pendingPageRequests.get(requestId); + if (settle) { + settle(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, + }; + } + const resolved = path.resolve(filePath); + // Enforce the same write scope the display_pdf writable flag uses. + // The viewer hides the save button for non-writable files, but we + // must not trust the client: a direct save_pdf call should also refuse. + if (!isWritablePath(resolved)) { + return { + content: [ + { + type: "text", + text: + "Save refused: file is not under a mounted directory root " + + "and was not passed as a CLI argument. MCP file roots are " + + "read-only (typically uploaded copies the client doesn't " + + "expect to change).", + }, + ], + isError: true, + }; + } + try { + const bytes = Buffer.from(data, "base64"); + 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 +2407,24 @@ 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 loads the Standard-14 fonts TWO ways: + // - fetch()s the .ttf bytes → connect-src + // - creates FontFace('name', 'url(...)') → font-src + // resourceDomains maps to font-src; we need both. + connectDomains: [STANDARD_FONT_ORIGIN], + resourceDomains: [STANDARD_FONT_ORIGIN], + }, + }, + }, + }, ], }; }, diff --git a/examples/pdf-server/src/annotation-panel.ts b/examples/pdf-server/src/annotation-panel.ts new file mode 100644 index 00000000..6b410952 --- /dev/null +++ b/examples/pdf-server/src/annotation-panel.ts @@ -0,0 +1,1087 @@ +/** + * Annotation Panel — floating/docked sidebar showing all annotations and + * form fields, grouped by page in accordion sections. Handles panel + * positioning, resize, drag-to-reposition, per-item cards, and the + * reset/clear-all actions. + * + * All coupling to mcp-app.ts flows through {@link PanelDeps} supplied to + * {@link initAnnotationPanel}. Shared Maps/Sets come from viewer-state.ts. + */ + +import type * as pdfjsLib from "pdfjs-dist"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { PdfAnnotationDef } from "./pdf-annotations.js"; +import { + type TrackedAnnotation, + annotationMap, + formFieldValues, + pdfBaselineFormValues, + selectedAnnotationIds, + fieldNameToIds, + fieldNameToPage, + fieldNameToLabel, + fieldNameToOrder, + undoStack, + redoStack, + searchBarEl, + formLayerEl, +} from "./viewer-state.js"; + +const log = { + error: console.error.bind(console, "[PDF-VIEWER]"), +}; + +// ============================================================================= +// Panel DOM Elements +// ============================================================================= + +/** Floating panel container. Exported — mcp-app.ts reads .classList. */ +export const annotationsPanelEl = document.getElementById("annotation-panel")!; +/** Scrollable list of accordion sections. Exported — selectAnnotation queries it. */ +export 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; + +// ============================================================================= +// Panel State (exported — mcp-app.ts reads .open, writes .openAccordionSection) +// ============================================================================= + +/** Which corner the floating panel is anchored to. */ +type PanelCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left"; + +export const panelState = { + /** Whether the panel is currently visible. Read by search open/close + debug. */ + open: false, + /** Which accordion section is open (e.g. "page-3"). Written by selectAnnotation. */ + openAccordionSection: null as string | null, +}; + +/** null = user hasn't manually toggled; true/false = manual preference */ +let annotationPanelUserPref: boolean | null = null; +/** Whether the user has ever interacted with accordion sections (prevents auto-open after explicit collapse). */ +let accordionUserInteracted = false; +let floatingPanelCorner: PanelCorner = "top-right"; + +// ============================================================================= +// Dependencies injected by mcp-app.ts +// ============================================================================= + +/** + * Callbacks and live-state getters supplied by mcp-app.ts. Scalars like + * `currentPage` and `pdfDocument` are reassigned throughout mcp-app.ts; + * a getter here avoids wrapping every one of their ~100 use sites. + */ +export interface PanelDeps { + // Live scalar state (read-only from panel's side) + state: () => { + currentPage: number; + isDirty: boolean; + pdfDocument: pdfjsLib.PDFDocumentProxy | null; + pdfBaselineAnnotations: PdfAnnotationDef[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cachedFieldObjects: Record | null; + searchOpen: boolean; + }; + // Callbacks into mcp-app.ts + renderPage: () => void; + goToPage: (page: number) => void; + selectAnnotation: (id: string | null) => void; + persistAnnotations: () => void; + removeAnnotation: (id: string) => void; + requestFitToContent: () => void; + updatePageContext: () => void; + setFocusedField: (name: string | null) => void; + // App bridge + sendMessage: (msg: { + role: "user"; + content: Array<{ type: "text"; text: string }>; + }) => Promise; + getHostContext: () => McpUiHostContext | undefined; +} + +let deps!: PanelDeps; + +// ============================================================================= +// Floating panel positioning +// ============================================================================= + +/** 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 = deps.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. */ +export 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 = + deps.state().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. */ +export 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(); + } +} + +export function setAnnotationPanelOpen(open: boolean): void { + panelState.open = 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(); + } + deps.requestFitToContent(); +} + +function toggleAnnotationPanel(): void { + annotationPanelUserPref = !panelState.open; + try { + localStorage.setItem( + "pdf-annotation-panel", + annotationPanelUserPref ? "open" : "closed", + ); + } catch { + /* ignore */ + } + setAnnotationPanelOpen(annotationPanelUserPref); +} + +// ============================================================================= +// Field & item state helpers +// ============================================================================= + +/** + * 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; +} + +export function updateAnnotationsBadge(): void { + const count = sidebarItemCount(); + if (count > 0 && !panelState.open) { + 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 && panelState.open) { + setAnnotationPanelOpen(false); + } +} + +// ============================================================================= +// Label / preview helpers +// ============================================================================= + +/** Human-readable label for an annotation type (used in sidebar). */ +export function getAnnotationLabel(def: PdfAnnotationDef): string { + switch (def.type) { + case "highlight": + return "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). */ +export 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 ""; + } +} + +export 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. */ +export 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; + // LineAnnotation has only x1/y1/x2/y2 — sort by the higher endpoint + // (higher internal-y = closer to page top). + if ("y1" in def) return Math.max(def.y1, def.y2); + return 0; +} + +// ============================================================================= +// Panel rendering +// ============================================================================= + +export function renderAnnotationPanel(): void { + if (!panelState.open) return; + + annotationsPanelCountEl.textContent = String(sidebarItemCount()); + annotationsPanelResetBtn.disabled = !deps.state().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 = ""; + + const { currentPage } = deps.state(); + + // Auto-open section for current page only on first render (before user interaction) + if (panelState.openAccordionSection === null && !accordionUserInteracted) { + if (allPages.has(currentPage)) { + panelState.openAccordionSection = `page-${currentPage}`; + } else if (sortedPages.length > 0) { + panelState.openAccordionSection = `page-${sortedPages[0]}`; + } + } + + for (const pageNum of sortedPages) { + const sectionKey = `page-${pageNum}`; + const isOpen = panelState.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 = panelState.openAccordionSection !== sectionKey; + panelState.openAccordionSection = opening ? sectionKey : null; + renderAnnotationPanel(); + // Navigate to the page when expanding a page section + if (opening) { + const pageMatch = sectionKey.match(/^page-(\d+)$/); + if (pageMatch) { + deps.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); +} + +const TRASH_SVG = ``; +const REVERT_SVG = ``; + +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 = TRASH_SVG; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + deps.removeAnnotation(def.id); + deps.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 !== deps.state().currentPage) { + deps.goToPage(def.page); + setTimeout(() => { + deps.selectAnnotation(def.id); + pulseAnnotation(def.id); + }, 300); + } else { + deps.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 === deps.state().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 + deps.selectAnnotation(def.id); + const label = getAnnotationLabel(def); + const previewText = getAnnotationPreview(def); + const desc = previewText ? `${label}: ${previewText}` : label; + void deps + .sendMessage({ + role: "user", + content: [{ type: "text", text: `update ${desc}: ` }], + }) + .catch(log.error); + }); + + return card; +} + +/** 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 + const { pdfDocument } = deps.state(); + 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(); + deps.renderPage(); + deps.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 + panelState.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 !== deps.state().currentPage) { + deps.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 + deps.setFocusedField(name); + deps.updatePageContext(); + const fieldLabel = getFormFieldLabel(name); + void deps + .sendMessage({ + role: "user", + content: [{ type: "text", text: `update ${fieldLabel}: ` }], + }) + .catch(log.error); + }); + + 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 }, + ); + } +} + +/** Toggle the `.selected` class on every card to match selectedAnnotationIds. */ +export 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), + ); + } +} + +// ============================================================================= +// Reset / Clear +// ============================================================================= + +/** 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 { + const { pdfDocument, cachedFieldObjects } = deps.state(); + 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(); + const { pdfDocument, pdfBaselineAnnotations } = deps.state(); + 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(); + deps.persistAnnotations(); // diff is now empty → setDirty(false) + deps.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(); + deps.persistAnnotations(); + deps.renderPage(); + renderAnnotationPanel(); +} + +// ============================================================================= +// Init +// ============================================================================= + +export function initAnnotationPanel(panelDeps: PanelDeps): void { + deps = panelDeps; + + // 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 */ + } + deps.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(); +} diff --git a/examples/pdf-server/src/commands.ts b/examples/pdf-server/src/commands.ts new file mode 100644 index 00000000..fa657ca8 --- /dev/null +++ b/examples/pdf-server/src/commands.ts @@ -0,0 +1,68 @@ +/** + * PdfCommand — the wire protocol between server and viewer. + * + * The server enqueues these via the `interact` tool; the viewer polls + * `poll_pdf_commands` and receives them as `structuredContent.commands`. + * + * This file is the single source of truth for the command shape. Both + * `server.ts` (which enqueues) and `mcp-app.ts` (which consumes) import + * from here — a new command variant must be added exactly once. + * + * Uses `import type` so neither side pulls pdf-lib into its bundle. + */ + +import type { PdfAnnotationDef } from "./pdf-annotations.js"; + +/** Single form field assignment, as sent by `fill_form`. */ +export interface FormFieldFill { + name: string; + value: string | boolean; +} + +/** + * Partial annotation update: `id` + `type` pin the target, everything + * else is optional. Server validates shape; viewer merges into existing. + */ +export type PdfAnnotationPatch = Partial & { + id: string; + type: PdfAnnotationDef["type"]; +}; + +/** Page range for text/screenshot extraction. Omitted bound = open-ended. */ +export interface PageInterval { + start?: number; + end?: number; +} + +/** + * Commands the server can send to the viewer via the poll queue. + * Adding a variant here means adding a `case` in both the server's + * `interact` handler (to enqueue) and the viewer's `processCommands` + * (to execute). + */ +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: PdfAnnotationDef[] } + | { type: "update_annotations"; annotations: PdfAnnotationPatch[] } + | { type: "remove_annotations"; ids: string[] } + | { + type: "highlight_text"; + id: string; + query: string; + page?: number; + color?: string; + content?: string; + } + | { type: "fill_form"; fields: FormFieldFill[] } + | { + type: "get_pages"; + requestId: string; + intervals: PageInterval[]; + getText: boolean; + getScreenshots: boolean; + } + | { type: "file_changed"; mtimeMs: number }; diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index 77e3757e..b3075946 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)); } @@ -223,7 +220,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; @@ -233,7 +231,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; } @@ -319,6 +319,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 */ @@ -414,6 +418,361 @@ 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: 100; /* Must float above all sibling annotation elements */ + pointer-events: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} +.annotation-note:hover .annotation-tooltip { + display: block; +} + +/* When hovering a note, raise the entire annotation layer above the form + layer so the tooltip isn't clipped by the z-index:4 form overlay. */ +.annotation-layer:has(.annotation-note:hover) { + z-index: 5; +} + +.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; @@ -448,6 +807,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: 500px; + min-width: 180px; + 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; @@ -475,6 +1174,7 @@ body { stroke: #e74c3c; } + /* Compact toolbar on narrow screens */ @media (max-width: 480px) { .toolbar-left { @@ -505,4 +1205,4 @@ body { .toolbar-right { gap: 0.2rem; } -} +} \ No newline at end of file diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 8b06a36c..c8281c03 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -15,23 +15,114 @@ 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 { + type TrackedAnnotation, + type EditEntry, + annotationMap, + formFieldValues, + pdfBaselineFormValues, + selectedAnnotationIds, + fieldNameToIds, + fieldNameToPage, + fieldNameToLabel, + fieldNameToOrder, + undoStack, + redoStack, + searchBarEl, + formLayerEl, +} from "./viewer-state.js"; +import { + panelState, + annotationsPanelEl, + annotationsPanelListEl, + renderAnnotationPanel, + updateAnnotationsBadge, + setAnnotationPanelOpen, + applyFloatingPanelPosition, + autoDockPanel, + initAnnotationPanel, + syncSidebarSelection, + getFormFieldLabel, + getAnnotationLabel, + getAnnotationPreview, + getAnnotationColor, +} from "./annotation-panel.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]"), }; +/** + * Resolve an ImageAnnotation to a src string safe for ``. + * Returns the parsed-and-reserialized URL (`URL.href`) rather than the + * raw input so CodeQL's taint tracker recognises the sanitisation barrier + * (js/xss, js/client-side-unvalidated-url-redirection). Blocks + * `javascript:` / `vbscript:` etc. The server normally resolves + * imageUrl → imageData before enqueueing; the imageUrl branch here is + * defense-in-depth for the server-side fetch-failure fallback. + */ +function safeImageSrc(def: { + imageData?: string; + mimeType?: string; + imageUrl?: string; +}): string | undefined { + if (def.imageData) { + return `data:${def.mimeType || "image/png"};base64,${def.imageData}`; + } + if (!def.imageUrl) return undefined; + try { + const parsed = new URL(def.imageUrl, document.baseURI); + if ( + parsed.protocol === "https:" || + parsed.protocol === "http:" || + parsed.protocol === "data:" || + parsed.protocol === "blob:" + ) { + return parsed.href; + } + } catch { + // fall through + } + return undefined; +} + // State let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null; let currentPage = 1; @@ -40,8 +131,45 @@ 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; +// Shared annotation state (annotationMap, formFieldValues, selectedAnnotationIds, +// undoStack, redoStack, fieldNameTo*) lives in ./viewer-state.ts — imported above. + +/** 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[] = []; + +// 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; + +let focusedFieldName: string | null = null; + +// 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(); +// 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")!; @@ -65,7 +193,7 @@ const fullscreenBtn = document.getElementById( ) as HTMLButtonElement; const searchBtn = document.getElementById("search-btn") as HTMLButtonElement; searchBtn.innerHTML = ``; -const searchBarEl = document.getElementById("search-bar")!; +// searchBarEl → imported from ./viewer-state.js const searchInputEl = document.getElementById( "search-input", ) as HTMLInputElement; @@ -80,6 +208,21 @@ const searchCloseBtn = document.getElementById( "search-close-btn", ) as HTMLButtonElement; const highlightLayerEl = document.getElementById("highlight-layer")!; +const annotationLayerEl = document.getElementById("annotation-layer")!; +// formLayerEl → imported from ./viewer-state.js +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 & state → ./annotation-panel.ts (imported above) // Search state interface SearchMatch { @@ -178,7 +321,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 --- @@ -224,6 +370,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() { @@ -355,6 +541,9 @@ function openSearch() { searchBarEl.style.display = "flex"; updateSearchUI(); searchInputEl.focus(); + if (panelState.open && annotationsPanelEl.classList.contains("floating")) { + applyFloatingPanelPosition(); + } // Text extraction is handled by the background preloader } @@ -362,6 +551,9 @@ function closeSearch() { if (!searchOpen) return; searchOpen = false; searchBarEl.style.display = "none"; + if (panelState.open && annotationsPanelEl.classList.contains("floating")) { + applyFloatingPanelPosition(); + } searchQuery = ""; searchInputEl.value = ""; allMatches = []; @@ -432,10 +624,156 @@ 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; +} + +/** + * Debug overlay: fixed-position bubble, bottom-left. Pretty-printed JSON + * dump of whatever the server stuffed into `_meta._debug`. Tooltips inside + * sandboxed iframes are unreliable; this survives the cross-origin barrier + * and shows up in screenshots. + */ +function showDebugBubble(debug: unknown): void { + const bubble = document.createElement("div"); + const base = + "position:fixed;bottom:8px;left:8px;z-index:99999;" + + "background:rgba(20,20,30,0.92);color:#cfe;padding:8px 12px;" + + "font:11px/1.4 monospace;border-radius:6px;" + + "box-shadow:0 2px 8px rgba(0,0,0,0.4);white-space:pre;cursor:pointer;" + + "transition:max-width 0.15s ease;"; + // Collapsed: clip to 60vw. Hover: expand to fit full paths (up to ~96vw), + // scrollable both axes in case the JSON is tall. + const collapsed = + base + + "max-width:60vw;max-height:40vh;overflow:hidden;text-overflow:ellipsis;"; + const expanded = + base + "max-width:calc(100vw - 32px);max-height:80vh;overflow:auto;"; + bubble.style.cssText = collapsed; + // Latch expanded on click so hover-collapse doesn't fight text selection. + let pinned = false; + bubble.onmouseenter = () => { + bubble.style.cssText = expanded; + }; + bubble.onmouseleave = () => { + if (!pinned) bubble.style.cssText = collapsed; + }; + bubble.onclick = () => { + pinned = true; + bubble.style.cssText = expanded; + }; + bubble.ondblclick = () => bubble.remove(); + bubble.title = "Click: pin open • Double-click: dismiss"; + bubble.textContent = "🐞 " + JSON.stringify(debug, null, 2); + document.body.appendChild(bubble); +} + 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 }); @@ -541,6 +879,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; @@ -577,15 +959,79 @@ 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}\n\nPage content:\n${content}`; + 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 }]; @@ -593,43 +1039,1847 @@ async function updatePageContext() { // 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), + // 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) { + panelState.openAccordionSection = `page-${tracked.def.page}`; + } + } + + // Re-render the panel so accordion sections open/close to match selection + renderAnnotationPanel(); + + // Scroll the selected card into view in the sidebar + if (id) { + const card = annotationsPanelListEl.querySelector( + `.annotation-card[data-annotation-id="${id}"]`, + ); + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + + // Sync sidebar + syncSidebarSelection(); + // Auto-dock floating panel away from selected annotation + if ( + selectedAnnotationIds.size > 0 && + annotationsPanelEl.classList.contains("floating") && + panelState.open + ) { + autoDockPanel(); + } + // Update model context with selection info + updatePageContext(); +} + +/** 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; + void app + .sendMessage({ + role: "user", + content: [{ type: "text", text: `update ${desc}: ` }], + }) + .catch(log.error); + }); +} + +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, ); - 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 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 = safeImageSrc(def); + 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 = safeImageSrc(def); + 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 → extracted to ./annotation-panel.ts +// ============================================================================= + +// ============================================================================= +// 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 []; + + // Text exists in the cache but the text-layer DOM for this page isn't + // rendered yet — we can't compute accurate rects. Returning a placeholder + // would persist wrong coordinates; return empty and let the caller skip. + return []; +} + +// ============================================================================= +// 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: [] }); } + } else if (ann.annotationType !== 20) { + // Widget (type 20) is expected to be skipped; anything else we + // don't import will still be painted by page.render() onto the + // canvas as unselectable pixels. Log so we can diagnose + // "ghost annotations" (visible but not in panel, not clickable). + log.info( + `[WARN] Baseline: skipped PDF annotation on page ${pageNum}`, + `type=${ann.annotationType}`, + `subtype=${ann.subtype ?? "?"}`, + `name=${ann.name ?? "?"}`, + `rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`, + ); + } + } + } catch (err) { + // Log the error — a thrown import for one annotation silently + // drops the REST of that page's annotations too. + log.info( + `[WARN] Baseline: page ${pageNum} annotation import failed:`, + err, + ); + } + } + log.info( + `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. The loop below is add-only, so we MUST also + // delete: loadBaselineAnnotations() runs between the two restore calls + // and re-seeds annotationMap with every baseline id — including the + // ones in diff.removed. Without this, the zombie survives the restore, + // and the next persistAnnotations() sees it in currentIds → computeDiff + // produces removed=[] → the deletion is permanently lost from storage. + const merged = mergeAnnotations(pdfBaselineAnnotations, diff); + for (const def of merged) { + if (!annotationMap.has(def.id)) { + annotationMap.set(def.id, { def, elements: [] }); + } + } + for (const id of diff.removed) { + annotationMap.delete(id); + } + + // 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); + } + } + + // buildAnnotatedPdfBytes gates on formFields.size > 0 and only writes + // entries present in the map. After clearAllItems() the map is empty → + // zero setText/uncheck calls → pdf-lib leaves original /V intact → + // the "stripped PDF" we promised keeps all its form data. To actually + // clear, send an explicit sentinel for every baseline field the user + // dropped: "" for text, false for checkbox (matching baseline type). + const formFieldsOut = new Map(formFieldValues); + for (const [name, baselineValue] of pdfBaselineFormValues) { + if (!formFieldsOut.has(name)) { + formFieldsOut.set(name, typeof baselineValue === "boolean" ? false : ""); + } + } + return buildAnnotatedPdfBytes( + fullBytes as Uint8Array, + annotations, + formFieldsOut, + ); +} + +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; + + saveInProgress = true; + saveBtn.disabled = true; + saveBtn.title = "Saving..."; + + 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; + + // Rebase: the file on disk now contains our annotations + form values. + // Update the baseline so future diffs are relative to what was saved. + pdfBaselineAnnotations = [...annotationMap.values()].map((t) => ({ + ...t.def, + })); + pdfBaselineFormValues.clear(); + for (const [k, v] of formFieldValues) pdfBaselineFormValues.set(k, v); + + 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"; } } @@ -728,15 +2978,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