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); } /**