feat(initial): add comprehensive PDF to JPEG preview library with dual-environment support
- Add Node.js implementation using @push.rocks/smartpdf - Add browser implementation with PDF.js and Web Workers - Support configurable quality, dimensions, and page selection - Include comprehensive TypeScript definitions and error handling - Provide extensive test coverage for both environments - Add download functionality and browser compatibility checking
This commit is contained in:
37
ts_web/index.ts
Normal file
37
ts_web/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @push.rocks/smartpreview - Web implementation
|
||||
*
|
||||
* A library for generating efficient JPEG previews from PDFs
|
||||
* in browser environments using PDF.js and Web Workers.
|
||||
*/
|
||||
|
||||
// Main classes
|
||||
export { SmartPreview } from './smartpreview.js';
|
||||
export { WebPdfProcessor } from './pdfprocessor.js';
|
||||
|
||||
// Runtime exports (classes and functions)
|
||||
export {
|
||||
PreviewError
|
||||
} from './interfaces.js';
|
||||
|
||||
// Type-only exports (interfaces and types)
|
||||
export type {
|
||||
IPreviewOptions,
|
||||
IPreviewResult,
|
||||
TWebInputType,
|
||||
TSupportedInputFormat,
|
||||
TSupportedOutputFormat,
|
||||
IWebFormatProcessor,
|
||||
IWebPdfProcessor,
|
||||
TPreviewError,
|
||||
IProgressCallback,
|
||||
IWebPreviewOptions,
|
||||
// Worker-related types
|
||||
TWorkerMessageType,
|
||||
IWorkerMessage,
|
||||
IPdfProcessRequest,
|
||||
IPdfProcessResponse
|
||||
} from './interfaces.js';
|
||||
|
||||
// Default export for convenience
|
||||
export { SmartPreview as default } from './smartpreview.js';
|
202
ts_web/interfaces.ts
Normal file
202
ts_web/interfaces.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Configuration options for preview generation in browser environment
|
||||
*/
|
||||
export interface IPreviewOptions {
|
||||
/**
|
||||
* JPEG quality (1-100)
|
||||
* @default 80
|
||||
*/
|
||||
quality?: number;
|
||||
|
||||
/**
|
||||
* Maximum width in pixels
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* Maximum height in pixels
|
||||
*/
|
||||
height?: number;
|
||||
|
||||
/**
|
||||
* PDF page number to convert (1-based)
|
||||
* @default 1
|
||||
*/
|
||||
page?: number;
|
||||
|
||||
/**
|
||||
* Scale factor for rendering
|
||||
* @default 1.0
|
||||
*/
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview generation result for browser environment
|
||||
*/
|
||||
export interface IPreviewResult {
|
||||
/**
|
||||
* Generated JPEG image blob
|
||||
*/
|
||||
blob: Blob;
|
||||
|
||||
/**
|
||||
* Image dimensions
|
||||
*/
|
||||
dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* File size in bytes
|
||||
*/
|
||||
size: number;
|
||||
|
||||
/**
|
||||
* MIME type
|
||||
*/
|
||||
mimeType: 'image/jpeg';
|
||||
|
||||
/**
|
||||
* Data URL for immediate use
|
||||
*/
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported input types for browser environment
|
||||
*/
|
||||
export type TWebInputType = File | Blob | ArrayBuffer | Uint8Array | string;
|
||||
|
||||
/**
|
||||
* Supported input formats (extensible)
|
||||
*/
|
||||
export type TSupportedInputFormat = 'pdf';
|
||||
|
||||
/**
|
||||
* Supported output formats (extensible)
|
||||
*/
|
||||
export type TSupportedOutputFormat = 'jpeg';
|
||||
|
||||
/**
|
||||
* Worker message types for communication
|
||||
*/
|
||||
export type TWorkerMessageType =
|
||||
| 'INIT'
|
||||
| 'PROCESS_PDF'
|
||||
| 'PROCESS_COMPLETE'
|
||||
| 'PROCESS_ERROR'
|
||||
| 'WORKER_READY';
|
||||
|
||||
/**
|
||||
* Worker message interface
|
||||
*/
|
||||
export interface IWorkerMessage {
|
||||
type: TWorkerMessageType;
|
||||
id: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF processing request for worker
|
||||
*/
|
||||
export interface IPdfProcessRequest {
|
||||
pdfData: ArrayBuffer;
|
||||
options: Required<IPreviewOptions>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF processing response from worker
|
||||
*/
|
||||
export interface IPdfProcessResponse {
|
||||
imageData: ArrayBuffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for format processors (extensible architecture)
|
||||
*/
|
||||
export interface IWebFormatProcessor {
|
||||
/**
|
||||
* Supported input format
|
||||
*/
|
||||
inputFormat: TSupportedInputFormat;
|
||||
|
||||
/**
|
||||
* Supported output format
|
||||
*/
|
||||
outputFormat: TSupportedOutputFormat;
|
||||
|
||||
/**
|
||||
* Process the input and generate preview
|
||||
*/
|
||||
processPreview(input: TWebInputType, options: IPreviewOptions): Promise<IPreviewResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF-specific processor interface for web
|
||||
*/
|
||||
export interface IWebPdfProcessor extends IWebFormatProcessor {
|
||||
inputFormat: 'pdf';
|
||||
outputFormat: 'jpeg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Error types for preview generation
|
||||
*/
|
||||
export type TPreviewError =
|
||||
| 'INVALID_INPUT'
|
||||
| 'UNSUPPORTED_FORMAT'
|
||||
| 'PROCESSING_FAILED'
|
||||
| 'INVALID_OPTIONS'
|
||||
| 'PDF_CORRUPTED'
|
||||
| 'PAGE_NOT_FOUND'
|
||||
| 'WORKER_ERROR'
|
||||
| 'WORKER_TIMEOUT';
|
||||
|
||||
/**
|
||||
* Custom error class for preview operations in browser
|
||||
*/
|
||||
export class PreviewError extends Error {
|
||||
public readonly errorType: TPreviewError;
|
||||
public readonly originalError?: Error;
|
||||
|
||||
constructor(errorType: TPreviewError, message: string, originalError?: Error) {
|
||||
super(message);
|
||||
this.name = 'PreviewError';
|
||||
this.errorType = errorType;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress callback interface
|
||||
*/
|
||||
export interface IProgressCallback {
|
||||
(progress: number, stage: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced options for web processing
|
||||
*/
|
||||
export interface IWebPreviewOptions extends IPreviewOptions {
|
||||
/**
|
||||
* Progress callback function
|
||||
*/
|
||||
onProgress?: IProgressCallback;
|
||||
|
||||
/**
|
||||
* Worker timeout in milliseconds
|
||||
* @default 30000
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Whether to generate data URL
|
||||
* @default true
|
||||
*/
|
||||
generateDataUrl?: boolean;
|
||||
}
|
423
ts_web/pdfprocessor.ts
Normal file
423
ts_web/pdfprocessor.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
IWebPdfProcessor,
|
||||
IPreviewOptions,
|
||||
IPreviewResult,
|
||||
TWebInputType,
|
||||
IWorkerMessage,
|
||||
IPdfProcessRequest,
|
||||
IPdfProcessResponse,
|
||||
IWebPreviewOptions
|
||||
} from './interfaces.js';
|
||||
import { PreviewError } from './interfaces.js';
|
||||
|
||||
/**
|
||||
* PDF processor implementation for browser using PDF.js worker
|
||||
*/
|
||||
export class WebPdfProcessor implements IWebPdfProcessor {
|
||||
public readonly inputFormat = 'pdf' as const;
|
||||
public readonly outputFormat = 'jpeg' as const;
|
||||
|
||||
private worker: Worker | null = null;
|
||||
private isInitialized = false;
|
||||
private pendingRequests = new Map<string, {
|
||||
resolve: (result: IPdfProcessResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout?: number;
|
||||
}>();
|
||||
private requestIdCounter = 0;
|
||||
|
||||
/**
|
||||
* Initialize the PDF processor with worker
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create worker from blob URL to avoid CORS issues
|
||||
const workerBlob = await this.createWorkerBlob();
|
||||
const workerUrl = URL.createObjectURL(workerBlob);
|
||||
|
||||
this.worker = new Worker(workerUrl);
|
||||
this.setupWorkerEventHandlers();
|
||||
|
||||
// Wait for worker to be ready
|
||||
await this.waitForWorkerReady();
|
||||
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
throw new PreviewError(
|
||||
'PROCESSING_FAILED',
|
||||
'Failed to initialize PDF processor',
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process PDF and generate JPEG preview
|
||||
*/
|
||||
public async processPreview(input: TWebInputType, options: IWebPreviewOptions = {}): Promise<IPreviewResult> {
|
||||
if (!this.isInitialized || !this.worker) {
|
||||
throw new PreviewError('PROCESSING_FAILED', 'PDF processor not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert input to ArrayBuffer
|
||||
const arrayBuffer = await this.inputToArrayBuffer(input);
|
||||
|
||||
// Validate PDF
|
||||
this.validatePdfBuffer(arrayBuffer);
|
||||
|
||||
// Set default options
|
||||
const processOptions = {
|
||||
quality: options.quality ?? 80,
|
||||
width: options.width ?? 800,
|
||||
height: options.height ?? 600,
|
||||
page: options.page ?? 1,
|
||||
scale: options.scale ?? 1.0,
|
||||
};
|
||||
|
||||
// Validate options
|
||||
this.validateOptions(processOptions);
|
||||
|
||||
// Process with worker
|
||||
const response = await this.processWithWorker(arrayBuffer, processOptions, options.timeout);
|
||||
|
||||
// Create blob from response
|
||||
const blob = new Blob([response.imageData], { type: 'image/jpeg' });
|
||||
|
||||
// Generate data URL if requested
|
||||
let dataUrl = '';
|
||||
if (options.generateDataUrl !== false) {
|
||||
dataUrl = await this.blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
return {
|
||||
blob,
|
||||
dimensions: {
|
||||
width: response.width,
|
||||
height: response.height,
|
||||
},
|
||||
size: response.imageData.byteLength,
|
||||
mimeType: 'image/jpeg',
|
||||
dataUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof PreviewError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PreviewError(
|
||||
'PROCESSING_FAILED',
|
||||
'Failed to process PDF',
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
if (this.worker) {
|
||||
// Cancel pending requests
|
||||
for (const [_id, request] of this.pendingRequests) {
|
||||
request.reject(new Error('Worker cleanup'));
|
||||
if (request.timeout) {
|
||||
clearTimeout(request.timeout);
|
||||
}
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
|
||||
// Terminate worker
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various input types to ArrayBuffer
|
||||
*/
|
||||
private async inputToArrayBuffer(input: TWebInputType): Promise<ArrayBuffer> {
|
||||
if (input instanceof ArrayBuffer) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (input instanceof Uint8Array) {
|
||||
return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
|
||||
}
|
||||
|
||||
if (input instanceof File || input instanceof Blob) {
|
||||
return await input.arrayBuffer();
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
// Assume it's a data URL or base64
|
||||
if (input.startsWith('data:')) {
|
||||
const base64 = input.split(',')[1];
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
throw new PreviewError('INVALID_INPUT', 'String input must be a data URL');
|
||||
}
|
||||
|
||||
throw new PreviewError('INVALID_INPUT', 'Unsupported input type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PDF buffer
|
||||
*/
|
||||
private validatePdfBuffer(buffer: ArrayBuffer): void {
|
||||
if (buffer.byteLength < 4) {
|
||||
throw new PreviewError('INVALID_INPUT', 'Input is too small to be a valid PDF');
|
||||
}
|
||||
|
||||
const header = new Uint8Array(buffer, 0, 4);
|
||||
const pdfMagic = new Uint8Array([37, 80, 68, 70]); // %PDF
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (header[i] !== pdfMagic[i]) {
|
||||
throw new PreviewError('INVALID_INPUT', 'Input is not a valid PDF file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate processing options
|
||||
*/
|
||||
private validateOptions(options: Required<IPreviewOptions>): void {
|
||||
if (options.quality < 1 || options.quality > 100) {
|
||||
throw new PreviewError('INVALID_OPTIONS', 'Quality must be between 1 and 100');
|
||||
}
|
||||
|
||||
if (options.width <= 0 || options.height <= 0) {
|
||||
throw new PreviewError('INVALID_OPTIONS', 'Width and height must be positive numbers');
|
||||
}
|
||||
|
||||
if (options.page < 1) {
|
||||
throw new PreviewError('INVALID_OPTIONS', 'Page number must be 1 or greater');
|
||||
}
|
||||
|
||||
if (options.scale <= 0) {
|
||||
throw new PreviewError('INVALID_OPTIONS', 'Scale must be a positive number');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process PDF with worker
|
||||
*/
|
||||
private async processWithWorker(
|
||||
pdfData: ArrayBuffer,
|
||||
options: Required<IPreviewOptions>,
|
||||
timeout = 30000
|
||||
): Promise<IPdfProcessResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = `req_${++this.requestIdCounter}`;
|
||||
|
||||
// Set up timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new PreviewError('WORKER_TIMEOUT', `Worker timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
// Store request
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: timeoutHandle as any,
|
||||
});
|
||||
|
||||
// Send request to worker
|
||||
const request: IPdfProcessRequest = {
|
||||
pdfData,
|
||||
options,
|
||||
};
|
||||
|
||||
this.worker!.postMessage({
|
||||
type: 'PROCESS_PDF',
|
||||
id: requestId,
|
||||
data: request,
|
||||
} as IWorkerMessage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create worker blob from source code
|
||||
*/
|
||||
private async createWorkerBlob(): Promise<Blob> {
|
||||
// In a real implementation, you would bundle the worker code
|
||||
// For now, we'll create a minimal worker that loads PDF.js from CDN
|
||||
const workerCode = `
|
||||
// Import PDF.js from CDN
|
||||
importScripts('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js');
|
||||
|
||||
// Configure PDF.js
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
// Initialize
|
||||
async function initialize() {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
postMessage({ type: 'WORKER_READY', id: 'init' });
|
||||
}
|
||||
|
||||
// Process PDF
|
||||
async function processPdf(requestId, request) {
|
||||
try {
|
||||
const pdf = await pdfjsLib.getDocument({ data: request.pdfData }).promise;
|
||||
const page = await pdf.getPage(request.options.page);
|
||||
|
||||
const viewport = page.getViewport({ scale: request.options.scale });
|
||||
let { width, height } = viewport;
|
||||
|
||||
if (request.options.width && width > request.options.width) {
|
||||
const scale = request.options.width / width;
|
||||
width = request.options.width;
|
||||
height = height * scale;
|
||||
}
|
||||
if (request.options.height && height > request.options.height) {
|
||||
const scale = request.options.height / height;
|
||||
height = request.options.height;
|
||||
width = width * scale;
|
||||
}
|
||||
|
||||
const scaledViewport = page.getViewport({
|
||||
scale: Math.min(width / viewport.width, height / viewport.height) * request.options.scale
|
||||
});
|
||||
|
||||
const canvas = new OffscreenCanvas(scaledViewport.width, scaledViewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
await page.render({ canvasContext: context, viewport: scaledViewport }).promise;
|
||||
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: 'image/jpeg',
|
||||
quality: request.options.quality / 100,
|
||||
});
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
postMessage({
|
||||
type: 'PROCESS_COMPLETE',
|
||||
id: requestId,
|
||||
data: {
|
||||
imageData: arrayBuffer,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
postMessage({
|
||||
type: 'PROCESS_ERROR',
|
||||
id: requestId,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Message handler
|
||||
self.addEventListener('message', async (event) => {
|
||||
const { type, id, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'INIT':
|
||||
await initialize();
|
||||
break;
|
||||
case 'PROCESS_PDF':
|
||||
await processPdf(id, data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-initialize
|
||||
initialize();
|
||||
`;
|
||||
|
||||
return new Blob([workerCode], { type: 'application/javascript' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up worker event handlers
|
||||
*/
|
||||
private setupWorkerEventHandlers(): void {
|
||||
if (!this.worker) return;
|
||||
|
||||
this.worker.addEventListener('message', (event: MessageEvent<IWorkerMessage>) => {
|
||||
const { type, id, data, error } = event.data;
|
||||
|
||||
const request = this.pendingRequests.get(id);
|
||||
if (!request) return;
|
||||
|
||||
// Clear timeout
|
||||
if (request.timeout) {
|
||||
clearTimeout(request.timeout);
|
||||
}
|
||||
|
||||
// Remove from pending
|
||||
this.pendingRequests.delete(id);
|
||||
|
||||
switch (type) {
|
||||
case 'PROCESS_COMPLETE':
|
||||
request.resolve(data as IPdfProcessResponse);
|
||||
break;
|
||||
|
||||
case 'PROCESS_ERROR':
|
||||
request.reject(new PreviewError('WORKER_ERROR', error || 'Unknown worker error'));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.addEventListener('error', (event) => {
|
||||
// Handle worker errors
|
||||
for (const [_id, request] of this.pendingRequests) {
|
||||
request.reject(new PreviewError('WORKER_ERROR', `Worker error: ${event.message}`));
|
||||
if (request.timeout) {
|
||||
clearTimeout(request.timeout);
|
||||
}
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for worker to be ready
|
||||
*/
|
||||
private async waitForWorkerReady(timeout = 10000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
reject(new PreviewError('WORKER_TIMEOUT', 'Worker initialization timeout'));
|
||||
}, timeout);
|
||||
|
||||
const messageHandler = (event: MessageEvent<IWorkerMessage>) => {
|
||||
if (event.data.type === 'WORKER_READY') {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.worker!.removeEventListener('message', messageHandler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
this.worker!.addEventListener('message', messageHandler);
|
||||
this.worker!.postMessage({ type: 'INIT', id: 'init' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert blob to data URL
|
||||
*/
|
||||
private async blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error('Failed to read blob as data URL'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
}
|
191
ts_web/pdfworker.ts
Normal file
191
ts_web/pdfworker.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* PDF.js worker for processing PDFs in the browser
|
||||
* This file runs in a Web Worker context
|
||||
*/
|
||||
|
||||
// Import types for worker context
|
||||
import type {
|
||||
IWorkerMessage,
|
||||
IPdfProcessRequest,
|
||||
IPdfProcessResponse,
|
||||
TWorkerMessageType
|
||||
} from './interfaces.js';
|
||||
import { PreviewError } from './interfaces.js';
|
||||
|
||||
// PDF.js library (loaded from CDN or bundled)
|
||||
declare const pdfjsLib: any;
|
||||
|
||||
/**
|
||||
* Worker context interface
|
||||
*/
|
||||
declare const self: any;
|
||||
|
||||
/**
|
||||
* PDF.js configuration
|
||||
*/
|
||||
const PDFJS_CDN_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||||
const WORKER_CDN_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
|
||||
/**
|
||||
* Worker state
|
||||
*/
|
||||
let isInitialized = false;
|
||||
let pdfjsWorker: any = null;
|
||||
|
||||
/**
|
||||
* Initialize PDF.js in worker context
|
||||
*/
|
||||
async function initializePdfJs(): Promise<void> {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load PDF.js library
|
||||
self.importScripts(PDFJS_CDN_URL);
|
||||
|
||||
if (typeof pdfjsLib === 'undefined') {
|
||||
throw new Error('Failed to load PDF.js library');
|
||||
}
|
||||
|
||||
// Configure PDF.js worker
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = WORKER_CDN_URL;
|
||||
|
||||
isInitialized = true;
|
||||
postMessage({
|
||||
type: 'WORKER_READY',
|
||||
id: 'init',
|
||||
data: { version: pdfjsLib.version }
|
||||
} as IWorkerMessage);
|
||||
} catch (error) {
|
||||
postMessage({
|
||||
type: 'PROCESS_ERROR',
|
||||
id: 'init',
|
||||
error: `Failed to initialize PDF.js: ${error instanceof Error ? error.message : String(error)}`
|
||||
} as IWorkerMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process PDF and generate JPEG preview
|
||||
*/
|
||||
async function processPdf(requestId: string, request: IPdfProcessRequest): Promise<void> {
|
||||
if (!isInitialized) {
|
||||
postMessage({
|
||||
type: 'PROCESS_ERROR',
|
||||
id: requestId,
|
||||
error: 'Worker not initialized'
|
||||
} as IWorkerMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load PDF document
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
data: request.pdfData,
|
||||
cMapUrl: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/cmaps/',
|
||||
cMapPacked: true,
|
||||
});
|
||||
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Validate page number
|
||||
if (request.options.page > pdf.numPages || request.options.page < 1) {
|
||||
throw new Error(`Page ${request.options.page} not found. Document has ${pdf.numPages} pages.`);
|
||||
}
|
||||
|
||||
// Get the specified page
|
||||
const page = await pdf.getPage(request.options.page);
|
||||
|
||||
// Calculate viewport
|
||||
const viewport = page.getViewport({ scale: request.options.scale });
|
||||
|
||||
// Adjust viewport to fit within max dimensions
|
||||
let { width, height } = viewport;
|
||||
if (request.options.width && width > request.options.width) {
|
||||
const scale = request.options.width / width;
|
||||
width = request.options.width;
|
||||
height = height * scale;
|
||||
}
|
||||
if (request.options.height && height > request.options.height) {
|
||||
const scale = request.options.height / height;
|
||||
height = request.options.height;
|
||||
width = width * scale;
|
||||
}
|
||||
|
||||
const scaledViewport = page.getViewport({
|
||||
scale: Math.min(width / viewport.width, height / viewport.height) * request.options.scale
|
||||
});
|
||||
|
||||
// Create canvas and render page
|
||||
const canvas = new OffscreenCanvas(scaledViewport.width, scaledViewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Failed to get 2D rendering context');
|
||||
}
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: scaledViewport,
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
|
||||
// Convert to JPEG
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: 'image/jpeg',
|
||||
quality: request.options.quality / 100,
|
||||
});
|
||||
|
||||
// Convert blob to ArrayBuffer
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
// Send response
|
||||
const response: IPdfProcessResponse = {
|
||||
imageData: arrayBuffer,
|
||||
width: scaledViewport.width,
|
||||
height: scaledViewport.height,
|
||||
};
|
||||
|
||||
postMessage({
|
||||
type: 'PROCESS_COMPLETE',
|
||||
id: requestId,
|
||||
data: response
|
||||
} as IWorkerMessage);
|
||||
|
||||
} catch (error) {
|
||||
postMessage({
|
||||
type: 'PROCESS_ERROR',
|
||||
id: requestId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
} as IWorkerMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle worker messages
|
||||
*/
|
||||
self.addEventListener('message', async (event: MessageEvent<IWorkerMessage>) => {
|
||||
const { type, id, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'INIT':
|
||||
await initializePdfJs();
|
||||
break;
|
||||
|
||||
case 'PROCESS_PDF':
|
||||
await processPdf(id, data as IPdfProcessRequest);
|
||||
break;
|
||||
|
||||
default:
|
||||
postMessage({
|
||||
type: 'PROCESS_ERROR',
|
||||
id: id || 'unknown',
|
||||
error: `Unknown message type: ${type}`
|
||||
} as IWorkerMessage);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-initialize when worker starts
|
||||
initializePdfJs();
|
302
ts_web/smartpreview.ts
Normal file
302
ts_web/smartpreview.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
IPreviewOptions,
|
||||
IPreviewResult,
|
||||
TWebInputType,
|
||||
TSupportedInputFormat,
|
||||
IWebPreviewOptions
|
||||
} from './interfaces.js';
|
||||
import { PreviewError } from './interfaces.js';
|
||||
import { WebPdfProcessor } from './pdfprocessor.js';
|
||||
|
||||
/**
|
||||
* Main SmartPreview class for browser environment
|
||||
* Provides unified API for generating previews from various document formats
|
||||
*/
|
||||
export class SmartPreview {
|
||||
private pdfProcessor: WebPdfProcessor | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* Create a new SmartPreview instance
|
||||
*/
|
||||
constructor() {
|
||||
// Constructor kept minimal following async initialization pattern
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the SmartPreview instance and all processors
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize PDF processor
|
||||
this.pdfProcessor = new WebPdfProcessor();
|
||||
await this.pdfProcessor.init();
|
||||
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
await this.cleanup(); // Cleanup on initialization failure
|
||||
throw new PreviewError(
|
||||
'PROCESSING_FAILED',
|
||||
'Failed to initialize SmartPreview',
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview from input
|
||||
* @param input - File, Blob, ArrayBuffer, Uint8Array, or data URL string
|
||||
* @param options - Preview generation options
|
||||
* @returns Promise resolving to preview result
|
||||
*/
|
||||
public async generatePreview(input: TWebInputType, options: IWebPreviewOptions = {}): Promise<IPreviewResult> {
|
||||
if (!this.isInitialized) {
|
||||
throw new PreviewError('PROCESSING_FAILED', 'SmartPreview not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
throw new PreviewError('INVALID_INPUT', 'Input is required');
|
||||
}
|
||||
|
||||
// Detect format and route to appropriate processor
|
||||
const format = await this.detectFormat(input);
|
||||
|
||||
switch (format) {
|
||||
case 'pdf':
|
||||
if (!this.pdfProcessor) {
|
||||
throw new PreviewError('PROCESSING_FAILED', 'PDF processor not available');
|
||||
}
|
||||
return await this.pdfProcessor.processPreview(input, options);
|
||||
|
||||
default:
|
||||
throw new PreviewError('UNSUPPORTED_FORMAT', `Format '${format}' is not supported`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview from File input (convenience method)
|
||||
* @param file - File object from file input
|
||||
* @param options - Preview generation options
|
||||
* @returns Promise resolving to preview result
|
||||
*/
|
||||
public async generatePreviewFromFile(file: File, options: IWebPreviewOptions = {}): Promise<IPreviewResult> {
|
||||
if (!(file instanceof File)) {
|
||||
throw new PreviewError('INVALID_INPUT', 'Input must be a File object');
|
||||
}
|
||||
|
||||
return await this.generatePreview(file, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview from URL (fetch and process)
|
||||
* @param url - URL to fetch the document from
|
||||
* @param options - Preview generation options
|
||||
* @returns Promise resolving to preview result
|
||||
*/
|
||||
public async generatePreviewFromUrl(url: string, options: IWebPreviewOptions = {}): Promise<IPreviewResult> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return await this.generatePreview(blob, options);
|
||||
} catch (error) {
|
||||
throw new PreviewError(
|
||||
'PROCESSING_FAILED',
|
||||
`Failed to fetch document from URL: ${url}`,
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create download link for preview
|
||||
* @param input - Document input
|
||||
* @param options - Preview generation options
|
||||
* @param filename - Optional filename for download
|
||||
* @returns Promise resolving to download URL and cleanup function
|
||||
*/
|
||||
public async createDownloadLink(
|
||||
input: TWebInputType,
|
||||
options: IWebPreviewOptions = {},
|
||||
filename = 'preview.jpg'
|
||||
): Promise<{ url: string; cleanup: () => void }> {
|
||||
const result = await this.generatePreview(input, options);
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
|
||||
return {
|
||||
url,
|
||||
cleanup: () => URL.revokeObjectURL(url)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger download of preview
|
||||
* @param input - Document input
|
||||
* @param options - Preview generation options
|
||||
* @param filename - Filename for download
|
||||
*/
|
||||
public async downloadPreview(
|
||||
input: TWebInputType,
|
||||
options: IWebPreviewOptions = {},
|
||||
filename = 'preview.jpg'
|
||||
): Promise<void> {
|
||||
const { url, cleanup } = await this.createDownloadLink(input, options, filename);
|
||||
|
||||
try {
|
||||
// Create temporary download link
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = 'none';
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} finally {
|
||||
// Clean up after short delay to ensure download starts
|
||||
setTimeout(cleanup, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported input formats
|
||||
*/
|
||||
public getSupportedFormats(): TSupportedInputFormat[] {
|
||||
return ['pdf'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a format is supported
|
||||
*/
|
||||
public isFormatSupported(format: string): format is TSupportedInputFormat {
|
||||
return this.getSupportedFormats().includes(format as TSupportedInputFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if File API is supported
|
||||
*/
|
||||
public static isFileApiSupported(): boolean {
|
||||
return typeof File !== 'undefined' && typeof FileReader !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Web Workers are supported
|
||||
*/
|
||||
public static isWebWorkerSupported(): boolean {
|
||||
return typeof Worker !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OffscreenCanvas is supported
|
||||
*/
|
||||
public static isOffscreenCanvasSupported(): boolean {
|
||||
return typeof OffscreenCanvas !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser compatibility info
|
||||
*/
|
||||
public static getBrowserCompatibility(): {
|
||||
fileApi: boolean;
|
||||
webWorkers: boolean;
|
||||
offscreenCanvas: boolean;
|
||||
isSupported: boolean;
|
||||
} {
|
||||
const fileApi = this.isFileApiSupported();
|
||||
const webWorkers = this.isWebWorkerSupported();
|
||||
const offscreenCanvas = this.isOffscreenCanvasSupported();
|
||||
|
||||
return {
|
||||
fileApi,
|
||||
webWorkers,
|
||||
offscreenCanvas,
|
||||
isSupported: fileApi && webWorkers && offscreenCanvas
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
if (this.pdfProcessor) {
|
||||
await this.pdfProcessor.cleanup();
|
||||
this.pdfProcessor = null;
|
||||
}
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect document format from input
|
||||
* @private
|
||||
*/
|
||||
private async detectFormat(input: TWebInputType): Promise<TSupportedInputFormat> {
|
||||
try {
|
||||
let buffer: ArrayBuffer;
|
||||
|
||||
if (input instanceof ArrayBuffer) {
|
||||
buffer = input;
|
||||
} else if (input instanceof Uint8Array) {
|
||||
buffer = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
|
||||
} else if (input instanceof File || input instanceof Blob) {
|
||||
// Read first few bytes to detect format
|
||||
const headerBlob = input.slice(0, 8);
|
||||
buffer = await headerBlob.arrayBuffer();
|
||||
} else if (typeof input === 'string') {
|
||||
// Handle data URLs
|
||||
if (input.startsWith('data:')) {
|
||||
const base64 = input.split(',')[1];
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(Math.min(8, binaryString.length));
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
buffer = bytes.buffer;
|
||||
} else {
|
||||
throw new PreviewError('INVALID_INPUT', 'String input must be a data URL');
|
||||
}
|
||||
} else {
|
||||
throw new PreviewError('INVALID_INPUT', 'Unsupported input type');
|
||||
}
|
||||
|
||||
// Check format signatures
|
||||
if (buffer.byteLength >= 4) {
|
||||
const header = new Uint8Array(buffer, 0, 4);
|
||||
|
||||
// PDF signature: %PDF
|
||||
if (header[0] === 37 && header[1] === 80 && header[2] === 68 && header[3] === 70) {
|
||||
return 'pdf';
|
||||
}
|
||||
}
|
||||
|
||||
// Future format detection can be added here
|
||||
// Example: JPEG (FF D8 FF), PNG (89 50 4E 47), etc.
|
||||
|
||||
throw new PreviewError('UNSUPPORTED_FORMAT', 'Unable to detect supported format from input');
|
||||
} catch (error) {
|
||||
if (error instanceof PreviewError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PreviewError(
|
||||
'PROCESSING_FAILED',
|
||||
'Error during format detection',
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory method for convenient instantiation
|
||||
*/
|
||||
public static async create(options: IWebPreviewOptions = {}): Promise<SmartPreview> {
|
||||
const instance = new SmartPreview();
|
||||
await instance.init();
|
||||
return instance;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user