Skip to content

Commit 61fe39b

Browse files
authored
feat(pdf-server): rasterize imported annotations + form/save consistency follow-ups (#593)
* 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'. * 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. * 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. * 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. * 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-<num>-<gen> and pdf-<num>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. * 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. * 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.
1 parent 3af0191 commit 61fe39b

File tree

5 files changed

+505
-31
lines changed

5 files changed

+505
-31
lines changed

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

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,10 +289,23 @@ function panelFieldNames(): Set<string> {
289289
return new Set([...formFieldValues.keys(), ...pdfBaselineFormValues.keys()]);
290290
}
291291

292+
/** Baseline annotations the user has deleted. Shown crossed-out in the panel
293+
* (mirroring cleared form fields) so they can be reverted, and so save knows
294+
* to strip their refs from /Annots. */
295+
function removedBaselineAnnotations(): PdfAnnotationDef[] {
296+
return deps
297+
.state()
298+
.pdfBaselineAnnotations.filter((a) => !annotationMap.has(a.id));
299+
}
300+
292301
/** Total count of annotations + form fields for the sidebar badge.
293302
* Uses the union so cleared baseline items still contribute. */
294303
function sidebarItemCount(): number {
295-
return annotationMap.size + panelFieldNames().size;
304+
return (
305+
annotationMap.size +
306+
removedBaselineAnnotations().length +
307+
panelFieldNames().size
308+
);
296309
}
297310

298311
export function updateAnnotationsBadge(): void {
@@ -338,6 +351,8 @@ export function getAnnotationLabel(def: PdfAnnotationDef): string {
338351
return "Line";
339352
case "image":
340353
return "Image";
354+
case "imported":
355+
return `${def.subtype} (from PDF)`;
341356
}
342357
}
343358

@@ -381,6 +396,8 @@ export function getAnnotationColor(def: PdfAnnotationDef): string {
381396
return "#333";
382397
case "image":
383398
return "#999";
399+
case "imported":
400+
return "#666";
384401
}
385402
}
386403

@@ -425,6 +442,14 @@ export function renderAnnotationPanel(): void {
425442
byPage.get(page)!.push(tracked);
426443
}
427444

445+
// Removed baseline annotations: still listed (crossed-out) so they can be
446+
// reverted before save strips them from the file.
447+
const removedByPage = new Map<number, PdfAnnotationDef[]>();
448+
for (const def of removedBaselineAnnotations()) {
449+
if (!removedByPage.has(def.page)) removedByPage.set(def.page, []);
450+
removedByPage.get(def.page)!.push(def);
451+
}
452+
428453
// Group form fields by page — iterate the UNION so cleared baseline
429454
// fields remain visible (crossed out) with a per-item revert button.
430455
const fieldsByPage = new Map<number, string[]>();
@@ -441,7 +466,11 @@ export function renderAnnotationPanel(): void {
441466
}
442467

443468
// Collect all pages that have annotations or form fields
444-
const allPages = new Set([...byPage.keys(), ...fieldsByPage.keys()]);
469+
const allPages = new Set([
470+
...byPage.keys(),
471+
...removedByPage.keys(),
472+
...fieldsByPage.keys(),
473+
]);
445474
const sortedPages = [...allPages].sort((a, b) => a - b);
446475

447476
// Sort annotations within each page by Y position (descending = top-first in PDF coords)
@@ -466,8 +495,9 @@ export function renderAnnotationPanel(): void {
466495
const sectionKey = `page-${pageNum}`;
467496
const isOpen = panelState.openAccordionSection === sectionKey;
468497
const annotations = byPage.get(pageNum) ?? [];
498+
const removed = removedByPage.get(pageNum) ?? [];
469499
const fields = fieldsByPage.get(pageNum) ?? [];
470-
const itemCount = annotations.length + fields.length;
500+
const itemCount = annotations.length + removed.length + fields.length;
471501

472502
appendAccordionSection(
473503
`Page ${pageNum} (${itemCount})`,
@@ -483,6 +513,10 @@ export function renderAnnotationPanel(): void {
483513
for (const tracked of annotations) {
484514
body.appendChild(createAnnotationCard(tracked));
485515
}
516+
// Then removed baseline annotations (crossed-out, revertable)
517+
for (const def of removed) {
518+
body.appendChild(createRemovedAnnotationCard(def));
519+
}
486520
},
487521
);
488522
}
@@ -648,6 +682,55 @@ function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement {
648682
return card;
649683
}
650684

685+
/**
686+
* Card for a baseline annotation the user deleted: crossed-out, no select/
687+
* navigate (it has no DOM on the page anymore), revert button puts it back
688+
* into `annotationMap` so it renders again and save leaves it in the file.
689+
*/
690+
function createRemovedAnnotationCard(def: PdfAnnotationDef): HTMLElement {
691+
const card = document.createElement("div");
692+
card.className = "annotation-card annotation-card-cleared";
693+
card.dataset.annotationId = def.id;
694+
695+
const row = document.createElement("div");
696+
row.className = "annotation-card-row";
697+
698+
const swatch = document.createElement("div");
699+
swatch.className = "annotation-card-swatch annotation-card-swatch-cleared";
700+
swatch.innerHTML = `<svg width="10" height="10" viewBox="0 0 10 10" stroke="${getAnnotationColor(def)}" stroke-width="1.5" stroke-linecap="round"><path d="M2 2l6 6M8 2L2 8"/></svg>`;
701+
row.appendChild(swatch);
702+
703+
const typeLabel = document.createElement("span");
704+
typeLabel.className = "annotation-card-type";
705+
typeLabel.textContent = getAnnotationLabel(def);
706+
row.appendChild(typeLabel);
707+
708+
const preview = getAnnotationPreview(def);
709+
if (preview) {
710+
const previewEl = document.createElement("span");
711+
previewEl.className = "annotation-card-preview";
712+
previewEl.textContent = preview;
713+
row.appendChild(previewEl);
714+
}
715+
716+
const revertBtn = document.createElement("button");
717+
revertBtn.className = "annotation-card-delete";
718+
revertBtn.title = "Restore annotation from file";
719+
revertBtn.innerHTML = REVERT_SVG;
720+
revertBtn.addEventListener("click", (e) => {
721+
e.stopPropagation();
722+
annotationMap.set(def.id, { def: { ...def }, elements: [] });
723+
updateAnnotationsBadge();
724+
renderAnnotationPanel();
725+
deps.renderPage();
726+
deps.persistAnnotations();
727+
});
728+
row.appendChild(revertBtn);
729+
730+
card.appendChild(row);
731+
return card;
732+
}
733+
651734
/** Revert one field to its PDF-stored baseline value. */
652735
function revertFieldToBaseline(name: string): void {
653736
const base = pdfBaselineFormValues.get(name);

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);

0 commit comments

Comments
 (0)