Skip to content

Commit b03a230

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 3af0191 commit b03a230

File tree

5 files changed

+235
-7
lines changed

5 files changed

+235
-7
lines changed

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
// =============================================================================
@@ -3166,11 +3231,21 @@ async function renderPage() {
31663231
// Set --scale-factor so CSS font-size/transform rules work correctly.
31673232
textLayerEl.style.setProperty("--scale-factor", `${scale}`);
31683233

3169-
// Render canvas - track the task so we can cancel it
3234+
// Render canvas - track the task so we can cancel it.
3235+
//
3236+
// annotationCanvasMap: pdf.js diverts annotations whose appearance needs
3237+
// its own bitmap (Stamp/Ink/FreeText/etc. with hasOwnCanvas) into
3238+
// per-id canvases instead of compositing onto the main canvas.
3239+
// renderImportedAnnotation() pulls from this map so those annotations
3240+
// become movable DOM elements with pixel-faithful visuals — instead of
3241+
// unselectable canvas pixels (the old "ghost annotation" problem) or
3242+
// our lossy text-label re-render.
3243+
annotationCanvasMap.clear();
31703244
// eslint-disable-next-line @typescript-eslint/no-explicit-any
31713245
const renderTask = (page.render as any)({
31723246
canvasContext: ctx,
31733247
viewport,
3248+
annotationCanvasMap,
31743249
});
31753250
currentRenderTask = renderTask;
31763251

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

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

552+
it("imports an unsupported subtype as 'imported' (placement only)", () => {
553+
// annotationType 15 = Ink, not in PDFJS_TYPE_MAP. We keep it as a
554+
// placement-only "imported" record so it's listed in the panel and
555+
// rendered from annotationCanvasMap instead of being dropped.
556+
const ann = {
557+
annotationType: 15,
558+
subtype: "Ink",
559+
id: "200R",
560+
rect: [100, 200, 180, 260],
561+
};
562+
const result = importPdfjsAnnotation(ann, 3, 0);
563+
expect(result).not.toBeNull();
564+
expect(result!.type).toBe("imported");
565+
expect(result!.page).toBe(3);
566+
expect((result as any).pdfjsId).toBe("200R");
567+
expect((result as any).subtype).toBe("Ink");
568+
expect((result as any).width).toBeCloseTo(80);
569+
expect((result as any).height).toBeCloseTo(60);
570+
});
571+
572+
it("imports an appearance-stream stamp as 'imported' (not text-label)", () => {
573+
// A Stamp with hasAppearance carries a custom visual (e.g. an image
574+
// signature) that our text-label StampAnnotation can't reproduce.
575+
const ann = {
576+
annotationType: 13,
577+
subtype: "Stamp",
578+
id: "118R",
579+
rect: [420, 760, 514, 792],
580+
hasAppearance: true,
581+
contentsObj: { str: "DRAFT" },
582+
};
583+
const result = importPdfjsAnnotation(ann, 1, 0);
584+
expect(result!.type).toBe("imported");
585+
expect((result as any).pdfjsId).toBe("118R");
586+
expect((result as any).subtype).toBe("Stamp");
587+
});
588+
589+
it("computeDiff: 'imported' present in both baseline and current → no diff", () => {
590+
const imp: PdfAnnotationDef = {
591+
type: "imported",
592+
id: "pdf-118R",
593+
page: 1,
594+
x: 420,
595+
y: 760,
596+
width: 94,
597+
height: 32,
598+
pdfjsId: "118R",
599+
subtype: "Stamp",
600+
};
601+
const diff = computeDiff([imp], [imp], new Map());
602+
expect(diff.added).toHaveLength(0);
603+
expect(diff.removed).toHaveLength(0);
604+
});
605+
606+
it("computeDiff: deleting an 'imported' annotation lists it in removed", () => {
607+
const imp: PdfAnnotationDef = {
608+
type: "imported",
609+
id: "pdf-118R",
610+
page: 1,
611+
x: 420,
612+
y: 760,
613+
width: 94,
614+
height: 32,
615+
pdfjsId: "118R",
616+
subtype: "Stamp",
617+
};
618+
const diff = computeDiff([imp], [], new Map());
619+
expect(diff.removed).toEqual(["pdf-118R"]);
620+
});
621+
552622
it("imports a note (Text) annotation", () => {
553623
const ann = {
554624
annotationType: 1,

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

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

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

145172
// =============================================================================
146173
// Coordinate Conversion (model ↔ internal PDF coords)
@@ -174,6 +201,7 @@ export function convertFromModelCoords(
174201
case "rectangle":
175202
case "circle":
176203
case "image":
204+
case "imported":
177205
return { ...def, y: pageHeight - def.y - def.height };
178206
case "line":
179207
return {
@@ -374,6 +402,7 @@ export function defaultColor(type: PdfAnnotationDef["type"]): string {
374402
case "stamp":
375403
return "#cc0000";
376404
case "image":
405+
case "imported":
377406
return "#00000000";
378407
}
379408
}
@@ -495,6 +524,11 @@ export async function addAnnotationDicts(
495524
const pages = pdfDoc.getPages();
496525

497526
for (const def of annotations) {
527+
// "imported" annotations are already in the source PDF — never
528+
// re-serialize them. (Moving/deleting one is UI-only for now; a future
529+
// pass can rewrite the page's /Annots array to drop the original ref.)
530+
if (def.type === "imported") continue;
531+
498532
const pageIdx = def.page - 1;
499533
if (pageIdx < 0 || pageIdx >= pages.length) continue;
500534
const page = pages[pageIdx];
@@ -1023,14 +1057,44 @@ export function importPdfjsAnnotation(
10231057
pageNum: number,
10241058
index: number,
10251059
): PdfAnnotationDef | null {
1026-
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
1027-
if (!ourType) return null;
1028-
1029-
// Skip form widgets (they're handled separately by AnnotationLayer)
1030-
if (ann.annotationType === 20) return null;
1060+
// Skip form widgets (they're handled separately by AnnotationLayer) and
1061+
// auxiliary types that aren't user-visible markup:
1062+
// 2 = Link (navigational, AnnotationLayer handles the click target)
1063+
// 16 = Popup (the speech-bubble UI for a parent annotation, not content)
1064+
if (
1065+
ann.annotationType === 20 ||
1066+
ann.annotationType === 2 ||
1067+
ann.annotationType === 16
1068+
) {
1069+
return null;
1070+
}
10311071

10321072
const id = makeAnnotationId(ann, pageNum, index);
10331073
const color = pdfjsColorToHex(ann.color);
1074+
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
1075+
1076+
// Anything we don't model — and stamps whose visual is an appearance
1077+
// stream we can't reproduce as a text label — are kept as "imported":
1078+
// a placement-only record that the renderer fills with the rasterized
1079+
// appearance from pdf.js's annotationCanvasMap. This keeps Ink, Polygon,
1080+
// image-signature stamps, etc. visible AND selectable in our layer.
1081+
const importAsBitmap =
1082+
!ourType || (ourType === "stamp" && ann.hasAppearance);
1083+
if (importAsBitmap) {
1084+
if (!ann.rect) return null;
1085+
const r = pdfjsRectToRect(ann.rect);
1086+
return {
1087+
type: "imported",
1088+
id,
1089+
page: pageNum,
1090+
x: r.x,
1091+
y: r.y,
1092+
width: r.width,
1093+
height: r.height,
1094+
pdfjsId: String(ann.id ?? ""),
1095+
subtype: String(ann.subtype ?? `type${ann.annotationType}`),
1096+
};
1097+
}
10341098

10351099
switch (ourType) {
10361100
case "highlight":

0 commit comments

Comments
 (0)