/** * PDF Viewer MCP App * * Interactive PDF viewer with single-page display. * - Fixed height (no auto-resize) * - Text selection via PDF.js TextLayer * - Page navigation, zoom */ import { App, type McpUiHostContext, applyDocumentTheme, applyHostStyleVariables, } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js"; import * as pdfjsLib 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; // 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; let totalPages = 0; 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")!; const loadingTextEl = document.getElementById("loading-text")!; const errorEl = document.getElementById("error")!; const errorMessageEl = document.getElementById("error-message")!; const viewerEl = document.getElementById("viewer")!; const canvasContainerEl = document.querySelector(".canvas-container")!; const canvasEl = document.getElementById("pdf-canvas") as HTMLCanvasElement; const textLayerEl = document.getElementById("text-layer")!; const titleEl = document.getElementById("pdf-title")!; const pageInputEl = document.getElementById("page-input") as HTMLInputElement; const totalPagesEl = document.getElementById("total-pages")!; const prevBtn = document.getElementById("prev-btn") as HTMLButtonElement; const nextBtn = document.getElementById("next-btn") as HTMLButtonElement; const zoomOutBtn = document.getElementById("zoom-out-btn") as HTMLButtonElement; const zoomInBtn = document.getElementById("zoom-in-btn") as HTMLButtonElement; const zoomLevelEl = document.getElementById("zoom-level")!; const fullscreenBtn = document.getElementById( "fullscreen-btn", ) as HTMLButtonElement; const searchBtn = document.getElementById("search-btn") as HTMLButtonElement; searchBtn.innerHTML = ``; // searchBarEl → imported from ./viewer-state.js const searchInputEl = document.getElementById( "search-input", ) as HTMLInputElement; const searchMatchCountEl = document.getElementById("search-match-count")!; const searchPrevBtn = document.getElementById( "search-prev-btn", ) as HTMLButtonElement; const searchNextBtn = document.getElementById( "search-next-btn", ) as HTMLButtonElement; 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 { pageNum: number; index: number; length: number; } let searchOpen = false; let searchQuery = ""; let searchDebounceTimer: ReturnType | null = null; const pageTextCache = new Map(); const pageTextItemsCache = new Map(); let allMatches: SearchMatch[] = []; let currentMatchIndex = -1; // Preload state — goToPage sets preloadPaused=true, renderPage's finally resets it. // The preloader's while(preloadPaused) loop yields so interactive loads always win. let preloadPaused = false; let pagesLoaded = 0; let preloadErrors: Array<{ page: number; err: unknown }> = []; const loadingIndicatorEl = document.getElementById("loading-indicator")!; const loadingIndicatorArc = loadingIndicatorEl.querySelector( ".loading-indicator-arc", ) as SVGCircleElement; // Track current display mode let currentDisplayMode: "inline" | "fullscreen" = "inline"; // Whether the user has manually zoomed (disables auto fit-to-width) let userHasZoomed = false; /** * Compute a scale that fits the PDF page width to the available container width. * Returns null if the container isn't visible or the page width is unavailable. */ async function computeFitToWidthScale(): Promise { if (!pdfDocument) return null; try { const page = await pdfDocument.getPage(currentPage); const naturalViewport = page.getViewport({ scale: 1.0 }); const pageWidth = naturalViewport.width; const container = canvasContainerEl as HTMLElement; const containerStyle = getComputedStyle(container); const paddingLeft = parseFloat(containerStyle.paddingLeft); const paddingRight = parseFloat(containerStyle.paddingRight); const availableWidth = container.clientWidth - paddingLeft - paddingRight; if (availableWidth <= 0 || pageWidth <= 0) return null; if (availableWidth >= pageWidth) return null; // Already fits return availableWidth / pageWidth; } catch { return null; } } /** * Request the host to resize the app to fit the current PDF page. * Only applies in inline mode - fullscreen mode uses scrolling. */ function requestFitToContent() { if (currentDisplayMode === "fullscreen") { return; // Fullscreen uses scrolling } const canvasHeight = canvasEl.height; if (canvasHeight <= 0) { return; // No content yet } // Get actual element dimensions const canvasContainerEl = document.querySelector( ".canvas-container", ) as HTMLElement; const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement; const toolbarEl = document.querySelector(".toolbar") as HTMLElement; if (!canvasContainerEl || !toolbarEl || !pageWrapperEl) { return; } // Get computed styles const containerStyle = getComputedStyle(canvasContainerEl); const paddingTop = parseFloat(containerStyle.paddingTop); const paddingBottom = parseFloat(containerStyle.paddingBottom); // Calculate required height: // toolbar + padding-top + page-wrapper height + padding-bottom + buffer // Note: search bar is absolutely positioned over the document area, so excluded const toolbarHeight = toolbarEl.offsetHeight; const pageWrapperHeight = pageWrapperEl.offsetHeight; const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks const totalHeight = toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER; // 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 --- function performSearch(query: string) { allMatches = []; currentMatchIndex = -1; searchQuery = query; if (!query) { updateSearchUI(); clearHighlights(); 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; } } // Set current match to first match on or after current page if (allMatches.length > 0) { const idx = allMatches.findIndex((m) => m.pageNum >= currentPage); currentMatchIndex = idx >= 0 ? idx : 0; } updateSearchUI(); renderHighlights(); // Navigate to match page if needed if (allMatches.length > 0 && currentMatchIndex >= 0) { const match = allMatches[currentMatchIndex]; if (match.pageNum !== currentPage) { 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() { clearHighlights(); if (!searchQuery || allMatches.length === 0) return; const spans = Array.from( textLayerEl.querySelectorAll("span"), ) as HTMLElement[]; if (spans.length === 0) return; const pageMatches = allMatches.filter((m) => m.pageNum === currentPage); if (pageMatches.length === 0) return; const lowerQuery = searchQuery.toLowerCase(); const lowerQueryLen = lowerQuery.length; // Position highlight divs over matching text using Range API. const wrapperEl = textLayerEl.parentElement!; const wrapperRect = wrapperEl.getBoundingClientRect(); let domMatchOrdinal = 0; for (const span of spans) { const text = span.textContent || ""; if (text.length === 0) continue; const lowerText = text.toLowerCase(); if (!lowerText.includes(lowerQuery)) continue; // Find all match positions within this span const matchPositions: number[] = []; let pos = 0; while (true) { const idx = lowerText.indexOf(lowerQuery, pos); if (idx === -1) break; matchPositions.push(idx); pos = idx + 1; } if (matchPositions.length === 0) continue; const textNode = span.firstChild; if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; for (const idx of matchPositions) { const isCurrentMatch = domMatchOrdinal < pageMatches.length && allMatches.indexOf(pageMatches[domMatchOrdinal]) === currentMatchIndex; try { const range = document.createRange(); range.setStart(textNode, idx); range.setEnd(textNode, Math.min(idx + lowerQueryLen, text.length)); const rects = range.getClientRects(); for (let ri = 0; ri < rects.length; ri++) { const r = rects[ri]; const div = document.createElement("div"); div.className = "search-highlight" + (isCurrentMatch ? " current" : ""); div.style.position = "absolute"; div.style.left = `${r.left - wrapperRect.left}px`; div.style.top = `${r.top - wrapperRect.top}px`; div.style.width = `${r.width}px`; div.style.height = `${r.height}px`; highlightLayerEl.appendChild(div); } } catch { // Range errors can happen with stale text nodes } domMatchOrdinal++; } } // Scroll current highlight into view only if not already visible const currentHL = highlightLayerEl.querySelector( ".search-highlight.current", ) as HTMLElement; if (currentHL) { const scrollParent = currentDisplayMode === "fullscreen" ? document.querySelector(".canvas-container") : null; if (scrollParent) { const sr = scrollParent.getBoundingClientRect(); const hr = currentHL.getBoundingClientRect(); if (hr.top < sr.top || hr.bottom > sr.bottom) { currentHL.scrollIntoView({ block: "center", behavior: "smooth" }); } } else { // Inline mode: check visibility in viewport const hr = currentHL.getBoundingClientRect(); if (hr.top < 0 || hr.bottom > window.innerHeight) { currentHL.scrollIntoView({ block: "center", behavior: "smooth" }); } } } } function clearHighlights() { highlightLayerEl.innerHTML = ""; } function updateSearchUI() { const hasQuery = searchQuery.length > 0; const stillLoading = totalPages > 0 && pagesLoaded < totalPages; const suffix = stillLoading ? " (loading\u2026)" : ""; if (allMatches.length === 0) { searchMatchCountEl.textContent = hasQuery ? `No matches${suffix}` : ""; } else { searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}${suffix}`; } searchPrevBtn.disabled = allMatches.length === 0; searchNextBtn.disabled = allMatches.length === 0; // Hide nav controls when there's no query const vis = hasQuery ? "" : "none"; searchMatchCountEl.style.display = vis; searchPrevBtn.style.display = vis; searchNextBtn.style.display = vis; } function openSearch() { if (searchOpen) { searchInputEl.focus(); searchInputEl.select(); return; } searchOpen = true; searchBarEl.style.display = "flex"; updateSearchUI(); searchInputEl.focus(); if (panelState.open && annotationsPanelEl.classList.contains("floating")) { applyFloatingPanelPosition(); } // Text extraction is handled by the background preloader } function closeSearch() { if (!searchOpen) return; searchOpen = false; searchBarEl.style.display = "none"; if (panelState.open && annotationsPanelEl.classList.contains("floating")) { applyFloatingPanelPosition(); } searchQuery = ""; searchInputEl.value = ""; allMatches = []; currentMatchIndex = -1; clearHighlights(); updateSearchUI(); } function toggleSearch() { if (searchOpen) { closeSearch(); } else { openSearch(); } } function goToNextMatch() { if (allMatches.length === 0) return; currentMatchIndex = (currentMatchIndex + 1) % allMatches.length; const match = allMatches[currentMatchIndex]; updateSearchUI(); if (match.pageNum !== currentPage) { goToPage(match.pageNum); } else { renderHighlights(); } } function goToPrevMatch() { if (allMatches.length === 0) return; currentMatchIndex = (currentMatchIndex - 1 + allMatches.length) % allMatches.length; const match = allMatches[currentMatchIndex]; updateSearchUI(); if (match.pageNum !== currentPage) { goToPage(match.pageNum); } else { renderHighlights(); } } // Create app instance // autoResize disabled - app fills its container, doesn't request size changes const app = new App( { name: "PDF Viewer", version: "1.0.0" }, {}, { autoResize: false }, ); // UI State functions function showLoading(text: string) { loadingTextEl.textContent = text; loadingEl.style.display = "flex"; errorEl.style.display = "none"; viewerEl.style.display = "none"; } function showError(message: string) { errorMessageEl.textContent = message; loadingEl.style.display = "none"; errorEl.style.display = "block"; viewerEl.style.display = "none"; } function showViewer() { loadingEl.style.display = "none"; errorEl.style.display = "none"; 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 updateTitleDisplay(); titleEl.style.textDecoration = "underline"; titleEl.style.cursor = "pointer"; titleEl.onclick = () => app.openLink({ url: pdfUrl }); pageInputEl.value = String(currentPage); pageInputEl.max = String(totalPages); totalPagesEl.textContent = `of ${totalPages}`; prevBtn.disabled = currentPage <= 1; nextBtn.disabled = currentPage >= totalPages; zoomLevelEl.textContent = `${Math.round(scale * 100)}%`; } /** * Format page text with optional selection, truncating intelligently. * - Centers window around selection when truncating * - Adds markers where text is elided * - If selection itself is too long, truncates inside: ... */ function formatPageContent( text: string, maxLength: number, selection?: { start: number; end: number }, ): string { const T = ""; // No truncation needed if (text.length <= maxLength) { if (!selection) return text; return ( text.slice(0, selection.start) + `${text.slice(selection.start, selection.end)}` + text.slice(selection.end) ); } // Truncation needed, no selection - just truncate end if (!selection) { return text.slice(0, maxLength) + "\n" + T; } // Calculate budgets const selLen = selection.end - selection.start; const overhead = "".length + T.length * 2 + 4; const contextBudget = maxLength - overhead; // Selection too long - truncate inside the selection tags if (selLen > contextBudget) { const keepLen = Math.max(100, contextBudget); const halfKeep = Math.floor(keepLen / 2); const selStart = text.slice(selection.start, selection.start + halfKeep); const selEnd = text.slice(selection.end - halfKeep, selection.end); return ( T + `${T}${selStart}...${selEnd}${T}` + T ); } // Selection fits - center it with context const remainingBudget = contextBudget - selLen; const beforeBudget = Math.floor(remainingBudget / 2); const afterBudget = remainingBudget - beforeBudget; const windowStart = Math.max(0, selection.start - beforeBudget); const windowEnd = Math.min(text.length, selection.end + afterBudget); const adjStart = selection.start - windowStart; const adjEnd = selection.end - windowStart; const windowText = text.slice(windowStart, windowEnd); return ( (windowStart > 0 ? T + "\n" : "") + windowText.slice(0, adjStart) + `${windowText.slice(adjStart, adjEnd)}` + windowText.slice(adjEnd) + (windowEnd < text.length ? "\n" + T : "") ); } /** * Find selection position in page text using fuzzy matching. * TextLayer spans may lack spaces between them, so we try both exact and spaceless match. */ function findSelectionInText( pageText: string, selectedText: string, ): { start: number; end: number } | undefined { if (!selectedText || selectedText.length <= 2) return undefined; // Try exact match let start = pageText.indexOf(selectedText); if (start >= 0) { return { start, end: start + selectedText.length }; } // Try spaceless match (TextLayer spans may not have spaces) const noSpaceSel = selectedText.replace(/\s+/g, ""); const noSpaceText = pageText.replace(/\s+/g, ""); const noSpaceStart = noSpaceText.indexOf(noSpaceSel); if (noSpaceStart >= 0) { // Map back to approximate position in original start = Math.floor((noSpaceStart / noSpaceText.length) * pageText.length); return { start, end: start + selectedText.length }; } 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; try { const page = await pdfDocument.getPage(currentPage); const textContent = await page.getTextContent(); const pageText = (textContent.items as Array<{ str?: string }>) .map((item) => item.str || "") .join(" ") .replace(/\s+/g, " ") .trim(); // Find selection position const sel = window.getSelection(); const selectedText = sel?.toString().replace(/\s+/g, " ").trim(); const selection = selectedText ? findSelectionInText(pageText, selectedText) : undefined; if (selection) { log.info( "Selection found:", selectedText?.slice(0, 30), "at", selection.start, ); } // Format content with selection markers and truncation const content = formatPageContent( pageText, MAX_MODEL_CONTEXT_LENGTH, 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}`, `Page size: ${pageWidthPt}×${pageHeightPt}pt (coordinates: origin at top-left, Y increases downward)`, ] .filter(Boolean) .join(" | "); // Include search status if active let searchSection = ""; if (searchOpen && searchQuery && allMatches.length > 0) { searchSection = formatSearchResults(); } else if (searchOpen && searchQuery) { searchSection = `\nSearch: "${searchQuery}" (no matches found)`; } // Include annotation details if any exist let annotationSection = ""; if (annotationMap.size > 0) { const onThisPage = [...annotationMap.values()].filter( (t) => t.def.page === currentPage, ); annotationSection = `\nAnnotations: ${onThisPage.length} on this page, ${annotationMap.size} total`; if (formFieldValues.size > 0) { annotationSection += ` | ${formFieldValues.size} form field(s) filled`; } // List annotations on current page with their coordinates (in model space) if (onThisPage.length > 0) { annotationSection += "\nAnnotations on this page (visible in screenshot):"; for (const t of onThisPage) { const d = convertToModelCoords(t.def, pageHeightPt); const selected = selectedAnnotationIds.has(d.id) ? " (SELECTED)" : ""; if ("rects" in d && d.rects.length > 0) { const r = d.rects[0]; annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(r.x)},${Math.round(r.y)}) ${Math.round(r.width)}x${Math.round(r.height)}${selected}`; } else if ("x" in d && "y" in d) { annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(d.x)},${Math.round(d.y)})${selected}`; } } } } // Include focused field or selected annotation info let focusSection = ""; if (selectedAnnotationIds.size > 0) { const ids = [...selectedAnnotationIds]; const descs = ids.map((selId) => { const tracked = annotationMap.get(selId); if (!tracked) return selId; return `[${selId}] (${tracked.def.type})`; }); focusSection = `\nSelected: ${descs.join(", ")}`; } if (focusedFieldName) { const label = getFormFieldLabel(focusedFieldName); const value = formFieldValues.get(focusedFieldName); focusSection += `\nFocused field: "${label}" (name="${focusedFieldName}")`; if (value !== undefined) { focusSection += ` = ${JSON.stringify(value)}`; } } const contextText = `${header}${searchSection}${annotationSection}${focusSection}\n\nPage content:\n${content}`; // Build content array with text and optional screenshot const contentBlocks: ContentBlock[] = [{ type: "text", text: contextText }]; // Add screenshot if host supports image content if (app.getHostCapabilities()?.updateModelContext?.image) { try { // Render offscreen with ENABLE_STORAGE so filled form fields are visible const base64Data = await renderPageOffscreen(currentPage); if (base64Data) { contentBlocks.push({ type: "image", data: base64Data, mimeType: "image/jpeg", }); log.info("Added screenshot to model context"); } } catch (err) { log.info("Failed to capture screenshot:", err); } } app.updateModelContext({ content: contentBlocks }); } catch (err) { log.error("Error updating context:", err); } } // ============================================================================= // Annotation Rendering // ============================================================================= /** * Convert PDF coordinates (bottom-left origin) to screen coordinates * relative to the page wrapper. PDF.js viewport handles rotation and scale. */ function pdfRectToScreen( rect: Rect, viewport: { width: number; height: number; scale: number }, ): { left: number; top: number; width: number; height: number } { const s = viewport.scale; // PDF origin is bottom-left, screen origin is top-left const left = rect.x * s; const top = viewport.height - (rect.y + rect.height) * s; const width = rect.width * s; const height = rect.height * s; return { left, top, width, height }; } function pdfPointToScreen( x: number, y: number, viewport: { width: number; height: number; scale: number }, ): { left: number; top: number } { const s = viewport.scale; return { left: x * s, top: viewport.height - y * s }; } /** Convert a screen-space delta (pixels) to a PDF-space delta. */ function screenToPdfDelta(dx: number, dy: number): { dx: number; dy: number } { return { dx: dx / scale, dy: -dy / scale }; } // ============================================================================= // Undo / Redo // ============================================================================= function pushEdit(entry: EditEntry): void { undoStack.push(entry); redoStack.length = 0; } function undo(): void { const entry = undoStack.pop(); if (!entry) return; redoStack.push(entry); applyEdit(entry, true); } function redo(): void { const entry = redoStack.pop(); if (!entry) return; undoStack.push(entry); applyEdit(entry, false); } function applyEdit(entry: EditEntry, reverse: boolean): void { const state = reverse ? entry.before : entry.after; if (entry.type === "add") { if (reverse) { removeAnnotation(entry.id, true); } else { addAnnotation(state!, true); } } else if (entry.type === "remove") { if (reverse) { addAnnotation(state!, true); } else { removeAnnotation(entry.id, true); } } else { if (state) { const tracked = annotationMap.get(entry.id); if (tracked) { tracked.def = { ...state }; } else { annotationMap.set(entry.id, { def: { ...state }, elements: [] }); } } renderAnnotationsForPage(currentPage); renderAnnotationPanel(); } persistAnnotations(); } // ============================================================================= // Selection // ============================================================================= /** * Select annotation(s). Pass null to deselect all. * If additive is true, toggle the given id without clearing existing selection. */ function selectAnnotation(id: string | null, additive = false): void { if (!additive) { // Clear all existing selection visuals for (const prevId of selectedAnnotationIds) { const tracked = annotationMap.get(prevId); if (tracked) { for (const el of tracked.elements) { el.classList.remove("annotation-selected"); } } } // Remove handles for (const h of annotationLayerEl.querySelectorAll( ".annotation-handle, .annotation-handle-rotate", )) { h.remove(); } selectedAnnotationIds.clear(); } if (id) { if (additive && selectedAnnotationIds.has(id)) { // Toggle off selectedAnnotationIds.delete(id); const tracked = annotationMap.get(id); if (tracked) { for (const el of tracked.elements) { el.classList.remove("annotation-selected"); } } } else { selectedAnnotationIds.add(id); } } // Apply selection visuals + handles on all selected // Only show handles when exactly one annotation is selected for (const selId of selectedAnnotationIds) { const tracked = annotationMap.get(selId); if (tracked) { for (const el of tracked.elements) { el.classList.add("annotation-selected"); } if (selectedAnnotationIds.size === 1) { showHandles(tracked); } } } // Auto-expand the accordion section for the selected annotation's page if (id) { const tracked = annotationMap.get(id); if (tracked) { 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; // Render current page with text layer for selection async function renderPage() { if (!pdfDocument) return; // If already rendering, queue this page for later if (isRendering) { pendingPage = currentPage; // Cancel current render to speed up if (currentRenderTask) { currentRenderTask.cancel(); } return; } isRendering = true; pendingPage = null; try { const pageToRender = currentPage; const page = await pdfDocument.getPage(pageToRender); const viewport = page.getViewport({ scale }); // Account for retina displays const dpr = window.devicePixelRatio || 1; const ctx = canvasEl.getContext("2d")!; // Set canvas size in pixels (scaled for retina) canvasEl.width = viewport.width * dpr; canvasEl.height = viewport.height * dpr; // Set display size in CSS pixels canvasEl.style.width = `${viewport.width}px`; canvasEl.style.height = `${viewport.height}px`; // Scale context for retina ctx.scale(dpr, dpr); // Clear and setup text layer textLayerEl.innerHTML = ""; textLayerEl.style.width = `${viewport.width}px`; textLayerEl.style.height = `${viewport.height}px`; // Set --scale-factor so CSS font-size/transform rules work correctly. textLayerEl.style.setProperty("--scale-factor", `${scale}`); // Render canvas - track the task so we can cancel it // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderTask = (page.render as any)({ canvasContext: ctx, viewport, }); currentRenderTask = renderTask; try { await renderTask.promise; } catch (renderErr) { // Ignore RenderingCancelledException - it's expected when we cancel if ( renderErr instanceof Error && renderErr.name === "RenderingCancelledException" ) { log.info("Render cancelled"); return; } throw renderErr; } finally { currentRenderTask = null; } // Only continue if this is still the page we want if (pageToRender !== currentPage) { return; } // Render text layer for selection const textContent = await page.getTextContent(); const textLayer = new TextLayer({ textContentSource: textContent, container: textLayerEl, viewport, }); await textLayer.render(); // Cache page text items if not already cached if (!pageTextItemsCache.has(pageToRender)) { const items = (textContent.items as Array<{ str?: string }>).map( (item) => item.str || "", ); pageTextItemsCache.set(pageToRender, items); pageTextCache.set(pageToRender, items.join("")); } // 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