From b03a230e08b0d278e1dc2bc3ed9beceb61c451d1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:12:58 -0400 Subject: [PATCH 1/7] feat(pdf-server): rasterize imported annotations via annotationCanvasMap Adds an 'imported' annotation type for anything in a loaded PDF that we either don't model (Ink, Polygon, Caret, FileAttachment, ...) or can't faithfully re-render (Stamp with an appearance stream, e.g. an image signature). These now: - Appear in the annotation panel as ' (from PDF)' - Render in our layer as a positioned div whose body is the per- annotation canvas pdf.js produced via page.render({annotationCanvasMap}) - if pdf.js didn't divert it (no hasOwnCanvas), the box is transparent over the main-canvas pixel and just captures clicks - Are selectable and draggable (resize/rotate disabled - bitmap would just stretch) - Are skipped by addAnnotationDicts; getAnnotatedPdfBytes already filters baseline ids, so save leaves the original in the PDF. Move/delete are UI-only for now (documented). Link (2) and Popup (16) are excluded - navigational/auxiliary, not markup. importPdfjsAnnotation tests added for unsupported-type and appearance-stamp paths; computeDiff round-trip for 'imported'. --- examples/pdf-server/src/annotation-panel.ts | 4 + examples/pdf-server/src/mcp-app.css | 15 ++++ examples/pdf-server/src/mcp-app.ts | 77 ++++++++++++++++++- .../pdf-server/src/pdf-annotations.test.ts | 70 +++++++++++++++++ examples/pdf-server/src/pdf-annotations.ts | 76 ++++++++++++++++-- 5 files changed, 235 insertions(+), 7 deletions(-) diff --git a/examples/pdf-server/src/annotation-panel.ts b/examples/pdf-server/src/annotation-panel.ts index 081d5c85..89482cf1 100644 --- a/examples/pdf-server/src/annotation-panel.ts +++ b/examples/pdf-server/src/annotation-panel.ts @@ -338,6 +338,8 @@ export function getAnnotationLabel(def: PdfAnnotationDef): string { return "Line"; case "image": return "Image"; + case "imported": + return `${def.subtype} (from PDF)`; } } @@ -381,6 +383,8 @@ export function getAnnotationColor(def: PdfAnnotationDef): string { return "#333"; case "image": return "#999"; + case "imported": + return "#666"; } } diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index 4f035209..a87dbcd4 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -590,6 +590,21 @@ body { user-select: none; } +/* Annotation imported verbatim from the PDF (Stamp/Ink/etc. via + * annotationCanvasMap). The body is the rasterized appearance canvas; + * if pdf.js didn't divert it (no hasOwnCanvas), the box stays transparent + * over the main-canvas pixel and just captures clicks. */ +.annotation-imported { + position: absolute; + pointer-events: auto; + cursor: grab; + user-select: none; +} +.annotation-imported:hover { + outline: 1px dashed var(--accent, #2563eb); + outline-offset: 1px; +} + /* Selection visuals */ .annotation-selected { outline: 2px solid var(--accent, #2563eb); diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 678721d0..b5f4751c 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -25,6 +25,7 @@ import { type LineAnnotation, type StampAnnotation, type ImageAnnotation, + type ImportedAnnotation, type NoteAnnotation, type FreetextAnnotation, cssColorToRgb, @@ -1383,6 +1384,10 @@ const DRAGGABLE_TYPES = new Set([ "stamp", "note", "image", + // "imported" is draggable in the UI but the move does NOT persist to the + // PDF on save (addAnnotationDicts skips it). Resize/rotate stay disabled + // — the appearance bitmap would just stretch. + "imported", ]); function setupAnnotationInteraction( @@ -1890,6 +1895,25 @@ function paintAnnotationsOnCanvas( } break; } + + case "imported": { + const s = pdfRectToScreen( + { x: def.x, y: def.y, width: def.width, height: def.height }, + viewport, + ); + const bmp = annotationCanvasMap.get(def.pdfjsId); + ctx.save(); + if (bmp) { + ctx.drawImage(bmp, s.left, s.top, s.width, s.height); + } else { + ctx.strokeStyle = "#666"; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.strokeRect(s.left, s.top, s.width, s.height); + } + ctx.restore(); + break; + } } } } @@ -1987,6 +2011,8 @@ function renderAnnotation( return [renderLineAnnotation(def, viewport)]; case "image": return [renderImageAnnotation(def, viewport)]; + case "imported": + return [renderImportedAnnotation(def, viewport)]; } } @@ -2175,6 +2201,45 @@ function renderImageAnnotation( return el; } +/** + * Per-annotation appearance bitmaps from page.render(). Keyed by pdf.js + * annotation id (e.g. "118R"). Populated for the current page only — + * cleared at the start of each renderPage(). + */ +const annotationCanvasMap = new Map(); + +function renderImportedAnnotation( + def: ImportedAnnotation, + 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-imported"; + el.style.left = `${screen.left}px`; + el.style.top = `${screen.top}px`; + el.style.width = `${screen.width}px`; + el.style.height = `${screen.height}px`; + el.title = `${def.subtype} (from PDF)`; + + // page.render() may or may not have produced a separate canvas for this + // annotation (hasOwnCanvas depends on the PDF's flags). When it did, use + // it as a pixel-faithful body; when it didn't, the appearance is on the + // main canvas already, so leave the box transparent — it still captures + // clicks for select/delete. + const canvas = annotationCanvasMap.get(def.pdfjsId); + if (canvas) { + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.display = "block"; + canvas.style.pointerEvents = "none"; + el.appendChild(canvas); + } + return el; +} + // ============================================================================= // Annotation CRUD // ============================================================================= @@ -3166,11 +3231,21 @@ async function renderPage() { // 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 + // Render canvas - track the task so we can cancel it. + // + // annotationCanvasMap: pdf.js diverts annotations whose appearance needs + // its own bitmap (Stamp/Ink/FreeText/etc. with hasOwnCanvas) into + // per-id canvases instead of compositing onto the main canvas. + // renderImportedAnnotation() pulls from this map so those annotations + // become movable DOM elements with pixel-faithful visuals — instead of + // unselectable canvas pixels (the old "ghost annotation" problem) or + // our lossy text-label re-render. + annotationCanvasMap.clear(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderTask = (page.render as any)({ canvasContext: ctx, viewport, + annotationCanvasMap, }); currentRenderTask = renderTask; diff --git a/examples/pdf-server/src/pdf-annotations.test.ts b/examples/pdf-server/src/pdf-annotations.test.ts index 6ceb3466..bef15177 100644 --- a/examples/pdf-server/src/pdf-annotations.test.ts +++ b/examples/pdf-server/src/pdf-annotations.test.ts @@ -549,6 +549,76 @@ describe("importPdfjsAnnotation", () => { expect(result!.page).toBe(2); }); + it("imports an unsupported subtype as 'imported' (placement only)", () => { + // annotationType 15 = Ink, not in PDFJS_TYPE_MAP. We keep it as a + // placement-only "imported" record so it's listed in the panel and + // rendered from annotationCanvasMap instead of being dropped. + const ann = { + annotationType: 15, + subtype: "Ink", + id: "200R", + rect: [100, 200, 180, 260], + }; + const result = importPdfjsAnnotation(ann, 3, 0); + expect(result).not.toBeNull(); + expect(result!.type).toBe("imported"); + expect(result!.page).toBe(3); + expect((result as any).pdfjsId).toBe("200R"); + expect((result as any).subtype).toBe("Ink"); + expect((result as any).width).toBeCloseTo(80); + expect((result as any).height).toBeCloseTo(60); + }); + + it("imports an appearance-stream stamp as 'imported' (not text-label)", () => { + // A Stamp with hasAppearance carries a custom visual (e.g. an image + // signature) that our text-label StampAnnotation can't reproduce. + const ann = { + annotationType: 13, + subtype: "Stamp", + id: "118R", + rect: [420, 760, 514, 792], + hasAppearance: true, + contentsObj: { str: "DRAFT" }, + }; + const result = importPdfjsAnnotation(ann, 1, 0); + expect(result!.type).toBe("imported"); + expect((result as any).pdfjsId).toBe("118R"); + expect((result as any).subtype).toBe("Stamp"); + }); + + it("computeDiff: 'imported' present in both baseline and current → no diff", () => { + const imp: PdfAnnotationDef = { + type: "imported", + id: "pdf-118R", + page: 1, + x: 420, + y: 760, + width: 94, + height: 32, + pdfjsId: "118R", + subtype: "Stamp", + }; + const diff = computeDiff([imp], [imp], new Map()); + expect(diff.added).toHaveLength(0); + expect(diff.removed).toHaveLength(0); + }); + + it("computeDiff: deleting an 'imported' annotation lists it in removed", () => { + const imp: PdfAnnotationDef = { + type: "imported", + id: "pdf-118R", + page: 1, + x: 420, + y: 760, + width: 94, + height: 32, + pdfjsId: "118R", + subtype: "Stamp", + }; + const diff = computeDiff([imp], [], new Map()); + expect(diff.removed).toEqual(["pdf-118R"]); + }); + it("imports a note (Text) annotation", () => { const ann = { annotationType: 1, diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index 85417064..afa77afc 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -130,6 +130,32 @@ export interface ImageAnnotation extends AnnotationBase { aspect?: "preserve" | "ignore"; } +/** + * An annotation that already exists in the loaded PDF and that we render + * verbatim from its appearance stream (via pdf.js's annotationCanvasMap) + * rather than re-modeling it with one of our shape types. + * + * Covers two cases: + * - subtypes we don't model (Ink, Polygon, Caret, FileAttachment, …) + * - subtypes we *could* model but whose appearance carries information + * our model would drop (e.g. Stamp with an image signature) + * + * The rasterized canvas is supplied at render time by mcp-app.ts; this + * struct only carries placement and identity. Coords are PDF user-space + * (origin bottom-left), matching the other rect-shaped types. + */ +export interface ImportedAnnotation extends AnnotationBase { + type: "imported"; + x: number; + y: number; + width: number; + height: number; + /** pdf.js getAnnotations() id (e.g. "118R") — key into annotationCanvasMap. */ + pdfjsId: string; + /** Original PDF /Subtype (e.g. "Stamp", "Ink") for the panel label. */ + subtype: string; +} + export type PdfAnnotationDef = | HighlightAnnotation | UnderlineAnnotation @@ -140,7 +166,8 @@ export type PdfAnnotationDef = | LineAnnotation | FreetextAnnotation | StampAnnotation - | ImageAnnotation; + | ImageAnnotation + | ImportedAnnotation; // ============================================================================= // Coordinate Conversion (model ↔ internal PDF coords) @@ -174,6 +201,7 @@ export function convertFromModelCoords( case "rectangle": case "circle": case "image": + case "imported": return { ...def, y: pageHeight - def.y - def.height }; case "line": return { @@ -374,6 +402,7 @@ export function defaultColor(type: PdfAnnotationDef["type"]): string { case "stamp": return "#cc0000"; case "image": + case "imported": return "#00000000"; } } @@ -495,6 +524,11 @@ export async function addAnnotationDicts( const pages = pdfDoc.getPages(); for (const def of annotations) { + // "imported" annotations are already in the source PDF — never + // re-serialize them. (Moving/deleting one is UI-only for now; a future + // pass can rewrite the page's /Annots array to drop the original ref.) + if (def.type === "imported") continue; + const pageIdx = def.page - 1; if (pageIdx < 0 || pageIdx >= pages.length) continue; const page = pages[pageIdx]; @@ -1023,14 +1057,44 @@ export function importPdfjsAnnotation( pageNum: number, index: number, ): PdfAnnotationDef | null { - const ourType = PDFJS_TYPE_MAP[ann.annotationType]; - if (!ourType) return null; - - // Skip form widgets (they're handled separately by AnnotationLayer) - if (ann.annotationType === 20) return null; + // Skip form widgets (they're handled separately by AnnotationLayer) and + // auxiliary types that aren't user-visible markup: + // 2 = Link (navigational, AnnotationLayer handles the click target) + // 16 = Popup (the speech-bubble UI for a parent annotation, not content) + if ( + ann.annotationType === 20 || + ann.annotationType === 2 || + ann.annotationType === 16 + ) { + return null; + } const id = makeAnnotationId(ann, pageNum, index); const color = pdfjsColorToHex(ann.color); + const ourType = PDFJS_TYPE_MAP[ann.annotationType]; + + // Anything we don't model — and stamps whose visual is an appearance + // stream we can't reproduce as a text label — are kept as "imported": + // a placement-only record that the renderer fills with the rasterized + // appearance from pdf.js's annotationCanvasMap. This keeps Ink, Polygon, + // image-signature stamps, etc. visible AND selectable in our layer. + const importAsBitmap = + !ourType || (ourType === "stamp" && ann.hasAppearance); + if (importAsBitmap) { + if (!ann.rect) return null; + const r = pdfjsRectToRect(ann.rect); + return { + type: "imported", + id, + page: pageNum, + x: r.x, + y: r.y, + width: r.width, + height: r.height, + pdfjsId: String(ann.id ?? ""), + subtype: String(ann.subtype ?? `type${ann.annotationType}`), + }; + } switch (ourType) { case "highlight": From fc09b643d9e8bea2509a564b346a19ed4b794bf7 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:28:32 -0400 Subject: [PATCH 2/7] fix(pdf-server): isEditing on page.render so stamps always divert to annotationCanvasMap StampAnnotation.hasOwnCanvas defaults to noRotate; stamps without that flag composited onto the main canvas, so deleting the 'imported' overlay left an unclickable pixel. isEditing forces hasOwnCanvas=true for stamps (via mustBeViewedWhenEditing) so the appearance lands in the per-id canvas and our DOM element is the only render. --- examples/pdf-server/src/mcp-app.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index b5f4751c..31e0c5ed 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3246,6 +3246,13 @@ async function renderPage() { canvasContext: ctx, viewport, annotationCanvasMap, + // isEditing forces hasOwnCanvas=true for stamps regardless of /F + // NoRotate (StampAnnotation.mustBeViewedWhenEditing in pdf.worker). + // Without this, stamps without NoRotate composite onto the main canvas + // and deleting the "imported" overlay leaves an unclickable pixel + // behind. Other markup types still gate on noRotate; for those the + // overlay stays a transparent click-box (delete is UI-only until save). + isEditing: true, }); currentRenderTask = renderTask; From 46df2d0aecd18ca234afd803c38c4a1f61ba5e2d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:29:10 -0400 Subject: [PATCH 3/7] fix(pdf-server): refresh annotation panel after save rebases baseline setDirty(false) updated the title and save button but the panel kept showing pending-change badges because it diffs against pdfBaselineAnnotations/FormValues and was never re-rendered. --- examples/pdf-server/src/mcp-app.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 31e0c5ed..a1d7a9ce 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3110,6 +3110,10 @@ async function savePdf(): Promise { for (const [k, v] of formFieldValues) pdfBaselineFormValues.set(k, v); setDirty(false); // → updateSaveBtn() disables button + // Panel diffs against the baselines we just rebased — re-render so the + // "edited" badges and pending-change list go away. + updateAnnotationsBadge(); + renderAnnotationPanel(); const key = annotationStorageKey(); if (key) { try { From 3dcefe4840aab33c652d94cf643fbe8d32520ae5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:39:10 -0400 Subject: [PATCH 4/7] fix(pdf-server): imported stamp canvases cropped/2x on retina renderPage applied devicePixelRatio via ctx.scale(dpr,dpr) instead of page.render's transform parameter. pdf.js sizes annotationCanvasMap backing buffers as rectW * outputScaleX * viewport.scale, and outputScaleX is read from transform[0] (defaults 1). So on retina the per-annotation canvas got a 1x backing while its internal setTransform (from the SVD of the already-dpr-scaled ctx) was 2x - the appearance rendered at 2x into a half-sized buffer, showing only the top-left quarter. Pass dpr via transform: [dpr,0,0,dpr,0,0] so outputScaleX matches. Also filter AnnotationLayer.render() to Widget annotations only so it stops creating empty pointer-events:auto sections for stamps in #form-layer that could steal clicks from our overlays. --- examples/pdf-server/src/mcp-app.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index a1d7a9ce..04e72755 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3225,8 +3225,15 @@ async function renderPage() { // resize so the size handoff is atomic. pageWrapperEl.style.transform = ""; - // Scale context for retina - ctx.scale(dpr, dpr); + // Retina: pass dpr via page.render's `transform` (NOT ctx.scale). + // pdf.js sizes per-annotation canvases as + // width = rectW * outputScaleX * viewport.scale + // and outputScaleX is read from transform[0] (defaults to 1). A bare + // ctx.scale(dpr,dpr) leaves outputScaleX at 1, so the + // annotationCanvasMap canvases get a half-sized backing store on + // retina while their internal setTransform IS dpr-aware → the + // appearance renders 2× too big into a 1× buffer → cropped/shifted. + const dprTransform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined; // Clear and setup text layer textLayerEl.innerHTML = ""; @@ -3249,6 +3256,7 @@ async function renderPage() { const renderTask = (page.render as any)({ canvasContext: ctx, viewport, + transform: dprTransform, annotationCanvasMap, // isEditing forces hasOwnCanvas=true for stamps regardless of /F // NoRotate (StampAnnotation.mustBeViewedWhenEditing in pdf.worker). @@ -3337,8 +3345,15 @@ async function renderPage() { commentManager: null, } as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any + // Only feed Widgets (form fields) here. Markup annotations are + // owned by #annotation-layer; letting AnnotationLayer create + //
elements for them in #form-layer adds invisible + // pointer-events:auto boxes that steal clicks from our overlays. + const widgetAnns = annotations.filter( + (a: { subtype?: string }) => a.subtype === "Widget", + ); await annotationLayer.render({ - annotations, + annotations: widgetAnns, div: formLayerEl, page, viewport, From 920207fcc3adc0af18fa83c4afac7747a3bd6031 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:46:48 -0400 Subject: [PATCH 5/7] feat(pdf-server): strip deleted baseline annotations from /Annots on save buildAnnotatedPdfBytes takes a removedRefs list and walks each page's /Annots array (backwards) removing matching PDFRef entries. getAnnotatedPdfBytes computes that list from baseline annotations no longer in annotationMap, parsing the ref back from our id via parseAnnotationRef (handles both pdf-- and pdf-R). Panel: removed baseline annotations now stay listed as crossed-out cards with a revert button (mirrors cleared form fields), so the user can see and undo the pending deletion before save commits it. --- examples/pdf-server/src/annotation-panel.ts | 85 ++++++++++++++++++- examples/pdf-server/src/mcp-app.ts | 30 +++++-- .../pdf-server/src/pdf-annotations.test.ts | 71 ++++++++++++++++ examples/pdf-server/src/pdf-annotations.ts | 51 ++++++++++- 4 files changed, 226 insertions(+), 11 deletions(-) diff --git a/examples/pdf-server/src/annotation-panel.ts b/examples/pdf-server/src/annotation-panel.ts index 89482cf1..9fd4457f 100644 --- a/examples/pdf-server/src/annotation-panel.ts +++ b/examples/pdf-server/src/annotation-panel.ts @@ -289,10 +289,23 @@ function panelFieldNames(): Set { return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]); } +/** Baseline annotations the user has deleted. Shown crossed-out in the panel + * (mirroring cleared form fields) so they can be reverted, and so save knows + * to strip their refs from /Annots. */ +function removedBaselineAnnotations(): PdfAnnotationDef[] { + return deps + .state() + .pdfBaselineAnnotations.filter((a) => !annotationMap.has(a.id)); +} + /** Total count of annotations + form fields for the sidebar badge. * Uses the union so cleared baseline items still contribute. */ function sidebarItemCount(): number { - return annotationMap.size + panelFieldNames().size; + return ( + annotationMap.size + + removedBaselineAnnotations().length + + panelFieldNames().size + ); } export function updateAnnotationsBadge(): void { @@ -429,6 +442,14 @@ export function renderAnnotationPanel(): void { byPage.get(page)!.push(tracked); } + // Removed baseline annotations: still listed (crossed-out) so they can be + // reverted before save strips them from the file. + const removedByPage = new Map(); + for (const def of removedBaselineAnnotations()) { + if (!removedByPage.has(def.page)) removedByPage.set(def.page, []); + removedByPage.get(def.page)!.push(def); + } + // Group form fields by page — iterate the UNION so cleared baseline // fields remain visible (crossed out) with a per-item revert button. const fieldsByPage = new Map(); @@ -445,7 +466,11 @@ export function renderAnnotationPanel(): void { } // Collect all pages that have annotations or form fields - const allPages = new Set([...byPage.keys(), ...fieldsByPage.keys()]); + const allPages = new Set([ + ...byPage.keys(), + ...removedByPage.keys(), + ...fieldsByPage.keys(), + ]); const sortedPages = [...allPages].sort((a, b) => a - b); // Sort annotations within each page by Y position (descending = top-first in PDF coords) @@ -470,8 +495,9 @@ export function renderAnnotationPanel(): void { const sectionKey = `page-${pageNum}`; const isOpen = panelState.openAccordionSection === sectionKey; const annotations = byPage.get(pageNum) ?? []; + const removed = removedByPage.get(pageNum) ?? []; const fields = fieldsByPage.get(pageNum) ?? []; - const itemCount = annotations.length + fields.length; + const itemCount = annotations.length + removed.length + fields.length; appendAccordionSection( `Page ${pageNum} (${itemCount})`, @@ -487,6 +513,10 @@ export function renderAnnotationPanel(): void { for (const tracked of annotations) { body.appendChild(createAnnotationCard(tracked)); } + // Then removed baseline annotations (crossed-out, revertable) + for (const def of removed) { + body.appendChild(createRemovedAnnotationCard(def)); + } }, ); } @@ -652,6 +682,55 @@ function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement { return card; } +/** + * Card for a baseline annotation the user deleted: crossed-out, no select/ + * navigate (it has no DOM on the page anymore), revert button puts it back + * into `annotationMap` so it renders again and save leaves it in the file. + */ +function createRemovedAnnotationCard(def: PdfAnnotationDef): HTMLElement { + const card = document.createElement("div"); + card.className = "annotation-card annotation-card-cleared"; + card.dataset.annotationId = def.id; + + const row = document.createElement("div"); + row.className = "annotation-card-row"; + + const swatch = document.createElement("div"); + swatch.className = "annotation-card-swatch annotation-card-swatch-cleared"; + swatch.innerHTML = ``; + row.appendChild(swatch); + + const typeLabel = document.createElement("span"); + typeLabel.className = "annotation-card-type"; + typeLabel.textContent = getAnnotationLabel(def); + row.appendChild(typeLabel); + + const preview = getAnnotationPreview(def); + if (preview) { + const previewEl = document.createElement("span"); + previewEl.className = "annotation-card-preview"; + previewEl.textContent = preview; + row.appendChild(previewEl); + } + + const revertBtn = document.createElement("button"); + revertBtn.className = "annotation-card-delete"; + revertBtn.title = "Restore annotation from file"; + revertBtn.innerHTML = REVERT_SVG; + revertBtn.addEventListener("click", (e) => { + e.stopPropagation(); + annotationMap.set(def.id, { def: { ...def }, elements: [] }); + updateAnnotationsBadge(); + renderAnnotationPanel(); + deps.renderPage(); + deps.persistAnnotations(); + }); + row.appendChild(revertBtn); + + card.appendChild(row); + return card; +} + /** Revert one field to its PDF-stored baseline value. */ function revertFieldToBaseline(name: string): void { const base = pdfBaselineFormValues.get(name); diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 04e72755..7e1133f8 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -35,6 +35,7 @@ import { computeDiff, isDiffEmpty, buildAnnotatedPdfBytes, + parseAnnotationRef, importPdfjsAnnotation, uint8ArrayToBase64, convertFromModelCoords, @@ -2922,12 +2923,16 @@ async function buildFieldNameMap( 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). + // Value: prefer the AcroForm field-tree value over the widget's + // fieldValue. pdf-lib's save() can leave a page widget pointing at a + // stale /V while the field tree has the new one (seen with comb text + // fields), and getAnnotations() reads the widget. If the two disagree + // we push the field-tree value into annotationStorage so the rendered + // input matches what's actually in /AcroForm. const type = fieldArr.find((f) => f.type)?.type; - const raw = widgetFieldValues.has(name) - ? widgetFieldValues.get(name) - : fieldArr.find((f) => f.value != null)?.value; + const fieldTreeRaw = fieldArr.find((f) => f.value != null)?.value; + const widgetRaw = widgetFieldValues.get(name); + const raw = fieldTreeRaw ?? widgetRaw; const v = normaliseFieldValue(type, raw); if (v !== null) { pdfBaselineFormValues.set(name, v); @@ -2935,6 +2940,12 @@ async function buildFieldNameMap( // restored localStorage diff (applied in restoreAnnotations) will // overwrite specific fields the user changed. if (!formFieldValues.has(name)) formFieldValues.set(name, v); + // Widget out of sync with field tree → force storage so + // AnnotationLayer renders the field-tree value, not the stale + // widget. (syncFormValuesToStorage skips baseline==current.) + if (fieldTreeRaw != null && fieldTreeRaw !== widgetRaw) { + setFieldInStorage(name, v); + } } // Skip parent entries with no concrete id (radio groups: the /T tree @@ -3036,6 +3047,14 @@ async function getAnnotatedPdfBytes(): Promise { } } + // Baseline annotations the user deleted: strip their refs from /Annots so + // they don't reappear on reload. Ids without a recoverable ref (page-index + // fallback) can't be removed by-ref and are skipped. + const removedRefs = pdfBaselineAnnotations + .filter((a) => !annotationMap.has(a.id)) + .map((a) => parseAnnotationRef(a.id)) + .filter((r): r is NonNullable => r !== null); + // Only write fields that actually changed vs. what's already in the PDF. // Unchanged fields are no-ops at best, and at worst trip pdf-lib edge // cases (max-length text, missing /Yes appearance, …) on fields the user @@ -3060,6 +3079,7 @@ async function getAnnotatedPdfBytes(): Promise { fullBytes as Uint8Array, annotations, formFieldsOut, + removedRefs, ); } diff --git a/examples/pdf-server/src/pdf-annotations.test.ts b/examples/pdf-server/src/pdf-annotations.test.ts index bef15177..5b0905d7 100644 --- a/examples/pdf-server/src/pdf-annotations.test.ts +++ b/examples/pdf-server/src/pdf-annotations.test.ts @@ -10,6 +10,7 @@ import { defaultColor, importPdfjsAnnotation, buildAnnotatedPdfBytes, + parseAnnotationRef, base64ToUint8Array, uint8ArrayToBase64, convertFromModelCoords, @@ -788,6 +789,29 @@ describe("base64 helpers", () => { // PDF Annotation Dict Creation (integration test with pdf-lib) // ============================================================================= +describe("parseAnnotationRef", () => { + it("parses pdf-- ids", () => { + expect(parseAnnotationRef("pdf-118-0")).toEqual({ + objectNumber: 118, + generationNumber: 0, + }); + expect(parseAnnotationRef("pdf-5-2")).toEqual({ + objectNumber: 5, + generationNumber: 2, + }); + }); + it("parses pdf-R ids (pdf.js string id, gen=0)", () => { + expect(parseAnnotationRef("pdf-118R")).toEqual({ + objectNumber: 118, + generationNumber: 0, + }); + }); + it("returns null for page-index fallback ids", () => { + expect(parseAnnotationRef("pdf-1-idx-3")).toBeNull(); + expect(parseAnnotationRef("user-abc")).toBeNull(); + }); +}); + describe("buildAnnotatedPdfBytes", () => { let blankPdfBytes: Uint8Array; @@ -808,6 +832,53 @@ describe("buildAnnotatedPdfBytes", () => { expect(header).toBe("%PDF-"); }); + it("strips removedRefs entries from each page's /Annots array", async () => { + // Seed: add two highlights, save, capture their object refs. + const seeded = await buildAnnotatedPdfBytes( + blankPdfBytes, + [ + { + type: "highlight", + id: "h1", + page: 1, + rects: [{ x: 72, y: 700, width: 100, height: 12 }], + color: "#ffff00", + }, + { + type: "highlight", + id: "h2", + page: 1, + rects: [{ x: 72, y: 680, width: 100, height: 12 }], + color: "#ffff00", + }, + ], + new Map(), + ); + const seededDoc = await PDFDocument.load(seeded); + const annots = seededDoc.getPage(0).node.Annots()!; + expect(annots.size()).toBe(2); + const ref0 = annots.get(0) as unknown as { + objectNumber: number; + generationNumber: number; + }; + + // Now remove the first one by ref. + const stripped = await buildAnnotatedPdfBytes(seeded, [], new Map(), [ + { objectNumber: ref0.objectNumber, generationNumber: ref0.generationNumber }, + ]); + const strippedDoc = await PDFDocument.load(stripped); + const remaining = strippedDoc.getPage(0).node.Annots(); + expect(remaining?.size() ?? 0).toBe(1); + }); + + it("removedRefs ignores refs not present in /Annots", async () => { + const out = await buildAnnotatedPdfBytes(blankPdfBytes, [], new Map(), [ + { objectNumber: 9999, generationNumber: 0 }, + ]); + const doc = await PDFDocument.load(out); + expect(doc.getPage(0).node.Annots()?.size() ?? 0).toBe(0); + }); + it("adds highlight annotation to PDF", async () => { const annotations: PdfAnnotationDef[] = [ { diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index afa77afc..79e9901b 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -525,8 +525,8 @@ export async function addAnnotationDicts( for (const def of annotations) { // "imported" annotations are already in the source PDF — never - // re-serialize them. (Moving/deleting one is UI-only for now; a future - // pass can rewrite the page's /Annots array to drop the original ref.) + // re-serialize them. Deletion is handled by buildAnnotatedPdfBytes' + // removedRefs strip; moving an imported annotation is still UI-only. if (def.type === "imported") continue; const pageIdx = def.page - 1; @@ -870,17 +870,62 @@ function setButtonGroupValue( } } +/** + * Recover the PDF object reference from an annotation id assigned by + * `makeAnnotationId`. Handles both `pdf--` (from `ann.ref`) and + * `pdf-R` (from pdf.js's string `ann.id`, gen always 0). Returns null for + * page-index fallback ids — those have no stable ref to remove by. + */ +export function parseAnnotationRef( + id: string, +): { objectNumber: number; generationNumber: number } | null { + let m = /^pdf-(\d+)-(\d+)$/.exec(id); + if (m) return { objectNumber: +m[1], generationNumber: +m[2] }; + m = /^pdf-(\d+)R$/.exec(id); + if (m) return { objectNumber: +m[1], generationNumber: 0 }; + return null; +} + /** * Build annotated PDF bytes from the original document. - * Applies user annotations and form fills, returns Uint8Array of the new PDF. + * Applies user annotations and form fills, removes baseline annotations the + * user deleted, returns Uint8Array of the new PDF. */ export async function buildAnnotatedPdfBytes( pdfBytes: Uint8Array, annotations: PdfAnnotationDef[], formFields: Map, + removedRefs: { objectNumber: number; generationNumber: number }[] = [], ): Promise { const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); + // Strip baseline annotations the user deleted. We match on object number + + // generation against each page's /Annots array entries (PDFRef). We don't + // know which page they were on, so scan every page — /Annots arrays are + // small and this only runs at save time. + if (removedRefs.length > 0) { + const wanted = new Set( + removedRefs.map((r) => `${r.objectNumber} ${r.generationNumber}`), + ); + for (const page of pdfDoc.getPages()) { + const annots = page.node.Annots(); + if (!annots) continue; + // Walk backwards so .remove(idx) doesn't shift unprocessed entries. + for (let i = annots.size() - 1; i >= 0; i--) { + const ref = annots.get(i) as { + objectNumber?: number; + generationNumber?: number; + }; + if ( + ref?.objectNumber !== undefined && + wanted.has(`${ref.objectNumber} ${ref.generationNumber ?? 0}`) + ) { + annots.remove(i); + } + } + } + } + // Add proper PDF annotation objects await addAnnotationDicts(pdfDoc, annotations); From 1a44564fae7671c6622806f34ddfb8d12a9b86d5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:51:15 -0400 Subject: [PATCH 6/7] fix(pdf-server): re-render after seeding storage from AcroForm tree buildFieldNameMap runs AFTER the first renderPage (perf: don't block the canvas on an O(numPages) scan). When it detects a widget/field-tree mismatch and pushes the field-tree value into annotationStorage, the form layer has already rendered the stale widget value. Re-render once when that happens so the input shows the AcroForm truth. --- examples/pdf-server/src/mcp-app.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 7e1133f8..ee256b3b 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -2827,7 +2827,8 @@ function normaliseFieldValue( */ async function buildFieldNameMap( doc: pdfjsLib.PDFDocumentProxy, -): Promise { +): Promise { + let pushedToStorage = false; fieldNameToIds.clear(); radioButtonValues.clear(); fieldNameToPage.clear(); @@ -2945,6 +2946,7 @@ async function buildFieldNameMap( // widget. (syncFormValuesToStorage skips baseline==current.) if (fieldTreeRaw != null && fieldTreeRaw !== widgetRaw) { setFieldInStorage(name, v); + pushedToStorage = true; } } @@ -2960,6 +2962,7 @@ async function buildFieldNameMap( } log.info(`Built field name map: ${fieldNameToIds.size} fields`); + return pushedToStorage; } /** @@ -4434,8 +4437,9 @@ async function reloadPdf(): Promise { await renderPage(); await loadBaselineAnnotations(document); - await buildFieldNameMap(document); + const seeded = await buildFieldNameMap(document); syncFormValuesToStorage(); + if (seeded) await renderPage(); updateAnnotationsBadge(); renderAnnotationPanel(); @@ -4660,9 +4664,16 @@ app.ontoolresult = async (result: CallToolResult) => { restoreAnnotations(); // Build field name → annotation ID mapping for form filling - await buildFieldNameMap(document); + const seeded = await buildFieldNameMap(document); // Pre-populate annotationStorage from restored formFieldValues syncFormValuesToStorage(); + // buildFieldNameMap may have pushed AcroForm-tree values into storage + // (when the page widget's /V is stale vs the field dict — pdf-lib's save + // can leave them split). The first renderPage above ran BEFORE that, so + // the form layer shows the stale widget value. Re-render so it picks up + // storage. Only when something was actually seeded — most PDFs don't hit + // this and the extra render would be pure waste. + if (seeded) await renderPage(); updateAnnotationsBadge(); // Save button visibility driven by setDirty()/updateSaveBtn(); From 731b0c7c944e865bb27d445d38d8ca69d72f360c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:57:21 -0400 Subject: [PATCH 7/7] fix(pdf-server): reload PDF after successful save instead of rebasing in place Rebasing baselines while keeping the old pdfDocument drifts: subsequent renders rasterize annotations that were just stripped from disk, and the field/widget split pdf-lib's save can create isn't visible until reload. Reload makes 'viewer == disk' an invariant. localStorage cleared first; file_changed echo suppressed by lastSavedMtime as before. --- examples/pdf-server/src/mcp-app.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index ee256b3b..f4a17901 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3124,19 +3124,6 @@ async function savePdf(): Promise { 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 - // Panel diffs against the baselines we just rebased — re-render so the - // "edited" badges and pending-change list go away. - updateAnnotationsBadge(); - renderAnnotationPanel(); const key = annotationStorageKey(); if (key) { try { @@ -3145,6 +3132,13 @@ async function savePdf(): Promise { /* ignore */ } } + // Reload from the bytes we just wrote. The previous approach (rebase + // baselines but keep the old pdfDocument) drifts: subsequent renders + // still rasterize stripped annotations from the old bytes, and the + // field/widget split that pdf-lib's save can create isn't reflected + // until reload anyway. Reload makes "what you see = what's on disk" + // an invariant. (file_changed echo is suppressed by lastSavedMtime.) + await reloadPdf(); } } catch (err) { log.error("Save failed:", err);