/**
* 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