feat: separate AGPL libraries and add dynamic WASM loading
- Add WASM settings page for configuring external AGPL modules - Implement dynamic loading for PyMuPDF, Ghostscript, and CoherentPDF - Add Cloudflare Worker proxy for serving WASM files with CORS - Update all affected tool pages to check WASM availability - Add showWasmRequiredDialog for missing module configuration Documentation: - Update README, licensing.html, and docs to clarify AGPL components are not bundled and must be configured separately - Add WASM-PROXY.md deployment guide with recommended source URLs - Rename "CPDF" to "CoherentPDF" for consistency
This commit is contained in:
@@ -1,15 +1,35 @@
|
||||
import { WasmProvider } from './wasm-provider';
|
||||
|
||||
let cpdfLoaded = false;
|
||||
let cpdfLoadPromise: Promise<void> | null = null;
|
||||
|
||||
//TODO: @ALAM,is it better to use a worker to load the cpdf library?
|
||||
// or just use the browser version?
|
||||
export async function ensureCpdfLoaded(): Promise<void> {
|
||||
function getCpdfUrl(): string | undefined {
|
||||
const userUrl = WasmProvider.getUrl('cpdf');
|
||||
if (userUrl) {
|
||||
const baseUrl = userUrl.endsWith('/') ? userUrl : `${userUrl}/`;
|
||||
return `${baseUrl}coherentpdf.browser.min.js`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isCpdfAvailable(): boolean {
|
||||
return WasmProvider.isConfigured('cpdf');
|
||||
}
|
||||
|
||||
export async function isCpdfLoaded(): Promise<void> {
|
||||
if (cpdfLoaded) return;
|
||||
|
||||
if (cpdfLoadPromise) {
|
||||
return cpdfLoadPromise;
|
||||
}
|
||||
|
||||
const cpdfUrl = getCpdfUrl();
|
||||
if (!cpdfUrl) {
|
||||
throw new Error(
|
||||
'CoherentPDF is not configured. Please configure it in WASM Settings.'
|
||||
);
|
||||
}
|
||||
|
||||
cpdfLoadPromise = new Promise((resolve, reject) => {
|
||||
if (typeof (window as any).coherentpdf !== 'undefined') {
|
||||
cpdfLoaded = true;
|
||||
@@ -18,13 +38,14 @@ export async function ensureCpdfLoaded(): Promise<void> {
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = import.meta.env.BASE_URL + 'coherentpdf.browser.min.js';
|
||||
script.src = cpdfUrl;
|
||||
script.onload = () => {
|
||||
cpdfLoaded = true;
|
||||
console.log('[CPDF] Loaded from:', script.src);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load CoherentPDF library'));
|
||||
reject(new Error('Failed to load CoherentPDF library from: ' + cpdfUrl));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
@@ -32,11 +53,7 @@ export async function ensureCpdfLoaded(): Promise<void> {
|
||||
return cpdfLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cpdf instance, ensuring it's loaded first
|
||||
*/
|
||||
export async function getCpdf(): Promise<any> {
|
||||
await ensureCpdfLoaded();
|
||||
await isCpdfLoaded();
|
||||
return (window as any).coherentpdf;
|
||||
}
|
||||
|
||||
|
||||
89
src/js/utils/ghostscript-dynamic-loader.ts
Normal file
89
src/js/utils/ghostscript-dynamic-loader.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { WasmProvider } from './wasm-provider.js';
|
||||
|
||||
let cachedGS: any = null;
|
||||
let loadPromise: Promise<any> | null = null;
|
||||
|
||||
export interface GhostscriptInterface {
|
||||
convertToPDFA(pdfBuffer: ArrayBuffer, profile: string): Promise<ArrayBuffer>;
|
||||
fontToOutline(pdfBuffer: ArrayBuffer): Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export async function loadGhostscript(): Promise<GhostscriptInterface> {
|
||||
if (cachedGS) {
|
||||
return cachedGS;
|
||||
}
|
||||
|
||||
if (loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
const baseUrl = WasmProvider.getUrl('ghostscript');
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
'Ghostscript is not configured. Please configure it in Advanced Settings.'
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
|
||||
try {
|
||||
const wrapperUrl = `${normalizedUrl}gs.js`;
|
||||
|
||||
await loadScript(wrapperUrl);
|
||||
|
||||
const globalScope =
|
||||
typeof globalThis !== 'undefined' ? globalThis : window;
|
||||
|
||||
if (typeof (globalScope as any).loadGS === 'function') {
|
||||
cachedGS = await (globalScope as any).loadGS({
|
||||
baseUrl: normalizedUrl,
|
||||
});
|
||||
} else if (typeof (globalScope as any).GhostscriptWASM === 'function') {
|
||||
cachedGS = new (globalScope as any).GhostscriptWASM(normalizedUrl);
|
||||
await cachedGS.init?.();
|
||||
} else {
|
||||
throw new Error(
|
||||
'Ghostscript wrapper did not expose expected interface. Expected loadGS() or GhostscriptWASM class.'
|
||||
);
|
||||
}
|
||||
|
||||
return cachedGS;
|
||||
} catch (error: any) {
|
||||
loadPromise = null;
|
||||
throw new Error(
|
||||
`Failed to load Ghostscript from ${normalizedUrl}: ${error.message}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
function loadScript(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${url}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.type = 'text/javascript';
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function isGhostscriptAvailable(): boolean {
|
||||
return WasmProvider.isConfigured('ghostscript');
|
||||
}
|
||||
|
||||
export function clearGhostscriptCache(): void {
|
||||
cachedGS = null;
|
||||
loadPromise = null;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* PDF/A Conversion using Ghostscript WASM
|
||||
* * Converts PDFs to PDF/A-1b, PDF/A-2b, or PDF/A-3b format.
|
||||
* Converts PDFs to PDF/A-1b, PDF/A-2b, or PDF/A-3b format.
|
||||
* Requires user to configure Ghostscript URL in WASM Settings.
|
||||
*/
|
||||
|
||||
import loadWASM from '@bentopdf/gs-wasm';
|
||||
import { getWasmBaseUrl, fetchWasmFile } from '../config/wasm-cdn-config.js';
|
||||
import {
|
||||
getWasmBaseUrl,
|
||||
fetchWasmFile,
|
||||
isWasmAvailable,
|
||||
} from '../config/wasm-cdn-config.js';
|
||||
import { PDFDocument, PDFDict, PDFName, PDFArray } from 'pdf-lib';
|
||||
|
||||
interface GhostscriptModule {
|
||||
@@ -34,6 +38,12 @@ export async function convertToPdfA(
|
||||
level: PdfALevel = 'PDF/A-2b',
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<Uint8Array> {
|
||||
if (!isWasmAvailable('ghostscript')) {
|
||||
throw new Error(
|
||||
'Ghostscript is not configured. Please configure it in WASM Settings.'
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.('Loading Ghostscript...');
|
||||
|
||||
let gs: GhostscriptModule;
|
||||
@@ -41,11 +51,16 @@ export async function convertToPdfA(
|
||||
if (cachedGsModule) {
|
||||
gs = cachedGsModule;
|
||||
} else {
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript');
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript')!;
|
||||
const libUrl = `${gsBaseUrl}dist/index.js`;
|
||||
const module = await import(/* @vite-ignore */ libUrl);
|
||||
const loadWASM = module.loadGhostscriptWASM || module.default;
|
||||
|
||||
gs = (await loadWASM({
|
||||
baseUrl: `${gsBaseUrl}assets/`,
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return gsBaseUrl + 'gs.wasm';
|
||||
return gsBaseUrl + 'assets/gs.wasm';
|
||||
}
|
||||
return path;
|
||||
},
|
||||
@@ -73,11 +88,12 @@ export async function convertToPdfA(
|
||||
|
||||
try {
|
||||
const iccFileName = 'sRGB_IEC61966-2-1_no_black_scaling.icc';
|
||||
const response = await fetchWasmFile('ghostscript', iccFileName);
|
||||
const iccLocalPath = `${import.meta.env.BASE_URL}ghostscript-wasm/${iccFileName}`;
|
||||
const response = await fetch(iccLocalPath);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.`
|
||||
`Failed to fetch ICC profile from ${iccLocalPath}: HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,6 +378,12 @@ export async function convertFontsToOutlines(
|
||||
pdfData: Uint8Array,
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<Uint8Array> {
|
||||
if (!isWasmAvailable('ghostscript')) {
|
||||
throw new Error(
|
||||
'Ghostscript is not configured. Please configure it in WASM Settings.'
|
||||
);
|
||||
}
|
||||
|
||||
onProgress?.('Loading Ghostscript...');
|
||||
|
||||
let gs: GhostscriptModule;
|
||||
@@ -369,11 +391,16 @@ export async function convertFontsToOutlines(
|
||||
if (cachedGsModule) {
|
||||
gs = cachedGsModule;
|
||||
} else {
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript');
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript')!;
|
||||
const libUrl = `${gsBaseUrl}dist/index.js`;
|
||||
const module = await import(/* @vite-ignore */ libUrl);
|
||||
const loadWASM = module.loadGhostscriptWASM || module.default;
|
||||
|
||||
gs = (await loadWASM({
|
||||
baseUrl: `${gsBaseUrl}assets/`,
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return gsBaseUrl + 'gs.wasm';
|
||||
return gsBaseUrl + 'assets/gs.wasm';
|
||||
}
|
||||
return path;
|
||||
},
|
||||
|
||||
87
src/js/utils/pymupdf-loader.ts
Normal file
87
src/js/utils/pymupdf-loader.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { WasmProvider } from './wasm-provider.js';
|
||||
|
||||
let cachedPyMuPDF: any = null;
|
||||
let loadPromise: Promise<any> | null = null;
|
||||
|
||||
export interface PyMuPDFInterface {
|
||||
load(): Promise<void>;
|
||||
compressPdf(
|
||||
file: Blob,
|
||||
options: any
|
||||
): Promise<{ blob: Blob; compressedSize: number }>;
|
||||
convertToPdf(file: Blob, ext: string): Promise<Blob>;
|
||||
extractText(file: Blob, options?: any): Promise<string>;
|
||||
extractImages(file: Blob): Promise<Array<{ data: Uint8Array; ext: string }>>;
|
||||
extractTables(file: Blob): Promise<any[]>;
|
||||
toSvg(file: Blob, pageNum: number): Promise<string>;
|
||||
renderPageToImage(file: Blob, pageNum: number, scale: number): Promise<Blob>;
|
||||
getPageCount(file: Blob): Promise<number>;
|
||||
rasterizePdf(file: Blob | File, options: any): Promise<Blob>;
|
||||
}
|
||||
|
||||
export async function loadPyMuPDF(): Promise<any> {
|
||||
if (cachedPyMuPDF) {
|
||||
return cachedPyMuPDF;
|
||||
}
|
||||
|
||||
if (loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
if (!WasmProvider.isConfigured('pymupdf')) {
|
||||
throw new Error(
|
||||
'PyMuPDF is not configured. Please configure it in Advanced Settings.'
|
||||
);
|
||||
}
|
||||
if (!WasmProvider.isConfigured('ghostscript')) {
|
||||
throw new Error(
|
||||
'Ghostscript is not configured. PyMuPDF requires Ghostscript for some operations. Please configure both in Advanced Settings.'
|
||||
);
|
||||
}
|
||||
|
||||
const pymupdfUrl = WasmProvider.getUrl('pymupdf')!;
|
||||
const gsUrl = WasmProvider.getUrl('ghostscript')!;
|
||||
const normalizedPymupdf = pymupdfUrl.endsWith('/')
|
||||
? pymupdfUrl
|
||||
: `${pymupdfUrl}/`;
|
||||
|
||||
try {
|
||||
const wrapperUrl = `${normalizedPymupdf}dist/index.js`;
|
||||
const module = await import(/* @vite-ignore */ wrapperUrl);
|
||||
|
||||
if (typeof module.PyMuPDF !== 'function') {
|
||||
throw new Error(
|
||||
'PyMuPDF module did not export expected PyMuPDF class.'
|
||||
);
|
||||
}
|
||||
|
||||
cachedPyMuPDF = new module.PyMuPDF({
|
||||
assetPath: `${normalizedPymupdf}assets/`,
|
||||
ghostscriptUrl: gsUrl,
|
||||
});
|
||||
|
||||
await cachedPyMuPDF.load();
|
||||
|
||||
console.log('[PyMuPDF Loader] Successfully loaded from CDN');
|
||||
return cachedPyMuPDF;
|
||||
} catch (error: any) {
|
||||
loadPromise = null;
|
||||
throw new Error(`Failed to load PyMuPDF from CDN: ${error.message}`);
|
||||
}
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export function isPyMuPDFAvailable(): boolean {
|
||||
return (
|
||||
WasmProvider.isConfigured('pymupdf') &&
|
||||
WasmProvider.isConfigured('ghostscript')
|
||||
);
|
||||
}
|
||||
|
||||
export function clearPyMuPDFCache(): void {
|
||||
cachedPyMuPDF = null;
|
||||
loadPromise = null;
|
||||
}
|
||||
@@ -1,132 +1,159 @@
|
||||
import { getLibreOfficeConverter } from './libreoffice-loader.js';
|
||||
import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
|
||||
import loadGsWASM from '@bentopdf/gs-wasm';
|
||||
import { setCachedGsModule } from './ghostscript-loader.js';
|
||||
import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
|
||||
import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
|
||||
|
||||
export enum PreloadStatus {
|
||||
IDLE = 'idle',
|
||||
LOADING = 'loading',
|
||||
READY = 'ready',
|
||||
ERROR = 'error'
|
||||
IDLE = 'idle',
|
||||
LOADING = 'loading',
|
||||
READY = 'ready',
|
||||
ERROR = 'error',
|
||||
UNAVAILABLE = 'unavailable',
|
||||
}
|
||||
|
||||
interface PreloadState {
|
||||
libreoffice: PreloadStatus;
|
||||
pymupdf: PreloadStatus;
|
||||
ghostscript: PreloadStatus;
|
||||
libreoffice: PreloadStatus;
|
||||
pymupdf: PreloadStatus;
|
||||
ghostscript: PreloadStatus;
|
||||
}
|
||||
|
||||
const preloadState: PreloadState = {
|
||||
libreoffice: PreloadStatus.IDLE,
|
||||
pymupdf: PreloadStatus.IDLE,
|
||||
ghostscript: PreloadStatus.IDLE
|
||||
libreoffice: PreloadStatus.IDLE,
|
||||
pymupdf: PreloadStatus.IDLE,
|
||||
ghostscript: PreloadStatus.IDLE,
|
||||
};
|
||||
|
||||
let pymupdfInstance: PyMuPDF | null = null;
|
||||
|
||||
export function getPreloadStatus(): Readonly<PreloadState> {
|
||||
return { ...preloadState };
|
||||
}
|
||||
|
||||
export function getPymupdfInstance(): PyMuPDF | null {
|
||||
return pymupdfInstance;
|
||||
}
|
||||
|
||||
async function preloadLibreOffice(): Promise<void> {
|
||||
if (preloadState.libreoffice !== PreloadStatus.IDLE) return;
|
||||
|
||||
preloadState.libreoffice = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting LibreOffice WASM preload...');
|
||||
|
||||
try {
|
||||
const converter = getLibreOfficeConverter();
|
||||
await converter.initialize();
|
||||
preloadState.libreoffice = PreloadStatus.READY;
|
||||
console.log('[Preloader] LibreOffice WASM ready');
|
||||
} catch (e) {
|
||||
preloadState.libreoffice = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] LibreOffice preload failed:', e);
|
||||
}
|
||||
return { ...preloadState };
|
||||
}
|
||||
|
||||
async function preloadPyMuPDF(): Promise<void> {
|
||||
if (preloadState.pymupdf !== PreloadStatus.IDLE) return;
|
||||
if (preloadState.pymupdf !== PreloadStatus.IDLE) return;
|
||||
|
||||
preloadState.pymupdf = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting PyMuPDF preload...');
|
||||
if (!isWasmAvailable('pymupdf')) {
|
||||
preloadState.pymupdf = PreloadStatus.UNAVAILABLE;
|
||||
console.log('[Preloader] PyMuPDF not configured, skipping preload');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pymupdfBaseUrl = getWasmBaseUrl('pymupdf');
|
||||
pymupdfInstance = new PyMuPDF(pymupdfBaseUrl);
|
||||
await pymupdfInstance.load();
|
||||
preloadState.pymupdf = PreloadStatus.READY;
|
||||
console.log('[Preloader] PyMuPDF ready');
|
||||
} catch (e) {
|
||||
preloadState.pymupdf = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] PyMuPDF preload failed:', e);
|
||||
}
|
||||
preloadState.pymupdf = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting PyMuPDF preload...');
|
||||
|
||||
try {
|
||||
const pymupdfBaseUrl = getWasmBaseUrl('pymupdf')!;
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript');
|
||||
const normalizedUrl = pymupdfBaseUrl.endsWith('/')
|
||||
? pymupdfBaseUrl
|
||||
: `${pymupdfBaseUrl}/`;
|
||||
|
||||
const wrapperUrl = `${normalizedUrl}dist/index.js`;
|
||||
const module = await import(/* @vite-ignore */ wrapperUrl);
|
||||
|
||||
const pymupdfInstance = new module.PyMuPDF({
|
||||
assetPath: `${normalizedUrl}assets/`,
|
||||
ghostscriptUrl: gsBaseUrl || '',
|
||||
});
|
||||
await pymupdfInstance.load();
|
||||
preloadState.pymupdf = PreloadStatus.READY;
|
||||
console.log('[Preloader] PyMuPDF ready');
|
||||
} catch (e) {
|
||||
preloadState.pymupdf = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] PyMuPDF preload failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadGhostscript(): Promise<void> {
|
||||
if (preloadState.ghostscript !== PreloadStatus.IDLE) return;
|
||||
if (preloadState.ghostscript !== PreloadStatus.IDLE) return;
|
||||
|
||||
preloadState.ghostscript = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting Ghostscript WASM preload...');
|
||||
if (!isWasmAvailable('ghostscript')) {
|
||||
preloadState.ghostscript = PreloadStatus.UNAVAILABLE;
|
||||
console.log('[Preloader] Ghostscript not configured, skipping preload');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript');
|
||||
const gsModule = await loadGsWASM({
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return gsBaseUrl + 'gs.wasm';
|
||||
}
|
||||
return path;
|
||||
},
|
||||
print: () => { },
|
||||
printErr: () => { },
|
||||
});
|
||||
setCachedGsModule(gsModule as any);
|
||||
preloadState.ghostscript = PreloadStatus.READY;
|
||||
console.log('[Preloader] Ghostscript WASM ready');
|
||||
} catch (e) {
|
||||
preloadState.ghostscript = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] Ghostscript preload failed:', e);
|
||||
preloadState.ghostscript = PreloadStatus.LOADING;
|
||||
console.log('[Preloader] Starting Ghostscript WASM preload...');
|
||||
|
||||
try {
|
||||
const gsBaseUrl = getWasmBaseUrl('ghostscript')!;
|
||||
|
||||
let packageBaseUrl = gsBaseUrl;
|
||||
if (packageBaseUrl.endsWith('/assets/')) {
|
||||
packageBaseUrl = packageBaseUrl.slice(0, -8);
|
||||
} else if (packageBaseUrl.endsWith('/assets')) {
|
||||
packageBaseUrl = packageBaseUrl.slice(0, -7);
|
||||
}
|
||||
const normalizedUrl = packageBaseUrl.endsWith('/')
|
||||
? packageBaseUrl
|
||||
: `${packageBaseUrl}/`;
|
||||
|
||||
const libUrl = `${normalizedUrl}dist/index.js`;
|
||||
const module = await import(/* @vite-ignore */ libUrl);
|
||||
const loadGsWASM = module.loadGhostscriptWASM || module.default;
|
||||
const { setCachedGsModule } = await import('./ghostscript-loader.js');
|
||||
|
||||
const gsModule = await loadGsWASM({
|
||||
baseUrl: `${normalizedUrl}assets/`,
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith('.wasm')) {
|
||||
return `${normalizedUrl}assets/gs.wasm`;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
});
|
||||
setCachedGsModule(gsModule as any);
|
||||
preloadState.ghostscript = PreloadStatus.READY;
|
||||
console.log('[Preloader] Ghostscript WASM ready');
|
||||
} catch (e) {
|
||||
preloadState.ghostscript = PreloadStatus.ERROR;
|
||||
console.warn('[Preloader] Ghostscript preload failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleIdleTask(task: () => Promise<void>): void {
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => task(), { timeout: 5000 });
|
||||
} else {
|
||||
setTimeout(() => task(), 1000);
|
||||
}
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => task(), { timeout: 5000 });
|
||||
} else {
|
||||
setTimeout(() => task(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function startBackgroundPreload(): void {
|
||||
console.log('[Preloader] Scheduling background WASM preloads...');
|
||||
console.log('[Preloader] Scheduling background WASM preloads...');
|
||||
|
||||
const libreOfficePages = [
|
||||
'word-to-pdf', 'excel-to-pdf', 'ppt-to-pdf', 'powerpoint-to-pdf',
|
||||
'docx-to-pdf', 'xlsx-to-pdf', 'pptx-to-pdf', 'csv-to-pdf',
|
||||
'rtf-to-pdf', 'odt-to-pdf', 'ods-to-pdf', 'odp-to-pdf'
|
||||
];
|
||||
const libreOfficePages = [
|
||||
'word-to-pdf',
|
||||
'excel-to-pdf',
|
||||
'ppt-to-pdf',
|
||||
'powerpoint-to-pdf',
|
||||
'docx-to-pdf',
|
||||
'xlsx-to-pdf',
|
||||
'pptx-to-pdf',
|
||||
'csv-to-pdf',
|
||||
'rtf-to-pdf',
|
||||
'odt-to-pdf',
|
||||
'ods-to-pdf',
|
||||
'odp-to-pdf',
|
||||
];
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const isLibreOfficePage = libreOfficePages.some(page => currentPath.includes(page));
|
||||
const currentPath = window.location.pathname;
|
||||
const isLibreOfficePage = libreOfficePages.some((page) =>
|
||||
currentPath.includes(page)
|
||||
);
|
||||
|
||||
if (isLibreOfficePage) {
|
||||
console.log('[Preloader] Skipping preloads on LibreOffice page to save memory');
|
||||
return;
|
||||
}
|
||||
if (isLibreOfficePage) {
|
||||
console.log(
|
||||
'[Preloader] Skipping preloads on LibreOffice page to save memory'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleIdleTask(async () => {
|
||||
console.log('[Preloader] Starting sequential WASM preloads...');
|
||||
scheduleIdleTask(async () => {
|
||||
console.log('[Preloader] Starting sequential WASM preloads...');
|
||||
|
||||
await preloadPyMuPDF();
|
||||
await preloadGhostscript();
|
||||
await preloadPyMuPDF();
|
||||
await preloadGhostscript();
|
||||
|
||||
console.log('[Preloader] Sequential preloads complete (LibreOffice skipped - loaded on demand)');
|
||||
});
|
||||
console.log('[Preloader] Sequential preloads complete');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
328
src/js/utils/wasm-provider.ts
Normal file
328
src/js/utils/wasm-provider.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
export type WasmPackage = 'pymupdf' | 'ghostscript' | 'cpdf';
|
||||
|
||||
interface WasmProviderConfig {
|
||||
pymupdf?: string;
|
||||
ghostscript?: string;
|
||||
cpdf?: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bentopdf:wasm-providers';
|
||||
|
||||
class WasmProviderManager {
|
||||
private config: WasmProviderConfig;
|
||||
private validationCache: Map<WasmPackage, boolean> = 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 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];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
hasAnyProvider(): boolean {
|
||||
return Object.keys(this.config).length > 0;
|
||||
}
|
||||
|
||||
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<WasmPackage, string> = {
|
||||
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 { ...this.config };
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.config = {};
|
||||
this.validationCache.clear();
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
getPackageDisplayName(packageName: WasmPackage): string {
|
||||
const names: Record<WasmPackage, string> = {
|
||||
pymupdf: 'PyMuPDF (Document Processing)',
|
||||
ghostscript: 'Ghostscript (PDF/A Conversion)',
|
||||
cpdf: 'CoherentPDF (Bookmarks & Metadata)',
|
||||
};
|
||||
return names[packageName];
|
||||
}
|
||||
|
||||
getPackageFeatures(packageName: WasmPackage): string[] {
|
||||
const features: Record<WasmPackage, string[]> = {
|
||||
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 = `
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-12 h-12 rounded-full bg-amber-500/20 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">Advanced Feature Required</h3>
|
||||
<p class="text-sm text-gray-400">External processing module needed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-300 mb-4">
|
||||
This feature requires <strong class="text-white">${displayName}</strong> to be configured.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-700/50 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-gray-400 mb-2">Features enabled by this module:</p>
|
||||
<ul class="text-sm text-gray-300 space-y-1">
|
||||
${features
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(f) =>
|
||||
`<li class="flex items-center gap-2"><span class="text-green-400">✓</span> ${f}</li>`
|
||||
)
|
||||
.join('')}
|
||||
${features.length > 4 ? `<li class="text-gray-500">+ ${features.length - 4} more...</li>` : ''}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
This module is licensed under AGPL-3.0. By configuring it, you agree to its license terms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 p-4 flex gap-3">
|
||||
<button id="wasm-modal-cancel" class="flex-1 px-4 py-2.5 rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="wasm-modal-configure" class="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 text-white hover:from-blue-500 hover:to-blue-400 transition-all font-medium">
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user