Skip to content

Commit 920207f

Browse files
committed
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.
1 parent 3dcefe4 commit 920207f

File tree

4 files changed

+226
-11
lines changed

4 files changed

+226
-11
lines changed

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

Lines changed: 82 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 {
@@ -429,6 +442,14 @@ export function renderAnnotationPanel(): void {
429442
byPage.get(page)!.push(tracked);
430443
}
431444

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+
432453
// Group form fields by page — iterate the UNION so cleared baseline
433454
// fields remain visible (crossed out) with a per-item revert button.
434455
const fieldsByPage = new Map<number, string[]>();
@@ -445,7 +466,11 @@ export function renderAnnotationPanel(): void {
445466
}
446467

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

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

476502
appendAccordionSection(
477503
`Page ${pageNum} (${itemCount})`,
@@ -487,6 +513,10 @@ export function renderAnnotationPanel(): void {
487513
for (const tracked of annotations) {
488514
body.appendChild(createAnnotationCard(tracked));
489515
}
516+
// Then removed baseline annotations (crossed-out, revertable)
517+
for (const def of removed) {
518+
body.appendChild(createRemovedAnnotationCard(def));
519+
}
490520
},
491521
);
492522
}
@@ -652,6 +682,55 @@ function createAnnotationCard(tracked: TrackedAnnotation): HTMLElement {
652682
return card;
653683
}
654684

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+
655734
/** Revert one field to its PDF-stored baseline value. */
656735
function revertFieldToBaseline(name: string): void {
657736
const base = pdfBaselineFormValues.get(name);

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
computeDiff,
3636
isDiffEmpty,
3737
buildAnnotatedPdfBytes,
38+
parseAnnotationRef,
3839
importPdfjsAnnotation,
3940
uint8ArrayToBase64,
4041
convertFromModelCoords,
@@ -2922,19 +2923,29 @@ async function buildFieldNameMap(
29222923
if (!widgetIds) continue; // no widget → not rendered anyway
29232924

29242925
// Type comes from getFieldObjects (widget annot data doesn't have it).
2925-
// Value comes from the widget annotation (fall back to field-dict if
2926-
// the widget didn't expose one).
2926+
// Value: prefer the AcroForm field-tree value over the widget's
2927+
// fieldValue. pdf-lib's save() can leave a page widget pointing at a
2928+
// stale /V while the field tree has the new one (seen with comb text
2929+
// fields), and getAnnotations() reads the widget. If the two disagree
2930+
// we push the field-tree value into annotationStorage so the rendered
2931+
// input matches what's actually in /AcroForm.
29272932
const type = fieldArr.find((f) => f.type)?.type;
2928-
const raw = widgetFieldValues.has(name)
2929-
? widgetFieldValues.get(name)
2930-
: fieldArr.find((f) => f.value != null)?.value;
2933+
const fieldTreeRaw = fieldArr.find((f) => f.value != null)?.value;
2934+
const widgetRaw = widgetFieldValues.get(name);
2935+
const raw = fieldTreeRaw ?? widgetRaw;
29312936
const v = normaliseFieldValue(type, raw);
29322937
if (v !== null) {
29332938
pdfBaselineFormValues.set(name, v);
29342939
// Seed current state from baseline so the panel shows it. A
29352940
// restored localStorage diff (applied in restoreAnnotations) will
29362941
// overwrite specific fields the user changed.
29372942
if (!formFieldValues.has(name)) formFieldValues.set(name, v);
2943+
// Widget out of sync with field tree → force storage so
2944+
// AnnotationLayer renders the field-tree value, not the stale
2945+
// widget. (syncFormValuesToStorage skips baseline==current.)
2946+
if (fieldTreeRaw != null && fieldTreeRaw !== widgetRaw) {
2947+
setFieldInStorage(name, v);
2948+
}
29382949
}
29392950

29402951
// Skip parent entries with no concrete id (radio groups: the /T tree
@@ -3036,6 +3047,14 @@ async function getAnnotatedPdfBytes(): Promise<Uint8Array> {
30363047
}
30373048
}
30383049

3050+
// Baseline annotations the user deleted: strip their refs from /Annots so
3051+
// they don't reappear on reload. Ids without a recoverable ref (page-index
3052+
// fallback) can't be removed by-ref and are skipped.
3053+
const removedRefs = pdfBaselineAnnotations
3054+
.filter((a) => !annotationMap.has(a.id))
3055+
.map((a) => parseAnnotationRef(a.id))
3056+
.filter((r): r is NonNullable<typeof r> => r !== null);
3057+
30393058
// Only write fields that actually changed vs. what's already in the PDF.
30403059
// Unchanged fields are no-ops at best, and at worst trip pdf-lib edge
30413060
// cases (max-length text, missing /Yes appearance, …) on fields the user
@@ -3060,6 +3079,7 @@ async function getAnnotatedPdfBytes(): Promise<Uint8Array> {
30603079
fullBytes as Uint8Array,
30613080
annotations,
30623081
formFieldsOut,
3082+
removedRefs,
30633083
);
30643084
}
30653085

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
defaultColor,
1111
importPdfjsAnnotation,
1212
buildAnnotatedPdfBytes,
13+
parseAnnotationRef,
1314
base64ToUint8Array,
1415
uint8ArrayToBase64,
1516
convertFromModelCoords,
@@ -788,6 +789,29 @@ describe("base64 helpers", () => {
788789
// PDF Annotation Dict Creation (integration test with pdf-lib)
789790
// =============================================================================
790791

792+
describe("parseAnnotationRef", () => {
793+
it("parses pdf-<num>-<gen> ids", () => {
794+
expect(parseAnnotationRef("pdf-118-0")).toEqual({
795+
objectNumber: 118,
796+
generationNumber: 0,
797+
});
798+
expect(parseAnnotationRef("pdf-5-2")).toEqual({
799+
objectNumber: 5,
800+
generationNumber: 2,
801+
});
802+
});
803+
it("parses pdf-<num>R ids (pdf.js string id, gen=0)", () => {
804+
expect(parseAnnotationRef("pdf-118R")).toEqual({
805+
objectNumber: 118,
806+
generationNumber: 0,
807+
});
808+
});
809+
it("returns null for page-index fallback ids", () => {
810+
expect(parseAnnotationRef("pdf-1-idx-3")).toBeNull();
811+
expect(parseAnnotationRef("user-abc")).toBeNull();
812+
});
813+
});
814+
791815
describe("buildAnnotatedPdfBytes", () => {
792816
let blankPdfBytes: Uint8Array;
793817

@@ -808,6 +832,53 @@ describe("buildAnnotatedPdfBytes", () => {
808832
expect(header).toBe("%PDF-");
809833
});
810834

835+
it("strips removedRefs entries from each page's /Annots array", async () => {
836+
// Seed: add two highlights, save, capture their object refs.
837+
const seeded = await buildAnnotatedPdfBytes(
838+
blankPdfBytes,
839+
[
840+
{
841+
type: "highlight",
842+
id: "h1",
843+
page: 1,
844+
rects: [{ x: 72, y: 700, width: 100, height: 12 }],
845+
color: "#ffff00",
846+
},
847+
{
848+
type: "highlight",
849+
id: "h2",
850+
page: 1,
851+
rects: [{ x: 72, y: 680, width: 100, height: 12 }],
852+
color: "#ffff00",
853+
},
854+
],
855+
new Map(),
856+
);
857+
const seededDoc = await PDFDocument.load(seeded);
858+
const annots = seededDoc.getPage(0).node.Annots()!;
859+
expect(annots.size()).toBe(2);
860+
const ref0 = annots.get(0) as unknown as {
861+
objectNumber: number;
862+
generationNumber: number;
863+
};
864+
865+
// Now remove the first one by ref.
866+
const stripped = await buildAnnotatedPdfBytes(seeded, [], new Map(), [
867+
{ objectNumber: ref0.objectNumber, generationNumber: ref0.generationNumber },
868+
]);
869+
const strippedDoc = await PDFDocument.load(stripped);
870+
const remaining = strippedDoc.getPage(0).node.Annots();
871+
expect(remaining?.size() ?? 0).toBe(1);
872+
});
873+
874+
it("removedRefs ignores refs not present in /Annots", async () => {
875+
const out = await buildAnnotatedPdfBytes(blankPdfBytes, [], new Map(), [
876+
{ objectNumber: 9999, generationNumber: 0 },
877+
]);
878+
const doc = await PDFDocument.load(out);
879+
expect(doc.getPage(0).node.Annots()?.size() ?? 0).toBe(0);
880+
});
881+
811882
it("adds highlight annotation to PDF", async () => {
812883
const annotations: PdfAnnotationDef[] = [
813884
{

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

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -525,8 +525,8 @@ export async function addAnnotationDicts(
525525

526526
for (const def of annotations) {
527527
// "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.)
528+
// re-serialize them. Deletion is handled by buildAnnotatedPdfBytes'
529+
// removedRefs strip; moving an imported annotation is still UI-only.
530530
if (def.type === "imported") continue;
531531

532532
const pageIdx = def.page - 1;
@@ -870,17 +870,62 @@ function setButtonGroupValue(
870870
}
871871
}
872872

873+
/**
874+
* Recover the PDF object reference from an annotation id assigned by
875+
* `makeAnnotationId`. Handles both `pdf-<num>-<gen>` (from `ann.ref`) and
876+
* `pdf-<num>R` (from pdf.js's string `ann.id`, gen always 0). Returns null for
877+
* page-index fallback ids — those have no stable ref to remove by.
878+
*/
879+
export function parseAnnotationRef(
880+
id: string,
881+
): { objectNumber: number; generationNumber: number } | null {
882+
let m = /^pdf-(\d+)-(\d+)$/.exec(id);
883+
if (m) return { objectNumber: +m[1], generationNumber: +m[2] };
884+
m = /^pdf-(\d+)R$/.exec(id);
885+
if (m) return { objectNumber: +m[1], generationNumber: 0 };
886+
return null;
887+
}
888+
873889
/**
874890
* Build annotated PDF bytes from the original document.
875-
* Applies user annotations and form fills, returns Uint8Array of the new PDF.
891+
* Applies user annotations and form fills, removes baseline annotations the
892+
* user deleted, returns Uint8Array of the new PDF.
876893
*/
877894
export async function buildAnnotatedPdfBytes(
878895
pdfBytes: Uint8Array,
879896
annotations: PdfAnnotationDef[],
880897
formFields: Map<string, string | boolean>,
898+
removedRefs: { objectNumber: number; generationNumber: number }[] = [],
881899
): Promise<Uint8Array> {
882900
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
883901

902+
// Strip baseline annotations the user deleted. We match on object number +
903+
// generation against each page's /Annots array entries (PDFRef). We don't
904+
// know which page they were on, so scan every page — /Annots arrays are
905+
// small and this only runs at save time.
906+
if (removedRefs.length > 0) {
907+
const wanted = new Set(
908+
removedRefs.map((r) => `${r.objectNumber} ${r.generationNumber}`),
909+
);
910+
for (const page of pdfDoc.getPages()) {
911+
const annots = page.node.Annots();
912+
if (!annots) continue;
913+
// Walk backwards so .remove(idx) doesn't shift unprocessed entries.
914+
for (let i = annots.size() - 1; i >= 0; i--) {
915+
const ref = annots.get(i) as {
916+
objectNumber?: number;
917+
generationNumber?: number;
918+
};
919+
if (
920+
ref?.objectNumber !== undefined &&
921+
wanted.has(`${ref.objectNumber} ${ref.generationNumber ?? 0}`)
922+
) {
923+
annots.remove(i);
924+
}
925+
}
926+
}
927+
}
928+
884929
// Add proper PDF annotation objects
885930
await addAnnotationDicts(pdfDoc, annotations);
886931

0 commit comments

Comments
 (0)