From 296b5921fb7e266fdd8919b507df18707a38527f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 19 Mar 2026 17:41:55 +0000 Subject: [PATCH] pdf-server: annotations, interact tool, page extraction & prompt engineering (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pdf-server: add interact tool to allow model to modify existing view * pdf-server: add annotations, download, form fill, and highlight_text Add PDF annotation system with 7 annotation types (highlight, underline, strikethrough, note, rectangle, freetext, stamp), text-based highlighting, form filling, and annotated PDF download using pdf-lib. - Server: annotation Zod schemas, extended interact tool with add/update/remove annotations, highlight_text, and fill_form actions - Client: annotation layer rendering with PDF coordinate conversion, persistence via localStorage (using toolInfo.id key), pdf-lib-based download with embedded annotations and form fills, uses app.downloadFile() SDK with fallback - Model context includes annotation summary Co-Authored-By: Claude Opus 4.6 * pdf-server: add get_pages tool for batch text/screenshot extraction New tool `get_pages` lets the model get text and/or screenshots from arbitrary page ranges without navigating the visible viewer. - Server: `get_pages` tool with interval-based page ranges (optional start/end, open ranges supported), `getText`/`getScreenshots` flags, request-response bridge via `submit_page_data` app-only tool - Client: offscreen rendering (hidden canvas, no visual interference), text from cache or on-demand extraction, screenshots scaled to 768px max dimension, results submitted back to server - Max 20 pages per request, 60s timeout Co-Authored-By: Claude Opus 4.6 * pdf-server: move get_pages into interact tool as an action Fold get_pages into the interact tool to minimize tools requiring approval. Now accessed via `interact(action: "get_pages", ...)`. Co-Authored-By: Claude Opus 4.6 * pdf-server: improve interact tool annotation discoverability - Add concrete per-type schema docs with field names in tool description - Add JSON example showing add_annotations with highlight + stamp - Replace opaque z.record(z.string(), z.unknown()) with typed union of all annotation schemas (full + partial forms) so the model sees exact field names and types - Remove redundant manual safeParse since Zod inputSchema validates Co-Authored-By: Claude Opus 4.6 * pdf-server: improve annotation discoverability + add E2E tests - display_pdf result text now explicitly lists annotation capabilities (highlights, stamps, notes, etc.) instead of vague "navigate, search, zoom, etc." - Restructured interact tool description: annotations promoted to top, with clear type reference, JSON example, and bold section headers - Added pdf-annotations.spec.ts with 6 E2E tests covering: - Result text mentions annotation capabilities - interact tool available in dropdown - add_annotations renders highlight - Multiple annotation types render (highlight, note, stamp, freetext, rectangle) - remove_annotations removes from DOM - highlight_text finds and highlights text Co-Authored-By: Claude Opus 4.6 * pdf-server: add Claude API prompt discovery tests for annotations Tests that Claude can discover and use PDF annotation capabilities by calling the Anthropic Messages API with the tool schemas and simulated display_pdf result. Disabled by default — skipped unless ANTHROPIC_API_KEY is set: ANTHROPIC_API_KEY=sk-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts 3 scenarios tested: - Model uses highlight_text when asked to highlight the title - Model discovers annotation capabilities when asked "can you annotate?" - Model uses interact (add_annotations or get_pages) when asked to add notes Co-Authored-By: Claude Opus 4.6 * pdf-server: add example prompts, testing docs, and updated tools table to README - Example prompts for annotations, navigation, page extraction, stamps, forms - Documents how to run E2E tests and API prompt discovery tests - Updated tools table to include interact tool - Updated key patterns table with annotations, command queue, file download - Added pdf-lib to dependencies list Co-Authored-By: Claude Opus 4.6 * pdf-server: simplify interact annotations schema (7802 → 2239 chars) The typed Zod union (14 anyOf variants: 7 full + 7 partial annotation types) produced a 5,817-char JSON schema for the annotations field alone. This bloated the interact tool schema to 7,802 chars, which may cause the model to struggle with or skip the tool. Replace with z.record(z.string(), z.any()) — annotation types are already fully documented in the tool description. Schema drops to 2,239 chars (71% reduction), annotations field to 254 chars (96% reduction). Co-Authored-By: Claude Opus 4.6 * pdf-server: enumerate all interact actions in display_pdf result The display_pdf result text now lists every action by name (navigate, search, find, search_navigate, zoom, add_annotations, update_annotations, remove_annotations, highlight_text, fill_form, get_pages) so the model knows exactly what commands are available without needing to inspect the interact tool schema. Co-Authored-By: Claude Opus 4.6 * pdf-server: validate viewUUID in interact, clarify in description The model was passing "pdf-viewer" instead of the actual UUID, causing get_pages to timeout (commands queued under wrong key, client never picks them up). - Add activeViewUUIDs set tracking UUIDs issued by display_pdf - Validate viewUUID at the top of interact handler with clear error - Add "IMPORTANT: viewUUID must be the exact UUID returned by display_pdf" to the interact tool description Co-Authored-By: Claude Opus 4.6 * pdf-server: fix annotation clickability and replace emoji icon with SVG - Raise annotation-layer z-index above text-layer so note annotations receive hover/click events (was z-index: 1, now 3; text-layer is 2) - Replace memo emoji (data-icon attr) with CSS mask SVG document icon that respects currentColor for consistent cross-platform rendering * pdf-server: add annotation side panel with bidirectional linking Right-side panel (250px) shows all annotations grouped by page with expand/collapse cards. Clicking a card navigates to the page and pulses the annotation; clicking a note icon on the PDF highlights its card in the panel. Panel auto-shows on first annotation, remembers user toggle preference via localStorage, and shows a badge count when collapsed. Co-Authored-By: Claude Opus 4.6 * pdf-server: replace 300ms interval polling with long-polling Server now holds poll_pdf_commands requests open (up to 30s) until commands arrive, waking waiters via enqueueCommand. Client loops sequentially instead of using setInterval, with 2s backoff on errors. Reduces idle RPC traffic from ~3 calls/sec to ~2 calls/min. Co-Authored-By: Claude Opus 4.6 * pdf-server: add interactive form filling via PDF.js AnnotationLayer Render PDF.js AnnotationLayer with renderForms:true so form fields appear as interactive HTML inputs. Build a fieldName→annotationID map via getFieldObjects() to bridge fill_form (which uses field names) with annotationStorage (which uses annotation IDs). Sync user input back to formFieldValues for persistence and PDF download. Co-Authored-By: Claude Opus 4.6 * pdf-server: add elicit_form_inputs to display_pdf for upfront form filling When enabled and the client supports elicitation, extracts form fields from the PDF via pdf-lib and prompts the user to fill them before the viewer loads. Elicited values are returned in content/structuredContent and enqueued as a fill_form command for the viewer. Co-Authored-By: Claude Opus 4.6 * pdf-server: replace pdf-lib with pdfjs-dist for server-side form field extraction Use pdfjs-dist's getDocument() + getFieldObjects() instead of pdf-lib's PDFDocument.load() + getForm().getFields() in extractFormSchema(). This removes the pdf-lib import from the server bundle (pdf-lib is still used client-side for PDF modification in downloadAnnotatedPdf). Uses the legacy pdfjs-dist build to avoid DOMMatrix dependency in Node.js. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix form field font size and programmatic fill_form Set --scale-factor/--total-scale-factor CSS variables on the form layer so AnnotationLayer font-size rules resolve correctly instead of falling back to browser defaults. Also update live DOM elements directly in fill_form handler so values appear immediately without waiting for a full re-render. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix listbox font size overflow in AnnotationLayer After rendering the annotation layer, shrink select[size] font to fit within the PDF rect height. The default AnnotationLayer CSS uses a fixed 9px * scale-factor which overflows when many options share a small rect. Co-Authored-By: Claude Opus 4.6 * pdf-server: add trash icon to annotation sidebar cards Show a delete button on each annotation card that appears on hover. Clicking it removes the annotation from the PDF and persists the change. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix space key captured in form fields Skip keyboard navigation shortcuts (space, arrows, +/-) when any input, textarea, or select element is focused, not just the search/page inputs. Co-Authored-By: Claude Opus 4.6 * pdf-server: show form fields in annotation sidebar with clear buttons Form field values now appear in the sidebar under a "Form Fields" group, with trash icons to clear individual values. The badge count and auto-show logic include form fields. The panel open/close now calls requestFitToContent with width to avoid overflow in inline layout mode. Co-Authored-By: Claude Opus 4.6 * pdf-server: clarify Y coordinate system in interact tool description Explicitly state that Y=0 is the bottom edge and Y=792 is the top for US Letter, with concrete guidance on typical values for top/bottom placement to prevent models from using top-down screen coordinates. Co-Authored-By: Claude Opus 4.6 * pdf-server: validate fill_form field names and report valid fields Extract form field names during display_pdf and cache per viewer UUID. fill_form now soft-fails on unknown field names (applies valid ones, reports skipped ones). Both display_pdf and fill_form results include the list of valid field names so the model can self-correct. Co-Authored-By: Claude Opus 4.6 * pdf-server: add compact annotation strip for inline mode In inline mode, replace the 250px side panel with a slim bottom strip that shows one annotation/form-field at a time with prev/next navigation. The side panel is still used in fullscreen mode. - Strip shows item swatch, label, preview + counter (N of M · Page P) - Click item to navigate to its page; delete single or clear all - requestFitToContent includes strip height in size calculation - fieldNameToPage map built during init for form field page context - Display mode toggle switches between strip and panel automatically * pdf-server: improve display_pdf description for form/annotation use cases Add explicit guidance on when to use the tool: filling out forms (tax forms, applications), annotating PDFs, and interactive review. This helps models route user requests like 'help me fill out this form' to the display_pdf tool. * pdf-server: only show download button when host supports downloadFile * pdf-server: resizable sidebar + human-readable form field labels - Sidebar panel is now resizable by dragging its left edge (150px min, 50vw max). Width is persisted in localStorage. - Form fields show human-readable labels from PDF's TU (alternativeText) field when available. Mechanical names like ').F1_01[0]' are replaced with the generic label 'Field' when no TU text exists. - Empty values are no longer shown in the sidebar. * pdf-server: fix field label collection to use getAnnotations() API getFieldObjects() doesn't include alternativeText. Instead, iterate per-page annotations (only pages with known form fields) to collect the TU/alternativeText labels that PDF authors set as tooltips. * pdf-server: move strip below toolbar + follow form field focus - Strip is now positioned below the toolbar (above search bar) instead of at the bottom, for better visibility and proximity to the toolbar. - Form field focus changes automatically sync the strip to show the focused field's item. - Search bar top offset adjusts dynamically when strip is shown/hidden. * pdf-server: add Clear all to fullscreen sidebar + steer model to reuse views - Add 'Clear all' button in the fullscreen sidebar panel header - Update display_pdf description to instruct models to use the interact tool with existing viewUUID rather than calling display_pdf again for the same PDF (e.g. when user asks to annotate an already-open doc) * pdf-server: add page dimensions to model context + improve coordinate docs - Model context now includes 'Page size: 612x792pt (coordinates: origin at bottom-left, Y increases upward)' so the model knows actual page dimensions when placing annotations. - Interact tool description expanded with clearer coordinate guidance, margin hints, and a tip to use highlight_text for text annotations. - Download button temporarily shown unconditionally (capability check commented out with TODO for re-enabling). * pdf-server: auto-close panel when empty + stronger display_pdf reuse guidance - Sidebar/strip auto-closes when all items are cleared (via delete or Clear all), and the annotations toolbar button hides. - display_pdf description now uses CRITICAL warning to prevent models from re-calling it to add annotations to an already-open PDF. * pdf-server: skip elicitation when form fields have mechanical names Only use elicitation if all editable fields have human-readable names. Fields with brackets, dots, or ALL_CAPS_UNDERSCORE patterns (and no alternativeText label from the PDF) are considered mechanical and would be confusing in an elicitation form. Also use alternativeText as the elicitation field title when available. * pdf-server: paint annotations on screenshots + list coords in model context Screenshots sent to the model (both updateModelContext and get_pages) now include annotations drawn on top of the PDF canvas, so the model can visually verify placement. The text context also lists each annotation on the current page with its coordinates. * pdf-server: style multiselect/listbox form fields to match viewer theme * pdf-server: add command batching to interact tool Extract command processing into a helper function and accept an optional `commands` array for sequential batch execution. This lets the model send multiple actions (e.g. add annotations + get screenshot) in a single tool call, reducing round trips. Stops on first error in a batch. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix multiselect rendering by removing font-size override The custom font-size: inherit on #form-layer select was conflicting with PDF.js AnnotationLayer's inline font-size, causing doubled/overlapping text in select options. Remove the override and keep only the checked-option highlight style. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix form reset button by passing fieldObjects to AnnotationLayer PDF.js AnnotationLayer needs the fieldObjects parameter to bind reset form button actions. Cache the result of doc.getFieldObjects() and pass it through to annotationLayer.render(). Co-Authored-By: Claude Opus 4.6 * pdf-server: improve annotation labels in sidebar and strip Use human-readable labels instead of raw type names (e.g. "Rectangle" instead of "rectangle", "Stamp: APPROVED" instead of "stamp"). Remove redundant preview text for types that have no additional content (rectangle, underline, strikethrough, stamp) to avoid "rectangle Rectangle" duplication. Co-Authored-By: Claude Opus 4.6 * pdf-server: split get_pages into get_text and get_screenshot commands Replace the combined get_pages action (with getText/getScreenshots flags) with two focused commands: - get_text: extract text from pages (single page or intervals) - get_screenshot: capture a single page as PNG image This makes the API cleaner and returns each command's result as its own content block in batch mode. Also fix download function syntax error from earlier capability check commenting. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix download by using pdfDocument.getData() instead of re-fetching The download was failing with "No PDF header found" because it re-fetched the PDF via read_pdf_bytes tool calls, but stateless MCP transport creates fresh server instances per request with empty caches. Using getData() from the already-loaded pdf.js document avoids the round-trip entirely. Co-Authored-By: Claude Opus 4.6 * pdf-server: increase express body limit to fix get_screenshot hang The submit_page_data tool sends base64 PNG screenshots that exceed the default 100KB express.json() body limit from createMcpExpressApp. This caused the request to silently fail, making waitForPageData timeout and the get_screenshot command hang. Replaced createMcpExpressApp with manual express setup using a 50MB body limit. Co-Authored-By: Claude Opus 4.6 * pdf-server: use JPEG for screenshots to fix get_screenshot hang Switch from PNG to JPEG (quality 0.85) for both offscreen screenshots (get_screenshot) and model context images. JPEG is much smaller than PNG for document pages, keeping the submit_page_data payload well within the MCP SDK's default 100KB express body limit. This also reverts the temporary workaround of replacing createMcpExpressApp with a custom express setup. Co-Authored-By: Claude Opus 4.6 * pdf-server: add ' - edited' suffix to download filename when modified Renames the download from '_annotated' to ' - edited' and also triggers the suffix when form fields have been filled, not just when annotations are present. Co-Authored-By: Claude Opus 4.6 * pdf-server: include viewUUID in display_pdf structuredContent The viewUUID was only in _meta but not in structuredContent, so the model couldn't retrieve it from the tool result to pass to interact. Co-Authored-By: Claude Opus 4.6 * pdf-server: move interact action list from display_pdf content to description The content response was bloated with static instructions about interact actions. Moved that to the tool description where it belongs, keeping only the dynamic PDF URL and viewUUID in the content. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix form reset setting dropdowns to first item PDF.js resetform handler deselects all options but doesn't re-add the hidden empty placeholder, so the browser falls back to showing the first real option. Work around this by listening for resetform on combo selects and re-inserting a hidden blank option when nothing is selected. Co-Authored-By: Claude Opus 4.6 * pdf-server: sort form fields in sidebar/strip by intrinsic PDF order Fields are now sorted by page number then top-to-bottom Y position on the page, matching the visual reading order of the PDF form rather than the arbitrary insertion order of formFieldValues. Co-Authored-By: Claude Opus 4.6 * pdf-server: add viewUUID to display_pdf outputSchema The viewUUID was added to structuredContent but not to the outputSchema, causing MCP validation error -32602. Co-Authored-By: Claude Opus 4.6 * pdf-server: allow horizontal scrolling in inline mode When the PDF is zoomed or wider than the container, users can now scroll horizontally in inline mode instead of the content being clipped. Co-Authored-By: Claude Opus 4.6 * pdf-server: add viewUUID to model context, remove activeViewUUIDs validation Include viewUUID in the updateModelContext header so the model can reference it when calling interact. Remove the activeViewUUIDs set validation since it breaks after server restarts — command queue TTL pruning is sufficient cleanup. Also prune empty queues with no active pollers. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix delete button alignment in sidebar annotation cards Annotations without preview text had the delete button hugging the type label instead of right-aligning. Add margin-left: auto so the button is consistently pushed to the right edge of the row. Co-Authored-By: Claude Opus 4.6 * pdf-server: add interactive annotations (select, drag, resize, rotate, undo/redo) Enable pointer-events on all annotation types so they can be clicked to select. Draggable types (rectangle, freetext, stamp, note) support smooth drag-to-move. Rectangles show corner resize handles, stamps show a rotate handle. Delete/Backspace removes the selected annotation, Ctrl+Z/Shift+Z provides unlimited undo/redo, and Escape deselects. Sidebar cards sync with on-canvas selection state. Co-Authored-By: Claude Opus 4.6 * pdf-server: clicking sidebar card focuses form field input and scrolls into view Sidebar annotation cards now scroll the annotation into view, and form field cards navigate to the correct page and focus the input element. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix form widget double-text by making inputs opaque PDF.js AnnotationLayer renders interactive form inputs with a semi-transparent background, letting the canvas's static form appearance bleed through. Since the widget font (9px * scale, sans-serif) doesn't match the PDF's actual font metrics, this creates a "double text" misalignment at most zoom levels. Make text/choice widgets opaque so only the interactive layer's text is visible. Co-Authored-By: Claude Opus 4.6 * pdf-server: add selection/focus to model context, double-click to send messages, toggle resize/rotate - Model context now includes selected annotation ID and focused form field name so the model knows what the user is interacting with. - Double-clicking a sidebar annotation card sends a message prompting the model to change that annotation. - Double-clicking a sidebar form field card sends a message prompting the model to fill that field. - Double-clicking an annotation in the renderer toggles between resize and rotate handles (for rectangles and stamps). - Rectangles now support rotation via the rotate handle. Co-Authored-By: Claude Opus 4.6 * pdf-server: replace inline-mode strip with floating accordion sidebar Replace the compact horizontal annotation strip with a floating panel at top-right in inline mode. Uses accordion-style collapsible sections (per-page annotations + form fields) with at most one section open at a time. Refactors renderAnnotationPanel into reusable helpers (appendAccordionSection, createAnnotationCard, createFormFieldCard). Co-Authored-By: Claude Opus 4.6 * pdf-server: remove dead annotation strip code The compact horizontal annotation strip is no longer used since inline mode now uses a floating accordion sidebar. Remove all strip-related HTML, CSS, JS (DOM refs, StripItem type, buildStripItems, renderStrip, navigateToStripItem, deleteStripItem, event listeners, stripHeight calculations). Co-Authored-By: Claude Opus 4.6 * pdf-server: multi-select annotations, natural prompts, form reset sync - Convert selection from single ID to Set for multi-select (Shift+click) - Deselect annotations when focusing a form input field - Fix rotation to always use center origin (stamp was using left-bottom) - Simplify double-click sendMessage prompts (e.g. "update Notes: ") - Call updateModelContext before sendMessage to guarantee context is sent - Listen for PDF.js resetform events to clear formFieldValues, update sidebar badge/count, and hide icon when no items remain Co-Authored-By: Claude Opus 4.6 * pdf-server: draggable floating panel with magnetic corner anchoring - Reduce floating panel width (220→180px), add border-radius - Panel header is draggable; on drop, snaps to nearest corner - Persists corner preference to localStorage - Auto-docks to opposite side when overlapping selected annotations - Accordion chevron: ▶ when collapsed, ▼ when expanded - Slightly reduce section header size for compactness Co-Authored-By: Claude Opus 4.6 * pdf-server: allow all accordion sections to be collapsed Only auto-open the first section on initial render. Once the user has interacted with accordion headers, respect their choice to collapse all sections without forcing one back open. Co-Authored-By: Claude Opus 4.6 * pdf-server: polish interactions and UI details - Remove auto-show annotation panel; wait for user to open it - Badge color: blue (accent) instead of red - Search bar: offset floating panel below it when anchored top-right - Text selection deselects annotations and clears field focus - Click on text layer / empty area clears field focus too - Zoom keeps selected annotation visible (scrollIntoView after render) - Fullscreen button: distinct exit icon + tooltip with Esc hint Co-Authored-By: Claude Opus 4.6 * pdf-server: fix text deselect, stamp dblclick, rotation direction, export - Text selection and text-layer mousedown now deselect annotations - Add user-select: none on draggable annotations (prevents stamp text selection on double-click, allowing resize/rotate toggle to work) - Fix rotation direction: remove CSS transform negation so clockwise drag = clockwise rotation on screen - Fix stamp export: rotate around center (matching CSS transformOrigin) and negate rotation for PDF coordinate convention - Add rotation support to rectangle PDF export - Remove auto-show annotation panel; badge uses blue accent color Co-Authored-By: Claude Opus 4.6 * pdf-server: allow any text as stamp label, not just predefined enum The StampLabel was unnecessarily restricted to 6 predefined values (APPROVED, DRAFT, etc). The PDF format supports arbitrary stamp text, so open it up to z.string() while keeping common labels as examples in the schema description. Co-Authored-By: Claude Opus 4.6 * pdf-server: show resize + rotate handles simultaneously, dblclick sends message Remove the interactionMode toggle — instead show both corner resize handles and the rotate handle at the same time on selected annotations. Rectangle gets resize corners + rotate handle; stamp gets rotate handle only. Double-click on annotation elements now sends a message to modify the annotation (matching sidebar card behavior) instead of toggling modes. Co-Authored-By: Claude Opus 4.6 * pdf-server: SVG fullscreen icons, Y-coord docs, dirty flag - Replace Unicode fullscreen chars (⛶/⊠) with proper SVG expand/collapse icons - Add Cmd+Enter keyboard shortcut for fullscreen toggle - Improve coordinate system docs: emphasize rectangle x,y is BOTTOM-LEFT corner, add worked examples - Add isDirty state with * prefix on title for unsaved changes - Track dirty state through persistAnnotations, suppress during restore * pdf-server: diff-based annotation persistence, proper PDF annotation export, PDF annotation import Extract annotation logic into pdf-annotations.ts module with comprehensive tests. Replace content-stream drawing (drawRectangle/drawText) with proper PDF annotation dicts (/Type /Annot) that are editable in Acrobat/Preview. Persistence now stores only a diff (additions/removals) relative to the PDF's native annotations, keeping localStorage small. On load, annotations are imported from the PDF via pdf.js and merged with the user's diff. Co-Authored-By: Claude Opus 4.6 * pdf-server: enhance page shadow for better depth perception Co-Authored-By: Claude Opus 4.6 * pdf-server: use floating panel in fullscreen, respect insets, direction-aware resize Always use the floating annotations panel (both inline and fullscreen) instead of the sidebar in fullscreen mode. Panel anchorage now respects safe area insets (especially bottom). Resize handle repositions to the correct edge based on which corner the panel is anchored to. Initial width reduced from 180px to 135px. Co-Authored-By: Claude Opus 4.6 * pdf-server: add save_pdf tool and save button for local files Add server-side save_pdf tool that writes annotated PDF bytes back to the original local file. Client shows a save button (floppy disk icon) next to the download button for file:// URLs. Ctrl/Cmd+S shortcut saves when dirty. Saving clears the dirty flag and localStorage diff. Co-Authored-By: Claude Opus 4.6 * pdf-server: add circle and line annotation types synced with PDF.js Add circle (ellipse) and line annotation support across all layers: server Zod schemas, pdf-annotations module (import/export), client renderers, canvas painting for screenshots, CSS, drag/resize handling, and tool description documentation. Co-Authored-By: Claude Opus 4.6 * pdf-server: add searchability keywords to display_pdf tool description Include viewer, render, renderer, display, show, annotate, edit, and form keywords so the tool is easier to discover by models. Co-Authored-By: Claude Opus 4.6 * pdf-server: switch model-facing annotation API to top-left origin coordinates Convert Y coordinates at the command boundary (add/update_annotations) so the model uses intuitive top-left origin while internal storage stays in PDF's native bottom-left origin. Also converts coordinates back to model space in context strings for consistency. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix stamp rotation in PDF export via appearance stream matrix Stamp rotation was a no-op (empty if-block with a comment). Now computes a proper rotation matrix around the bounding box center and applies it to the appearance stream. Also adds verification tests for rectangle stroke/fill colors and stamp color/rotation in exported PDF dicts. Co-Authored-By: Claude Opus 4.6 * pdf-server: fix screenshots to include filled form field values Screenshots sent to the model were missing form field values because the canvas render didn't use ENABLE_STORAGE mode. Now both offscreen renders (model context and get_pages) pass annotationStorage to PDF.js so filled fields appear in screenshots. Also reuses renderPageOffscreen for model context screenshots to deduplicate rendering logic. Co-Authored-By: Claude Opus 4.6 * pdf-server: list form fields with page, bounding box, and label in display_pdf result The model needs field locations to fill forms accurately, especially for fields with mechanical names or no name at all. Extracts per-page bounding boxes in model coordinates (top-left origin) and includes labels where available from PDF alternativeText. Co-Authored-By: Claude Opus 4.6 * pdf-server: include full field listing in fill_form result and update UI after fill fill_form results now include all form fields with names, types, pages, and bounding boxes so the model can self-correct on unknown field names. Also updates the annotation panel badge and visibility after programmatic fills. Co-Authored-By: Claude Opus 4.6 * pdf-server: add formFields to display_pdf structuredContent and output schema Programmatic consumers need structured access to form field metadata (names, types, pages, bounding boxes) without parsing text content. Co-Authored-By: Claude Opus 4.6 * pdf-server: add image annotation type with URL/base64 support, drag-drop, and PDF export Co-Authored-By: Claude Opus 4.6 * pdf-server: fix image annotation click/select, add Shift-resize for aspect ratio, add handle tooltips Co-Authored-By: Claude Opus 4.6 * pdf-server: fix image annotation to stretch to fill its bounding box Co-Authored-By: Claude Opus 4.6 * pdf-server: fix stamp PDF export to match on-screen appearance Use non-standard /Name to prevent Preview.app from substituting built-in stamp graphics, align padding with CSS (4pt/12pt), add ExtGState for 0.6 opacity, and fix text baseline positioning. Co-Authored-By: Claude Opus 4.6 * pdf-server: improve annotation panel UX and image resize behavior - Page accordion click navigates to that page - Form fields appear in their page's accordion section (before annotations) - Selecting annotation or focusing form field auto-expands page accordion - Image resize preserves aspect ratio by default (Shift for free resize) - Add aspect field ("preserve"|"ignore") to ImageAnnotation type - Fix selection UI regression: remove overflow:hidden that clipped handles Co-Authored-By: Claude Opus 4.6 * pdf-server: add signature/signing keywords and image example to tool descriptions - Add signing/signature keywords to display_pdf description for discoverability - Add image annotation example (signature placement) to interact tool description - Clarify that imageUrl supports file paths and HTTP URLs with auto-fetch Co-Authored-By: Claude Opus 4.6 * pdf-server: discourage base64 encoding in image annotation, clarify imageUrl accepts file paths Models tend to encode images as base64 themselves instead of passing file paths. Make the schema descriptions and tool docs explicit: prefer imageUrl with a file path, no data: URIs, don't encode images yourself. Co-Authored-By: Claude Opus 4.6 * pdf-server: remove imageData from image annotation API, require imageUrl Models were encoding images as base64 themselves instead of passing file paths. Remove imageData from the model-facing Zod schema entirely — only imageUrl (file path or HTTPS URL) is accepted. The server fetches and embeds the image automatically. imageData remains as an internal field for client-side rendering and drag-drop. Also mention drag-and-drop as an alternative in the tool description. Co-Authored-By: Claude Opus 4.6 * pdf-server: add clipboard support (copy/cut/paste annotations and images) - Request clipboardWrite permission in resource metadata - Ctrl/Cmd+C: copy selected annotations as JSON to clipboard - Ctrl/Cmd+X: cut (copy + delete) selected annotations - Ctrl/Cmd+V / paste event: paste annotations from clipboard JSON, or paste images from clipboard (e.g. screenshots, copied images) - Refactor drag-drop image creation into shared addImageFromFile() - Pasted annotations get new IDs and slight offset to avoid overlap - Pasted images are centered on the current page Co-Authored-By: Claude Opus 4.6 * pdf-server: add disableInteract option for distributed deployments Adds a `disableInteract` boolean to `createServer()` options that skips registering the `interact`, `poll_pdf_commands`, and `submit_page_data` tools (all relying on an in-memory command queue). Adjusts `display_pdf` description and schema accordingly when disabled. Co-Authored-By: Claude Opus 4.6 * pdf-server: flip interact to opt-in, enable only for --stdio Rename disableInteract → enableInteract (default false) so existing library consumers get read-only mode without code changes. The CLI entry point passes enableInteract: true only when running with --stdio. Co-Authored-By: Claude Opus 4.6 * pdf-server: log client-roots warning once at startup, not per request * pdf-server: fix form widget transparency, poll spam, and pdf.js warnings - Form widgets: PDF.js writes inline background-color (from the annotation /BC entry, typically 'transparent'), defeating our CSS. Force opaque with !important. Fixes double-text in listboxes and text inputs at any zoom. - Poll spam: view was calling poll_pdf_commands in a tight loop when interact is disabled (HTTP mode) — server returns isError, which doesn't throw, so the loop never backed off. Server now signals interactEnabled via _meta; view only polls when true, and stops entirely on isError results. - Silence Helvetica / inset-border warnings: set verbosity=ERRORS for server-side getDocument (only used for form introspection, no rendering, so font fallback warnings are noise). * pdf-server: set standardFontDataUrl for server-side pdf.js Resolve pdfjs-dist/standard_fonts/ at runtime via createRequire. Works from both source (bun main.ts) and bundled dist/ since pdfjs-dist is --external. NodeStandardFontDataFactory reads via fs.readFile, so a filesystem path with trailing separator is what's expected. Keep verbosity=ERRORS as backstop for unrelated warnings (e.g. 'Unimplemented border style: inset'). * pdf-server: use unpkg CDN for pdf.js standard fonts, add to CSP - Single CDN URL pinned to the bundled pdfjs-dist version, used in both server (Node) and viewer (browser) - Node: default NodeStandardFontDataFactory uses fs.readFile which can't fetch URLs — pass a minimal FetchStandardFontDataFactory - Browser: DOMStandardFontDataFactory already fetches, just pass the URL - Add https://unpkg.com to CSP connectDomains (pdf.js loads font binaries via fetch(), maps to connect-src) * pdf-server: guard against missing totalBytes in viewer Without this, an invalid totalBytes flows to PDFDataRangeTransport.length → worker ChunkedStream sized at 0 → opaque 'Bad end offset: N' error. The guard turns that into an actionable message (rebuild hint). * pdf-server: re-probe file size at load time, ignore stale totalBytes from history The display_pdf result (including totalBytes) is baked into conversation history. If the user saves the annotated PDF (grows the file) then reloads the conversation, the host replays the stale size. pdf.js initializes its ChunkedStream at that stale length, then the first live read_pdf_bytes returns the current (larger) file — every chunk fails the 'end === bytes.length' check with 'Bad end offset: N'. Fix: drop the fileSizeBytes param and always probe via a live 1-byte read_pdf_bytes call before setting up the range transport. * pdf-server: file watching, save confirmation, disk-change reload Server: - fs.watch on local files when interact is enabled; pushes file_changed via the poll_pdf_commands channel. Re-attaches on rename to survive atomic writes (vim/vscode). Debounced 150ms. Skips unchanged mtime. - save_pdf returns {filePath, mtimeMs} so the saving viewer can recognise its own write's echo. Other viewers on the same file still get notified (their content is genuinely stale). - stopFileWatch in pruneStaleQueues TTL sweep. Viewer: - In-app confirm dialog (no window.confirm — would block the iframe). - Save button: hidden until first edit, enabled while dirty, disabled (but still visible) after save, re-enabled on next edit. - savePdf() asks for confirmation, records lastSavedMtime. - loadGeneration counter invalidates stale fetchChunk results and aborts the preloader when the PDF is reloaded underneath them. - reloadPdf() clears all per-document state (byte cache, annotations, form fields, undo/redo, text caches, field maps, localStorage diff) and loads fresh. - file_changed handler: suppresses own-save echo via saveInProgress + mtime match; auto-reloads when clean; prompts Keep/Discard when dirty. * pdf-server: add file watching tests Covers: external write → file_changed enqueued via poll_pdf_commands; debounced rapid writes; stopFileWatch prevents further events; save_pdf returns mtimeMs; watcher survives atomic rename (vim/vscode pattern). * pdf-server: fix confirm-btn-primary hover, show filename only in save prompt - .confirm-btn:hover's background cascaded to primary buttons too (same specificity, primary has both classes) — turning the blue button light grey on hover. Re-assert background in primary:hover. - Save confirm: extract just the filename from pdfUrl (strip file:// prefix, take basename). * pdf-server: match confirm dialog to host's native style Use host-provided CSS variables (--color-*, --font-*, --border-radius-*, --shadow-*) from applyHostStyleVariables, with local fallbacks. - Larger border-radius (--border-radius-xl), softer shadow (--shadow-lg) - Larger bold title (--font-heading-md-size, --font-weight-bold) - Primary button uses inverse colors (dark bg + light text) like the host's downloadFile dialog, not blue - New .confirm-detail box: monospace bordered rect for filename - Button order: Cancel first, primary last (native convention) - Escape resolves to first non-primary button * pdf-server: derive dirty from diff — undoing all changes clears save button persistAnnotations() was unconditionally setDirty(true). It already computes the diff vs baseline — use isDiffEmpty(diff) to decide instead. Now undoing all the way back to the original state marks the viewer clean again (save button disables, title loses asterisk). * pdf-server: import saved form field values from PDF as baseline getFieldObjects() returns the PDF's stored form values but we were only reading field IDs/pages from it. After saving a filled form and reopening, the panel showed nothing and there was no way to see what was filled. - New pdfBaselineFormValues map, populated in buildFieldNameMap() from each field's .value (skipping empty/Off/button values). Seeds formFieldValues so the panel shows PDF-stored values on open. - computeDiff takes an optional baselineFormFields param and only includes values that differ — opening a filled PDF doesn't mark dirty, editing a field does, reverting to the PDF's value marks clean again. - importFieldValue() normalises radio-group value lookup (parent entry has value=undefined, children have the real export value), checkbox→ true, listbox array→joined string. * pdf-server: fix form Reset button; split panel into Reset vs Clear all Reset-button regression: buildFieldNameMap() now seeds formFieldValues from the PDF's stored values, but syncFormValuesToStorage() was pushing them back into annotationStorage in our normalised repr (checkbox→true, radio→export string). pdf.js's native repr may differ, and overwriting it breaks the form's Reset button. Fix: skip syncing values that match baseline — the PDF's own values are already in storage natively. Panel buttons: - Reset: revert to what's in the PDF file. Restores baseline annotations and form values, clears undo/redo. Empty diff → clean. Disabled when not dirty. - Clear all: removes EVERYTHING including PDF-native items. Non-empty diff (baseline items are 'removed') → dirty; saving writes stripped PDF. Disabled when nothing to clear. * pdf-server: fix Clear all to push defaultValue; gate save on writability Clear all regression: annotationStorage.remove(id) only drops our override — the widget reverts to the PDF's stored /V (the baseline value). To actually clear, push each field's defaultValue (/DV) via setValue instead, which is what the PDF's native Reset button does. Save button visibility: server now checks fs.access(path, W_OK) and reports via _meta.writable. Viewer gates save on that instead of just 'is this a local path'. Hides the button for read-only mounts and Claude Desktop's uploads directory. Removed dead isLocalFileUrl(). * pdf-server: fix annotationStorage keys — use widget annotation IDs Root cause: getFieldObjects() returns field-dictionary refs (the /T tree, e.g. '86R'), but annotationStorage is keyed by WIDGET annotation refs (what page.getAnnotations() returns, e.g. '9R'). These differ when a PDF's field and its widget /Kids are separate objects. fieldNameToIds was built from getFieldObjects().id, so EVERY storage write was keyed wrong and silently no-op'd: - syncFormValuesToStorage: wrote to dead keys - fill_form: setValue + querySelector([data-element-id=...]) both missed - individual field delete: remove() on wrong key - clearAllItems: setValue to wrong keys Fix: build fieldNameToIds from page.getAnnotations() which gives the correct widget IDs. Keep cachedFieldObjects only for type/value/ defaultValue metadata (matched by name, not id) and for passing to AnnotationLayer.render() where pdf.js uses it internally. Also extract clearFieldInStorage() helper — pushes defaultValue for a single field, used by both individual delete and Clear all. * pdf-server: remap fieldObjects IDs to widget IDs for pdf.js Reset pdf.js _bindResetFormAction iterates fieldObjects using each entry's .id to (a) key annotationStorage and (b) querySelector([data-element-id=...]). Both expect WIDGET annotation IDs. fieldObjects contains field-dict IDs. Works only when field and widget share a PDF object — which pdf-lib's save breaks (it splits merged objects on write). This is a latent pdf.js bug, not a regression from our changes — it was broken the moment the user first saved via save_pdf. Fix: rebuild cachedFieldObjects with widget IDs (from fieldNameToIds) before passing to AnnotationLayer.render({fieldObjects}). Skip parent entries with no concrete id (radio group /T tree root). Preserve type/defaultValue/exportValues which Reset needs. * pdf-server: fix combo reset, scope writability, drop flaky CI assert Combo reset: pdf.js's resetform handler sets all option.selected = (option.value === defaultFieldValue); when defaultFieldValue is null nothing matches. Chrome then synchronously normalises the non-multiple without setting .value (pdf.mjs: 18138-18144), so target.value defaults to the HTML spec's 'on'. Our input listener read that → panel showed 'on' after clicking a radio, but baseline import correctly used the export value ('0') — so clicking then reverting showed two different strings for the same selection. Fix: store each radio widget's buttonValue during the annotation scan (we're already there for widget IDs), read it in the input listener via data-element-id. Now baseline, click, revert, and save all agree on the export value. (Form.pdf happens to use '0'/'1' as export values instead of 'Male'/'Female' — that's the form's design; the labels are just PDF text next to the widgets, not form data.) * pdf-server: panel Reset/Clear-all as icon buttons, keep tooltips * pdf-server: file roots from MCP client are read-only allowedLocalFiles conflated two sources: CLI args (user explicitly named the file when starting the server — overwriting is intentional) and MCP file roots (client-uploaded copies in ad-hoc hidden folders that the client doesn't expect to change). New cliLocalFiles set tracks only the CLI-sourced files. Writable now requires: file is in cliLocalFiles OR strictly under a directory root. Directory roots are mounted folders where saving is expected. save_pdf enforces the same scope server-side — the viewer hides the button based on _meta.writable, but we must not trust the client. * pdf-server: file root stays read-only even under a directory root Extracted isWritablePath() with the full policy in one place (was duplicated between display_pdf and save_pdf): - CLI file → writable (user explicitly named it) - MCP file root → read-only, ALWAYS. Even when the path happens to fall inside a mounted directory root — the client sending it as a file root is the stronger signal ('here's an upload') - under a directory root at any depth → writable (mounted folder) - the directory root itself → not writable (rel === '' excluded) - no roots and no CLI files → nothing writable 8 unit tests cover each case. * pdf-server: fix e2e (interact for HTTP test harness), skip rename test on Linux E2E: tests/e2e/pdf-annotations.spec.ts expects 'interact' in the tool dropdown and calls it for annotation tests. Since 3f2c209c interact is stdio-only; e2e runs HTTP basic-host → tool absent → all tests fail (broken since Mar 4). Add --enable-interact CLI flag for HTTP mode and pass it in 'npm run serve'. The in-memory command queue works fine for the e2e setup (one long-lived process); the stdio-only restriction is about stateless multi-instance deployments. Rename test: inotify watches inodes. When the directory entry is atomically swapped to a NEW inode, the watcher on the OLD inode gets no event. kqueue (macOS) watches paths and fires 'rename'. Gate with skipIf(platform !== 'darwin'). * pdf-server: list_pdfs walks directory roots for *.pdf files Recurse into allowedLocalDirs (depth ≤ 8, ≤ 500 files) and enumerate actual .pdf files as file:// URLs alongside the explicitly-registered ones. Skip dotfiles/dirs and node_modules. Dedup (a file may be both explicitly registered and under a walked directory). Report truncation. * pdf-server: fix stale e2e assertion after 2d47f14b Commit 2d47f14b moved the interact action list from display_pdf's content to the tool description, but the e2e test still asserted the result text contained 'add_annotations', 'highlight_text', etc. Now checks for viewUUID / interactEnabled / 'Displaying PDF' which are the actual result contents. * pdf-server: add unpkg to resourceDomains (font-src) for pdf.js FontFace pdf.js in the browser doesn't just fetch() the .ttf bytes — it also creates FontFace('name', 'url(https://unpkg.com/...)') which goes through CSP font-src, not connect-src. Nest's CSP blocked it ('font-src self assets.claude.ai'). pdf.js fell back to system fonts so nothing broke visually, but the CDN URL was doing nothing. (Separate finding re: the 'bricked UI' report — logs show fill_form processed cleanly at 16:27:18, then Nest's router navigated away 13s later. Poll id=17 returned to an already-unmounted iframe. Normal teardown, not a brick.) * pdf-server: validate imageUrl scheme before assignment Fixes CodeQL js/xss + js/client-side-unvalidated-url-redirection on line 1994 (renderImageAnnotation) and the canvas-paint fallback. The server resolves imageUrl to imageData before enqueueing, so def.imageUrl only survives to the client if the server-side fetch fails. safeImageSrc whitelists https/http/data/blob and returns undefined for javascript: et al. * pdf-server: fix get_pages timeout race; trim fill_form response get_pages round-trip (interact → enqueue → poll → render → submit): - GET_PAGES_TIMEOUT_MS 60s → 45s. The MCP SDK also defaults to 60s, so if the round-trip got close, the client timed out first and our error went nowhere. Server rejects first now. - waitForPageData wires extra.signal. If the client cancels interact (timeout/user abort), we clean up the pendingPageRequests entry immediately instead of leaking until our own timeout. Also simplified to a single settle callback instead of {resolve, reject, timer}. - handleGetPages .catch() → submit empty pages. Was fire-and-forget with no error path; any rejection before the inner try/catch meant no submit_page_data → server waited the full timeout. fill_form response: dropped the full field listing that was appended on every call. display_pdf already returns it; echoing it back after every successful fill is noise. Only list valid field names when the model got one wrong. * pdf-server: return URL.href from safeImageSrc so CodeQL sees sanitizer The previous attempt returned the raw input string after a scheme check, which CodeQL's taint tracker doesn't recognise as a sanitiser (it sees tainted in → tainted out). Now parse with the URL constructor and return the .href property — this is a canonical sanitiser pattern CodeQL models as a barrier for js/xss and unvalidated-url-redirection. Also folded the data: URI branch into safeImageSrc for a single call site responsible for producing the string. * feat(pdf-server): treat uploads-root dirs as read-only by default Dir roots whose basename is 'uploads' (e.g. Claude Desktop's attachment drop folder) are now treated as read-only unless --writeable-uploads-root is passed. This prevents the save button from appearing for attached PDFs that the client doesn't expect to be overwritten. Also gates the _debug diagnostic block on --debug and adds save/restore of writeFlags.allowUploadsRoot in test beforeEach/afterEach. * feat(pdf-server): add --debug flag, fix interact timeout, skip persist for read-only cmds - Wire --debug CLI flag through to createServer; _debug diagnostic block in display_pdf _meta is now only emitted when --debug is set. - Viewer: showDebugBubble renders _debug payload as a fixed overlay. - Fix interact get_pages timeout: await handleGetPages instead of fire-and-forget, so submit_page_data doesn't queue behind the next 30s long-poll on serialized host connections. - Skip persistAnnotations for read-only commands (get_pages, file_changed). * fix(pdf-server): rebase annotations baseline after save After a successful save_pdf, update pdfBaselineAnnotations and pdfBaselineFormValues to reflect what was written to disk. Without this, removing all annotations after a save produced an empty diff (compared to the stale pre-save baseline), incorrectly disabling the save button even though the file on disk still had annotations. * fix(pdf-server): sync annotation panel on canvas click, fix tooltip z-index Clicking an annotation on the canvas now re-renders the panel to expand the correct page section and scrolls the card into view. Previously only the selection class toggled without updating the accordion. Raise tooltip z-index to 100 within the annotation layer and promote the annotation layer above the form layer on note hover (:has selector) so tooltips aren't clipped by the form overlay. * debug(pdf-server): add __pdfDebug() helper + log skipped baseline annotations Adds window.__pdfDebug() that dumps annotationMap, baseline, layer children, and localStorage diff to diagnose ghost annotations (visible on canvas but not in panel, not selectable). Also logs when importPdfjsAnnotation returns null for non-widget annotations, and surfaces any thrown errors during baseline import instead of silently swallowing them. * debug(pdf-server): expose pdfDocument.annotationStorage + window.__pdf internals __pdfDebug() now also dumps PDF.js's annotationStorage contents (editor stamps live there, invisible to our tracking) and all localStorage keys matching pdf-annot pattern. After running __pdfDebug(), internals are exposed as window.__pdf.{pdfDocument, annotationMap, annotationLayerEl, formLayerEl} for interactive console poking. * fix(pdf-server): hide viewUUID when interact disabled, widen annotations panel - display_pdf result text no longer leaks viewUUID when the interact tool is not registered (model has no use for it). - Annotations panel default width: 250px -> 500px (max-width:50vw still caps on narrow viewports). * refactor(pdf-server): extract src/commands.ts, dedupe PdfCommand type PdfCommand was defined independently in server.ts and mcp-app.ts with no compiler check to keep them in sync. Extract to src/commands.ts as the single source of truth; both sides import the type. Uses the TS interfaces from pdf-annotations.ts (not Zod-inferred types) since those already describe the post-resolveImageAnnotation wire shape (imageData present, x/y/width/height non-optional). Also deletes ~150 lines of dead Zod schemas (PdfAnnotationDef and the 10 variant schemas that built it) -- they were never used as runtime validators, only for z.infer type inference. Input remains z.record(z.any()) for model-API forgiveness. * fix(pdf-server): correctness cleanup bundle - findTextRectsFromCache: return [] instead of a hardcoded placeholder rect when text-layer DOM isn't rendered. The placeholder was persisted as real coordinates; caller already guards on empty. - onteardown: bump loadGeneration to stop the preloader (reuses the reload-abort check) and clear searchDebounceTimer. - startPreloading: re-check loadGeneration inside the pause-wait loop so teardown mid-pause exits instead of spinning. - app.sendMessage calls: wrap in .catch(log.error) so host-unsupported doesn't throw an unhandled rejection. - Remove dead ternary (Highlight:Highlight), stale TODO and commented downloadBtn line. * docs(pdf-server): fix action names in example prompts, document all flags - Example prompts referenced get_pages with getText/getScreenshots params; actual model-facing actions are get_text (intervals) and get_screenshot (single page). Fixed 3 prompts. - Document --debug, --enable-interact, --writeable-uploads-root flags. - Add image annotation type to the Annotation Types table. - Note that interact is disabled in HTTP mode unless --enable-interact. - Add Deployment section (stdio vs HTTP single-instance vs stateless). * test(pdf-server): add interact tool unit tests New describe("interact tool") block with 7 tests covering: - enqueue -> poll_pdf_commands roundtrip - missing-arg error paths for navigate, fill_form, add_annotations - command queue isolation across distinct viewUUIDs - fill_form passthrough when viewFieldNames not registered - (skip) unknown-UUID poll (LONG_POLL_TIMEOUT_MS not exported, can't bypass the 30s wait without changing server.ts) Surprises found: - interact never validates viewUUID exists; enqueues to any string - batch-mode early-exits on first error, silently dropping later commands * style: auto-fix prettier formatting * refactor(pdf-server): extract annotation panel to src/annotation-panel.ts Split ~930 lines from mcp-app.ts into two new modules: - src/viewer-state.ts (50 lines): shared Maps/Sets/Arrays + types (annotationMap, formFieldValues, selectedAnnotationIds, fieldNameTo*, undoStack/redoStack, TrackedAnnotation, EditEntry). Only containers with stable bindings — both modules mutate contents, never reassign. - src/annotation-panel.ts (1084 lines): floating panel positioning, accordion rendering, annotation/form-field cards, reset/clear-all. Coupling to mcp-app flows through PanelDeps injected at init — scalars like currentPage/pdfDocument reach the panel via a state() getter so the ~100 use sites in mcp-app stay unwrapped. mcp-app.ts: 5666 → 4721 lines. panelState.open replaces annotationPanelOpen; panelState.openAccordionSection is written by selectAnnotation. syncSidebarSelection moved to the panel module. * style: auto-fix prettier formatting * test(pdf-server): document computeDiff same-id modification behavior Two new tests: - User-added annotation modification: captured in diff.added (id not in baseline, so latest content persists correctly). - Baseline annotation in-place modification: KNOWN LIMITATION. Same-id edit produces empty diff (id-set based), so the change vanishes on reload. Viewer's addAnnotation() works around via remove+add, but updateAnnotation() (interact tool's update_annotations) does not. * chore(pdf-server): revert unrelated screenshot regenerations Pre-commit hook runs build:all which regenerates all example screenshots. These 24 PNGs have no semantic change; reverting to keep the PR diff focused on pdf-server. * fix(pdf-server): gate resolveImageAnnotation on validateUrl (arbitrary file read) resolveImageAnnotation() did fs.readFile on whatever imageUrl the model sent, bypassing the allowedLocalFiles/allowedLocalDirs machinery that protects display_pdf and save_pdf. Model could request {imageUrl:"/Users/x/.ssh/id_rsa"}, server would base64 the bytes into the add_annotations command, iframe stores it, get_screenshot reads it back. Also: fetch() branch accepted plain http:// (SSRF to local network). Fix: call validateUrl(imageUrl) before touching the filesystem or network. Throws on rejection; caller converts to {isError:true} so the model sees a clear error instead of silent skip. 3 tests: reject local path outside roots, reject http://, accept path under allowed dir (with real readFile + imageData population). * fix(pdf-server): deleted-baseline zombies, clear-all form strip, height-only coord shift Three bugs from PR review, all in viewer state/coord handling: 1. Deleted baseline annotations zombied back on reload (mcp-app.ts:2512). restoreAnnotations' apply loop was add-only. loadBaselineAnnotations runs between the two restore calls and re-seeds annotationMap with every baseline id, including the ones in diff.removed. The zombie survives, and the next persistAnnotations sees it in currentIds -> computeDiff produces removed=[] -> deletion permanently lost. Fix: delete diff.removed ids after the merge loop. 2. Clear All didn't strip form values from the saved PDF (mcp-app.ts:2756). clearAllItems() empties formFieldValues, but buildAnnotatedPdfBytes gates on formFields.size > 0 and only writes entries present in the map. Empty map -> zero setText/uncheck calls -> pdf-lib keeps the original /V values. Fix: at getAnnotatedPdfBytes time, inject an explicit clearing sentinel ("" or false by baseline type) for every baseline field absent from formFieldValues. 3. update_annotations shifted rect/circle/image when patching height without y (mcp-app.ts:4161). The old code spread existing.def (internal coords, y = bottom edge) with update (model coords, y = top-left), then key-filtered back to only update keys. But internal y = pageHeight - modelY - height; patching height with top fixed requires rewriting internal y, which the key-filter discarded. Fix: convert existing to model coords, merge in model space, convert back, pass the full merged def. Also uses the target page height when update.page differs (fixes the low-severity page-move bug too). * fix(pdf-server): sweep aux maps independently of commandQueues; line annotations sort Two lower-severity findings from review: Leak (server.ts:265): pruneStaleQueues iterated commandQueues to clean up viewFieldNames/viewFieldInfo/viewFileWatches, but display_pdf populates those maps without creating a commandQueues entry (only enqueueCommand does), and dequeueCommands deletes the entry on every poll. Net: the sweep found nothing; aux maps leaked every display_pdf. viewFileWatches entries hold an fs.StatWatcher -> slow FD exhaustion on HTTP --enable-interact. Fix: new viewLastActivity heartbeat map, touched at display_pdf/enqueue/dequeue, swept on TTL. Dead second loop (entry.commands.length===0 was unreachable) removed. Line-annotation sort (annotation-panel.ts:400): getAnnotationY checked 'y' in def and 'rects' in def, but LineAnnotation has only x1/y1/x2/y2. Fell through to return 0 -> all line annotations sorted to panel bottom. Added y1 branch returning max(y1, y2). * feat(pdf-server): rewrite display_pdf prompts to steer model toward interact The model was calling display_pdf again to add annotations instead of using interact with the existing viewUUID. Root cause: the description's first two sentences invited exactly that -- 'Use this to display, annotate, edit, and fill form fields' 'when the user wants to ... annotate, edit, sign, stamp' -- then the CRITICAL warning arrived three paragraphs later, after the model had already pattern-matched 'user wants to sign' -> display_pdf. Three changes: - First sentence is now narrow: 'Open a PDF. Call this ONCE per PDF.' - Follow-up-actions-go-through-interact is sentence 2, not paragraph 3. - Result text is directive at the decision point: tells the model to call interact with the viewUUID, warns against re-calling display_pdf. * fix(pdf-server): correct the 'discards state' claim in display_pdf prompts Both viewers coexist; nothing is discarded. The actual failure mode is the model now holds a new viewUUID and addresses the wrong viewer -- interact calls land on the fresh empty one, not the one with the user's existing annotations. Rewording to say that. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] --- examples/pdf-server/README.md | 165 +- examples/pdf-server/grid-cell.png | Bin 30605 -> 25957 bytes examples/pdf-server/main.ts | 37 +- examples/pdf-server/mcp-app.html | 71 +- examples/pdf-server/package.json | 3 +- examples/pdf-server/screenshot.png | Bin 68443 -> 87852 bytes examples/pdf-server/server.test.ts | 609 +++ examples/pdf-server/server.ts | 1806 ++++++++- examples/pdf-server/src/annotation-panel.ts | 1087 ++++++ examples/pdf-server/src/commands.ts | 68 + examples/pdf-server/src/mcp-app.css | 714 +++- examples/pdf-server/src/mcp-app.ts | 3460 ++++++++++++++++- .../pdf-server/src/pdf-annotations.test.ts | 1458 +++++++ examples/pdf-server/src/pdf-annotations.ts | 1081 +++++ examples/pdf-server/src/viewer-state.ts | 50 + package-lock.json | 43 + tests/e2e/pdf-annotations-api.spec.ts | 333 ++ tests/e2e/pdf-annotations.spec.ts | 310 ++ 18 files changed, 11171 insertions(+), 124 deletions(-) create mode 100644 examples/pdf-server/src/annotation-panel.ts create mode 100644 examples/pdf-server/src/commands.ts create mode 100644 examples/pdf-server/src/pdf-annotations.test.ts create mode 100644 examples/pdf-server/src/pdf-annotations.ts create mode 100644 examples/pdf-server/src/viewer-state.ts create mode 100644 tests/e2e/pdf-annotations-api.spec.ts create mode 100644 tests/e2e/pdf-annotations.spec.ts diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md index 5b518d5..cd265f8 100644 --- a/examples/pdf-server/README.md +++ b/examples/pdf-server/README.md @@ -149,6 +149,20 @@ bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf bun examples/pdf-server/main.ts --stdio ./papers/ ``` +### Additional Flags + +- `--debug` — Enable verbose server-side logging. +- `--enable-interact` — Enable the `interact` tool in HTTP mode (see [Deployment](#deployment)). Not needed for stdio. +- `--writeable-uploads-root` — Allow saving annotated PDFs back to files under client roots named `uploads` (Claude Desktop mounts attachments there; writes are refused by default). + +## Deployment + +The `interact` tool relies on an in-memory command queue (server enqueues → viewer polls). This constrains how the server can be deployed: + +- **stdio** (Claude Desktop) — `interact` is always enabled. The server runs as a single long-lived process, so the in-memory queue works. +- **HTTP, single instance** — Pass `--enable-interact` to opt in. Works as long as all requests land on the same process. +- **HTTP, stateless / multi-instance** — `interact` will not work. Commands enqueued on one instance are invisible to viewers polling another. Leave the flag off; the tool will not be registered. + ## Security: Client Roots MCP clients may advertise **roots** — `file://` URIs pointing to directories on the client's file system. The server uses these to allow access to local files under those directories. @@ -174,34 +188,149 @@ When roots are ignored the server logs: ## Tools -| Tool | Visibility | Purpose | -| ---------------- | ---------- | -------------------------------------- | -| `list_pdfs` | Model | List available local files and origins | -| `display_pdf` | Model + UI | Display interactive viewer | -| `read_pdf_bytes` | App only | Stream PDF data in chunks | +| Tool | Visibility | Purpose | +| ---------------- | ---------- | ----------------------------------------------------- | +| `list_pdfs` | Model | List available local files and origins | +| `display_pdf` | Model + UI | Display interactive viewer | +| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms | +| `read_pdf_bytes` | App only | Stream PDF data in chunks | +| `save_pdf` | App only | Save annotated PDF back to local file | + +¹ stdio only by default; in HTTP mode requires `--enable-interact` — see [Deployment](#deployment). + +## Example Prompts + +After the model calls `display_pdf`, it receives the `viewUUID` and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features: + +### Annotating + +> **User:** Show me the Attention Is All You Need paper +> +> _Model calls `display_pdf` → viewer opens_ +> +> **User:** Highlight the title and add an APPROVED stamp on the first page. +> +> _Model calls `interact` with `highlight_text` for the title and `add_annotations` with a stamp_ + +> **User:** Can you annotate this PDF? Mark important sections for me. +> +> _Model calls `interact` with `get_text` to read content first, then `add_annotations` with highlights/notes_ + +> **User:** Add a note on page 1 saying "Key contribution" at position (200, 500), and highlight the abstract. +> +> _Model calls `interact` with `add_annotations` containing a `note` and either `highlight_text` or a `highlight` annotation_ + +### Navigation & Search + +> **User:** Search for "self-attention" in the paper. +> +> _Model calls `interact` with action `search`, query `"self-attention"`_ + +> **User:** Go to page 5. +> +> _Model calls `interact` with action `navigate`, page `5`_ + +### Page Extraction + +> **User:** Give me the text of pages 1–3. +> +> _Model calls `interact` with action `get_text`, intervals `[{start:1, end:3}]`_ + +> **User:** Take a screenshot of the first page. +> +> _Model calls `interact` with action `get_screenshot`, page `1`_ + +### Stamps & Form Filling + +> **User:** Stamp this document as CONFIDENTIAL on every page. +> +> _Model calls `interact` with `add_annotations` containing `stamp` annotations on each page_ + +> **User:** Fill in the "Name" field with "Alice" and "Date" with "2026-02-26". +> +> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_ + +## Testing + +### E2E Tests (Playwright) + +```bash +# Run annotation E2E tests (renders annotations in a real browser) +npx playwright test tests/e2e/pdf-annotations.spec.ts + +# Run all PDF server tests +npx playwright test -g "PDF Server" +``` + +### API Prompt Discovery Tests + +These tests verify that Claude can discover and use annotation capabilities by calling the Anthropic Messages API with the tool schemas. They are **disabled by default** — skipped unless `ANTHROPIC_API_KEY` is set: + +```bash +ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts +``` + +The API tests simulate a conversation where `display_pdf` has already been called, then send a follow-up user message and verify the model uses annotation actions (or at least the `interact` tool). Three scenarios are tested: + +| Scenario | User prompt | Expected model behavior | +| -------------------- | ----------------------------------------------------------------- | ------------------------------------------ | +| Direct annotation | "Highlight the title and add an APPROVED stamp" | Uses `highlight_text` or `add_annotations` | +| Capability discovery | "Can you annotate this PDF?" | Uses interact or mentions annotations | +| Specific notes | "Add a note saying 'Key contribution' and highlight the abstract" | Uses `interact` tool | ## Architecture ``` -server.ts # MCP server + tools -main.ts # CLI entry point +server.ts # MCP server + tools +main.ts # CLI entry point src/ -└── mcp-app.ts # Interactive viewer UI (PDF.js) +├── mcp-app.ts # Interactive viewer UI (PDF.js) +├── pdf-annotations.ts # Annotation types, diff model, PDF import/export +└── pdf-annotations.test.ts # Unit tests for annotation module ``` ## Key Patterns Shown -| Pattern | Implementation | -| ----------------- | ------------------------------------------- | -| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | -| Chunked responses | `hasMore` + `offset` pagination | -| Model context | `app.updateModelContext()` | -| Display modes | `app.requestDisplayMode()` | -| External links | `app.openLink()` | -| View persistence | `viewUUID` + localStorage | -| Theming | `applyDocumentTheme()` + CSS `light-dark()` | +| Pattern | Implementation | +| ----------------------------- | -------------------------------------------------------------- | +| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | +| Chunked responses | `hasMore` + `offset` pagination | +| Model context | `app.updateModelContext()` | +| Display modes | `app.requestDisplayMode()` | +| External links | `app.openLink()` | +| View persistence | `viewUUID` + localStorage | +| Theming | `applyDocumentTheme()` + CSS `light-dark()` | +| Annotations | DOM overlays synced with proper PDF annotation dicts | +| Annotation import | Load existing PDF annotations via PDF.js `getAnnotations()` | +| Diff-based persistence | localStorage stores only additions/removals vs PDF baseline | +| Proper PDF export | pdf-lib low-level API creates real `/Type /Annot` dictionaries | +| Save to file | App-only `save_pdf` tool writes annotated bytes back to disk | +| Dirty flag | `*` prefix on title when unsaved local changes exist | +| Command queue | Server enqueues → client polls + processes | +| File download | `app.downloadFile()` for annotated PDF | +| Floating panel with anchoring | Magnetic corner-snapping panel for annotation list | +| Drag, resize, rotate | Interactive annotation handles with undo/redo | +| Keyboard shortcuts | Ctrl+Z/Y (undo/redo), Ctrl+S (save), Ctrl+F (search), ⌘Enter | + +### Annotation Types + +Supported annotation types (synced with PDF.js): + +| Type | Properties | PDF Subtype | +| --------------- | ------------------------------------------------------------------ | ------------ | +| `highlight` | `rects`, `color?`, `content?` | `/Highlight` | +| `underline` | `rects`, `color?` | `/Underline` | +| `strikethrough` | `rects`, `color?` | `/StrikeOut` | +| `note` | `x`, `y`, `content`, `color?` | `/Text` | +| `rectangle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Square` | +| `circle` | `x`, `y`, `width`, `height`, `color?`, etc. | `/Circle` | +| `line` | `x1`, `y1`, `x2`, `y2`, `color?` | `/Line` | +| `freetext` | `x`, `y`, `content`, `fontSize?`, `color?` | `/FreeText` | +| `stamp` | `x`, `y`, `label`, `color?`, `rotation?` | `/Stamp` | +| `image` | `x`, `y`, `width`, `height`, `imageData?`/`imageUrl?`, `rotation?` | `/Stamp` | ## Dependencies -- `pdfjs-dist`: PDF rendering (frontend only) +- `pdfjs-dist`: PDF rendering and annotation import (frontend only) +- `pdf-lib`: Client-side PDF modification — creates proper PDF annotation dictionaries for export - `@modelcontextprotocol/ext-apps`: MCP Apps SDK diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png index 5acd222724ad01d430cf5eec34fc050f816e9c08..0e6e571d879e5c82dfd347bc464bb9ba20b86d01 100644 GIT binary patch literal 25957 zcmeFY^;=Y78}F;4bc1v^(j5*ZDTsg|9S$i{(p}QsCCv~@cb7CnNshDvBMmYzz>s_K zzGt7a&%Vz22hMeVn2T%Hu;PjJJm2qqf9@#Fx5{|fRM?LmJ;HmfqVVp~qsQD2A53)M zh_`>dJMahVvxbSkR zne@-U4w#h6*)~|;mxM`diyeRuo4S1un=YL0bvrIMVy4<|5MyJk8OHSo$!ffu{2_Hh&;EyvCaglYVR>X?|KI zZ`aw33vymBLxCwtpZhn1acIhClK5AK6()WsgJZnimm4l?-rH$`xBF(=V%$c}+%lXY zbHQ`L^%R1bzl%?&Wb!@XuEGO=p z3DT+pTlxdP38L)0n)(EJ3~LfDo9nb%FMXWH(UT0AU`3gkBvbPs%(q`n z43ZKksq(?m3Zp|w8skrR;G&&7MKPt;V|tBw_m{&mcX61*tiw3Gtp%lGW>y&suhRV` zjlKSajp>ruN1%>QhF%NG1@se2^@;?yKSOaZ9K102cr~bEuo2?Gb{(W8i!h+Q-G8UobEXn~JV(U!s8C)oXl3{(?fcLq= zVq*z-taT6i8xq&}dc@PX!FD@8@J=UUo_Jh*hAm=8-*A;eZ*jNx&596FsED?AI1$<4 zN8iw}0dp@rlRhdkF`DPqhs?>D-zn3l?Ca*RxY&w@R+TXA=6n*UYNU=kPkpfLO2rWz zwk?uUZ^V`mN;z%E_qFv?!h%^s;0aySwyHiP)rtb#^duK&zhd29Dy7W}@6dmC-v5{T zERn8Xe!$hPb~w5FSB1VkXn_XXYBPn$`Ww`Ec&+uP?B}eNTFz^cq4V>Pdu*6u76})O z$E2M?Mf74-^-aVsfGK+Tvp(*Xegn^D^iYA4NFTU9PmTJFNSEVS`uae-dD*c8u}OdL zwRiKT>--&arL_6p9bH(l5@SZmZ?GceitE|5GM|{R2~i*RsL%S+OA9;7}4V*79tKCt5xe61EcM6Iyd8utQcx(CjsBQZ#tqeR> z8_E}m8iRNGZzssyU2>x7^*p?>W252K8f6g>VqIf$?X$x=pz}VGubsp>xFx^#Wh=XHJzr$o*S|&`*vNFOh%~TPwjLieOz~=X^^ri?Wl*CC2>tCh zy3>hx&qb>YpKiWUK?;mRKb0sle}CkcU_ep}4+-%&g) z;I8}IW4@NQ2?r><^hwlriw3!TqGqeuvjAWT$k6M#nuiysyfr-fY7s9{+KEW3sf1!f zPfNwRzIWapI@RFG81?z|Q?F?{2ApPREUzAUz}dvwUbpR=92>3yw^wgc%)d#Gj(F~U zbF(eO*KyqK>wWCI{*skh;U@2*v&`=dLZ8c23+p;^hlkMLd{_*;zugxwg=eqd{QV^R zZ(HWRtrHl^ob(S$td)s1%~3a{IBDZywQr@6b1uMrKYvOU(Ccfz`g%>cPJ8S%T)&;? zyU?D5MsRc9(&LYsuL%$lPSCGi&s$CDbp1(SS3)FMSw$*z&x#6IV%;bQr&f} zFj_5NIj|Pvy68IgA0zL%9IPWw2W`H-)}Jj2fQ)?l<$Vb}x=Ht0311pP;AKBjhWM(V z{*GU9BsQVj?=*Y8a+q+LFW@AmJX^AJ%I~kxe9C+&=mHGS#{&;awf z+A@N3aZO~YW+$Zc#qhvQ?Y$n5JS~_*%W8Ay-uoKEqop=NGQLdZ=K{aKRS3J&)#cgA zNmb_Got0SuJ48*_@|y3liRjWth^nwwYa3xYcCOb(R1^D_0j=M^AIJvH(C3&nCn7c8 z6FN@P_ylT(onHz3ext#A(FiK>%JfQQr}t$!glmE^*s8kDmn!bIZEt~zmjktE4z*ID z$QN1Aha@kIncX2wHSC&0p9{{Yt#3*QocRC4J%nV$#gf1k?MDi%8UiZ7Pj~t8a!&;bJTjkubfxs2G?yr09 zjhYv&vpJn=re>+aYMGp1brs}YJBn6^O)%b$!)Bw!&;582_}`USxwo!@FhB)&3y+5+ zYiT`x6@u-HH>o1R5Ez}#b zC!>AzNwdX1WbU7Ktlw7SlPIOVr+;-PDNL7q-+7%FVi6f#9jf=@-#!`$v_|noPDk#p zKQ@r9@6)EpM)1QN7*jJvB&)_xJ5Oyr)7S=w@$UQ@sB!e)H_D)1`Heo$oNzj3{aW8S zN7;Y-<+?ZYCEo3d*Jj)pdH26Ccy(?}gcXZyv%jFl^5n&+%^KF&=Pz;;Xih+f&EL}L zE-vC?QGTaOLGWxP)~f0IFm+vuW5&320 zc`Hgo#)+v+*Di;|suh}cOS99b{EuA6+V<5X!k=4(lDri!^g%NA9|v4?q%n9E%n+#- z@Gbr17Z_2?0+tMUmkTUG=GktaanW$A)DvJw4X6~T{3)MX`j*Yjxmv!@3O4t;JHdP0 z0(!MyW7cIyfAZ0}zQ+I1PX3f4t#(HQ;rM#KMcC{lT95lJu3Csw?GJRM;d6o658wJm zJXYOdOC#MK&nQH8s<}gzQGR5CJ9Kd+86SRaQW0Z|3nt4GNHzL^K94X)yqTDElj3%+ z*m*iYW-iVwyL6#4;6|5z&DrH=6q4nrzF>^SWlgs{6+KBDe&cO#-72dQ7wT0#OzUYZ zr9|$n1=6p_7I(syQNhIew-KRE6{o6y_$Kee9e-BrD}CpDNf2sOBadlTH=dysIlwMm zCq^c`$6ctUHBc{3?F5V;*C-~5-@ehhVYOoeD>n@5@i(*tY`^O%653|wabQc!*$i;4 zcpm?Iz>1^p19eF8Tx^{Nk&?**1D>2DHpgA9W~d1M*|Sy77X-cRxOv<2ajyr(>Z3&k z4hV&n9Q(rs{6n8(fA}`>GRhTFnPaL-x4{3+3^V2u=@5$(stQ;ZXG`?tuRizk! zhBJW02)T^=J>F(J*ORxRs@d@Pr)#Ra{_MO_&Kvu$@=vPl*#*O&|9;}int#!`JukaT z=X;kM<+aWc`W&VxL2;%LfJk}9UfGQ6+U8k^r}UH8ODgCkp4f5Ze_j^BJY-W zRv8!Uu_~rWvK0CZIk>|kMDTOOVr;NFGNUxvmiqUoetw%On+UIM56j^J4<2E~JVw23 zIsaG(s$68ivOB@#l5F0U-ejw6*3KR4n|^;~(ARx!t)?HZCBF8^$)->mk#g(@b_Ll5I@ zipl4lM;;zdIn=zS{u4vd;4=hOS;l%PY{JesTVrwnC+3xY-7jI|splva8&RW*>VuzX zg0;_COn%5jru{YszB$VtnKFr&d9bNkFo$ z5O4tw$YIM7FG|7OJTVk#7#sR)p#A^?)M9F@+sjh}Z|(lIUF|KdE}Pce%zWj| zA1t?^PaRaIRjRs(9tJ0%hJ#jyO1D1l*w=zpIdW%?J8~AoOmUSTWCiTS)!=l z|8$*7alP(H%HJ0ErHqMme*l46jfU2eZ(r|b2VYmWkH;*9?j6`F^DUig)jfv`y@zA_ zbt%{W8wY{P0HM{F<_}$k}$<}T144@b?UJi!jrut9}X-(+*eZXN?)t8a&0g?=j zmagjC^@*P2rSo>O{-eV@y)}&h7fl-^y@!Tz;Iv|i-v&vEv4=sRL5B+kth8nW)pJ#%y9iGf7@i%|21b*Q#& zB?s0hx7;q*-`#A7h&s&1?eldtre5^)r1spv*Y|&=Ide5Y;$OgV%=EiEAtRO9rg|Q; z?q2>E3FS6pUnTF)%1Zn;smEY-6Bn^U=m^LC1@;&)JnQ=kmlQaRFzEAS-dM@xdR1w|F^j4Mbs9%$U}md0w^ZBB1gYnccx zEVwaE-X}h6yd>%ja%6edSeAxps=>QU@weMK_Os!f@>%%ZBFt3NPrwKN1AH_BsS;P$ zd^23`P-88gb8-etlDYopzb_I3bf6@8{H?>z7cGZPr;{aq$G$MCto=zTPjAX^DW^$n z5`aVxxgHfgllLDjtfKaA9-uw^`_l5z=R%47YLGA8WyLvU9f6x1FW9b^t$<1uTM~x4 z!qZ@-lqjx5+w_F`nWk#v5-xRE%hR$P40_O|dV;)C1vmJaZRa1n62P36 z+*}TqtXy=L9CSEwY6M=%e&Yu$uNNm8kp8)gh4~~GC2bm7fw0F0Uu#j^Ikhu1q{_Y;H)1=Cto8@V8(LNqf|Kg z{B1b_>ky=)ArAh$*3}tW2D19i_^hxk9M-#hy?U&==y&=qUb;Qip_voDZ$_VLHzlQp z8n;pPp0Gp5M*fTdw0H)=%vRDr!#Oj_Sko?wi|jRF%^VH?Pny*iT~4xJIu1n>ExbMj z?Tl=1rC=bfxknz2xWG&>ypyFgc1{xLx70kgc1`P9=)D84W<##e7i~24ki|cN3vepa zXh8vYhB)#h*gD^qA%Na3!SF3%td+{rE zIX~QI(lGp|@gs3}QqNs;uSf5m@o~Vp%qSaU+~!|@m9!8{>G4?T9?|XkvJ>2ymT`3d zFCxS)Rc^^r9Ygx=BzW@MY=x;vB7uKVid&}Xzs5$0WO>3mh_AUoYA)DM=4$$fgk&|H zCDqesTc|B|0?9%&0-DC)x8!7*TOU=jgE`e^VyK3EIh{57wqe5ecEgW&cV;$qs2Hn-7~|_+5nOX!PPA zg^)4l3#EGmXOF<2*8hF+s+$xtESAN`6%CT5sIl82F-Qz% zC|8FcxS4kxRwEygC-SAEgTsQA`6y)dBNb~}kqaRfpvTw{av-RI(e+f`Cenu)Z{X7F zHC5i1@`P}6ni2IWQUN|GDFh^u>Wve!h#YTH06V(Z%6foQo>|CGb&Dd|@+MU+j}VbU zV5O)7c>16%3UX!;a!#)MNcn55*&?9^2lJC(PCda9gM`Q>A0_mu0_2s-GYVb}B@|~5 zV(@b)m|t}$g2CHM1&^ZM=ObGE`(cLk>-E4yGDzvK*njw*6lV)TI~0=1NfGr)!cQ|Kjj z-*_)0;O>eMxJxvDA`m$*NN;ZCLAca-t=_FUGXy445y+TAp zY-5V>4gtzm`{I+Y`#Gb`;gB{iUyibp5{M7>A|nZd`yV25kiBy8Ln`OrHrY+hHE;tf z(E)Br%>G~bw})@WzhzEHLOg7r{rg9U{APEWXAAAV&BNQEV_BN~*-WeZQtM}i#$3vu zljs3EH*d(^k+s199F#8_J z_$tD)XW3<71A2bdJnr0?W^v;L3sj_}WFn3S8+MQ~26$#XX=EX>Rm@sKxWTtc@?OU@ z8#}ByNEo#tioHoXp^*?bI4sS?sFZ(2*X(MkVP09tiipDL^-68r-KC>@GvB?6khz!t z;U5~ONWFQ7%`B*|d%_BH&$)hncTQk#UpUro3USz%_ z&vdl8F<|iS#Am5Qx(3hxX7J$A8XJbr9}X^STzK6OG)mRhd2xdrYHW|~e2+X9u+Ms* zWR^O2z%F>Y?Z=8Qwxb3w%_^0-89|xN8DGEO9iEJs70mvG@I+Xc3#W56y3;Mho1mJc z>~LHXtt$ognw4b@QoozrZv~DxQ9SmX-M^N}dKNu z+A@>e`D=B=Qqg=|x2r>29|X^-+hm7odk|wVu^ma3c98DArGVZkL!#W-EV};4WPY-+ zVYX?Ef7OH)3#X6JV)|nN^?KxebqrXEBfYnb5ltLcx_PDf2#@sfoeD`C<{Y*vc2Tpc z?d31%-&Ae%;7|M^f2mGL8>|IFn0SOt>ojXj^r>)gjoilO{JSS{6Kv1*x5`0ZMpX09N|^@irXGpXfRjU(cYC7 zqff6=U1k;B+Rnaq%`ygeWex%KgQv4b|@?>+NX0UISj4{N+iw<>7`}Ej& z`RxbdSM7`;ANR$b{?3d$q*kSM*)erKI{a5Fb)-LHoVZ>b=c;96g>5n~(4qG-f^MW? z?6DwHY%MhYVJB%k5li7~)rLgdWNduv7Xzw`ova)oJ4>k9 z&l!=(zVYz037V|;0Y^XFyTN{pdGtfUaZHTOUMK^wxm0J{Q z-I~avDBylz+K@xK=43mjd{-@kGe{zYLpJm}DedJ#VOEjucBaJaen-ZSuvgemoZ5Fk zpr-~K%9MZ*0ytsfbL;j6i?ncloRT$bJ)(HYWZHx;Hl%Bu{tR4en3NsyjLM12CljL7 zxr%YK3#mT(LrKxnB%;uq+}r468`-B0&a9C2OSw7@Ka%!53t@97iCxUkN>Q zJmu%m7NoND@2H+;-eZK}_+@fMW711+TfOi~Ttc-s#Xn@5!r-{oTA~f3g=kB%45m{4 zr{?1{cJdD2JD2!C)S9~9wiuG7agf=VER_pp-a2mhPoi%zi_*5cItk+D3^G@cc$uZrCK{@wXRz(GV7|m#xf6k~6`(d<7+UqV# zv>ExUEtP;yQHRLiTaeg2OKTwzi2}QmXD@!bO@kSJhHY z;GMQ>_ahbET*9tqmvz5k+zf88mN@iL>KYZpHiht?AoRYoP;6Wcc?O0!GKEq(N^;iu zmu;psR)^{!J;leII0LSFNAn#|;D6IJNu#Ok%qnA|Y;ZU`30Ob)kv>IfDX=I=RHeMa zj@Jf3kC`3Z>%@gRS?!!TI<6@Ri{!|lQzI15vIahR{g(Qcu{ol+9<^~!4|vEm;)Uidz4f^l$$Abkol zglx4+aE=A5SO|kmZm0%mWURM%op(jY3|S-;JJ}RV^!n)^3bH|nYUFH_0BFf;(~g{I z_@@!7NZlgLA5;X1W5i6sFBg+wz*ce)sPQo-HsJlAEWltx%A_&|NrI#;0j$*&g?7i> z=z&+{!x=OAc6sJlo`80Tpq5}QRdmJ!b4>*Lb`mo}dJx-tdoh~-6*oC?GZ_ScSxZ zQRr!eCT_MDCXf?k@Q-Hqhf=O#C>F_rgwU0K;6A32_ls2G`+j%Afl)*xWWkNAIQ6X) zp<@<tXH#-#$D-IWN%;Ap|P$S9)b0MbX`F~p9 zlA^FI{?&rMe<jiAh zG~?oU--|DQ6m|J1@CidK<1{9+L`|P3-OxK-(%Mjb$y{IK#W|bq9I|zzv(>D!RDKihh_lLgXjzc7q)NHyw4G`5X$7K4gh~<_Y zHw(^c5@hE5NN<#r+2r0usa7XYN0!2vP7&FXsZ~ERJzlj~{;TT!=T+QXN}m0wH;+Q~ ztb>adLH85rr@zL-+e~pM-!StL2n^vCW+^&LGz?mcTdX6`rwa#E<5()+KmU{UuP8B* zF~>l0!0HgcPr!&B)A8$Sd!D|WqiwJika;}OUjuUnl~AV#>o0(lLowLs$DSu9a=I&E zs0>^9c#9y!bfw0@17%!&vyOY#Mt27jVw`#BO(^aP8kwB4eK* zZsR)|FE=tN!{U!4~g2z=0R!+y;Hv zyGpGG_~JjwlG^Cq-iHGckH{NWYFV)MfiR-B0gy_Ax>vjAUgbVyT6)V%&|qyt=gvQp z0gUAS3l1+wjUSwA>>=B4db_9he_rU}0C1t$6eAan3k9aSt)wxk^xjmbfOqIz9r&?0 zyZ^5Z``?<(^Y~9qC5n}JQ;B$i2|IuyxN%?CSP16Mz3N*w=GweFDVhtuXdG5S{HLMlV#sF8Qy}i+LJmSmi ze!vX4qMYoil~ssw6iN{|59=n=BA%1aXF6u@So2KW6TmUjV!8xSUwM2c#W!O)(cB#< zlqoXS57}qh0AvjM0Q>|7o;T$4w6xDxL8ky*?z`Kv8`I%Ai4yJh)XU>e!~^$X4)}akPE;Pmc0OpNt(p|J7Mt}%sd4N`UYYSI?deD}}xo6My z>KKmMWuHr1i%!Nn>GQgTc|Ym1LINK4{q$bL8G9?&Nr~-L8>xK*xamc^9_q-mr^A?* zcoXQkC(MyHFcVLn2d-sols&(5<-`maT`M4cTON)@4}b|{DS2nb--oEIE{VhI0k8>% zp%FqOy6$(10O*_2JEyMTT~PFbX0U+IRlt5#OCD3+Zf6oljN~e6(vvsC*kyE1BGjiL zd$sbQwjV!D_{ay=k&c9!1-_UVIUVE+aGoG}{S1h)ZtD=+pHf(flfmwGlx1E#pgwl( zzDI2~Ql<7lx4N#FWpN|djK#V{cz4RaM&EVOdY zy-$@*PK@B9&Jj_9mqHsrB^k})wRPPgYiicsB~sP=djO(za<|kcw@BUH?*Pz3K zWUbwQQ@z>%Q=v zZgVQ&yus&fjw2gDjd=xSJGR?S04R-*o)np7RVRqAC+QNP-B4FX(vibXrg}yR52#G% zc~ebQj~TLDZ{7~q;9(}g>ZOt2HL8ZaBJ66qJZRJHS{^R){?5q0PBG3?3cyOLS**Rr z_tgpP0)SmgHu4W1!8XvTfp`Z9OAc`+>`1)TzoISQyE zl$dVdVTN8^2Lb8NvOJ$px3mJ>N=H3~e}Fl!e|IW#-$18_KMX{$>C-d14y=%3$yi^g zmGu8`E?%X#{K*6})nN4rUH!(8(I4Z>=eU2MlYHe4c zKY(9XW)EpTv$^%_+{zhcR|#AJM8gln8r#7Qr>-odi|xjD!Vhqz5BOCyr@ z3UB`q1ad6fH>e|h(nLC!7>3VkhZ0ySpRsW_dkTW@P)rO_LsVo&#WYFGzN+)0?Kr3(n|u1iybaFdWUxOB3Bha^a%}LJB_iW1 ziwgmf>IH1bLr8FT_9`$y<@UenA`ZCt@3!n*Y^P4*gTY&+Qv$CQgDBFd|xfk`n&38>i$MUU5{qZ z&g|MyT@80!0=9N{-jZ+VGtH}9+xa+)lK51`!sGodp4$AdJ<~rw+|A;#?3g#!^Ft!jBF1ZA-8eRsMlpVpS45C6GX>^i6U=I691OrxHB2Gn1#H$2vtGl)Sy!RGI*zNF^TO!*WIw`(&VuF^iF3p4qxZzXc{J- z^^pd3E`)XCAE^`7E%0h)kQ)q=7=GF<*VW%2wK?NfdAoX{$^qkw>0EY`y^|(%*A_L+ zxn^1wFEgEqL=5`Jr|1<|Nn2KJVv4h^o@X|W2+r8UYFUS3SSne=k1Sp|5UiT;iI^Nq zt-7zKt!)ZchXrt3{emdF@+j+B`#T66d>!Zmpdc*s(qUI?3l>Q7l5ri=QvqIF--XY- zR@92;r$~DXbL;<24*f535Kw^bA^HCmfIj~>0Z8`K8&j|SzrTEye4vTr4+Tm9s<(9= z+aW>LOXmlK`oF&#O4b6Kw;q0EnaI!}&*=u(AnbrB{J)yffwt8rRzrK-?x$3e3d>Mf z4y*AKAr9cY%7g#wZvUS^R!&)dzNfADN~7C!#hXsU_N6v;d#|D<@1*tKVKHKLTc3tr z&xHBdk@7E&o{9UsjM=PEyvBmt26j|upT8Qb zexJ%=bm5sQv^T?J(Q>0zR&Bg=E#K{6T2)Ebe&;`Yy|*-2E7?qsh?I1<>eNK$z8$XF z%gO$}wkm434{JWGZTFZxCSLF}XTsbEpmZBx_gy=6>KZAGpffZ*{R(?y;`uumBmF^d zXx{^D0$=)rq(XYGoIcRfpdC4a7Y~d0SQx;mtO1}!2EdkhOA0l?K0-ntpjl@F|^Q0r_5UFv;6!RM%Y z0^BF}!!Uia2mt5-PMPb)sd9v=&u+;BJYOC?%)l}Hz~YzRL<0NQrMyFd_FIXP8=$)@ z(Y^ouz|d3t@CexZr^Xl~Xdcj@smT~nkqO7Y2P^l0FC(tk@4IPS#|7Z^W4v7dQORwD z;gTE)_5R2Tv#NScg8Fw(;(tO(phMJ7#z2Gf=iE3>fr<*F8rbWb4e}liKs}082qu^L z!jTDqHxN3{iMH)LBX}sqh^91P5NgJNJKXuG8yz`Af9JSPa#A}qYPA7`x(QOQW1pk@ zB+e;SS!z!U&J5rqRj`LzjwMb;n-2$=`wyEN0{pz^jUH6~a7e z;yn0VM5_HCFbBGzzeL#cyv)6qm)CU1_~Wm_*N(d`*3#^~WTD@r;9bB*HQ&tW3EOiU zk?(K-u-ztnNs9s;z;~Ed5$t^1P4Iag)48(U;}!^r^10693aL>t#*|?EV2A0)s4n11 zt6(pfy_2zMB>_r*h$$k9nY$GM<*InMauV-ZSuD$eizLavRJbKfH{E$~Ws488u$K;a ziS)u0X&%ZV#wK4L8$SrT@4_O`Do6JURWFd>#ZlwzTlj-aL-hc#hEEj;emZU=-2jjk z1Ql^tz-YEUaN>Lq4ucEpo!9n*$AYaz4P%^&D15|Ju0Ery(nkae zIRZr~gEokoR*W2)-g^TA$e8YSM;UhdP#mEiL3ACMyKh{!5HT$UNcD=)Neem2JGB@s zM?fOO|D*eX8D@Z&pWMC0%E$R2D#z}wkIjw%Ba>PN%-uAOCB$R|?pvALv%P76(jeg6 zukLUZv4+2T`#@q>m@<%Bfg?wr&%qxPc*!CaD9(YGV*vqJhqg{&m@9k4R^4V_;3#lN zNf!9ipJop0$gf%?NPWr+==e)H<)y^h;Jg6HInx;v_t}6*{p=OMFxA|<&nQtW$x;7n zY;yKWDj-ohP^NX)xlIV2ON;x4mdq{#1i8okMYvS^X!Mt|w8YhIf4|P7y?PTm0P$Ix&C|sSC*&l^NG-CRBkhtY^VjfU<4CRoRQqG)IJ4Qk|F^k?2D8=<9H~LDF+7tRVF+SRUQ?O zg?vy9y`l-II>uw8l?}2qV-Diuqe24zkMGYi0M6|F=!3+54;NmMKTt8t{nF~K zl3QgrFF_AHx~jhA)1}P2M0U#daCUjovtTjb z_3O13pWl#y*ES(P>5<+WB>DPMV$4r0%L5)XYsHGU;#tz9^zLhoa)pwkGdXr@+U{$@ z=y)A=4adFS(&DjjueVCfN`hyZcMPlpWz5TdrxO*pCi@iEA7mz{yV7fQM+ZI^E(iymhCZ+dU`K(6#B$3CWa`JG9=hdvEbm$B{l(F7Fal zFi@v`i3xN%HBqf2K?r7xTv^-_uPSp>U?JM%^4@*J!OIu25pZZ{X7qOQloicgckP?9 zms+@1Y>ohv5nYgIOk&WLq!VV9I%PAWBOzGugHoB$tAXwkxtbl4dUlP%M}iC;&huh! zdE07bZcs_|f#Mu!>7n1ltGv{`_1jS1d7$)gg~XJ1$G)*byrLHUIOkv|oG8qv0&*t) zIDVS_@QA7q&Gh@{5d5f%PvTJrqbluNOAFvM=E~htyKU$ObM4kv*H6PdO3PUzj)=n-i^Y!Ot#V9DTFYCtsTuOI#!3yo7TKGBCKOspiDcE# zFH8g{`l}LYHwZ{A^U8dVHCo98bZ2Ru^AIMQm&LKZ}F`h zp8M(3F2Si+ONbn@&(u8=Iwt**_AniFy{TrN>&`-5$Da#L^Hu4>@->Dt2SzVNs;^qC z+RAM=r3Ib#SCscmvYN)culAP9PLr&2j4-*q`#urDi8BZ(M1*6rzEwXSKE9l~-4@M0 z`8b7k0sA@hx$I>>(Yp2WcxK};8HpD9S`xURHH1-Z9N!(r=ne;ev*UPH^$KSvCF~4W zvE3cdLink$(Nd{YJ`!|&&`=f*S?ZR8!HypH)1Op}lf%p8Ut!M(&DriRY)egUY0dC~ z(FK~ou<_TJH3H1W z^9n?|n7d*uleYUt-=*^T^|Vnu>q*p8{O8S#8s_54N(bRTrT z(UKhj(H!`kwnmje+(QxP`?mS40R0+^kBmF2(o1rtJX-NMN$KZ=q~sZL-vrtUdD2Ky z@vwpsGZ-$B6p4b&rHR?iWS(vbnYbtwBV+tGN*{fPRPuA!A^M_BY=2E%%E<4s-wVa2 z92*l2@$pytA>JMD7~?)Sl9Ji{im&^UBjLQ1WND?#lGL5-;tJSfv|l#s*OW+4M>SAh z#kn;J{q(KZ3>+RYx&za1*qGZ0tc+9WmN7m3EOH|~>(I>|3$P_Shb>B^-PiL>-bOy*W#b|*qHV#$G{(LjXJlPNmN zL#kwK9OD~_NpKA7CzOp?XoAHfopFyA6HBa!aHr?m8b^M*cN(VPTRL!tU5}Ct-L~Rj zo?)k=q3LK?Y0(FJAe-`3q$a`zRox-vOXCs)F+o+3Cr`O4_*AM6Vl}C=Ta)mmWCu#` zC|h3RTrqH{vp>xm)%Qv7RWZwhl8`XDo`5#!;L%xP!R~8Ge)EF|e;x`DzqYVqcaH{{ z?lsOW3#>t0hOQJHl+fAC8eiU$^V`sOx;cD5MJ%&~MB$=reH}g|j|K|9Cu1gIQJ49} zIPR%$X%4FVr~;*!oncf!FV*|`VWE7-go9U(SsbtT8=gCjQ0knK0$&kPrOrZ7tPaG^ zUaE;P%zy>@T7Wa8k%%!YJj{=@Q6D{RSZsvYNak6kb%^oQ>?>hw zP2_lZM-s0K19kMbyqTX6LH!;|VnrL_*la|yU5nplIlI#&@sq}g@0?njho z5!pMA1S*frKO*YJ(Fvb2A8knctoE|oL{tXMGCoA`A~8%FgN7*Q=Gy@h-Iwq&29apGkYUKM6@Td7 zCTM6|P{=&aQZnPqJIUF;STQ>9+O;7`GVMPgLhRUH;pV*XZC1QpL0oTJeP|m+==|SN zY6VFUXBTb%%ziR~JVbLZY`|L~HXBF7`pDwf^yZWPhM+_uP0b-SQy*{D*9PiQV`@uv zXpC(gO2-xBeaslnRe4veS|4;(J3lr~| zXT@wLq1G?xvkcgJy+)^8@DXw^pf;D1FvfS(YQmO!yzaA6pJb4K{?GxC`g`^?ja)+a zP*CHuW0j6i4HO7lWrZEnytdj$tLeObuRdEwdNnyG@<|n*o@6Fook&XHBFsb1z>NF? zq4Q!i(RG~`Lr35NqRTmXP2M#vkXR{<#3IrcqGw_6^Dpx~R+wwmudvhjLEPx|7`f=P zP?P`30_gbvRzCqPQC90O9=1qcil+W1N>Uz>ern6Rsq!zD^DN4%svc~LkKhd*e|4Uu;i>DR zxW{w&aco3prhDfrD?uch!S@>cmroA2W|vfUs@C1U;1&~I5urlg1~ubL47)J($fp-l zck6xX+nYRa>*;8Ce%N?Y>w4%WqbVI{%klHAbIXOt^qP#G1a5ZI-sIebXjQlCYRS{p zMaX$iq1@Bzh!Uv`1IX^=loh&j`}uK&o^PHU0@u8#um*p2cglWmfiC~(#(o(PvDK!g zt2fG$XDb{<$OCItJKigq+)lD+F|wT6aIE^{cCFar>s=#iVkuRzuI7{W9wW$4?I!I7 zf9>B6KCAUjv(pV)8;*-tN5^CAKEJvJJ!a!v++ie9u5Ne*H3$8BD-(7)ZnKe;Z`QT) zWcPR7PG$918?W|Os^o(_NrVEbR<3cI(w1vQzpCFVn^5=y_0Y(p%4t}&KYaUji%_Tk z7+d>x`0lc?WLXpg_wRsk9Mv7n@PCP_t*gJq>m$J!%(0BGv%~k7!xDUGw0jx&?Z$b@ z2w|%1sa>n4Y|uxO+WN4V>qwGNPvZVBiF#Z@7YbjYl&b6aluE(%qwMJ{p_Q*h5l#^S z0;DHKnk#Ppp#9SB>h~=wBsWg`^hj3M$0p8k95Jr_KJy-!l>J=WOM^L(UZiuNZ0E%7 zlB_W&LR*Q1ZJKg$292=)Eb=vo7w!X7%x{YbiUYOH-w&rnOz;QM6kOUReK z(Cxs1s;6p$PEQq|X*^qz!i}5T+DA#sM`o%EaX}tW@W0lk>}}Iuw|Zgx2#Av7zy&)t zTJ@6xyqi=x&!s<-obV4y&%9dsLYVG0ziM{7U=+%9uc09H`L5v8F9K@v-d?XrC+um97#^u%2|qzAGF~7z`1D&QtjG&@ zAeIbm9@1_G8CCvTCpsYCkvbZPr?E3rS4PN4eXY$utUj+gY>%a=&D%xzPgGc3amII; z8(-nritPm%%78f7k5U+}B^uqovxU_kD{fiRX`Pokj0PP^g|V3IW)H^Bj|srzxhIY^ zue>;ZNvJC6O2%X&wN1mLbP`R+uXg_+5B{VvISQ#_a*)|6Ez-&hr)D2CK8Jj+0cyl& z?8S#fM5=U=F1<-_N=>A9>53o_ARtH=BoM$LO?n9(L{NG!L5h&j zOX$4@2wx!dCP-0GJmL2boO5$7=3*}9nR(vX&+NV5wLWWlBq1tih({J|LcCM;B3eqk zeIYC{)T%jq6U;OlY0ZyuNh1SaK#wv{_0UmIUG;rb2~IbPB}bs0ue1wqbZ90U&oe{pXMN1LwQ|r@tjML!+_{#aCIsX9OI}d6VVX zH6n1VSF~1R^5bsmC|N?dq5D0SLbAd%`N-v6Ubg2XE_z8dW0xbp3-DyS=6Lmq!#05# znv!n%dQ|1H{HB9OJiK5TueFwaQ;DeberD@t%t@6WHOb~mR8(sJBz{HcT$t!XZO}^P zg(2!8ks8Yr!h#bknsT|H5sDHY3wDbC&hMn3@q+ddrn*3`)M1=7%hJFYbZTcHr9@Q|o&=BAb z9Uy?xrKJ$=dnEHN{_sg>k?I|z)XDh_2Bxl3u6($BR4AL{>CkNa_R{UcVX1XS#=`nX zRcZ4>w>LHM1L#D`)3tIdS6a9J88o95*(m(f(;sQY{!|)#YOLi^1KedTOl_~W!WG}; z>4E2btk?^ahiCt35aMEdof3&kS7Kz-XS=iW*dg>-^7{AOBygMOv)nMl$VX zMeXq};d*t|OkDo87Edtx9!!Jv^{Le7AEWvZLzYGtT1QIQpyCwVNi;;W6Yyk+8EH{b z$o;V%?hL0s9`@$4)nb=^n;R(OzG?3K#j_JdqnJE^*0xFO@`@}>+~b4&c9WRb(4M58 z{A+HQdw<=@LMQfMLyF_I<3Xps3I?Tm#jPALEi+M)uRRsLeZGG3nT+XsB$Kd5$6(*( zGbFeH`n9mA9_~w~ym|J@FuAu;-vrkca^Shyw6e%WK78+u} zQ=XhRB9NrjQ{N8@m5P-}+2lk;bnlEFlkb;ul?@H0X|=LQ5gq-z8hI;=%te}m=f;tV z9*a&q*}p4{{4d_L?qhzU)Io6Q3%n~o*GBz8py#~wr>A-F(m`r$dhM;ck67ZXo@Nu2x>c_2EP2Q^Z-L3ZW z6Z@c$!!9=Wpp|{#Ve}EB|3hn_TDCn%c3$-`P{v$%<%T zPe$HZF|dj~vRHW<(vv~-tW*xx;mjM}vJ=2Gz1VGi7HILwgY}!oc8lF$fW^-QKj8Eu zke)tcYOLRQ(JrP5Ip>xlX``fRHU7NT1mYmHzDKv(ywG&4=1CzVUgh&Y)|To2LAbD} zA6VS$)zh?qxkW}EZp*NnJFblSQb;gSOpA#=ge1(?^;N*;;~iG!Ow%^pC8w_%TWYrV z#TgIvH}>eJbA4Hi0QUS_oYP|cpCy1XzCfAzqquY((HzI_Q_0dOMF%9kh(GZ0obaPZ$FIt z15KxE_{CyRJKFr})NMQf-U17;W8mg-B-WTk}}->sUPz;O zNRNe!&SZnXc_e!H9t3HM%a&0{sN}kL=(p$Xy*$pq9SAjEVBF?c5yf@6wC)DsogO$R z35g)SL3Fz_?u`ULk@E+k>*t5~E_`ExyTkpZWewy)fL*J>zYJE{1-o#)b>Fzl@*@PY z+(y#ueZm^V$8N~`<#%n5oQ$givYTq z1Og8=G#AR)EO-551$Ok!hLXyUNFG$)>`PnN9+WoY`d0n8%E^aIv0(m8G`D3G9e?*_ zsitD^$A^OjCL$Is#`*&lGoBv5be5dvFpW#_`wkhl!q)pab;{sIuZ;W~hS`taMhVkT z*e}e`ba9KYkzzXT4OVsBT$%N*2TdEuiTUFbnue*B4vdqyYhV|PrSAB+2guUHD;)S&AnYY7627OX?s6W zs=_3sTxWJzU2MvR>TLm&8AhU#? z!(N*ZHGkboO{dWO%3GbzMR>%uIIt$*VEm}#*u;;@hAbkVw_?GF15sA%v$t|cR{nAu zmO{@0Pu?FGpXh}k=u5BH6O?eGS?=mRP=z}S3lqTj7{QS5kcaC|1F3(C3?dWbdcIR5 zPAby6P8M`(B(P6(BQ%@N;yw?KPZ*GP?#D3Y?Ql8N!se5Oe0bdW43h>$6xCBzr?@08 z_vDKp6RM=MkD=whpiha@cd&VNWR|I32P;p>PpyT>-0)j{FNM70_W}$(b4Mfd^^xiJ z)y7~3LD7kzpRptT5vJ(<{*By^C#^rA{Qhv0keSP2HslF8)OEfUJ$ zt?mmM{1`14v)cETXUNM^t%@P1#?X80he;<;$FpRgs;$p`L@l{k1=-9Zq>u9RcrH2O zm%G5n?W_8$vz}F5^`y`*>HI>8_8-;tGo4<9eByheokoOiLROK*oMnYW0pDD;0T}Rh zom?cIxTRNVQZ(#LRSQf27H8EK)vUVLtA|RAEs*3x+}Q}~8nt-ndKmjFRz>jc-IKc~ zLQ(`SQW^-d=_5+4f;SP#A-ns!!ICVDj~0gFOn8E+k%R~DUxAbsvf3ku7qVAZI)Y;#b7z{S~?0E z<=8nzM_lK`(JTF_-R(-Bkp7k@;c)p%i!=q zQf!znSj-%?f~mhNQ+b-`f7VC4RK;u5Ln+R)?qP{8xvLt2dM!O`FK?LWD9EY$esjLW ziCu7T!}P;9-;)ylR`tsrel;GZD}6tw5=gJ328p7w50Ad1fh?j*-C3t}l&GJrRvGWl zn+a<8wu*VN?K((i*{}arP}5I@%HGADLSB{km2_jmxq~Ty(60e$iVdZj@kV-rj!&dD0>E>q>kD7sdrvIH>u2H3SEn#B)b4* zfQYoQGhYZ7B`?&-5nQ%~+GN`sLUK#Ea7#kA*vrBn(;lNQ%R#E=5=;U~ol*C?R8-?Xs1BfuLlqw){NC>w1B~3T^!e9Y$`X$emG_WV9I4J|2 z#lr7UZ}Kwl{))x~z`Q7YhR5k6(XJ8;PbKVfrP)vPD2b`s)fra`99a6{W9ry!N^4G` z#bVaA>D3UlTygAhtjnnba$Dhh%xC}Kmf={$hdCk@Hy!EMCk()iQdKIPF+WoFH$am(I7@vB{fFcUR`~ofrEDXZvkp|$Cre?T#?!T+$ z7k%out)7R=i)bXsI(MD;<=`hN0NOKOe zw~qg8%-W~ibZ`fHs)-}Nc2N=5L)30r@jK_p9j!NE!O4Gk3|*v2{0AANqpk| z+6sNaeOxo;|9VwCU}tFl7_RxpGc(dRGq(5G&$Ywj^0z>(d14Rt#llKq#dQb_K!lxZ zgK@RY=KJj$_>&vN$zy|UXYrRLHK4D_-nJ%|T4mawTNjg8!j}7KdyK>0o&ow#|5L6l zUm+=>sgaqXi(}0&T0Ob=Y$>dsk)6l%aE?tpf|BkXG}-P+p#KokihAf_WrefZm}J=z zaC-1vjmug33hm?p#(fAt>D`f^o^Lkd-n2XQrF}ytpwP)qUHO{UyVKs~Y*15l93J?5 z+|5!@tf?rEuH(=xy!~-z!A5-%nF!$SbazQt*DpX*FYO=3yD{D^{Yh;h@8hO_jWXkR zMU^aFP8?y}RK@cai80$DLeBT@)TsNc6FS;IifB-2!N8(kh0)90FI4e~pE}^?H=%YE zxnUT-v&FMC*O?}^Co#d9w9dujBfUff<{c`$8x5>n&qD*Yq|USTcQqFO`n55eBd;}Z zV)EjN3`MGjj*qw`1PaB#t77$j?BV|69JnXE0yXO#6V!*2W$6hG>x!=*EASX^c|X-&?S<(9krRK)G}Z}2hh0pFu0 zW}JYBhK6&IP}0Jx-b4N1r(vggOMDXZF*g4T`5WyccBqy9-OCCH6!!WjW;YuwCB;L- zZdIAeO@AWW#Dw%Ki@MUb63$f2e34tnWo>syKY=hpyp!sItz_<$srWacl6hi5VvNCG zQ?f37Rmhz{RXx7#HQQaXi%&84AC#y#x#Cf$CrVCxt79bpS%{iKs0yX_XEV}$DCW%G zM)JX{<4@C@Sf0r!BedMbD-#Q~i|Q^O$M${PyRTQ)PG$}u(j~|71mhecbzv+Sb~T;i zyqbPP6$gQ~js;bSM%~H*99Wn1M<8)%u@KBHd?c(7BmOO6FVSv9pw+O{&S}C+g=9#t z2Jzc=;JKjYoDjL-#F&c5&wynKv*t}gk@f^*folu{OXk7uBa`HAX< zz=89VXGOr-h9v0}QXBP#JvX7d(yDt1+YIWDhI)7NEk1>3%_Q9f5quIn@OM`)q{JAq z;_a;;(i040QDP7~n&4DqG4GUYPJ(&;&i^|WJI)fJz6o#C1W@;fJqHjx zjOF&2?+@g?r^Yha!(kTB$C@3we|Q#|W;z~WEEMj*3gZ2#2W3RS--?qkUUJ=eT?a`# zR|vEPDjFrO^YER^3~$R9Izx5lFY*A~jXk9v~GYtn8E6R|23U>HlW@g}LN3F+KbxmxQ89Vm$m|W40j2mhiYa zI$eU1TIAcJ++n{(GOZ;ShqT7`8Uzt{MjV_S3}FYq`%D^#+GT*LSe$Sv5N0+%UM>Y; z;({l&rL~;)*nPB}e6vBX!ocOdyYr&m>D1kt(nIQk1LCK}^qq#ENgXwBFiCao0Y@OC>?H*fv@MyxVpFI3jnfOzBiULS)TpPu|21Qc zj1uI}d=X|iYv()y?$jtnX@c0oc{4$r2Ngqj{*U%STFVr=*TqGVmhm<%z63*>*ax`? z3*`sH%1~1iZD}rtbGhXW%`&<5IhG$`ASPGz1y22~FAio-Eu(txh&+K3rhO0p4U=(RSP&9;^O}jpt1D)L=nQj)1D5pVbc0 zqa4Zk#03cNFCH-32MVs70Xqk@sb_TTNhDGqfx>ZykvBg>HetAAZS#WCFN*GP<=+)? zvL(Zql*5TT(laDe-cJ>s^r1U)%;ew46zWTd0RgW||DEZvGwuKG$Mp2~7CC05b->vG8wkS$C_gOFFOA;H9PXvQm z`=PH7lGHD#=Gb5I1Co-5@sn24@|bz!rMy=eJ&nFLE1s!@ogmw4#PpY^x0(y;U4|zw zm2R4UH*LC%H=cVw9$0dfuu5mXfprf|^oxPlZ))v)wjT{%=?p-tKFD4>xjA5z1GE zZ{Z!bkJkZHF+r_Cj~a8x$0CmUKOB}odh%#@JWDbyAo$!kW?fw;j7xoz1_tfXnpEk_ zNwvGQS#N+$LSd-(0B?@+FE60rO+3>h9P3A~gp`M_uO{B{v#a|S!cM#ni}QvpJ1E^( zGG2JmIVW)9#8d}KV!yX;vFf`2%L`34-@g^+=UpeG+L_3Vkaz`84?1|Y#(gf&h}I#4#@w{PGZ?U#ePY?{DG-FfWbw&UJ89wc#OySGwz=H1F+J zkb~+iO7S2hQPmA%?zZWQmF9ez{PY8{7m(W9yqx70fWy}V-)MDK3Es?dNF)1r7 z%i^2xw`BPre$Vgdx`dOR+f0_e*6TCx-L>ZRG9a7bi zUKmkos1qQcxqZ=i;zoaf?6(e0*LP5>AMN?7_dK^c+xAJCHQo_o+u_MK5O*r;na9ra zO@gJnUYEOm8J`qfkvXfahpOsZzTiVh;&0N&hMjP`gXK>5u_(LbZgv?KhR24_s%}eY@aRg`)-8$VdX>1g*XeE8G1x2^-Ryv^ z1F!mAzekfqM>=@hARQ|Ad4a2h$BEpUJx$F0y80+xOig3$_th!d=SkNE&k{8QT95Z~ zbU9+u@ymbu=tX9nucK@t3W-c<>d*j(VeuQlLl)51gdwo&%ef0!dY_%1H>QXZAbVw;1udsR#YZbg!%h>rg z4}}idTxr#4W3P+YDU>$8cYe1->dX%V57Ig7+_`T^RWe5qZnDn|ezX}k{piZe1s6(L0 z`9U}2<~h9NKcruhu-W0T>^mG<9sz2iTa>kZ*90pY=O);}D9Wn0NzIRgXxPCjj@Wlm zP@Ie9UDMM<0B-ZJn=CZy^D!UbG1|fiC!O)4lzp2w*W@3U@ef&5QU{!D4c}w-7@Xrp8GG(}F$TY23y zc(+g6Jw~wXATnSG$5!6*5@@~sw4=By=(*PE4-bo-@B8T#QrbXO7KdLCA-oj3!ljvs z{S(qr5&AT`7L{ZrR))0db?w;=_AVn)Qq3~HDBh$!W<^%QM3KL{H-Z^Y@#Ut4Eg{a@ z{`xOKWavzlW>*agk$`-gdlNPn6XN(0u@90H?eNUK#tH*P58DH&Q(vq$$;A{$;pd|i z>!1hvRO?E~A$(Q%pjIh7=gfDXKK%dF$;c~vOs0(QzoefZOJg){1shBI897CorIdhH zkBp_YMxPOa2Z1u~$Pe4M3KUpB6oT;p){c7)= zxC9IQjt329{T~JX%fBQK?8s-T31cb`{PLA4`{^xb;s3pdy+lVMDmn(gC;)2_*+bVV zUWVPe*U$M&|MM8#uk8qi&94UTSHJXI@@TFMtMpHCJL& z{iy;gAJ_X}D-YTLvsPJ~YYAfm)#nHI$!|IIDn06ZQ`4PKAF3 zjQAtDtrFjkZoIj{N7I{-5AScc;m*j7plXZwp35-4rY{E3Z;cOztkRNcYWHsw-6Q(r zq}_4pEFkR9yh~9uN02)|UMJrN&0Z`17jkzmzzgg-4A*IhIRAGistmWW!ni_-|8EL` NuBM?zjhbE9{{gmGUxEMt literal 30605 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 diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts index 2ff4979..1ff848a 100644 --- a/examples/pdf-server/main.ts +++ b/examples/pdf-server/main.ts @@ -20,8 +20,10 @@ import { pathToFileUrl, fileUrlToPath, allowedLocalFiles, + cliLocalFiles, DEFAULT_PDF, allowedLocalDirs, + writeFlags, } from "./server.js"; /** @@ -93,17 +95,33 @@ function parseArgs(): { urls: string[]; stdio: boolean; useClientRoots: boolean; + enableInteract: boolean; + debug: boolean; } { const args = process.argv.slice(2); const urls: string[] = []; let stdio = false; let useClientRoots = false; + let enableInteract = false; + let debug = false; for (const arg of args) { if (arg === "--stdio") { stdio = true; } else if (arg === "--use-client-roots") { useClientRoots = true; + } else if (arg === "--enable-interact") { + // Force-enable interact for HTTP mode. Only use when running a + // single long-lived server process (e.g. the e2e test harness) — + // the command queue is in-memory per-process, so stateless + // multi-instance deployments will drop commands. + enableInteract = true; + } else if (arg === "--debug") { + debug = true; + } else if (arg === "--writeable-uploads-root") { + // Claude Desktop mounts attachments under a dir root named "uploads"; + // by default we refuse to write there. This flag opts back in. + writeFlags.allowUploadsRoot = true; } else if (!arg.startsWith("-")) { // Convert local paths to file:// URLs, normalize arxiv URLs let url = arg; @@ -124,11 +142,13 @@ function parseArgs(): { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio, useClientRoots, + enableInteract, + debug, }; } async function main() { - const { urls, stdio, useClientRoots } = parseArgs(); + const { urls, stdio, useClientRoots, enableInteract, debug } = parseArgs(); // Register local files in whitelist for (const url of urls) { @@ -138,6 +158,7 @@ async function main() { const s = fs.statSync(filePath); if (s.isFile()) { allowedLocalFiles.add(filePath); + cliLocalFiles.add(filePath); console.error(`[pdf-server] Registered local file: ${filePath}`); } else if (s.isDirectory()) { allowedLocalDirs.add(filePath); @@ -153,10 +174,20 @@ async function main() { if (stdio) { // stdio → client is local (e.g. Claude Desktop), roots are safe - await startStdioServer(() => createServer({ useClientRoots: true })); + await startStdioServer(() => + createServer({ enableInteract: true, useClientRoots: true, debug }), + ); } else { // HTTP → client is remote, only honour roots with explicit opt-in - await startStreamableHTTPServer(() => createServer({ useClientRoots })); + if (!useClientRoots) { + console.error( + "[pdf-server] Client roots are ignored (default for remote transports). " + + "Pass --use-client-roots to allow the client to expose local directories.", + ); + } + await startStreamableHTTPServer(() => + createServer({ useClientRoots, enableInteract, debug }), + ); } } diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html index c18345d..cf1545a 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 09687ec..bbf13ad 100644 --- a/examples/pdf-server/package.json +++ b/examples/pdf-server/package.json @@ -16,7 +16,7 @@ "scripts": { "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node --external pdfjs-dist && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --external pdfjs-dist --banner \"#!/usr/bin/env node\"", "watch": "cross-env INPUT=mcp-app.html vite build --watch", - "serve": "bun --watch main.ts", + "serve": "bun --watch main.ts --enable-interact", "serve:stdio": "bun main.ts --stdio", "start": "cross-env NODE_ENV=development npm run build && npm run serve", "start:stdio": "cross-env NODE_ENV=development npm run build 1>&2 && npm run serve:stdio", @@ -28,6 +28,7 @@ "@modelcontextprotocol/sdk": "^1.24.0", "cors": "^2.8.5", "express": "^5.1.0", + "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.0.0", "zod": "^4.1.13" }, diff --git a/examples/pdf-server/screenshot.png b/examples/pdf-server/screenshot.png index bdb8f17600c0d211fd32bb60de5aceeb8f394f95..fd87f8f50e49d4c2dfe82d04e25b8f0fc322b517 100644 GIT binary patch literal 87852 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 rjN|@5s7upyUO=88#|SuhP~OsDF`B&t5ycoK0pm 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
IZV89nm26nxb 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 { server.close(); }); }); + +describe("isWritablePath", () => { + let savedFiles: Set; + let savedDirs: Set; + let savedCli: Set; + let savedAllowUploadsRoot: boolean; + + beforeEach(() => { + savedFiles = new Set(allowedLocalFiles); + savedDirs = new Set(allowedLocalDirs); + savedCli = new Set(cliLocalFiles); + allowedLocalFiles.clear(); + allowedLocalDirs.clear(); + cliLocalFiles.clear(); + savedAllowUploadsRoot = writeFlags.allowUploadsRoot; + writeFlags.allowUploadsRoot = false; + }); + + afterEach(() => { + allowedLocalFiles.clear(); + allowedLocalDirs.clear(); + cliLocalFiles.clear(); + for (const x of savedFiles) allowedLocalFiles.add(x); + for (const x of savedDirs) allowedLocalDirs.add(x); + for (const x of savedCli) cliLocalFiles.add(x); + writeFlags.allowUploadsRoot = savedAllowUploadsRoot; + }); + + it("nothing is writable when no roots and no CLI files", () => { + expect(isWritablePath("/any/path/file.pdf")).toBe(false); + }); + + it("CLI file is writable", () => { + allowedLocalFiles.add("/tmp/explicit.pdf"); + cliLocalFiles.add("/tmp/explicit.pdf"); + expect(isWritablePath("/tmp/explicit.pdf")).toBe(true); + }); + + it("MCP file root is NOT writable", () => { + allowedLocalFiles.add("/tmp/uploaded.pdf"); // from refreshRoots, no CLI + expect(isWritablePath("/tmp/uploaded.pdf")).toBe(false); + }); + + it("file under a directory root at any depth is writable", () => { + allowedLocalDirs.add("/home/user/docs"); + expect(isWritablePath("/home/user/docs/file.pdf")).toBe(true); + expect(isWritablePath("/home/user/docs/sub/deep/file.pdf")).toBe(true); + }); + + it("the directory root itself is NOT writable", () => { + allowedLocalDirs.add("/home/user/docs"); + expect(isWritablePath("/home/user/docs")).toBe(false); + }); + + it("MCP file root stays read-only even when under a directory root", () => { + // Client sent BOTH the directory and a file inside it as roots. + // The explicit file-root is the stronger signal: treat as upload. + allowedLocalDirs.add("/home/user/docs"); + allowedLocalFiles.add("/home/user/docs/uploaded.pdf"); + expect(isWritablePath("/home/user/docs/uploaded.pdf")).toBe(false); + // Siblings not sent as file roots remain writable + expect(isWritablePath("/home/user/docs/other.pdf")).toBe(true); + }); + + it("CLI file wins even if also in allowedLocalFiles", () => { + // CLI file added to both sets (main.ts does this) + allowedLocalFiles.add("/tmp/cli.pdf"); + cliLocalFiles.add("/tmp/cli.pdf"); + expect(isWritablePath("/tmp/cli.pdf")).toBe(true); + }); + + it("file outside any directory root is not writable", () => { + allowedLocalDirs.add("/home/user/docs"); + expect(isWritablePath("/home/user/other/file.pdf")).toBe(false); + expect(isWritablePath("/home/user/docsevil/file.pdf")).toBe(false); + }); + + it("dir root named 'uploads' is read-only by default", () => { + // Claude Desktop mounts the conversation's attachment drop folder as a + // directory root literally named 'uploads'. The attached PDF lives + // directly under it. + allowedLocalDirs.add("/var/folders/xy/T/claude/uploads"); + expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe( + false, + ); + // Deep nesting under the uploads root — still the same root, still no. + expect( + isWritablePath("/var/folders/xy/T/claude/uploads/sub/deep.pdf"), + ).toBe(false); + }); + + it("uploads-root guard matches basename, not substring", () => { + allowedLocalDirs.add("/home/user/my-uploads"); // contains 'uploads' but ≠ + allowedLocalDirs.add("/home/user/uploads-archive"); + expect(isWritablePath("/home/user/my-uploads/f.pdf")).toBe(true); + expect(isWritablePath("/home/user/uploads-archive/f.pdf")).toBe(true); + }); + + it("--writeable-uploads-root opts back in", () => { + allowedLocalDirs.add("/var/folders/xy/T/claude/uploads"); + writeFlags.allowUploadsRoot = true; + expect(isWritablePath("/var/folders/xy/T/claude/uploads/Form.pdf")).toBe( + true, + ); + }); + + it("CLI file under an uploads root is still writable", () => { + // Explicit CLI intent beats the uploads-basename heuristic. + allowedLocalDirs.add("/tmp/uploads"); + allowedLocalFiles.add("/tmp/uploads/explicit.pdf"); + cliLocalFiles.add("/tmp/uploads/explicit.pdf"); + expect(isWritablePath("/tmp/uploads/explicit.pdf")).toBe(true); + }); +}); + +describe("file watching", () => { + let tmpDir: string; + let tmpFile: string; + const uuid = "test-watch-uuid"; + + // Long-poll timeout is 30s — tests that poll must complete sooner. + const pollWithTimeout = async ( + client: Client, + timeoutMs = 5000, + ): Promise<{ type: string; mtimeMs?: number }[]> => { + const result = await Promise.race([ + client.callTool({ + name: "poll_pdf_commands", + arguments: { viewUUID: uuid }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("poll timeout")), timeoutMs), + ), + ]); + return ( + ((result as { structuredContent?: { commands?: unknown[] } }) + .structuredContent?.commands as { type: string; mtimeMs?: number }[]) ?? + [] + ); + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-watch-")); + tmpFile = path.join(tmpDir, "test.pdf"); + fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%test\n")); + allowedLocalFiles.add(tmpFile); + cliLocalFiles.add(tmpFile); // save_pdf test needs write scope + }); + + afterEach(() => { + stopFileWatch(uuid); + allowedLocalFiles.delete(tmpFile); + cliLocalFiles.delete(tmpFile); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("enqueues file_changed after external write", async () => { + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + startFileWatch(uuid, tmpFile); + await new Promise((r) => setTimeout(r, 50)); // let watcher settle + + fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%changed\n")); + + const cmds = await pollWithTimeout(client); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("file_changed"); + expect(cmds[0].mtimeMs).toBeGreaterThan(0); + + await client.close(); + await server.close(); + }); + + it("debounces rapid writes into one command", async () => { + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + startFileWatch(uuid, tmpFile); + await new Promise((r) => setTimeout(r, 50)); + + fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%a\n")); + fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%b\n")); + fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%c\n")); + + const cmds = await pollWithTimeout(client); + expect(cmds).toHaveLength(1); + + await client.close(); + await server.close(); + }); + + it("stopFileWatch prevents further commands", async () => { + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + startFileWatch(uuid, tmpFile); + await new Promise((r) => setTimeout(r, 50)); + stopFileWatch(uuid); + + fs.writeFileSync(tmpFile, Buffer.from("%PDF-1.4\n%x\n")); + + // Debounce window + margin — no event should fire + await new Promise((r) => setTimeout(r, 300)); + + // Poll should block (long-poll) → timeout here means no command was queued + await expect(pollWithTimeout(client, 500)).rejects.toThrow("poll timeout"); + + await client.close(); + await server.close(); + }); + + it("save_pdf returns mtimeMs in structuredContent", async () => { + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + const before = fs.statSync(tmpFile).mtimeMs; + // Ensure mtime will differ on coarse-granularity filesystems + await new Promise((r) => setTimeout(r, 10)); + + const r = await client.callTool({ + name: "save_pdf", + arguments: { + url: tmpFile, + data: Buffer.from("%PDF-1.4\nnew").toString("base64"), + }, + }); + expect(r.isError).toBeFalsy(); + const sc = r.structuredContent as { filePath: string; mtimeMs: number }; + expect(sc.filePath).toBe(tmpFile); + expect(sc.mtimeMs).toBeGreaterThanOrEqual(before); + + await client.close(); + await server.close(); + }); + + it("save_pdf refuses file roots from MCP client (not CLI)", async () => { + // Simulate: file is readable (in allowedLocalFiles via refreshRoots) + // but NOT in cliLocalFiles — it came from the client, not a CLI arg. + cliLocalFiles.delete(tmpFile); + + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + const original = fs.readFileSync(tmpFile); + const r = await client.callTool({ + name: "save_pdf", + arguments: { + url: tmpFile, + data: Buffer.from("%PDF-1.4\nshould-not-write").toString("base64"), + }, + }); + expect(r.isError).toBe(true); + const text = (r.content as { text: string }[])[0].text; + expect(text).toContain("read-only"); + // Verify the file was NOT modified + expect(fs.readFileSync(tmpFile)).toEqual(original); + + await client.close(); + await server.close(); + }); + + it("save_pdf allows files under a directory root", async () => { + // File is under a mounted directory root — but NOT itself a file root + // (a file root, even under a mounted dir, is read-only per isWritablePath). + cliLocalFiles.delete(tmpFile); + allowedLocalFiles.delete(tmpFile); + allowedLocalDirs.add(tmpDir); + + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + const r = await client.callTool({ + name: "save_pdf", + arguments: { + url: tmpFile, + data: Buffer.from("%PDF-1.4\nvia-dir-root").toString("base64"), + }, + }); + expect(r.isError).toBeFalsy(); + expect(fs.readFileSync(tmpFile, "utf8")).toBe("%PDF-1.4\nvia-dir-root"); + + allowedLocalDirs.delete(tmpDir); + await client.close(); + await server.close(); + }); + + // fs.watch on a file that gets replaced via rename: on macOS (kqueue) + // the watcher reliably fires a "rename" event which our re-attach logic + // handles. On Linux (inotify), a watcher on the old inode often gets no + // event at all — inotify watches inodes, and the rename just atomically + // swaps the directory entry to a NEW inode. Directory-level watching + // would fix this but isn't what we do. Skip on non-darwin. + it.skipIf(process.platform !== "darwin")( + "detects atomic rename (macOS kqueue only)", + async () => { + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + + startFileWatch(uuid, tmpFile); + await new Promise((r) => setTimeout(r, 50)); + + // Simulate vim/vscode: write to temp, rename over original + const tmpWrite = tmpFile + ".swp"; + fs.writeFileSync(tmpWrite, Buffer.from("%PDF-1.4\n%atomic\n")); + fs.renameSync(tmpWrite, tmpFile); + + const cmds = await pollWithTimeout(client); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("file_changed"); + + await client.close(); + await server.close(); + }, + ); +}); + +describe("interact tool", () => { + // Helper: connected server+client pair with interact enabled. + // Command queues are MODULE-LEVEL (shared across server instances), so each + // test uses a distinct viewUUID to avoid cross-test interference. + async function connect() { + const server = createServer({ enableInteract: true }); + const client = new Client({ name: "t", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + return { server, client }; + } + + // Helper: poll with an outer deadline so a failing test doesn't hang for the + // full 30s long-poll. Safe ONLY when a command is already enqueued — poll + // then returns after the 200ms batch window. + async function poll(client: Client, uuid: string, timeoutMs = 2000) { + const result = await Promise.race([ + client.callTool({ + name: "poll_pdf_commands", + arguments: { viewUUID: uuid }, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("poll timeout")), timeoutMs), + ), + ]); + return ((result as { structuredContent?: { commands?: unknown[] } }) + .structuredContent?.commands ?? []) as Array>; + } + + function firstText(r: Awaited>): string { + return (r.content as Array<{ type: string; text: string }>)[0].text; + } + + it("enqueue → poll roundtrip delivers the command", async () => { + const { server, client } = await connect(); + const uuid = "test-interact-roundtrip"; + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: uuid, action: "navigate", page: 5 }, + }); + expect(r.isError).toBeFalsy(); + expect(firstText(r)).toContain("Queued"); + expect(firstText(r)).toContain("page 5"); + + // Core mechanism: the viewer polls for what the model enqueued. + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("navigate"); + expect(cmds[0].page).toBe(5); + + await client.close(); + await server.close(); + }); + + it("navigate without `page` returns isError with a helpful message", async () => { + const { server, client } = await connect(); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "test-err-nav", action: "navigate" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("navigate"); + expect(firstText(r)).toContain("page"); + + await client.close(); + await server.close(); + }); + + it("fill_form without `fields` returns isError with a helpful message", async () => { + const { server, client } = await connect(); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "test-err-fill", action: "fill_form" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("fill_form"); + expect(firstText(r)).toContain("fields"); + + await client.close(); + await server.close(); + }); + + it("add_annotations without `annotations` returns isError with a helpful message", async () => { + const { server, client } = await connect(); + + const r = await client.callTool({ + name: "interact", + arguments: { viewUUID: "test-err-ann", action: "add_annotations" }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("add_annotations"); + expect(firstText(r)).toContain("annotations"); + + await client.close(); + await server.close(); + }); + + it("isolates command queues across distinct viewUUIDs", async () => { + const { server, client } = await connect(); + const uuidA = "test-isolate-A"; + const uuidB = "test-isolate-B"; + + await client.callTool({ + name: "interact", + arguments: { viewUUID: uuidA, action: "navigate", page: 3 }, + }); + await client.callTool({ + name: "interact", + arguments: { viewUUID: uuidB, action: "search", query: "quantum" }, + }); + + const cmdsA = await poll(client, uuidA); + expect(cmdsA).toHaveLength(1); + expect(cmdsA[0].type).toBe("navigate"); + expect(cmdsA[0].page).toBe(3); + + const cmdsB = await poll(client, uuidB); + expect(cmdsB).toHaveLength(1); + expect(cmdsB[0].type).toBe("search"); + expect(cmdsB[0].query).toBe("quantum"); + + await client.close(); + await server.close(); + }); + + // SKIPPED: the unknown-UUID path enters the long-poll branch and blocks for + // the full LONG_POLL_TIMEOUT_MS (30s, module-local const, not configurable). + // The handler does dequeue [] at the end, so the return value IS + // {commands: []} — but there's no fast path to reach it without waiting. + // See the `stopFileWatch prevents further commands` test above for indirect + // coverage of the same blocking behaviour. + it.skip("poll with unknown viewUUID returns {commands: []} after long-poll", () => {}); + + it("fill_form passes all fields through when viewFieldNames is not registered", async () => { + const { server, client } = await connect(); + // Fresh UUID never seen by display_pdf → viewFieldNames.get(uuid) is + // undefined → the known-fields guard (`knownFields && !knownFields.has()`) + // is falsy for every field → everything is enqueued. + const uuid = "test-fillform-passthrough"; + + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: uuid, + action: "fill_form", + fields: [ + { name: "anything", value: "goes" }, + { name: "unchecked", value: true }, + ], + }, + }); + expect(r.isError).toBeFalsy(); + expect(firstText(r)).toContain("Filled 2 field(s)"); + // No rejection complaint — the registry has no entry for this UUID + expect(firstText(r)).not.toContain("Unknown"); + + const cmds = await poll(client, uuid); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("fill_form"); + const fields = cmds[0].fields as Array<{ name: string; value: unknown }>; + expect(fields).toHaveLength(2); + expect(fields.map((f) => f.name).sort()).toEqual(["anything", "unchecked"]); + + // Note: the "registered → reject unknown" branch needs viewFieldNames + // populated, which only happens inside display_pdf (requires a real PDF). + // That map isn't exported, so the rejection path is covered by e2e only. + + await client.close(); + await server.close(); + }); + + // SECURITY: resolveImageAnnotation must not read arbitrary local files. + // The model controls imageUrl; without validation it's an exfil primitive + // (readFile → base64 → iframe → get_screenshot reads it back). + describe("add_annotations image: imageUrl validation", () => { + let savedDirs: Set; + beforeEach(() => { + savedDirs = new Set(allowedLocalDirs); + allowedLocalDirs.clear(); + }); + afterEach(() => { + allowedLocalDirs.clear(); + for (const d of savedDirs) allowedLocalDirs.add(d); + }); + + it("rejects local path outside allowed roots", async () => { + const { server, client } = await connect(); + // Whitelist a harmless temp dir; target a path clearly outside it. + allowedLocalDirs.add(os.tmpdir()); + const target = path.join(os.homedir(), ".ssh", "id_rsa"); + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "sec-local", + action: "add_annotations", + annotations: [{ type: "image", id: "i1", page: 1, imageUrl: target }], + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("imageUrl rejected"); + await client.close(); + await server.close(); + }); + + it("rejects http:// URL (SSRF)", async () => { + const { server, client } = await connect(); + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "sec-http", + action: "add_annotations", + annotations: [ + { + type: "image", + id: "i1", + page: 1, + imageUrl: "http://169.254.169.254/latest/meta-data/", + }, + ], + }, + }); + expect(r.isError).toBe(true); + expect(firstText(r)).toContain("imageUrl rejected"); + await client.close(); + await server.close(); + }); + + it("accepts path under an allowed dir (reaches readFile)", async () => { + const { server, client } = await connect(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-imgurl-")); + allowedLocalDirs.add(dir); + // Minimal valid PNG (1x1 transparent): 8-byte sig + IHDR + IDAT + IEND. + const png = Buffer.from( + "89504E470D0A1A0A0000000D49484452000000010000000108060000001F15C4890000000A49444154789C6360000000000200015E9AFE400000000049454E44AE426082", + "hex", + ); + const imgPath = path.join(dir, "sig.png"); + fs.writeFileSync(imgPath, png); + try { + const r = await client.callTool({ + name: "interact", + arguments: { + viewUUID: "sec-ok", + action: "add_annotations", + annotations: [ + { type: "image", id: "i1", page: 1, imageUrl: imgPath }, + ], + }, + }); + // No security rejection; readFile succeeds; command enqueued. + expect(r.isError).toBeFalsy(); + const cmds = await poll(client, "sec-ok"); + expect(cmds).toHaveLength(1); + expect(cmds[0].type).toBe("add_annotations"); + const anns = cmds[0].annotations as Array>; + // validateUrl passed → readFile ran → imageData populated. + expect(typeof anns[0].imageData).toBe("string"); + expect((anns[0].imageData as string).length).toBeGreaterThan(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + await client.close(); + await server.close(); + }); + }); +}); diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts index 0f835b7..5bc2502 100644 --- a/examples/pdf-server/server.ts +++ b/examples/pdf-server/server.ts @@ -25,6 +25,42 @@ import { type CallToolResult, type ReadResourceResult, } from "@modelcontextprotocol/sdk/types.js"; +// Use the legacy build to avoid DOMMatrix dependency in Node.js +import { + getDocument, + VerbosityLevel, + version as PDFJS_VERSION, +} from "pdfjs-dist/legacy/build/pdf.mjs"; + +/** + * PDF Standard-14 fonts from CDN. Used by both server and viewer so we + * declare a single well-known origin in CSP connectDomains. + * + * pdf.js in Node defaults to NodeStandardFontDataFactory (fs.readFile) which + * can't fetch URLs, so we pass {@link FetchStandardFontDataFactory} alongside. + * The browser viewer uses the DOM factory by default and just needs the URL. + */ +export const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${PDFJS_VERSION}/standard_fonts/`; +const STANDARD_FONT_ORIGIN = "https://unpkg.com"; + +/** pdf.js font factory that uses fetch() instead of fs.readFile. */ +class FetchStandardFontDataFactory { + baseUrl: string | null; + constructor({ baseUrl = null }: { baseUrl?: string | null }) { + this.baseUrl = baseUrl; + } + async fetch({ filename }: { filename: string }): Promise { + if (!this.baseUrl) throw new Error("standardFontDataUrl not provided"); + const url = `${this.baseUrl}${filename}`; + const res = await globalThis.fetch(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); + } +} +import type { + PrimitiveSchemaDefinition, + ElicitResult, +} from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; // ============================================================================= @@ -44,17 +80,326 @@ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds /** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */ export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB -/** Allowed local file paths (populated from CLI args) */ +/** Allowed local file paths (CLI args + file roots — read access). */ export const allowedLocalFiles = new Set(); -/** Allowed local directories (populated from MCP roots) */ +/** Allowed local directories (CLI args + directory roots — read access). */ export const allowedLocalDirs = new Set(); +/** + * Subset of allowedLocalFiles that came from CLI args (not MCP roots). + * Only these individual files are writable. File roots from the client + * are uploaded copies in ad-hoc hidden folders — treat as read-only. + * Directory roots are mounted folders; files UNDER them are writable. + */ +export const cliLocalFiles = new Set(); + +/** + * Write-permission flags. Object wrapper (not a bare `let`) so main.ts can + * mutate via the exported binding without re-import gymnastics — same + * pattern as the Sets above. + */ +export const writeFlags = { + /** + * Claude Desktop mounts its per-conversation drop folder as a directory + * root whose basename is literally `uploads`. Files in there are one-shot + * copies the client doesn't expect us to overwrite. Default: read-only. + * `--writeable-uploads-root` flips this for local testing. + */ + allowUploadsRoot: false, +}; + +/** + * Saving is allowed iff: + * (a) the file was passed as a CLI arg — the user explicitly named it + * when starting the server, so overwriting is clearly intentional; OR + * (b) the file is STRICTLY UNDER a directory root at any depth + * (isAncestorDir excludes rel === "", so the root itself never + * counts), AND the client did not ALSO send it as a file root. + * A file root is the client's way of saying "here's an upload" — + * treat that signal as authoritative even when the path happens + * to fall inside a mounted directory. + * + * EXCEPTION to (b): a dir root whose basename is `uploads` is treated + * as read-only unless `writeFlags.allowUploadsRoot` is set. This is how + * Claude Desktop surfaces attached files — writing back to them + * surprises the user (the attachment doesn't update). + * + * With no directory roots and no CLI files, nothing is writable. + */ +export function isWritablePath(resolved: string): boolean { + if (cliLocalFiles.has(resolved)) return true; + // MCP file root → always read-only, regardless of ancestry + if (allowedLocalFiles.has(resolved)) return false; + return [...allowedLocalDirs].some((dir) => { + if (!isAncestorDir(dir, resolved)) return false; + if (!writeFlags.allowUploadsRoot && path.basename(dir) === "uploads") { + return false; + } + return true; + }); +} + // Works both from source (server.ts) and compiled (dist/server.js) const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname; +// ============================================================================= +// Command Queue (shared across stateless server instances) +// ============================================================================= + +/** Commands expire after this many ms if never polled */ +const COMMAND_TTL_MS = 60_000; // 60 seconds + +/** Periodic sweep interval to drop stale queues */ +const SWEEP_INTERVAL_MS = 30_000; // 30 seconds + +/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */ +const POLL_BATCH_WAIT_MS = 200; +const LONG_POLL_TIMEOUT_MS = 30_000; // Max time to hold a long-poll request open + +// ============================================================================= +// Interact Tool Input Schemas (runtime validators) +// ============================================================================= +// +// Annotation structure docs live in src/pdf-annotations.ts (the TS +// interfaces) and in the interact tool description. The inputSchema +// for `annotations` accepts z.record(z.any()) to keep the model-facing +// API forgiving; adding strict validation here would be a behavior change. + +const FormField = z.object({ + name: z.string(), + value: z.union([z.string(), z.boolean()]), +}); + +const PageInterval = z.object({ + start: z.number().min(1).optional(), + end: z.number().min(1).optional(), +}); + +// ============================================================================= +// Command Queue — wire protocol shared with the viewer +// ============================================================================= + +// PdfCommand is the single source of truth for what flows through the +// poll queue. Defined once in src/commands.ts; both sides import it. +// (`import type` → no pdf-lib bundled into the server.) +import type { PdfCommand } from "./src/commands.js"; +export type { PdfCommand }; + +// ============================================================================= +// Pending get_pages Requests (request-response bridge via client) +// ============================================================================= + +// Keep well under the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC (60s) so we +// reject first and return a real error instead of the client cancelling us. +const GET_PAGES_TIMEOUT_MS = 45_000; + +interface PageDataEntry { + page: number; + text?: string; + image?: string; // base64 PNG +} + +const pendingPageRequests = new Map< + string, + (data: PageDataEntry[] | Error) => void +>(); + +/** + * Wait for the viewer to render and submit page data. + * Rejects on timeout or when the interact request is aborted upstream. + */ +function waitForPageData( + requestId: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const settle = (v: PageDataEntry[] | Error) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + pendingPageRequests.delete(requestId); + v instanceof Error ? reject(v) : resolve(v); + }; + const onAbort = () => settle(new Error("interact request cancelled")); + const timer = setTimeout( + () => settle(new Error("Timeout waiting for page data from viewer")), + GET_PAGES_TIMEOUT_MS, + ); + signal?.addEventListener("abort", onAbort); + pendingPageRequests.set(requestId, settle); + }); +} + +interface QueueEntry { + commands: PdfCommand[]; + /** Timestamp of the most recent enqueue or dequeue */ + lastActivity: number; +} + +const commandQueues = new Map(); + +/** Waiters for long-poll: resolve callback wakes up a blocked poll_pdf_commands */ +const pollWaiters = new Map void>(); + +/** Valid form field names per viewer UUID (populated during display_pdf) */ +const viewFieldNames = new Map>(); + +/** Detailed form field info per viewer UUID (populated during display_pdf) */ +const viewFieldInfo = new Map(); + +/** + * Active fs.watch per view. Only created for local files when interact is + * enabled (stdio). Watcher is re-established on `rename` events to survive + * atomic writes (vim/vscode write-to-tmp-then-rename changes the inode). + */ +interface ViewFileWatch { + filePath: string; + watcher: fs.FSWatcher; + lastMtimeMs: number; + debounce: ReturnType | null; +} +const viewFileWatches = new Map(); + +/** + * Per-view heartbeat. THIS is what the sweep iterates — not commandQueues. + * + * Why not commandQueues: display_pdf populates viewFieldNames/viewFieldInfo/ + * viewFileWatches but never touches commandQueues (only enqueueCommand does, + * and it's triply gated). And dequeueCommands deletes the entry on every poll, + * so even when it exists the sweep's TTL window is ~200ms wide. Net effect: + * the sweep found nothing and the aux maps leaked every display_pdf call. + * viewFileWatches entries hold an fs.StatWatcher (FD + timer) — slow FD + * exhaustion on HTTP --enable-interact. + */ +const viewLastActivity = new Map(); + +/** Register or refresh the heartbeat for a view. */ +function touchView(uuid: string): void { + viewLastActivity.set(uuid, Date.now()); +} + +function pruneStaleQueues(): void { + const now = Date.now(); + for (const [uuid, lastActivity] of viewLastActivity) { + if (now - lastActivity > COMMAND_TTL_MS) { + viewLastActivity.delete(uuid); + commandQueues.delete(uuid); + viewFieldNames.delete(uuid); + viewFieldInfo.delete(uuid); + stopFileWatch(uuid); + } + } +} + +// Periodic sweep so abandoned views don't leak +setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref(); + +function enqueueCommand(viewUUID: string, command: PdfCommand): void { + let entry = commandQueues.get(viewUUID); + if (!entry) { + entry = { commands: [], lastActivity: Date.now() }; + commandQueues.set(viewUUID, entry); + } + entry.commands.push(command); + entry.lastActivity = Date.now(); + touchView(viewUUID); + + // Wake up any long-polling request waiting for this viewUUID + const waiter = pollWaiters.get(viewUUID); + if (waiter) { + pollWaiters.delete(viewUUID); + waiter(); + } +} + +function dequeueCommands(viewUUID: string): PdfCommand[] { + // Poll is activity — keep the view alive even when the queue is empty + // (the common case: viewer polls every ~30s with nothing to receive). + touchView(viewUUID); + const entry = commandQueues.get(viewUUID); + if (!entry) return []; + const commands = entry.commands; + commandQueues.delete(viewUUID); + return commands; +} + +// ============================================================================= +// File Watching (local files, stdio only) +// ============================================================================= + +const FILE_WATCH_DEBOUNCE_MS = 150; + +export function startFileWatch(viewUUID: string, filePath: string): void { + const resolved = path.resolve(filePath); + let stat: fs.Stats; + try { + stat = fs.statSync(resolved); + } catch { + return; // vanished between validation and here + } + + // Replace any existing watcher for this view + stopFileWatch(viewUUID); + + const entry: ViewFileWatch = { + filePath: resolved, + watcher: null as unknown as fs.FSWatcher, + lastMtimeMs: stat.mtimeMs, + debounce: null, + }; + + const onEvent = (eventType: string): void => { + if (entry.debounce) clearTimeout(entry.debounce); + entry.debounce = setTimeout(() => { + entry.debounce = null; + let s: fs.Stats; + try { + s = fs.statSync(resolved); + } catch { + return; // gone mid-atomic-write; next rename will re-attach + } + if (s.mtimeMs === entry.lastMtimeMs) return; // spurious / already sent + entry.lastMtimeMs = s.mtimeMs; + enqueueCommand(viewUUID, { type: "file_changed", mtimeMs: s.mtimeMs }); + }, FILE_WATCH_DEBOUNCE_MS); + + // Atomic saves replace the inode — old watcher stops firing. Re-attach. + if (eventType === "rename") { + try { + entry.watcher.close(); + } catch { + /* already closed */ + } + try { + entry.watcher = fs.watch(resolved, onEvent); + } catch { + // File removed, not replaced. Leave closed; pruneStaleQueues cleans up. + } + } + }; + + try { + entry.watcher = fs.watch(resolved, onEvent); + } catch { + return; // fs.watch unsupported (e.g. some network filesystems) + } + viewFileWatches.set(viewUUID, entry); +} + +export function stopFileWatch(viewUUID: string): void { + const entry = viewFileWatches.get(viewUUID); + if (!entry) return; + if (entry.debounce) clearTimeout(entry.debounce); + try { + entry.watcher.close(); + } catch { + /* ignore */ + } + viewFileWatches.delete(viewUUID); +} + // ============================================================================= // URL Validation & Normalization // ============================================================================= @@ -439,11 +784,233 @@ async function refreshRoots(server: Server): Promise { } } +// ============================================================================= +// PDF Form Field Extraction +// ============================================================================= + +/** + * Extract form fields from a PDF and build an elicitation schema. + * Returns null if the PDF has no form fields. + */ +/** Shape of field objects returned by pdfjs-dist's getFieldObjects(). */ +interface PdfJsFieldObject { + type: string; + name: string; + editable: boolean; + exportValues?: string; + items?: Array<{ exportValue: string; displayValue: string }>; +} + +/** Detailed info about a form field, including its location on the page. */ +interface FormFieldInfo { + name: string; + type: string; + page: number; + label?: string; + /** Bounding box in model coordinates (top-left origin) */ + x: number; + y: number; + width: number; + height: number; +} + +/** + * Extract detailed form field info (name, type, page, bounding box, label) + * from a PDF. Bounding boxes are converted to model coordinates (top-left origin). + */ +async function extractFormFieldInfo( + url: string, + readRange: ( + url: string, + offset: number, + byteCount: number, + ) => Promise<{ data: Uint8Array; totalBytes: number }>, +): Promise { + const { totalBytes } = await readRange(url, 0, 1); + const { data } = await readRange(url, 0, totalBytes); + + const loadingTask = getDocument({ + data, + standardFontDataUrl: STANDARD_FONT_DATA_URL, + StandardFontDataFactory: FetchStandardFontDataFactory, + // We only introspect form fields (never render) — silence residual + // warnings like "Unimplemented border style: inset". + verbosity: VerbosityLevel.ERRORS, + }); + const pdfDoc = await loadingTask.promise; + + const fields: FormFieldInfo[] = []; + try { + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + const pageHeight = page.getViewport({ scale: 1.0 }).height; + const annotations = await page.getAnnotations(); + for (const ann of annotations) { + // Only include form widgets (annotationType 20) + if (ann.annotationType !== 20) continue; + if (!ann.rect) continue; + + const fieldName = ann.fieldName || ""; + const fieldType = ann.fieldType || "unknown"; + + // PDF rect is [x1, y1, x2, y2] in bottom-left origin + const x1 = Math.min(ann.rect[0], ann.rect[2]); + const y1 = Math.min(ann.rect[1], ann.rect[3]); + const x2 = Math.max(ann.rect[0], ann.rect[2]); + const y2 = Math.max(ann.rect[1], ann.rect[3]); + const width = x2 - x1; + const height = y2 - y1; + + // Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height + const modelY = pageHeight - y2; + + fields.push({ + name: fieldName, + type: fieldType, + page: i, + x: Math.round(x1), + y: Math.round(modelY), + width: Math.round(width), + height: Math.round(height), + ...(ann.alternativeText ? { label: ann.alternativeText } : undefined), + }); + } + } + } finally { + pdfDoc.destroy(); + } + + return fields; +} + +async function extractFormSchema( + url: string, + readRange: ( + url: string, + offset: number, + byteCount: number, + ) => Promise<{ data: Uint8Array; totalBytes: number }>, +): Promise<{ + type: "object"; + properties: Record; + required?: string[]; +} | null> { + // Read full PDF bytes + const { totalBytes } = await readRange(url, 0, 1); + const { data } = await readRange(url, 0, totalBytes); + + const loadingTask = getDocument({ + data, + standardFontDataUrl: STANDARD_FONT_DATA_URL, + StandardFontDataFactory: FetchStandardFontDataFactory, + // We only introspect form fields (never render) — silence residual + // warnings like "Unimplemented border style: inset". + verbosity: VerbosityLevel.ERRORS, + }); + const pdfDoc = await loadingTask.promise; + + let fieldObjects: Record | null; + try { + fieldObjects = (await pdfDoc.getFieldObjects()) as Record< + string, + PdfJsFieldObject[] + > | null; + } catch { + pdfDoc.destroy(); + return null; + } + if (!fieldObjects || Object.keys(fieldObjects).length === 0) { + pdfDoc.destroy(); + return null; + } + + const properties: Record = {}; + for (const [name, fields] of Object.entries(fieldObjects)) { + const field = fields[0]; // first widget determines the type + if (!field.editable) continue; + + switch (field.type) { + case "text": + properties[name] = { type: "string", title: name }; + break; + case "checkbox": + properties[name] = { type: "boolean", title: name }; + break; + case "radiobutton": { + const options = fields + .map((f) => f.exportValues) + .filter((v): v is string => !!v && v !== "Off"); + properties[name] = + options.length > 0 + ? { type: "string", title: name, enum: options } + : { type: "string", title: name }; + break; + } + case "combobox": + case "listbox": { + const items = field.items?.map((i) => i.exportValue).filter(Boolean); + properties[name] = + items && items.length > 0 + ? { type: "string", title: name, enum: items } + : { type: "string", title: name }; + break; + } + // Skip "button" (push buttons) and unknown types + } + } + + // Collect alternativeText labels from per-page annotations + // (getFieldObjects doesn't include them) + const fieldLabels = new Map(); + try { + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + const annotations = await page.getAnnotations(); + for (const ann of annotations) { + if (ann.fieldName && ann.alternativeText) { + fieldLabels.set(ann.fieldName, ann.alternativeText); + } + } + } + } catch { + // ignore + } + + // Use labels as titles where available + for (const [name, prop] of Object.entries(properties)) { + const label = fieldLabels.get(name); + if (label) { + prop.title = label; + } + } + + // If any editable field has a mechanical name (no human-readable label), + // elicitation would be confusing — return null to skip it. + const hasMechanicalNames = Object.keys(properties).some((name) => { + if (fieldLabels.has(name)) return false; + return /[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name); + }); + + pdfDoc.destroy(); + if (Object.keys(properties).length === 0) return null; + if (hasMechanicalNames) return null; + + return { type: "object", properties }; +} + // ============================================================================= // MCP Server Factory // ============================================================================= export interface CreateServerOptions { + /** + * Enable the `interact` tool and related command-queue infrastructure + * (in-memory command queue, `poll_pdf_commands`, `submit_page_data`). + * Only suitable for single-instance deployments (e.g. stdio transport). + * Defaults to false — server exposes only `list_pdfs` and `display_pdf` (read-only). + */ + enableInteract?: boolean; + /** * Whether to honour MCP roots sent by the client. * @@ -463,10 +1030,18 @@ export interface CreateServerOptions { * @default false */ useClientRoots?: boolean; + + /** + * Emit debug metadata to the viewer (currently: allowed roots shown + * in a floating bubble). Toggled by the `--debug` CLI flag. + */ + debug?: boolean; } export function createServer(options: CreateServerOptions = {}): McpServer { - const { useClientRoots = false } = options; + const { enableInteract = false, useClientRoots = false } = options; + const debug = options.debug ?? false; + const disableInteract = !enableInteract; const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); if (useClientRoots) { @@ -480,11 +1055,6 @@ export function createServer(options: CreateServerOptions = {}): McpServer { await refreshRoots(server.server); }, ); - } else { - console.error( - "[pdf-server] Client roots are ignored (default for remote transports). " + - "Pass --use-client-roots to allow the client to expose local directories.", - ); } // Create session-local cache (isolated per server instance) @@ -496,23 +1066,61 @@ export function createServer(options: CreateServerOptions = {}): McpServer { "List available PDFs that can be displayed", {}, async (): Promise => { - const pdfs: Array<{ url: string; type: "local" | "remote" }> = []; + const seen = new Set(); + const localFiles: string[] = []; + const addLocal = (filePath: string) => { + const url = pathToFileUrl(filePath); + if (seen.has(url)) return; + seen.add(url); + localFiles.push(url); + }; - // Add local files - for (const filePath of allowedLocalFiles) { - pdfs.push({ url: pathToFileUrl(filePath), type: "local" }); - } + // Explicitly registered files (CLI args + file roots) + for (const filePath of allowedLocalFiles) addLocal(filePath); + + // Walk directory roots for *.pdf files + const WALK_MAX_DEPTH = 8; + const WALK_MAX_FILES = 500; + let truncated = false; + const walk = async (dir: string, depth: number): Promise => { + if (depth > WALK_MAX_DEPTH || localFiles.length >= WALK_MAX_FILES) { + truncated ||= localFiles.length >= WALK_MAX_FILES; + return; + } + let entries; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; // unreadable — skip silently + } + for (const e of entries) { + if (localFiles.length >= WALK_MAX_FILES) { + truncated = true; + return; + } + // Skip dotfiles/dirs and common noise + if (e.name.startsWith(".") || e.name === "node_modules") continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + await walk(full, depth + 1); + } else if (e.isFile() && /\.pdf$/i.test(e.name)) { + addLocal(full); + } + } + }; + for (const dir of allowedLocalDirs) await walk(dir, 0); // Build text const parts: string[] = []; - if (pdfs.length > 0) { - parts.push( - `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}`, - ); + if (localFiles.length > 0) { + const header = truncated + ? `Available PDFs (showing first ${WALK_MAX_FILES}):` + : `Available PDFs:`; + parts.push(`${header}\n${localFiles.map((u) => `- ${u}`).join("\n")}`); } if (allowedLocalDirs.size > 0) { parts.push( - `Allowed local directories (from client roots):\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`, + `Allowed local directories:\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`, ); } parts.push( @@ -522,8 +1130,9 @@ export function createServer(options: CreateServerOptions = {}): McpServer { return { content: [{ type: "text", text: parts.join("\n\n") }], structuredContent: { - localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url), + localFiles, allowedDirectories: [...allowedLocalDirs], + truncated, }, }; }, @@ -613,27 +1222,76 @@ export function createServer(options: CreateServerOptions = {}): McpServer { "display_pdf", { title: "Display PDF", - description: `Display an interactive PDF viewer. + description: disableInteract + ? `Show and render a PDF in a read-only viewer. -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`, +Use this tool when the user wants to view or read a PDF. The renderer displays the document for viewing. + +Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.` + : `Open a PDF in an interactive viewer. Call this ONCE per PDF. + +**All follow-up actions go through the \`interact\` tool** with the returned viewUUID — annotating, signing, stamping, filling forms, navigating, searching, extracting text/screenshots. Calling display_pdf again creates a SEPARATE viewer with a different viewUUID — interact calls using the new UUID will not reach the viewer the user already sees. + +Returns a viewUUID in structuredContent. Pass it to \`interact\`: +- add_annotations, update_annotations, remove_annotations, highlight_text +- fill_form (fill PDF form fields) +- navigate, search, find, search_navigate, zoom +- get_text, get_screenshot (extract content) + +Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL. +Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`, inputSchema: { url: z .string() .default(DEFAULT_PDF) .describe("PDF URL or local file path"), page: z.number().min(1).default(1).describe("Initial page"), + ...(disableInteract + ? {} + : { + elicit_form_inputs: z + .boolean() + .default(false) + .describe( + "If true and the PDF has form fields, prompt the user to fill them before displaying", + ), + }), }, outputSchema: z.object({ + viewUUID: z + .string() + .describe( + "UUID for this viewer instance" + + (disableInteract ? "" : " — pass to interact tool"), + ), url: z.string(), initialPage: z.number(), totalBytes: z.number(), + formFieldValues: z + .record(z.string(), z.union([z.string(), z.boolean()])) + .optional() + .describe("Form field values filled by the user via elicitation"), + formFields: z + .array( + z.object({ + name: z.string(), + type: z.string(), + page: z.number(), + label: z.string().optional(), + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + }), + ) + .optional() + .describe( + "Form fields with bounding boxes in model coordinates (top-left origin)", + ), }), _meta: { ui: { resourceUri: RESOURCE_URI } }, }, - async ({ url, page }): Promise => { + async ({ url, page, elicit_form_inputs }): Promise => { const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; const validation = validateUrl(normalized); @@ -646,21 +1304,1096 @@ Accepts: // Probe file size so the client can set up range transport without an extra fetch const { totalBytes } = await readPdfRange(normalized, 0, 1); + const uuid = randomUUID(); + // Start the heartbeat now so the sweep can clean up viewFieldNames/ + // viewFieldInfo/viewFileWatches even if no interact calls ever happen. + if (!disableInteract) touchView(uuid); + + // Check writability (governs save button; see isWritablePath doc). + // Also requires OS-level W_OK so we don't lie on read-only mounts. + let writable = false; + let debugResolved: string | undefined; // only used when --debug + if (isFileUrl(normalized) || isLocalPath(normalized)) { + const localPath = isFileUrl(normalized) + ? fileUrlToPath(normalized) + : decodeURIComponent(normalized); + const resolved = path.resolve(localPath); + debugResolved = resolved; + if (isWritablePath(resolved)) { + try { + await fs.promises.access(resolved, fs.constants.W_OK); + writable = true; + } catch { + // Not writable — leave false + } + } + // Watch for external changes (stdio only — needs the poll channel) + if (!disableInteract) { + startFileWatch(uuid, localPath); + } + } + + // Extract form field schema (used for elicitation and field name validation) + let formSchema: Awaited> = null; + try { + formSchema = await extractFormSchema(normalized, readPdfRange); + } catch { + // Non-fatal — PDF may not have form fields + } + if (formSchema) { + viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties))); + } + + // Extract detailed form field info (page, bounding box, label) + let fieldInfo: FormFieldInfo[] = []; + try { + fieldInfo = await extractFormFieldInfo(normalized, readPdfRange); + if (fieldInfo.length > 0) { + viewFieldInfo.set(uuid, fieldInfo); + // Also populate viewFieldNames from field info if not already set + if (!viewFieldNames.has(uuid)) { + viewFieldNames.set( + uuid, + new Set(fieldInfo.map((f) => f.name).filter(Boolean)), + ); + } + } + } catch { + // Non-fatal + } + + // Elicit form field values if requested and client supports it + let formFieldValues: Record | undefined; + let elicitResult: ElicitResult | undefined; + if (elicit_form_inputs && formSchema) { + const clientCaps = server.server.getClientCapabilities(); + if (clientCaps?.elicitation?.form) { + try { + elicitResult = await server.server.elicitInput({ + message: `Please fill in the PDF form fields for "${normalized.split("/").pop() || normalized}":`, + requestedSchema: formSchema, + }); + if (elicitResult.action === "accept" && elicitResult.content) { + formFieldValues = {}; + for (const [k, v] of Object.entries(elicitResult.content)) { + if (typeof v === "string" || typeof v === "boolean") { + formFieldValues[k] = v; + } + } + // Queue fill_form command so the viewer picks it up + enqueueCommand(uuid, { + type: "fill_form", + fields: Object.entries(formFieldValues).map( + ([name, value]) => ({ name, value }), + ), + }); + } + } catch (err) { + // Elicitation failed — continue without form values + console.error("[pdf-server] Form elicitation failed:", err); + } + } + } + + const contentParts: Array<{ type: "text"; text: string }> = [ + { + type: "text", + text: disableInteract + ? `Displaying PDF: ${normalized}` + : `PDF opened. viewUUID: ${uuid} + +→ To annotate, sign, stamp, fill forms, navigate, or extract: call \`interact\` with this viewUUID. +→ DO NOT call display_pdf again — that spawns a separate viewer with a different viewUUID; your interact calls would target the new empty one, not the one the user is looking at. + +URL: ${normalized}`, + }, + ]; + + if (formFieldValues && Object.keys(formFieldValues).length > 0) { + const fieldSummary = Object.entries(formFieldValues) + .map( + ([name, value]) => + ` ${name}: ${typeof value === "boolean" ? (value ? "checked" : "unchecked") : value}`, + ) + .join("\n"); + contentParts.push({ + type: "text", + text: `\nUser-provided form field values:\n${fieldSummary}`, + }); + } else if ( + elicit_form_inputs && + elicitResult && + elicitResult.action !== "accept" + ) { + contentParts.push({ + type: "text", + text: `\nForm elicitation was ${elicitResult.action}d by the user.`, + }); + } + + // Include detailed form field info so the model can locate and fill fields + if (fieldInfo.length > 0) { + // Group by page + const byPage = new Map(); + for (const f of fieldInfo) { + let list = byPage.get(f.page); + if (!list) { + list = []; + byPage.set(f.page, list); + } + list.push(f); + } + const lines: string[] = [ + `\nForm fields (${fieldInfo.length})${disableInteract ? "" : " — use fill_form with {name, value}"}:`, + ]; + for (const [pg, fields] of [...byPage.entries()].sort( + (a, b) => a[0] - b[0], + )) { + lines.push(` Page ${pg}:`); + for (const f of fields) { + const label = f.label ? ` "${f.label}"` : ""; + const nameStr = f.name || "(unnamed)"; + lines.push( + ` ${nameStr}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}`, + ); + } + } + contentParts.push({ type: "text", text: lines.join("\n") }); + } else { + // Fallback to simple field name listing if detailed info unavailable + const fieldNames = viewFieldNames.get(uuid); + if (fieldNames && fieldNames.size > 0) { + contentParts.push({ + type: "text", + text: `\nForm fields${disableInteract ? "" : " available for fill_form"}: ${[...fieldNames].join(", ")}`, + }); + } + } return { - content: [{ type: "text", text: `Displaying PDF: ${normalized}` }], + content: contentParts, structuredContent: { + viewUUID: uuid, url: normalized, initialPage: page, totalBytes, + ...(formFieldValues ? { formFieldValues } : {}), + ...(fieldInfo.length > 0 ? { formFields: fieldInfo } : {}), }, _meta: { - viewUUID: randomUUID(), + viewUUID: uuid, + interactEnabled: !disableInteract, + writable, + // Debug: viewer renders this in a floating bubble (--debug flag). + ...(debug + ? { + _debug: { + resolved: debugResolved, + writable, + isWritablePath: debugResolved + ? isWritablePath(debugResolved) + : undefined, + cliLocalFiles: [...cliLocalFiles], + allowedLocalFiles: [...allowedLocalFiles], + allowedLocalDirs: [...allowedLocalDirs], + }, + } + : {}), }, }; }, ); + if (!disableInteract) { + // Schema for a single interact command (used in commands array) + const InteractCommandSchema = z.object({ + action: z + .enum([ + "navigate", + "search", + "find", + "search_navigate", + "zoom", + "add_annotations", + "update_annotations", + "remove_annotations", + "highlight_text", + "fill_form", + "get_text", + "get_screenshot", + ]) + .describe("Action to perform"), + page: z + .number() + .min(1) + .optional() + .describe( + "Page number (for navigate, highlight_text, get_screenshot, get_text)", + ), + query: z + .string() + .optional() + .describe("Search text (for search / find / highlight_text)"), + matchIndex: z + .number() + .min(0) + .optional() + .describe("Match index (for search_navigate)"), + scale: z + .number() + .min(0.5) + .max(3.0) + .optional() + .describe("Zoom scale, 1.0 = 100% (for zoom)"), + annotations: z + .array(z.record(z.string(), z.any())) + .optional() + .describe( + "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.", + ), + ids: z + .array(z.string()) + .optional() + .describe("Annotation IDs (for remove_annotations)"), + color: z + .string() + .optional() + .describe("Color override (for highlight_text)"), + content: z + .string() + .optional() + .describe("Tooltip/note content (for highlight_text)"), + fields: z + .array(FormField) + .optional() + .describe( + "Form fields to fill (for fill_form): { name, value } where value is string or boolean", + ), + intervals: z + .array(PageInterval) + .optional() + .describe( + "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.", + ), + }); + + type InteractCommand = z.infer; + type ContentPart = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string }; + + /** + * Resolve an image annotation: fetch imageUrl → imageData if needed, + * auto-detect dimensions, and set defaults for x/y. + * + * SECURITY: imageUrl is model-controlled. It must pass the same + * validateUrl() gate as display_pdf/save_pdf — otherwise the model + * can request `{imageUrl:"/Users/x/.ssh/id_rsa"}`, we'd readFile it, + * base64 the bytes, ship them to the iframe, and get_screenshot (or + * any future echo path) reads them back. Throws on rejection so the + * tool result carries the error; silent skip hides the attack attempt. + */ + async function resolveImageAnnotation( + ann: Record, + ): Promise { + // Fetch image data from URL if no imageData provided + if (!ann.imageData && ann.imageUrl) { + const url = String(ann.imageUrl); + // Same gate as every other local/remote read in this server. + // Local: must be in allowedLocalFiles or under allowedLocalDirs. + // Remote: must be https://. + const check = validateUrl(url); + if (!check.valid) { + throw new Error( + `imageUrl rejected by validateUrl: ${check.error ?? url}`, + ); + } + let imgBytes: Uint8Array; + if (url.startsWith("https://")) { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`); + imgBytes = new Uint8Array(await resp.arrayBuffer()); + } else { + // validateUrl already confirmed this path is under an allowed root. + const filePath = isFileUrl(url) + ? fileUrlToPath(url) + : decodeURIComponent(url); + imgBytes = await fs.promises.readFile(path.resolve(filePath)); + } + ann.imageData = Buffer.from(imgBytes).toString("base64"); + } + + // Auto-detect mimeType from magic bytes if not set + if (ann.imageData && !ann.mimeType) { + const bytes = Buffer.from(ann.imageData, "base64"); + if ( + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ) { + ann.mimeType = "image/png"; + } else { + ann.mimeType = "image/jpeg"; + } + } + + // Auto-detect dimensions from image if not specified + if (ann.imageData && (ann.width == null || ann.height == null)) { + const dims = detectImageDimensions( + Buffer.from(ann.imageData, "base64"), + ); + if (dims) { + const maxWidth = 200; // default max width in PDF points + const aspectRatio = dims.height / dims.width; + ann.width = ann.width ?? Math.min(dims.width, maxWidth); + ann.height = ann.height ?? ann.width * aspectRatio; + } else { + ann.width = ann.width ?? 200; + ann.height = ann.height ?? 200; + } + } + + // Default position if not specified + ann.x = ann.x ?? 72; + ann.y = ann.y ?? 72; + } + + /** + * Detect image dimensions from PNG or JPEG bytes. + */ + function detectImageDimensions( + bytes: Buffer, + ): { width: number; height: number } | null { + // PNG: width at offset 16 (4 bytes BE), height at offset 20 (4 bytes BE) + if ( + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ) { + if (bytes.length >= 24) { + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + return { width, height }; + } + } + // JPEG: scan for SOF0/SOF2 markers (0xFF 0xC0 / 0xFF 0xC2) + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + let offset = 2; + while (offset < bytes.length - 8) { + if (bytes[offset] !== 0xff) break; + const marker = bytes[offset + 1]; + if (marker === 0xc0 || marker === 0xc2) { + const height = bytes.readUInt16BE(offset + 5); + const width = bytes.readUInt16BE(offset + 7); + return { width, height }; + } + const segLen = bytes.readUInt16BE(offset + 2); + offset += 2 + segLen; + } + } + return null; + } + + /** Process a single interact command. Returns content parts and an isError flag. */ + async function processInteractCommand( + uuid: string, + cmd: InteractCommand, + signal?: AbortSignal, + ): Promise<{ content: ContentPart[]; isError?: boolean }> { + const { + action, + page, + query, + matchIndex, + scale, + annotations, + ids, + color, + content, + fields, + intervals, + } = cmd; + + let description: string; + switch (action) { + case "navigate": + if (page == null) + return { + content: [{ type: "text", text: "navigate requires `page`" }], + isError: true, + }; + enqueueCommand(uuid, { type: "navigate", page }); + description = `navigate to page ${page}`; + break; + case "search": + if (!query) + return { + content: [{ type: "text", text: "search requires `query`" }], + isError: true, + }; + enqueueCommand(uuid, { type: "search", query }); + description = `search for "${query}"`; + break; + case "find": + if (!query) + return { + content: [{ type: "text", text: "find requires `query`" }], + isError: true, + }; + enqueueCommand(uuid, { type: "find", query }); + description = `find "${query}" (silent)`; + break; + case "search_navigate": + if (matchIndex == null) + return { + content: [ + { + type: "text", + text: "search_navigate requires `matchIndex`", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { type: "search_navigate", matchIndex }); + description = `go to match #${matchIndex}`; + break; + case "zoom": + if (scale == null) + return { + content: [{ type: "text", text: "zoom requires `scale`" }], + isError: true, + }; + enqueueCommand(uuid, { type: "zoom", scale }); + description = `zoom to ${Math.round(scale * 100)}%`; + break; + case "add_annotations": + if (!annotations || annotations.length === 0) + return { + content: [ + { + type: "text", + text: "add_annotations requires `annotations` array", + }, + ], + isError: true, + }; + // Resolve image annotations: fetch imageUrl → imageData, auto-detect dimensions. + // Rejection (path not allowed, not https, fetch failed) surfaces as + // a tool error so the model sees it — don't silently skip. + try { + for (const ann of annotations) { + if ((ann as any).type === "image") { + await resolveImageAnnotation(ann as any); + } + } + } catch (err) { + return { + content: [ + { + type: "text", + text: `add_annotations: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + enqueueCommand(uuid, { + type: "add_annotations", + // resolveImageAnnotation populates optional x/y/width/height; + // input is validated as Record[] so this cast is + // the wire-protocol promise, not a compiler guarantee. + annotations: annotations as Extract< + PdfCommand, + { type: "add_annotations" } + >["annotations"], + }); + description = `add ${annotations.length} annotation(s)`; + break; + case "update_annotations": + if (!annotations || annotations.length === 0) + return { + content: [ + { + type: "text", + text: "update_annotations requires `annotations` array", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { + type: "update_annotations", + annotations: annotations as Extract< + PdfCommand, + { type: "update_annotations" } + >["annotations"], + }); + description = `update ${annotations.length} annotation(s)`; + break; + case "remove_annotations": + if (!ids || ids.length === 0) + return { + content: [ + { + type: "text", + text: "remove_annotations requires `ids` array", + }, + ], + isError: true, + }; + enqueueCommand(uuid, { type: "remove_annotations", ids }); + description = `remove ${ids.length} annotation(s)`; + break; + case "highlight_text": { + if (!query) + return { + content: [ + { type: "text", text: "highlight_text requires `query`" }, + ], + isError: true, + }; + const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + enqueueCommand(uuid, { + type: "highlight_text", + id, + query, + page, + color, + content, + }); + description = `highlight text "${query}"${page ? ` on page ${page}` : ""}`; + break; + } + case "fill_form": { + if (!fields || fields.length === 0) + return { + content: [ + { type: "text", text: "fill_form requires `fields` array" }, + ], + isError: true, + }; + const knownFields = viewFieldNames.get(uuid); + const validFields: typeof fields = []; + const unknownNames: string[] = []; + for (const f of fields) { + if (knownFields && !knownFields.has(f.name)) { + unknownNames.push(f.name); + } else { + validFields.push(f); + } + } + if (validFields.length > 0) { + enqueueCommand(uuid, { type: "fill_form", fields: validFields }); + } + const parts: string[] = []; + if (validFields.length > 0) { + parts.push( + `Filled ${validFields.length} field(s): ${validFields.map((f) => f.name).join(", ")}`, + ); + } + if (unknownNames.length > 0) { + parts.push(`Unknown field(s) skipped: ${unknownNames.join(", ")}`); + // Only list valid names when the model got something wrong — + // display_pdf already returned the full field info on open. + if (knownFields && knownFields.size > 0) { + parts.push(`Valid field names: ${[...knownFields].join(", ")}`); + } + } + description = parts.join(". "); + if (unknownNames.length > 0 && validFields.length === 0) { + return { + content: [{ type: "text", text: description }], + isError: true, + }; + } + break; + } + case "get_text": { + const resolvedIntervals = + intervals ?? (page ? [{ start: page, end: page }] : [{}]); + + const requestId = randomUUID(); + + enqueueCommand(uuid, { + type: "get_pages", + requestId, + intervals: resolvedIntervals, + getText: true, + getScreenshots: false, + }); + + let pageData: PageDataEntry[]; + try { + pageData = await waitForPageData(requestId, signal); + } catch (err) { + return { + content: [ + { + type: "text", + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + + const textParts: ContentPart[] = []; + for (const entry of pageData) { + if (entry.text != null) { + textParts.push({ + type: "text", + text: `--- Page ${entry.page} ---\n${entry.text}`, + }); + } + } + if (textParts.length === 0) { + textParts.push({ type: "text", text: "No text content returned" }); + } + return { content: textParts }; + } + case "get_screenshot": { + if (page == null) + return { + content: [ + { type: "text", text: "get_screenshot requires `page`" }, + ], + isError: true, + }; + + const requestId = randomUUID(); + + enqueueCommand(uuid, { + type: "get_pages", + requestId, + intervals: [{ start: page, end: page }], + getText: false, + getScreenshots: true, + }); + + let pageData: PageDataEntry[]; + try { + pageData = await waitForPageData(requestId, signal); + } catch (err) { + return { + content: [ + { + type: "text", + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + + const entry = pageData[0]; + if (entry?.image) { + return { + content: [ + { + type: "image", + data: entry.image, + mimeType: "image/jpeg", + }, + ], + }; + } + return { + content: [{ type: "text", text: "No screenshot returned" }], + isError: true, + }; + } + default: + return { + content: [{ type: "text", text: `Unknown action: ${action}` }], + isError: true, + }; + } + return { + content: [{ type: "text", text: `Queued: ${description}` }], + }; + } + + // Tool: interact - Interact with an existing PDF viewer + server.registerTool( + "interact", + { + title: "Interact with PDF", + description: `Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms. +IMPORTANT: viewUUID must be the exact UUID returned by display_pdf (e.g. "a1b2c3d4-..."). Do NOT use arbitrary strings. + +**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially. TIP: End with \`get_screenshot\` to verify your changes. + +**ANNOTATION** — add_annotations with array of annotation objects. Each needs: id (unique string), type, page (1-indexed). + +**COORDINATE SYSTEM**: PDF points (1pt = 1/72in), origin at page TOP-LEFT corner. X increases rightward, Y increases downward. +- US Letter = 612×792pt. Margins: top≈y=50, bottom≈y=742, left≈x=72, right≈x=540, center≈(306, 396). +- Rectangle/circle/stamp x,y is the TOP-LEFT corner. To place a 200×30 box at the TOP of the page: x=72, y=50, width=200, height=30. +- For highlights/underlines, each rect's y is the TOP of the highlighted region. + +Annotation types: +• highlight: rects:[{x,y,width,height}], color?, content? • underline: rects:[{x,y,w,h}], color? +• strikethrough: rects:[{x,y,w,h}], color? • note: x, y, content, color? +• rectangle: x, y, width, height, color?, fillColor?, rotation? • circle: x, y, width, height, color?, fillColor? +• line: x1, y1, x2, y2, color? • freetext: x, y, content, fontSize?, color? +• stamp: x, y, label (any text, e.g. APPROVED, DRAFT, CONFIDENTIAL), color?, rotation? +• image: imageUrl (required), x?, y?, width?, height?, mimeType?, rotation?, aspect? — places an image (signature, logo, etc.) on the page. Pass a local file path or HTTPS URL (NO data: URIs, NO base64). Width/height auto-detected if omitted. Users can also drag & drop images directly onto the viewer. + +TIP: For text annotations, prefer highlight_text (auto-finds text) over manual rects. + +Example — add a signature image and a stamp, then screenshot to verify: +\`\`\`json +{"viewUUID":"…","commands":[ + {"action":"add_annotations","annotations":[ + {"id":"sig1","type":"image","page":1,"x":72,"y":700,"imageUrl":"/path/to/signature.png"}, + {"id":"s1","type":"stamp","page":1,"x":300,"y":400,"label":"APPROVED"} + ]}, + {"action":"get_screenshot","page":1} +]} +\`\`\` + +• highlight_text: auto-find and highlight text (query, page?, color?, content?) +• update_annotations: partial update (id+type required) • remove_annotations: remove by ids + +**NAVIGATION**: navigate (page), search (query), find (query, silent), search_navigate (matchIndex), zoom (scale 0.5–3.0) + +**TEXT/SCREENSHOTS**: +• get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages. +• get_screenshot: capture a single page as PNG image. Requires \`page\`. + +**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`, + inputSchema: { + viewUUID: z + .string() + .describe( + "The viewUUID of the PDF viewer (from display_pdf result)", + ), + // Single-command mode (backwards-compatible) + action: z + .enum([ + "navigate", + "search", + "find", + "search_navigate", + "zoom", + "add_annotations", + "update_annotations", + "remove_annotations", + "highlight_text", + "fill_form", + "get_text", + "get_screenshot", + ]) + .optional() + .describe( + "Action to perform (for single command). Use `commands` array for batching.", + ), + page: z + .number() + .min(1) + .optional() + .describe( + "Page number (for navigate, highlight_text, get_screenshot, get_text)", + ), + query: z + .string() + .optional() + .describe("Search text (for search / find / highlight_text)"), + matchIndex: z + .number() + .min(0) + .optional() + .describe("Match index (for search_navigate)"), + scale: z + .number() + .min(0.5) + .max(3.0) + .optional() + .describe("Zoom scale, 1.0 = 100% (for zoom)"), + annotations: z + .array(z.record(z.string(), z.any())) + .optional() + .describe( + "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.", + ), + ids: z + .array(z.string()) + .optional() + .describe("Annotation IDs (for remove_annotations)"), + color: z + .string() + .optional() + .describe("Color override (for highlight_text)"), + content: z + .string() + .optional() + .describe("Tooltip/note content (for highlight_text)"), + fields: z + .array(FormField) + .optional() + .describe( + "Form fields to fill (for fill_form): { name, value } where value is string or boolean", + ), + intervals: z + .array(PageInterval) + .optional() + .describe( + "Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.", + ), + // Batch mode + commands: z + .array(InteractCommandSchema) + .optional() + .describe( + "Array of commands to execute sequentially. More efficient than separate calls. Tip: end with get_pages+getScreenshots to verify changes.", + ), + }, + }, + async ( + { + viewUUID: uuid, + action, + page, + query, + matchIndex, + scale, + annotations, + ids, + color, + content, + fields, + intervals, + commands, + }, + extra, + ): Promise => { + // Build the list of commands to process + const commandList: InteractCommand[] = commands + ? commands + : action + ? [ + { + action, + page, + query, + matchIndex, + scale, + annotations, + ids, + color, + content, + fields, + intervals, + }, + ] + : []; + + if (commandList.length === 0) { + return { + content: [ + { + type: "text", + text: "No action or commands specified. Provide either `action` (single command) or `commands` (batch).", + }, + ], + isError: true, + }; + } + + // Process commands sequentially, collecting all content parts + const allContent: ContentPart[] = []; + let hasError = false; + + for (let i = 0; i < commandList.length; i++) { + const result = await processInteractCommand( + uuid, + commandList[i], + extra.signal, + ); + if (result.isError) { + hasError = true; + } + allContent.push(...result.content); + if (hasError) break; // Stop on first error + } + + return { + content: allContent, + ...(hasError ? { isError: true } : {}), + }; + }, + ); + + // Tool: submit_page_data (app-only) - Client submits rendered page data + registerAppTool( + server, + "submit_page_data", + { + title: "Submit Page Data", + description: + "Submit rendered page data for a get_pages request (used by viewer)", + inputSchema: { + requestId: z + .string() + .describe("The request ID from the get_pages command"), + pages: z + .array( + z.object({ + page: z.number(), + text: z.string().optional(), + image: z.string().optional().describe("Base64 PNG image data"), + }), + ) + .describe("Page data entries"), + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ requestId, pages }): Promise => { + const settle = pendingPageRequests.get(requestId); + if (settle) { + settle(pages); + return { + content: [ + { type: "text", text: `Submitted ${pages.length} page(s)` }, + ], + }; + } + return { + content: [ + { type: "text", text: `No pending request for ${requestId}` }, + ], + isError: true, + }; + }, + ); + + // Tool: poll_pdf_commands (app-only) - Poll for pending commands + registerAppTool( + server, + "poll_pdf_commands", + { + title: "Poll PDF Commands", + description: "Poll for pending commands for a PDF viewer", + inputSchema: { + viewUUID: z.string().describe("The viewUUID of the PDF viewer"), + }, + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ viewUUID: uuid }): Promise => { + // If commands are already queued, wait briefly to let more accumulate + if (commandQueues.has(uuid)) { + await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS)); + } else { + // Long-poll: wait for commands to arrive or timeout + await new Promise((resolve) => { + const timer = setTimeout(() => { + pollWaiters.delete(uuid); + resolve(); + }, LONG_POLL_TIMEOUT_MS); + // Cancel any existing waiter for this uuid + const prev = pollWaiters.get(uuid); + if (prev) prev(); + pollWaiters.set(uuid, () => { + clearTimeout(timer); + resolve(); + }); + }); + // After waking, wait briefly for batching + if (commandQueues.has(uuid)) { + await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS)); + } + } + const commands = dequeueCommands(uuid); + return { + content: [{ type: "text", text: `${commands.length} command(s)` }], + structuredContent: { commands }, + }; + }, + ); + } // end if (!disableInteract) + + // Tool: save_pdf (app-only) - Save annotated PDF back to local file + registerAppTool( + server, + "save_pdf", + { + title: "Save PDF", + description: "Save annotated PDF bytes back to a local file", + inputSchema: { + url: z.string().describe("Original PDF URL or local file path"), + data: z.string().describe("Base64-encoded PDF bytes"), + }, + outputSchema: z.object({ + filePath: z.string(), + mtimeMs: z.number(), + }), + _meta: { ui: { visibility: ["app"] } }, + }, + async ({ url, data }): Promise => { + const validation = validateUrl(url); + if (!validation.valid) { + return { + content: [{ type: "text", text: validation.error! }], + isError: true, + }; + } + const filePath = isFileUrl(url) + ? fileUrlToPath(url) + : isLocalPath(url) + ? decodeURIComponent(url) + : null; + if (!filePath) { + return { + content: [ + { type: "text", text: "Save is only supported for local files" }, + ], + isError: true, + }; + } + const resolved = path.resolve(filePath); + // Enforce the same write scope the display_pdf writable flag uses. + // The viewer hides the save button for non-writable files, but we + // must not trust the client: a direct save_pdf call should also refuse. + if (!isWritablePath(resolved)) { + return { + content: [ + { + type: "text", + text: + "Save refused: file is not under a mounted directory root " + + "and was not passed as a CLI argument. MCP file roots are " + + "read-only (typically uploaded copies the client doesn't " + + "expect to change).", + }, + ], + isError: true, + }; + } + try { + const bytes = Buffer.from(data, "base64"); + await fs.promises.writeFile(resolved, bytes); + const { mtimeMs } = await fs.promises.stat(resolved); + // Don't suppress file_changed here — the saving viewer will recognise + // its own mtime, while other viewers on the same file correctly get + // notified that their content is stale. + return { + content: [{ type: "text", text: `Saved to ${filePath}` }], + structuredContent: { filePath: resolved, mtimeMs }, + }; + } catch (err) { + return { + content: [ + { + type: "text", + text: `Failed to save: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, + ); + // Resource: UI HTML registerAppResource( server, @@ -674,7 +2407,24 @@ Accepts: ); return { contents: [ - { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + permissions: { clipboardWrite: {} }, + csp: { + // pdf.js loads the Standard-14 fonts TWO ways: + // - fetch()s the .ttf bytes → connect-src + // - creates FontFace('name', 'url(...)') → font-src + // resourceDomains maps to font-src; we need both. + connectDomains: [STANDARD_FONT_ORIGIN], + resourceDomains: [STANDARD_FONT_ORIGIN], + }, + }, + }, + }, ], }; }, diff --git a/examples/pdf-server/src/annotation-panel.ts b/examples/pdf-server/src/annotation-panel.ts new file mode 100644 index 0000000..6b41095 --- /dev/null +++ b/examples/pdf-server/src/annotation-panel.ts @@ -0,0 +1,1087 @@ +/** + * Annotation Panel — floating/docked sidebar showing all annotations and + * form fields, grouped by page in accordion sections. Handles panel + * positioning, resize, drag-to-reposition, per-item cards, and the + * reset/clear-all actions. + * + * All coupling to mcp-app.ts flows through {@link PanelDeps} supplied to + * {@link initAnnotationPanel}. Shared Maps/Sets come from viewer-state.ts. + */ + +import type * as pdfjsLib from "pdfjs-dist"; +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { PdfAnnotationDef } from "./pdf-annotations.js"; +import { + type TrackedAnnotation, + annotationMap, + formFieldValues, + pdfBaselineFormValues, + selectedAnnotationIds, + fieldNameToIds, + fieldNameToPage, + fieldNameToLabel, + fieldNameToOrder, + undoStack, + redoStack, + searchBarEl, + formLayerEl, +} from "./viewer-state.js"; + +const log = { + error: console.error.bind(console, "[PDF-VIEWER]"), +}; + +// ============================================================================= +// Panel DOM Elements +// ============================================================================= + +/** Floating panel container. Exported — mcp-app.ts reads .classList. */ +export const annotationsPanelEl = document.getElementById("annotation-panel")!; +/** Scrollable list of accordion sections. Exported — selectAnnotation queries it. */ +export const annotationsPanelListEl = document.getElementById( + "annotation-panel-list", +)!; +const annotationsPanelCountEl = document.getElementById( + "annotation-panel-count", +)!; +const annotationsPanelCloseBtn = document.getElementById( + "annotation-panel-close", +) as HTMLButtonElement; +const annotationsPanelResetBtn = document.getElementById( + "annotation-panel-reset", +) as HTMLButtonElement; +const annotationsPanelClearAllBtn = document.getElementById( + "annotation-panel-clear-all", +) as HTMLButtonElement; +const annotationsBtn = document.getElementById( + "annotations-btn", +) as HTMLButtonElement; +const annotationsBadgeEl = document.getElementById( + "annotations-badge", +) as HTMLElement; + +// ============================================================================= +// Panel State (exported — mcp-app.ts reads .open, writes .openAccordionSection) +// ============================================================================= + +/** Which corner the floating panel is anchored to. */ +type PanelCorner = "top-right" | "top-left" | "bottom-right" | "bottom-left"; + +export const panelState = { + /** Whether the panel is currently visible. Read by search open/close + debug. */ + open: false, + /** Which accordion section is open (e.g. "page-3"). Written by selectAnnotation. */ + openAccordionSection: null as string | null, +}; + +/** null = user hasn't manually toggled; true/false = manual preference */ +let annotationPanelUserPref: boolean | null = null; +/** Whether the user has ever interacted with accordion sections (prevents auto-open after explicit collapse). */ +let accordionUserInteracted = false; +let floatingPanelCorner: PanelCorner = "top-right"; + +// ============================================================================= +// Dependencies injected by mcp-app.ts +// ============================================================================= + +/** + * Callbacks and live-state getters supplied by mcp-app.ts. Scalars like + * `currentPage` and `pdfDocument` are reassigned throughout mcp-app.ts; + * a getter here avoids wrapping every one of their ~100 use sites. + */ +export interface PanelDeps { + // Live scalar state (read-only from panel's side) + state: () => { + currentPage: number; + isDirty: boolean; + pdfDocument: pdfjsLib.PDFDocumentProxy | null; + pdfBaselineAnnotations: PdfAnnotationDef[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cachedFieldObjects: Record | null; + searchOpen: boolean; + }; + // Callbacks into mcp-app.ts + renderPage: () => void; + goToPage: (page: number) => void; + selectAnnotation: (id: string | null) => void; + persistAnnotations: () => void; + removeAnnotation: (id: string) => void; + requestFitToContent: () => void; + updatePageContext: () => void; + setFocusedField: (name: string | null) => void; + // App bridge + sendMessage: (msg: { + role: "user"; + content: Array<{ type: "text"; text: string }>; + }) => Promise; + getHostContext: () => McpUiHostContext | undefined; +} + +let deps!: PanelDeps; + +// ============================================================================= +// Floating panel positioning +// ============================================================================= + +/** Get inset margins for the floating panel (safe area + padding). */ +function getFloatingPanelInsets(): { + top: number; + right: number; + bottom: number; + left: number; +} { + const insets = { top: 4, right: 4, bottom: 4, left: 4 }; + const ctx = deps.getHostContext(); + if (ctx?.safeAreaInsets) { + insets.top += ctx.safeAreaInsets.top; + insets.right += ctx.safeAreaInsets.right; + insets.bottom += ctx.safeAreaInsets.bottom; + insets.left += ctx.safeAreaInsets.left; + } + return insets; +} + +/** Position the floating panel based on its anchored corner. */ +export function applyFloatingPanelPosition(): void { + const el = annotationsPanelEl; + // Reset all position props + el.style.top = ""; + el.style.bottom = ""; + el.style.left = ""; + el.style.right = ""; + + const insets = getFloatingPanelInsets(); + + // When search bar is visible and panel is anchored top-right, offset below it + const searchBarExtra = + deps.state().searchOpen && floatingPanelCorner === "top-right" + ? searchBarEl.offsetHeight + 2 + : 0; + + const isRight = floatingPanelCorner.includes("right"); + const isBottom = floatingPanelCorner.includes("bottom"); + + if (isBottom) { + el.style.bottom = `${insets.bottom}px`; + } else { + el.style.top = `${insets.top + searchBarExtra}px`; + } + if (isRight) { + el.style.right = `${insets.right}px`; + } else { + el.style.left = `${insets.left}px`; + } + + // Update resize handle position based on anchorage + updateResizeHandlePosition(); +} + +/** Position the resize handle on the correct edge based on panel anchorage. */ +function updateResizeHandlePosition(): void { + const resizeHandle = document.getElementById("annotation-panel-resize"); + if (!resizeHandle) return; + const isRight = floatingPanelCorner.includes("right"); + if (isRight) { + // Panel is on the right → resize handle on the left edge + resizeHandle.style.left = "-3px"; + resizeHandle.style.right = ""; + } else { + // Panel is on the left → resize handle on the right edge + resizeHandle.style.left = ""; + resizeHandle.style.right = "-3px"; + } +} + +/** Auto-dock the floating panel to the opposite side if it overlaps selected annotations. */ +export function autoDockPanel(): void { + const panelRect = annotationsPanelEl.getBoundingClientRect(); + let overlaps = false; + for (const selId of selectedAnnotationIds) { + const tracked = annotationMap.get(selId); + if (!tracked) continue; + for (const el of tracked.elements) { + const elRect = el.getBoundingClientRect(); + // Check overlap + if ( + panelRect.left < elRect.right && + panelRect.right > elRect.left && + panelRect.top < elRect.bottom && + panelRect.bottom > elRect.top + ) { + overlaps = true; + break; + } + } + if (overlaps) break; + } + if (overlaps) { + // Swap left ↔ right + if (floatingPanelCorner.includes("right")) { + floatingPanelCorner = floatingPanelCorner.replace( + "right", + "left", + ) as PanelCorner; + } else { + floatingPanelCorner = floatingPanelCorner.replace( + "left", + "right", + ) as PanelCorner; + } + applyFloatingPanelPosition(); + } +} + +export function setAnnotationPanelOpen(open: boolean): void { + panelState.open = open; + annotationsBtn.classList.toggle("active", open); + updateAnnotationsBadge(); + + // Always use floating panel (both inline and fullscreen) + annotationsPanelEl.classList.toggle("floating", true); + annotationsPanelEl.style.display = open ? "" : "none"; + if (open) { + applyFloatingPanelPosition(); + renderAnnotationPanel(); + } + deps.requestFitToContent(); +} + +function toggleAnnotationPanel(): void { + annotationPanelUserPref = !panelState.open; + try { + localStorage.setItem( + "pdf-annotation-panel", + annotationPanelUserPref ? "open" : "closed", + ); + } catch { + /* ignore */ + } + setAnnotationPanelOpen(annotationPanelUserPref); +} + +// ============================================================================= +// Field & item state helpers +// ============================================================================= + +/** + * Derived state of a form field relative to the PDF baseline. + * Not stored — computed on demand by comparing formFieldValues to + * pdfBaselineFormValues. + */ +type FieldState = + | "unchanged" // current === baseline (came from the PDF, untouched) + | "modified" // baseline exists but current differs + | "cleared" // baseline exists but current is absent/empty + | "added"; // no baseline — user-filled or fill_form + +function fieldState(name: string): FieldState { + const cur = formFieldValues.get(name); + const base = pdfBaselineFormValues.get(name); + if (base === undefined) return "added"; + if (cur === undefined || cur === "" || cur === false) return "cleared"; + return cur === base ? "unchanged" : "modified"; +} + +/** All field names that should appear in the panel: current ∪ baseline. + * Cleared baseline fields remain visible (crossed out) so they can be + * reverted individually. */ +function panelFieldNames(): Set { + return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]); +} + +/** Total count of annotations + form fields for the sidebar badge. + * Uses the union so cleared baseline items still contribute. */ +function sidebarItemCount(): number { + return annotationMap.size + panelFieldNames().size; +} + +export function updateAnnotationsBadge(): void { + const count = sidebarItemCount(); + if (count > 0 && !panelState.open) { + annotationsBadgeEl.textContent = String(count); + annotationsBadgeEl.style.display = ""; + } else { + annotationsBadgeEl.style.display = "none"; + } + // Show/hide the toolbar button based on whether items exist + annotationsBtn.style.display = count > 0 ? "" : "none"; + // Auto-close panel when all items are gone + if (count === 0 && panelState.open) { + setAnnotationPanelOpen(false); + } +} + +// ============================================================================= +// Label / preview helpers +// ============================================================================= + +/** Human-readable label for an annotation type (used in sidebar). */ +export function getAnnotationLabel(def: PdfAnnotationDef): string { + switch (def.type) { + case "highlight": + return "Highlight"; + case "underline": + return "Underline"; + case "strikethrough": + return "Strikethrough"; + case "note": + return "Note"; + case "freetext": + return "Text"; + case "rectangle": + return "Rectangle"; + case "stamp": + return `Stamp: ${def.label}`; + case "circle": + return "Circle"; + case "line": + return "Line"; + case "image": + return "Image"; + } +} + +/** Preview text for an annotation (shown after the label). */ +export function getAnnotationPreview(def: PdfAnnotationDef): string { + switch (def.type) { + case "note": + case "freetext": + return def.content || ""; + case "highlight": + return def.content || ""; + case "stamp": + return ""; + case "image": + return ""; + default: + return ""; + } +} + +export function getAnnotationColor(def: PdfAnnotationDef): string { + if ("color" in def && def.color) return def.color; + switch (def.type) { + case "highlight": + return "rgba(255, 255, 0, 0.7)"; + case "underline": + return "#ff0000"; + case "strikethrough": + return "#ff0000"; + case "note": + return "#f5a623"; + case "rectangle": + return "#0066cc"; + case "freetext": + return "#333"; + case "stamp": + return "#cc0000"; + case "circle": + return "#0066cc"; + case "line": + return "#333"; + case "image": + return "#999"; + } +} + +/** Return a human-readable label for a form field name. */ +export function getFormFieldLabel(name: string): string { + // Prefer the PDF's TU (alternativeText) if available + const alt = fieldNameToLabel.get(name); + if (alt) return alt; + // If the name looks mechanical (contains brackets, dots, or is all-caps with underscores), + // just show "Field" as a generic fallback + if (/[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name)) { + return "Field"; + } + return name; +} + +function getAnnotationY(def: PdfAnnotationDef): number { + if ("y" in def && typeof def.y === "number") return def.y; + if ("rects" in def && def.rects.length > 0) return def.rects[0].y; + // LineAnnotation has only x1/y1/x2/y2 — sort by the higher endpoint + // (higher internal-y = closer to page top). + if ("y1" in def) return Math.max(def.y1, def.y2); + return 0; +} + +// ============================================================================= +// Panel rendering +// ============================================================================= + +export function renderAnnotationPanel(): void { + if (!panelState.open) return; + + annotationsPanelCountEl.textContent = String(sidebarItemCount()); + annotationsPanelResetBtn.disabled = !deps.state().isDirty; + annotationsPanelClearAllBtn.disabled = sidebarItemCount() === 0; + + // Group annotations by page, sorted by Y position within each page + const byPage = new Map(); + for (const tracked of annotationMap.values()) { + const page = tracked.def.page; + if (!byPage.has(page)) byPage.set(page, []); + byPage.get(page)!.push(tracked); + } + + // Group form fields by page — iterate the UNION so cleared baseline + // fields remain visible (crossed out) with a per-item revert button. + const fieldsByPage = new Map(); + for (const name of panelFieldNames()) { + const page = fieldNameToPage.get(name) ?? 1; + if (!fieldsByPage.has(page)) fieldsByPage.set(page, []); + fieldsByPage.get(page)!.push(name); + } + // Sort fields by their intrinsic document order within each page + for (const names of fieldsByPage.values()) { + names.sort( + (a, b) => (fieldNameToOrder.get(a) ?? 0) - (fieldNameToOrder.get(b) ?? 0), + ); + } + + // Collect all pages that have annotations or form fields + const allPages = new Set([...byPage.keys(), ...fieldsByPage.keys()]); + const sortedPages = [...allPages].sort((a, b) => a - b); + + // Sort annotations within each page by Y position (descending = top-first in PDF coords) + for (const annotations of byPage.values()) { + annotations.sort((a, b) => getAnnotationY(b.def) - getAnnotationY(a.def)); + } + + annotationsPanelListEl.innerHTML = ""; + + const { currentPage } = deps.state(); + + // Auto-open section for current page only on first render (before user interaction) + if (panelState.openAccordionSection === null && !accordionUserInteracted) { + if (allPages.has(currentPage)) { + panelState.openAccordionSection = `page-${currentPage}`; + } else if (sortedPages.length > 0) { + panelState.openAccordionSection = `page-${sortedPages[0]}`; + } + } + + for (const pageNum of sortedPages) { + const sectionKey = `page-${pageNum}`; + const isOpen = panelState.openAccordionSection === sectionKey; + const annotations = byPage.get(pageNum) ?? []; + const fields = fieldsByPage.get(pageNum) ?? []; + const itemCount = annotations.length + fields.length; + + appendAccordionSection( + `Page ${pageNum} (${itemCount})`, + sectionKey, + isOpen, + pageNum === currentPage, + (body) => { + // Form fields first + for (const name of fields) { + body.appendChild(createFormFieldCard(name)); + } + // Then annotations + for (const tracked of annotations) { + body.appendChild(createAnnotationCard(tracked)); + } + }, + ); + } +} + +function appendAccordionSection( + title: string, + sectionKey: string, + isOpen: boolean, + isCurrent: boolean, + populateBody: (body: HTMLElement) => void, +): void { + const header = document.createElement("div"); + header.className = + "annotation-section-header" + + (isCurrent ? " current-page" : "") + + (isOpen ? " open" : ""); + + const titleSpan = document.createElement("span"); + titleSpan.textContent = title; + header.appendChild(titleSpan); + + const chevron = document.createElement("span"); + chevron.className = "annotation-section-chevron"; + chevron.textContent = isOpen ? "▼" : "▶"; + header.appendChild(chevron); + + header.addEventListener("click", () => { + accordionUserInteracted = true; + const opening = panelState.openAccordionSection !== sectionKey; + panelState.openAccordionSection = opening ? sectionKey : null; + renderAnnotationPanel(); + // Navigate to the page when expanding a page section + if (opening) { + const pageMatch = sectionKey.match(/^page-(\d+)$/); + if (pageMatch) { + deps.goToPage(Number(pageMatch[1])); + } + } + }); + + annotationsPanelListEl.appendChild(header); + + const body = document.createElement("div"); + body.className = "annotation-section-body" + (isOpen ? " open" : ""); + if (isOpen) { + populateBody(body); + } + annotationsPanelListEl.appendChild(body); +} + +const TRASH_SVG = ``; +const REVERT_SVG = ``; + +function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement { + const def = tracked.def; + const card = document.createElement("div"); + card.className = + "annotation-card" + (selectedAnnotationIds.has(def.id) ? " selected" : ""); + card.dataset.annotationId = def.id; + + const row = document.createElement("div"); + row.className = "annotation-card-row"; + + // Color swatch + const swatch = document.createElement("div"); + swatch.className = "annotation-card-swatch"; + swatch.style.background = getAnnotationColor(def); + row.appendChild(swatch); + + // Type label + const typeLabel = document.createElement("span"); + typeLabel.className = "annotation-card-type"; + typeLabel.textContent = getAnnotationLabel(def); + row.appendChild(typeLabel); + + // Preview text + const preview = getAnnotationPreview(def); + if (preview) { + const previewEl = document.createElement("span"); + previewEl.className = "annotation-card-preview"; + previewEl.textContent = preview; + row.appendChild(previewEl); + } + + // Delete button + const deleteBtn = document.createElement("button"); + deleteBtn.className = "annotation-card-delete"; + deleteBtn.title = "Delete annotation"; + deleteBtn.innerHTML = TRASH_SVG; + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + deps.removeAnnotation(def.id); + deps.persistAnnotations(); + }); + row.appendChild(deleteBtn); + + // Expand chevron (only for annotations with content) + const hasContent = "content" in def && def.content; + if (hasContent) { + const expand = document.createElement("span"); + expand.className = "annotation-card-expand"; + expand.textContent = "▼"; + row.appendChild(expand); + } + + card.appendChild(row); + + // Expandable content area + if (hasContent) { + const contentEl = document.createElement("div"); + contentEl.className = "annotation-card-content"; + contentEl.textContent = (def as { content: string }).content; + card.appendChild(contentEl); + } + + // Click handler: select + expand/collapse + navigate to page + pulse annotation + card.addEventListener("click", () => { + if (hasContent) { + card.classList.toggle("expanded"); + } + if (def.page !== deps.state().currentPage) { + deps.goToPage(def.page); + setTimeout(() => { + deps.selectAnnotation(def.id); + pulseAnnotation(def.id); + }, 300); + } else { + deps.selectAnnotation(def.id); + pulseAnnotation(def.id); + if (tracked.elements.length > 0) { + tracked.elements[0].scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + } + }); + + // Hover handler: pulse annotation on PDF + card.addEventListener("mouseenter", () => { + if (def.page === deps.state().currentPage) { + pulseAnnotation(def.id); + } + }); + + // Double-click handler: send message to modify annotation + card.addEventListener("dblclick", (e) => { + e.stopPropagation(); + // Select this annotation + update model context before sending message + deps.selectAnnotation(def.id); + const label = getAnnotationLabel(def); + const previewText = getAnnotationPreview(def); + const desc = previewText ? `${label}: ${previewText}` : label; + void deps + .sendMessage({ + role: "user", + content: [{ type: "text", text: `update ${desc}: ` }], + }) + .catch(log.error); + }); + + return card; +} + +/** Revert one field to its PDF-stored baseline value. */ +function revertFieldToBaseline(name: string): void { + const base = pdfBaselineFormValues.get(name); + if (base === undefined) return; + formFieldValues.set(name, base); + // Remove our storage override → widget falls back to PDF's /V = baseline + const { pdfDocument } = deps.state(); + if (pdfDocument) { + const ids = fieldNameToIds.get(name); + if (ids) for (const id of ids) pdfDocument.annotationStorage.remove(id); + } +} + +function createFormFieldCard(name: string): HTMLElement { + const state = fieldState(name); + const value = formFieldValues.get(name); + const baseValue = pdfBaselineFormValues.get(name); + + const card = document.createElement("div"); + card.className = "annotation-card"; + if (state === "cleared") card.classList.add("annotation-card-cleared"); + + const row = document.createElement("div"); + row.className = "annotation-card-row"; + + // Swatch: solid blue normally; crossed-out for cleared baseline fields + const swatch = document.createElement("div"); + swatch.className = "annotation-card-swatch"; + if (state === "cleared") { + swatch.classList.add("annotation-card-swatch-cleared"); + swatch.innerHTML = ``; + } else { + swatch.style.background = "#4a90d9"; + } + // Subtle modified marker + if (state === "modified") swatch.title = "Modified from file"; + row.appendChild(swatch); + + // Field label + const nameEl = document.createElement("span"); + nameEl.className = "annotation-card-type"; + nameEl.textContent = getFormFieldLabel(name); + row.appendChild(nameEl); + + // Value preview: show current, or struck-out baseline when cleared + const shown = state === "cleared" ? baseValue : value; + const displayValue = + typeof shown === "boolean" ? (shown ? "checked" : "unchecked") : shown; + if (displayValue) { + const valueEl = document.createElement("span"); + valueEl.className = "annotation-card-preview"; + valueEl.textContent = displayValue; + row.appendChild(valueEl); + } + + // Action button: revert for modified/cleared baseline fields, trash otherwise + const isRevertable = state === "modified" || state === "cleared"; + const actionBtn = document.createElement("button"); + actionBtn.className = "annotation-card-delete"; + actionBtn.title = isRevertable + ? "Revert to value stored in file" + : "Clear field"; + actionBtn.innerHTML = isRevertable ? REVERT_SVG : TRASH_SVG; + actionBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (isRevertable) { + revertFieldToBaseline(name); + } else { + formFieldValues.delete(name); + clearFieldInStorage(name); + } + updateAnnotationsBadge(); + renderAnnotationPanel(); + deps.renderPage(); + deps.persistAnnotations(); + }); + row.appendChild(actionBtn); + + // Click handler: navigate to page and focus form input + card.addEventListener("click", () => { + const fieldPage = fieldNameToPage.get(name) ?? 1; + // Auto-expand the page's accordion section + panelState.openAccordionSection = `page-${fieldPage}`; + const focusField = () => { + const input = formLayerEl.querySelector( + `[name="${CSS.escape(name)}"]`, + ) as HTMLElement | null; + if (input) { + input.focus(); + input.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + if (fieldPage !== deps.state().currentPage) { + deps.goToPage(fieldPage); + setTimeout(focusField, 300); + } else { + focusField(); + } + }); + + // Double-click handler: send message to fill field + card.addEventListener("dblclick", (e) => { + e.stopPropagation(); + // Focus field + update model context before sending message + deps.setFocusedField(name); + deps.updatePageContext(); + const fieldLabel = getFormFieldLabel(name); + void deps + .sendMessage({ + role: "user", + content: [{ type: "text", text: `update ${fieldLabel}: ` }], + }) + .catch(log.error); + }); + + card.appendChild(row); + return card; +} + +function pulseAnnotation(id: string): void { + const tracked = annotationMap.get(id); + if (!tracked) return; + for (const el of tracked.elements) { + el.classList.remove("annotation-pulse"); + // Force reflow to restart animation + void el.offsetWidth; + el.classList.add("annotation-pulse"); + el.addEventListener( + "animationend", + () => { + el.classList.remove("annotation-pulse"); + }, + { once: true }, + ); + } +} + +/** Toggle the `.selected` class on every card to match selectedAnnotationIds. */ +export function syncSidebarSelection(): void { + for (const card of annotationsPanelListEl.querySelectorAll( + ".annotation-card", + )) { + const cardId = (card as HTMLElement).dataset.annotationId; + card.classList.toggle( + "selected", + !!cardId && selectedAnnotationIds.has(cardId), + ); + } +} + +// ============================================================================= +// Reset / Clear +// ============================================================================= + +/** Remove the DOM elements backing every annotation and clear the map. */ +function clearAnnotationMap(): void { + for (const [, tracked] of annotationMap) { + for (const el of tracked.elements) el.remove(); + } + annotationMap.clear(); +} + +/** + * Push a field's defaultValue (/DV) into annotationStorage so the widget + * renders cleared. annotationStorage.remove() only drops our override — + * the widget reverts to the PDF's /V (the stored value), not /DV. + * + * Widget IDs come from page.getAnnotations(); field metadata (types, + * defaultValue) comes from getFieldObjects(). We match them by field name. + */ +function clearFieldInStorage(name: string): void { + const { pdfDocument, cachedFieldObjects } = deps.state(); + if (!pdfDocument) return; + const ids = fieldNameToIds.get(name); + if (!ids) return; + const storage = pdfDocument.annotationStorage; + const meta = cachedFieldObjects?.[name]; + // defaultValue is per-field, not per-widget — take from first non-parent entry + const dv = + meta?.find((f) => f.defaultValue != null)?.defaultValue ?? + meta?.[0]?.defaultValue ?? + ""; + const type = meta?.find((f) => f.type)?.type; + const clearValue = + type === "checkbox" || type === "radiobutton" ? (dv ?? "Off") : (dv ?? ""); + for (const id of ids) storage.setValue(id, { value: clearValue }); +} + +/** + * Revert to what's in the PDF file: restore baseline annotations, restore + * baseline form values, discard all user edits. Result: diff is empty, clean. + * + * Form fields: remove ALL storage overrides — every field reverts to the + * PDF's /V (which IS baseline). We can't skip baseline-named fields: if the + * user edited one, our override is in storage under that name, and skipping + * it leaves the widget showing the stale edit. + */ +function resetToBaseline(): void { + clearAnnotationMap(); + const { pdfDocument, pdfBaselineAnnotations } = deps.state(); + for (const def of pdfBaselineAnnotations) { + annotationMap.set(def.id, { def: { ...def }, elements: [] }); + } + + if (pdfDocument) { + const storage = pdfDocument.annotationStorage; + for (const name of new Set([ + ...formFieldValues.keys(), + ...pdfBaselineFormValues.keys(), + ])) { + const ids = fieldNameToIds.get(name); + if (ids) for (const id of ids) storage.remove(id); + } + } + formFieldValues.clear(); + for (const [name, value] of pdfBaselineFormValues) { + formFieldValues.set(name, value); + } + + undoStack.length = 0; + redoStack.length = 0; + selectedAnnotationIds.clear(); + + updateAnnotationsBadge(); + deps.persistAnnotations(); // diff is now empty → setDirty(false) + deps.renderPage(); + renderAnnotationPanel(); +} + +/** + * Remove everything, including annotations and form values that came from + * the PDF file. Result: diff is non-empty (baseline items are "removed"), + * dirty — saving writes a stripped PDF. + * + * Form fields: annotationStorage.remove() only drops our override, so the + * widget reverts to the PDF's stored /V. To actually CLEAR we must push + * each field's defaultValue (/DV) — which is what the PDF's own Reset + * button would do. + * + * Note: baseline annotations are still baked into the canvas appearance + * stream — we can only remove them from our overlay and the panel. Saving + * will omit them from the output (getAnnotatedPdfBytes skips baseline). + */ +function clearAllItems(): void { + clearAnnotationMap(); + + for (const name of new Set([ + ...formFieldValues.keys(), + ...pdfBaselineFormValues.keys(), + ])) { + clearFieldInStorage(name); + } + formFieldValues.clear(); + + undoStack.length = 0; + redoStack.length = 0; + selectedAnnotationIds.clear(); + + updateAnnotationsBadge(); + deps.persistAnnotations(); + deps.renderPage(); + renderAnnotationPanel(); +} + +// ============================================================================= +// Init +// ============================================================================= + +export function initAnnotationPanel(panelDeps: PanelDeps): void { + deps = panelDeps; + + // Restore user preference + try { + const pref = localStorage.getItem("pdf-annotation-panel"); + if (pref === "open") annotationPanelUserPref = true; + else if (pref === "closed") annotationPanelUserPref = false; + } catch { + /* ignore */ + } + + // Restore saved panel width + try { + const savedWidth = localStorage.getItem("pdf-annotation-panel-width"); + if (savedWidth) { + const w = parseInt(savedWidth, 10); + if (w >= 120) { + annotationsPanelEl.style.width = `${w}px`; + } + } + } catch { + /* ignore */ + } + + // Resize handle — direction-aware based on anchorage + const resizeHandle = document.getElementById("annotation-panel-resize")!; + resizeHandle.addEventListener("mousedown", (e) => { + e.preventDefault(); + resizeHandle.classList.add("dragging"); + const startX = e.clientX; + const startWidth = annotationsPanelEl.offsetWidth; + const isRight = floatingPanelCorner.includes("right"); + + const onMouseMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX; + // If panel is on the right, dragging left (negative dx) increases width + // If panel is on the left, dragging right (positive dx) increases width + const newWidth = Math.max(120, startWidth + (isRight ? -dx : dx)); + annotationsPanelEl.style.width = `${newWidth}px`; + }; + const onMouseUp = () => { + resizeHandle.classList.remove("dragging"); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + try { + localStorage.setItem( + "pdf-annotation-panel-width", + String(annotationsPanelEl.offsetWidth), + ); + } catch { + /* ignore */ + } + deps.requestFitToContent(); + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + + // Floating panel drag-to-reposition + const panelHeader = annotationsPanelEl.querySelector( + ".annotation-panel-header", + ) as HTMLElement; + if (panelHeader) { + panelHeader.addEventListener("mousedown", (e) => { + if (!annotationsPanelEl.classList.contains("floating")) return; + // Ignore clicks on buttons within header + if ((e.target as HTMLElement).closest("button")) return; + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const container = annotationsPanelEl.parentElement!; + const containerRect = container.getBoundingClientRect(); + let moved = false; + + // Temporarily position absolutely during drag + const panelRect = annotationsPanelEl.getBoundingClientRect(); + let curLeft = panelRect.left - containerRect.left; + let curTop = panelRect.top - containerRect.top; + + // Switch to left/top positioning for free drag + annotationsPanelEl.style.right = ""; + annotationsPanelEl.style.bottom = ""; + annotationsPanelEl.style.left = `${curLeft}px`; + annotationsPanelEl.style.top = `${curTop}px`; + annotationsPanelEl.style.transition = "none"; + annotationsPanelEl.classList.add("dragging"); + + const onMouseMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true; + const newLeft = Math.max( + 0, + Math.min( + curLeft + dx, + containerRect.width - annotationsPanelEl.offsetWidth, + ), + ); + const newTop = Math.max( + 0, + Math.min( + curTop + dy, + containerRect.height - annotationsPanelEl.offsetHeight, + ), + ); + annotationsPanelEl.style.left = `${newLeft}px`; + annotationsPanelEl.style.top = `${newTop}px`; + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + annotationsPanelEl.classList.remove("dragging"); + annotationsPanelEl.style.transition = ""; + + if (!moved) return; + + // Snap to nearest corner (magnetic anchor) + const finalRect = annotationsPanelEl.getBoundingClientRect(); + const cx = finalRect.left + finalRect.width / 2 - containerRect.left; + const cy = finalRect.top + finalRect.height / 2 - containerRect.top; + const midX = containerRect.width / 2; + const midY = containerRect.height / 2; + + const isRight = cx > midX; + const isBottom = cy > midY; + floatingPanelCorner = isBottom + ? isRight + ? "bottom-right" + : "bottom-left" + : isRight + ? "top-right" + : "top-left"; + + applyFloatingPanelPosition(); + try { + localStorage.setItem("pdf-panel-corner", floatingPanelCorner); + } catch { + /* ignore */ + } + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + } + + // Restore saved corner + try { + const saved = localStorage.getItem("pdf-panel-corner"); + if ( + saved && + ["top-right", "top-left", "bottom-right", "bottom-left"].includes(saved) + ) { + floatingPanelCorner = saved as PanelCorner; + } + } catch { + /* ignore */ + } + + // Toggle button + annotationsBtn.addEventListener("click", toggleAnnotationPanel); + annotationsPanelCloseBtn.addEventListener("click", toggleAnnotationPanel); + annotationsPanelResetBtn.addEventListener("click", resetToBaseline); + annotationsPanelClearAllBtn.addEventListener("click", clearAllItems); + + updateAnnotationsBadge(); +} diff --git a/examples/pdf-server/src/commands.ts b/examples/pdf-server/src/commands.ts new file mode 100644 index 0000000..fa657ca --- /dev/null +++ b/examples/pdf-server/src/commands.ts @@ -0,0 +1,68 @@ +/** + * PdfCommand — the wire protocol between server and viewer. + * + * The server enqueues these via the `interact` tool; the viewer polls + * `poll_pdf_commands` and receives them as `structuredContent.commands`. + * + * This file is the single source of truth for the command shape. Both + * `server.ts` (which enqueues) and `mcp-app.ts` (which consumes) import + * from here — a new command variant must be added exactly once. + * + * Uses `import type` so neither side pulls pdf-lib into its bundle. + */ + +import type { PdfAnnotationDef } from "./pdf-annotations.js"; + +/** Single form field assignment, as sent by `fill_form`. */ +export interface FormFieldFill { + name: string; + value: string | boolean; +} + +/** + * Partial annotation update: `id` + `type` pin the target, everything + * else is optional. Server validates shape; viewer merges into existing. + */ +export type PdfAnnotationPatch = Partial & { + id: string; + type: PdfAnnotationDef["type"]; +}; + +/** Page range for text/screenshot extraction. Omitted bound = open-ended. */ +export interface PageInterval { + start?: number; + end?: number; +} + +/** + * Commands the server can send to the viewer via the poll queue. + * Adding a variant here means adding a `case` in both the server's + * `interact` handler (to enqueue) and the viewer's `processCommands` + * (to execute). + */ +export type PdfCommand = + | { type: "navigate"; page: number } + | { type: "search"; query: string } + | { type: "find"; query: string } + | { type: "search_navigate"; matchIndex: number } + | { type: "zoom"; scale: number } + | { type: "add_annotations"; annotations: PdfAnnotationDef[] } + | { type: "update_annotations"; annotations: PdfAnnotationPatch[] } + | { type: "remove_annotations"; ids: string[] } + | { + type: "highlight_text"; + id: string; + query: string; + page?: number; + color?: string; + content?: string; + } + | { type: "fill_form"; fields: FormFieldFill[] } + | { + type: "get_pages"; + requestId: string; + intervals: PageInterval[]; + getText: boolean; + getScreenshots: boolean; + } + | { type: "file_changed"; mtimeMs: number }; diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index 77e3757..b307594 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -13,10 +13,7 @@ --text200: light-dark(#999999, #888888); /* Shadows */ - --shadow-page: light-dark( - 0 2px 8px rgba(0, 0, 0, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.4) - ); + --shadow-page-color: light-dark(rgba(0 0 0 / 0.15), rgba(0 0 0 / 0.4)); --selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4)); } @@ -223,7 +220,8 @@ body { /* Single Page Canvas Container */ .canvas-container { flex: 1; - overflow: visible; + overflow-x: auto; /* Allow horizontal scrolling when zoomed in inline mode */ + overflow-y: visible; display: flex; justify-content: center; align-items: flex-start; @@ -233,7 +231,9 @@ body { .page-wrapper { position: relative; - box-shadow: var(--shadow-page); + box-shadow: + 0 1px 4px var(--shadow-page-color), + 0 4px 16px var(--shadow-page-color); background: white; } @@ -319,6 +319,10 @@ body { min-height: 0; /* Allow flex item to shrink below content size */ } +.main.fullscreen .viewer-body { + min-height: 0; +} + .main.fullscreen .canvas-container { min-height: 0; /* Allow flex item to shrink below content size */ overflow: auto; /* Scroll within the document area only */ @@ -414,6 +418,361 @@ body { cursor: not-allowed; } +/* Annotation Layer */ +.annotation-layer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 3; /* above text-layer (z-index: 2) so notes are clickable */ +} + +.annotation-highlight { + position: absolute; + background: rgba(255, 255, 0, 0.35); + mix-blend-mode: multiply; + border-radius: 1px; + pointer-events: auto; + cursor: pointer; +} + +.annotation-underline { + position: absolute; + border-bottom: 2px solid #ff0000; + box-sizing: border-box; + pointer-events: auto; + cursor: pointer; +} + +.annotation-strikethrough { + position: absolute; + box-sizing: border-box; + pointer-events: auto; + cursor: pointer; +} +.annotation-strikethrough::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + border-top: 2px solid #ff0000; +} + +.annotation-note { + position: absolute; + width: 20px; + height: 20px; + cursor: pointer; + pointer-events: auto; +} +.annotation-note::after { + content: ""; + display: block; + width: 16px; + height: 16px; + margin: 2px; + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='black'%3E%3Cpath d='M3 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5l-4-4H3zm7 1.5L13.5 6H11a1 1 0 0 1-1-1V2.5zM5 8.5h6v1H5v-1zm0 2.5h4v1H5v-1z'/%3E%3C/svg%3E") no-repeat center / contain; +} +.annotation-note .annotation-tooltip { + display: none; + position: absolute; + bottom: 100%; + left: 0; + background: var(--bg000, #fff); + color: var(--text000, #000); + border: 1px solid var(--bg200, #ccc); + border-radius: 4px; + padding: 4px 8px; + font-size: 0.8rem; + white-space: pre-wrap; + max-width: 200px; + z-index: 100; /* Must float above all sibling annotation elements */ + pointer-events: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} +.annotation-note:hover .annotation-tooltip { + display: block; +} + +/* When hovering a note, raise the entire annotation layer above the form + layer so the tooltip isn't clipped by the z-index:4 form overlay. */ +.annotation-layer:has(.annotation-note:hover) { + z-index: 5; +} + +.annotation-rectangle { + position: absolute; + border: 2px solid #0066cc; + box-sizing: border-box; + pointer-events: auto; + cursor: grab; + user-select: none; +} + +.annotation-circle { + position: absolute; + border: 2px solid #0066cc; + border-radius: 50%; + box-sizing: border-box; + pointer-events: auto; + cursor: grab; + user-select: none; +} + +.annotation-line { + position: absolute; + height: 0; + border-top: 2px solid #333; + pointer-events: auto; + cursor: grab; + user-select: none; +} + +.annotation-freetext { + position: absolute; + font-family: Helvetica, Arial, sans-serif; + white-space: pre-wrap; + pointer-events: auto; + cursor: grab; + user-select: none; +} + +.annotation-stamp { + position: absolute; + font-family: Helvetica, Arial, sans-serif; + font-weight: bold; + font-size: 24px; + border: 3px solid currentColor; + padding: 4px 12px; + opacity: 0.6; + text-transform: uppercase; + white-space: nowrap; + pointer-events: auto; + cursor: grab; + user-select: none; +} + +.annotation-image { + position: absolute; + pointer-events: auto; + cursor: grab; + user-select: none; +} + +/* Selection visuals */ +.annotation-selected { + outline: 2px solid var(--accent, #2563eb); + outline-offset: 2px; + z-index: 10; +} + +.annotation-handle { + position: absolute; + width: 8px; + height: 8px; + background: white; + border: 2px solid var(--accent, #2563eb); + border-radius: 50%; + z-index: 11; + pointer-events: auto; +} +.annotation-handle.nw { top: -5px; left: -5px; cursor: nwse-resize; } +.annotation-handle.ne { top: -5px; right: -5px; cursor: nesw-resize; } +.annotation-handle.sw { bottom: -5px; left: -5px; cursor: nesw-resize; } +.annotation-handle.se { bottom: -5px; right: -5px; cursor: nwse-resize; } + +.annotation-handle-rotate { + position: absolute; + top: -20px; + left: 50%; + transform: translateX(-50%); + width: 10px; + height: 10px; + background: white; + border: 2px solid var(--accent, #2563eb); + border-radius: 50%; + z-index: 11; + pointer-events: auto; + cursor: grab; +} + +.annotation-card.selected { + background: var(--accent-bg, rgba(37, 99, 235, 0.08)); + border-left-color: var(--accent, #2563eb); +} + +.annotation-dragging { + cursor: grabbing !important; +} + +@media (prefers-color-scheme: dark) { + .annotation-highlight { + background: rgba(255, 255, 0, 0.3); + mix-blend-mode: screen; + } +} + +/* Save / Download Buttons */ +.save-btn, +.download-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--bg200); + border-radius: 4px; + background: var(--bg000); + color: var(--text000); + cursor: pointer; + font-size: 1rem; + transition: all 0.15s ease; +} + +.save-btn:hover:not(:disabled), +.download-btn:hover:not(:disabled) { + background: var(--bg100); + border-color: var(--bg300); +} + +.save-btn:disabled, +.download-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* + * Confirmation Dialog + * + * Styled to match the host's native dialogs (e.g. the downloadFile prompt). + * Uses host-provided CSS variables (set via applyHostStyleVariables) with + * local fallbacks so it still looks reasonable in standalone dev. + */ +.confirm-dialog { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)); + font-family: var(--font-sans, inherit); +} + +.confirm-box { + background: var(--color-background-primary, var(--bg000)); + border-radius: var(--border-radius-xl, 20px); + padding: 1.75rem; + min-width: 360px; + max-width: 520px; + box-shadow: var(--shadow-lg, 0 20px 50px rgba(0, 0, 0, 0.3)); +} + +.confirm-title { + font-weight: var(--font-weight-bold, 700); + font-size: var(--font-heading-md-size, 1.5rem); + line-height: var(--font-heading-md-line-height, 1.25); + margin-bottom: 0.5rem; + color: var(--color-text-primary, var(--text000)); +} + +.confirm-body { + font-size: var(--font-text-md-size, 1rem); + line-height: var(--font-text-md-line-height, 1.5); + color: var(--color-text-secondary, var(--text100)); + margin-bottom: 1.5rem; + word-break: break-word; +} + +/* Optional monospace detail box (filename, path, etc.) */ +.confirm-detail { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--font-text-sm-size, 0.9rem); + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + border: var(--border-width-regular, 1px) solid + var(--color-border-secondary, var(--bg200)); + border-radius: var(--border-radius-md, 8px); + background: var(--color-background-secondary, var(--bg100)); + color: var(--color-text-primary, var(--text000)); + word-break: break-all; +} + +.confirm-detail:empty { + display: none; +} + +.confirm-buttons { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.confirm-btn { + padding: 0.625rem 1.25rem; + border: var(--border-width-regular, 1px) solid + var(--color-border-primary, var(--bg200)); + border-radius: var(--border-radius-md, 8px); + background: var(--color-background-primary, var(--bg000)); + color: var(--color-text-primary, var(--text000)); + cursor: pointer; + font: inherit; + font-size: var(--font-text-md-size, 0.95rem); + font-weight: var(--font-weight-medium, 500); +} + +.confirm-btn:hover { + background: var(--color-background-secondary, var(--bg100)); +} + +.confirm-btn-primary { + /* Host's native primary buttons use inverse colors (dark bg, light text) */ + background: var(--color-background-inverse, var(--text000)); + border-color: var(--color-background-inverse, var(--text000)); + color: var(--color-text-inverse, var(--bg000)); +} + +.confirm-btn-primary:hover { + /* .confirm-btn:hover above would otherwise reset background */ + background: var(--color-background-inverse, var(--text000)); + filter: brightness(1.15); +} + +/* Form Layer (PDF.js AnnotationLayer for interactive form widgets) */ +#form-layer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + z-index: 4; /* above annotation-layer (z-index: 3) so form inputs are clickable */ +} + +/* + * PDF.js AnnotationLayer renders interactive form inputs on top of the canvas. + * When the widget is transparent the canvas's static appearance bleeds through, + * causing double-text (the widget font never matches the PDF's font metrics). + * Fix: force widgets opaque. PDF.js also reads the annotation's /BC entry and + * writes it as an inline `background-color` on the element (typically + * `transparent` for unstyled forms) — inline style beats any selector, hence + * !important. + */ +#form-layer .textWidgetAnnotation :is(input, textarea), +#form-layer .choiceWidgetAnnotation select { + background-image: none !important; + background-color: light-dark(#fff, #2a2a2a) !important; +} + +#form-layer select option:checked { + background: light-dark(#d0e0ff, #335); +} + + /* Highlight Layer */ .highlight-layer { position: absolute; @@ -448,6 +807,346 @@ body { } } +/* Viewer Body (flex row for canvas + panel) */ +.viewer-body { + display: flex; + flex: 1; + min-height: 0; + overflow: visible; + position: relative; /* Anchor for floating annotation panel */ +} + +/* Annotations Toolbar Button */ +.annotations-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--bg200); + border-radius: 4px; + background: var(--bg000); + color: var(--text000); + cursor: pointer; + font-size: 1rem; + transition: all 0.15s ease; + position: relative; +} +.annotations-btn:hover { + background: var(--bg100); + border-color: var(--bg300); +} +.annotations-btn.active { + background: var(--bg200); + border-color: var(--bg300); +} + +.annotations-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + background: var(--accent, #2563eb); + color: #fff; + font-size: 0.65rem; + font-weight: 600; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; + line-height: 1; +} + +/* Annotation Side Panel */ +.annotation-panel { + width: 500px; + min-width: 180px; + max-width: 50vw; + background: var(--bg000); + border-left: 1px solid var(--bg200); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +/* Resize handle on left edge */ +.annotation-panel-resize { + position: absolute; + left: -3px; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + z-index: 10; +} +.annotation-panel-resize:hover, +.annotation-panel-resize.dragging { + background: var(--text100); + opacity: 0.3; +} + +.annotation-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--bg200); + flex-shrink: 0; +} + +.annotation-panel-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text000); +} + +.annotation-panel-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.annotation-panel-reset, +.annotation-panel-clear-all { + border: none; + background: transparent; + color: var(--text100); + cursor: pointer; + border-radius: 4px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; +} +.annotation-panel-reset:hover { + color: var(--text000); + background: var(--bg100); +} +.annotation-panel-clear-all:hover { + color: #e74c3c; + background: var(--bg100); +} +.annotation-panel-reset:disabled, +.annotation-panel-clear-all:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.annotation-panel-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text100); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.15s ease; +} +.annotation-panel-close:hover { + background: var(--bg100); + color: var(--text000); +} + +.annotation-panel-list { + flex: 1; + overflow-y: auto; + padding: 0.25rem 0; +} + +/* Floating annotation panel (used in both inline and fullscreen) */ +.annotation-panel.floating { + position: absolute; + z-index: 20; + border: 1px solid var(--bg200); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + max-height: 60%; + width: 135px; + transition: top 0.15s ease, left 0.15s ease, right 0.15s ease, bottom 0.15s ease; +} +.annotation-panel.floating .annotation-panel-header { + cursor: grab; +} +.annotation-panel.floating.dragging .annotation-panel-header { + cursor: grabbing; +} + +/* Accordion section headers */ +.annotation-section-header { + font-size: 0.7rem; + font-weight: 600; + color: var(--text100); + padding: 0.3rem 0.6rem; + text-transform: uppercase; + letter-spacing: 0.03em; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--bg200); + user-select: none; + transition: background 0.1s ease; +} +.annotation-section-header:hover { + background: var(--bg100); +} +.annotation-section-header.current-page { + color: var(--text000); +} +.annotation-section-header.open { + color: var(--text000); +} +.annotation-section-chevron { + font-size: 0.55rem; + transition: transform 0.15s ease; +} +.annotation-section-body { + display: none; +} +.annotation-section-body.open { + display: block; +} + +/* Legacy page group headers (fullscreen mode) */ +.annotation-page-group { + font-size: 0.75rem; + font-weight: 600; + color: var(--text100); + padding: 0.5rem 0.75rem 0.25rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.annotation-page-group.current-page { + color: var(--text000); +} + +/* Annotation cards */ +.annotation-card { + display: flex; + flex-direction: column; + padding: 0.4rem 0.75rem; + cursor: pointer; + transition: background 0.1s ease; + border-left: 3px solid transparent; +} +.annotation-card:hover { + background: var(--bg100); +} +.annotation-card.highlighted { + background: var(--bg100); + border-left-color: var(--text100); +} + +.annotation-card-row { + display: flex; + align-items: center; + gap: 0.4rem; + min-height: 24px; +} + +.annotation-card-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + border: 1px solid rgba(0, 0, 0, 0.15); +} + +/* Swatch for baseline items the user cleared — outlined with a cross + instead of solid fill, so the panel still shows "this existed in the + file and you removed it" rather than vanishing. */ +.annotation-card-swatch-cleared { + background: transparent; + border-color: #4a90d9; + display: flex; + align-items: center; + justify-content: center; +} + +.annotation-card-cleared .annotation-card-type, +.annotation-card-cleared .annotation-card-preview { + opacity: 0.55; + text-decoration: line-through; +} + +.annotation-card-type { + font-size: 0.7rem; + color: var(--text200); + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; +} + +.annotation-card-preview { + flex: 1; + min-width: 0; + font-size: 0.8rem; + color: var(--text000); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.annotation-card-delete { + flex-shrink: 0; + margin-left: auto; + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text200); + opacity: 0; + transition: opacity 0.1s ease, color 0.1s ease; + display: flex; + align-items: center; +} +.annotation-card:hover .annotation-card-delete { + opacity: 1; +} +.annotation-card-delete:hover { + color: #d32f2f; +} + +.annotation-card-expand { + flex-shrink: 0; + font-size: 0.65rem; + color: var(--text200); + transition: transform 0.15s ease; +} +.annotation-card.expanded .annotation-card-expand { + transform: rotate(180deg); +} + +.annotation-card-content { + display: none; + font-size: 0.8rem; + color: var(--text100); + padding: 0.25rem 0 0.25rem 14px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.4; +} +.annotation-card.expanded .annotation-card-content { + display: block; +} + +/* Pulse animation for annotation elements on PDF */ +@keyframes annotation-pulse { + 0% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.6); } + 70% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); } + 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0); } +} +.annotation-pulse { + animation: annotation-pulse 0.6s ease-out 2; +} + /* Loading Indicator (pie) */ .loading-indicator { display: inline-flex; @@ -475,6 +1174,7 @@ body { stroke: #e74c3c; } + /* Compact toolbar on narrow screens */ @media (max-width: 480px) { .toolbar-left { @@ -505,4 +1205,4 @@ body { .toolbar-right { gap: 0.2rem; } -} +} \ No newline at end of file diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 8b06a36..c8281c0 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -15,23 +15,114 @@ import { import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js"; import * as pdfjsLib from "pdfjs-dist"; -import { TextLayer } from "pdfjs-dist"; +import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist"; +import "pdfjs-dist/web/pdf_viewer.css"; +import { + type PdfAnnotationDef, + type Rect, + type RectangleAnnotation, + type CircleAnnotation, + type LineAnnotation, + type StampAnnotation, + type ImageAnnotation, + type NoteAnnotation, + type FreetextAnnotation, + serializeDiff, + deserializeDiff, + mergeAnnotations, + computeDiff, + isDiffEmpty, + buildAnnotatedPdfBytes, + importPdfjsAnnotation, + uint8ArrayToBase64, + convertFromModelCoords, + convertToModelCoords, +} from "./pdf-annotations.js"; +import { + type TrackedAnnotation, + type EditEntry, + annotationMap, + formFieldValues, + pdfBaselineFormValues, + selectedAnnotationIds, + fieldNameToIds, + fieldNameToPage, + fieldNameToLabel, + fieldNameToOrder, + undoStack, + redoStack, + searchBarEl, + formLayerEl, +} from "./viewer-state.js"; +import { + panelState, + annotationsPanelEl, + annotationsPanelListEl, + renderAnnotationPanel, + updateAnnotationsBadge, + setAnnotationPanelOpen, + applyFloatingPanelPosition, + autoDockPanel, + initAnnotationPanel, + syncSidebarSelection, + getFormFieldLabel, + getAnnotationLabel, + getAnnotationPreview, + getAnnotationColor, +} from "./annotation-panel.js"; import "./global.css"; import "./mcp-app.css"; const MAX_MODEL_CONTEXT_LENGTH = 15000; -const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max screenshot dimension // Configure PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.mjs", import.meta.url, ).href; +// PDF Standard-14 fonts from CDN (requires unpkg.com in CSP connectDomains). +// Pinned to the bundled pdfjs-dist version so font glyph indices match. +const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/standard_fonts/`; + const log = { info: console.log.bind(console, "[PDF-VIEWER]"), error: console.error.bind(console, "[PDF-VIEWER]"), }; +/** + * Resolve an ImageAnnotation to a src string safe for ``. + * Returns the parsed-and-reserialized URL (`URL.href`) rather than the + * raw input so CodeQL's taint tracker recognises the sanitisation barrier + * (js/xss, js/client-side-unvalidated-url-redirection). Blocks + * `javascript:` / `vbscript:` etc. The server normally resolves + * imageUrl → imageData before enqueueing; the imageUrl branch here is + * defense-in-depth for the server-side fetch-failure fallback. + */ +function safeImageSrc(def: { + imageData?: string; + mimeType?: string; + imageUrl?: string; +}): string | undefined { + if (def.imageData) { + return `data:${def.mimeType || "image/png"};base64,${def.imageData}`; + } + if (!def.imageUrl) return undefined; + try { + const parsed = new URL(def.imageUrl, document.baseURI); + if ( + parsed.protocol === "https:" || + parsed.protocol === "http:" || + parsed.protocol === "data:" || + parsed.protocol === "blob:" + ) { + return parsed.href; + } + } catch { + // fall through + } + return undefined; +} + // State let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null; let currentPage = 1; @@ -40,8 +131,45 @@ let scale = 1.0; let pdfUrl = ""; let pdfTitle: string | undefined; let viewUUID: string | undefined; +let interactEnabled = false; +/** Server-reported writability of the underlying file (fs.access W_OK). */ +let fileWritable = false; let currentRenderTask: { cancel: () => void } | null = null; +// Shared annotation state (annotationMap, formFieldValues, selectedAnnotationIds, +// undoStack, redoStack, fieldNameTo*) lives in ./viewer-state.ts — imported above. + +/** Cache loaded HTMLImageElement instances by annotation ID for canvas painting. */ +const imageCache = new Map(); + +/** Annotations imported from the PDF file (baseline for diff computation). */ +let pdfBaselineAnnotations: PdfAnnotationDef[] = []; + +// Dirty flag — tracks unsaved local changes +let isDirty = false; +/** Whether we're currently restoring annotations (suppress dirty flag). */ +let isRestoring = false; +/** Once the save button is shown, it stays visible (possibly disabled) until reload. */ +let saveBtnEverShown = false; +/** True between save_pdf call and resolution; suppresses file_changed handling. */ +let saveInProgress = false; +/** mtime returned by our most recent successful save_pdf. Compare against + * incoming file_changed.mtimeMs to suppress our own write's echo. */ +let lastSavedMtime: number | null = null; +/** Incremented on every reload. Fetches/preloads from an older generation are + * discarded — prevents stale rangeCache entries and stale page renders. */ +let loadGeneration = 0; + +let focusedFieldName: string | null = null; + +// Radio widget annotation ID → its export value (buttonValue). pdf.js +// creates without setting .value, so target.value +// defaults to "on"; this map lets the input listener report the real value. +const radioButtonValues = new Map(); +// Cached result of doc.getFieldObjects() — needed for AnnotationLayer reset button support +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let cachedFieldObjects: Record | null = null; + // DOM Elements const mainEl = document.querySelector(".main") as HTMLElement; const loadingEl = document.getElementById("loading")!; @@ -65,7 +193,7 @@ const fullscreenBtn = document.getElementById( ) as HTMLButtonElement; const searchBtn = document.getElementById("search-btn") as HTMLButtonElement; searchBtn.innerHTML = ``; -const searchBarEl = document.getElementById("search-bar")!; +// searchBarEl → imported from ./viewer-state.js const searchInputEl = document.getElementById( "search-input", ) as HTMLInputElement; @@ -80,6 +208,21 @@ const searchCloseBtn = document.getElementById( "search-close-btn", ) as HTMLButtonElement; const highlightLayerEl = document.getElementById("highlight-layer")!; +const annotationLayerEl = document.getElementById("annotation-layer")!; +// formLayerEl → imported from ./viewer-state.js +const saveBtn = document.getElementById("save-btn") as HTMLButtonElement; +const downloadBtn = document.getElementById( + "download-btn", +) as HTMLButtonElement; +const confirmDialogEl = document.getElementById( + "confirm-dialog", +) as HTMLDivElement; +const confirmTitleEl = document.getElementById("confirm-title")!; +const confirmBodyEl = document.getElementById("confirm-body")!; +const confirmDetailEl = document.getElementById("confirm-detail")!; +const confirmButtonsEl = document.getElementById("confirm-buttons")!; + +// Annotation Panel DOM Elements & state → ./annotation-panel.ts (imported above) // Search state interface SearchMatch { @@ -178,7 +321,10 @@ function requestFitToContent() { const totalHeight = toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER; - app.sendSizeChanged({ height: totalHeight }); + // In inline mode (this function early-returns for fullscreen) the side panel is hidden + const totalWidth = pageWrapperEl.offsetWidth + BUFFER; + + app.sendSizeChanged({ width: totalWidth, height: totalHeight }); } // --- Search Functions --- @@ -224,6 +370,46 @@ function performSearch(query: string) { goToPage(match.pageNum); } } + + // Update model context with search results + updatePageContext(); +} + +/** + * Silent search: populate matches and report via model context + * without opening the search bar or rendering highlights. + */ +function performSilentSearch(query: string) { + allMatches = []; + currentMatchIndex = -1; + searchQuery = query; + + if (!query) { + updatePageContext(); + return; + } + + const lowerQuery = query.toLowerCase(); + for (let pageNum = 1; pageNum <= totalPages; pageNum++) { + const pageText = pageTextCache.get(pageNum); + if (!pageText) continue; + const lowerText = pageText.toLowerCase(); + let startIdx = 0; + while (true) { + const idx = lowerText.indexOf(lowerQuery, startIdx); + if (idx === -1) break; + allMatches.push({ pageNum, index: idx, length: query.length }); + startIdx = idx + 1; + } + } + + if (allMatches.length > 0) { + const idx = allMatches.findIndex((m) => m.pageNum >= currentPage); + currentMatchIndex = idx >= 0 ? idx : 0; + } + + log.info(`Silent search "${query}": ${allMatches.length} matches`); + updatePageContext(); } function renderHighlights() { @@ -355,6 +541,9 @@ function openSearch() { searchBarEl.style.display = "flex"; updateSearchUI(); searchInputEl.focus(); + if (panelState.open && annotationsPanelEl.classList.contains("floating")) { + applyFloatingPanelPosition(); + } // Text extraction is handled by the background preloader } @@ -362,6 +551,9 @@ function closeSearch() { if (!searchOpen) return; searchOpen = false; searchBarEl.style.display = "none"; + if (panelState.open && annotationsPanelEl.classList.contains("floating")) { + applyFloatingPanelPosition(); + } searchQuery = ""; searchInputEl.value = ""; allMatches = []; @@ -432,10 +624,156 @@ function showViewer() { viewerEl.style.display = "flex"; } +// --------------------------------------------------------------------------- +// Confirm dialog +// --------------------------------------------------------------------------- + +interface ConfirmButton { + label: string; + primary?: boolean; +} + +let activeConfirmResolve: ((i: number) => void) | null = null; + +/** + * In-app confirmation overlay. Resolves to the clicked button index, the + * cancel index on Escape, or `-1` if pre-empted by another dialog. Callers + * should treat anything but the expected button index as "cancel". + * + * Button ordering follows the host's native convention: Cancel first, + * primary action last. + * + * @param detail Optional monospace string shown in a bordered box (e.g. + * a filename), matching the host's native dialog style. + */ +function showConfirmDialog( + title: string, + body: string, + buttons: ConfirmButton[], + detail?: string, +): Promise { + // Pre-empt any open dialog: resolve it as cancelled + if (activeConfirmResolve) { + activeConfirmResolve(-1); + activeConfirmResolve = null; + } + + // Escape → first non-primary button (native Cancel-first ordering) + const nonPrimary = buttons.findIndex((b) => !b.primary); + const escIndex = nonPrimary >= 0 ? nonPrimary : buttons.length - 1; + + confirmTitleEl.textContent = title; + confirmBodyEl.textContent = body; + confirmDetailEl.textContent = detail ?? ""; + confirmButtonsEl.innerHTML = ""; + confirmDialogEl.style.display = "flex"; + + return new Promise((resolve) => { + activeConfirmResolve = resolve; + + const done = (i: number): void => { + if (activeConfirmResolve !== resolve) return; // already pre-empted + activeConfirmResolve = null; + confirmDialogEl.style.display = "none"; + document.removeEventListener("keydown", onKey, true); + resolve(i); + }; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + done(escIndex); + } + }; + document.addEventListener("keydown", onKey, true); + + buttons.forEach((btn, i) => { + const el = document.createElement("button"); + el.textContent = btn.label; + el.className = btn.primary + ? "confirm-btn confirm-btn-primary" + : "confirm-btn"; + el.addEventListener("click", () => done(i)); + confirmButtonsEl.appendChild(el); + if (btn.primary) setTimeout(() => el.focus(), 0); + }); + }); +} + +function setDirty(dirty: boolean): void { + if (isDirty === dirty) return; + isDirty = dirty; + updateTitleDisplay(); + updateSaveBtn(); +} + +function updateSaveBtn(): void { + if (!fileWritable) { + saveBtn.style.display = "none"; + return; + } + if (isDirty) { + saveBtn.style.display = ""; + saveBtn.disabled = false; + saveBtnEverShown = true; + } else if (saveBtnEverShown) { + saveBtn.style.display = ""; + saveBtn.disabled = true; + } else { + saveBtn.style.display = "none"; + } +} + +function updateTitleDisplay(): void { + const display = pdfTitle || pdfUrl; + titleEl.textContent = (isDirty ? "* " : "") + display; + titleEl.title = pdfUrl; +} + +/** + * Debug overlay: fixed-position bubble, bottom-left. Pretty-printed JSON + * dump of whatever the server stuffed into `_meta._debug`. Tooltips inside + * sandboxed iframes are unreliable; this survives the cross-origin barrier + * and shows up in screenshots. + */ +function showDebugBubble(debug: unknown): void { + const bubble = document.createElement("div"); + const base = + "position:fixed;bottom:8px;left:8px;z-index:99999;" + + "background:rgba(20,20,30,0.92);color:#cfe;padding:8px 12px;" + + "font:11px/1.4 monospace;border-radius:6px;" + + "box-shadow:0 2px 8px rgba(0,0,0,0.4);white-space:pre;cursor:pointer;" + + "transition:max-width 0.15s ease;"; + // Collapsed: clip to 60vw. Hover: expand to fit full paths (up to ~96vw), + // scrollable both axes in case the JSON is tall. + const collapsed = + base + + "max-width:60vw;max-height:40vh;overflow:hidden;text-overflow:ellipsis;"; + const expanded = + base + "max-width:calc(100vw - 32px);max-height:80vh;overflow:auto;"; + bubble.style.cssText = collapsed; + // Latch expanded on click so hover-collapse doesn't fight text selection. + let pinned = false; + bubble.onmouseenter = () => { + bubble.style.cssText = expanded; + }; + bubble.onmouseleave = () => { + if (!pinned) bubble.style.cssText = collapsed; + }; + bubble.onclick = () => { + pinned = true; + bubble.style.cssText = expanded; + }; + bubble.ondblclick = () => bubble.remove(); + bubble.title = "Click: pin open • Double-click: dismiss"; + bubble.textContent = "🐞 " + JSON.stringify(debug, null, 2); + document.body.appendChild(bubble); +} + function updateControls() { // Show URL with CSS ellipsis, full URL as tooltip, clickable to open - titleEl.textContent = pdfUrl; - titleEl.title = pdfUrl; + updateTitleDisplay(); titleEl.style.textDecoration = "underline"; titleEl.style.cursor = "pointer"; titleEl.onclick = () => app.openLink({ url: pdfUrl }); @@ -541,6 +879,50 @@ function findSelectionInText( return undefined; } +/** + * Format search results with excerpts for model context. + * Limits to first 20 matches to avoid overwhelming the context. + */ +function formatSearchResults(): string { + const MAX_RESULTS = 20; + const EXCERPT_RADIUS = 40; // characters around the match + + const lines: string[] = []; + const totalMatchCount = allMatches.length; + const currentIdx = currentMatchIndex >= 0 ? currentMatchIndex : -1; + + lines.push( + `\nSearch: "${searchQuery}" (${totalMatchCount} match${totalMatchCount !== 1 ? "es" : ""} across ${new Set(allMatches.map((m) => m.pageNum)).size} page${new Set(allMatches.map((m) => m.pageNum)).size !== 1 ? "s" : ""})`, + ); + + const displayed = allMatches.slice(0, MAX_RESULTS); + for (let i = 0; i < displayed.length; i++) { + const match = displayed[i]; + const pageText = pageTextCache.get(match.pageNum) || ""; + const start = Math.max(0, match.index - EXCERPT_RADIUS); + const end = Math.min( + pageText.length, + match.index + match.length + EXCERPT_RADIUS, + ); + const before = pageText.slice(start, match.index).replace(/\n/g, " "); + const matched = pageText.slice(match.index, match.index + match.length); + const after = pageText + .slice(match.index + match.length, end) + .replace(/\n/g, " "); + const prefix = start > 0 ? "..." : ""; + const suffix = end < pageText.length ? "..." : ""; + const current = i === currentIdx ? " (current)" : ""; + lines.push( + ` [${i}] p.${match.pageNum}, offset ${match.index}${current}: ${prefix}${before}«${matched}»${after}${suffix}`, + ); + } + if (totalMatchCount > MAX_RESULTS) { + lines.push(` ... and ${totalMatchCount - MAX_RESULTS} more matches`); + } + + return lines.join("\n"); +} + // Extract text from current page and update model context async function updatePageContext() { if (!pdfDocument) return; @@ -577,15 +959,79 @@ async function updatePageContext() { selection, ); + // Get page dimensions in PDF points for model context + const viewport = page.getViewport({ scale: 1.0 }); + const pageWidthPt = Math.round(viewport.width); + const pageHeightPt = Math.round(viewport.height); + // Build context with tool ID for multi-tool disambiguation const toolId = app.getHostContext()?.toolInfo?.id; const header = [ `PDF viewer${toolId ? ` (${toolId})` : ""}`, + viewUUID ? `viewUUID: ${viewUUID}` : null, pdfTitle ? `"${pdfTitle}"` : pdfUrl, `Current Page: ${currentPage}/${totalPages}`, - ].join(" | "); + `Page size: ${pageWidthPt}×${pageHeightPt}pt (coordinates: origin at top-left, Y increases downward)`, + ] + .filter(Boolean) + .join(" | "); - const contextText = `${header}\n\nPage content:\n${content}`; + // 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 }]; @@ -593,34 +1039,15 @@ async function updatePageContext() { // Add screenshot if host supports image content if (app.getHostCapabilities()?.updateModelContext?.image) { try { - // Scale down to reduce token usage (tokens depend on dimensions) - const sourceCanvas = canvasEl; - const scale = Math.min( - 1, - MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION / - Math.max(sourceCanvas.width, sourceCanvas.height), - ); - 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})`, - ); - } + // 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); @@ -633,6 +1060,1829 @@ async function updatePageContext() { } } +// ============================================================================= +// Annotation Rendering +// ============================================================================= + +/** + * Convert PDF coordinates (bottom-left origin) to screen coordinates + * relative to the page wrapper. PDF.js viewport handles rotation and scale. + */ +function pdfRectToScreen( + rect: Rect, + viewport: { width: number; height: number; scale: number }, +): { left: number; top: number; width: number; height: number } { + const s = viewport.scale; + // PDF origin is bottom-left, screen origin is top-left + const left = rect.x * s; + const top = viewport.height - (rect.y + rect.height) * s; + const width = rect.width * s; + const height = rect.height * s; + return { left, top, width, height }; +} + +function pdfPointToScreen( + x: number, + y: number, + viewport: { width: number; height: number; scale: number }, +): { left: number; top: number } { + const s = viewport.scale; + return { left: x * s, top: viewport.height - y * s }; +} + +/** Convert a screen-space delta (pixels) to a PDF-space delta. */ +function screenToPdfDelta(dx: number, dy: number): { dx: number; dy: number } { + return { dx: dx / scale, dy: -dy / scale }; +} + +// ============================================================================= +// Undo / Redo +// ============================================================================= + +function pushEdit(entry: EditEntry): void { + undoStack.push(entry); + redoStack.length = 0; +} + +function undo(): void { + const entry = undoStack.pop(); + if (!entry) return; + redoStack.push(entry); + applyEdit(entry, true); +} + +function redo(): void { + const entry = redoStack.pop(); + if (!entry) return; + undoStack.push(entry); + applyEdit(entry, false); +} + +function applyEdit(entry: EditEntry, reverse: boolean): void { + const state = reverse ? entry.before : entry.after; + if (entry.type === "add") { + if (reverse) { + removeAnnotation(entry.id, true); + } else { + addAnnotation(state!, true); + } + } else if (entry.type === "remove") { + if (reverse) { + addAnnotation(state!, true); + } else { + removeAnnotation(entry.id, true); + } + } else { + if (state) { + const tracked = annotationMap.get(entry.id); + if (tracked) { + tracked.def = { ...state }; + } else { + annotationMap.set(entry.id, { def: { ...state }, elements: [] }); + } + } + renderAnnotationsForPage(currentPage); + renderAnnotationPanel(); + } + persistAnnotations(); +} + +// ============================================================================= +// Selection +// ============================================================================= + +/** + * Select annotation(s). Pass null to deselect all. + * If additive is true, toggle the given id without clearing existing selection. + */ +function selectAnnotation(id: string | null, additive = false): void { + if (!additive) { + // Clear all existing selection visuals + for (const prevId of selectedAnnotationIds) { + const tracked = annotationMap.get(prevId); + if (tracked) { + for (const el of tracked.elements) { + el.classList.remove("annotation-selected"); + } + } + } + // Remove handles + for (const h of annotationLayerEl.querySelectorAll( + ".annotation-handle, .annotation-handle-rotate", + )) { + h.remove(); + } + selectedAnnotationIds.clear(); + } + + if (id) { + if (additive && selectedAnnotationIds.has(id)) { + // Toggle off + selectedAnnotationIds.delete(id); + const tracked = annotationMap.get(id); + if (tracked) { + for (const el of tracked.elements) { + el.classList.remove("annotation-selected"); + } + } + } else { + selectedAnnotationIds.add(id); + } + } + + // Apply selection visuals + handles on all selected + // Only show handles when exactly one annotation is selected + for (const selId of selectedAnnotationIds) { + const tracked = annotationMap.get(selId); + if (tracked) { + for (const el of tracked.elements) { + el.classList.add("annotation-selected"); + } + if (selectedAnnotationIds.size === 1) { + showHandles(tracked); + } + } + } + + // Auto-expand the accordion section for the selected annotation's page + if (id) { + const tracked = annotationMap.get(id); + if (tracked) { + panelState.openAccordionSection = `page-${tracked.def.page}`; + } + } + + // Re-render the panel so accordion sections open/close to match selection + renderAnnotationPanel(); + + // Scroll the selected card into view in the sidebar + if (id) { + const card = annotationsPanelListEl.querySelector( + `.annotation-card[data-annotation-id="${id}"]`, + ); + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } + + // Sync sidebar + syncSidebarSelection(); + // Auto-dock floating panel away from selected annotation + if ( + selectedAnnotationIds.size > 0 && + annotationsPanelEl.classList.contains("floating") && + panelState.open + ) { + autoDockPanel(); + } + // Update model context with selection info + updatePageContext(); +} + +/** Types that support resize handles (need width/height). */ +const RESIZABLE_TYPES = new Set(["rectangle", "circle", "image"]); +/** Types that support rotation. */ +const ROTATABLE_TYPES = new Set(["rectangle", "stamp", "image"]); + +function showHandles(tracked: TrackedAnnotation): void { + const def = tracked.def; + if (tracked.elements.length === 0) return; + if (!RESIZABLE_TYPES.has(def.type) && !ROTATABLE_TYPES.has(def.type)) return; + + const el = tracked.elements[0]; + + // Resize handles (corners) for types with width/height + if (RESIZABLE_TYPES.has(def.type) && "width" in def && "height" in def) { + for (const corner of ["nw", "ne", "sw", "se"] as const) { + const handle = document.createElement("div"); + handle.className = `annotation-handle ${corner}`; + handle.dataset.corner = corner; + const isImagePreserve = + def.type === "image" && + ((def as ImageAnnotation).aspect ?? "preserve") === "preserve"; + handle.title = isImagePreserve + ? "Drag to resize (Shift for free resize)" + : "Drag to resize (Shift to keep proportions)"; + setupResizeHandle(handle, tracked, corner); + el.appendChild(handle); + } + } + + // Rotate handle for rotatable types + if (ROTATABLE_TYPES.has(def.type)) { + const handle = document.createElement("div"); + handle.className = "annotation-handle-rotate"; + handle.title = "Drag to rotate"; + setupRotateHandle(handle, tracked); + el.appendChild(handle); + } +} + +// ============================================================================= +// Drag (move) +// ============================================================================= + +const DRAGGABLE_TYPES = new Set([ + "rectangle", + "circle", + "line", + "freetext", + "stamp", + "note", + "image", +]); + +function setupAnnotationInteraction( + el: HTMLElement, + tracked: TrackedAnnotation, +): void { + // Click to select (Shift+click for additive multi-select) + el.addEventListener("mousedown", (e) => { + // Ignore if clicking on a handle + if ( + (e.target as HTMLElement).classList.contains("annotation-handle") || + (e.target as HTMLElement).classList.contains("annotation-handle-rotate") + ) { + return; + } + e.stopPropagation(); + selectAnnotation(tracked.def.id, e.shiftKey); + + // Start drag for draggable types (only single-select) + if (DRAGGABLE_TYPES.has(tracked.def.type) && !e.shiftKey) { + startDrag(e, tracked); + } + }); + + // Double-click to send message to modify annotation (same as sidebar card) + el.addEventListener("dblclick", (e) => { + e.stopPropagation(); + selectAnnotation(tracked.def.id); + const label = getAnnotationLabel(tracked.def); + const previewText = getAnnotationPreview(tracked.def); + const desc = previewText ? `${label}: ${previewText}` : label; + void app + .sendMessage({ + role: "user", + content: [{ type: "text", text: `update ${desc}: ` }], + }) + .catch(log.error); + }); +} + +function startDrag(e: MouseEvent, tracked: TrackedAnnotation): void { + const def = tracked.def; + const startX = e.clientX; + const startY = e.clientY; + const beforeDef = { ...def } as PdfAnnotationDef; + let moved = false; + + // Store original element positions + const originalPositions = tracked.elements.map((el) => ({ + left: parseFloat(el.style.left), + top: parseFloat(el.style.top), + })); + + document.body.style.cursor = "grabbing"; + for (const el of tracked.elements) { + el.classList.add("annotation-dragging"); + } + + const onMouseMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true; + // Move elements directly for smooth feedback + for (let i = 0; i < tracked.elements.length; i++) { + tracked.elements[i].style.left = `${originalPositions[i].left + dx}px`; + tracked.elements[i].style.top = `${originalPositions[i].top + dy}px`; + } + }; + + const onMouseUp = (ev: MouseEvent) => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.cursor = ""; + for (const el of tracked.elements) { + el.classList.remove("annotation-dragging"); + } + + if (!moved) return; + + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + const pdfDelta = screenToPdfDelta(dx, dy); + + // Apply move to def + applyMoveToDef( + tracked.def as PdfAnnotationDef & { x: number; y: number }, + pdfDelta.dx, + pdfDelta.dy, + ); + + const afterDef = { ...tracked.def } as PdfAnnotationDef; + pushEdit({ + type: "update", + id: def.id, + before: beforeDef, + after: afterDef, + }); + persistAnnotations(); + // Re-render to get correct positions + renderAnnotationsForPage(currentPage); + // Re-select to show handles + selectAnnotation(def.id); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); +} + +function applyMoveToDef( + def: PdfAnnotationDef & { x?: number; y?: number }, + dx: number, + dy: number, +): void { + if (def.type === "line") { + def.x1 += dx; + def.y1 += dy; + def.x2 += dx; + def.y2 += dy; + } else if ("x" in def && "y" in def) { + def.x! += dx; + def.y! += dy; + } +} + +// ============================================================================= +// Resize (rectangle, circle, image) +// ============================================================================= + +function setupResizeHandle( + handle: HTMLElement, + tracked: TrackedAnnotation, + corner: "nw" | "ne" | "sw" | "se", +): void { + handle.addEventListener("mousedown", (e) => { + e.stopPropagation(); + e.preventDefault(); + + const def = tracked.def as + | RectangleAnnotation + | CircleAnnotation + | ImageAnnotation; + const beforeDef = { ...def }; + const startX = e.clientX; + const startY = e.clientY; + const aspectRatio = beforeDef.height / beforeDef.width; + + const onMouseMove = (ev: MouseEvent) => { + const dxScreen = ev.clientX - startX; + const dyScreen = ev.clientY - startY; + const pdfD = screenToPdfDelta(dxScreen, dyScreen); + + // Reset to before state then apply delta + let newX = beforeDef.x; + let newY = beforeDef.y; + let newW = beforeDef.width; + let newH = beforeDef.height; + + // In PDF coords: x goes right, y goes up + if (corner.includes("w")) { + newX += pdfD.dx; + newW -= pdfD.dx; + } else { + newW += pdfD.dx; + } + if (corner.includes("s")) { + newY += pdfD.dy; + newH -= pdfD.dy; + } else { + newH += pdfD.dy; + } + + // Constrain aspect ratio: + // - For images: preserve by default (Shift to ignore), unless aspect="ignore" + // - For other shapes: Shift to preserve + const isImage = def.type === "image"; + const imageAspect = isImage + ? ((def as ImageAnnotation).aspect ?? "preserve") + : undefined; + const constrainAspect = isImage + ? imageAspect === "preserve" + ? !ev.shiftKey // preserve by default, Shift to free-resize + : ev.shiftKey // ignore by default, Shift to constrain + : ev.shiftKey; // non-image: Shift to constrain + + if (constrainAspect) { + // Use the wider dimension to drive the other + const candidateH = newW * aspectRatio; + newH = candidateH; + // Adjust origin for corners that anchor at bottom/left + if (corner.includes("s")) { + newY = beforeDef.y + beforeDef.height - newH; + } + if (corner.includes("w")) { + // width changed by resize, x was already adjusted above + } + } + + // Enforce minimum size + if (newW < 5) { + newW = 5; + } + if (newH < 5) { + newH = 5; + } + + def.x = newX; + def.y = newY; + def.width = newW; + def.height = newH; + + // Re-render for live feedback + renderAnnotationsForPage(currentPage); + selectAnnotation(def.id); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + const afterDef = { ...def }; + pushEdit({ + type: "update", + id: def.id, + before: beforeDef, + after: afterDef, + }); + persistAnnotations(); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); +} + +// ============================================================================= +// Rotate (stamp, rectangle) +// ============================================================================= + +function setupRotateHandle( + handle: HTMLElement, + tracked: TrackedAnnotation, +): void { + handle.addEventListener("mousedown", (e) => { + e.stopPropagation(); + e.preventDefault(); + + const def = tracked.def as + | StampAnnotation + | RectangleAnnotation + | ImageAnnotation; + const beforeDef = { ...def }; + const el = tracked.elements[0]; + const rect = el.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const onMouseMove = (ev: MouseEvent) => { + const angle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX); + // Convert to degrees, offset so 0 = pointing up + let degrees = (angle * 180) / Math.PI + 90; + // Normalize + if (degrees < 0) degrees += 360; + if (degrees > 360) degrees -= 360; + // Snap to 15-degree increments when close + const snapped = Math.round(degrees / 15) * 15; + if (Math.abs(degrees - snapped) < 3) degrees = snapped; + + def.rotation = Math.round(degrees); + renderAnnotationsForPage(currentPage); + selectAnnotation(def.id); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + + const afterDef = { ...def }; + pushEdit({ + type: "update", + id: def.id, + before: beforeDef, + after: afterDef, + }); + persistAnnotations(); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); +} + +/** + * Paint annotations for a page onto a 2D canvas context. + * Used to include annotations in screenshots sent to the model. + */ +function paintAnnotationsOnCanvas( + ctx: CanvasRenderingContext2D, + pageNum: number, + viewport: { width: number; height: number; scale: number }, +): void { + for (const tracked of annotationMap.values()) { + const def = tracked.def; + if (def.page !== pageNum) continue; + + const color = getAnnotationColor(def); + + switch (def.type) { + case "highlight": + ctx.save(); + ctx.globalAlpha = 0.35; + ctx.fillStyle = def.color || "rgba(255, 255, 0, 1)"; + for (const rect of def.rects) { + const s = pdfRectToScreen(rect, viewport); + ctx.fillRect(s.left, s.top, s.width, s.height); + } + ctx.restore(); + break; + + case "underline": + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + for (const rect of def.rects) { + const s = pdfRectToScreen(rect, viewport); + ctx.beginPath(); + ctx.moveTo(s.left, s.top + s.height); + ctx.lineTo(s.left + s.width, s.top + s.height); + ctx.stroke(); + } + ctx.restore(); + break; + + case "strikethrough": + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + for (const rect of def.rects) { + const s = pdfRectToScreen(rect, viewport); + const midY = s.top + s.height / 2; + ctx.beginPath(); + ctx.moveTo(s.left, midY); + ctx.lineTo(s.left + s.width, midY); + ctx.stroke(); + } + ctx.restore(); + break; + + case "note": { + const pos = pdfPointToScreen(def.x, def.y, viewport); + ctx.save(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.8; + ctx.fillRect(pos.left, pos.top - 16, 16, 16); + ctx.restore(); + break; + } + + case "rectangle": { + const s = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + ctx.save(); + if (def.rotation) { + const cx = s.left + s.width / 2; + const cy = s.top + s.height / 2; + ctx.translate(cx, cy); + ctx.rotate((def.rotation * Math.PI) / 180); + ctx.translate(-cx, -cy); + } + if (def.fillColor) { + ctx.globalAlpha = 0.3; + ctx.fillStyle = def.fillColor; + ctx.fillRect(s.left, s.top, s.width, s.height); + } + ctx.globalAlpha = 1; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(s.left, s.top, s.width, s.height); + ctx.restore(); + break; + } + + case "freetext": { + const pos = pdfPointToScreen(def.x, def.y, viewport); + ctx.save(); + ctx.fillStyle = color; + ctx.font = `${(def.fontSize || 12) * viewport.scale}px Helvetica, Arial, sans-serif`; + ctx.fillText(def.content, pos.left, pos.top); + ctx.restore(); + break; + } + + case "stamp": { + const pos = pdfPointToScreen(def.x, def.y, viewport); + ctx.save(); + ctx.translate(pos.left, pos.top); + if (def.rotation) ctx.rotate((def.rotation * Math.PI) / 180); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 3; + ctx.globalAlpha = 0.6; + ctx.font = `bold ${24 * viewport.scale}px Helvetica, Arial, sans-serif`; + const metrics = ctx.measureText(def.label); + const pad = 8 * viewport.scale; + ctx.strokeRect( + -pad, + -24 * viewport.scale - pad, + metrics.width + pad * 2, + 24 * viewport.scale + pad * 2, + ); + ctx.fillText(def.label, 0, 0); + ctx.restore(); + break; + } + + case "circle": { + const s = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + ctx.save(); + if (def.fillColor) { + ctx.globalAlpha = 0.3; + ctx.fillStyle = def.fillColor; + ctx.beginPath(); + ctx.ellipse( + s.left + s.width / 2, + s.top + s.height / 2, + s.width / 2, + s.height / 2, + 0, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + ctx.globalAlpha = 1; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse( + s.left + s.width / 2, + s.top + s.height / 2, + s.width / 2, + s.height / 2, + 0, + 0, + Math.PI * 2, + ); + ctx.stroke(); + ctx.restore(); + break; + } + + case "line": { + const p1 = pdfPointToScreen(def.x1, def.y1, viewport); + const p2 = pdfPointToScreen(def.x2, def.y2, viewport); + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(p1.left, p1.top); + ctx.lineTo(p2.left, p2.top); + ctx.stroke(); + ctx.restore(); + break; + } + + case "image": { + const s = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + // Try to draw from cache + const cachedImg = imageCache.get(def.id); + if (cachedImg) { + ctx.save(); + if (def.rotation) { + const cx = s.left + s.width / 2; + const cy = s.top + s.height / 2; + ctx.translate(cx, cy); + ctx.rotate((def.rotation * Math.PI) / 180); + ctx.translate(-cx, -cy); + } + ctx.drawImage(cachedImg, s.left, s.top, s.width, s.height); + ctx.restore(); + } else { + // Load image asynchronously into cache for next paint + const src = safeImageSrc(def); + if (src) { + const img = new Image(); + img.onload = () => { + imageCache.set(def.id, img); + }; + img.src = src; + } + // Draw placeholder border + ctx.save(); + ctx.strokeStyle = "#999"; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.strokeRect(s.left, s.top, s.width, s.height); + ctx.restore(); + } + break; + } + } + } +} + +function renderAnnotationsForPage(pageNum: number): void { + // Clear existing annotation elements + annotationLayerEl.innerHTML = ""; + + // Remove tracked element refs for all annotations + for (const tracked of annotationMap.values()) { + tracked.elements = []; + } + + if (!pdfDocument) return; + + // Get viewport for coordinate conversion + const vp = { + width: parseFloat(annotationLayerEl.style.width) || 0, + height: parseFloat(annotationLayerEl.style.height) || 0, + scale, + }; + if (vp.width === 0 || vp.height === 0) return; + + for (const tracked of annotationMap.values()) { + const def = tracked.def; + if (def.page !== pageNum) continue; + + const elements = renderAnnotation(def, vp); + tracked.elements = elements; + for (const el of elements) { + // Set up selection + drag/resize/rotate interactions + setupAnnotationInteraction(el, tracked); + annotationLayerEl.appendChild(el); + } + // Restore selection state after re-render + if (selectedAnnotationIds.has(def.id)) { + for (const el of elements) { + el.classList.add("annotation-selected"); + } + if (selectedAnnotationIds.size === 1) { + showHandles(tracked); + } + } + } + + // Refresh panel to update current-page highlighting + renderAnnotationPanel(); +} + +function renderAnnotation( + def: PdfAnnotationDef, + viewport: { width: number; height: number; scale: number }, +): HTMLElement[] { + switch (def.type) { + case "highlight": + return renderRectsAnnotation( + def.rects, + "annotation-highlight", + viewport, + def.color ? { background: def.color } : {}, + ); + case "underline": + return renderRectsAnnotation( + def.rects, + "annotation-underline", + viewport, + def.color ? { borderBottomColor: def.color } : {}, + ); + case "strikethrough": + return renderRectsAnnotation( + def.rects, + "annotation-strikethrough", + viewport, + {}, + def.color, + ); + case "note": + return [renderNoteAnnotation(def, viewport)]; + case "rectangle": + return [renderRectangleAnnotation(def, viewport)]; + case "freetext": + return [renderFreetextAnnotation(def, viewport)]; + case "stamp": + return [renderStampAnnotation(def, viewport)]; + case "circle": + return [renderCircleAnnotation(def, viewport)]; + case "line": + return [renderLineAnnotation(def, viewport)]; + case "image": + return [renderImageAnnotation(def, viewport)]; + } +} + +function renderRectsAnnotation( + rects: Rect[], + className: string, + viewport: { width: number; height: number; scale: number }, + extraStyles: Record, + strikeColor?: string, +): HTMLElement[] { + return rects.map((rect) => { + const screen = pdfRectToScreen(rect, viewport); + const el = document.createElement("div"); + el.className = className; + el.style.left = `${screen.left}px`; + el.style.top = `${screen.top}px`; + el.style.width = `${screen.width}px`; + el.style.height = `${screen.height}px`; + for (const [k, v] of Object.entries(extraStyles)) { + (el.style as unknown as Record)[k] = v; + } + if (strikeColor) { + // Set color for the ::after pseudo-element via CSS custom property + el.style.setProperty("--strike-color", strikeColor); + el.querySelector("::after"); // no-op, style via CSS instead + // Actually use inline style on a child element for the line + const line = document.createElement("div"); + line.style.position = "absolute"; + line.style.left = "0"; + line.style.right = "0"; + line.style.top = "50%"; + line.style.borderTop = `2px solid ${strikeColor}`; + el.appendChild(line); + } + return el; + }); +} + +function renderNoteAnnotation( + def: NoteAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const pos = pdfPointToScreen(def.x, def.y, viewport); + const el = document.createElement("div"); + el.className = "annotation-note"; + el.style.left = `${pos.left}px`; + el.style.top = `${pos.top - 20}px`; // offset up so note icon is at the point + if (def.color) el.style.color = def.color; + + const tooltip = document.createElement("div"); + tooltip.className = "annotation-tooltip"; + tooltip.textContent = def.content; + el.appendChild(tooltip); + + return el; +} + +function renderRectangleAnnotation( + def: RectangleAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const screen = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + const el = document.createElement("div"); + el.className = "annotation-rectangle"; + el.style.left = `${screen.left}px`; + el.style.top = `${screen.top}px`; + el.style.width = `${screen.width}px`; + el.style.height = `${screen.height}px`; + if (def.color) el.style.borderColor = def.color; + if (def.fillColor) el.style.backgroundColor = def.fillColor; + if (def.rotation) { + el.style.transform = `rotate(${def.rotation}deg)`; + el.style.transformOrigin = "center center"; + } + return el; +} + +function renderFreetextAnnotation( + def: FreetextAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const pos = pdfPointToScreen(def.x, def.y, viewport); + const el = document.createElement("div"); + el.className = "annotation-freetext"; + el.style.left = `${pos.left}px`; + el.style.top = `${pos.top}px`; + el.style.fontSize = `${(def.fontSize || 12) * viewport.scale}px`; + if (def.color) el.style.color = def.color; + el.textContent = def.content; + return el; +} + +function renderStampAnnotation( + def: StampAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const pos = pdfPointToScreen(def.x, def.y, viewport); + const el = document.createElement("div"); + el.className = "annotation-stamp"; + el.style.left = `${pos.left}px`; + el.style.top = `${pos.top}px`; + el.style.fontSize = `${24 * viewport.scale}px`; + if (def.color) el.style.color = def.color; + if (def.rotation) { + el.style.transform = `rotate(${def.rotation}deg)`; + el.style.transformOrigin = "center center"; + } + el.textContent = def.label; + return el; +} + +function renderCircleAnnotation( + def: CircleAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const screen = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + const el = document.createElement("div"); + el.className = "annotation-circle"; + el.style.left = `${screen.left}px`; + el.style.top = `${screen.top}px`; + el.style.width = `${screen.width}px`; + el.style.height = `${screen.height}px`; + if (def.color) el.style.borderColor = def.color; + if (def.fillColor) el.style.backgroundColor = def.fillColor; + return el; +} + +function renderLineAnnotation( + def: LineAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const p1 = pdfPointToScreen(def.x1, def.y1, viewport); + const p2 = pdfPointToScreen(def.x2, def.y2, viewport); + const dx = p2.left - p1.left; + const dy = p2.top - p1.top; + const length = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + + const el = document.createElement("div"); + el.className = "annotation-line"; + el.style.left = `${p1.left}px`; + el.style.top = `${p1.top}px`; + el.style.width = `${length}px`; + el.style.transform = `rotate(${angle}rad)`; + el.style.transformOrigin = "0 0"; + if (def.color) el.style.borderColor = def.color; + return el; +} + +function renderImageAnnotation( + def: ImageAnnotation, + viewport: { width: number; height: number; scale: number }, +): HTMLElement { + const screen = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + const el = document.createElement("div"); + el.className = "annotation-image"; + el.style.left = `${screen.left}px`; + el.style.top = `${screen.top}px`; + el.style.width = `${screen.width}px`; + el.style.height = `${screen.height}px`; + if (def.rotation) { + el.style.transform = `rotate(${def.rotation}deg)`; + el.style.transformOrigin = "center center"; + } + + const imgSrc = safeImageSrc(def); + if (imgSrc) { + const img = document.createElement("img"); + img.src = imgSrc; + img.style.width = "100%"; + img.style.height = "100%"; + img.style.display = "block"; + img.style.pointerEvents = "none"; + img.draggable = false; + el.appendChild(img); + } + return el; +} + +// ============================================================================= +// Annotation CRUD +// ============================================================================= + +function addAnnotation(def: PdfAnnotationDef, skipUndo = false): void { + // Remove existing if same id (without pushing to undo) + removeAnnotation(def.id, true); + annotationMap.set(def.id, { def, elements: [] }); + if (!skipUndo) { + pushEdit({ type: "add", id: def.id, before: null, after: { ...def } }); + } + // Re-render if on current page + if (def.page === currentPage) { + renderAnnotationsForPage(currentPage); + } + updateAnnotationsBadge(); + renderAnnotationPanel(); +} + +function updateAnnotation( + update: Partial & { id: string; type: string }, + skipUndo = false, +): void { + const tracked = annotationMap.get(update.id); + if (!tracked) return; + + const before = { ...tracked.def } as PdfAnnotationDef; + + // Merge partial update into existing def + const merged = { ...tracked.def, ...update } as PdfAnnotationDef; + tracked.def = merged; + + if (!skipUndo) { + pushEdit({ type: "update", id: update.id, before, after: { ...merged } }); + } + + // Re-render if on current page + if (merged.page === currentPage) { + renderAnnotationsForPage(currentPage); + } + renderAnnotationPanel(); +} + +function removeAnnotation(id: string, skipUndo = false): void { + const tracked = annotationMap.get(id); + if (!tracked) return; + if (!skipUndo) { + pushEdit({ type: "remove", id, before: { ...tracked.def }, after: null }); + } + for (const el of tracked.elements) el.remove(); + annotationMap.delete(id); + selectedAnnotationIds.delete(id); + updateAnnotationsBadge(); + renderAnnotationPanel(); +} +// ============================================================================= +// Annotation Panel → extracted to ./annotation-panel.ts +// ============================================================================= + +// ============================================================================= +// highlight_text Command +// ============================================================================= + +function handleHighlightText(cmd: { + id: string; + query: string; + page?: number; + color?: string; + content?: string; +}): void { + const pagesToSearch: number[] = []; + if (cmd.page) { + pagesToSearch.push(cmd.page); + } else { + // Search all pages that have cached text + for (const [pageNum, text] of pageTextCache) { + if (text.toLowerCase().includes(cmd.query.toLowerCase())) { + pagesToSearch.push(pageNum); + } + } + } + + let annotationIndex = 0; + for (const pageNum of pagesToSearch) { + // Find text positions using the text layer DOM if on current page, + // otherwise create approximate rects from text cache positions + const rects = findTextRects(cmd.query, pageNum); + if (rects.length > 0) { + const id = + pagesToSearch.length > 1 + ? `${cmd.id}_p${pageNum}_${annotationIndex++}` + : cmd.id; + addAnnotation({ + type: "highlight", + id, + page: pageNum, + rects, + color: cmd.color, + content: cmd.content, + }); + } + } +} + +/** + * Find text in a page and return PDF-coordinate rects. + * Uses the TextLayer DOM when the page is currently rendered, + * otherwise falls back to approximate character-based positioning. + */ +function findTextRects(query: string, pageNum: number): Rect[] { + if (pageNum !== currentPage) { + // For non-current pages, create approximate rects from page dimensions + // The text will be properly positioned when the user navigates to that page + return findTextRectsFromCache(query, pageNum); + } + + // Use text layer DOM for current page + const spans = Array.from( + textLayerEl.querySelectorAll("span"), + ) as HTMLElement[]; + if (spans.length === 0) return findTextRectsFromCache(query, pageNum); + + const lowerQuery = query.toLowerCase(); + const rects: Rect[] = []; + const wrapperEl = textLayerEl.parentElement!; + const wrapperRect = wrapperEl.getBoundingClientRect(); + + for (const span of spans) { + const text = span.textContent || ""; + if (text.length === 0) continue; + const lowerText = text.toLowerCase(); + + let pos = 0; + while (true) { + const idx = lowerText.indexOf(lowerQuery, pos); + if (idx === -1) break; + pos = idx + 1; + + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + try { + const range = document.createRange(); + range.setStart(textNode, idx); + range.setEnd(textNode, Math.min(idx + lowerQuery.length, text.length)); + const clientRects = range.getClientRects(); + + for (let ri = 0; ri < clientRects.length; ri++) { + const r = clientRects[ri]; + // Convert screen coords back to PDF coords + const screenLeft = r.left - wrapperRect.left; + const screenTop = r.top - wrapperRect.top; + const pdfX = screenLeft / scale; + const pdfHeight = r.height / scale; + const pdfWidth = r.width / scale; + const pageHeight = parseFloat(annotationLayerEl.style.height) / scale; + const pdfY = pageHeight - (screenTop + r.height) / scale; + rects.push({ + x: pdfX, + y: pdfY, + width: pdfWidth, + height: pdfHeight, + }); + } + } catch { + // Range API errors with stale nodes + } + } + } + + return rects; +} + +function findTextRectsFromCache(query: string, pageNum: number): Rect[] { + const text = pageTextCache.get(pageNum); + if (!text) return []; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const idx = lowerText.indexOf(lowerQuery); + if (idx === -1) return []; + + // Text exists in the cache but the text-layer DOM for this page isn't + // rendered yet — we can't compute accurate rects. Returning a placeholder + // would persist wrong coordinates; return empty and let the caller skip. + return []; +} + +// ============================================================================= +// get_pages — Offscreen rendering for model analysis +// ============================================================================= + +const MAX_GET_PAGES = 20; +const SCREENSHOT_MAX_DIM = 768; // Max pixel dimension for screenshots + +/** + * Expand intervals into a sorted deduplicated list of page numbers, + * clamped to [1, totalPages]. + */ +function expandIntervals( + intervals: Array<{ start?: number; end?: number }>, +): number[] { + const pages = new Set(); + for (const iv of intervals) { + const s = Math.max(1, iv.start ?? 1); + const e = Math.min(totalPages, iv.end ?? totalPages); + for (let p = s; p <= e; p++) pages.add(p); + } + return [...pages].sort((a, b) => a - b); +} + +/** + * Render a single page to an offscreen canvas and return base64 JPEG. + * Does not affect the visible canvas or text layer. + */ +async function renderPageOffscreen(pageNum: number): Promise { + if (!pdfDocument) throw new Error("No PDF loaded"); + const page = await pdfDocument.getPage(pageNum); + const baseViewport = page.getViewport({ scale: 1.0 }); + + // Scale down to fit within SCREENSHOT_MAX_DIM + const maxDim = Math.max(baseViewport.width, baseViewport.height); + const renderScale = + maxDim > SCREENSHOT_MAX_DIM ? SCREENSHOT_MAX_DIM / maxDim : 1.0; + const viewport = page.getViewport({ scale: renderScale }); + + const canvas = document.createElement("canvas"); + const dpr = 1; // No retina scaling for model screenshots + canvas.width = viewport.width * dpr; + canvas.height = viewport.height * dpr; + const ctx = canvas.getContext("2d")!; + ctx.scale(dpr, dpr); + + // Render with ENABLE_STORAGE so filled form fields appear on the canvas + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (page.render as any)({ + canvasContext: ctx, + viewport, + annotationMode: AnnotationMode.ENABLE_STORAGE, + annotationStorage: pdfDocument.annotationStorage, + }).promise; + + // Paint annotations on top so the model can see them + paintAnnotationsOnCanvas(ctx, pageNum, { + width: viewport.width, + height: viewport.height, + scale: renderScale, + }); + + // Extract base64 JPEG (much smaller than PNG, well within body limits) + const dataUrl = canvas.toDataURL("image/jpeg", 0.85); + return dataUrl.split(",")[1]; +} + +async function handleGetPages(cmd: { + requestId: string; + intervals: Array<{ start?: number; end?: number }>; + getText: boolean; + getScreenshots: boolean; +}): Promise { + const allPages = expandIntervals(cmd.intervals); + const pages = allPages.slice(0, MAX_GET_PAGES); + + log.info( + `get_pages: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${cmd.getText}, screenshots=${cmd.getScreenshots}`, + ); + + const results: Array<{ + page: number; + text?: string; + image?: string; + }> = []; + + for (const pageNum of pages) { + const entry: { page: number; text?: string; image?: string } = { + page: pageNum, + }; + + if (cmd.getText) { + // Use cached text if available, otherwise extract on the fly + let text = pageTextCache.get(pageNum); + if (text == null && pdfDocument) { + try { + const pg = await pdfDocument.getPage(pageNum); + const tc = await pg.getTextContent(); + text = (tc.items as Array<{ str?: string }>) + .map((item) => item.str || "") + .join(" "); + pageTextCache.set(pageNum, text); + } catch (err) { + log.error( + `get_pages: text extraction failed for page ${pageNum}:`, + err, + ); + text = ""; + } + } + entry.text = text ?? ""; + } + + if (cmd.getScreenshots) { + try { + entry.image = await renderPageOffscreen(pageNum); + } catch (err) { + log.error(`get_pages: screenshot failed for page ${pageNum}:`, err); + } + } + + results.push(entry); + } + + // Submit results back to server + try { + await app.callServerTool({ + name: "submit_page_data", + arguments: { requestId: cmd.requestId, pages: results }, + }); + log.info( + `get_pages: submitted ${results.length} page(s) for ${cmd.requestId}`, + ); + } catch (err) { + log.error("get_pages: failed to submit results:", err); + } +} + +// ============================================================================= +// Annotation Persistence +// ============================================================================= + +/** Storage key for annotations — uses toolInfo.id (available early) with viewUUID fallback */ +function annotationStorageKey(): string | null { + const toolId = app.getHostContext()?.toolInfo?.id; + if (toolId) return `pdf-annot:${toolId}`; + if (viewUUID) return `${viewUUID}:annotations`; + return null; +} + +/** + * Import annotations from the loaded PDF to establish the baseline. + * These are the annotations that exist in the PDF file itself. + */ +async function loadBaselineAnnotations( + doc: pdfjsLib.PDFDocumentProxy, +): Promise { + pdfBaselineAnnotations = []; + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + try { + const page = await doc.getPage(pageNum); + const annotations = await page.getAnnotations(); + for (let i = 0; i < annotations.length; i++) { + const ann = annotations[i]; + const def = importPdfjsAnnotation(ann, pageNum, i); + if (def) { + pdfBaselineAnnotations.push(def); + // Add to annotationMap if not already present (from localStorage restore) + if (!annotationMap.has(def.id)) { + annotationMap.set(def.id, { def, elements: [] }); + } + } else if (ann.annotationType !== 20) { + // Widget (type 20) is expected to be skipped; anything else we + // don't import will still be painted by page.render() onto the + // canvas as unselectable pixels. Log so we can diagnose + // "ghost annotations" (visible but not in panel, not clickable). + log.info( + `[WARN] Baseline: skipped PDF annotation on page ${pageNum}`, + `type=${ann.annotationType}`, + `subtype=${ann.subtype ?? "?"}`, + `name=${ann.name ?? "?"}`, + `rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`, + ); + } + } + } catch (err) { + // Log the error — a thrown import for one annotation silently + // drops the REST of that page's annotations too. + log.info( + `[WARN] Baseline: page ${pageNum} annotation import failed:`, + err, + ); + } + } + log.info( + `Loaded ${pdfBaselineAnnotations.length} baseline annotations from PDF`, + ); +} + +function persistAnnotations(): void { + // Compute diff relative to PDF baseline + const currentAnnotations: PdfAnnotationDef[] = []; + for (const tracked of annotationMap.values()) { + currentAnnotations.push(tracked.def); + } + const diff = computeDiff( + pdfBaselineAnnotations, + currentAnnotations, + formFieldValues, + pdfBaselineFormValues, + ); + + // Dirty tracks whether there are unsaved changes. Undoing back to baseline + // yields an empty diff → clean again → save button disables. + if (!isRestoring) setDirty(!isDiffEmpty(diff)); + + const key = annotationStorageKey(); + if (!key) return; + try { + localStorage.setItem(key, serializeDiff(diff)); + } catch { + // localStorage may be full or unavailable + } +} + +function restoreAnnotations(): void { + const key = annotationStorageKey(); + if (!key) return; + isRestoring = true; + try { + const raw = localStorage.getItem(key); + if (!raw) return; + + // Try new diff-based format first + const diff = deserializeDiff(raw); + + // Merge baseline + diff. The loop below is add-only, so we MUST also + // delete: loadBaselineAnnotations() runs between the two restore calls + // and re-seeds annotationMap with every baseline id — including the + // ones in diff.removed. Without this, the zombie survives the restore, + // and the next persistAnnotations() sees it in currentIds → computeDiff + // produces removed=[] → the deletion is permanently lost from storage. + const merged = mergeAnnotations(pdfBaselineAnnotations, diff); + for (const def of merged) { + if (!annotationMap.has(def.id)) { + annotationMap.set(def.id, { def, elements: [] }); + } + } + for (const id of diff.removed) { + annotationMap.delete(id); + } + + // Restore form fields + for (const [k, v] of Object.entries(diff.formFields)) { + formFieldValues.set(k, v); + } + + // If we have user changes (diff is not empty), mark dirty + if ( + diff.added.length > 0 || + diff.removed.length > 0 || + Object.keys(diff.formFields).length > 0 + ) { + setDirty(true); + } + log.info( + `Restored ${annotationMap.size} annotations (${diff.added.length} added, ${diff.removed.length} removed), ${formFieldValues.size} form fields`, + ); + } catch { + // Parse error or unavailable + } finally { + isRestoring = false; + } +} + +// ============================================================================= +// PDF.js Form Field Name → ID Mapping +// ============================================================================= + +/** + * Normalise a raw form field value into our string|boolean model. + * Returns null for empty/unfilled/button values so they don't clutter the + * panel or count as baseline. + * + * `type` is from getFieldObjects() (which knows field types); `raw` is + * preferably from page.getAnnotations().fieldValue (which is what the + * widget actually renders). A PDF can have the field-dict /V out of sync + * with the widget — AnnotationLayer trusts the widget, so we must too. + */ +function normaliseFieldValue( + type: string | undefined, + raw: unknown, +): string | boolean | null { + if (type === "button") return null; + // Checkbox/radio: fieldValue is the export string (e.g. "Yes"), "Off" = unset + if (type === "checkbox") { + return raw != null && raw !== "" && raw !== "Off" ? true : null; + } + if (type === "radiobutton") { + return raw != null && raw !== "" && raw !== "Off" ? String(raw) : null; + } + // Text/choice: fieldValue may be a string or an array of selections + if (Array.isArray(raw)) { + const joined = raw.filter(Boolean).join(", "); + return joined || null; + } + if (raw == null || raw === "") return null; + return String(raw); +} + +/** + * Build mapping from field names (used by fill_form) to widget annotation IDs + * (used by annotationStorage). + * + * CRITICAL: getFieldObjects() returns field-dictionary IDs (the /T tree), + * but annotationStorage is keyed by WIDGET annotation IDs (what + * page.getAnnotations() returns). The two differ for PDFs where fields and + * their widget /Kids are separate objects. Using the wrong key makes all + * storage writes silently miss. + */ +async function buildFieldNameMap( + doc: pdfjsLib.PDFDocumentProxy, +): Promise { + fieldNameToIds.clear(); + radioButtonValues.clear(); + fieldNameToPage.clear(); + fieldNameToLabel.clear(); + fieldNameToOrder.clear(); + cachedFieldObjects = null; + pdfBaselineFormValues.clear(); + + // getFieldObjects() gives us types, current values (/V), and defaults (/DV). + // We DON'T use its .id — that's the field dict ref, not the widget annot ref. + try { + cachedFieldObjects = + ((await doc.getFieldObjects()) as Record | null) ?? null; + } catch { + // getFieldObjects may fail on some PDFs + } + + // Scan every page's widget annotations to collect the CORRECT storage keys, + // plus labels, pages, positions, AND fieldValue (what the widget renders + // — which can differ from getFieldObjects().value if the PDF is internally + // inconsistent, e.g. after a pdf-lib setText silently failed). + const fieldPositions: Array<{ name: string; page: number; y: number }> = []; + const widgetFieldValues = new Map(); + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + let annotations; + try { + const page = await doc.getPage(pageNum); + annotations = await page.getAnnotations(); + } catch { + continue; + } + for (const ann of annotations) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const a = ann as any; + if (!a.fieldName || !a.id) continue; + + // Widget annotation ID — this is what annotationStorage keys by + const ids = fieldNameToIds.get(a.fieldName) ?? []; + ids.push(a.id); + fieldNameToIds.set(a.fieldName, ids); + + // Radio buttons: pdf.js creates WITHOUT setting + // .value, so reading target.value gives the HTML default "on". + // Remember each widget's export value so the input listener can + // report it instead. + if (a.radioButton && a.buttonValue != null) { + radioButtonValues.set(a.id, String(a.buttonValue)); + } + + if (!fieldNameToPage.has(a.fieldName)) { + fieldNameToPage.set(a.fieldName, pageNum); + } + if (a.alternativeText) { + fieldNameToLabel.set(a.fieldName, a.alternativeText); + } + if (a.rect) { + fieldPositions.push({ name: a.fieldName, page: pageNum, y: a.rect[3] }); + } + // Capture the value the widget will actually render. First widget wins + // (radio groups share the field's /V so they all match anyway). + if (!widgetFieldValues.has(a.fieldName) && a.fieldValue !== undefined) { + widgetFieldValues.set(a.fieldName, a.fieldValue); + } + } + } + + // Ordering: page ascending, then Y descending (top-to-bottom on page) + fieldPositions.sort((a, b) => a.page - b.page || b.y - a.y); + const seen = new Set(); + let idx = 0; + for (const fp of fieldPositions) { + if (!seen.has(fp.name)) { + seen.add(fp.name); + fieldNameToOrder.set(fp.name, idx++); + } + } + + // Import baseline values AND remap cachedFieldObjects to widget IDs. + // + // Baseline: prefer the widget's fieldValue (what AnnotationLayer renders) + // over getFieldObjects().value. A PDF can have the field-dict /V out of + // sync with the widget — if we import the field-dict value, the panel + // disagrees with what's on screen. + // + // Remap: pdf.js _bindResetFormAction (the PDF's in-document Reset button) + // iterates this structure, using .id to key storage and find DOM elements + // via [data-element-id=...]. Both use WIDGET ids. pdf-lib's save splits + // merged field+widget objects, so we rebuild with widget ids. + if (cachedFieldObjects) { + const remapped: Record = {}; + for (const [name, fieldArr] of Object.entries(cachedFieldObjects)) { + const widgetIds = fieldNameToIds.get(name); + if (!widgetIds) continue; // no widget → not rendered anyway + + // Type comes from getFieldObjects (widget annot data doesn't have it). + // Value comes from the widget annotation (fall back to field-dict if + // the widget didn't expose one). + const type = fieldArr.find((f) => f.type)?.type; + const raw = widgetFieldValues.has(name) + ? widgetFieldValues.get(name) + : fieldArr.find((f) => f.value != null)?.value; + const v = normaliseFieldValue(type, raw); + if (v !== null) { + pdfBaselineFormValues.set(name, v); + // Seed current state from baseline so the panel shows it. A + // restored localStorage diff (applied in restoreAnnotations) will + // overwrite specific fields the user changed. + if (!formFieldValues.has(name)) formFieldValues.set(name, v); + } + + // Skip parent entries with no concrete id (radio groups: the /T tree + // has a parent with the export value, plus one child per widget). + const concrete = fieldArr.filter((f) => f.id && f.type); + remapped[name] = widgetIds.map((wid, i) => ({ + ...(concrete[i] ?? concrete[0] ?? fieldArr[0]), + id: wid, + })); + } + cachedFieldObjects = remapped; + } + + log.info(`Built field name map: ${fieldNameToIds.size} fields`); +} + +/** Sync formFieldValues into pdfDocument.annotationStorage so AnnotationLayer renders pre-filled values. + * Skips values that match the PDF's baseline — those are already in storage + * in pdf.js's native format (which may differ from our string/bool repr, + * e.g. checkbox stores "Yes" not `true`). Overwriting with our normalised + * form can break the Reset button's ability to restore defaults. */ +function syncFormValuesToStorage(): void { + if (!pdfDocument || fieldNameToIds.size === 0) return; + const storage = pdfDocument.annotationStorage; + for (const [name, value] of formFieldValues) { + if (pdfBaselineFormValues.get(name) === value) continue; + const ids = fieldNameToIds.get(name); + if (ids) { + for (const id of ids) { + storage.setValue(id, { + value: typeof value === "boolean" ? value : String(value), + }); + } + } + } +} + +// ============================================================================= +// PDF Save / Download with Annotations +// ============================================================================= + +/** Build annotated PDF bytes from the current state. */ +async function getAnnotatedPdfBytes(): Promise { + if (!pdfDocument) throw new Error("No PDF loaded"); + const fullBytes = await pdfDocument.getData(); + + // Only export user-added annotations; baseline ones are already in the PDF + const annotations: PdfAnnotationDef[] = []; + const baselineIds = new Set(pdfBaselineAnnotations.map((a) => a.id)); + for (const tracked of annotationMap.values()) { + if (!baselineIds.has(tracked.def.id)) { + annotations.push(tracked.def); + } + } + + // buildAnnotatedPdfBytes gates on formFields.size > 0 and only writes + // entries present in the map. After clearAllItems() the map is empty → + // zero setText/uncheck calls → pdf-lib leaves original /V intact → + // the "stripped PDF" we promised keeps all its form data. To actually + // clear, send an explicit sentinel for every baseline field the user + // dropped: "" for text, false for checkbox (matching baseline type). + const formFieldsOut = new Map(formFieldValues); + for (const [name, baselineValue] of pdfBaselineFormValues) { + if (!formFieldsOut.has(name)) { + formFieldsOut.set(name, typeof baselineValue === "boolean" ? false : ""); + } + } + return buildAnnotatedPdfBytes( + fullBytes as Uint8Array, + annotations, + formFieldsOut, + ); +} + +async function savePdf(): Promise { + if (!pdfDocument || !isDirty || saveInProgress) return; + + const fileName = + pdfUrl + .replace(/^(file|computer):\/\//, "") + .split(/[/\\]/) + .pop() || pdfUrl; + const choice = await showConfirmDialog( + "Save PDF", + "Overwrite this file with your annotations and form edits?", + [{ label: "Cancel" }, { label: "Save", primary: true }], + fileName, + ); + if (choice !== 1) return; + + saveInProgress = true; + saveBtn.disabled = true; + saveBtn.title = "Saving..."; + + try { + const pdfBytes = await getAnnotatedPdfBytes(); + const base64 = uint8ArrayToBase64(pdfBytes); + + const result = await app.callServerTool({ + name: "save_pdf", + arguments: { url: pdfUrl, data: base64 }, + }); + + if (result.isError) { + log.error("Save failed:", result.content); + saveBtn.disabled = false; // let user retry + } else { + log.info("PDF saved"); + // Record mtime so we recognize our own write in file_changed + const sc = result.structuredContent as { mtimeMs?: number } | undefined; + lastSavedMtime = sc?.mtimeMs ?? null; + + // Rebase: the file on disk now contains our annotations + form values. + // Update the baseline so future diffs are relative to what was saved. + pdfBaselineAnnotations = [...annotationMap.values()].map((t) => ({ + ...t.def, + })); + pdfBaselineFormValues.clear(); + for (const [k, v] of formFieldValues) pdfBaselineFormValues.set(k, v); + + setDirty(false); // → updateSaveBtn() disables button + const key = annotationStorageKey(); + if (key) { + try { + localStorage.removeItem(key); + } catch { + /* ignore */ + } + } + } + } catch (err) { + log.error("Save failed:", err); + saveBtn.disabled = false; + } finally { + saveInProgress = false; + saveBtn.title = "Save to file (overwrites original)"; + } +} + +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("Download error:", err); + } finally { + downloadBtn.disabled = false; + downloadBtn.title = "Download PDF"; + } +} + // Render state - prevents concurrent renders let isRendering = false; let pendingPage: number | null = null; @@ -728,15 +2978,122 @@ async function renderPage() { pageTextCache.set(pageToRender, items.join("")); } - // Size highlight layer to match canvas + // Size overlay layers to match canvas highlightLayerEl.style.width = `${viewport.width}px`; highlightLayerEl.style.height = `${viewport.height}px`; + annotationLayerEl.style.width = `${viewport.width}px`; + annotationLayerEl.style.height = `${viewport.height}px`; + + // Render PDF.js AnnotationLayer for interactive form widgets + formLayerEl.innerHTML = ""; + formLayerEl.style.width = `${viewport.width}px`; + formLayerEl.style.height = `${viewport.height}px`; + // Set CSS custom properties so AnnotationLayer font-size rules work correctly + formLayerEl.style.setProperty("--scale-factor", `${scale}`); + formLayerEl.style.setProperty("--total-scale-factor", `${scale}`); + try { + const annotations = await page.getAnnotations(); + if (annotations.length > 0) { + const linkService = { + getDestinationHash: () => "#", + getAnchorUrl: () => "#", + addLinkAttributes: () => {}, + isPageVisible: () => true, + isPageCached: () => true, + externalLinkEnabled: true, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const annotationLayer = new AnnotationLayer({ + div: formLayerEl, + page, + viewport, + annotationStorage: pdfDocument.annotationStorage, + linkService, + accessibilityManager: null, + annotationCanvasMap: null, + annotationEditorUIManager: null, + structTreeLayer: null, + commentManager: null, + } as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await annotationLayer.render({ + annotations, + div: formLayerEl, + page, + viewport, + renderForms: true, + linkService, + annotationStorage: pdfDocument.annotationStorage, + fieldObjects: cachedFieldObjects, + } as any); + + // Fix combo reset: pdf.js's resetform handler sets all + // option.selected = (option.value === defaultFieldValue), and + // defaultFieldValue is typically null — nothing matches. On a + // non-multiple