/** * PDF Annotation Helpers * * Pure functions for annotation persistence (diff-based model), * color conversion, and PDF annotation dict creation using pdf-lib. * * The diff-based model stores only changes relative to the PDF's * native annotations: additions, removals, and modifications. * This keeps localStorage small and preserves round-trip fidelity. */ import { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString, StandardFonts, PDFTextField, PDFCheckBox, PDFDropdown, PDFOptionList, PDFRadioGroup, type PDFForm, } from "@cantoo/pdf-lib"; // ============================================================================= // Types // ============================================================================= export interface Rect { x: number; y: number; width: number; height: number; } export interface AnnotationBase { id: string; page: number; } export interface HighlightAnnotation extends AnnotationBase { type: "highlight"; rects: Rect[]; color?: string; content?: string; } export interface UnderlineAnnotation extends AnnotationBase { type: "underline"; rects: Rect[]; color?: string; } export interface StrikethroughAnnotation extends AnnotationBase { type: "strikethrough"; rects: Rect[]; color?: string; } export interface NoteAnnotation extends AnnotationBase { type: "note"; x: number; y: number; content: string; color?: string; } export interface RectangleAnnotation extends AnnotationBase { type: "rectangle"; x: number; y: number; width: number; height: number; color?: string; fillColor?: string; rotation?: number; } export interface CircleAnnotation extends AnnotationBase { type: "circle"; x: number; y: number; width: number; height: number; color?: string; fillColor?: string; } export interface LineAnnotation extends AnnotationBase { type: "line"; x1: number; y1: number; x2: number; y2: number; color?: string; } export interface FreetextAnnotation extends AnnotationBase { type: "freetext"; x: number; y: number; content: string; fontSize?: number; color?: string; } export interface StampAnnotation extends AnnotationBase { type: "stamp"; x: number; y: number; label: string; color?: string; rotation?: number; } export interface ImageAnnotation extends AnnotationBase { type: "image"; x: number; y: number; width: number; height: number; imageData?: string; imageUrl?: string; mimeType?: string; rotation?: number; 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 | StrikethroughAnnotation | NoteAnnotation | RectangleAnnotation | CircleAnnotation | LineAnnotation | FreetextAnnotation | StampAnnotation | ImageAnnotation | ImportedAnnotation; // ============================================================================= // Coordinate Conversion (model ↔ internal PDF coords) // ============================================================================= /** * Convert annotation coordinates from model space (top-left origin, Y↓) * to internal PDF space (bottom-left origin, Y↑). * * Call this when receiving coordinates from the model via add/update_annotations. */ export function convertFromModelCoords( def: PdfAnnotationDef, pageHeight: number, ): PdfAnnotationDef { switch (def.type) { case "highlight": case "underline": case "strikethrough": return { ...def, rects: def.rects.map((r) => ({ ...r, y: pageHeight - r.y - r.height, })), }; case "note": case "freetext": case "stamp": return { ...def, y: pageHeight - def.y }; case "rectangle": case "circle": case "image": case "imported": return { ...def, y: pageHeight - def.y - def.height }; case "line": return { ...def, y1: pageHeight - def.y1, y2: pageHeight - def.y2, }; } } /** * Convert annotation coordinates from internal PDF space (bottom-left origin, Y↑) * to model space (top-left origin, Y↓). * * Call this when presenting coordinates to the model (e.g. in context strings). */ export function convertToModelCoords( def: PdfAnnotationDef, pageHeight: number, ): PdfAnnotationDef { // The conversion is its own inverse (same formula flips both ways) return convertFromModelCoords(def, pageHeight); } // ============================================================================= // Diff-Based Persistence Model // ============================================================================= /** * Represents changes relative to the PDF's native annotations. * Only this diff is stored in localStorage, keeping it small. */ export interface AnnotationDiff { /** Annotations created by the user (not in the original PDF) */ added: PdfAnnotationDef[]; /** PDF annotation ref strings that the user deleted */ removed: string[]; /** Form field values the user filled in */ formFields: Record; } /** Create an empty diff */ export function emptyDiff(): AnnotationDiff { return { added: [], removed: [], formFields: {} }; } /** Check if a diff has any changes */ export function isDiffEmpty(diff: AnnotationDiff): boolean { return ( diff.added.length === 0 && diff.removed.length === 0 && Object.keys(diff.formFields).length === 0 ); } /** Serialize diff to JSON string for localStorage */ export function serializeDiff(diff: AnnotationDiff): string { return JSON.stringify(diff); } /** Deserialize diff from JSON string. Returns empty diff on error. */ export function deserializeDiff(json: string): AnnotationDiff { try { const parsed = JSON.parse(json); return { added: Array.isArray(parsed.added) ? parsed.added : [], removed: Array.isArray(parsed.removed) ? parsed.removed : [], formFields: parsed.formFields && typeof parsed.formFields === "object" ? parsed.formFields : {}, }; } catch { return emptyDiff(); } } /** * Merge PDF-native annotations with user diff to produce the final annotation set. * * @param pdfAnnotations - Annotations imported from the PDF file * @param diff - User's local changes (additions, removals) * @returns Merged annotation list */ export function mergeAnnotations( pdfAnnotations: PdfAnnotationDef[], diff: AnnotationDiff, ): PdfAnnotationDef[] { const removedSet = new Set(diff.removed); // Start with PDF annotations, filtering out removed ones const merged = pdfAnnotations.filter((a) => !removedSet.has(a.id)); // Add user-created annotations // If an added annotation has the same ID as a PDF annotation, the added one wins const addedIds = new Set(diff.added.map((a) => a.id)); const result = merged.filter((a) => !addedIds.has(a.id)); result.push(...diff.added); return result; } /** * Compute a diff given the PDF-native annotations and the current full set. * * @param pdfAnnotations - Original annotations from the PDF * @param currentAnnotations - Current full annotation set (after user edits) * @param formFields - Current form field values * @returns The diff to persist */ export function computeDiff( pdfAnnotations: PdfAnnotationDef[], currentAnnotations: PdfAnnotationDef[], formFields: Map, baselineFormFields?: Map, ): AnnotationDiff { const pdfIds = new Set(pdfAnnotations.map((a) => a.id)); const currentIds = new Set(currentAnnotations.map((a) => a.id)); // Added: in current but not in PDF const added = currentAnnotations.filter((a) => !pdfIds.has(a.id)); // Removed: in PDF but not in current const removed = pdfAnnotations .filter((a) => !currentIds.has(a.id)) .map((a) => a.id); // Form fields: only values that differ from what's already in the PDF. // Without a baseline, every filled field is a user edit (back-compat). const formFieldsObj: Record = {}; for (const [k, v] of formFields) { if (baselineFormFields?.get(k) === v) continue; formFieldsObj[k] = v; } // Fields present in baseline but cleared in current are also a change if (baselineFormFields) { for (const [k, v] of baselineFormFields) { if (!formFields.has(k) && v !== "" && v !== false) { formFieldsObj[k] = formFields.get(k) ?? ""; } } } return { added, removed, formFields: formFieldsObj }; } // ============================================================================= // Color Conversion // ============================================================================= /** * Parse a CSS color string to normalized RGB values (0-1 range). * Supports hex (#rgb, #rrggbb, #rrggbbaa) and rgb()/rgba() notation. */ export function cssColorToRgb( color: string, ): { r: number; g: number; b: number } | null { // Parse hex colors const hex = color.match(/^#([0-9a-f]{3,8})$/i); if (hex) { let h = hex[1]; if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; return { r: parseInt(h.slice(0, 2), 16) / 255, g: parseInt(h.slice(2, 4), 16) / 255, b: parseInt(h.slice(4, 6), 16) / 255, }; } // Parse rgb/rgba const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if (rgbMatch) { return { r: parseInt(rgbMatch[1]) / 255, g: parseInt(rgbMatch[2]) / 255, b: parseInt(rgbMatch[3]) / 255, }; } return null; } /** Default colors for each annotation type */ export function defaultColor(type: PdfAnnotationDef["type"]): string { switch (type) { case "highlight": return "#ffff00"; case "underline": case "strikethrough": return "#ff0000"; case "note": return "#f5a623"; case "rectangle": case "circle": return "#0066cc"; case "line": return "#333333"; case "freetext": return "#333333"; case "stamp": return "#cc0000"; case "image": case "imported": return "#00000000"; } } // ============================================================================= // PDF Annotation Dict Creation (pdf-lib low-level API) // ============================================================================= /** * Create a PDF color array [r, g, b] from a CSS color string. * Falls back to the default color for the annotation type. */ function makePdfColor( context: PDFDocument["context"], cssColor: string | undefined, annotType: PdfAnnotationDef["type"], ): PDFArray { const rgb = cssColorToRgb(cssColor || defaultColor(annotType)); const { r, g, b } = rgb || { r: 0, g: 0, b: 0 }; const arr = PDFArray.withContext(context); arr.push(PDFNumber.of(r)); arr.push(PDFNumber.of(g)); arr.push(PDFNumber.of(b)); return arr; } /** Create a PDF /Rect array [x1, y1, x2, y2] */ function makePdfRect( context: PDFDocument["context"], x: number, y: number, width: number, height: number, ): PDFArray { const arr = PDFArray.withContext(context); arr.push(PDFNumber.of(x)); arr.push(PDFNumber.of(y)); arr.push(PDFNumber.of(x + width)); arr.push(PDFNumber.of(y + height)); return arr; } /** * Create /QuadPoints array for markup annotations (Highlight, Underline, StrikeOut). * Each rect → 8 numbers: x1,y1 (top-left), x2,y2 (top-right), x3,y3 (bottom-left), x4,y4 (bottom-right) * PDF spec order: top-left, top-right, bottom-left, bottom-right */ function makeQuadPoints( context: PDFDocument["context"], rects: Rect[], ): PDFArray { const arr = PDFArray.withContext(context); for (const r of rects) { const x1 = r.x; const y1 = r.y; const x2 = r.x + r.width; const y2 = r.y + r.height; // QuadPoints order: top-left, top-right, bottom-left, bottom-right arr.push(PDFNumber.of(x1)); arr.push(PDFNumber.of(y2)); // top-left arr.push(PDFNumber.of(x2)); arr.push(PDFNumber.of(y2)); // top-right arr.push(PDFNumber.of(x1)); arr.push(PDFNumber.of(y1)); // bottom-left arr.push(PDFNumber.of(x2)); arr.push(PDFNumber.of(y1)); // bottom-right } return arr; } /** Compute bounding box of an array of rects */ function boundingBox(rects: Rect[]): { x: number; y: number; w: number; h: number; } { if (rects.length === 0) return { x: 0, y: 0, w: 0, h: 0 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const r of rects) { minX = Math.min(minX, r.x); minY = Math.min(minY, r.y); maxX = Math.max(maxX, r.x + r.width); maxY = Math.max(maxY, r.y + r.height); } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; } /** * Detect image MIME type from magic bytes. */ function detectImageMimeType(bytes: Uint8Array): "image/png" | "image/jpeg" { // PNG magic: 0x89 0x50 0x4E 0x47 if ( bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 ) { return "image/png"; } // JPEG magic: 0xFF 0xD8 return "image/jpeg"; } /** * Add proper PDF annotation objects to a pdf-lib PDFDocument. * Creates /Type /Annot dictionaries with correct /Subtype for each annotation type. * These are real PDF annotations, editable in Acrobat/Preview. */ export async function addAnnotationDicts( pdfDoc: PDFDocument, annotations: PdfAnnotationDef[], ): Promise { const context = pdfDoc.context; 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]; const dict = PDFDict.withContext(context); dict.set(PDFName.of("Type"), PDFName.of("Annot")); // Set /F (flags) = 4 (Print) so annotations print dict.set(PDFName.of("F"), PDFNumber.of(4)); const color = makePdfColor( context, "color" in def ? def.color : undefined, def.type, ); switch (def.type) { case "highlight": case "underline": case "strikethrough": { const subtypeMap = { highlight: "Highlight", underline: "Underline", strikethrough: "StrikeOut", }; dict.set(PDFName.of("Subtype"), PDFName.of(subtypeMap[def.type])); const bb = boundingBox(def.rects); dict.set( PDFName.of("Rect"), makePdfRect(context, bb.x, bb.y, bb.w, bb.h), ); dict.set(PDFName.of("QuadPoints"), makeQuadPoints(context, def.rects)); dict.set(PDFName.of("C"), color); if (def.type === "highlight" && "content" in def && def.content) { dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.content)); } break; } case "note": { dict.set(PDFName.of("Subtype"), PDFName.of("Text")); // Note icon is 24x24 points dict.set( PDFName.of("Rect"), makePdfRect(context, def.x, def.y - 24, 24, 24), ); dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.content)); dict.set(PDFName.of("C"), color); dict.set(PDFName.of("Name"), PDFName.of("Note")); // Open = false (collapsed by default) dict.set(PDFName.of("Open"), context.obj(false)); break; } case "rectangle": case "circle": { dict.set( PDFName.of("Subtype"), PDFName.of(def.type === "rectangle" ? "Square" : "Circle"), ); dict.set( PDFName.of("Rect"), makePdfRect(context, def.x, def.y, def.width, def.height), ); dict.set(PDFName.of("C"), color); // Border style const bs = PDFDict.withContext(context); bs.set(PDFName.of("Type"), PDFName.of("Border")); bs.set(PDFName.of("W"), PDFNumber.of(2)); bs.set(PDFName.of("S"), PDFName.of("S")); // Solid dict.set(PDFName.of("BS"), bs); if (def.fillColor) { const fc = cssColorToRgb(def.fillColor); if (fc) { const icArr = PDFArray.withContext(context); icArr.push(PDFNumber.of(fc.r)); icArr.push(PDFNumber.of(fc.g)); icArr.push(PDFNumber.of(fc.b)); dict.set(PDFName.of("IC"), icArr); } } break; } case "line": { dict.set(PDFName.of("Subtype"), PDFName.of("Line")); // Rect is bounding box of the line const lx = Math.min(def.x1, def.x2); const ly = Math.min(def.y1, def.y2); const lw = Math.abs(def.x2 - def.x1); const lh = Math.abs(def.y2 - def.y1); dict.set( PDFName.of("Rect"), makePdfRect(context, lx, ly, lw || 1, lh || 1), ); // Line endpoints const lineArr = PDFArray.withContext(context); lineArr.push(PDFNumber.of(def.x1)); lineArr.push(PDFNumber.of(def.y1)); lineArr.push(PDFNumber.of(def.x2)); lineArr.push(PDFNumber.of(def.y2)); dict.set(PDFName.of("L"), lineArr); dict.set(PDFName.of("C"), color); break; } case "freetext": { dict.set(PDFName.of("Subtype"), PDFName.of("FreeText")); const fontSize = def.fontSize || 12; // Estimate text dimensions const textWidth = def.content.length * fontSize * 0.6; const textHeight = fontSize * 1.4; dict.set( PDFName.of("Rect"), makePdfRect( context, def.x, def.y - textHeight, textWidth, textHeight, ), ); dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.content)); // Default appearance string (DA) — required for FreeText const rgb = cssColorToRgb(def.color || defaultColor("freetext")); const { r, g, b } = rgb || { r: 0, g: 0, b: 0 }; dict.set( PDFName.of("DA"), PDFString.of(`${r} ${g} ${b} rg /Helv ${fontSize} Tf`), ); break; } case "stamp": { dict.set(PDFName.of("Subtype"), PDFName.of("Stamp")); const fontSize = 24; // Match CSS padding: 4px top/bottom, 12px left/right const padX = 12; const padY = 4; dict.set(PDFName.of("C"), color); // Use a non-standard /Name so viewers like Preview.app don't // substitute their own built-in stamp graphic dict.set(PDFName.of("Name"), PDFName.of("#custom")); dict.set(PDFName.of("Contents"), PDFHexString.fromText(def.label)); // Create a simple appearance stream so the stamp text is visible const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const actualTextWidth = font.widthOfTextAtSize(def.label, fontSize); const apRectW = actualTextWidth + padX * 2; const apRectH = fontSize + padY * 2; // Set rect to match appearance stream dimensions dict.set( PDFName.of("Rect"), makePdfRect(context, def.x, def.y - apRectH, apRectW, apRectH), ); // Build appearance stream content matching the CSS rendering: // - 3pt border stroke // - 0.6 opacity // - text baseline positioned to visually center in the box const rgb = cssColorToRgb(def.color || defaultColor("stamp")); const { r, g, b } = rgb || { r: 0.8, g: 0, b: 0 }; // Font descent ≈ 20% of font size for Helvetica const descent = fontSize * 0.2; const textBaselineY = padY + descent; const streamContent = [ `/GS0 gs`, // match CSS opacity: 0.6 `${r} ${g} ${b} RG`, // stroke color `${r} ${g} ${b} rg`, // fill color `3 w`, // line width (matches CSS border: 3px) `1.5 1.5 ${apRectW - 3} ${apRectH - 3} re S`, // border rect inset by half line width `BT`, `/F1 ${fontSize} Tf`, `${padX} ${textBaselineY} Td`, `(${def.label.replace(/[()\\]/g, "\\$&")}) Tj`, `ET`, ].join("\n"); // Compute rotation matrix if specified let rotationMatrix: number[] | undefined; if (def.rotation) { const rad = (def.rotation * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); // Rotation matrix around the center of the bounding box const cx = apRectW / 2; const cy = apRectH / 2; // Translate to origin, rotate, translate back: [cos sin -sin cos tx ty] const tx = cx - cos * cx + sin * cy; const ty = cy - sin * cx - cos * cy; rotationMatrix = [cos, sin, -sin, cos, tx, ty]; } // Create ExtGState for opacity (ca = fill opacity, CA = stroke opacity) const gsDict = PDFDict.withContext(context); gsDict.set(PDFName.of("Type"), PDFName.of("ExtGState")); gsDict.set(PDFName.of("ca"), PDFNumber.of(0.6)); gsDict.set(PDFName.of("CA"), PDFNumber.of(0.6)); const gsRef = context.register(gsDict); // Build Resources dict with Font and ExtGState const stampResDict = PDFDict.withContext(context); const fontResDict = PDFDict.withContext(context); fontResDict.set(PDFName.of("F1"), font.ref); stampResDict.set(PDFName.of("Font"), fontResDict); const gsResDict = PDFDict.withContext(context); gsResDict.set(PDFName.of("GS0"), gsRef); stampResDict.set(PDFName.of("ExtGState"), gsResDict); // Create the appearance stream const apStream = context.flateStream(streamContent, { Type: "XObject", Subtype: "Form", BBox: [0, 0, apRectW, apRectH], ...(rotationMatrix ? { Matrix: rotationMatrix } : {}), }); // Attach Resources to the stream dict const apStreamDict = (apStream as any).dict || apStream; apStreamDict.set(PDFName.of("Resources"), stampResDict); const apRef = context.register(apStream); // Create AP dictionary const apDict = PDFDict.withContext(context); apDict.set(PDFName.of("N"), apRef); dict.set(PDFName.of("AP"), apDict); break; } case "image": { dict.set(PDFName.of("Subtype"), PDFName.of("Stamp")); dict.set( PDFName.of("Rect"), makePdfRect(context, def.x, def.y, def.width, def.height), ); dict.set(PDFName.of("Name"), PDFName.of("Image")); if (def.imageData) { // Detect mime type from magic bytes or use provided mimeType const imgBytes = base64ToUint8Array(def.imageData); const mime = def.mimeType || detectImageMimeType(imgBytes); const embeddedImage = mime === "image/jpeg" ? await pdfDoc.embedJpg(imgBytes) : await pdfDoc.embedPng(imgBytes); const imgW = def.width; const imgH = def.height; // Build appearance stream that draws the image const streamContent = `q ${imgW} 0 0 ${imgH} 0 0 cm /Img Do Q`; // Compute rotation matrix if specified let rotationMatrix: number[] | undefined; if (def.rotation) { const rad = (def.rotation * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const cx = imgW / 2; const cy = imgH / 2; const tx = cx - cos * cx + sin * cy; const ty = cy - sin * cx - cos * cy; rotationMatrix = [cos, sin, -sin, cos, tx, ty]; } // Create Resources dict with XObject containing the image const xObjDict = PDFDict.withContext(context); xObjDict.set(PDFName.of("Img"), embeddedImage.ref); const resDict = PDFDict.withContext(context); resDict.set(PDFName.of("XObject"), xObjDict); const apStream = context.flateStream(streamContent, { Type: "XObject", Subtype: "Form", BBox: [0, 0, imgW, imgH], ...(rotationMatrix ? { Matrix: rotationMatrix } : {}), }); // Attach Resources to the stream dict const apStreamDict = (apStream as any).dict || apStream; apStreamDict.set(PDFName.of("Resources"), resDict); const apRef = context.register(apStream); const apDict = PDFDict.withContext(context); apDict.set(PDFName.of("N"), apRef); dict.set(PDFName.of("AP"), apDict); } break; } } // Register the annotation dict and add to page const annotRef = context.register(dict); page.node.addAnnot(annotRef); } } /** * Select a radio-style button group by widget on-value, bypassing pdf-lib's * type-level guards. Used when pdf-lib classifies a radio as `PDFCheckBox` * (PDF lacks the /Ff Radio bit) — `check()` would always pick the first * widget. Mirrors `PDFAcroRadioButton.setValue` minus its `onValues` throw. */ function setButtonGroupValue( field: PDFCheckBox | PDFRadioGroup, onValue: string, ): void { const acro = field.acroField; const off = PDFName.of("Off"); const widgets = acro.getWidgets(); // Match by PDFName identity (pdf-lib interns names) — the viewer stored // pdf.js's buttonValue, which IS the widget's /AP /N on-state name. let target = onValue && onValue !== "Off" ? PDFName.of(onValue) : off; if ( target !== off && !widgets.some((w: { getOnValue(): PDFName | undefined }) => { return w.getOnValue() === target; }) ) { // No widget has this on-state — leave as-is rather than corrupt /V. return; } acro.dict.set(PDFName.of("V"), target); for (const w of widgets) { const on = (w as { getOnValue(): PDFName | undefined }).getOnValue(); (w as { setAppearanceState(s: PDFName): void }).setAppearanceState( on === target ? target : off, ); } } /** * Recover the PDF object reference from an annotation id assigned by * `makeAnnotationId`. Handles both `pdf--` (from `ann.ref`) and * `pdf-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, removes baseline annotations the * user deleted, returns Uint8Array of the new PDF. */ export async function buildAnnotatedPdfBytes( pdfBytes: Uint8Array, annotations: PdfAnnotationDef[], formFields: Map, removedRefs: { objectNumber: number; generationNumber: number }[] = [], ): Promise { 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); // Apply form fills. Dispatch on actual field type — getTextField(name) throws // for dropdowns/radios, so we look up the generic field and instanceof it. // Each field is wrapped in its own try/catch: pdf-lib can throw on // length-constrained text, radios whose buttonValue maps to neither label // nor index, checkboxes missing a /Yes appearance, etc. One bad field must // not abort the rest of the loop (regressed in #577 when the inner catch // was dropped along with the type-specific getters). if (formFields.size > 0) { let form: PDFForm | undefined; try { form = pdfDoc.getForm(); } catch { // No AcroForm in this PDF } if (form) { for (const [name, value] of formFields) { try { const field = form.getFieldMaybe(name); if (!field) continue; if (field instanceof PDFCheckBox) { const widgets = field.acroField.getWidgets(); if (typeof value === "string" && widgets.length > 1) { // Multi-widget "checkbox" with a string value = pdf-lib // misclassified a radio group (PDF lacks the /Ff Radio flag). // The viewer stored pdf.js's buttonValue (the widget's on-state // name, e.g. "0"/"1"); check()/uncheck() would hit the FIRST // widget regardless. Write /V and per-widget /AS directly. setButtonGroupValue(field, value); } else { // Single-widget (real) checkbox. Viewer normally stores boolean, // but be liberal: any truthy non-"Off" string counts as checked. const on = value === true || (typeof value === "string" && value !== "" && value !== "Off"); if (on) field.check(); else field.uncheck(); } } else if (field instanceof PDFRadioGroup) { // The viewer stores pdf.js's buttonValue, which for PDFs with an // /Opt array is a numeric index ("0","1","2") rather than the // option label pdf-lib's select() expects. Try the label first, // then fall back to indexing into getOptions(). const opts = field.getOptions(); const s = String(value); if (opts.includes(s)) { field.select(s); } else { const idx = Number(s); if (Number.isInteger(idx) && idx >= 0 && idx < opts.length) { field.select(opts[idx]); } // else: value is neither label nor index — leave unset } } else if (field instanceof PDFDropdown) { // select() auto-enables edit mode for values outside getOptions(), // so this works for both enumerated and free-text combos. field.select(String(value)); } else if (field instanceof PDFOptionList) { // Viewer stores multiselect listboxes as a comma-joined string // (pdf.js's annotationStorage value for /Ch is string|string[]; // our Map flattens arrays). Only select // values that are actually in the option list — pdf-lib throws // on unknowns and there's no edit-mode fallback like Dropdown. const opts = field.getOptions(); const wanted = String(value) .split(",") .map((s) => s.trim()) .filter((s) => opts.includes(s)); if (wanted.length > 0) field.select(wanted); else field.clear(); } else if (field instanceof PDFTextField) { field.setText(String(value)); } // PDFButton, PDFSignature: no fill_form support yet } catch (err) { // Skip this field; carry on with the rest. Surfacing per-field // failures is the caller's job (see fill_form result), not save's. console.warn(`buildAnnotatedPdfBytes: skipped field "${name}":`, err); } } // Some PDFs ship widgets with no on-state appearance stream (no // /AP/N/). pdf-lib's check()/select()/setText() set /V and /AS // but don't synthesize the missing appearance, so other readers // (Preview, Acrobat) show the field as if unchanged. This generates a // default appearance for any field marked dirty above. try { form.updateFieldAppearances(); } catch (err) { console.warn("buildAnnotatedPdfBytes: updateFieldAppearances:", err); } } } return pdfDoc.save(); } // ============================================================================= // PDF.js Annotation Import // ============================================================================= /** * PDF.js annotation type constants (from AnnotationType enum). * We only import types we support. */ const PDFJS_TYPE_MAP: Record = { 1: "note", // TEXT 3: "freetext", // FREETEXT 4: "line", // LINE 5: "rectangle", // SQUARE 6: "circle", // CIRCLE 9: "highlight", // HIGHLIGHT 10: "underline", // UNDERLINE 12: "strikethrough", // STRIKEOUT 13: "stamp", // STAMP }; /** * Convert a PDF.js annotation color array [r, g, b] (0-255) to CSS hex string. */ function pdfjsColorToHex( color: Uint8ClampedArray | number[] | null | undefined, ): string | undefined { if (!color || color.length < 3) return undefined; const r = Math.round(color[0]); const g = Math.round(color[1]); const b = Math.round(color[2]); const hex = ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0"); return `#${hex}`; } /** * Convert a PDF.js annotation rect [x1, y1, x2, y2] to our Rect format. */ function pdfjsRectToRect(rect: number[]): Rect { return { x: Math.min(rect[0], rect[2]), y: Math.min(rect[1], rect[3]), width: Math.abs(rect[2] - rect[0]), height: Math.abs(rect[3] - rect[1]), }; } /** * Build a stable annotation ID from pdf.js annotation data. * Uses the annotation's ref (PDF object reference) if available, * otherwise falls back to page + index. */ function makeAnnotationId( // eslint-disable-next-line @typescript-eslint/no-explicit-any ann: any, pageNum: number, index: number, ): string { if (ann.ref) { return `pdf-${ann.ref.num}-${ann.ref.gen}`; } if (ann.id) { return `pdf-${ann.id}`; } return `pdf-${pageNum}-${index}`; } /** * Convert a single PDF.js annotation object to our PdfAnnotationDef format. * Returns null for unsupported annotation types. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function importPdfjsAnnotation( ann: any, pageNum: number, index: number, ): PdfAnnotationDef | 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": case "underline": case "strikethrough": { // pdf.js emits quadPoints as a FLAT Float32Array [x1,y1,...,x4,y4, …] // (8 numbers per quad), NOT as nested arrays. Iterating it yields // numbers, so the old `for (const qp of …) if (qp.length>=8)` never // matched and every quad-based annotation was dropped (#506). const rects: Rect[] = []; const qp = ann.quadPoints as ArrayLike | undefined; if (qp && qp.length >= 8) { for (let i = 0; i + 8 <= qp.length; i += 8) { const xs = [qp[i], qp[i + 2], qp[i + 4], qp[i + 6]]; const ys = [qp[i + 1], qp[i + 3], qp[i + 5], qp[i + 7]]; const minX = Math.min(...xs); const minY = Math.min(...ys); rects.push({ x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY, }); } } if (rects.length === 0 && ann.rect) { rects.push(pdfjsRectToRect(ann.rect)); } if (rects.length === 0) return null; const base = { id, page: pageNum, rects, color }; if (ourType === "highlight") { return { ...base, type: "highlight", content: ann.contentsObj?.str || ann.contents || undefined, }; } return { ...base, type: ourType } as PdfAnnotationDef; } case "note": { if (!ann.rect) return null; const rect = pdfjsRectToRect(ann.rect); return { type: "note", id, page: pageNum, x: rect.x, y: rect.y + rect.height, // PDF.js rect y is bottom; note uses top point content: ann.contentsObj?.str || ann.contents || "", color, }; } case "rectangle": case "circle": { if (!ann.rect) return null; const rect = pdfjsRectToRect(ann.rect); return { type: ourType, id, page: pageNum, x: rect.x, y: rect.y, width: rect.width, height: rect.height, color, } as PdfAnnotationDef; } case "line": { // PDF.js LineAnnotation provides lineCoordinates [x1, y1, x2, y2] if (ann.lineCoordinates && ann.lineCoordinates.length >= 4) { return { type: "line", id, page: pageNum, x1: ann.lineCoordinates[0], y1: ann.lineCoordinates[1], x2: ann.lineCoordinates[2], y2: ann.lineCoordinates[3], color, }; } if (!ann.rect) return null; const lineRect = pdfjsRectToRect(ann.rect); return { type: "line", id, page: pageNum, x1: lineRect.x, y1: lineRect.y, x2: lineRect.x + lineRect.width, y2: lineRect.y + lineRect.height, color, }; } case "freetext": { if (!ann.rect) return null; const rect = pdfjsRectToRect(ann.rect); return { type: "freetext", id, page: pageNum, x: rect.x, y: rect.y + rect.height, content: ann.contentsObj?.str || ann.contents || "", fontSize: ann.fontSize || 12, color, }; } case "stamp": { if (!ann.rect) return null; const rect = pdfjsRectToRect(ann.rect); return { type: "stamp", id, page: pageNum, x: rect.x, y: rect.y + rect.height, label: ann.name || ann.contentsObj?.str || ann.contents || "STAMP", color, }; } } return null; } /** * Convert base64 string to Uint8Array. */ export function base64ToUint8Array(base64: string): Uint8Array { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } /** * Convert Uint8Array to base64 string. */ export function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }