Skip to content

Commit 01580bf

Browse files
committed
fix(pdf-server): deleted-baseline zombies, clear-all form strip, height-only coord shift
Three bugs from PR review, all in viewer state/coord handling: 1. Deleted baseline annotations zombied back on reload (mcp-app.ts:2512). restoreAnnotations' apply loop was add-only. loadBaselineAnnotations runs between the two restore calls and re-seeds annotationMap with every baseline id, including the ones in diff.removed. The zombie survives, and the next persistAnnotations sees it in currentIds -> computeDiff produces removed=[] -> deletion permanently lost. Fix: delete diff.removed ids after the merge loop. 2. Clear All didn't strip form values from the saved PDF (mcp-app.ts:2756). clearAllItems() empties formFieldValues, but buildAnnotatedPdfBytes gates on formFields.size > 0 and only writes entries present in the map. Empty map -> zero setText/uncheck calls -> pdf-lib keeps the original /V values. Fix: at getAnnotatedPdfBytes time, inject an explicit clearing sentinel ("" or false by baseline type) for every baseline field absent from formFieldValues. 3. update_annotations shifted rect/circle/image when patching height without y (mcp-app.ts:4161). The old code spread existing.def (internal coords, y = bottom edge) with update (model coords, y = top-left), then key-filtered back to only update keys. But internal y = pageHeight - modelY - height; patching height with top fixed requires rewriting internal y, which the key-filter discarded. Fix: convert existing to model coords, merge in model space, convert back, pass the full merged def. Also uses the target page height when update.page differs (fixes the low-severity page-move bug too).
1 parent c0d257b commit 01580bf

1 file changed

Lines changed: 49 additions & 20 deletions

File tree

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

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2497,13 +2497,21 @@ function restoreAnnotations(): void {
24972497
// Try new diff-based format first
24982498
const diff = deserializeDiff(raw);
24992499

2500-
// Merge baseline + diff
2500+
// Merge baseline + diff. The loop below is add-only, so we MUST also
2501+
// delete: loadBaselineAnnotations() runs between the two restore calls
2502+
// and re-seeds annotationMap with every baseline id — including the
2503+
// ones in diff.removed. Without this, the zombie survives the restore,
2504+
// and the next persistAnnotations() sees it in currentIds → computeDiff
2505+
// produces removed=[] → the deletion is permanently lost from storage.
25012506
const merged = mergeAnnotations(pdfBaselineAnnotations, diff);
25022507
for (const def of merged) {
25032508
if (!annotationMap.has(def.id)) {
25042509
annotationMap.set(def.id, { def, elements: [] });
25052510
}
25062511
}
2512+
for (const id of diff.removed) {
2513+
annotationMap.delete(id);
2514+
}
25072515

25082516
// Restore form fields
25092517
for (const [k, v] of Object.entries(diff.formFields)) {
@@ -2739,10 +2747,22 @@ async function getAnnotatedPdfBytes(): Promise<Uint8Array> {
27392747
}
27402748
}
27412749

2750+
// buildAnnotatedPdfBytes gates on formFields.size > 0 and only writes
2751+
// entries present in the map. After clearAllItems() the map is empty →
2752+
// zero setText/uncheck calls → pdf-lib leaves original /V intact →
2753+
// the "stripped PDF" we promised keeps all its form data. To actually
2754+
// clear, send an explicit sentinel for every baseline field the user
2755+
// dropped: "" for text, false for checkbox (matching baseline type).
2756+
const formFieldsOut = new Map(formFieldValues);
2757+
for (const [name, baselineValue] of pdfBaselineFormValues) {
2758+
if (!formFieldsOut.has(name)) {
2759+
formFieldsOut.set(name, typeof baselineValue === "boolean" ? false : "");
2760+
}
2761+
}
27422762
return buildAnnotatedPdfBytes(
27432763
fullBytes as Uint8Array,
27442764
annotations,
2745-
formFieldValues,
2765+
formFieldsOut,
27462766
);
27472767
}
27482768

@@ -4147,24 +4167,33 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
41474167
for (const update of cmd.annotations) {
41484168
const existing = annotationMap.get(update.id);
41494169
if (!existing) continue;
4150-
const pageHeight = await getPageHeight(existing.def.page);
4151-
// Merge partial update into existing def, convert the merged result,
4152-
// then extract only the fields that were in the original update
4153-
const merged = { ...existing.def, ...update } as PdfAnnotationDef;
4154-
const converted = convertFromModelCoords(merged, pageHeight);
4155-
const convertedUpdate = { ...update } as Record<string, unknown> &
4156-
typeof update;
4157-
for (const key of Object.keys(update)) {
4158-
convertedUpdate[key] = (
4159-
converted as unknown as Record<string, unknown>
4160-
)[key];
4161-
}
4162-
updateAnnotation(
4163-
convertedUpdate as Partial<PdfAnnotationDef> & {
4164-
id: string;
4165-
type: string;
4166-
},
4167-
);
4170+
// The model sends model coords (y-down, y = top-left). existing.def
4171+
// is internal coords (y-up, y = bottom-left). For rect/circle/image
4172+
// the converted internal y = pageHeight - modelY - height — a function
4173+
// of BOTH y AND height. If the model patches only {height}, we must
4174+
// still rewrite internal y to keep the top fixed; otherwise the
4175+
// bottom stays fixed and the top shifts. Same coupling applies to
4176+
// {page} changes across differently-sized pages.
4177+
//
4178+
// Fix: round-trip through model space. Convert existing to model
4179+
// coords, spread the patch on top (all-model now), convert back.
4180+
// convertToModelCoords is self-inverse (pdf-annotations.ts:192) so
4181+
// unchanged fields pass through unmolested.
4182+
const srcPageH = await getPageHeight(existing.def.page);
4183+
const existingModel = convertToModelCoords(existing.def, srcPageH);
4184+
const mergedModel = {
4185+
...existingModel,
4186+
...update,
4187+
} as PdfAnnotationDef;
4188+
const dstPageH =
4189+
update.page != null && update.page !== existing.def.page
4190+
? await getPageHeight(update.page)
4191+
: srcPageH;
4192+
const mergedInternal = convertFromModelCoords(mergedModel, dstPageH);
4193+
// Pass the FULL merged def. updateAnnotation() already merges over
4194+
// the tracked def, so passing everything is correct and avoids the
4195+
// "only copy back Object.keys(update)" loop that caused the bug.
4196+
updateAnnotation(mergedInternal);
41684197
}
41694198
break;
41704199
case "remove_annotations":

0 commit comments

Comments
 (0)