mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -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 0–1). 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
Reference in New Issue
Block a user