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.
This commit is contained in:
parent
3af019144f
commit
61fe39b4db
1 changed files with 505 additions and 31 deletions
|
|
@ -130,6 +130,32 @@ export interface ImageAnnotation extends AnnotationBase {
|
|||
aspect?: "preserve" | "ignore";
|
||||
}
|
||||
|
||||
/**
|
||||
* An annotation that already exists in the loaded PDF and that we render
|
||||
* verbatim from its appearance stream (via pdf.js's annotationCanvasMap)
|
||||
* rather than re-modeling it with one of our shape types.
|
||||
*
|
||||
* Covers two cases:
|
||||
* - subtypes we don't model (Ink, Polygon, Caret, FileAttachment, …)
|
||||
* - subtypes we *could* model but whose appearance carries information
|
||||
* our model would drop (e.g. Stamp with an image signature)
|
||||
*
|
||||
* The rasterized canvas is supplied at render time by mcp-app.ts; this
|
||||
* struct only carries placement and identity. Coords are PDF user-space
|
||||
* (origin bottom-left), matching the other rect-shaped types.
|
||||
*/
|
||||
export interface ImportedAnnotation extends AnnotationBase {
|
||||
type: "imported";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** pdf.js getAnnotations() id (e.g. "118R") — key into annotationCanvasMap. */
|
||||
pdfjsId: string;
|
||||
/** Original PDF /Subtype (e.g. "Stamp", "Ink") for the panel label. */
|
||||
subtype: string;
|
||||
}
|
||||
|
||||
export type PdfAnnotationDef =
|
||||
| HighlightAnnotation
|
||||
| UnderlineAnnotation
|
||||
|
|
@ -140,7 +166,8 @@ export type PdfAnnotationDef =
|
|||
| LineAnnotation
|
||||
| FreetextAnnotation
|
||||
| StampAnnotation
|
||||
| ImageAnnotation;
|
||||
| ImageAnnotation
|
||||
| ImportedAnnotation;
|
||||
|
||||
// =============================================================================
|
||||
// Coordinate Conversion (model ↔ internal PDF coords)
|
||||
|
|
@ -174,6 +201,7 @@ export function convertFromModelCoords(
|
|||
case "rectangle":
|
||||
case "circle":
|
||||
case "image":
|
||||
case "imported":
|
||||
return { ...def, y: pageHeight - def.y - def.height };
|
||||
case "line":
|
||||
return {
|
||||
|
|
@ -374,6 +402,7 @@ export function defaultColor(type: PdfAnnotationDef["type"]): string {
|
|||
case "stamp":
|
||||
return "#cc0000";
|
||||
case "image":
|
||||
case "imported":
|
||||
return "#00000000";
|
||||
}
|
||||
}
|
||||
|
|
@ -495,6 +524,11 @@ export async function addAnnotationDicts(
|
|||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const def of annotations) {
|
||||
// "imported" annotations are already in the source PDF — never
|
||||
// re-serialize them. Deletion is handled by buildAnnotatedPdfBytes'
|
||||
// removedRefs strip; moving an imported annotation is still UI-only.
|
||||
if (def.type === "imported") continue;
|
||||
|
||||
const pageIdx = def.page - 1;
|
||||
if (pageIdx < 0 || pageIdx >= pages.length) continue;
|
||||
const page = pages[pageIdx];
|
||||
|
|
@ -836,17 +870,62 @@ function setButtonGroupValue(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the PDF object reference from an annotation id assigned by
|
||||
* `makeAnnotationId`. Handles both `pdf-<num>-<gen>` (from `ann.ref`) and
|
||||
* `pdf-<num>R` (from pdf.js's string `ann.id`, gen always 0). Returns null for
|
||||
* page-index fallback ids — those have no stable ref to remove by.
|
||||
*/
|
||||
export function parseAnnotationRef(
|
||||
id: string,
|
||||
): { objectNumber: number; generationNumber: number } | null {
|
||||
let m = /^pdf-(\d+)-(\d+)$/.exec(id);
|
||||
if (m) return { objectNumber: +m[1], generationNumber: +m[2] };
|
||||
m = /^pdf-(\d+)R$/.exec(id);
|
||||
if (m) return { objectNumber: +m[1], generationNumber: 0 };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build annotated PDF bytes from the original document.
|
||||
* Applies user annotations and form fills, returns Uint8Array of the new PDF.
|
||||
* Applies user annotations and form fills, removes baseline annotations the
|
||||
* user deleted, returns Uint8Array of the new PDF.
|
||||
*/
|
||||
export async function buildAnnotatedPdfBytes(
|
||||
pdfBytes: Uint8Array,
|
||||
annotations: PdfAnnotationDef[],
|
||||
formFields: Map<string, string | boolean>,
|
||||
removedRefs: { objectNumber: number; generationNumber: number }[] = [],
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
||||
|
||||
// Strip baseline annotations the user deleted. We match on object number +
|
||||
// generation against each page's /Annots array entries (PDFRef). We don't
|
||||
// know which page they were on, so scan every page — /Annots arrays are
|
||||
// small and this only runs at save time.
|
||||
if (removedRefs.length > 0) {
|
||||
const wanted = new Set(
|
||||
removedRefs.map((r) => `${r.objectNumber} ${r.generationNumber}`),
|
||||
);
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const annots = page.node.Annots();
|
||||
if (!annots) continue;
|
||||
// Walk backwards so .remove(idx) doesn't shift unprocessed entries.
|
||||
for (let i = annots.size() - 1; i >= 0; i--) {
|
||||
const ref = annots.get(i) as {
|
||||
objectNumber?: number;
|
||||
generationNumber?: number;
|
||||
};
|
||||
if (
|
||||
ref?.objectNumber !== undefined &&
|
||||
wanted.has(`${ref.objectNumber} ${ref.generationNumber ?? 0}`)
|
||||
) {
|
||||
annots.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add proper PDF annotation objects
|
||||
await addAnnotationDicts(pdfDoc, annotations);
|
||||
|
||||
|
|
@ -1023,14 +1102,44 @@ export function importPdfjsAnnotation(
|
|||
pageNum: number,
|
||||
index: number,
|
||||
): PdfAnnotationDef | null {
|
||||
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
|
||||
if (!ourType) return null;
|
||||
|
||||
// Skip form widgets (they're handled separately by AnnotationLayer)
|
||||
if (ann.annotationType === 20) return null;
|
||||
// Skip form widgets (they're handled separately by AnnotationLayer) and
|
||||
// auxiliary types that aren't user-visible markup:
|
||||
// 2 = Link (navigational, AnnotationLayer handles the click target)
|
||||
// 16 = Popup (the speech-bubble UI for a parent annotation, not content)
|
||||
if (
|
||||
ann.annotationType === 20 ||
|
||||
ann.annotationType === 2 ||
|
||||
ann.annotationType === 16
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = makeAnnotationId(ann, pageNum, index);
|
||||
const color = pdfjsColorToHex(ann.color);
|
||||
const ourType = PDFJS_TYPE_MAP[ann.annotationType];
|
||||
|
||||
// Anything we don't model — and stamps whose visual is an appearance
|
||||
// stream we can't reproduce as a text label — are kept as "imported":
|
||||
// a placement-only record that the renderer fills with the rasterized
|
||||
// appearance from pdf.js's annotationCanvasMap. This keeps Ink, Polygon,
|
||||
// image-signature stamps, etc. visible AND selectable in our layer.
|
||||
const importAsBitmap =
|
||||
!ourType || (ourType === "stamp" && ann.hasAppearance);
|
||||
if (importAsBitmap) {
|
||||
if (!ann.rect) return null;
|
||||
const r = pdfjsRectToRect(ann.rect);
|
||||
return {
|
||||
type: "imported",
|
||||
id,
|
||||
page: pageNum,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
pdfjsId: String(ann.id ?? ""),
|
||||
subtype: String(ann.subtype ?? `type${ann.annotationType}`),
|
||||
};
|
||||
}
|
||||
|
||||
switch (ourType) {
|
||||
case "highlight":
|
||||
|
|
|
|||
Loading…
Reference in a new issue