This commit is contained in:
Reece Browne 2025-08-19 00:55:48 +01:00
parent 646cedfb0f
commit aff610448b
5 changed files with 139 additions and 902 deletions

View File

@ -1,26 +0,0 @@
// Web Worker for lightweight data processing (not PDF rendering)
// PDF rendering must stay on main thread due to DOM dependencies
self.onmessage = async function(e) {
const { type, data, jobId } = e.data;
try {
// Handle PING for worker health check
if (type === 'PING') {
self.postMessage({ type: 'PONG', jobId });
return;
}
if (type === 'GENERATE_THUMBNAILS') {
// Web Workers cannot do PDF rendering due to DOM dependencies
// This is expected to fail and trigger main thread fallback
throw new Error('PDF rendering requires main thread (DOM access needed)');
}
} catch (error) {
self.postMessage({
type: 'ERROR',
jobId,
data: { error: error.message }
});
}
};

View File

@ -154,6 +154,7 @@ const PageThumbnail = React.memo(({
: undefined; : undefined;
onReorderPages(page.pageNumber, targetIndex, pagesToMove); onReorderPages(page.pageNumber, targetIndex, pagesToMove);
} }
}
} }
}); });

View File

@ -1,5 +1,5 @@
/** /**
* High-performance thumbnail generation service using Web Workers * High-performance thumbnail generation service using main thread processing
*/ */
interface ThumbnailResult { interface ThumbnailResult {
@ -23,108 +23,17 @@ interface CachedThumbnail {
} }
export class ThumbnailGenerationService { export class ThumbnailGenerationService {
private workers: Worker[] = [];
private activeJobs = new Map<string, { resolve: Function; reject: Function; onProgress?: Function }>();
private jobCounter = 0;
// Session-based thumbnail cache // Session-based thumbnail cache
private thumbnailCache = new Map<string, CachedThumbnail>(); private thumbnailCache = new Map<string, CachedThumbnail>();
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
private currentCacheSize = 0; private currentCacheSize = 0;
constructor(private maxWorkers: number = 3) { constructor(private maxWorkers: number = 3) {
/** // PDF rendering requires DOM access, so we use optimized main thread processing
* NOTE: PDF rendering requires DOM access (document, canvas, etc.) which isn't
* available in Web Workers. This service attempts Web Worker setup but will
* gracefully fallback to optimized main thread processing when Workers fail.
* This is expected behavior, not an error.
*/
this.initializeWorkers();
}
private initializeWorkers(): void {
const workerPromises: Promise<Worker | null>[] = [];
for (let i = 0; i < this.maxWorkers; i++) {
const workerPromise = new Promise<Worker | null>((resolve) => {
try {
const worker = new Worker('/thumbnailWorker.js');
let workerReady = false;
let pingTimeout: NodeJS.Timeout;
worker.onmessage = (e) => {
const { type, data, jobId } = e.data;
// Handle PONG response to confirm worker is ready
if (type === 'PONG') {
workerReady = true;
clearTimeout(pingTimeout);
resolve(worker);
return;
}
const job = this.activeJobs.get(jobId);
if (!job) return;
switch (type) {
case 'PROGRESS':
if (job.onProgress) {
job.onProgress(data);
}
break;
case 'COMPLETE':
job.resolve(data.thumbnails);
this.activeJobs.delete(jobId);
break;
case 'ERROR':
job.reject(new Error(data.error));
this.activeJobs.delete(jobId);
break;
}
};
worker.onerror = (error) => {
clearTimeout(pingTimeout);
worker.terminate();
resolve(null);
};
// Test worker with timeout
pingTimeout = setTimeout(() => {
if (!workerReady) {
worker.terminate();
resolve(null);
}
}, 1000); // Quick timeout since we expect failure
// Send PING to test worker
try {
worker.postMessage({ type: 'PING' });
} catch (pingError) {
clearTimeout(pingTimeout);
worker.terminate();
resolve(null);
}
} catch (error) {
resolve(null);
}
});
workerPromises.push(workerPromise);
}
// Wait for all workers to initialize or fail
Promise.all(workerPromises).then((workers) => {
this.workers = workers.filter((w): w is Worker => w !== null);
// Workers expected to fail due to PDF.js DOM requirements - no logging needed
});
} }
/** /**
* Generate thumbnails for multiple pages using Web Workers * Generate thumbnails for multiple pages using main thread processing
*/ */
async generateThumbnails( async generateThumbnails(
pdfArrayBuffer: ArrayBuffer, pdfArrayBuffer: ArrayBuffer,
@ -132,86 +41,16 @@ export class ThumbnailGenerationService {
options: ThumbnailGenerationOptions = {}, options: ThumbnailGenerationOptions = {},
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> { ): Promise<ThumbnailResult[]> {
// Create unique job ID to track this specific generation request
const jobId = `thumbnails-${++this.jobCounter}`;
// Instead of blocking globally, we'll track individual generation jobs
// This allows multiple thumbnail generation requests to run concurrently
const { const {
scale = 0.2, scale = 0.2,
quality = 0.8, quality = 0.8
batchSize = 20, // Pages per worker
parallelBatches = this.maxWorkers
} = options; } = options;
try { return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
// Check if workers are available, fallback to main thread if not
if (this.workers.length === 0) {
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
}
// Split pages across workers
const workerBatches = this.distributeWork(pageNumbers, this.workers.length);
const jobPromises: Promise<ThumbnailResult[]>[] = [];
for (let i = 0; i < workerBatches.length; i++) {
const batch = workerBatches[i];
if (batch.length === 0) continue;
const worker = this.workers[i % this.workers.length];
const jobId = `job-${++this.jobCounter}`;
const promise = new Promise<ThumbnailResult[]>((resolve, reject) => {
const timeout = setTimeout(() => {
this.activeJobs.delete(jobId);
reject(new Error(`Worker job ${jobId} timed out`));
}, 60000); // 1 minute timeout
// Create job with timeout handling
this.activeJobs.set(jobId, {
resolve: (result: any) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error: any) => {
clearTimeout(timeout);
reject(error);
},
onProgress: onProgress
});
worker.postMessage({
type: 'GENERATE_THUMBNAILS',
jobId,
data: {
pdfArrayBuffer,
pageNumbers: batch,
scale,
quality
}
});
});
jobPromises.push(promise);
}
// Wait for all workers to complete
const results = await Promise.all(jobPromises);
// Flatten and sort results by page number
const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber);
return allThumbnails;
} catch (error) {
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
} finally {
// Individual job completed, no need to reset global flag
}
} }
/** /**
* Fallback thumbnail generation on main thread * Main thread thumbnail generation with batching for UI responsiveness
*/ */
private async generateThumbnailsMainThread( private async generateThumbnailsMainThread(
pdfArrayBuffer: ArrayBuffer, pdfArrayBuffer: ArrayBuffer,
@ -220,13 +59,9 @@ export class ThumbnailGenerationService {
quality: number, quality: number,
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> { ): Promise<ThumbnailResult[]> {
// Import PDF.js dynamically for main thread
const { getDocument } = await import('pdfjs-dist'); const { getDocument } = await import('pdfjs-dist');
// Load PDF once
const pdf = await getDocument({ data: pdfArrayBuffer }).promise; const pdf = await getDocument({ data: pdfArrayBuffer }).promise;
const allResults: ThumbnailResult[] = []; const allResults: ThumbnailResult[] = [];
let completed = 0; let completed = 0;
const batchSize = 5; // Small batches for UI responsiveness const batchSize = 5; // Small batches for UI responsiveness
@ -277,141 +112,81 @@ export class ThumbnailGenerationService {
}); });
} }
// Small delay to keep UI responsive // Yield control to prevent UI blocking
if (i + batchSize < pageNumbers.length) { await new Promise(resolve => setTimeout(resolve, 1));
await new Promise(resolve => setTimeout(resolve, 10));
}
} }
// Clean up await pdf.destroy();
pdf.destroy(); return allResults;
return allResults.filter(r => r.success);
} }
/** /**
* Distribute work evenly across workers * Cache management
*/
private distributeWork(pageNumbers: number[], numWorkers: number): number[][] {
const batches: number[][] = Array(numWorkers).fill(null).map(() => []);
pageNumbers.forEach((pageNum, index) => {
const workerIndex = index % numWorkers;
batches[workerIndex].push(pageNum);
});
return batches;
}
/**
* Generate a single thumbnail (fallback for individual pages)
*/
async generateSingleThumbnail(
pdfArrayBuffer: ArrayBuffer,
pageNumber: number,
options: ThumbnailGenerationOptions = {}
): Promise<string> {
const results = await this.generateThumbnails(pdfArrayBuffer, [pageNumber], options);
if (results.length === 0 || !results[0].success) {
throw new Error(`Failed to generate thumbnail for page ${pageNumber}`);
}
return results[0].thumbnail;
}
/**
* Add thumbnail to cache with size management
*/
addThumbnailToCache(pageId: string, thumbnail: string): void {
const thumbnailSizeBytes = thumbnail.length * 0.75; // Rough base64 size estimate
const now = Date.now();
// Add new thumbnail
this.thumbnailCache.set(pageId, {
thumbnail,
lastUsed: now,
sizeBytes: thumbnailSizeBytes
});
this.currentCacheSize += thumbnailSizeBytes;
// If we exceed 1GB, trigger cleanup
if (this.currentCacheSize > this.maxCacheSizeBytes) {
this.cleanupThumbnailCache();
}
}
/**
* Get thumbnail from cache and update last used timestamp
*/ */
getThumbnailFromCache(pageId: string): string | null { getThumbnailFromCache(pageId: string): string | null {
const cached = this.thumbnailCache.get(pageId); const cached = this.thumbnailCache.get(pageId);
if (!cached) return null; if (cached) {
cached.lastUsed = Date.now();
// Update last used timestamp return cached.thumbnail;
cached.lastUsed = Date.now(); }
return null;
return cached.thumbnail;
} }
/** addThumbnailToCache(pageId: string, thumbnail: string): void {
* Clean up cache using LRU eviction const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
*/
private cleanupThumbnailCache(): void {
const entries = Array.from(this.thumbnailCache.entries());
// Sort by last used (oldest first) // Enforce cache size limits
entries.sort(([, a], [, b]) => a.lastUsed - b.lastUsed); while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) {
this.evictLeastRecentlyUsed();
}
this.thumbnailCache.set(pageId, {
thumbnail,
lastUsed: Date.now(),
sizeBytes
});
this.thumbnailCache.clear(); this.currentCacheSize += sizeBytes;
this.currentCacheSize = 0; }
const targetSize = this.maxCacheSizeBytes * 0.8; // Clean to 80% of limit
private evictLeastRecentlyUsed(): void {
// Keep most recently used entries until we hit target size let oldestEntry: [string, CachedThumbnail] | null = null;
for (let i = entries.length - 1; i >= 0 && this.currentCacheSize < targetSize; i--) { let oldestTime = Date.now();
const [key, value] = entries[i];
this.thumbnailCache.set(key, value); for (const [key, value] of this.thumbnailCache.entries()) {
this.currentCacheSize += value.sizeBytes; if (value.lastUsed < oldestTime) {
oldestTime = value.lastUsed;
oldestEntry = [key, value];
}
}
if (oldestEntry) {
this.thumbnailCache.delete(oldestEntry[0]);
this.currentCacheSize -= oldestEntry[1].sizeBytes;
} }
} }
/**
* Clear all cached thumbnails
*/
clearThumbnailCache(): void {
this.thumbnailCache.clear();
this.currentCacheSize = 0;
}
/**
* Get cache statistics
*/
getCacheStats() { getCacheStats() {
return { return {
entries: this.thumbnailCache.size, size: this.thumbnailCache.size,
totalSizeBytes: this.currentCacheSize, sizeBytes: this.currentCacheSize,
maxSizeBytes: this.maxCacheSizeBytes maxSizeBytes: this.maxCacheSizeBytes
}; };
} }
/**
* Stop generation but keep cache and workers alive
*/
stopGeneration(): void { stopGeneration(): void {
this.activeJobs.clear(); // No-op since we removed workers
}
clearCache(): void {
this.thumbnailCache.clear();
this.currentCacheSize = 0;
} }
/**
* Terminate all workers and clear cache (only on explicit cleanup)
*/
destroy(): void { destroy(): void {
this.workers.forEach(worker => worker.terminate()); this.clearCache();
this.workers = [];
this.activeJobs.clear();
this.clearThumbnailCache();
} }
} }
// Export singleton instance // Global singleton instance
export const thumbnailGenerationService = new ThumbnailGenerationService(); export const thumbnailGenerationService = new ThumbnailGenerationService();

View File

@ -1,292 +0,0 @@
/**
* Typed operation model with discriminated unions
* Centralizes all PDF operations with proper type safety
*/
import { FileId } from './fileContext';
export type OperationId = string;
export type OperationStatus =
| 'idle'
| 'preparing'
| 'uploading'
| 'processing'
| 'completed'
| 'failed'
| 'canceled';
// Base operation interface
export interface BaseOperation {
id: OperationId;
type: string;
status: OperationStatus;
progress: number;
error?: string | null;
createdAt: number;
startedAt?: number;
completedAt?: number;
abortController?: AbortController;
}
// Split operations
export type SplitMode =
| 'pages'
| 'size'
| 'duplicates'
| 'bookmarks'
| 'sections';
export interface SplitPagesParams {
mode: 'pages';
pages: number[];
}
export interface SplitSizeParams {
mode: 'size';
maxSizeBytes: number;
}
export interface SplitDuplicatesParams {
mode: 'duplicates';
tolerance?: number;
}
export interface SplitBookmarksParams {
mode: 'bookmarks';
level?: number;
}
export interface SplitSectionsParams {
mode: 'sections';
sectionCount: number;
}
export type SplitParams =
| SplitPagesParams
| SplitSizeParams
| SplitDuplicatesParams
| SplitBookmarksParams
| SplitSectionsParams;
export interface SplitOperation extends BaseOperation {
type: 'split';
inputFileId: FileId;
params: SplitParams;
outputFileIds?: FileId[];
}
// Merge operations
export interface MergeOperation extends BaseOperation {
type: 'merge';
inputFileIds: FileId[];
params: {
sortBy?: 'name' | 'size' | 'date' | 'custom';
customOrder?: FileId[];
bookmarks?: boolean;
};
outputFileId?: FileId;
}
// Compress operations
export interface CompressOperation extends BaseOperation {
type: 'compress';
inputFileId: FileId;
params: {
level: 'low' | 'medium' | 'high' | 'extreme';
imageQuality?: number; // 0-100
grayscale?: boolean;
removeAnnotations?: boolean;
};
outputFileId?: FileId;
}
// Convert operations
export type ConvertFormat =
| 'pdf'
| 'docx'
| 'pptx'
| 'xlsx'
| 'html'
| 'txt'
| 'jpg'
| 'png';
export interface ConvertOperation extends BaseOperation {
type: 'convert';
inputFileIds: FileId[];
params: {
targetFormat: ConvertFormat;
imageSettings?: {
quality?: number;
dpi?: number;
colorSpace?: 'rgb' | 'grayscale' | 'cmyk';
};
pdfSettings?: {
pdfStandard?: 'PDF/A-1' | 'PDF/A-2' | 'PDF/A-3';
compliance?: boolean;
};
};
outputFileIds?: FileId[];
}
// OCR operations
export interface OcrOperation extends BaseOperation {
type: 'ocr';
inputFileId: FileId;
params: {
languages: string[];
mode: 'searchable' | 'text-only' | 'overlay';
preprocess?: boolean;
deskew?: boolean;
};
outputFileId?: FileId;
}
// Security operations
export interface SecurityOperation extends BaseOperation {
type: 'security';
inputFileId: FileId;
params: {
action: 'encrypt' | 'decrypt' | 'sign' | 'watermark';
password?: string;
permissions?: {
printing?: boolean;
copying?: boolean;
editing?: boolean;
annotations?: boolean;
};
watermark?: {
text: string;
position: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
opacity: number;
};
};
outputFileId?: FileId;
}
// Union type for all operations
export type Operation =
| SplitOperation
| MergeOperation
| CompressOperation
| ConvertOperation
| OcrOperation
| SecurityOperation;
// Operation state management
export interface OperationState {
operations: Record<OperationId, Operation>;
queue: OperationId[];
active: OperationId[];
history: OperationId[];
}
// Operation creation helpers
export function createOperationId(): OperationId {
return `op-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
}
export function createBaseOperation(type: string): BaseOperation {
return {
id: createOperationId(),
type,
status: 'idle',
progress: 0,
error: null,
createdAt: Date.now(),
abortController: new AbortController()
};
}
// Type guards for operations
export function isSplitOperation(op: Operation): op is SplitOperation {
return op.type === 'split';
}
export function isMergeOperation(op: Operation): op is MergeOperation {
return op.type === 'merge';
}
export function isCompressOperation(op: Operation): op is CompressOperation {
return op.type === 'compress';
}
export function isConvertOperation(op: Operation): op is ConvertOperation {
return op.type === 'convert';
}
export function isOcrOperation(op: Operation): op is OcrOperation {
return op.type === 'ocr';
}
export function isSecurityOperation(op: Operation): op is SecurityOperation {
return op.type === 'security';
}
// Operation status helpers
export function isOperationActive(op: Operation): boolean {
return ['preparing', 'uploading', 'processing'].includes(op.status);
}
export function isOperationComplete(op: Operation): boolean {
return op.status === 'completed';
}
export function isOperationFailed(op: Operation): boolean {
return op.status === 'failed';
}
export function canRetryOperation(op: Operation): boolean {
return op.status === 'failed' && !!op.abortController && !op.abortController.signal.aborted;
}
// Operation validation
export function validateSplitParams(params: SplitParams): string | null {
switch (params.mode) {
case 'pages':
if (!params.pages.length) return 'No pages specified';
if (params.pages.some(p => p < 1)) return 'Invalid page numbers';
break;
case 'size':
if (params.maxSizeBytes <= 0) return 'Invalid size limit';
break;
case 'sections':
if (params.sectionCount < 2) return 'Section count must be at least 2';
break;
}
return null;
}
export function validateMergeParams(params: MergeOperation['params'], fileIds: FileId[]): string | null {
if (fileIds.length < 2) return 'At least 2 files required for merge';
if (params.sortBy === 'custom' && !params.customOrder?.length) {
return 'Custom order required when sort by custom is selected';
}
return null;
}
export function validateCompressParams(params: CompressOperation['params']): string | null {
if (params.imageQuality !== undefined && (params.imageQuality < 0 || params.imageQuality > 100)) {
return 'Image quality must be between 0-100';
}
return null;
}
// Operation result types
export interface OperationResult {
operationId: OperationId;
success: boolean;
outputFileIds: FileId[];
error?: string;
metadata?: Record<string, unknown>;
}
// Operation events for pub/sub
export type OperationEvent =
| { type: 'operation:created'; operation: Operation }
| { type: 'operation:started'; operationId: OperationId }
| { type: 'operation:progress'; operationId: OperationId; progress: number }
| { type: 'operation:completed'; operationId: OperationId; result: OperationResult }
| { type: 'operation:failed'; operationId: OperationId; error: string }
| { type: 'operation:canceled'; operationId: OperationId };

View File

@ -7,20 +7,42 @@ export interface ThumbnailWithMetadata {
/** /**
* Calculate thumbnail scale based on file size * Calculate thumbnail scale based on file size
* Smaller files get higher quality, larger files get lower quality
*/ */
export function calculateScaleFromFileSize(fileSize: number): number { export function calculateScaleFromFileSize(fileSize: number): number {
const MB = 1024 * 1024; const MB = 1024 * 1024;
if (fileSize < 1 * MB) return 0.6;
if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality if (fileSize < 5 * MB) return 0.4;
if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality if (fileSize < 15 * MB) return 0.3;
if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality if (fileSize < 30 * MB) return 0.2;
if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality return 0.15;
return 0.15; // 30MB+: Low quality
} }
/** /**
* Generate modern placeholder thumbnail with file extension * Get file type color scheme
*/
function getFileTypeColor(extension: string): { bg: string; text: string; icon: string } {
const ext = extension.toLowerCase();
const colorMap: Record<string, { bg: string; text: string; icon: string }> = {
pdf: { bg: '#ff4444', text: '#ffffff', icon: '📄' },
doc: { bg: '#2196f3', text: '#ffffff', icon: '📝' },
docx: { bg: '#2196f3', text: '#ffffff', icon: '📝' },
xls: { bg: '#4caf50', text: '#ffffff', icon: '📊' },
xlsx: { bg: '#4caf50', text: '#ffffff', icon: '📊' },
ppt: { bg: '#ff9800', text: '#ffffff', icon: '📈' },
pptx: { bg: '#ff9800', text: '#ffffff', icon: '📈' },
txt: { bg: '#607d8b', text: '#ffffff', icon: '📃' },
rtf: { bg: '#795548', text: '#ffffff', icon: '📃' },
odt: { bg: '#3f51b5', text: '#ffffff', icon: '📝' },
ods: { bg: '#009688', text: '#ffffff', icon: '📊' },
odp: { bg: '#e91e63', text: '#ffffff', icon: '📈' }
};
return colorMap[ext] || { bg: '#9e9e9e', text: '#ffffff', icon: '📄' };
}
/**
* Generate simple placeholder thumbnail
*/ */
function generatePlaceholderThumbnail(file: File): string { function generatePlaceholderThumbnail(file: File): string {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -28,301 +50,103 @@ function generatePlaceholderThumbnail(file: File): string {
canvas.height = 150; canvas.height = 150;
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
// Get file extension for color theming const extension = file.name.split('.').pop() || 'file';
const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE'; const colors = getFileTypeColor(extension);
const colorScheme = getFileTypeColorScheme(extension);
// Create gradient background // Colored background
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); ctx.fillStyle = colors.bg;
gradient.addColorStop(0, colorScheme.bgTop); ctx.fillRect(0, 0, canvas.width, canvas.height);
gradient.addColorStop(1, colorScheme.bgBottom);
// Rounded rectangle background // File icon
drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8); ctx.font = '48px Arial';
ctx.fillStyle = gradient;
ctx.fill();
// Subtle shadow/border
ctx.strokeStyle = colorScheme.border;
ctx.lineWidth = 1.5;
ctx.stroke();
// Modern document icon
drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon);
// Extension badge
drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme);
// File size with subtle styling
const sizeText = formatFileSize(file.size);
ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = colorScheme.textSecondary;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15); ctx.fillStyle = colors.text;
ctx.fillText(colors.icon, canvas.width / 2, canvas.height / 2);
// File extension
ctx.font = '12px Arial';
ctx.fillStyle = colors.text;
ctx.fillText(extension.toUpperCase(), canvas.width / 2, canvas.height - 20);
return canvas.toDataURL(); return canvas.toDataURL();
} }
/** /**
* Get color scheme based on file extension * Generate PDF thumbnail from first page
*/ */
function getFileTypeColorScheme(extension: string) { async function generatePdfThumbnail(file: File, scale: number): Promise<string> {
const schemes: Record<string, any> = { const arrayBuffer = await file.arrayBuffer();
// Documents const pdf = await getDocument({ data: arrayBuffer }).promise;
'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
// Spreadsheets
'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
// Presentations
'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
// Archives
'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
'7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
// Default
'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' }
};
return schemes[extension] || schemes['DEFAULT']; const page = await pdf.getPage(1);
} const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
/** if (!context) {
* Draw rounded rectangle throw new Error('Could not get canvas context');
*/ }
function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/** await page.render({ canvasContext: context, viewport }).promise;
* Draw modern document icon const thumbnail = canvas.toDataURL();
*/
function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, color: string) {
const size = 24;
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
// Document body pdf.destroy();
drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3); return thumbnail;
ctx.fill();
// Folded corner
ctx.beginPath();
ctx.moveTo(centerX + size/2 - 6, centerY - size/2);
ctx.lineTo(centerX + size/2, centerY - size/2 + 6);
ctx.lineTo(centerX + size/2 - 6, centerY - size/2 + 6);
ctx.closePath();
ctx.fillStyle = '#FFFFFF40';
ctx.fill();
} }
/**
* Draw extension badge
*/
function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) {
const badgeWidth = Math.max(extension.length * 8 + 16, 40);
const badgeHeight = 22;
// Badge background
drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11);
ctx.fillStyle = colorScheme.badge;
ctx.fill();
// Badge text
ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = colorScheme.textPrimary;
ctx.textAlign = 'center';
ctx.fillText(extension, centerX, centerY + 4);
}
/**
* Format file size for display
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
/** /**
* Generate thumbnail for any file type * Generate thumbnail for any file type
* Returns base64 data URL or undefined if generation fails
*/ */
export async function generateThumbnailForFile(file: File): Promise<string | undefined> { export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
console.log(`🎯 generateThumbnailForFile: Starting for ${file.name} (${file.type}, ${file.size} bytes)`); // Skip very large files
if (file.size >= 100 * 1024 * 1024) {
// Skip thumbnail generation for very large files to avoid memory issues return generatePlaceholderThumbnail(file);
if (file.size >= 100 * 1024 * 1024) { // 100MB limit
console.log('🎯 Skipping thumbnail generation for large file:', file.name);
const placeholder = generatePlaceholderThumbnail(file);
console.log('🎯 Generated placeholder thumbnail for large file:', file.name);
return placeholder;
} }
// Handle image files - use original file directly // Handle image files
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
console.log('🎯 Creating blob URL for image file:', file.name); return URL.createObjectURL(file);
const url = URL.createObjectURL(file);
console.log('🎯 Created image blob URL:', url);
return url;
} }
// Handle PDF files // Handle PDF files
if (!file.type.startsWith('application/pdf')) { if (file.type.startsWith('application/pdf')) {
console.log('🎯 File is not a PDF or image, generating placeholder:', file.name); const scale = calculateScaleFromFileSize(file.size);
const placeholder = generatePlaceholderThumbnail(file); try {
console.log('🎯 Generated placeholder thumbnail for non-PDF file:', file.name); return await generatePdfThumbnail(file, scale);
return placeholder; } catch (error) {
return generatePlaceholderThumbnail(file);
}
} }
// Calculate quality scale based on file size // All other files get placeholder
console.log('🎯 Generating PDF thumbnail for', file.name); return generatePlaceholderThumbnail(file);
const scale = calculateScaleFromFileSize(file.size);
console.log(`🎯 Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
try {
// Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const arrayBuffer = await chunk.arrayBuffer();
const pdf = await getDocument({
data: arrayBuffer,
disableAutoFetch: true,
disableStream: true
}).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale }); // Dynamic scale based on file size
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
// Immediately clean up memory after thumbnail generation
pdf.destroy();
console.log('🎯 PDF thumbnail successfully generated for', file.name, 'size:', thumbnail.length);
return thumbnail;
} catch (error) {
console.warn('🎯 Error generating PDF thumbnail for', file.name, ':', error);
if (error instanceof Error) {
if (error.name === 'InvalidPDFException') {
console.warn(`🎯 PDF structure issue for ${file.name} - trying fallback with full file`);
// Return a placeholder or try with full file instead of chunk
try {
const fullArrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({
data: fullArrayBuffer,
disableAutoFetch: true,
disableStream: true,
verbosity: 0 // Reduce PDF.js warnings
}).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
pdf.destroy();
console.log('🎯 Fallback PDF thumbnail generation succeeded for', file.name);
return thumbnail;
} catch (fallbackError) {
console.warn('🎯 Fallback thumbnail generation also failed for', file.name, fallbackError);
console.log('🎯 Using placeholder thumbnail for', file.name);
return generatePlaceholderThumbnail(file);
}
} else {
console.warn('🎯 Non-PDF error generating thumbnail for', file.name, error);
console.log('🎯 Using placeholder thumbnail for', file.name);
return generatePlaceholderThumbnail(file);
}
}
console.warn('🎯 Unknown error generating thumbnail for', file.name, error);
console.log('🎯 Using placeholder thumbnail for', file.name);
return generatePlaceholderThumbnail(file);
}
} }
/** /**
* Generate thumbnail and extract page count for a PDF file * Generate thumbnail and extract page count for a PDF file
* Returns both thumbnail and metadata in a single pass
*/ */
export async function generateThumbnailWithMetadata(file: File): Promise<ThumbnailWithMetadata> { export async function generateThumbnailWithMetadata(file: File): Promise<ThumbnailWithMetadata> {
console.log(`🎯 generateThumbnailWithMetadata: Starting for ${file.name} (${file.type}, ${file.size} bytes)`);
// Non-PDF files default to 1 page // Non-PDF files default to 1 page
if (!file.type.startsWith('application/pdf')) { if (!file.type.startsWith('application/pdf')) {
console.log('🎯 File is not a PDF, generating placeholder with pageCount=1:', file.name);
const thumbnail = await generateThumbnailForFile(file); const thumbnail = await generateThumbnailForFile(file);
return { thumbnail, pageCount: 1 }; return { thumbnail, pageCount: 1 };
} }
// Skip thumbnail generation for very large files to avoid memory issues // Skip very large files
if (file.size >= 100 * 1024 * 1024) { // 100MB limit if (file.size >= 100 * 1024 * 1024) {
console.log('🎯 Skipping processing for large PDF file:', file.name);
const thumbnail = generatePlaceholderThumbnail(file); const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 }; // Default to 1 for large files return { thumbnail, pageCount: 1 };
} }
// Calculate quality scale based on file size
const scale = calculateScaleFromFileSize(file.size); const scale = calculateScaleFromFileSize(file.size);
console.log(`🎯 Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
try { try {
// Read file chunk for processing const arrayBuffer = await file.arrayBuffer();
const chunkSize = 2 * 1024 * 1024; // 2MB const pdf = await getDocument({ data: arrayBuffer }).promise;
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const arrayBuffer = await chunk.arrayBuffer();
const pdf = await getDocument({
data: arrayBuffer,
disableAutoFetch: true,
disableStream: true,
verbosity: 0
}).promise;
const pageCount = pdf.numPages; const pageCount = pdf.numPages;
console.log(`🎯 PDF ${file.name} has ${pageCount} pages`);
// Generate thumbnail for first page
const page = await pdf.getPage(1); const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale }); const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
@ -337,57 +161,12 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
await page.render({ canvasContext: context, viewport }).promise; await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL(); const thumbnail = canvas.toDataURL();
// Clean up
pdf.destroy();
console.log('🎯 Successfully generated thumbnail with metadata for', file.name, `${pageCount} pages, thumbnail size:`, thumbnail.length); pdf.destroy();
return { thumbnail, pageCount }; return { thumbnail, pageCount };
} catch (error) { } catch (error) {
console.warn('🎯 Error generating PDF thumbnail with metadata for', file.name, ':', error);
// Try fallback with full file if chunk approach failed
if (error instanceof Error && error.name === 'InvalidPDFException') {
try {
console.warn(`🎯 Trying fallback with full file for ${file.name}`);
const fullArrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({
data: fullArrayBuffer,
disableAutoFetch: true,
disableStream: true,
verbosity: 0
}).promise;
const pageCount = pdf.numPages;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext("2d");
if (!context) {
pdf.destroy();
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
pdf.destroy();
console.log('🎯 Fallback successful for', file.name, `${pageCount} pages`);
return { thumbnail, pageCount };
} catch (fallbackError) {
console.warn('🎯 Fallback also failed for', file.name, fallbackError);
}
}
// Final fallback: placeholder thumbnail with default page count
console.log('🎯 Using placeholder thumbnail with default pageCount=1 for', file.name);
const thumbnail = generatePlaceholderThumbnail(file); const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 }; return { thumbnail, pageCount: 1 };
} }
} }