export type WasmPackage = 'pymupdf' | 'ghostscript' | 'cpdf'; interface WasmProviderConfig { pymupdf?: string; ghostscript?: string; cpdf?: string; } const STORAGE_KEY = 'bentopdf:wasm-providers'; const CDN_DEFAULTS: Record = { pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/', ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/', cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/', }; function envOrDefault(envVar: string | undefined, fallback: string): string { return envVar || fallback; } const ENV_DEFAULTS: Record = { pymupdf: envOrDefault( import.meta.env.VITE_WASM_PYMUPDF_URL, CDN_DEFAULTS.pymupdf ), ghostscript: envOrDefault( import.meta.env.VITE_WASM_GS_URL, CDN_DEFAULTS.ghostscript ), cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf), }; class WasmProviderManager { private config: WasmProviderConfig; private validationCache: Map = new Map(); constructor() { this.config = this.loadConfig(); } private loadConfig(): WasmProviderConfig { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { return JSON.parse(stored); } } catch (e) { console.warn( '[WasmProvider] Failed to load config from localStorage:', e ); } return {}; } private getEnvDefault(packageName: WasmPackage): string | undefined { return ENV_DEFAULTS[packageName]; } private saveConfig(): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(this.config)); } catch (e) { console.error('[WasmProvider] Failed to save config to localStorage:', e); } } getUrl(packageName: WasmPackage): string | undefined { return this.config[packageName] || this.getEnvDefault(packageName); } setUrl(packageName: WasmPackage, url: string): void { const normalizedUrl = url.endsWith('/') ? url : `${url}/`; this.config[packageName] = normalizedUrl; this.validationCache.delete(packageName); this.saveConfig(); } removeUrl(packageName: WasmPackage): void { delete this.config[packageName]; this.validationCache.delete(packageName); this.saveConfig(); } isConfigured(packageName: WasmPackage): boolean { return !!(this.config[packageName] || this.getEnvDefault(packageName)); } isUserConfigured(packageName: WasmPackage): boolean { return !!this.config[packageName]; } hasEnvDefault(packageName: WasmPackage): boolean { return !!this.getEnvDefault(packageName); } hasAnyProvider(): boolean { return ( Object.keys(this.config).length > 0 || Object.values(ENV_DEFAULTS).some(Boolean) ); } async validateUrl( packageName: WasmPackage, url?: string ): Promise<{ valid: boolean; error?: string }> { const testUrl = url || this.config[packageName]; if (!testUrl) { return { valid: false, error: 'No URL configured' }; } try { const parsedUrl = new URL(testUrl); if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return { valid: false, error: 'URL must start with http:// or https://', }; } } catch { return { valid: false, error: 'Invalid URL format. Please enter a valid URL (e.g., https://example.com/wasm/)', }; } const normalizedUrl = testUrl.endsWith('/') ? testUrl : `${testUrl}/`; try { const testFiles: Record = { pymupdf: 'dist/index.js', ghostscript: 'gs.js', cpdf: 'coherentpdf.browser.min.js', }; const testFile = testFiles[packageName]; const fullUrl = `${normalizedUrl}${testFile}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s const response = await fetch(fullUrl, { method: 'GET', mode: 'cors', signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { return { valid: false, error: `Could not find ${testFile} at the specified URL (HTTP ${response.status}). Make sure the file exists.`, }; } const reader = response.body?.getReader(); if (reader) { try { await reader.read(); reader.cancel(); } catch { return { valid: false, error: `File exists but could not be read. Check CORS configuration.`, }; } } const contentType = response.headers.get('content-type'); if ( contentType && !contentType.includes('javascript') && !contentType.includes('application/octet-stream') && !contentType.includes('text/') ) { return { valid: false, error: `The URL returned unexpected content type: ${contentType}. Expected a JavaScript file.`, }; } if (!url || url === this.config[packageName]) { this.validationCache.set(packageName, true); } return { valid: true }; } catch (e: unknown) { const errorMessage = e instanceof Error ? e.message : 'Unknown error'; if ( errorMessage.includes('Failed to fetch') || errorMessage.includes('NetworkError') ) { return { valid: false, error: 'Network error: Could not connect to the URL. Check that the URL is correct and the server allows CORS requests.', }; } return { valid: false, error: `Network error: ${errorMessage}`, }; } } getAllProviders(): WasmProviderConfig { return { pymupdf: this.config.pymupdf || ENV_DEFAULTS.pymupdf, ghostscript: this.config.ghostscript || ENV_DEFAULTS.ghostscript, cpdf: this.config.cpdf || ENV_DEFAULTS.cpdf, }; } clearAll(): void { this.config = {}; this.validationCache.clear(); try { localStorage.removeItem(STORAGE_KEY); } catch (e) { console.error('[WasmProvider] Failed to clear localStorage:', e); } } resetToDefaults(): void { this.clearAll(); } getPackageDisplayName(packageName: WasmPackage): string { const names: Record = { pymupdf: 'PyMuPDF (Document Processing)', ghostscript: 'Ghostscript (PDF/A Conversion)', cpdf: 'CoherentPDF (Bookmarks & Metadata)', }; return names[packageName]; } getPackageFeatures(packageName: WasmPackage): string[] { const features: Record = { pymupdf: [ 'PDF to Text', 'PDF to Markdown', 'PDF to SVG', 'PDF to Images (High Quality)', 'PDF to DOCX', 'PDF to Excel/CSV', 'Extract Images', 'Extract Tables', 'EPUB/MOBI/FB2/XPS/CBZ to PDF', 'Image Compression', 'Deskew PDF', 'PDF Layers', ], ghostscript: ['PDF/A Conversion', 'Font to Outline'], cpdf: [ 'Merge PDF', 'Alternate Merge', 'Split by Bookmarks', 'Table of Contents', 'PDF to JSON', 'JSON to PDF', 'Add/Edit/Extract Attachments', 'Edit Bookmarks', 'PDF Metadata', ], }; return features[packageName]; } } export const WasmProvider = new WasmProviderManager(); export function showWasmRequiredDialog( packageName: WasmPackage, onConfigure?: () => void ): void { const displayName = WasmProvider.getPackageDisplayName(packageName); const features = WasmProvider.getPackageFeatures(packageName); // Create modal const overlay = document.createElement('div'); overlay.className = 'fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4'; overlay.id = 'wasm-required-modal'; const modal = document.createElement('div'); modal.className = 'bg-gray-800 rounded-2xl max-w-md w-full shadow-2xl border border-gray-700'; modal.innerHTML = `

Advanced Feature Required

External processing module needed

This feature requires ${displayName} to be configured.

Features enabled by this module:

    ${features .slice(0, 4) .map( (f) => `
  • ✓ ${f}
  • ` ) .join('')} ${features.length > 4 ? `
  • + ${features.length - 4} more...
  • ` : ''}

This module is licensed under AGPL-3.0. By configuring it, you agree to its license terms.

`; overlay.appendChild(modal); document.body.appendChild(overlay); const cancelBtn = modal.querySelector('#wasm-modal-cancel'); const configureBtn = modal.querySelector('#wasm-modal-configure'); const closeModal = () => { overlay.remove(); }; cancelBtn?.addEventListener('click', closeModal); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); }); configureBtn?.addEventListener('click', () => { closeModal(); if (onConfigure) { onConfigure(); } else { window.location.href = `${import.meta.env.BASE_URL}wasm-settings.html`; } }); } export function requireWasm( packageName: WasmPackage, onAvailable?: () => void ): boolean { if (WasmProvider.isConfigured(packageName)) { onAvailable?.(); return true; } showWasmRequiredDialog(packageName); return false; }