From f4d760b13935dcc03a888f9b8af862c15d7f3c00 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 20 Jul 2026 14:05:01 +0200 Subject: [PATCH] fix(sign): preserve PNG signature placement and page content (#7093) # Description of Changes This PR fixes PNG signature application issues in the PDF signing workflow. ## What was changed - Reworked signature application to create locked and printable PDFium stamp annotations with dedicated appearance streams. - Removed the use of `FPDFPage_GenerateContent()` from the signature workflow. - Preserved the signature's original position and dimensions when converting from the viewer's top-left coordinate system to PDF coordinates. - Added CropBox-aware coordinate conversion for PDFs whose visible page origin differs from the MediaBox origin. - Improved signature image extraction to handle internal EmbedPDF asset references and nested image data. - Refactored PDFium bitmap creation so image objects can safely be transferred to annotations. - Corrected PDFium bitmap ownership and cleanup to prevent duplicate destruction. - Added a PDFium WASM integration test covering: - Existing page-content preservation - Stamp appearance generation - Signature coordinates and dimensions - Printable, read-only, and locked annotation flags - Persisted image data taking precedence over internal asset references ## Why the change was made Applying a PNG signature previously regenerated the complete page content through PDFium. This could corrupt existing vector or font-based page elements, including the university logo reported in the linked issue. The previous coordinate conversion also relied only on the page height and did not account for CropBox offsets, allowing the applied signature to move from its preview position. Creating a PDFium stamp annotation with its own appearance stream avoids regenerating existing page content while retaining the selected signature position and size. Closes #7083 --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../src/core/utils/pdfiumBitmapUtils.ts | 156 ++++--- .../core/utils/signatureFlattening.test.ts | 114 +++++ .../src/core/utils/signatureFlattening.ts | 403 ++++++++++-------- 3 files changed, 436 insertions(+), 237 deletions(-) create mode 100644 frontend/editor/src/core/utils/signatureFlattening.test.ts diff --git a/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts b/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts index 95022d80d1..b21eddcd47 100644 --- a/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts +++ b/frontend/editor/src/core/utils/pdfiumBitmapUtils.ts @@ -82,6 +82,82 @@ export interface DecodedImage { height: number; } +function setImageObjectMatrix( + m: WrappedPdfiumModule, + imageObjPtr: number, + pdfX: number, + pdfY: number, + drawWidth: number, + drawHeight: number, +): boolean { + const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); + try { + m.pdfium.setValue(matrixPtr, drawWidth, "float"); + m.pdfium.setValue(matrixPtr + 4, 0, "float"); + m.pdfium.setValue(matrixPtr + 8, 0, "float"); + m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); + m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); + m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); + return m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr); + } finally { + m.pdfium.wasmExports.free(matrixPtr); + } +} + +/** + * Create a PDFium image page object from decoded pixels. + * + * The caller owns the returned object until it is inserted into a page or + * appended to an annotation. Destroy it with FPDFPageObj_Destroy on failure. + */ +export function createBitmapImageObject( + m: WrappedPdfiumModule, + docPtr: number, + pagePtr: number, + image: DecodedImage, + pdfX: number, + pdfY: number, + drawWidth: number, + drawHeight: number, +): number | null { + const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1); + if (!bitmapPtr) return null; + + try { + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + + copyRgbaToBgraHeap( + m, + image.rgba, + bufferPtr, + image.width, + image.height, + stride, + ); + + const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr); + if (!imageObjPtr) return null; + + if (!m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr)) { + m.FPDFPageObj_Destroy(imageObjPtr); + return null; + } + + if ( + !setImageObjectMatrix(m, imageObjPtr, pdfX, pdfY, drawWidth, drawHeight) + ) { + m.FPDFPageObj_Destroy(imageObjPtr); + return null; + } + + return imageObjPtr; + } finally { + // FPDFImageObj_SetBitmap copies the bitmap data into the image object. + m.FPDFBitmap_Destroy(bitmapPtr); + } +} + /** * Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image * page object, position it via an affine matrix, and insert it into the page. @@ -99,70 +175,20 @@ export function embedBitmapImageOnPage( drawWidth: number, drawHeight: number, ): boolean { - const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1); - if (!bitmapPtr) return false; + const imageObjPtr = createBitmapImageObject( + m, + docPtr, + pagePtr, + image, + pdfX, + pdfY, + drawWidth, + drawHeight, + ); + if (!imageObjPtr) return false; - try { - const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); - const stride = m.FPDFBitmap_GetStride(bitmapPtr); - - copyRgbaToBgraHeap( - m, - image.rgba, - bufferPtr, - image.width, - image.height, - stride, - ); - - const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr); - if (!imageObjPtr) return false; - - const setBitmapOk = m.FPDFImageObj_SetBitmap( - pagePtr, - 0, - imageObjPtr, - bitmapPtr, - ); - if (!setBitmapOk) { - m.FPDFPageObj_Destroy(imageObjPtr); - return false; - } - - // -- early-destroy the bitmap; PDFium has copied the pixel data internally - m.FPDFBitmap_Destroy(bitmapPtr); - - // Set affine transform: [a b c d e f] - const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4); - try { - m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a — scaleX - m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b - m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c - m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d — scaleY - m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); // e — translateX - m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); // f — translateY - - if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) { - m.FPDFPageObj_Destroy(imageObjPtr); - return false; - } - } finally { - m.pdfium.wasmExports.free(matrixPtr); - } - - m.FPDFPage_InsertObject(pagePtr, imageObjPtr); - return true; - } finally { - // Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium - // builds but guard anyway. If already destroyed above, the second call - // is harmless because we allow it to be idempotent. - // We use a try-catch to be safe across PDFium WASM builds. - try { - m.FPDFBitmap_Destroy(bitmapPtr); - } catch { - /* already freed */ - } - } + m.FPDFPage_InsertObject(pagePtr, imageObjPtr); + return true; } /** * Draw a simple light-grey rectangle as a placeholder for annotations @@ -206,8 +232,8 @@ export function decodeImageDataUrl( img.onload = () => { try { const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; const ctx = canvas.getContext("2d"); if (!ctx) { resolve(null); diff --git a/frontend/editor/src/core/utils/signatureFlattening.test.ts b/frontend/editor/src/core/utils/signatureFlattening.test.ts new file mode 100644 index 0000000000..cb31b9cd01 --- /dev/null +++ b/frontend/editor/src/core/utils/signatureFlattening.test.ts @@ -0,0 +1,114 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { + PDFArray, + PDFDict, + PDFDocument, + PDFName, + PDFNumber, + PDFRawStream, + decodePDFRawStream, +} from "@cantoo/pdf-lib"; +import { embedSignatureImages } from "@app/utils/signatureFlattening"; + +const ONE_PIXEL_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +beforeAll(async () => { + const wasmPath = path.resolve( + process.cwd(), + "node_modules/@embedpdf/pdfium/dist/pdfium.wasm", + ); + const wasmBytes = await readFile(wasmPath); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Promise.resolve( + new Response(wasmBytes, { + headers: { "Content-Type": "application/wasm" }, + }), + ), + ), + ); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const readPageContentStreams = (document: PDFDocument): string[] => { + const contents = document.getPage(0).node.Contents(); + if (!(contents instanceof PDFArray)) return []; + + const decoder = new TextDecoder(); + const streams: string[] = []; + for (let index = 0; index < contents.size(); index++) { + const stream = contents.lookup(index, PDFRawStream); + streams.push(decoder.decode(decodePDFRawStream(stream).decode())); + } + return streams; +}; + +describe("signatureFlattening", () => { + test("adds a PDFium stamp without regenerating page content", async () => { + const sourceDocument = await PDFDocument.create(); + const sourcePage = sourceDocument.addPage([300, 400]); + const markerStream = sourceDocument.context.stream( + "q\n% ORIGINAL_TYPE3_CONTENT\nQ\n", + ); + sourcePage.node.addContentStream( + sourceDocument.context.register(markerStream), + ); + const sourceBytes = await sourceDocument.save(); + + const outputBytes = await embedSignatureImages( + Uint8Array.from(sourceBytes).buffer, + [ + { + pageIndex: 0, + annotations: [ + { + id: "signature-1", + // EmbedPDF may expose an internal asset reference here after the + // annotation has been placed. The persisted PNG must win. + imageData: "embedpdf-asset-reference", + rect: { + origin: { x: 25, y: 30 }, + size: { width: 120, height: 50 }, + }, + imageSrc: `data:image/png;base64,${ONE_PIXEL_PNG}`, + }, + ], + }, + ], + (id) => + id === "signature-1" + ? `data:image/png;base64,${ONE_PIXEL_PNG}` + : undefined, + async () => ({ + width: 1, + height: 1, + rgba: new Uint8Array([0, 80, 180, 255]), + }), + ); + + const outputDocument = await PDFDocument.load(outputBytes); + const contentStreams = readPageContentStreams(outputDocument); + const annotations = outputDocument.getPage(0).node.Annots(); + + expect(contentStreams).toContain("q\n% ORIGINAL_TYPE3_CONTENT\nQ\n"); + expect(annotations).toBeInstanceOf(PDFArray); + + const stamp = annotations?.lookup(0, PDFDict); + const stampRect = stamp?.lookup(PDFName.of("Rect"), PDFArray); + expect(stamp?.get(PDFName.of("Subtype"))).toEqual(PDFName.of("Stamp")); + expect(stamp?.lookup(PDFName.of("F"), PDFNumber).asNumber()).toBe(196); + expect(stamp?.get(PDFName.of("AP"))).toBeDefined(); + expect( + Array.from({ length: stampRect?.size() ?? 0 }, (_, index) => + stampRect?.lookup(index, PDFNumber).asNumber(), + ), + ).toEqual([25, 320, 145, 370]); + }, 20_000); +}); diff --git a/frontend/editor/src/core/utils/signatureFlattening.ts b/frontend/editor/src/core/utils/signatureFlattening.ts index 56d9d63fef..a6200f52f2 100644 --- a/frontend/editor/src/core/utils/signatureFlattening.ts +++ b/frontend/editor/src/core/utils/signatureFlattening.ts @@ -1,11 +1,15 @@ -// PDFium annotation subtype constants import { - FPDF_ANNOT_INK, - FPDF_ANNOT_LINE, - embedBitmapImageOnPage, - drawPlaceholderRect, + createBitmapImageObject, decodeImageDataUrl, + type DecodedImage, } from "@app/utils/pdfiumBitmapUtils"; +import { + closeDocAndFreeBuffer, + getPdfiumModule, + openRawDocumentSafe, + readEffectivePageBox, + saveRawDocument, +} from "@app/services/pdfiumService"; import { generateThumbnailWithMetadata } from "@app/utils/thumbnailUtils"; import { createChildStub, @@ -18,12 +22,6 @@ import { StirlingFileStub, } from "@app/types/fileContext"; import type { SignatureAPI } from "@app/components/viewer/viewerTypes"; -import { - getPdfiumModule, - openRawDocumentSafe, - closeDocAndFreeBuffer, - saveRawDocument, -} from "@app/services/pdfiumService"; interface MinimalFileContextSelectors { getAllFileIds: () => FileId[]; @@ -75,23 +73,9 @@ export async function flattenSignatures( const pageAnnotations = await signatureApiRef.current.getPageAnnotations(pageIndex); if (pageAnnotations && pageAnnotations.length > 0) { - const sessionAnnotations = pageAnnotations.filter((annotation) => { - const hasStoredImageData = - annotation.id && getImageData(annotation.id); - const hasDirectImageData = - annotation.imageData || - annotation.appearance || - annotation.stampData || - annotation.imageSrc || - annotation.contents || - annotation.data; - return ( - hasStoredImageData || - (hasDirectImageData && - typeof hasDirectImageData === "string" && - hasDirectImageData.startsWith("data:image")) - ); - }); + const sessionAnnotations = pageAnnotations.filter((annotation) => + Boolean(getAnnotationImageData(annotation, getImageData)), + ); if (sessionAnnotations.length > 0) { allAnnotations.push({ @@ -166,143 +150,23 @@ export async function flattenSignatures( type: "application/pdf", }); - // Step 4: Manually render extracted annotations onto the PDF using PDFium WASM + // Step 4: Add signatures as locked, printable PDFium stamp annotations. + // FPDFAnnot_AppendObject creates the annotation appearance without asking + // PDFium to regenerate the page's existing content. GenerateContent would + // corrupt some Type3/vector content, including the issue #7083 logo. if (allAnnotations.length > 0) { try { - const pdfArrayBufferForFlattening = await signedFile.arrayBuffer(); - const m = await getPdfiumModule(); - const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening); - - try { - const pageCount = m.FPDF_GetPageCount(docPtr); - - for (const pageData of allAnnotations) { - const { pageIndex, annotations } = pageData; - - if (pageIndex < pageCount) { - const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); - if (!pagePtr) continue; - - const pageHeight = m.FPDF_GetPageHeightF(pagePtr); - - for (const annotation of annotations) { - try { - const rect = - annotation.rect || - annotation.bounds || - annotation.rectangle || - annotation.position; - - if (rect) { - const originalX = - rect.origin?.x || rect.x || rect.left || 0; - const originalY = - rect.origin?.y || rect.y || rect.top || 0; - const width = rect.size?.width || rect.width || 100; - const height = rect.size?.height || rect.height || 50; - - // Convert from CSS top-left to PDF bottom-left - const pdfX = originalX; - const pdfY = pageHeight - originalY - height; - - let imageDataUrl = - annotation.imageData || - annotation.appearance || - annotation.stampData || - annotation.imageSrc || - annotation.contents || - annotation.data; - - if (!imageDataUrl && annotation.id) { - const storedImageData = getImageData(annotation.id); - if (storedImageData) { - imageDataUrl = storedImageData; - } - } - - // Convert SVG to PNG first if needed - if ( - imageDataUrl && - typeof imageDataUrl === "string" && - imageDataUrl.startsWith("data:image/svg+xml") - ) { - const pngBytes = await rasteriseSvgToPng( - imageDataUrl, - width * 2, - height * 2, - ); - if (pngBytes) { - imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes); - } else { - drawPlaceholderRect( - m, - pagePtr, - pdfX, - pdfY, - width, - height, - ); - continue; - } - } - - if ( - imageDataUrl && - typeof imageDataUrl === "string" && - imageDataUrl.startsWith("data:image") - ) { - // Decode the image data URL to raw pixels via canvas - const imageResult = - await decodeImageDataUrl(imageDataUrl); - if (imageResult) { - embedBitmapImageOnPage( - m, - docPtr, - pagePtr, - imageResult, - pdfX, - pdfY, - width, - height, - ); - } - } else if ( - annotation.type === FPDF_ANNOT_INK || - annotation.type === FPDF_ANNOT_LINE - ) { - drawPlaceholderRect( - m, - pagePtr, - pdfX, - pdfY, - width, - height, - ); - } - } - } catch (annotationError) { - console.warn( - "Failed to render annotation:", - annotationError, - ); - } - } - - m.FPDFPage_GenerateContent(pagePtr); - m.FPDF_ClosePage(pagePtr); - } - } - - const resultBuf = await saveRawDocument(docPtr); - signedFile = new File([resultBuf], currentFile.name, { - type: "application/pdf", - }); - } finally { - closeDocAndFreeBuffer(m, docPtr); - } + const resultBytes = await embedSignatureImages( + await signedFile.arrayBuffer(), + allAnnotations, + getImageData, + ); + signedFile = new File([resultBytes as BlobPart], currentFile.name, { + type: "application/pdf", + }); } catch (renderError) { - console.error("Failed to manually render annotations:", renderError); - console.warn("Signatures may only show as annotations"); + console.error("Failed to embed signature images:", renderError); + console.warn("Signatures may only remain as annotations"); } } @@ -343,16 +207,211 @@ export async function flattenSignatures( } } -/** - * Convert Uint8Array PNG bytes to a data URL for canvas decoding. - */ -function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise { - return new Promise((resolve) => { - const blob = new Blob([pngBytes as BlobPart], { type: "image/png" }); - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(blob); - }); +type SignatureAnnotationsByPage = Array<{ + pageIndex: number; + annotations: any[]; +}>; + +function extractImageDataUrl( + value: unknown, + depth = 0, + visited: Set = new Set(), +): string | undefined { + if (!value || depth > 6) return undefined; + + if (typeof value === "string") { + return value.startsWith("data:image") ? value : undefined; + } + + if (typeof value !== "object" || visited.has(value)) return undefined; + visited.add(value); + + const entries = Array.isArray(value) + ? value + : Object.values(value as Record); + for (const entry of entries) { + const imageDataUrl = extractImageDataUrl(entry, depth + 1, visited); + if (imageDataUrl) return imageDataUrl; + } + + return undefined; +} + +function getAnnotationImageData( + annotation: any, + getImageData: (id: string) => string | undefined, +): string | undefined { + // EmbedPDF can replace fields such as imageData/appearance with an internal + // asset reference after placement. Prefer our persistent original and only + // accept values that actually contain an image data URL. + const candidates: unknown[] = [ + annotation.id ? getImageData(annotation.id) : undefined, + annotation.imageSrc, + annotation.imageData, + annotation.appearance, + annotation.stampData, + annotation.contents, + annotation.data, + annotation.customData, + annotation.asset, + ]; + + for (const candidate of candidates) { + const imageDataUrl = extractImageDataUrl(candidate); + if (imageDataUrl) return imageDataUrl; + } + + return undefined; +} + +export async function embedSignatureImages( + pdfArrayBuffer: ArrayBuffer, + annotationsByPage: SignatureAnnotationsByPage, + getImageData: (id: string) => string | undefined, + imageDecoder: ( + dataUrl: string, + ) => Promise = decodeImageDataUrl, +): Promise { + const m = await getPdfiumModule(); + const docPtr = await openRawDocumentSafe(pdfArrayBuffer); + + try { + const pageCount = m.FPDF_GetPageCount(docPtr); + + for (const { pageIndex, annotations } of annotationsByPage) { + if (pageIndex < 0 || pageIndex >= pageCount) continue; + + const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex); + if (!pagePtr) continue; + + try { + const pageBox = readEffectivePageBox(m, pagePtr); + const cropHeight = pageBox.top - pageBox.bottom; + + for (const annotation of annotations) { + const rect = + annotation.rect ?? + annotation.bounds ?? + annotation.rectangle ?? + annotation.position; + if (!rect) continue; + + const originalX = rect.origin?.x ?? rect.x ?? rect.left ?? 0; + const originalY = rect.origin?.y ?? rect.y ?? rect.top ?? 0; + const width = rect.size?.width ?? rect.width ?? 100; + const height = rect.size?.height ?? rect.height ?? 50; + if (width <= 0 || height <= 0) continue; + + let imageDataUrl = getAnnotationImageData(annotation, getImageData); + if (!imageDataUrl) continue; + + if (imageDataUrl.startsWith("data:image/svg+xml")) { + const pngBytes = await rasteriseSvgToPng( + imageDataUrl, + width * 2, + height * 2, + ); + if (!pngBytes) continue; + imageDataUrl = `data:image/png;base64,${uint8ArrayToBase64(pngBytes)}`; + } + + const decodedImage = await imageDecoder(imageDataUrl); + if (!decodedImage) continue; + + const pdfX = pageBox.left + originalX; + const pdfY = pageBox.bottom + cropHeight - originalY - height; + appendStampAnnotation( + m, + docPtr, + pagePtr, + decodedImage, + pdfX, + pdfY, + width, + height, + ); + } + } finally { + m.FPDF_ClosePage(pagePtr); + } + } + + return await saveRawDocument(docPtr); + } finally { + closeDocAndFreeBuffer(m, docPtr); + } +} + +const FPDF_ANNOT_STAMP = 13; +const FPDF_ANNOT_FLAG_PRINT = 1 << 2; +const FPDF_ANNOT_FLAG_READONLY = 1 << 6; +const FPDF_ANNOT_FLAG_LOCKED = 1 << 7; + +function appendStampAnnotation( + m: Awaited>, + docPtr: number, + pagePtr: number, + image: DecodedImage, + pdfX: number, + pdfY: number, + width: number, + height: number, +): boolean { + const annotationIndex = m.FPDFPage_GetAnnotCount(pagePtr); + const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_STAMP); + if (!annotPtr) return false; + + let appended = false; + let imageObjPtr = 0; + const rectPtr = m.pdfium.wasmExports.malloc(4 * 4); + + try { + // FS_RECTF layout: left, top, right, bottom. + m.pdfium.setValue(rectPtr, pdfX, "float"); + m.pdfium.setValue(rectPtr + 4, pdfY + height, "float"); + m.pdfium.setValue(rectPtr + 8, pdfX + width, "float"); + m.pdfium.setValue(rectPtr + 12, pdfY, "float"); + if (!m.FPDFAnnot_SetRect(annotPtr, rectPtr)) return false; + + imageObjPtr = + createBitmapImageObject( + m, + docPtr, + pagePtr, + image, + pdfX, + pdfY, + width, + height, + ) ?? 0; + if (!imageObjPtr) return false; + + if (!m.FPDFAnnot_AppendObject(annotPtr, imageObjPtr)) return false; + imageObjPtr = 0; // The annotation owns the object after a successful append. + + m.FPDFAnnot_SetFlags( + annotPtr, + FPDF_ANNOT_FLAG_PRINT | FPDF_ANNOT_FLAG_READONLY | FPDF_ANNOT_FLAG_LOCKED, + ); + appended = true; + return true; + } finally { + m.pdfium.wasmExports.free(rectPtr); + if (imageObjPtr) m.FPDFPageObj_Destroy(imageObjPtr); + m.FPDFPage_CloseAnnot(annotPtr); + if (!appended) m.FPDFPage_RemoveAnnot(pagePtr, annotationIndex); + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode( + ...bytes.subarray(offset, offset + chunkSize), + ); + } + return btoa(binary); } /**