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