Skip to content

Commit b8d2578

Browse files
committed
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 '<Subtype> (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'.
1 parent 4fc9513 commit b8d2578

5 files changed

Lines changed: 235 additions & 7 deletions

File tree

examples/pdf-server/src/annotation-panel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ export function getAnnotationLabel(def: PdfAnnotationDef): string {
338338
return "Line";
339339
case "image":
340340
return "Image";
341+
case "imported":
342+
return `${def.subtype} (from PDF)`;
341343
}
342344
}
343345

@@ -381,6 +383,8 @@ export function getAnnotationColor(def: PdfAnnotationDef): string {
381383
return "#333";
382384
case "image":
383385
return "#999";
386+
case "imported":
387+
return "#666";
384388
}
385389
}
386390

examples/pdf-server/src/mcp-app.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,21 @@ body {
590590
user-select: none;
591591
}
592592

593+
/* Annotation imported verbatim from the PDF (Stamp/Ink/etc. via
594+
* annotationCanvasMap). The body is the rasterized appearance canvas;
595+
* if pdf.js didn't divert it (no hasOwnCanvas), the box stays transparent
596+
* over the main-canvas pixel and just captures clicks. */
597+
.annotation-imported {
598+
position: absolute;
599+
pointer-events: auto;
600+
cursor: grab;
601+
user-select: none;
602+
}
603+
.annotation-imported:hover {
604+
outline: 1px dashed var(--accent, #2563eb);
605+
outline-offset: 1px;
606+
}
607+
593608
/* Selection visuals */
594609
.annotation-selected {
595610
outline: 2px solid var(--accent, #2563eb);

examples/pdf-server/src/mcp-app.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type LineAnnotation,
2626
type StampAnnotation,
2727
type ImageAnnotation,
28+
type ImportedAnnotation,
2829
type NoteAnnotation,
2930
type FreetextAnnotation,
3031
cssColorToRgb,
@@ -1383,6 +1384,10 @@ const DRAGGABLE_TYPES = new Set<string>([
13831384
"stamp",
13841385
"note",
13851386
"image",
1387+
// "imported" is draggable in the UI but the move does NOT persist to the
1388+
// PDF on save (addAnnotationDicts skips it). Resize/rotate stay disabled
1389+
// — the appearance bitmap would just stretch.
1390+
"imported",
13861391
]);
13871392

13881393
function setupAnnotationInteraction(
@@ -1890,6 +1895,25 @@ function paintAnnotationsOnCanvas(
18901895
}
18911896
break;
18921897
}
1898+
1899+
case "imported": {
1900+
const s = pdfRectToScreen(
1901+
{ x: def.x, y: def.y, width: def.width, height: def.height },
1902+
viewport,
1903+
);
1904+
const bmp = annotationCanvasMap.get(def.pdfjsId);
1905+
ctx.save();
1906+
if (bmp) {
1907+
ctx.drawImage(bmp, s.left, s.top, s.width, s.height);
1908+
} else {
1909+
ctx.strokeStyle = "#666";
1910+
ctx.lineWidth = 1;
1911+
ctx.setLineDash([3, 3]);
1912+
ctx.strokeRect(s.left, s.top, s.width, s.height);
1913+
}
1914+
ctx.restore();
1915+
break;
1916+
}
18931917
}
18941918
}
18951919
}
@@ -1987,6 +2011,8 @@ function renderAnnotation(
19872011
return [renderLineAnnotation(def, viewport)];
19882012
case "image":
19892013
return [renderImageAnnotation(def, viewport)];
2014+
case "imported":
2015+
return [renderImportedAnnotation(def, viewport)];
19902016
}
19912017
}
19922018

@@ -2175,6 +2201,45 @@ function renderImageAnnotation(
21752201
return el;
21762202
}
21772203

2204+
/**
2205+
* Per-annotation appearance bitmaps from page.render(). Keyed by pdf.js
2206+
* annotation id (e.g. "118R"). Populated for the current page only —
2207+
* cleared at the start of each renderPage().
2208+
*/
2209+
const annotationCanvasMap = new Map<string, HTMLCanvasElement>();
2210+
2211+
function renderImportedAnnotation(
2212+
def: ImportedAnnotation,
2213+
viewport: { width: number; height: number; scale: number },
2214+
): HTMLElement {
2215+
const screen = pdfRectToScreen(
2216+
{ x: def.x, y: def.y, width: def.width, height: def.height },
2217+
viewport,
2218+
);
2219+
const el = document.createElement("div");
2220+
el.className = "annotation-imported";
2221+
el.style.left = `${screen.left}px`;
2222+
el.style.top = `${screen.top}px`;
2223+
el.style.width = `${screen.width}px`;
2224+
el.style.height = `${screen.height}px`;
2225+
el.title = `${def.subtype} (from PDF)`;
2226+
2227+
// page.render() may or may not have produced a separate canvas for this
2228+
// annotation (hasOwnCanvas depends on the PDF's flags). When it did, use
2229+
// it as a pixel-faithful body; when it didn't, the appearance is on the
2230+
// main canvas already, so leave the box transparent — it still captures
2231+
// clicks for select/delete.
2232+
const canvas = annotationCanvasMap.get(def.pdfjsId);
2233+
if (canvas) {
2234+
canvas.style.width = "100%";
2235+
canvas.style.height = "100%";
2236+
canvas.style.display = "block";
2237+
canvas.style.pointerEvents = "none";
2238+
el.appendChild(canvas);
2239+
}
2240+
return el;
2241+
}
2242+
21782243
// =============================================================================
21792244
// Annotation CRUD
21802245
// =============================================================================
@@ -3158,11 +3223,21 @@ async function renderPage() {
31583223
// Set --scale-factor so CSS font-size/transform rules work correctly.
31593224
textLayerEl.style.setProperty("--scale-factor", `${scale}`);
31603225

3161-
// Render canvas - track the task so we can cancel it
3226+
// Render canvas - track the task so we can cancel it.
3227+
//
3228+
// annotationCanvasMap: pdf.js diverts annotations whose appearance needs
3229+
// its own bitmap (Stamp/Ink/FreeText/etc. with hasOwnCanvas) into
3230+
// per-id canvases instead of compositing onto the main canvas.
3231+
// renderImportedAnnotation() pulls from this map so those annotations
3232+
// become movable DOM elements with pixel-faithful visuals — instead of
3233+
// unselectable canvas pixels (the old "ghost annotation" problem) or
3234+
// our lossy text-label re-render.
3235+
annotationCanvasMap.clear();
31623236
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31633237
const renderTask = (page.render as any)({
31643238
canvasContext: ctx,
31653239
viewport,
3240+
annotationCanvasMap,
31663241
});
31673242
currentRenderTask = renderTask;
31683243

examples/pdf-server/src/pdf-annotations.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,76 @@ describe("importPdfjsAnnotation", () => {
502502
expect(result!.page).toBe(2);
503503
});
504504

505+
it("imports an unsupported subtype as 'imported' (placement only)", () => {
506+
// annotationType 15 = Ink, not in PDFJS_TYPE_MAP. We keep it as a
507+
// placement-only "imported" record so it's listed in the panel and
508+
// rendered from annotationCanvasMap instead of being dropped.
509+
const ann = {
510+
annotationType: 15,
511+
subtype: "Ink",
512+
id: "200R",
513+
rect: [100, 200, 180, 260],
514+
};
515+
const result = importPdfjsAnnotation(ann, 3, 0);
516+
expect(result).not.toBeNull();
517+
expect(result!.type).toBe("imported");
518+
expect(result!.page).toBe(3);
519+
expect((result as any).pdfjsId).toBe("200R");
520+
expect((result as any).subtype).toBe("Ink");
521+
expect((result as any).width).toBeCloseTo(80);
522+
expect((result as any).height).toBeCloseTo(60);
523+
});
524+
525+
it("imports an appearance-stream stamp as 'imported' (not text-label)", () => {
526+
// A Stamp with hasAppearance carries a custom visual (e.g. an image
527+
// signature) that our text-label StampAnnotation can't reproduce.
528+
const ann = {
529+
annotationType: 13,
530+
subtype: "Stamp",
531+
id: "118R",
532+
rect: [420, 760, 514, 792],
533+
hasAppearance: true,
534+
contentsObj: { str: "DRAFT" },
535+
};
536+
const result = importPdfjsAnnotation(ann, 1, 0);
537+
expect(result!.type).toBe("imported");
538+
expect((result as any).pdfjsId).toBe("118R");
539+
expect((result as any).subtype).toBe("Stamp");
540+
});
541+
542+
it("computeDiff: 'imported' present in both baseline and current → no diff", () => {
543+
const imp: PdfAnnotationDef = {
544+
type: "imported",
545+
id: "pdf-118R",
546+
page: 1,
547+
x: 420,
548+
y: 760,
549+
width: 94,
550+
height: 32,
551+
pdfjsId: "118R",
552+
subtype: "Stamp",
553+
};
554+
const diff = computeDiff([imp], [imp], new Map());
555+
expect(diff.added).toHaveLength(0);
556+
expect(diff.removed).toHaveLength(0);
557+
});
558+
559+
it("computeDiff: deleting an 'imported' annotation lists it in removed", () => {
560+
const imp: PdfAnnotationDef = {
561+
type: "imported",
562+
id: "pdf-118R",
563+
page: 1,
564+
x: 420,
565+
y: 760,
566+
width: 94,
567+
height: 32,
568+
pdfjsId: "118R",
569+
subtype: "Stamp",
570+
};
571+
const diff = computeDiff([imp], [], new Map());
572+
expect(diff.removed).toEqual(["pdf-118R"]);
573+
});
574+
505575
it("imports a note (Text) annotation", () => {
506576
const ann = {
507577
annotationType: 1,

examples/pdf-server/src/pdf-annotations.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,32 @@ export interface ImageAnnotation extends AnnotationBase {
128128
aspect?: "preserve" | "ignore";
129129
}
130130

131+
/**
132+
* An annotation that already exists in the loaded PDF and that we render
133+
* verbatim from its appearance stream (via pdf.js's annotationCanvasMap)
134+
* rather than re-modeling it with one of our shape types.
135+
*
136+
* Covers two cases:
137+
* - subtypes we don't model (Ink, Polygon, Caret, FileAttachment, …)
138+
* - subtypes we *could* model but whose appearance carries information
139+
* our model would drop (e.g. Stamp with an image signature)
140+
*
141+
* The rasterized canvas is supplied at render time by mcp-app.ts; this
142+
* struct only carries placement and identity. Coords are PDF user-space
143+
* (origin bottom-left), matching the other rect-shaped types.
144+
*/
145+
export interface ImportedAnnotation extends AnnotationBase {
146+
type: "imported";
147+
x: number;
148+
y: number;
149+
width: number;
150+
height: number;
151+
/** pdf.js getAnnotations() id (e.g. "118R") — key into annotationCanvasMap. */
152+
pdfjsId: string;
153+
/** Original PDF /Subtype (e.g. "Stamp", "Ink") for the panel label. */
154+
subtype: string;
155+
}
156+
131157
export type PdfAnnotationDef =
132158
| HighlightAnnotation
133159
| UnderlineAnnotation
@@ -138,7 +164,8 @@ export type PdfAnnotationDef =
138164
| LineAnnotation
139165
| FreetextAnnotation
140166
| StampAnnotation
141-
| ImageAnnotation;
167+
| ImageAnnotation
168+
| ImportedAnnotation;
142169

143170
// =============================================================================
144171
// Coordinate Conversion (model ↔ internal PDF coords)
@@ -172,6 +199,7 @@ export function convertFromModelCoords(
172199
case "rectangle":
173200
case "circle":
174201
case "image":
202+
case "imported":
175203
return { ...def, y: pageHeight - def.y - def.height };
176204
case "line":
177205
return {
@@ -372,6 +400,7 @@ export function defaultColor(type: PdfAnnotationDef["type"]): string {
372400
case "stamp":
373401
return "#cc0000";
374402
case "image":
403+
case "imported":
375404
return "#00000000";
376405
}
377406
}
@@ -493,6 +522,11 @@ export async function addAnnotationDicts(
493522
const pages = pdfDoc.getPages();
494523

495524
for (const def of annotations) {
525+
// "imported" annotations are already in the source PDF — never
526+
// re-serialize them. (Moving/deleting one is UI-only for now; a future
527+
// pass can rewrite the page's /Annots array to drop the original ref.)
528+
if (def.type === "imported") continue;
529+
496530
const pageIdx = def.page - 1;
497531
if (pageIdx < 0 || pageIdx >= pages.length) continue;
498532
const page = pages[pageIdx];
@@ -935,14 +969,44 @@ export function importPdfjsAnnotation(
935969
pageNum: number,
936970
index: number,
937971
): PdfAnnotationDef | null {
938-
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
939-
if (!ourType) return null;
940-
941-
// Skip form widgets (they're handled separately by AnnotationLayer)
942-
if (ann.annotationType === 20) return null;
972+
// Skip form widgets (they're handled separately by AnnotationLayer) and
973+
// auxiliary types that aren't user-visible markup:
974+
// 2 = Link (navigational, AnnotationLayer handles the click target)
975+
// 16 = Popup (the speech-bubble UI for a parent annotation, not content)
976+
if (
977+
ann.annotationType === 20 ||
978+
ann.annotationType === 2 ||
979+
ann.annotationType === 16
980+
) {
981+
return null;
982+
}
943983

944984
const id = makeAnnotationId(ann, pageNum, index);
945985
const color = pdfjsColorToHex(ann.color);
986+
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
987+
988+
// Anything we don't model — and stamps whose visual is an appearance
989+
// stream we can't reproduce as a text label — are kept as "imported":
990+
// a placement-only record that the renderer fills with the rasterized
991+
// appearance from pdf.js's annotationCanvasMap. This keeps Ink, Polygon,
992+
// image-signature stamps, etc. visible AND selectable in our layer.
993+
const importAsBitmap =
994+
!ourType || (ourType === "stamp" && ann.hasAppearance);
995+
if (importAsBitmap) {
996+
if (!ann.rect) return null;
997+
const r = pdfjsRectToRect(ann.rect);
998+
return {
999+
type: "imported",
1000+
id,
1001+
page: pageNum,
1002+
x: r.x,
1003+
y: r.y,
1004+
width: r.width,
1005+
height: r.height,
1006+
pdfjsId: String(ann.id ?? ""),
1007+
subtype: String(ann.subtype ?? `type${ann.annotationType}`),
1008+
};
1009+
}
9461010

9471011
switch (ourType) {
9481012
case "highlight":

0 commit comments

Comments
 (0)