feat(pdf): replace PdfLib with Pdfium for form handling and general rendering tasks (#5899)

# Description of Changes

Improves PDF rendering in the viewer by adding digital signature field
support,
cleaning up overlay rendering, and migrating the contrast tool off
pdf-lib to PDFium WASM.

### Signature Field Overlay
- Added `SignatureFieldOverlay` component that renders digital signature
form fields
- Renders appearance streams when present; shows a fallback badge for
unsigned fields
- Uses PDFium WASM for bitmap extraction

### Overlay Rendering
- Integrated `SignatureFieldOverlay` and `ButtonAppearanceOverlay` into
`LocalEmbedPDF`
- Overlays are now clipped to page boundaries
- Clarified in `EmbedPdfViewer` that frontend overlays use PDFium WASM,
  backend overlays use PDFBox

### Contrast Tool Migration
- Replaced pdf-lib with PDFium WASM in `useAdjustContrastOperation`
- PDF page creation and image embedding now go through PDFium APIs
directly
- Updated bitmap handling and memory management accordingly

### Cleanup
- Fixed import ordering in viewer components
- Removed stale comments in the contrast operation hook

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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/devGuide/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 tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
brios
2026-03-24 13:34:52 +00:00
committed by GitHub
co-authored by Reece Browne
parent 3ea11352e3
commit c3530024c4
34 changed files with 4299 additions and 1910 deletions
@@ -0,0 +1,208 @@
/**
* pdfiumBitmapUtils — Shared utilities for working with PDFium WASM bitmaps.
*
* Centralises the RGBA→BGRA pixel conversion and image-to-page-object
* embedding that previously appeared (copy-pasted) in at least 5 files.
*
* Performance note: instead of calling `m.pdfium.setValue()` per pixel
* (which crosses the JS↔WASM FFI boundary on every call), we perform
* the colour-channel swizzle in a plain JS TypedArray and then bulk-
* copy the result into the WASM heap with a single `HEAPU8.set()`.
*/
import type { WrappedPdfiumModule } from '@embedpdf/pdfium';
/** FPDF_ANNOT_LINK */
export const FPDF_ANNOT_LINK = 4;
/** FPDF_ANNOT_WIDGET (form field) */
export const FPDF_ANNOT_WIDGET = 20;
/** FPDF_ANNOT_INK */
export const FPDF_ANNOT_INK = 15;
/** FPDF_ANNOT_LINE */
export const FPDF_ANNOT_LINE = 3;
/** PDFACTION_GOTO */
export const PDFACTION_GOTO = 1;
/** PDFACTION_URI */
export const PDFACTION_URI = 3;
/** FLAT_PRINT (for FPDFPage_Flatten) */
export const FLAT_PRINT = 2;
/**
* Convert an RGBA pixel buffer to BGRA (PDFium's expected format) **in place
* inside the WASM heap** with a single bulk memcpy.
*
* When `stride === width * 4` the copy is a single `HEAPU8.set()`.
* When the bitmap has padding (stride > width * 4), rows are copied
* individually to skip the padding bytes.
*
* This is ~100× faster than per-pixel `m.pdfium.setValue()` calls for
* large images.
*/
export function copyRgbaToBgraHeap(
m: WrappedPdfiumModule,
rgba: Uint8Array | Uint8ClampedArray,
bufferPtr: number,
width: number,
height: number,
stride: number,
): void {
const rowBytes = width * 4;
if (stride === rowBytes) {
// Fast path: no padding — single bulk copy after swizzle
const bgra = new Uint8Array(rgba.length);
for (let i = 0; i < rgba.length; i += 4) {
bgra[i] = rgba[i + 2]; // B
bgra[i + 1] = rgba[i + 1]; // G
bgra[i + 2] = rgba[i]; // R
bgra[i + 3] = rgba[i + 3]; // A
}
new Uint8Array(
(m.pdfium.wasmExports as any).memory.buffer,
).set(bgra, bufferPtr);
} else {
// Stride has padding — swizzle + copy row by row
const rowBuf = new Uint8Array(rowBytes);
const heap = new Uint8Array(
(m.pdfium.wasmExports as any).memory.buffer,
);
for (let y = 0; y < height; y++) {
const srcRowStart = y * rowBytes;
for (let x = 0; x < rowBytes; x += 4) {
rowBuf[x] = rgba[srcRowStart + x + 2]; // B
rowBuf[x + 1] = rgba[srcRowStart + x + 1]; // G
rowBuf[x + 2] = rgba[srcRowStart + x]; // R
rowBuf[x + 3] = rgba[srcRowStart + x + 3]; // A
}
heap.set(rowBuf, bufferPtr + y * stride);
}
}
}
export interface DecodedImage {
rgba: Uint8Array;
width: number;
height: number;
}
/**
* 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.
*
* Returns `true` if the image was successfully inserted, `false` otherwise.
* All intermediate WASM resources are cleaned up on failure.
*/
export function embedBitmapImageOnPage(
m: WrappedPdfiumModule,
docPtr: number,
pagePtr: number,
image: DecodedImage,
pdfX: number,
pdfY: number,
drawWidth: number,
drawHeight: number,
): boolean {
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
if (!bitmapPtr) 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 */ }
}
}
/**
* Draw a simple light-grey rectangle as a placeholder for annotations
* that could not be rendered.
*/
export function drawPlaceholderRect(
m: WrappedPdfiumModule,
pagePtr: number,
x: number,
y: number,
width: number,
height: number,
): void {
const pathPtr = m.FPDFPageObj_CreateNewPath(x, y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, x + width, y);
m.FPDFPath_LineTo(pathPtr, x + width, y + height);
m.FPDFPath_LineTo(pathPtr, x, y + height);
m.FPDFPath_Close(pathPtr);
m.FPDFPageObj_SetFillColor(pathPtr, 230, 230, 230, 150);
m.FPDFPageObj_SetStrokeColor(pathPtr, 128, 128, 128, 255);
m.FPDFPageObj_SetStrokeWidth(pathPtr, 1);
// fillMode 1 = alternate fill, stroke = true
m.FPDFPath_SetDrawMode(pathPtr, 1, true);
m.FPDFPage_InsertObject(pagePtr, pathPtr);
}
/**
* Decode an image data URL (e.g. `data:image/png;base64,...`) to raw RGBA
* pixel data via an offscreen canvas.
*/
export function decodeImageDataUrl(dataUrl: string): Promise<DecodedImage | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) { resolve(null); return; }
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
resolve({
rgba: new Uint8Array(imageData.data.buffer),
width: canvas.width,
height: canvas.height,
});
} catch { resolve(null); }
};
img.onerror = () => resolve(null);
img.src = dataUrl;
});
}