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
+139 -82
View File
@@ -1,7 +1,19 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from '@cantoo/pdf-lib';
import {
getPdfiumModule,
openRawDocumentSafe,
closeRawDocument,
saveRawDocument,
importPages,
setPageRotation,
addNewPage,
} from '@app/services/pdfiumService';
import { downloadFile } from '@app/services/downloadService';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
// A4 dimensions in PDF points (72 dpi)
const A4_WIDTH = 595.276;
const A4_HEIGHT = 841.890;
export interface ExportOptions {
selectedOnly?: boolean;
filename?: string;
@@ -19,7 +31,6 @@ export class PDFExportService {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
@@ -28,10 +39,8 @@ export class PDFExportService {
throw new Error('No pages to export');
}
// Load original PDF and create new document
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
const sourceDoc = await PDFLibDocument.load(originalPDFBytes, { ignoreEncryption: true });
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const blob = await this.createSingleDocument(originalPDFBytes, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name);
return { blob, filename: exportFilename };
@@ -56,7 +65,6 @@ export class PDFExportService {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
@@ -79,102 +87,140 @@ export class PDFExportService {
}
/**
* Create a PDF document from multiple source files
* Create a PDF document from multiple source files using PDFium WASM.
*/
private async createMultiSourceDocument(
sourceFiles: Map<string, File>,
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
const m = await getPdfiumModule();
// Create destination document
const destDocPtr = m.FPDF_CreateNewDocument();
if (!destDocPtr) throw new Error('PDFium: failed to create destination document');
// Load all source documents once and cache them
const loadedDocs = new Map<string, PDFLibDocument>();
const loadedDocs = new Map<string, number>();
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true });
loadedDocs.set(fileId, doc);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
}
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
blankPage.setRotation(degrees(page.rotation));
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
// Get the correct source document for this page
const sourceDoc = loadedDocs.get(page.originalFileId)!;
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page from the correct source document
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
copiedPage.setRotation(degrees(page.rotation));
newDoc.addPage(copiedPage);
try {
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const docPtr = await openRawDocumentSafe(arrayBuffer);
loadedDocs.set(fileId, docPtr);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
} else {
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
}
let insertIdx = 0;
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Insert a blank A4 page
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
// Apply rotation
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
const srcDocPtr = loadedDocs.get(page.originalFileId)!;
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
// Import the specific page (1-based page range for FPDF_ImportPages)
const pageRange = String(sourcePageIndex + 1);
const imported = await importPages(destDocPtr, srcDocPtr, pageRange, insertIdx);
if (!imported) {
console.warn(
`[PDFExport] importPages failed for fileId=${page.originalFileId} pageRange=${pageRange} — page will be missing from output.`,
);
}
// Apply rotation
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
}
} else {
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
}
}
// Save the assembled document
const resultBuf = await saveRawDocument(destDocPtr);
return new Blob([resultBuf], { type: 'application/pdf' });
} finally {
// Cleanup all loaded source documents
for (const docPtr of loadedDocs.values()) {
await closeRawDocument(docPtr);
}
m.FPDF_CloseDocument(destDocPtr);
}
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
}
/**
* Create a single PDF document with all operations applied (single source)
* Create a single PDF document with all operations applied (single source) using PDFium.
*/
private async createSingleDocument(
sourceDoc: PDFLibDocument,
sourceData: ArrayBuffer,
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
const m = await getPdfiumModule();
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
blankPage.setRotation(degrees(page.rotation));
} else {
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
copiedPage.setRotation(degrees(page.rotation));
newDoc.addPage(copiedPage);
}
}
// Open source document
const srcDocPtr = await openRawDocumentSafe(sourceData);
const destDocPtr = m.FPDF_CreateNewDocument();
if (!destDocPtr) {
await closeRawDocument(srcDocPtr);
throw new Error('PDFium: failed to create destination document');
}
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
try {
const srcPageCount = m.FPDF_GetPageCount(srcDocPtr);
let insertIdx = 0;
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes as BlobPart], { type: 'application/pdf' });
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT);
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
} else {
const sourcePageIndex = page.originalPageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < srcPageCount) {
const pageRange = String(sourcePageIndex + 1);
const imported = await importPages(destDocPtr, srcDocPtr, pageRange, insertIdx);
if (!imported) {
console.warn(
`[PDFExport] importPages failed for page ${page.originalPageNumber} pageRange=${pageRange} — page will be missing from output.`,
);
}
const pdfiumRotation = degreesToPdfiumRotation(page.rotation);
if (pdfiumRotation !== 0) {
await setPageRotation(destDocPtr, insertIdx, pdfiumRotation);
}
insertIdx++;
}
}
}
const resultBuf = await saveRawDocument(destDocPtr);
return new Blob([resultBuf], { type: 'application/pdf' });
} finally {
await closeRawDocument(srcDocPtr);
m.FPDF_CloseDocument(destDocPtr);
}
}
/**
* Generate appropriate filename for export
*/
@@ -183,7 +229,6 @@ export class PDFExportService {
return `${baseName}.pdf`;
}
/**
* Download a single file
*/
@@ -198,7 +243,7 @@ export class PDFExportService {
blobs.forEach((blob, index) => {
setTimeout(() => {
this.downloadFile(blob, filenames[index]);
}, index * 500); // Stagger downloads
}, index * 500);
});
}
@@ -241,9 +286,8 @@ export class PDFExportService {
const splitCount = pagesToExport.reduce((count, page) => {
return count + (page.splitAfter ? 1 : 0);
}, 1); // At least 1 document
}, 1);
// Rough size estimation (very approximate)
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
const estimatedBytes = avgPageSize * pagesToExport.length;
const estimatedSize = this.formatFileSize(estimatedBytes);
@@ -267,5 +311,18 @@ export class PDFExportService {
}
}
/**
* Convert degrees (0, 90, 180, 270) to PDFium rotation enum (0, 1, 2, 3).
*/
function degreesToPdfiumRotation(degrees: number): number {
const normalized = ((degrees % 360) + 360) % 360;
switch (normalized) {
case 90: return 1;
case 180: return 2;
case 270: return 3;
default: return 0;
}
}
// Export singleton instance
export const pdfExportService = new PDFExportService();
@@ -34,6 +34,7 @@ class PDFWorkerManager {
'pdfjs-dist/legacy/build/pdf.worker.min.mjs',
import.meta.url
).toString();
(GlobalWorkerOptions as any).docBaseUrl = undefined;
this.isInitialized = true;
}
}
@@ -74,13 +75,17 @@ class PDFWorkerManager {
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
verbosity: options.verbosity ?? 0,
// Suppress warnings about unimplemented widget types and other non-critical issues
isEvalSupported: false,
} : {
...pdfData,
disableAutoFetch: options.disableAutoFetch ?? true,
disableStream: options.disableStream ?? true,
stopAtErrors: options.stopAtErrors ?? false,
verbosity: options.verbosity ?? 0
verbosity: options.verbosity ?? 0,
// Suppress warnings about unimplemented widget types and other non-critical issues
isEvalSupported: false,
}
);
@@ -0,0 +1,405 @@
/**
* pdfiumDocBuilder — High-level PDF document builder powered by PDFium WASM.
*
* Provides an API surface compatible with common pdf-lib patterns
* (drawText, drawRectangle, drawLine, drawImage, font metrics, etc.)
* so that code previously using pdf-lib for PDF *generation* can be
* migrated with minimal changes.
*
* Used by the signature validation report system.
*/
import { getPdfiumModule, writeUtf16, saveRawDocument } from '@app/services/pdfiumService';
import { embedBitmapImageOnPage } from '@app/utils/pdfiumBitmapUtils';
import type { WrappedPdfiumModule } from '@embedpdf/pdfium';
// ---------------------------------------------------------------------------
// Color type (replaces pdf-lib's `rgb()`)
// ---------------------------------------------------------------------------
export interface PdfiumColor {
_r: number;
_g: number;
_b: number;
}
/**
* Create an RGB color (each component 01). Drop-in replacement for pdf-lib's `rgb()`.
*/
export function rgb(r: number, g: number, b: number): PdfiumColor {
return { _r: r, _g: g, _b: b };
}
function colorToRGBA(c: PdfiumColor): [number, number, number, number] {
return [Math.round(c._r * 255), Math.round(c._g * 255), Math.round(c._b * 255), 255];
}
// ---------------------------------------------------------------------------
// Font abstraction
// ---------------------------------------------------------------------------
/** Standard PDF font names matching pdf-lib's StandardFonts enum. */
export const StandardFonts = {
Helvetica: 'Helvetica',
HelveticaBold: 'Helvetica-Bold',
HelveticaOblique: 'Helvetica-Oblique',
HelveticaBoldOblique: 'Helvetica-BoldOblique',
Courier: 'Courier',
CourierBold: 'Courier-Bold',
TimesRoman: 'Times-Roman',
TimesBold: 'Times-Bold',
} as const;
export class PdfiumFont {
readonly name: string;
private _canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
private _ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null;
constructor(name: string) {
this.name = name;
}
/**
* Measure the width of `text` at the given `fontSize` (in PDF points).
* Uses canvas font metrics as a reliable cross-browser measurement.
*/
widthOfTextAtSize(text: string, fontSize: number): number {
const ctx = this._getCtx();
ctx.font = `${fontSize}px "${this._cssFontFamily()}"`;
return ctx.measureText(text).width;
}
/**
* Returns the line height for a given font size (approximation).
*/
heightAtSize(fontSize: number): number {
return fontSize * 1.2;
}
/** Map PDF font name to a CSS font-family for canvas measurement. */
private _cssFontFamily(): string {
if (this.name.startsWith('Helvetica')) return 'Helvetica, Arial, sans-serif';
if (this.name.startsWith('Courier')) return 'Courier, monospace';
if (this.name.startsWith('Times')) return 'Times New Roman, serif';
return 'sans-serif';
}
private _getCtx(): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D {
if (this._ctx) return this._ctx;
if (typeof OffscreenCanvas !== 'undefined') {
this._canvas = new OffscreenCanvas(1, 1);
this._ctx = this._canvas.getContext('2d')!;
} else {
this._canvas = document.createElement('canvas');
this._ctx = this._canvas.getContext('2d')!;
}
return this._ctx;
}
}
// ---------------------------------------------------------------------------
// Image abstraction
// ---------------------------------------------------------------------------
export class PdfiumImage {
readonly width: number;
readonly height: number;
/** Raw RGBA pixel data */
readonly _rgba: Uint8Array;
constructor(rgba: Uint8Array, width: number, height: number) {
this._rgba = rgba;
this.width = width;
this.height = height;
}
/** Returns scaled dimensions (matches pdf-lib's `PDFImage.scale()`). */
scale(factor: number): { width: number; height: number } {
return { width: this.width * factor, height: this.height * factor };
}
/** Scale to fit within maxWidth × maxHeight preserving aspect ratio (matches pdf-lib's `PDFImage.scaleToFit()`). */
scaleToFit(maxWidth: number, maxHeight: number): { width: number; height: number } {
const ratio = Math.min(maxWidth / this.width, maxHeight / this.height, 1);
return { width: this.width * ratio, height: this.height * ratio };
}
}
// ---------------------------------------------------------------------------
// Page abstraction
// ---------------------------------------------------------------------------
export interface DrawTextOptions {
x: number;
y: number;
size: number;
font?: PdfiumFont;
color?: PdfiumColor;
}
export interface DrawRectangleOptions {
x: number;
y: number;
width: number;
height: number;
color?: PdfiumColor;
borderColor?: PdfiumColor;
borderWidth?: number;
opacity?: number;
}
export interface DrawImageOptions {
x: number;
y: number;
width: number;
height: number;
}
export interface DrawLineOptions {
start: { x: number; y: number };
end: { x: number; y: number };
thickness?: number;
color?: PdfiumColor;
}
export class PdfiumPage {
readonly _m: WrappedPdfiumModule;
readonly _docPtr: number;
readonly _pagePtr: number;
readonly width: number;
readonly height: number;
constructor(m: WrappedPdfiumModule, docPtr: number, pagePtr: number, width: number, height: number) {
this._m = m;
this._docPtr = docPtr;
this._pagePtr = pagePtr;
this.width = width;
this.height = height;
}
/**
* Draw text on the page. API compatible with pdf-lib's `PDFPage.drawText()`.
*/
drawText(text: string, options: DrawTextOptions): void {
const { x, y, size, font, color } = options;
const m = this._m;
const fontName = font?.name ?? 'Helvetica';
const textObjPtr = m.FPDFPageObj_NewTextObj(this._docPtr, fontName, size);
if (!textObjPtr) return;
// Set text content (UTF-16)
const textPtr = writeUtf16(m, text);
m.FPDFText_SetText(textObjPtr, textPtr);
m.pdfium.wasmExports.free(textPtr);
// Set color
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetFillColor(textObjPtr, r, g, b, a);
}
// Position the text object
// FPDFPageObj_Transform(obj, a, b, c, d, e, f) — applies affine transform
// Identity + translate: a=1, b=0, c=0, d=1, e=x, f=y
m.FPDFPageObj_Transform(textObjPtr, 1, 0, 0, 1, x, y);
m.FPDFPage_InsertObject(this._pagePtr, textObjPtr);
}
/**
* Draw a rectangle on the page. API compatible with pdf-lib's `PDFPage.drawRectangle()`.
*/
drawRectangle(options: DrawRectangleOptions): void {
const { x, y, width, height, color, borderColor, borderWidth = 1 } = options;
const m = this._m;
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);
let hasFill = false;
let hasStroke = false;
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetFillColor(pathPtr, r, g, b, a);
hasFill = true;
}
if (borderColor) {
const [r, g, b, a] = colorToRGBA(borderColor);
m.FPDFPageObj_SetStrokeColor(pathPtr, r, g, b, a);
m.FPDFPageObj_SetStrokeWidth(pathPtr, borderWidth);
hasStroke = true;
}
// Fill mode: 0 = none, 1 = alternate, 2 = winding
const fillMode = hasFill ? 1 : 0;
m.FPDFPath_SetDrawMode(pathPtr, fillMode, hasStroke);
m.FPDFPage_InsertObject(this._pagePtr, pathPtr);
}
/**
* Draw a line on the page. API compatible with pdf-lib's `PDFPage.drawLine()`.
*/
drawLine(options: DrawLineOptions): void {
const { start, end, thickness = 1, color } = options;
const m = this._m;
const pathPtr = m.FPDFPageObj_CreateNewPath(start.x, start.y);
if (!pathPtr) return;
m.FPDFPath_LineTo(pathPtr, end.x, end.y);
if (color) {
const [r, g, b, a] = colorToRGBA(color);
m.FPDFPageObj_SetStrokeColor(pathPtr, r, g, b, a);
}
m.FPDFPageObj_SetStrokeWidth(pathPtr, thickness);
// fillMode=0 (no fill), stroke=true
m.FPDFPath_SetDrawMode(pathPtr, 0, true);
m.FPDFPage_InsertObject(this._pagePtr, pathPtr);
}
/**
* Draw an image on the page. API compatible with pdf-lib's `PDFPage.drawImage()`.
*/
drawImage(image: PdfiumImage, options: DrawImageOptions): void {
const { x, y, width, height } = options;
embedBitmapImageOnPage(
this._m, this._docPtr, this._pagePtr,
{ rgba: image._rgba, width: image.width, height: image.height },
x, y, width, height,
);
}
/** Finalize the page content stream. Called internally by PdfiumDocument. */
_generateContent(): void {
this._m.FPDFPage_GenerateContent(this._pagePtr);
}
/** Close the page pointer. Called internally by PdfiumDocument. */
_close(): void {
this._m.FPDF_ClosePage(this._pagePtr);
}
}
// ---------------------------------------------------------------------------
// Document abstraction
// ---------------------------------------------------------------------------
export class PdfiumDocument {
readonly _m: WrappedPdfiumModule;
readonly _docPtr: number;
private _pages: PdfiumPage[] = [];
private _fonts: Map<string, PdfiumFont> = new Map();
private constructor(m: WrappedPdfiumModule, docPtr: number) {
this._m = m;
this._docPtr = docPtr;
}
/** Create a new empty PDF document. Drop-in replacement for `PDFDocument.create()`. */
static async create(): Promise<PdfiumDocument> {
const m = await getPdfiumModule();
const docPtr = m.FPDF_CreateNewDocument();
if (!docPtr) throw new Error('PDFium: failed to create document');
return new PdfiumDocument(m, docPtr);
}
/** Add a new page to the document. */
addPage(dimensions: [number, number]): PdfiumPage {
const [width, height] = dimensions;
const insertIdx = this._pages.length;
const pagePtr = this._m.FPDFPage_New(this._docPtr, insertIdx, width, height);
if (!pagePtr) throw new Error('PDFium: failed to create page');
const page = new PdfiumPage(this._m, this._docPtr, pagePtr, width, height);
this._pages.push(page);
return page;
}
/** Embed a standard PDF font. Returns a PdfiumFont for text measurement and drawing. */
async embedFont(fontName: string): Promise<PdfiumFont> {
if (this._fonts.has(fontName)) return this._fonts.get(fontName)!;
const font = new PdfiumFont(fontName);
this._fonts.set(fontName, font);
return font;
}
/** Embed a PNG image from raw bytes. */
async embedPng(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), 'image/png');
}
/** Embed a JPEG image from raw bytes. */
async embedJpg(bytes: Uint8Array | ArrayBuffer): Promise<PdfiumImage> {
return this._decodeImage(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), 'image/jpeg');
}
/** Get the number of pages. */
getPageCount(): number {
return this._pages.length;
}
/** Save the document and return the PDF bytes. */
async save(): Promise<Uint8Array> {
// Generate content for all pages before saving
for (const page of this._pages) {
page._generateContent();
}
const buf = await saveRawDocument(this._docPtr);
// Close all pages and the document
for (const page of this._pages) {
page._close();
}
this._m.FPDF_CloseDocument(this._docPtr);
return new Uint8Array(buf);
}
/**
* Decode image bytes to RGBA pixel data via canvas.
* PDFium's image object APIs require bitmap data.
*/
private _decodeImage(bytes: Uint8Array, mimeType: string): Promise<PdfiumImage> {
return new Promise((resolve, reject) => {
const blob = new Blob([bytes as BlobPart], { type: mimeType });
const url = URL.createObjectURL(blob);
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) {
URL.revokeObjectURL(url);
reject(new Error('Canvas 2D context unavailable'));
return;
}
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
resolve(new PdfiumImage(new Uint8Array(imageData.data.buffer), canvas.width, canvas.height));
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to decode image'));
};
img.src = url;
});
}
}
File diff suppressed because it is too large Load Diff