diff --git a/examples/basic-server-react/grid-cell.png b/examples/basic-server-react/grid-cell.png index e6a309d7..2136c161 100644 Binary files a/examples/basic-server-react/grid-cell.png and b/examples/basic-server-react/grid-cell.png differ diff --git a/examples/basic-server-react/screenshot.png b/examples/basic-server-react/screenshot.png index 31df3010..b9a9245a 100644 Binary files a/examples/basic-server-react/screenshot.png and b/examples/basic-server-react/screenshot.png differ diff --git a/examples/budget-allocator-server/grid-cell.png b/examples/budget-allocator-server/grid-cell.png index dee5a313..b2019ff0 100644 Binary files a/examples/budget-allocator-server/grid-cell.png and b/examples/budget-allocator-server/grid-cell.png differ diff --git a/examples/budget-allocator-server/screenshot.png b/examples/budget-allocator-server/screenshot.png index 1bd9783c..b50b9ac9 100644 Binary files a/examples/budget-allocator-server/screenshot.png and b/examples/budget-allocator-server/screenshot.png differ diff --git a/examples/cohort-heatmap-server/grid-cell.png b/examples/cohort-heatmap-server/grid-cell.png index 4349361f..21d188e2 100644 Binary files a/examples/cohort-heatmap-server/grid-cell.png and b/examples/cohort-heatmap-server/grid-cell.png differ diff --git a/examples/cohort-heatmap-server/screenshot.png b/examples/cohort-heatmap-server/screenshot.png index e469da81..21d9ecd8 100644 Binary files a/examples/cohort-heatmap-server/screenshot.png and b/examples/cohort-heatmap-server/screenshot.png differ diff --git a/examples/customer-segmentation-server/grid-cell.png b/examples/customer-segmentation-server/grid-cell.png index 992f95b7..70b2edb6 100644 Binary files a/examples/customer-segmentation-server/grid-cell.png and b/examples/customer-segmentation-server/grid-cell.png differ diff --git a/examples/customer-segmentation-server/screenshot.png b/examples/customer-segmentation-server/screenshot.png index e643013d..f75c8893 100644 Binary files a/examples/customer-segmentation-server/screenshot.png and b/examples/customer-segmentation-server/screenshot.png differ diff --git a/examples/debug-server/grid-cell.png b/examples/debug-server/grid-cell.png index 2f1814f7..fe8d0647 100644 Binary files a/examples/debug-server/grid-cell.png and b/examples/debug-server/grid-cell.png differ diff --git a/examples/debug-server/screenshot.png b/examples/debug-server/screenshot.png index 869fec08..4be854d9 100644 Binary files a/examples/debug-server/screenshot.png and b/examples/debug-server/screenshot.png differ diff --git a/examples/map-server/grid-cell.png b/examples/map-server/grid-cell.png index 41ebc224..f680d997 100644 Binary files a/examples/map-server/grid-cell.png and b/examples/map-server/grid-cell.png differ diff --git a/examples/map-server/screenshot.png b/examples/map-server/screenshot.png index 97c91912..edd01512 100644 Binary files a/examples/map-server/screenshot.png and b/examples/map-server/screenshot.png differ diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 5b518d5f..b68fe529 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -174,34 +174,146 @@ When roots are ignored the server logs: ## Tools -| Tool | Visibility | Purpose | -| ---------------- | ---------- | -------------------------------------- | -| `list_pdfs` | Model | List available local files and origins | -| `display_pdf` | Model + UI | Display interactive viewer | -| `read_pdf_bytes` | App only | Stream PDF data in chunks | +| Tool | Visibility | Purpose | +| ---------------- | ---------- | ----------------------------------------------------- | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `interact` | Model | Navigate, annotate, search, extract pages, fill forms | +| `read_pdf_bytes` | App only | Stream PDF data in chunks | +| `save_pdf` | App only | Save annotated PDF back to local file | + +## Example Prompts + +After the model calls `display_pdf`, it receives the `viewUUID` and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features: + +### Annotating + +> **User:** Show me the Attention Is All You Need paper +> +> _Model calls `display_pdf` → viewer opens_ +> +> **User:** Highlight the title and add an APPROVED stamp on the first page. +> +> _Model calls `interact` with `highlight_text` for the title and `add_annotations` with a stamp_ + +> **User:** Can you annotate this PDF? Mark important sections for me. +> +> _Model calls `interact` with `get_pages` to read content first, then `add_annotations` with highlights/notes_ + +> **User:** Add a note on page 1 saying "Key contribution" at position (200, 500), and highlight the abstract. +> +> _Model calls `interact` with `add_annotations` containing a `note` and either `highlight_text` or a `highlight` annotation_ + +### Navigation & Search + +> **User:** Search for "self-attention" in the paper. +> +> _Model calls `interact` with action `search`, query `"self-attention"`_ + +> **User:** Go to page 5. +> +> _Model calls `interact` with action `navigate`, page `5`_ + +### Page Extraction + +> **User:** Give me the text of pages 1–3. +> +> _Model calls `interact` with action `get_pages`, intervals `[{start:1, end:3}]`, getText `true`_ + +> **User:** Take a screenshot of the first page. +> +> _Model calls `interact` with action `get_pages`, intervals `[{start:1, end:1}]`, getScreenshots `true`_ + +### Stamps & Form Filling + +> **User:** Stamp this document as CONFIDENTIAL on every page. +> +> _Model calls `interact` with `add_annotations` containing `stamp` annotations on each page_ + +> **User:** Fill in the "Name" field with "Alice" and "Date" with "2026-02-26". +> +> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_ + +## Testing + +### E2E Tests (Playwright) + +```bash +# Run annotation E2E tests (renders annotations in a real browser) +npx playwright test tests/e2e/pdf-annotations.spec.ts + +# Run all PDF server tests +npx playwright test -g "PDF Server" +``` + +### API Prompt Discovery Tests + +These tests verify that Claude can discover and use annotation capabilities by calling the Anthropic Messages API with the tool schemas. They are **disabled by default** — skipped unless `ANTHROPIC_API_KEY` is set: + +```bash +ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts +``` + +The API tests simulate a conversation where `display_pdf` has already been called, then send a follow-up user message and verify the model uses annotation actions (or at least the `interact` tool). Three scenarios are tested: + +| Scenario | User prompt | Expected model behavior | +| -------------------- | ----------------------------------------------------------------- | ------------------------------------------ | +| Direct annotation | "Highlight the title and add an APPROVED stamp" | Uses `highlight_text` or `add_annotations` | +| Capability discovery | "Can you annotate this PDF?" | Uses interact or mentions annotations | +| Specific notes | "Add a note saying 'Key contribution' and highlight the abstract" | Uses `interact` tool | ## Architecture ``` -server.ts # MCP server + tools -main.ts # CLI entry point +server.ts # MCP server + tools +main.ts # CLI entry point src/ -└── mcp-app.ts # Interactive viewer UI (PDF.js) +├── mcp-app.ts # Interactive viewer UI (PDF.js) +├── pdf-annotations.ts # Annotation types, diff model, PDF import/export +└── pdf-annotations.test.ts # Unit tests for annotation module ``` ## Key Patterns Shown -| Pattern | Implementation | -| ----------------- | ------------------------------------------- | -| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | -| Chunked responses | `hasMore` + `offset` pagination | -| Model context | `app.updateModelContext()` | -| Display modes | `app.requestDisplayMode()` | -| External links | `app.openLink()` | -| View persistence | `viewUUID` + localStorage | -| Theming | `applyDocumentTheme()` + CSS `light-dark()` | +| Pattern | Implementation | +| ----------------------------- | -------------------------------------------------------------- | +| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | +| Chunked responses | `hasMore` + `offset` pagination | +| Model context | `app.updateModelContext()` | +| Display modes | `app.requestDisplayMode()` | +| External links | `app.openLink()` | +| View persistence | `viewUUID` + localStorage | +| Theming | `applyDocumentTheme()` + CSS `light-dark()` | +| Annotations | DOM overlays synced with proper PDF annotation dicts | +| Annotation import | Load existing PDF annotations via PDF.js `getAnnotations()` | +| Diff-based persistence | localStorage stores only additions/removals vs PDF baseline | +| Proper PDF export | pdf-lib low-level API creates real `/Type /Annot` dictionaries | +| Save to file | App-only `save_pdf` tool writes annotated bytes back to disk | +| Dirty flag | `*` prefix on title when unsaved local changes exist | +| Command queue | Server enqueues → client polls + processes | +| File download | `app.downloadFile()` for annotated PDF | +| Floating panel with anchoring | Magnetic corner-snapping panel for annotation list | +| Drag, resize, rotate | Interactive annotation handles with undo/redo | +| Keyboard shortcuts | Ctrl+Z/Y (undo/redo), Ctrl+S (save), Ctrl+F (search), ⌘Enter | + +### Annotation Types + +Supported annotation types (synced with PDF.js): + +| Type | Properties | PDF Subtype | +| --------------- | ------------------------------------------- | ------------ | +| `highlight` | `rects`, `color?`, `content?` | `/Highlight` | +| `underline` | `rects`, `color?` | `/Underline` | +| `strikethrough` | `rects`, `color?` | `/StrikeOut` | +| `note` | `x`, `y`, `content`, `color?` | `/Text` | +| `rectangle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Square` | +| `circle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Circle` | +| `line` | `x1`, `y1`, `x2`, `y2`, `color?` | `/Line` | +| `freetext` | `x`, `y`, `content`, `fontSize?`, `color?` | `/FreeText` | +| `stamp` | `x`, `y`, `label`, `color?`, `rotation?` | `/Stamp` | ## Dependencies -- `pdfjs-dist`: PDF rendering (frontend only) +- `pdfjs-dist`: PDF rendering and annotation import (frontend only) +- `pdf-lib`: Client-side PDF modification — creates proper PDF annotation dictionaries for export - `@modelcontextprotocol/ext-apps`: MCP Apps SDK diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png index 5acd2227..0e6e571d 100644 Binary files a/examples/pdf-server/grid-cell.png and b/examples/pdf-server/grid-cell.png differ diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index 2ff4979f..c637f00c 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -153,9 +153,17 @@ async function main() { if (stdio) { // stdio → client is local (e.g. Claude Desktop), roots are safe - await startStdioServer(() => createServer({ useClientRoots: true })); + await startStdioServer(() => + createServer({ enableInteract: true, useClientRoots: true }), + ); } else { // HTTP → client is remote, only honour roots with explicit opt-in + if (!useClientRoots) { + console.error( + "[pdf-server] Client roots are ignored (default for remote transports). " + + "Pass --use-client-roots to allow the client to expose local directories.", + ); + } await startStreamableHTTPServer(() => createServer({ useClientRoots })); } } diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html index c18345d2..cf1545a7 100644 --- a/examples/pdf-server/mcp-app.html +++ b/examples/pdf-server/mcp-app.html @@ -19,6 +19,16 @@

An error occurred

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