diff --git a/.gitignore b/.gitignore
index b7965c83..c52fb704 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,5 +16,10 @@ _ci_*
.pnpm-store
-# Test artifacts
-packages/*/test/webpack5-angular-project
\ No newline at end of file
+# Test artifacts - only ignore generated files, not source
+packages/*/test/webpack5-angular-project/node_modules
+packages/*/test/webpack5-angular-project/dist
+packages/*/test/webpack5-angular-project/.angular
+packages/*/test/webpack5-angular-project/package-lock.json
+packages/*/test/webpack5-build-test
+.playwright-mcp/
\ No newline at end of file
diff --git a/packages/plugin-print-ready-pdfs-web/.gitignore b/packages/plugin-print-ready-pdfs-web/.gitignore
index e2a5f481..6254187b 100644
--- a/packages/plugin-print-ready-pdfs-web/.gitignore
+++ b/packages/plugin-print-ready-pdfs-web/.gitignore
@@ -30,3 +30,11 @@ pnpm-debug.log*
test/converted-pdfx3.pdf
test-results/
.vercel
+
+# Webpack 5 test projects (generated)
+test/webpack5-build-test/
+test/webpack5-angular-project/node_modules/
+test/webpack5-angular-project/dist/
+test/webpack5-angular-project/.angular/
+test/webpack5-angular-cesdk-project/
+test/cp-command-test/
diff --git a/packages/plugin-print-ready-pdfs-web/CHANGELOG.md b/packages/plugin-print-ready-pdfs-web/CHANGELOG.md
index c8951c5d..be2bc870 100644
--- a/packages/plugin-print-ready-pdfs-web/CHANGELOG.md
+++ b/packages/plugin-print-ready-pdfs-web/CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.1.2] - 2025-12-23
+
+### Fixed
+
+- Fixed Webpack 5 **runtime** compatibility issue where `import.meta.url` was transformed to `file://` URLs at build time, causing "Cannot find module" errors in Angular 17+ and other Webpack 5 bundled environments
+
+### Added
+
+- New `AssetLoader` interface for customizing how plugin assets (gs.js, gs.wasm, ICC profiles) are loaded
+- `BrowserAssetLoader` for browser environments with configurable `assetPath` option
+- `NodeAssetLoader` for Node.js environments (used automatically)
+- New `assetPath` option for `convertToPDFX3()` to specify where assets are served from
+- Separate browser/node entry points via conditional exports in package.json
+- New Playwright test suite for Angular + Webpack 5 runtime verification (`pnpm test:webpack5:angular`)
+
+### Changed
+
+- Refactored asset loading to use dependency injection pattern for better bundler compatibility
+- Vite and native ESM continue to work without configuration (auto-detects via `import.meta.url`)
+
## [1.1.1] - 2025-12-18
### Fixed
diff --git a/packages/plugin-print-ready-pdfs-web/README.md b/packages/plugin-print-ready-pdfs-web/README.md
index 8472ccd2..d34c2d18 100644
--- a/packages/plugin-print-ready-pdfs-web/README.md
+++ b/packages/plugin-print-ready-pdfs-web/README.md
@@ -36,6 +36,96 @@ This plugin automatically converts CE.SDK's RGB PDFs into print-ready files that
npm install @imgly/plugin-print-ready-pdfs-web
```
+## Bundler Setup (Webpack 5 / Angular)
+
+When using bundlers like Webpack 5 or Angular CLI, the plugin's WASM assets need to be copied to your public folder and the `assetPath` option must be provided.
+
+**Why is this needed?** Webpack 5 transforms `import.meta.url` to `file://` URLs at build time, which don't work in browsers. The `assetPath` option tells the plugin where to find its assets at runtime.
+
+### Angular CLI
+
+Add to your `angular.json` in the `assets` array:
+
+```json
+{
+ "glob": "{gs.js,gs.wasm,*.icc}",
+ "input": "node_modules/@imgly/plugin-print-ready-pdfs-web/dist",
+ "output": "/assets/print-ready-pdfs"
+}
+```
+
+Then provide the `assetPath` option:
+
+```javascript
+const printReadyPDF = await convertToPDFX3(pdfBlob, {
+ outputProfile: 'fogra39',
+ assetPath: '/assets/print-ready-pdfs/'
+});
+```
+
+### Webpack 5
+
+Use `copy-webpack-plugin` to copy the assets:
+
+```javascript
+const CopyPlugin = require('copy-webpack-plugin');
+
+module.exports = {
+ plugins: [
+ new CopyPlugin({
+ patterns: [{
+ from: 'node_modules/@imgly/plugin-print-ready-pdfs-web/dist/*.{js,wasm,icc}',
+ to: 'assets/[name][ext]'
+ }]
+ })
+ ]
+};
+```
+
+Then provide the `assetPath` option:
+
+```javascript
+const printReadyPDF = await convertToPDFX3(pdfBlob, {
+ outputProfile: 'fogra39',
+ assetPath: '/assets/'
+});
+```
+
+### Vite / Native ESM
+
+No additional configuration needed. The plugin automatically resolves assets using `import.meta.url`.
+
+### Custom Asset Loading
+
+For advanced use cases (CDN hosting, custom loading logic), implement the `AssetLoader` interface:
+
+```typescript
+import { convertToPDFX3, type AssetLoader } from '@imgly/plugin-print-ready-pdfs-web';
+
+class CDNAssetLoader implements AssetLoader {
+ private cdnBase = 'https://cdn.example.com/pdf-plugin/v1.1.2/';
+
+ async loadGhostscriptModule() {
+ const module = await import(/* webpackIgnore: true */ this.cdnBase + 'gs.js');
+ return module.default;
+ }
+
+ getWasmPath() {
+ return this.cdnBase + 'gs.wasm';
+ }
+
+ async loadICCProfile(name: string) {
+ const response = await fetch(this.cdnBase + name);
+ return response.blob();
+ }
+}
+
+const printReadyPDF = await convertToPDFX3(pdfBlob, {
+ outputProfile: 'fogra39',
+ assetLoader: new CDNAssetLoader()
+});
+```
+
## Quick Start
Just one function call transforms any PDF into a print-ready file:
@@ -143,9 +233,23 @@ interface PDFX3Options {
outputConditionIdentifier?: string; // ICC profile identifier for OutputIntent
outputCondition?: string; // Human-readable output condition description
flattenTransparency?: boolean; // Flatten transparency (default: true for PDF/X-3)
+
+ // Asset loading (for bundler compatibility)
+ assetPath?: string; // URL path where plugin assets are served
+ assetLoader?: AssetLoader; // Custom asset loader (advanced)
}
```
+**Asset Loading Options:**
+
+| Option | When to Use |
+|--------|-------------|
+| Neither | Vite, native ESM - auto-detected via `import.meta.url` |
+| `assetPath` | Webpack 5, Angular - specify where assets are served |
+| `assetLoader` | CDN hosting, custom loading logic - full control |
+
+See [Bundler Setup](#bundler-setup-webpack-5--angular) for configuration examples.
+
**Note:** Batch processing handles each PDF sequentially to avoid overwhelming the WASM module.
**OutputIntent Metadata:**
diff --git a/packages/plugin-print-ready-pdfs-web/esbuild/config.mjs b/packages/plugin-print-ready-pdfs-web/esbuild/config.mjs
index 3ab9b1c3..29ceeb27 100644
--- a/packages/plugin-print-ready-pdfs-web/esbuild/config.mjs
+++ b/packages/plugin-print-ready-pdfs-web/esbuild/config.mjs
@@ -1,87 +1,22 @@
-import chalk from 'chalk';
-import { readFile, copyFile, mkdir, writeFile } from 'fs/promises';
-import { existsSync } from 'fs';
-import { join, dirname } from 'path';
-import { fileURLToPath } from 'url';
-
-import baseConfig from '../../../esbuild/config.mjs';
-import log from '../../../esbuild/log.mjs';
-
/**
- * Add webpackIgnore comments to Node.js module imports in gs.js
- * This prevents Webpack 5 from trying to resolve these modules in browser builds.
- * See: https://github.com/imgly/ubq/issues/11471
+ * esbuild configuration for plugin-print-ready-pdfs-web
+ *
+ * NOTE: This config is used by the shared build infrastructure.
+ * For multi-bundle builds (browser, node, universal), see scripts/build.mjs
+ * which handles the full build process directly.
*/
-function addWebpackIgnoreComments(content) {
- // Transform: await import("module") -> await import(/* webpackIgnore: true */ "module")
- // Transform: await import("path") -> await import(/* webpackIgnore: true */ "path")
- // Also handle other Node.js modules that might be imported
- const nodeModules = ['module', 'path', 'fs', 'url', 'os'];
- let transformed = content;
- for (const mod of nodeModules) {
- // Match: import("module") or import('module') with optional whitespace
- const pattern = new RegExp(`import\\(\\s*["']${mod}["']\\s*\\)`, 'g');
- transformed = transformed.replace(pattern, `import(/* webpackIgnore: true */ "${mod}")`);
- }
+import { readFile } from 'fs/promises';
- return transformed;
-}
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
+import baseConfig from '../../../esbuild/config.mjs';
+import log from '../../../esbuild/log.mjs';
-// Avoid the Experimental Feature warning when using the above.
const packageJson = JSON.parse(
await readFile(new URL('../package.json', import.meta.url))
);
-// Plugin to copy WASM, JS, and ICC profile files to dist
-const copyWasmPlugin = {
- name: 'copy-wasm',
- setup(build) {
- build.onEnd(async () => {
- const distDir = join(__dirname, '../dist');
-
- if (!existsSync(distDir)) {
- await mkdir(distDir, { recursive: true });
- }
-
- // Copy WASM file
- const srcWasm = join(__dirname, '../src/wasm/gs.wasm');
- const distWasm = join(distDir, 'gs.wasm');
- await copyFile(srcWasm, distWasm);
- log(chalk.green('✓ Copied gs.wasm to dist/'));
-
- // Copy and transform gs.js file to add webpackIgnore comments
- // This fixes Webpack 5 compatibility (see https://github.com/imgly/ubq/issues/11471)
- const srcJs = join(__dirname, '../src/wasm/gs.js');
- const distJs = join(distDir, 'gs.js');
- const gsContent = await readFile(srcJs, 'utf-8');
- const transformedContent = addWebpackIgnoreComments(gsContent);
- await writeFile(distJs, transformedContent);
- log(chalk.green('✓ Copied and transformed gs.js to dist/ (added webpackIgnore comments)'));
-
- // Copy ICC profile files
- const iccProfiles = [
- 'GRACoL2013_CRPC6.icc',
- 'ISOcoated_v2_eci.icc',
- 'sRGB_IEC61966-2-1.icc'
- ];
-
- for (const profile of iccProfiles) {
- const srcProfile = join(__dirname, '../src/assets/icc-profiles', profile);
- const distProfile = join(distDir, profile);
- await copyFile(srcProfile, distProfile);
- log(chalk.green(`✓ Copied ${profile} to dist/`));
- }
- });
- }
-};
-
export default ({ isDevelopment }) => {
- log(
- `${chalk.yellow('Building version:')} ${chalk.bold(packageJson.version)}`
- );
+ log(`Building version: ${packageJson.version}`);
const config = baseConfig({
isDevelopment,
@@ -89,14 +24,10 @@ export default ({ isDevelopment }) => {
pluginVersion: packageJson.version
});
- // Add loader configuration for WASM files
config.loader = {
...config.loader,
'.wasm': 'file'
};
- // Add our custom plugin
- config.plugins = [...(config.plugins || []), copyWasmPlugin];
-
return config;
-};
\ No newline at end of file
+}
diff --git a/packages/plugin-print-ready-pdfs-web/package.json b/packages/plugin-print-ready-pdfs-web/package.json
index 3c50ff0c..95f41d6d 100644
--- a/packages/plugin-print-ready-pdfs-web/package.json
+++ b/packages/plugin-print-ready-pdfs-web/package.json
@@ -1,6 +1,6 @@
{
"name": "@imgly/plugin-print-ready-pdfs-web",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "Print-Ready PDFs plugin for CE.SDK editor - PDF/X conversion and export functionality. Contains AGPL-3.0 licensed Ghostscript WASM.",
"keywords": [
"CE.SDK",
@@ -27,8 +27,26 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
- "import": "./dist/index.mjs",
- "types": "./dist/index.d.ts"
+ "node": {
+ "import": "./dist/index.node.mjs",
+ "types": "./dist/index.node.d.ts"
+ },
+ "browser": {
+ "import": "./dist/index.browser.mjs",
+ "types": "./dist/index.browser.d.ts"
+ },
+ "default": {
+ "import": "./dist/index.mjs",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "./browser": {
+ "import": "./dist/index.browser.mjs",
+ "types": "./dist/index.browser.d.ts"
+ },
+ "./node": {
+ "import": "./dist/index.node.mjs",
+ "types": "./dist/index.node.d.ts"
}
},
"homepage": "https://img.ly/products/creative-sdk",
@@ -68,7 +86,11 @@
"test:silent": "pnpm run build && vitest run silent-conversion",
"test:all": "pnpm run test:browser && pnpm run test:integration",
"test:visual": "pnpm run build && node test/visual-test.mjs",
- "test:webpack5": "bash test/webpack5-compatibility-test.sh"
+ "test:webpack5": "bash test/webpack5-compatibility-test.sh",
+ "test:webpack5:angular": "playwright test test/webpack5-angular-runtime.spec.ts --timeout 180000",
+ "test:webpack5:angular:setup": "cd test/webpack5-angular-project && npm install",
+ "test:webpack5:angular:cesdk": "playwright test test/webpack5-angular-cesdk.spec.ts --timeout 300000",
+ "test:webpack5:angular:cesdk:create": "bash scripts/create-angular-cesdk-test-project.sh"
},
"devDependencies": {
"@cesdk/cesdk-js": "~1.61.0",
diff --git a/packages/plugin-print-ready-pdfs-web/scripts/build.mjs b/packages/plugin-print-ready-pdfs-web/scripts/build.mjs
index 5536cb78..9d77fc45 100644
--- a/packages/plugin-print-ready-pdfs-web/scripts/build.mjs
+++ b/packages/plugin-print-ready-pdfs-web/scripts/build.mjs
@@ -1,4 +1,147 @@
import * as esbuild from 'esbuild';
-import config from '../esbuild/config.mjs';
+import chalk from 'chalk';
+import { readFile, copyFile, mkdir, writeFile } from 'fs/promises';
+import { existsSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
-await esbuild.build(config({ isDevelopment: false }));
\ No newline at end of file
+import baseConfig from '../../../esbuild/config.mjs';
+import log from '../../../esbuild/log.mjs';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Add webpackIgnore comments to Node.js module imports in gs.js
+ */
+function addWebpackIgnoreComments(content) {
+ const nodeModules = ['module', 'path', 'fs', 'url', 'os'];
+ let transformed = content;
+
+ for (const mod of nodeModules) {
+ const pattern = new RegExp(`import\\(\\s*["']${mod}["']\\s*\\)`, 'g');
+ transformed = transformed.replace(pattern, `import(/* webpackIgnore: true */ "${mod}")`);
+ }
+
+ return transformed;
+}
+
+// Plugin to replace node-loader with browser stub in browser builds
+const browserNodeLoaderStub = {
+ name: 'browser-node-loader-stub',
+ setup(build) {
+ // Redirect node-loader imports to the browser stub
+ build.onResolve({ filter: /\.\/loaders\/node-loader$/ }, () => ({
+ path: join(__dirname, '../src/loaders/node-loader.browser.ts'),
+ }));
+ }
+};
+
+// Plugin to copy WASM, JS, and ICC profile files to dist
+const copyWasmPlugin = {
+ name: 'copy-wasm',
+ setup(build) {
+ build.onEnd(async () => {
+ const distDir = join(__dirname, '../dist');
+
+ if (!existsSync(distDir)) {
+ await mkdir(distDir, { recursive: true });
+ }
+
+ // Copy WASM file
+ const srcWasm = join(__dirname, '../src/wasm/gs.wasm');
+ const distWasm = join(distDir, 'gs.wasm');
+ await copyFile(srcWasm, distWasm);
+ log(chalk.green('✓ Copied gs.wasm to dist/'));
+
+ // Copy and transform gs.js file
+ const srcJs = join(__dirname, '../src/wasm/gs.js');
+ const distJs = join(distDir, 'gs.js');
+ const gsContent = await readFile(srcJs, 'utf-8');
+ const transformedContent = addWebpackIgnoreComments(gsContent);
+ await writeFile(distJs, transformedContent);
+ log(chalk.green('✓ Copied and transformed gs.js to dist/'));
+
+ // Copy ICC profile files
+ const iccProfiles = [
+ 'GRACoL2013_CRPC6.icc',
+ 'ISOcoated_v2_eci.icc',
+ 'sRGB_IEC61966-2-1.icc'
+ ];
+
+ for (const profile of iccProfiles) {
+ const srcProfile = join(__dirname, '../src/assets/icc-profiles', profile);
+ const distProfile = join(distDir, profile);
+ await copyFile(srcProfile, distProfile);
+ log(chalk.green(`✓ Copied ${profile} to dist/`));
+ }
+
+ // Copy type declaration files
+ const typesDir = join(distDir, 'types');
+ if (!existsSync(typesDir)) {
+ await mkdir(typesDir, { recursive: true });
+ }
+ const typeFiles = ['pdfx.ts', 'ghostscript.ts', 'index.ts', 'asset-loader.ts'];
+ for (const typeFile of typeFiles) {
+ const srcType = join(__dirname, '../src/types', typeFile);
+ const distType = join(typesDir, typeFile);
+ if (existsSync(srcType)) {
+ await copyFile(srcType, distType);
+ }
+ }
+ log(chalk.green('✓ Copied type declarations to dist/types/'));
+ });
+ }
+};
+
+async function build() {
+ const packageJson = JSON.parse(
+ await readFile(new URL('../package.json', import.meta.url))
+ );
+
+ log(`${chalk.yellow('Building version:')} ${chalk.bold(packageJson.version)}`);
+
+ const isDevelopment = false;
+ const commonExternal = ['@cesdk/cesdk-js'];
+ const nodeExternal = ['path', 'url', 'fs', 'os', 'module'];
+
+ // Helper to create config
+ const createConfig = (entryPoint, outfile, platform, external, extraPlugins = []) => {
+ const config = baseConfig({
+ isDevelopment,
+ external,
+ pluginVersion: packageJson.version
+ });
+
+ config.entryPoints = [entryPoint];
+ config.outfile = outfile;
+ config.platform = platform;
+ config.loader = { ...config.loader, '.wasm': 'file' };
+ config.plugins = [...(config.plugins || []), ...extraPlugins];
+
+ return config;
+ };
+
+ // Build all bundles
+ const configs = [
+ // Main/universal bundle (copies assets)
+ createConfig('src/index.ts', 'dist/index.mjs', 'neutral', [...commonExternal, ...nodeExternal], [copyWasmPlugin]),
+ // Browser bundle - use stub for node-loader to avoid bundling Node.js-specific code
+ createConfig('src/index.browser.ts', 'dist/index.browser.mjs', 'browser', [...commonExternal], [browserNodeLoaderStub]),
+ // Node.js bundle
+ createConfig('src/index.node.ts', 'dist/index.node.mjs', 'node', [...commonExternal, ...nodeExternal], []),
+ ];
+
+ // Build each config sequentially to avoid race conditions with the copy plugin
+ for (const config of configs) {
+ log(chalk.blue(`Building ${config.outfile}...`));
+ await esbuild.build(config);
+ log(chalk.green(`✓ Built ${config.outfile}`));
+ }
+
+ log(chalk.green('\n✓ All bundles built successfully!'));
+}
+
+build().catch((error) => {
+ console.error(chalk.red('Build failed:'), error);
+ process.exit(1);
+});
diff --git a/packages/plugin-print-ready-pdfs-web/scripts/create-angular-cesdk-test-project.sh b/packages/plugin-print-ready-pdfs-web/scripts/create-angular-cesdk-test-project.sh
new file mode 100755
index 00000000..0030de93
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/scripts/create-angular-cesdk-test-project.sh
@@ -0,0 +1,675 @@
+#!/bin/bash
+# Creates Angular 18 + Webpack 5 + CE.SDK + print-ready-pdfs test project
+# This script generates a complete test project for verifying Webpack 5 compatibility
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+PROJECT_DIR="$PLUGIN_DIR/test/webpack5-angular-cesdk-project"
+PROJECT_NAME="webpack5-angular-cesdk-project"
+
+echo "=== Angular + Webpack 5 + CE.SDK Test Project Generator ==="
+echo "Plugin directory: $PLUGIN_DIR"
+echo "Project directory: $PROJECT_DIR"
+
+# Check if project already exists
+if [ -d "$PROJECT_DIR" ]; then
+ echo "Project already exists at $PROJECT_DIR"
+ echo "To recreate, delete the directory first: rm -rf $PROJECT_DIR"
+ exit 0
+fi
+
+# Check for required tools
+command -v node >/dev/null 2>&1 || { echo "Error: node is required but not installed."; exit 1; }
+command -v npm >/dev/null 2>&1 || { echo "Error: npm is required but not installed."; exit 1; }
+
+echo ""
+echo "Step 1: Creating project directory..."
+mkdir -p "$PROJECT_DIR"
+cd "$PROJECT_DIR"
+
+echo ""
+echo "Step 2: Creating package.json..."
+cat > package.json << 'PACKAGE_JSON'
+{
+ "name": "webpack5-angular-cesdk-project",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "serve": "npx http-server dist/webpack5-angular-cesdk-project -p 4299 --cors"
+ },
+ "dependencies": {
+ "@angular/animations": "^18.0.0",
+ "@angular/common": "^18.0.0",
+ "@angular/compiler": "^18.0.0",
+ "@angular/core": "^18.0.0",
+ "@angular/forms": "^18.0.0",
+ "@angular/platform-browser": "^18.0.0",
+ "@angular/platform-browser-dynamic": "^18.0.0",
+ "@cesdk/cesdk-js": "^1.66.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.14.0"
+ },
+ "devDependencies": {
+ "@angular-builders/custom-webpack": "^18.0.0",
+ "@angular-devkit/build-angular": "^18.0.0",
+ "@angular/cli": "^18.0.0",
+ "@angular/compiler-cli": "^18.0.0",
+ "@types/node": "^20.0.0",
+ "http-server": "^14.1.1",
+ "typescript": "~5.4.0"
+ }
+}
+PACKAGE_JSON
+
+echo ""
+echo "Step 3: Creating angular.json..."
+cat > angular.json << 'ANGULAR_JSON'
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "webpack5-angular-cesdk-project": {
+ "projectType": "application",
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-builders/custom-webpack:browser",
+ "options": {
+ "outputPath": "dist/webpack5-angular-cesdk-project",
+ "index": "src/index.html",
+ "main": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets",
+ {
+ "glob": "*.wasm",
+ "input": "node_modules/@imgly/plugin-print-ready-pdfs-web/dist",
+ "output": "/assets/print-ready-pdfs"
+ },
+ {
+ "glob": "gs.js",
+ "input": "node_modules/@imgly/plugin-print-ready-pdfs-web/dist",
+ "output": "/assets/print-ready-pdfs"
+ },
+ {
+ "glob": "*.icc",
+ "input": "node_modules/@imgly/plugin-print-ready-pdfs-web/dist",
+ "output": "/assets/print-ready-pdfs"
+ }
+ ],
+ "styles": ["src/styles.css"],
+ "scripts": [],
+ "customWebpackConfig": {
+ "path": "./webpack.config.js"
+ }
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "5mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kb",
+ "maximumError": "4kb"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-builders/custom-webpack:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "webpack5-angular-cesdk-project:build:production"
+ },
+ "development": {
+ "buildTarget": "webpack5-angular-cesdk-project:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ }
+ }
+ }
+ }
+}
+ANGULAR_JSON
+
+echo ""
+echo "Step 4: Creating webpack.config.js..."
+cat > webpack.config.js << 'WEBPACK_CONFIG'
+// Custom Webpack 5 configuration for Angular + CE.SDK + print-ready-pdfs plugin
+module.exports = {
+ resolve: {
+ fallback: {
+ // Node.js polyfills - not needed in browser
+ fs: false,
+ path: false,
+ crypto: false
+ }
+ },
+ module: {
+ rules: [
+ // Handle .wasm files
+ {
+ test: /\.wasm$/,
+ type: 'asset/resource'
+ }
+ ]
+ },
+ // Ignore Node.js specific modules that might be referenced
+ externals: {
+ 'fs': 'commonjs fs',
+ 'path': 'commonjs path'
+ }
+};
+WEBPACK_CONFIG
+
+echo ""
+echo "Step 5: Creating tsconfig.json..."
+cat > tsconfig.json << 'TSCONFIG'
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": ["ES2022", "dom"],
+ "useDefineForClassFields": false
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
+TSCONFIG
+
+echo ""
+echo "Step 6: Creating tsconfig.app.json..."
+cat > tsconfig.app.json << 'TSCONFIG_APP'
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": ["node"]
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.d.ts"]
+}
+TSCONFIG_APP
+
+echo ""
+echo "Step 7: Creating src directory structure..."
+mkdir -p src/app
+mkdir -p src/assets
+
+echo ""
+echo "Step 8: Creating src/index.html..."
+cat > src/index.html << 'INDEX_HTML'
+
+
+
+
+ Angular + Webpack 5 + CE.SDK + PDF/X-3 Test
+
+
+
+
+
+
+
+
+INDEX_HTML
+
+echo ""
+echo "Step 9: Creating src/main.ts..."
+cat > src/main.ts << 'MAIN_TS'
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent)
+ .catch((err) => console.error(err));
+MAIN_TS
+
+echo ""
+echo "Step 10: Creating src/styles.css..."
+cat > src/styles.css << 'STYLES_CSS'
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background-color: #f5f5f5;
+}
+STYLES_CSS
+
+echo ""
+echo "Step 11: Creating src/app/app.component.ts..."
+cat > src/app/app.component.ts << 'APP_COMPONENT_TS'
+import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import CreativeEditorSDK from '@cesdk/cesdk-js';
+import { convertToPDFX3 } from '@imgly/plugin-print-ready-pdfs-web';
+
+// Extend Window interface for test results
+declare global {
+ interface Window {
+ testResults: {
+ cesdkReady: boolean;
+ importSuccess: boolean;
+ importError: string | null;
+ conversionAttempted: boolean;
+ conversionSuccess: boolean;
+ conversionError: string | null;
+ pdfSize: number | null;
+ testComplete: boolean;
+ };
+ cesdk: any;
+ triggerExport: () => Promise;
+ }
+}
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [CommonModule, FormsModule],
+ template: `
+
+
Angular + Webpack 5 + CE.SDK + PDF/X-3 Test
+
+
+
Status
+
+ Plugin Import:
+ {{ importSuccess ? 'Success' : (importError || 'Pending...') }}
+
+
+ CE.SDK:
+ {{ cesdkReady ? 'Ready' : 'Loading...' }}
+
+
+ PDF/X-3 Conversion:
+ {{ conversionStatus }}
+
+
+
+
+
+
+
+
+
+
+
+
Results
+
{{ resultsJson }}
+
+
+ `,
+ styles: [`
+ .container {
+ padding: 20px;
+ max-width: 1400px;
+ margin: 0 auto;
+ }
+ h1 {
+ color: #333;
+ margin-bottom: 20px;
+ }
+ .status-panel {
+ background: white;
+ padding: 15px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+ .status-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px 0;
+ border-bottom: 1px solid #eee;
+ }
+ .status-item:last-child {
+ border-bottom: none;
+ }
+ .status-item.success {
+ color: #28a745;
+ }
+ .status-item.error {
+ color: #dc3545;
+ }
+ .controls {
+ display: flex;
+ gap: 15px;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+ select {
+ padding: 8px 12px;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ }
+ button {
+ padding: 10px 20px;
+ background: #007bff;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ }
+ button:hover:not(:disabled) {
+ background: #0056b3;
+ }
+ button:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ }
+ .cesdk-container {
+ width: 100%;
+ height: 600px;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
+ }
+ .results {
+ margin-top: 20px;
+ background: white;
+ padding: 15px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+ .results pre {
+ background: #f8f9fa;
+ padding: 10px;
+ border-radius: 4px;
+ overflow-x: auto;
+ }
+ `]
+})
+export class AppComponent implements OnInit, OnDestroy {
+ @ViewChild('cesdkContainer', { static: true }) cesdkContainer!: ElementRef;
+
+ cesdk: any = null;
+ cesdkReady = false;
+ importSuccess = false;
+ importError: string | null = null;
+ conversionSuccess = false;
+ conversionError: string | null = null;
+ isExporting = false;
+ selectedProfile = 'fogra39';
+ pdfSize: number | null = null;
+
+ get conversionStatus(): string {
+ if (this.conversionError) return this.conversionError;
+ if (this.conversionSuccess) return `Success (${this.pdfSize} bytes)`;
+ if (this.isExporting) return 'Converting...';
+ return 'Not started';
+ }
+
+ get resultsJson(): string {
+ return JSON.stringify(window.testResults, null, 2);
+ }
+
+ // Test mode from query params (for Playwright tests)
+ // - normal: Uses assetPath (default)
+ // - testError: Omits assetPath to test error message
+ // - custom: Uses custom assetPath from query param
+ testMode: 'normal' | 'testError' | 'custom' = 'normal';
+ customAssetPath: string | null = null;
+
+ ngOnInit() {
+ // Check for test mode via query parameter
+ const urlParams = new URLSearchParams(window.location.search);
+ const customPath = urlParams.get('assetPath');
+
+ if (customPath) {
+ this.testMode = 'custom';
+ this.customAssetPath = customPath;
+ console.log('Test mode: custom assetPath:', customPath);
+ } else if (urlParams.get('testError') === 'true') {
+ this.testMode = 'testError';
+ console.log('Test mode: testError (no assetPath, expecting error)');
+ }
+
+ // Initialize test results for Playwright
+ window.testResults = {
+ cesdkReady: false,
+ importSuccess: false,
+ importError: null,
+ conversionAttempted: false,
+ conversionSuccess: false,
+ conversionError: null,
+ pdfSize: null,
+ testComplete: false
+ };
+
+ // Check import
+ try {
+ if (typeof convertToPDFX3 === 'function') {
+ this.importSuccess = true;
+ window.testResults.importSuccess = true;
+ console.log('Plugin import successful');
+ }
+ } catch (error: any) {
+ this.importError = error.message;
+ window.testResults.importError = error.message;
+ console.error('Plugin import failed:', error);
+ }
+
+ // Initialize CE.SDK
+ this.initCESDK();
+
+ // Expose export function for Playwright
+ window.triggerExport = () => this.exportPDF();
+ }
+
+ ngOnDestroy() {
+ if (this.cesdk) {
+ this.cesdk.dispose();
+ }
+ }
+
+ async initCESDK() {
+ try {
+ console.log('Initializing CE.SDK...');
+ this.cesdk = await CreativeEditorSDK.create(this.cesdkContainer.nativeElement, {
+ baseURL: 'https://cdn.img.ly/packages/imgly/cesdk-js/1.66.1/assets',
+ callbacks: {
+ onUpload: 'local'
+ }
+ });
+
+ // Load default scene
+ await this.cesdk.createDesignScene();
+
+ this.cesdkReady = true;
+ window.testResults.cesdkReady = true;
+ window.cesdk = this.cesdk;
+ console.log('CE.SDK initialized successfully');
+ } catch (error: any) {
+ console.error('CE.SDK initialization failed:', error);
+ this.cesdkReady = false;
+ }
+ }
+
+ async exportPDF() {
+ if (!this.cesdk || this.isExporting) return;
+
+ this.isExporting = true;
+ this.conversionError = null;
+ this.conversionSuccess = false;
+ window.testResults.conversionAttempted = true;
+
+ try {
+ console.log('Starting PDF export...');
+
+ // Get the current page
+ const engine = this.cesdk.engine;
+ const pages = engine.scene.getPages();
+ if (pages.length === 0) {
+ throw new Error('No pages in scene');
+ }
+
+ // Export as PDF
+ console.log('Exporting PDF from CE.SDK...');
+ const pdfBlob = await engine.block.export(pages[0], 'application/pdf');
+ console.log(`PDF exported: ${pdfBlob.size} bytes`);
+
+ // Convert to PDF/X-3
+ // Note: assetPath is required for Webpack 5/Angular because import.meta.url
+ // is transformed to a file:// URL that doesn't work in browsers
+ console.log(`Converting to PDF/X-3 with profile: ${this.selectedProfile}...`);
+ console.log(`Test mode: ${this.testMode}`);
+
+ // Build options based on test mode
+ const options: any = {
+ outputProfile: this.selectedProfile as any,
+ title: 'Angular Webpack5 Test Export',
+ };
+
+ if (this.testMode === 'custom' && this.customAssetPath) {
+ options.assetPath = this.customAssetPath;
+ console.log('Using custom assetPath:', this.customAssetPath);
+ } else if (this.testMode === 'normal') {
+ options.assetPath = '/assets/print-ready-pdfs/';
+ console.log('Using default assetPath: /assets/print-ready-pdfs/');
+ } else {
+ console.log('No assetPath provided (testError mode)');
+ }
+
+ const pdfX3Blob = await convertToPDFX3(pdfBlob, options);
+
+ this.pdfSize = pdfX3Blob.size;
+ this.conversionSuccess = true;
+ window.testResults.conversionSuccess = true;
+ window.testResults.pdfSize = pdfX3Blob.size;
+ window.testResults.testComplete = true;
+ console.log(`PDF/X-3 conversion successful: ${pdfX3Blob.size} bytes`);
+
+ // Download the file
+ const url = URL.createObjectURL(pdfX3Blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `print-ready-${this.selectedProfile}.pdf`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ } catch (error: any) {
+ console.error('Export failed:', error);
+ this.conversionError = error.message;
+ window.testResults.conversionError = error.message;
+ window.testResults.testComplete = true;
+ } finally {
+ this.isExporting = false;
+ }
+ }
+}
+APP_COMPONENT_TS
+
+echo ""
+echo "Step 12: Creating favicon.ico placeholder..."
+# Create a minimal favicon (1x1 transparent PNG converted to ICO format)
+touch src/favicon.ico
+
+echo ""
+echo "Step 12.5: Creating type declaration for plugin..."
+cat > src/plugin.d.ts << 'PLUGIN_DTS'
+// Type declaration for @imgly/plugin-print-ready-pdfs-web
+// This provides types for the local symlinked plugin
+declare module '@imgly/plugin-print-ready-pdfs-web' {
+ export interface PDFX3Options {
+ outputProfile: 'fogra39' | 'gracol' | 'srgb' | 'custom' | string;
+ customProfile?: Blob;
+ title?: string;
+ flattenTransparency?: boolean;
+ outputConditionIdentifier?: string;
+ outputCondition?: string;
+ /**
+ * Base URL path where plugin assets (gs.js, gs.wasm, *.icc) are served from.
+ * Required for bundled environments (Webpack 5, Angular).
+ */
+ assetPath?: string;
+ }
+
+ export function convertToPDFX3(
+ input: Blob | Blob[],
+ options: PDFX3Options
+ ): Promise;
+}
+PLUGIN_DTS
+
+echo ""
+echo "Step 13: Installing dependencies..."
+npm install
+
+echo ""
+echo "Step 14: Linking local plugin..."
+# Create symlink to local plugin ROOT (not dist) to mimic npm package structure
+# When published, npm package has: package-root/dist/{gs.js,gs.wasm,*.icc,index.mjs}
+# So integrators use: node_modules/@imgly/plugin-print-ready-pdfs-web/dist/
+mkdir -p node_modules/@imgly
+if [ -L "node_modules/@imgly/plugin-print-ready-pdfs-web" ]; then
+ rm "node_modules/@imgly/plugin-print-ready-pdfs-web"
+fi
+ln -s "$PLUGIN_DIR" "node_modules/@imgly/plugin-print-ready-pdfs-web"
+
+echo ""
+echo "=== Project created successfully! ==="
+echo ""
+echo "To build and serve the project:"
+echo " cd $PROJECT_DIR"
+echo " npm run build"
+echo " npm run serve"
+echo ""
+echo "Then open http://localhost:4299 in your browser"
diff --git a/packages/plugin-print-ready-pdfs-web/src/core/ghostscript-loader.ts b/packages/plugin-print-ready-pdfs-web/src/core/ghostscript-loader.ts
index e7218db3..320fb441 100644
--- a/packages/plugin-print-ready-pdfs-web/src/core/ghostscript-loader.ts
+++ b/packages/plugin-print-ready-pdfs-web/src/core/ghostscript-loader.ts
@@ -1,10 +1,16 @@
import type { EmscriptenModule } from '../types/ghostscript';
+import type { AssetLoader } from '../types/asset-loader';
import { Logger } from '../utils/logger';
import { BrowserDetection } from '../utils/browser-detection';
import createGhostscriptModule from '../wasm/ghostscript-module';
export interface LoaderOptions {
timeout?: number;
+ /**
+ * Asset loader for loading plugin assets.
+ * Required - use BrowserAssetLoader or NodeAssetLoader.
+ */
+ assetLoader: AssetLoader;
}
export class GhostscriptLoader {
@@ -12,7 +18,9 @@ export class GhostscriptLoader {
private static readonly TIMEOUT_MS = 30000;
- static async load(options: LoaderOptions = {}): Promise {
+ static async load(options: LoaderOptions): Promise {
+ // If already loaded, return the cached instance
+ // (the WASM module is a singleton and assetLoader should be consistent within an app)
if (this.instance) {
return this.instance;
}
@@ -37,7 +45,10 @@ export class GhostscriptLoader {
try {
logger.info('Loading bundled Ghostscript (AGPL-3.0 licensed)');
logger.info('Source available at: https://github.com/imgly/plugins');
- return await this.loadWithTimeout(() => this.loadFromBundle(), timeout);
+ return await this.loadWithTimeout(
+ () => this.loadFromBundle(options.assetLoader),
+ timeout
+ );
} catch (error) {
logger.error('Ghostscript loading failed', {
error: (error as Error).message,
@@ -48,14 +59,16 @@ export class GhostscriptLoader {
}
}
- private static async loadFromBundle(): Promise {
+ private static async loadFromBundle(
+ assetLoader: AssetLoader
+ ): Promise {
// Use the bundled WASM module with proper configuration
const logger = new Logger('GhostscriptInit');
try {
logger.info('Initializing Ghostscript module');
- const module = await createGhostscriptModule();
+ const module = await createGhostscriptModule({ assetLoader });
logger.info('Ghostscript module initialized successfully', {
version: module.version || 'unknown',
diff --git a/packages/plugin-print-ready-pdfs-web/src/index.browser.ts b/packages/plugin-print-ready-pdfs-web/src/index.browser.ts
new file mode 100644
index 00000000..1757ae3e
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/index.browser.ts
@@ -0,0 +1,25 @@
+/**
+ * @imgly/plugin-pdfx-web - Print-Ready PDF conversion for CE.SDK
+ * Browser-specific entry point
+ *
+ * This entry point is optimized for browser environments:
+ * - No Node.js-specific code included
+ * - CSP-safe (no new Function() or eval())
+ * - Uses standard dynamic import() for loading gs.js
+ *
+ * IMPORTANT: This package includes Ghostscript WASM binaries licensed under AGPL-3.0.
+ * Commercial users must ensure license compliance. See README.md for details.
+ */
+
+// Export the main conversion function
+export { convertToPDFX3 } from './pdfx';
+
+// Export BrowserAssetLoader for browser environments
+export { BrowserAssetLoader } from './loaders/browser-loader';
+
+// Export Logger for controlling log verbosity
+export { Logger } from './utils/logger';
+
+// Export types for TypeScript users
+export type { PDFX3Options, AssetLoader } from './types';
+export type { LogLevel } from './utils/logger';
diff --git a/packages/plugin-print-ready-pdfs-web/src/index.node.ts b/packages/plugin-print-ready-pdfs-web/src/index.node.ts
new file mode 100644
index 00000000..d06b493f
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/index.node.ts
@@ -0,0 +1,25 @@
+/**
+ * @imgly/plugin-pdfx-web - Print-Ready PDF conversion for CE.SDK
+ * Node.js-specific entry point
+ *
+ * This entry point is optimized for Node.js environments:
+ * - Uses filesystem APIs to load assets
+ * - No browser-specific code included
+ * - Assets are resolved relative to the module location
+ *
+ * IMPORTANT: This package includes Ghostscript WASM binaries licensed under AGPL-3.0.
+ * Commercial users must ensure license compliance. See README.md for details.
+ */
+
+// Export the main conversion function
+export { convertToPDFX3 } from './pdfx';
+
+// Export NodeAssetLoader for Node.js environments
+export { NodeAssetLoader } from './loaders/node-loader';
+
+// Export Logger for controlling log verbosity
+export { Logger } from './utils/logger';
+
+// Export types for TypeScript users
+export type { PDFX3Options, AssetLoader } from './types';
+export type { LogLevel } from './utils/logger';
diff --git a/packages/plugin-print-ready-pdfs-web/src/index.ts b/packages/plugin-print-ready-pdfs-web/src/index.ts
index 3c6ff145..351a04b6 100644
--- a/packages/plugin-print-ready-pdfs-web/src/index.ts
+++ b/packages/plugin-print-ready-pdfs-web/src/index.ts
@@ -1,16 +1,25 @@
/**
* @imgly/plugin-pdfx-web - Print-Ready PDF conversion for CE.SDK
+ * Universal entry point (auto-detects environment)
*
* IMPORTANT: This package includes Ghostscript WASM binaries licensed under AGPL-3.0.
* Commercial users must ensure license compliance. See README.md for details.
+ *
+ * For optimal bundle size, consider using the platform-specific entry points:
+ * - `@imgly/plugin-print-ready-pdfs-web/browser` for browser environments
+ * - `@imgly/plugin-print-ready-pdfs-web/node` for Node.js environments
*/
// Export the main conversion function (supports both single blob and array of blobs)
export { convertToPDFX3 } from './pdfx';
+// Export asset loaders for both environments
+export { BrowserAssetLoader } from './loaders/browser-loader';
+export { NodeAssetLoader } from './loaders/node-loader';
+
// Export Logger for controlling log verbosity
export { Logger } from './utils/logger';
// Export types for TypeScript users
-export type { PDFX3Options } from './types/pdfx';
+export type { PDFX3Options, AssetLoader } from './types';
export type { LogLevel } from './utils/logger';
diff --git a/packages/plugin-print-ready-pdfs-web/src/loaders/browser-loader.ts b/packages/plugin-print-ready-pdfs-web/src/loaders/browser-loader.ts
new file mode 100644
index 00000000..617a6735
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/loaders/browser-loader.ts
@@ -0,0 +1,52 @@
+import type { AssetLoader } from '../types/asset-loader';
+import type { GhostscriptModuleFactory } from '../types/ghostscript';
+import { normalizeAssetPath } from '../utils/asset-path';
+
+/**
+ * Browser-specific asset loader implementation.
+ * Loads assets via HTTP fetch and dynamic import.
+ *
+ * @example
+ * ```typescript
+ * const loader = new BrowserAssetLoader('/assets/print-ready-pdfs/');
+ * const result = await convertToPDFX3(pdfBlob, {
+ * outputProfile: 'fogra39',
+ * assetLoader: loader
+ * });
+ * ```
+ */
+export class BrowserAssetLoader implements AssetLoader {
+ private baseUrl: string;
+
+ /**
+ * Create a new BrowserAssetLoader.
+ * @param assetPath - Base URL path where plugin assets are served from.
+ * Must be an absolute path (e.g., '/assets/') or full URL.
+ */
+ constructor(assetPath: string) {
+ this.baseUrl = normalizeAssetPath(assetPath);
+ }
+
+ async loadGhostscriptModule(): Promise {
+ const moduleUrl = new URL('gs.js', this.baseUrl).href;
+ // webpackIgnore comment prevents Webpack from trying to bundle this dynamic import
+ // The URL is determined at runtime based on assetPath configuration
+ const module = await import(/* webpackIgnore: true */ moduleUrl);
+ return module.default || module;
+ }
+
+ getWasmPath(): string {
+ return new URL('gs.wasm', this.baseUrl).href;
+ }
+
+ async loadICCProfile(profileName: string): Promise {
+ const profileUrl = new URL(profileName, this.baseUrl).href;
+ const response = await fetch(profileUrl);
+ if (!response.ok) {
+ throw new Error(
+ `Failed to load ICC profile ${profileName}: ${response.status} ${response.statusText}`
+ );
+ }
+ return response.blob();
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/src/loaders/node-loader.browser.ts b/packages/plugin-print-ready-pdfs-web/src/loaders/node-loader.browser.ts
new file mode 100644
index 00000000..9693264d
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/loaders/node-loader.browser.ts
@@ -0,0 +1,29 @@
+/**
+ * Browser stub for NodeAssetLoader.
+ * This file is only used by the browser bundle and should never be called at runtime.
+ * If it is called, it means the user didn't provide assetPath or assetLoader option.
+ */
+
+// This error will never actually be thrown because the browser entry point
+// throws before reaching the NodeAssetLoader import. But Webpack needs to
+// be able to resolve this import, so we provide a stub.
+export class NodeAssetLoader {
+ constructor() {
+ throw new Error(
+ 'NodeAssetLoader cannot be used in browser environments. ' +
+ 'Please provide the `assetPath` or `assetLoader` option to convertToPDFX3().'
+ );
+ }
+
+ async loadGhostscriptModule(): Promise {
+ throw new Error('Not available in browser');
+ }
+
+ getWasmPath(): string {
+ throw new Error('Not available in browser');
+ }
+
+ async loadICCProfile(): Promise {
+ throw new Error('Not available in browser');
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/src/loaders/node-loader.ts b/packages/plugin-print-ready-pdfs-web/src/loaders/node-loader.ts
new file mode 100644
index 00000000..929adb4e
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/loaders/node-loader.ts
@@ -0,0 +1,101 @@
+import type { AssetLoader } from '../types/asset-loader';
+import type { GhostscriptModuleFactory } from '../types/ghostscript';
+
+/**
+ * Node.js-specific asset loader implementation.
+ * Loads assets from the filesystem relative to the module's location.
+ *
+ * This loader automatically finds assets relative to the built module,
+ * so no configuration is needed in Node.js environments.
+ *
+ * @example
+ * ```typescript
+ * // In Node.js, the loader is used automatically when no assetPath is provided
+ * const result = await convertToPDFX3(pdfBlob, {
+ * outputProfile: 'fogra39'
+ * });
+ *
+ * // Or explicitly:
+ * const loader = new NodeAssetLoader();
+ * const result = await convertToPDFX3(pdfBlob, {
+ * outputProfile: 'fogra39',
+ * assetLoader: loader
+ * });
+ * ```
+ */
+export class NodeAssetLoader implements AssetLoader {
+ private moduleDir: string | null = null;
+ private gsPath: string | null = null;
+ private initPromise: Promise | null = null;
+
+ constructor() {
+ // Initialization is deferred to first use because we need async imports
+ this.initPromise = this.initialize();
+ }
+
+ private async initialize(): Promise {
+ // Dynamic imports for Node.js built-in modules
+ // These use /* webpackIgnore: true */ comments to prevent bundlers from processing them
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const moduleLib: any = await import(/* webpackIgnore: true */ 'module');
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const pathLib: any = await import(/* webpackIgnore: true */ 'path');
+
+ const createRequire = moduleLib.createRequire || moduleLib.default?.createRequire;
+ const requireForESM = createRequire(import.meta.url);
+ // gs.js is copied to dist/ alongside the bundle
+ this.gsPath = requireForESM.resolve('./gs.js');
+
+ const dirname = pathLib.dirname || pathLib.default?.dirname;
+ this.moduleDir = dirname(this.gsPath);
+ }
+
+ private async ensureInitialized(): Promise {
+ if (this.initPromise) {
+ await this.initPromise;
+ this.initPromise = null;
+ }
+ }
+
+ async loadGhostscriptModule(): Promise {
+ await this.ensureInitialized();
+ // Dynamic import for ESM compatibility
+ const module = await import(this.gsPath!);
+ return module.default || module;
+ }
+
+ /**
+ * Get the path to the WASM file.
+ *
+ * **Important:** This method must be called after `loadGhostscriptModule()` has completed,
+ * as initialization happens lazily during that call. The library's internal usage
+ * guarantees this ordering, but if you're using `NodeAssetLoader` directly, ensure
+ * you call `loadGhostscriptModule()` first.
+ *
+ * @throws Error if called before initialization completes
+ */
+ getWasmPath(): string {
+ if (!this.moduleDir) {
+ throw new Error(
+ 'NodeAssetLoader not initialized. Call loadGhostscriptModule() first and await its completion.'
+ );
+ }
+ return `${this.moduleDir}/gs.wasm`;
+ }
+
+ async loadICCProfile(profileName: string): Promise {
+ await this.ensureInitialized();
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fsLib: any = await import(/* webpackIgnore: true */ 'fs');
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const pathLib: any = await import(/* webpackIgnore: true */ 'path');
+
+ const join = pathLib.join || pathLib.default?.join;
+ const readFileSync = fsLib.readFileSync || fsLib.default?.readFileSync;
+
+ const profilePath = join(this.moduleDir!, profileName);
+ const data = readFileSync(profilePath);
+ return new Blob([data], { type: 'application/vnd.iccprofile' });
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/src/pdfx.ts b/packages/plugin-print-ready-pdfs-web/src/pdfx.ts
index f71a798b..099edb02 100644
--- a/packages/plugin-print-ready-pdfs-web/src/pdfx.ts
+++ b/packages/plugin-print-ready-pdfs-web/src/pdfx.ts
@@ -1,8 +1,77 @@
-import type { PDFX3Options } from './types/pdfx';
+import type { PDFX3Options, AssetLoader } from './types';
import { GhostscriptLoader } from './core/ghostscript-loader';
import { VirtualFileSystem } from './core/virtual-filesystem';
import { BlobUtils } from './utils/blob-utils';
+/**
+ * Try to get a usable base URL from import.meta.url.
+ * Returns null if the URL is not usable (e.g., file:// protocol in browser).
+ */
+function tryGetBaseUrlFromImportMeta(): string | null {
+ try {
+ // import.meta.url points to the bundled JS file location
+ // In Vite: "http://localhost:5173/node_modules/.vite/deps/..."
+ // In Webpack 5: "file:///path/to/dist/index.mjs" (not usable in browser)
+ const url = import.meta.url;
+ if (!url) return null;
+
+ // file:// URLs don't work for dynamic imports in browsers
+ if (url.startsWith('file://')) return null;
+
+ // Extract the directory from the URL
+ const lastSlash = url.lastIndexOf('/');
+ if (lastSlash === -1) return null;
+
+ return url.substring(0, lastSlash + 1);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Get the appropriate AssetLoader based on options and environment.
+ */
+async function getAssetLoader(options: PDFX3Options): Promise {
+ // 1. Explicit loader takes precedence
+ if (options.assetLoader) {
+ return options.assetLoader;
+ }
+
+ // 2. assetPath creates a BrowserAssetLoader
+ if (options.assetPath) {
+ const { BrowserAssetLoader } = await import('./loaders/browser-loader');
+ return new BrowserAssetLoader(options.assetPath);
+ }
+
+ // 3. Auto-detect environment
+ const isBrowser =
+ typeof window !== 'undefined' || typeof document !== 'undefined';
+
+ if (isBrowser) {
+ // Try to auto-detect base URL from import.meta.url (works in Vite)
+ const autoBaseUrl = tryGetBaseUrlFromImportMeta();
+ if (autoBaseUrl) {
+ const { BrowserAssetLoader } = await import('./loaders/browser-loader');
+ return new BrowserAssetLoader(autoBaseUrl);
+ }
+
+ // Can't auto-detect - require explicit configuration (Webpack 5, Angular, etc.)
+ throw new Error(
+ 'In browser environments with Webpack 5 or Angular, you must provide the `assetPath` option.\n\n' +
+ 'Example:\n' +
+ ' convertToPDFX3(blob, {\n' +
+ " outputProfile: 'fogra39',\n" +
+ " assetPath: '/assets/print-ready-pdfs/'\n" +
+ ' });\n\n' +
+ 'See: https://github.com/imgly/plugins/tree/main/packages/plugin-print-ready-pdfs-web#bundler-setup-webpack-5--angular'
+ );
+ }
+
+ // Node.js - use NodeAssetLoader
+ const { NodeAssetLoader } = await import('./loaders/node-loader');
+ return new NodeAssetLoader();
+}
+
/**
* PDF/X-3 conversion function
* Converts RGB PDF(s) to PDF/X-3 format using specified output profile
@@ -114,8 +183,11 @@ async function convertToPDFX3Single(
throw new Error('Invalid PDF format');
}
- // Load Ghostscript
- const module = await GhostscriptLoader.load();
+ // Get the asset loader for this conversion
+ const assetLoader = await getAssetLoader(options);
+
+ // Load Ghostscript with the asset loader
+ const module = await GhostscriptLoader.load({ assetLoader });
const vfs = new VirtualFileSystem(module);
try {
@@ -132,59 +204,12 @@ async function convertToPDFX3Single(
if (options.outputProfile === 'custom' && options.customProfile) {
await vfs.writeBlob(customProfilePath, options.customProfile);
} else if (options.outputProfile !== 'custom') {
- // Load the bundled ICC profile
+ // Load the bundled ICC profile using the asset loader
const profileInfo =
PROFILE_PRESETS[options.outputProfile as keyof typeof PROFILE_PRESETS];
const profilePath = `/tmp/${profileInfo.file}`;
- // Load ICC profile - different approach for Node.js vs browser
- const isNode =
- typeof process !== 'undefined' && process.versions?.node != null;
-
- let profileBlob: Blob;
-
- if (isNode) {
- // Node.js: Load from filesystem using readFileSync
- // Use indirect dynamic import to prevent Webpack 5 from statically analyzing these imports
- // But use direct imports in test environments (vitest) where indirect imports bypass mocking
- // See: https://github.com/imgly/ubq/issues/11471
- const isTestEnv =
- process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
-
- // Note: new Function() could fail in CSP-restricted environments, but CSP is a browser
- // security mechanism and doesn't apply to Node.js. This code only runs in Node.js (isNode check above).
- // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
- const indirectImport = new Function('s', 'return import(s)') as (
- s: string
- ) => Promise;
- const dynamicImport = isTestEnv
- ? (s: string) => import(s)
- : indirectImport;
-
- const { readFileSync } = await dynamicImport('fs');
- const { fileURLToPath } = await dynamicImport('url');
- const { dirname, join } = await dynamicImport('path');
-
- // Get the directory of the built module
- const moduleDir = dirname(fileURLToPath(import.meta.url));
- const profileFilePath = join(moduleDir, profileInfo.file);
-
- const profileData = readFileSync(profileFilePath);
- profileBlob = new Blob([profileData], {
- type: 'application/vnd.iccprofile',
- });
- } else {
- // Browser: Use fetch
- const profileUrl = new URL(profileInfo.file, import.meta.url).href;
- const profileResponse = await fetch(profileUrl);
- if (!profileResponse.ok) {
- throw new Error(
- `Failed to load ICC profile ${profileInfo.file}: ${profileResponse.statusText}`
- );
- }
- profileBlob = await profileResponse.blob();
- }
-
+ const profileBlob = await assetLoader.loadICCProfile(profileInfo.file);
await vfs.writeBlob(profilePath, profileBlob);
}
diff --git a/packages/plugin-print-ready-pdfs-web/src/types.ts b/packages/plugin-print-ready-pdfs-web/src/types.ts
index 42ed3dba..c9cc8785 100644
--- a/packages/plugin-print-ready-pdfs-web/src/types.ts
+++ b/packages/plugin-print-ready-pdfs-web/src/types.ts
@@ -1,2 +1,2 @@
-// Import all types from the types directory
-export * from './types/pdfx';
+// Re-export all types from the types directory
+export * from './types/index';
diff --git a/packages/plugin-print-ready-pdfs-web/src/types/asset-loader.ts b/packages/plugin-print-ready-pdfs-web/src/types/asset-loader.ts
new file mode 100644
index 00000000..30e59005
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/types/asset-loader.ts
@@ -0,0 +1,56 @@
+import type { GhostscriptModuleFactory } from './ghostscript';
+
+/**
+ * Interface for loading plugin assets (gs.js, gs.wasm, ICC profiles).
+ * Implement this interface to customize how assets are loaded in your environment.
+ *
+ * Built-in implementations:
+ * - `BrowserAssetLoader`: For browser environments, loads assets via HTTP
+ * - `NodeAssetLoader`: For Node.js environments, loads assets from the filesystem
+ *
+ * @example Custom loader for CDN
+ * ```typescript
+ * class CDNAssetLoader implements AssetLoader {
+ * private cdnBase = 'https://cdn.example.com/pdf-plugin/v1.1.2/';
+ *
+ * async loadGhostscriptModule() {
+ * const module = await import(this.cdnBase + 'gs.js');
+ * return module.default;
+ * }
+ *
+ * getWasmPath() {
+ * return this.cdnBase + 'gs.wasm';
+ * }
+ *
+ * async loadICCProfile(name: string) {
+ * const response = await fetch(this.cdnBase + name);
+ * return response.blob();
+ * }
+ * }
+ * ```
+ */
+export interface AssetLoader {
+ /**
+ * Load the Ghostscript JavaScript module.
+ * @returns The Ghostscript module factory function
+ */
+ loadGhostscriptModule(): Promise;
+
+ /**
+ * Get the URL/path to the WASM file for Emscripten's locateFile callback.
+ *
+ * **Note:** This method is called synchronously by Emscripten after `loadGhostscriptModule()`
+ * completes. Implementations should ensure any required initialization happens in
+ * `loadGhostscriptModule()` so that `getWasmPath()` can return immediately.
+ *
+ * @returns URL or path to gs.wasm
+ */
+ getWasmPath(): string;
+
+ /**
+ * Load an ICC profile by name.
+ * @param profileName - e.g., 'GRACoL2013_CRPC6.icc'
+ * @returns The ICC profile as a Blob
+ */
+ loadICCProfile(profileName: string): Promise;
+}
diff --git a/packages/plugin-print-ready-pdfs-web/src/types/ghostscript.d.ts b/packages/plugin-print-ready-pdfs-web/src/types/ghostscript.d.ts
deleted file mode 100644
index c45a8bd8..00000000
--- a/packages/plugin-print-ready-pdfs-web/src/types/ghostscript.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// Re-export types from @privyid/ghostscript
-export type { GSModule as EmscriptenModule } from '@privyid/ghostscript';
-export type { FS as EmscriptenFS } from '@privyid/ghostscript';
-
-// Type alias for compatibility
-export type GhostscriptModuleFactory =
- typeof import('@privyid/ghostscript').default;
diff --git a/packages/plugin-print-ready-pdfs-web/src/types/ghostscript.ts b/packages/plugin-print-ready-pdfs-web/src/types/ghostscript.ts
new file mode 100644
index 00000000..cdd1020c
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/types/ghostscript.ts
@@ -0,0 +1,47 @@
+/**
+ * Type definitions for the Ghostscript WASM module.
+ * Based on Emscripten module types with Ghostscript-specific extensions.
+ */
+
+/**
+ * Emscripten filesystem interface for file operations.
+ */
+export interface EmscriptenFS {
+ mkdir(path: string): void;
+ writeFile(path: string, data: string | Uint8Array): void;
+ readFile(path: string): Uint8Array;
+ stat(path: string): { size: number };
+ unlink(path: string): void;
+}
+
+/**
+ * Ghostscript WASM module interface.
+ * Extends Emscripten module with Ghostscript-specific methods.
+ */
+export interface EmscriptenModule {
+ /** Emscripten virtual filesystem */
+ FS: EmscriptenFS;
+
+ /** Call the main() function with arguments */
+ callMain(args: string[]): Promise;
+
+ /** Optional version string */
+ version?: string;
+
+ /** Print function for stdout */
+ print?: (text: string) => void;
+
+ /** Print function for stderr */
+ printErr?: (text: string) => void;
+
+ /** Custom locateFile function for WASM loading */
+ locateFile?: (filename: string) => string;
+}
+
+/**
+ * Factory function returned by the Ghostscript JavaScript module.
+ * Creates an initialized EmscriptenModule instance.
+ */
+export type GhostscriptModuleFactory = (
+ config?: Record
+) => Promise;
diff --git a/packages/plugin-print-ready-pdfs-web/src/types/index.d.ts b/packages/plugin-print-ready-pdfs-web/src/types/index.ts
similarity index 63%
rename from packages/plugin-print-ready-pdfs-web/src/types/index.d.ts
rename to packages/plugin-print-ready-pdfs-web/src/types/index.ts
index 4fd23365..7ba05ae6 100644
--- a/packages/plugin-print-ready-pdfs-web/src/types/index.d.ts
+++ b/packages/plugin-print-ready-pdfs-web/src/types/index.ts
@@ -1,2 +1,3 @@
export * from './ghostscript';
export * from './pdfx';
+export * from './asset-loader';
diff --git a/packages/plugin-print-ready-pdfs-web/src/types/pdfx.d.ts b/packages/plugin-print-ready-pdfs-web/src/types/pdfx.d.ts
deleted file mode 100644
index 806fe064..00000000
--- a/packages/plugin-print-ready-pdfs-web/src/types/pdfx.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export interface PDFX3Options {
- // Required
- outputProfile: 'gracol' | 'fogra39' | 'srgb' | 'custom';
- customProfile?: Blob; // Only if outputProfile is 'custom'
-
- // Optional (with sensible defaults)
- title?: string; // Document title (default: use existing)
- outputConditionIdentifier?: string; // OutputIntent identifier (e.g., "FOGRA39")
- outputCondition?: string; // Human-readable condition description
- flattenTransparency?: boolean; // Force transparency flattening (default: true for PDF/X-3 compliance)
-}
diff --git a/packages/plugin-print-ready-pdfs-web/src/types/pdfx.ts b/packages/plugin-print-ready-pdfs-web/src/types/pdfx.ts
new file mode 100644
index 00000000..2a56e348
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/types/pdfx.ts
@@ -0,0 +1,46 @@
+import type { AssetLoader } from './asset-loader';
+
+export interface PDFX3Options {
+ // Required
+ outputProfile: 'gracol' | 'fogra39' | 'srgb' | 'custom';
+ customProfile?: Blob; // Only if outputProfile is 'custom'
+
+ // Optional (with sensible defaults)
+ title?: string; // Document title (default: use existing)
+ outputConditionIdentifier?: string; // OutputIntent identifier (e.g., "FOGRA39")
+ outputCondition?: string; // Human-readable condition description
+ flattenTransparency?: boolean; // Force transparency flattening (default: true for PDF/X-3 compliance)
+
+ /**
+ * Asset loader instance for loading plugin assets.
+ *
+ * If not provided:
+ * - Browser: Must provide `assetPath` instead
+ * - Node.js: Uses NodeAssetLoader automatically
+ *
+ * Provide a custom AssetLoader implementation for advanced scenarios
+ * like loading assets from a CDN, custom storage, or service worker cache.
+ *
+ * @example
+ * ```typescript
+ * const loader = new BrowserAssetLoader('/assets/');
+ * const result = await convertToPDFX3(blob, {
+ * outputProfile: 'fogra39',
+ * assetLoader: loader
+ * });
+ * ```
+ */
+ assetLoader?: AssetLoader;
+
+ /**
+ * Base URL path where plugin assets (gs.js, gs.wasm, *.icc) are served from.
+ * Shorthand for creating a BrowserAssetLoader.
+ *
+ * Required in browser environments unless `assetLoader` is provided.
+ * Ignored in Node.js environments.
+ *
+ * @example '/assets/print-ready-pdfs/'
+ * @example 'https://cdn.example.com/libs/print-ready-pdfs/'
+ */
+ assetPath?: string;
+}
diff --git a/packages/plugin-print-ready-pdfs-web/src/utils/asset-path.ts b/packages/plugin-print-ready-pdfs-web/src/utils/asset-path.ts
new file mode 100644
index 00000000..3f4b830b
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/src/utils/asset-path.ts
@@ -0,0 +1,72 @@
+/**
+ * Asset path utilities for resolving plugin assets in different environments.
+ * Handles the Webpack 5 / Angular CLI issue where import.meta.url is transformed
+ * to a file:// URL that doesn't work in browsers.
+ */
+
+/**
+ * Normalizes an asset path to ensure it has a trailing slash and is a valid URL.
+ * Converts relative paths (e.g., '/assets/wasm/') to absolute URLs using the current origin.
+ */
+export function normalizeAssetPath(path: string): string {
+ const normalizedPath = path.endsWith('/') ? path : path + '/';
+
+ // If it's already an absolute URL, return as-is
+ if (
+ normalizedPath.startsWith('http://') ||
+ normalizedPath.startsWith('https://')
+ ) {
+ return normalizedPath;
+ }
+
+ // Convert relative path to absolute URL using document.location.origin
+ const origin =
+ typeof document !== 'undefined' ? document.location?.origin || '' : '';
+
+ return origin + normalizedPath;
+}
+
+/**
+ * Resolves the base URL for loading plugin assets in browser environments.
+ * Throws a helpful error if assetPath is required but not provided.
+ *
+ * @param assetPath - Explicit asset path provided by the user
+ * @param currentModuleUrl - The import.meta.url of the calling module
+ */
+export function resolveAssetBasePath(
+ assetPath: string | undefined,
+ currentModuleUrl: string
+): string {
+ // 1. Explicit assetPath always wins
+ if (assetPath) {
+ return normalizeAssetPath(assetPath);
+ }
+
+ // 2. Try import.meta.url (works in Vite, native ESM)
+ if (!currentModuleUrl.startsWith('file://')) {
+ // Valid browser URL - use it directly
+ return new URL('./', currentModuleUrl).href;
+ }
+
+ // 3. Bundled environment (Webpack 5 transforms to file://)
+ // assetPath is required - throw helpful error
+ throw new Error(
+ `Could not locate plugin assets. The assetPath option is required.\n\n` +
+ `This typically happens when using a bundler (like Webpack 5 or Angular CLI) ` +
+ `that transforms import.meta.url to a file:// URL.\n\n` +
+ `To fix this, copy the plugin assets to your public folder and provide the assetPath option:\n\n` +
+ `Option A: Configure your bundler to copy assets automatically\n\n` +
+ ` Angular CLI - add to angular.json "assets" array:\n` +
+ ` { "glob": "{gs.js,gs.wasm,*.icc}", "input": "node_modules/@imgly/plugin-print-ready-pdfs-web/dist", "output": "/assets/" }\n\n` +
+ ` Webpack - use copy-webpack-plugin:\n` +
+ ` new CopyPlugin({ patterns: [{ from: "node_modules/@imgly/plugin-print-ready-pdfs-web/dist/*.{js,wasm,icc}", to: "assets/[name][ext]" }] })\n\n` +
+ `Option B: Copy manually\n\n` +
+ ` cp node_modules/@imgly/plugin-print-ready-pdfs-web/dist/{gs.js,gs.wasm,*.icc} public/assets/\n\n` +
+ `Then pass the assetPath option:\n\n` +
+ ` convertToPDFX3(blob, {\n` +
+ ` outputProfile: 'fogra39',\n` +
+ ` assetPath: '/assets/' // adjust to match your output path\n` +
+ ` });\n\n` +
+ `See: https://github.com/imgly/plugins/tree/main/packages/plugin-print-ready-pdfs-web#bundler-setup-webpack-5--angular`
+ );
+}
diff --git a/packages/plugin-print-ready-pdfs-web/src/wasm/ghostscript-module.ts b/packages/plugin-print-ready-pdfs-web/src/wasm/ghostscript-module.ts
index f5a61693..d2fb92a4 100644
--- a/packages/plugin-print-ready-pdfs-web/src/wasm/ghostscript-module.ts
+++ b/packages/plugin-print-ready-pdfs-web/src/wasm/ghostscript-module.ts
@@ -3,6 +3,7 @@ import type {
EmscriptenModule,
GhostscriptModuleFactory,
} from '../types/ghostscript';
+import type { AssetLoader } from '../types/asset-loader';
export interface GhostscriptModuleOptions {
/**
@@ -10,64 +11,24 @@ export interface GhostscriptModuleOptions {
* Default: true (silent mode)
*/
silent?: boolean;
+
+ /**
+ * Asset loader for loading gs.js and gs.wasm.
+ * Required - use BrowserAssetLoader or NodeAssetLoader.
+ */
+ assetLoader: AssetLoader;
}
export default async function createGhostscriptModule(
- options: GhostscriptModuleOptions = {}
+ options: GhostscriptModuleOptions
): Promise {
- const { silent = true } = options;
- // Check if we're in Node.js
- const isNode =
- typeof process !== 'undefined' &&
- process.versions != null &&
- process.versions.node != null;
-
- let GhostscriptModule: any;
- let wasmPath: string;
-
- if (isNode) {
- // Node.js: Use require.resolve to find gs.js relative to this module
- // Use indirect dynamic import to prevent Webpack 5 from statically analyzing these imports
- // But use direct imports in test environments (vitest) where indirect imports bypass mocking
- // See: https://github.com/imgly/ubq/issues/11471
- const isTestEnv =
- typeof process !== 'undefined' &&
- (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test');
-
- // Helper for dynamic imports - uses indirect import in production to avoid Webpack static analysis
- // Note: new Function() could fail in CSP-restricted environments, but CSP is a browser
- // security mechanism and doesn't apply to Node.js. This code only runs in Node.js (isNode check above).
- // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
- const indirectImport = new Function('s', 'return import(s)') as (
- s: string
- ) => Promise;
- const dynamicImport = isTestEnv ? (s: string) => import(s) : indirectImport;
-
- const moduleLib = await dynamicImport('module');
- const pathLib = await dynamicImport('path');
- const createRequire = moduleLib.createRequire;
- const dirname = pathLib.dirname;
- const join = pathLib.join;
-
- const requireForESM = createRequire(import.meta.url);
-
- // Resolve gs.js directly (it's copied to dist/ alongside the bundle)
- const gsPath = requireForESM.resolve('./gs.js');
- const moduleDir = dirname(gsPath);
- wasmPath = join(moduleDir, 'gs.wasm');
-
- GhostscriptModule = await dynamicImport(gsPath);
- } else {
- // Browser: Use URL-based imports
- const moduleUrl = new URL('./gs.js', import.meta.url).href;
- GhostscriptModule = await import(/* webpackIgnore: true */ moduleUrl);
- wasmPath = new URL('./gs.wasm', import.meta.url).href;
- }
+ const { silent = true, assetLoader } = options;
- const factory = (GhostscriptModule.default ||
- GhostscriptModule) as GhostscriptModuleFactory;
+ // Load the Ghostscript module using the provided loader
+ const factory = await assetLoader.loadGhostscriptModule();
+ const wasmPath = assetLoader.getWasmPath();
- // Configure the module to load WASM from the bundled location
+ // Configure the module to load WASM from the specified location
const moduleConfig: Record = {
locateFile: (filename: string) => {
if (filename === 'gs.wasm') {
diff --git a/packages/plugin-print-ready-pdfs-web/test/browser-test.spec.ts b/packages/plugin-print-ready-pdfs-web/test/browser-test.spec.ts
index a442ed09..c40a91f0 100644
--- a/packages/plugin-print-ready-pdfs-web/test/browser-test.spec.ts
+++ b/packages/plugin-print-ready-pdfs-web/test/browser-test.spec.ts
@@ -214,6 +214,7 @@ test.describe('PDF/X Conversion Browser Tests', () => {
const startTime = Date.now();
// Perform actual conversion
+ // In Vite-like environments (including this test with http-server), assetPath is auto-detected
const outputBlob = await window.PDFXPlugin.convertToPDFX3(inputBlob, {
outputProfile: 'fogra39',
title: 'E2E Test - FOGRA39'
@@ -260,6 +261,7 @@ test.describe('PDF/X Conversion Browser Tests', () => {
const inputBlob = new Blob([new Uint8Array(pdfData)], { type: 'application/pdf' });
const startTime = Date.now();
+ // In Vite-like environments, assetPath is auto-detected
const outputBlob = await window.PDFXPlugin.convertToPDFX3(inputBlob, {
outputProfile: 'gracol',
title: 'E2E Test - GRACoL'
@@ -302,6 +304,7 @@ test.describe('PDF/X Conversion Browser Tests', () => {
const inputBlob = new Blob([new Uint8Array(pdfData)], { type: 'application/pdf' });
const startTime = Date.now();
+ // In Vite-like environments, assetPath is auto-detected
const outputBlob = await window.PDFXPlugin.convertToPDFX3(inputBlob, {
outputProfile: 'fogra39',
title: 'E2E Test - Images',
diff --git a/packages/plugin-print-ready-pdfs-web/test/fixtures/pdfs/test-minimal-pdfx.pdf b/packages/plugin-print-ready-pdfs-web/test/fixtures/pdfs/test-minimal-pdfx.pdf
new file mode 100644
index 00000000..cf923fc8
Binary files /dev/null and b/packages/plugin-print-ready-pdfs-web/test/fixtures/pdfs/test-minimal-pdfx.pdf differ
diff --git a/packages/plugin-print-ready-pdfs-web/test/index.html b/packages/plugin-print-ready-pdfs-web/test/index.html
index 23e763d2..1e4ec805 100644
--- a/packages/plugin-print-ready-pdfs-web/test/index.html
+++ b/packages/plugin-print-ready-pdfs-web/test/index.html
@@ -547,6 +547,8 @@ Debug Logs
const startTime = Date.now();
// Convert using the simplified API with FOGRA39 profile
+ // In Vite-like environments, assetPath is auto-detected from import.meta.url
+ // For Webpack 5/Angular, assetPath must be explicitly provided
convertedPDFBlob = await convertToPDFX3(uploadedPDFBlob, {
outputProfile: 'fogra39',
title: 'Test Conversion'
diff --git a/packages/plugin-print-ready-pdfs-web/test/integration/black-background-regression.test.ts b/packages/plugin-print-ready-pdfs-web/test/integration/black-background-regression.test.ts
index dca3b22e..1bc29d6b 100644
--- a/packages/plugin-print-ready-pdfs-web/test/integration/black-background-regression.test.ts
+++ b/packages/plugin-print-ready-pdfs-web/test/integration/black-background-regression.test.ts
@@ -70,8 +70,7 @@ describe('Black Background Regression (#11242)', () => {
// Initialize CE.SDK engine for creating test PDFs
try {
engine = await CreativeEngine.init({
- license: CESDK_LICENSE,
- baseURL: 'https://cdn.img.ly/packages/imgly/cesdk-node/1.61.0/assets'
+ license: CESDK_LICENSE
});
} catch (error) {
console.warn(
@@ -304,7 +303,7 @@ describe('Black Background Regression (#11242)', () => {
// 7. Add STICKERS from "craft" group (PNG with transparency - known to cause issues per #11242)
// These stickers have alpha channels that can trigger the black background bug
- const stickerBaseUrl = 'https://cdn.img.ly/assets/v5';
+ const stickerBaseUrl = 'https://cdn.img.ly/packages/imgly/cesdk-engine/1.64.0/assets/v4';
// Craft sticker: tape (PNG with transparency)
const tapeSticker = engine.block.create('graphic');
diff --git a/packages/plugin-print-ready-pdfs-web/test/integration/pdfx3-visual-fidelity.test.ts b/packages/plugin-print-ready-pdfs-web/test/integration/pdfx3-visual-fidelity.test.ts
index a35a87a6..33eda7b7 100644
--- a/packages/plugin-print-ready-pdfs-web/test/integration/pdfx3-visual-fidelity.test.ts
+++ b/packages/plugin-print-ready-pdfs-web/test/integration/pdfx3-visual-fidelity.test.ts
@@ -74,8 +74,7 @@ describe('PDF/X-3 Visual Fidelity', () => {
// Initialize CE.SDK engine
try {
engine = await CreativeEngine.init({
- license: CESDK_LICENSE,
- baseURL: 'https://cdn.img.ly/packages/imgly/cesdk-node/1.61.0/assets'
+ license: CESDK_LICENSE
});
} catch (error) {
console.error('Failed to initialize CE.SDK:', error);
@@ -392,7 +391,7 @@ describe('PDF/X-3 Visual Fidelity', () => {
return;
}
- const stickerUrl = 'https://cdn.img.ly/assets/v5/ly.img.sticker/images/craft/tape01.png';
+ const stickerUrl = 'https://cdn.img.ly/packages/imgly/cesdk-engine/1.64.0/assets/v4/ly.img.sticker/images/craft/tape01.png';
const scene = await createTestScene('sticker-alpha', async (eng, page) => {
const sticker = eng.block.create('graphic');
@@ -538,7 +537,7 @@ describe('PDF/X-3 Visual Fidelity', () => {
eng.block.setString(
stickerFill,
'fill/image/imageFileURI',
- 'https://cdn.img.ly/assets/v5/ly.img.sticker/images/3Dstickers/3d_stickers_astronaut.png'
+ 'https://cdn.img.ly/packages/imgly/cesdk-engine/1.64.0/assets/v4/ly.img.sticker/images/3Dstickers/3d_stickers_astronaut.png'
);
eng.block.setFill(sticker, stickerFill);
});
diff --git a/packages/plugin-print-ready-pdfs-web/test/integration/transparency-scenarios.test.ts b/packages/plugin-print-ready-pdfs-web/test/integration/transparency-scenarios.test.ts
index 9712c6ba..d593e186 100644
--- a/packages/plugin-print-ready-pdfs-web/test/integration/transparency-scenarios.test.ts
+++ b/packages/plugin-print-ready-pdfs-web/test/integration/transparency-scenarios.test.ts
@@ -63,8 +63,7 @@ describe('Transparency Scenarios', () => {
try {
engine = await CreativeEngine.init({
- license: CESDK_LICENSE,
- baseURL: 'https://cdn.img.ly/packages/imgly/cesdk-node/1.61.0/assets'
+ license: CESDK_LICENSE
});
} catch (error) {
console.error('Failed to initialize CE.SDK:', error);
@@ -348,7 +347,7 @@ describe('Transparency Scenarios', () => {
// ============================================================
describe('3D Stickers (alpha channel)', () => {
- const baseUrl = 'https://cdn.img.ly/assets/v5/ly.img.sticker/images/3Dstickers';
+ const baseUrl = 'https://cdn.img.ly/packages/imgly/cesdk-engine/1.64.0/assets/v4/ly.img.sticker/images/3Dstickers';
test('3D sticker: astronaut', async () => {
const result = await testScenario('sticker-3d-astronaut', async (eng, page) => {
@@ -432,7 +431,7 @@ describe('Transparency Scenarios', () => {
// ============================================================
describe('Craft Stickers (alpha channel)', () => {
- const baseUrl = 'https://cdn.img.ly/assets/v5/ly.img.sticker/images/craft';
+ const baseUrl = 'https://cdn.img.ly/packages/imgly/cesdk-engine/1.64.0/assets/v4/ly.img.sticker/images/craft';
test('craft sticker: tape', async () => {
const result = await testScenario('sticker-craft-tape', async (eng, page) => {
@@ -646,7 +645,7 @@ describe('Transparency Scenarios', () => {
eng.block.setString(
stickerFill,
'fill/image/imageFileURI',
- 'https://cdn.img.ly/assets/v5/ly.img.sticker/images/3Dstickers/3d_stickers_astronaut.png'
+ 'https://cdn.img.ly/packages/imgly/cesdk-engine/1.64.0/assets/v4/ly.img.sticker/images/3Dstickers/3d_stickers_astronaut.png'
);
eng.block.setFill(sticker, stickerFill);
});
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-cesdk.spec.ts b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-cesdk.spec.ts
new file mode 100644
index 00000000..0c2373f3
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-cesdk.spec.ts
@@ -0,0 +1,832 @@
+/**
+ * Angular + Webpack 5 + CE.SDK + PDF/X-3 Integration Test
+ *
+ * This test verifies the full integration:
+ * 1. Angular 18 + Webpack 5 project setup
+ * 2. CE.SDK editor initialization
+ * 3. PDF export from CE.SDK
+ * 4. PDF/X-3 conversion using the plugin
+ *
+ * The test project is generated by scripts/create-angular-cesdk-test-project.sh
+ */
+
+import { test, expect } from '@playwright/test';
+import { execSync, spawn, ChildProcess } from 'child_process';
+import { existsSync, mkdirSync, rmSync } from 'fs';
+import { join } from 'path';
+
+const PLUGIN_DIR = join(__dirname, '..');
+const PROJECT_DIR = join(PLUGIN_DIR, 'test', 'webpack5-angular-cesdk-project');
+const SERVER_PORT = 4299;
+const SERVER_URL = `http://localhost:${SERVER_PORT}`;
+
+// Path constants for restoring original files between test suites
+const ANGULAR_JSON_PATH = join(PROJECT_DIR, 'angular.json');
+const WEBPACK_CONFIG_PATH = join(PROJECT_DIR, 'webpack.config.js');
+
+// Original file contents - stored by the script that creates the project
+// We read these from the generated project to restore between test runs
+let ORIGINAL_ANGULAR_JSON: string | null = null;
+let ORIGINAL_WEBPACK_CONFIG: string | null = null;
+
+/**
+ * Ensures the Angular project files are in their original state.
+ * This is critical because other test suites modify angular.json and webpack.config.js,
+ * and if a previous test run failed, the files may be left in a modified state.
+ */
+async function ensureOriginalProjectFiles(): Promise {
+ const fs = await import('fs/promises');
+
+ // If project doesn't exist yet, nothing to restore
+ if (!existsSync(PROJECT_DIR)) {
+ return;
+ }
+
+ // Check if symlink to plugin ROOT exists and is valid
+ // This mimics npm package structure: node_modules/@imgly/plugin-print-ready-pdfs-web/dist/
+ const pluginSymlinkPath = join(PROJECT_DIR, 'node_modules', '@imgly', 'plugin-print-ready-pdfs-web');
+
+ try {
+ // Check if symlink exists and points to a valid target
+ const symlinkTarget = await fs.realpath(pluginSymlinkPath);
+ const expectedTarget = await fs.realpath(PLUGIN_DIR);
+ if (symlinkTarget !== expectedTarget) {
+ throw new Error('Symlink points to wrong target');
+ }
+ // Check if target has required files in dist/ subdirectory (like npm package)
+ if (!existsSync(join(symlinkTarget, 'dist', 'gs.js'))) {
+ throw new Error('gs.js not found in symlink target dist/');
+ }
+ } catch (error) {
+ // Symlink is broken or missing - recreate it
+ console.log('Recreating plugin symlink to plugin root...');
+ const imglyDir = join(PROJECT_DIR, 'node_modules', '@imgly');
+ if (!existsSync(imglyDir)) {
+ await fs.mkdir(imglyDir, { recursive: true });
+ }
+ try {
+ await fs.unlink(pluginSymlinkPath);
+ } catch {
+ // Symlink didn't exist
+ }
+ // Symlink to plugin ROOT, not dist - to match npm package structure
+ await fs.symlink(PLUGIN_DIR, pluginSymlinkPath);
+ }
+
+ // Read current files to check if they need restoration
+ const currentAngularJson = await fs.readFile(ANGULAR_JSON_PATH, 'utf-8');
+ const currentWebpackConfig = await fs.readFile(WEBPACK_CONFIG_PATH, 'utf-8');
+
+ // Check if angular.json has the original asset configuration
+ // The original config has input: "node_modules/@imgly/plugin-print-ready-pdfs-web/dist"
+ // and output: "/assets/print-ready-pdfs"
+ const angularJson = JSON.parse(currentAngularJson);
+ const buildOptions = angularJson.projects?.['webpack5-angular-cesdk-project']?.architect?.build?.options;
+
+ if (buildOptions) {
+ const hasOriginalAssetConfig = buildOptions.assets?.some((asset: any) => {
+ return typeof asset === 'object' &&
+ asset.input?.includes('plugin-print-ready-pdfs-web/dist') &&
+ asset.output === '/assets/print-ready-pdfs';
+ });
+
+ // Check if webpack.config.js has copy-webpack-plugin (which it shouldn't in original)
+ const hasCopyPlugin = currentWebpackConfig.includes('copy-webpack-plugin');
+
+ if (!hasOriginalAssetConfig || hasCopyPlugin) {
+ console.log('Detected modified project files from previous test run, recreating project...');
+ // Remove and recreate the project to get a clean state
+ rmSync(PROJECT_DIR, { recursive: true, force: true });
+ }
+ }
+}
+
+test.describe('Angular + Webpack 5 + CE.SDK + PDF/X-3 Integration', () => {
+ let serverProcess: ChildProcess | null = null;
+
+ test.beforeAll(async () => {
+ // Ensure project files are in original state (handles leftover state from failed tests)
+ await ensureOriginalProjectFiles();
+
+ // Ensure plugin is built
+ if (!existsSync(join(PLUGIN_DIR, 'dist', 'index.mjs'))) {
+ console.log('Building plugin...');
+ execSync('pnpm run build', { cwd: PLUGIN_DIR, stdio: 'inherit' });
+ }
+
+ // Create project if it doesn't exist
+ if (!existsSync(PROJECT_DIR)) {
+ console.log('Creating Angular test project...');
+ execSync('bash scripts/create-angular-cesdk-test-project.sh', {
+ cwd: PLUGIN_DIR,
+ stdio: 'inherit'
+ });
+ }
+
+ // Store original file contents for other test suites to restore
+ const fs = await import('fs/promises');
+ ORIGINAL_ANGULAR_JSON = await fs.readFile(ANGULAR_JSON_PATH, 'utf-8');
+ ORIGINAL_WEBPACK_CONFIG = await fs.readFile(WEBPACK_CONFIG_PATH, 'utf-8');
+
+ // Build Angular project
+ console.log('Building Angular project...');
+ try {
+ execSync('npm run build', {
+ cwd: PROJECT_DIR,
+ stdio: 'inherit',
+ timeout: 300000 // 5 minutes
+ });
+ } catch (error) {
+ console.error('Angular build failed:', error);
+ throw error;
+ }
+
+ // Start HTTP server
+ console.log(`Starting HTTP server on port ${SERVER_PORT}...`);
+ serverProcess = spawn('npm', ['run', 'serve'], {
+ cwd: PROJECT_DIR,
+ stdio: 'pipe',
+ detached: false
+ });
+
+ // Wait for server to start
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('Server startup timeout'));
+ }, 30000);
+
+ serverProcess!.stdout?.on('data', (data) => {
+ const output = data.toString();
+ console.log('[Server]', output);
+ if (output.includes('Available on') || output.includes('Serving')) {
+ clearTimeout(timeout);
+ // Give it a moment to fully start
+ setTimeout(resolve, 1000);
+ }
+ });
+
+ serverProcess!.stderr?.on('data', (data) => {
+ console.error('[Server Error]', data.toString());
+ });
+
+ serverProcess!.on('error', (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+ });
+ });
+
+ test.afterAll(async () => {
+ // Kill server
+ if (serverProcess) {
+ console.log('Stopping server...');
+ serverProcess.kill('SIGTERM');
+ serverProcess = null;
+ }
+ });
+
+ test('should load CE.SDK and convert PDF to PDF/X-3 without file:// errors', async ({ page }) => {
+ const consoleMessages: string[] = [];
+ const errors: string[] = [];
+
+ // Capture console messages
+ page.on('console', (msg) => {
+ const text = msg.text();
+ consoleMessages.push(`[${msg.type()}] ${text}`);
+ if (msg.type() === 'error') {
+ errors.push(text);
+ }
+ });
+
+ // Capture page errors
+ page.on('pageerror', (error) => {
+ errors.push(`Page error: ${error.message}`);
+ });
+
+ // Navigate to the app
+ console.log(`Navigating to ${SERVER_URL}...`);
+ await page.goto(SERVER_URL, { timeout: 60000 });
+
+ // Wait for initial load
+ await page.waitForLoadState('networkidle');
+
+ // Wait for plugin import to succeed
+ console.log('Waiting for plugin import...');
+ await page.waitForFunction(
+ () => (window as any).testResults?.importSuccess === true,
+ { timeout: 30000 }
+ );
+
+ // Verify no file:// protocol errors during import
+ const fileProtocolErrors = errors.filter(
+ (e) => e.includes('file://') || e.includes('Cannot find module')
+ );
+ expect(fileProtocolErrors).toHaveLength(0);
+
+ // Wait for CE.SDK to initialize
+ console.log('Waiting for CE.SDK initialization...');
+ await page.waitForFunction(
+ () => (window as any).testResults?.cesdkReady === true,
+ { timeout: 120000 } // CE.SDK can take a while to load
+ );
+
+ // Verify CE.SDK loaded without errors
+ const cesdkErrors = errors.filter(
+ (e) => e.toLowerCase().includes('cesdk') || e.toLowerCase().includes('creative')
+ );
+ // Some warnings are OK, but no critical errors
+ const criticalCesdkErrors = cesdkErrors.filter(
+ (e) => !e.includes('Warning') && !e.includes('deprecated')
+ );
+ expect(criticalCesdkErrors).toHaveLength(0);
+
+ // Trigger PDF export and conversion
+ console.log('Triggering PDF export and PDF/X-3 conversion...');
+ await page.evaluate(() => {
+ return (window as any).triggerExport();
+ });
+
+ // Wait for conversion to complete
+ console.log('Waiting for conversion to complete...');
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 120000 } // Conversion can take time
+ );
+
+ // Get final results
+ const results = await page.evaluate(() => (window as any).testResults);
+ console.log('Test results:', JSON.stringify(results, null, 2));
+
+ // Verify conversion success
+ expect(results.importSuccess).toBe(true);
+ expect(results.cesdkReady).toBe(true);
+ expect(results.conversionAttempted).toBe(true);
+ expect(results.conversionSuccess).toBe(true);
+ expect(results.conversionError).toBeNull();
+ expect(results.pdfSize).toBeGreaterThan(0);
+
+ // Check for file:// errors during entire test
+ const allFileErrors = consoleMessages.filter(
+ (m) => m.includes('file://') && m.includes('error')
+ );
+ expect(allFileErrors).toHaveLength(0);
+
+ console.log('Test passed! PDF/X-3 conversion successful.');
+ console.log(`Output PDF size: ${results.pdfSize} bytes`);
+ });
+
+ test('should handle multiple export profiles', async ({ page }) => {
+ const profiles = ['fogra39', 'gracol', 'srgb'];
+
+ await page.goto(SERVER_URL, { timeout: 60000 });
+ await page.waitForLoadState('networkidle');
+
+ // Wait for CE.SDK
+ await page.waitForFunction(
+ () => (window as any).testResults?.cesdkReady === true,
+ { timeout: 120000 }
+ );
+
+ for (const profile of profiles) {
+ console.log(`Testing profile: ${profile}`);
+
+ // Reset test results
+ await page.evaluate(() => {
+ (window as any).testResults.conversionAttempted = false;
+ (window as any).testResults.conversionSuccess = false;
+ (window as any).testResults.conversionError = null;
+ (window as any).testResults.pdfSize = null;
+ (window as any).testResults.testComplete = false;
+ });
+
+ // Select profile
+ await page.selectOption('select', profile);
+
+ // Trigger export
+ await page.evaluate(() => (window as any).triggerExport());
+
+ // Wait for completion
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 120000 }
+ );
+
+ // Verify success
+ const results = await page.evaluate(() => (window as any).testResults);
+ expect(results.conversionSuccess).toBe(true);
+ expect(results.pdfSize).toBeGreaterThan(0);
+
+ console.log(`Profile ${profile}: Success (${results.pdfSize} bytes)`);
+ }
+ });
+
+ test('should show helpful error message when assets are not found', async ({ page }) => {
+ // Navigate with testError=true to skip assetPath and trigger the error
+ console.log('Testing error message when assets are not discoverable...');
+ await page.goto(`${SERVER_URL}/?testError=true`, { timeout: 60000 });
+ await page.waitForLoadState('networkidle');
+
+ // Wait for CE.SDK
+ await page.waitForFunction(
+ () => (window as any).testResults?.cesdkReady === true,
+ { timeout: 120000 }
+ );
+
+ // Trigger export (should fail with helpful error)
+ await page.evaluate(() => (window as any).triggerExport());
+
+ // Wait for completion
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 120000 }
+ );
+
+ // Get results
+ const results = await page.evaluate(() => (window as any).testResults);
+
+ // Verify conversion failed
+ expect(results.conversionSuccess).toBe(false);
+ expect(results.conversionError).not.toBeNull();
+
+ // Verify error message contains helpful information
+ const errorMessage = results.conversionError as string;
+ expect(errorMessage).toContain('Could not locate plugin assets');
+ expect(errorMessage).toContain('assetPath option is required');
+ expect(errorMessage).toContain('Angular CLI');
+ expect(errorMessage).toContain('cp node_modules/@imgly/plugin-print-ready-pdfs-web/dist/');
+ expect(errorMessage).toContain('https://img.ly/docs/cesdk/print-ready-pdfs/bundler-setup');
+
+ console.log('Error message test passed - helpful error shown to user');
+ });
+});
+
+/**
+ * Test that verifies the Angular CLI assets configuration from the error message works.
+ * This simulates the real user experience:
+ * 1. First attempt WITHOUT assets configured → should fail with helpful error
+ * 2. Update angular.json with the recommended assets configuration
+ * 3. Rebuild and verify it works
+ *
+ * This test reuses the existing Angular project but modifies angular.json dynamically.
+ */
+test.describe('Angular CLI Assets Configuration from Error Message', () => {
+ let serverProcess: ChildProcess | null = null;
+ const ASSETS_TEST_PORT = 4298;
+ let originalAngularJson: string;
+
+ test.beforeAll(async () => {
+ // Ensure plugin is built
+ if (!existsSync(join(PLUGIN_DIR, 'dist', 'index.mjs'))) {
+ console.log('Building plugin...');
+ execSync('pnpm run build', { cwd: PLUGIN_DIR, stdio: 'inherit' });
+ }
+
+ // Create project if it doesn't exist
+ if (!existsSync(PROJECT_DIR)) {
+ console.log('Creating Angular test project...');
+ execSync('bash scripts/create-angular-cesdk-test-project.sh', {
+ cwd: PLUGIN_DIR,
+ stdio: 'inherit'
+ });
+ }
+
+ // Use the shared original or read from file
+ const fs = await import('fs/promises');
+ if (ORIGINAL_ANGULAR_JSON) {
+ originalAngularJson = ORIGINAL_ANGULAR_JSON;
+ // Restore to original state in case a previous test left it modified
+ await fs.writeFile(ANGULAR_JSON_PATH, originalAngularJson);
+ } else {
+ originalAngularJson = await fs.readFile(ANGULAR_JSON_PATH, 'utf-8');
+ }
+ });
+
+ test.afterAll(async () => {
+ // Restore original angular.json
+ if (originalAngularJson) {
+ const fs = await import('fs/promises');
+ await fs.writeFile(ANGULAR_JSON_PATH, originalAngularJson);
+ }
+
+ // Kill server if running
+ if (serverProcess) {
+ console.log('Stopping assets test server...');
+ serverProcess.kill('SIGTERM');
+ serverProcess = null;
+ }
+ });
+
+ test('should fail without assets, then succeed after adding angular.json assets config', async ({ page }) => {
+ const fs = await import('fs/promises');
+
+ // ============================================
+ // STEP 1: Build WITHOUT assets - should fail with helpful error
+ // ============================================
+ console.log('Step 1: Building Angular project WITHOUT assets configured...');
+
+ // Modify angular.json to remove ALL asset configurations for our plugin
+ const angularJson = JSON.parse(originalAngularJson);
+ const buildOptions = angularJson.projects['webpack5-angular-cesdk-project'].architect.build.options;
+
+ // Remove any existing plugin asset configurations
+ buildOptions.assets = buildOptions.assets.filter((asset: any) => {
+ if (typeof asset === 'string') return true;
+ return !asset.input?.includes('plugin-print-ready-pdfs-web');
+ });
+
+ await fs.writeFile(ANGULAR_JSON_PATH, JSON.stringify(angularJson, null, 2));
+
+ // Build Angular project without assets
+ console.log('Building Angular project (no plugin assets)...');
+ execSync('npm run build', { cwd: PROJECT_DIR, stdio: 'inherit', timeout: 300000 });
+
+ // Start server
+ console.log(`Starting HTTP server on port ${ASSETS_TEST_PORT}...`);
+ serverProcess = spawn('npx', ['http-server', 'dist/webpack5-angular-cesdk-project', '-p', String(ASSETS_TEST_PORT), '--cors', '-c-1'], {
+ cwd: PROJECT_DIR,
+ stdio: 'pipe',
+ detached: false
+ });
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000);
+ serverProcess!.stdout?.on('data', (data) => {
+ const output = data.toString();
+ console.log('[Assets Test Server]', output);
+ if (output.includes('Available on') || output.includes('Serving')) {
+ clearTimeout(timeout);
+ setTimeout(resolve, 1000);
+ }
+ });
+ serverProcess!.stderr?.on('data', (data) => console.error('[Assets Test Server Error]', data.toString()));
+ serverProcess!.on('error', (error) => { clearTimeout(timeout); reject(error); });
+ });
+
+ // Navigate with testError=true (no assetPath provided)
+ console.log('Testing conversion without assets (should fail)...');
+ await page.goto(`http://localhost:${ASSETS_TEST_PORT}/?testError=true`, { timeout: 60000 });
+ await page.waitForLoadState('networkidle');
+
+ // Wait for CE.SDK
+ await page.waitForFunction(
+ () => (window as any).testResults?.cesdkReady === true,
+ { timeout: 120000 }
+ );
+
+ // Trigger export (should fail)
+ await page.evaluate(() => (window as any).triggerExport());
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 120000 }
+ );
+
+ let results = await page.evaluate(() => (window as any).testResults);
+ console.log('Step 1 results:', JSON.stringify(results, null, 2));
+
+ // Verify conversion failed with helpful error
+ expect(results.conversionSuccess).toBe(false);
+ expect(results.conversionError).not.toBeNull();
+
+ const errorMessage = results.conversionError as string;
+ expect(errorMessage).toContain('Could not locate plugin assets');
+ expect(errorMessage).toContain('Angular CLI');
+ expect(errorMessage).toContain('angular.json');
+ expect(errorMessage).toContain('assetPath');
+
+ console.log('Step 1 passed: Got expected error with Angular CLI instructions');
+
+ // Stop server for rebuild
+ serverProcess.kill('SIGTERM');
+ serverProcess = null;
+
+ // ============================================
+ // STEP 2: Add the Angular CLI assets configuration from the error message
+ // ============================================
+ console.log('Step 2: Adding angular.json assets configuration...');
+
+ // This is the EXACT configuration pattern from the error message that real users would use:
+ // { "glob": "{gs.js,gs.wasm,*.icc}", "input": "node_modules/@imgly/plugin-print-ready-pdfs-web/dist", "output": "/assets/" }
+ //
+ // For our test app, we use "/assets/print-ready-pdfs/" to match what the app expects
+ // (the app component uses assetPath: '/assets/print-ready-pdfs/' in normal mode)
+ const testAssetConfig = {
+ glob: '{gs.js,gs.wasm,*.icc}',
+ input: 'node_modules/@imgly/plugin-print-ready-pdfs-web/dist', // Exact path users would use
+ output: '/assets/print-ready-pdfs/' // Where the Angular app expects them
+ };
+
+ buildOptions.assets.push(testAssetConfig);
+ await fs.writeFile(ANGULAR_JSON_PATH, JSON.stringify(angularJson, null, 2));
+
+ console.log('Asset config added:', JSON.stringify(testAssetConfig));
+
+ // ============================================
+ // STEP 3: Rebuild and verify it works
+ // ============================================
+ console.log('Step 3: Rebuilding Angular project WITH assets configured...');
+ execSync('npm run build', { cwd: PROJECT_DIR, stdio: 'inherit', timeout: 300000 });
+
+ // Verify assets were copied
+ const distAssetsDir = join(PROJECT_DIR, 'dist', 'webpack5-angular-cesdk-project', 'assets', 'print-ready-pdfs');
+ const expectedFiles = ['gs.js', 'gs.wasm', 'ISOcoated_v2_eci.icc', 'GRACoL2013_CRPC6.icc', 'sRGB_IEC61966-2-1.icc'];
+ for (const file of expectedFiles) {
+ const filePath = join(distAssetsDir, file);
+ if (!existsSync(filePath)) {
+ throw new Error(`Angular CLI did not copy ${file} to dist/assets/print-ready-pdfs/`);
+ }
+ }
+ console.log('Assets copied successfully by Angular CLI to /assets/print-ready-pdfs/');
+
+ // Start server again
+ console.log(`Starting HTTP server on port ${ASSETS_TEST_PORT}...`);
+ serverProcess = spawn('npx', ['http-server', 'dist/webpack5-angular-cesdk-project', '-p', String(ASSETS_TEST_PORT), '--cors', '-c-1'], {
+ cwd: PROJECT_DIR,
+ stdio: 'pipe',
+ detached: false
+ });
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000);
+ serverProcess!.stdout?.on('data', (data) => {
+ const output = data.toString();
+ console.log('[Assets Test Server]', output);
+ if (output.includes('Available on') || output.includes('Serving')) {
+ clearTimeout(timeout);
+ setTimeout(resolve, 1000);
+ }
+ });
+ serverProcess!.stderr?.on('data', (data) => console.error('[Assets Test Server Error]', data.toString()));
+ serverProcess!.on('error', (error) => { clearTimeout(timeout); reject(error); });
+ });
+
+ // Test with assetPath pointing to where Angular CLI copied the assets
+ console.log('Testing conversion WITH assets and assetPath...');
+
+ // Clear browser cache
+ const client = await page.context().newCDPSession(page);
+ await client.send('Network.clearBrowserCache');
+
+ // Navigate normally (assets should now be available, use assetPath: '/assets/')
+ await page.goto(`http://localhost:${ASSETS_TEST_PORT}/`, { timeout: 60000 });
+ await page.waitForLoadState('networkidle');
+
+ // Wait for CE.SDK
+ await page.waitForFunction(
+ () => (window as any).testResults?.cesdkReady === true,
+ { timeout: 120000 }
+ );
+
+ // Trigger export (should succeed)
+ await page.evaluate(() => (window as any).triggerExport());
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 120000 }
+ );
+
+ results = await page.evaluate(() => (window as any).testResults);
+ console.log('Step 3 results:', JSON.stringify(results, null, 2));
+
+ // Verify success
+ expect(results.conversionSuccess).toBe(true);
+ expect(results.conversionError).toBeNull();
+ expect(results.pdfSize).toBeGreaterThan(0);
+
+ console.log(`Step 3 passed: Conversion succeeded with ${results.pdfSize} bytes`);
+ console.log('');
+ console.log('=== Angular CLI Assets Configuration Test Complete ===');
+ console.log('The documented angular.json assets configuration works correctly!');
+ });
+});
+
+/**
+ * Test that verifies the copy-webpack-plugin approach from the error message works.
+ * This is the pure Webpack 5 way of copying assets, as an alternative to Angular's assets config.
+ */
+test.describe('Webpack 5 copy-webpack-plugin Approach', () => {
+ let serverProcess: ChildProcess | null = null;
+ const WEBPACK_TEST_PORT = 4297;
+ const packageJsonPath = join(PROJECT_DIR, 'package.json');
+ let originalAngularJson: string;
+ let originalWebpackConfig: string;
+ let originalPackageJson: string;
+
+ test.beforeAll(async () => {
+ // Ensure plugin is built
+ if (!existsSync(join(PLUGIN_DIR, 'dist', 'index.mjs'))) {
+ console.log('Building plugin...');
+ execSync('pnpm run build', { cwd: PLUGIN_DIR, stdio: 'inherit' });
+ }
+
+ // Create project if it doesn't exist
+ if (!existsSync(PROJECT_DIR)) {
+ console.log('Creating Angular test project...');
+ execSync('bash scripts/create-angular-cesdk-test-project.sh', {
+ cwd: PLUGIN_DIR,
+ stdio: 'inherit'
+ });
+ }
+
+ // Use shared originals or read from file
+ const fs = await import('fs/promises');
+ if (ORIGINAL_ANGULAR_JSON) {
+ originalAngularJson = ORIGINAL_ANGULAR_JSON;
+ // Restore to original state in case a previous test left it modified
+ await fs.writeFile(ANGULAR_JSON_PATH, originalAngularJson);
+ } else {
+ originalAngularJson = await fs.readFile(ANGULAR_JSON_PATH, 'utf-8');
+ }
+ if (ORIGINAL_WEBPACK_CONFIG) {
+ originalWebpackConfig = ORIGINAL_WEBPACK_CONFIG;
+ await fs.writeFile(WEBPACK_CONFIG_PATH, originalWebpackConfig);
+ } else {
+ originalWebpackConfig = await fs.readFile(WEBPACK_CONFIG_PATH, 'utf-8');
+ }
+ originalPackageJson = await fs.readFile(packageJsonPath, 'utf-8');
+ });
+
+ test.afterAll(async () => {
+ // Restore original files
+ const fs = await import('fs/promises');
+ if (originalAngularJson) {
+ await fs.writeFile(ANGULAR_JSON_PATH, originalAngularJson);
+ }
+ if (originalWebpackConfig) {
+ await fs.writeFile(WEBPACK_CONFIG_PATH, originalWebpackConfig);
+ }
+ if (originalPackageJson) {
+ await fs.writeFile(packageJsonPath, originalPackageJson);
+ }
+
+ // Kill server if running
+ if (serverProcess) {
+ console.log('Stopping webpack test server...');
+ serverProcess.kill('SIGTERM');
+ serverProcess = null;
+ }
+ });
+
+ test('should work with copy-webpack-plugin instead of angular.json assets', async ({ page }) => {
+ const fs = await import('fs/promises');
+
+ // ============================================
+ // STEP 1: Install copy-webpack-plugin
+ // ============================================
+ console.log('Step 1: Installing copy-webpack-plugin...');
+
+ // Add copy-webpack-plugin to devDependencies
+ const packageJson = JSON.parse(originalPackageJson);
+ packageJson.devDependencies['copy-webpack-plugin'] = '^12.0.0';
+ await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
+
+ // Install the new dependency
+ execSync('npm install', { cwd: PROJECT_DIR, stdio: 'inherit', timeout: 120000 });
+
+ // Recreate the plugin symlink (npm install may have removed it)
+ const pluginSymlinkPath = join(PROJECT_DIR, 'node_modules', '@imgly', 'plugin-print-ready-pdfs-web');
+ const imglyDir = join(PROJECT_DIR, 'node_modules', '@imgly');
+ if (!existsSync(imglyDir)) {
+ await fs.mkdir(imglyDir, { recursive: true });
+ }
+ try {
+ await fs.unlink(pluginSymlinkPath);
+ } catch {
+ // Symlink didn't exist
+ }
+ await fs.symlink(PLUGIN_DIR, pluginSymlinkPath);
+ console.log('Recreated plugin symlink');
+
+ // ============================================
+ // STEP 2: Configure webpack.config.js with CopyPlugin
+ // ============================================
+ console.log('Step 2: Configuring webpack.config.js with CopyPlugin...');
+
+ // This is the EXACT pattern from our error message that real users would use:
+ // new CopyPlugin({ patterns: [{ from: "node_modules/@imgly/plugin-print-ready-pdfs-web/dist/*.{js,wasm,icc}", to: "assets/[name][ext]" }] })
+ const webpackConfigWithCopyPlugin = `// Custom Webpack 5 configuration with copy-webpack-plugin
+const CopyPlugin = require('copy-webpack-plugin');
+
+module.exports = {
+ resolve: {
+ fallback: {
+ fs: false,
+ path: false,
+ crypto: false
+ }
+ },
+ module: {
+ rules: [
+ {
+ test: /\\.wasm$/,
+ type: 'asset/resource'
+ }
+ ]
+ },
+ externals: {
+ 'fs': 'commonjs fs',
+ 'path': 'commonjs path'
+ },
+ plugins: [
+ // This is the EXACT pattern from the error message - what real users would copy-paste
+ new CopyPlugin({
+ patterns: [
+ {
+ from: 'node_modules/@imgly/plugin-print-ready-pdfs-web/dist/*.{js,wasm,icc}',
+ to: 'assets/[name][ext]'
+ }
+ ]
+ })
+ ]
+};
+`;
+ await fs.writeFile(WEBPACK_CONFIG_PATH, webpackConfigWithCopyPlugin);
+
+ // ============================================
+ // STEP 3: Remove Angular assets config (use only webpack plugin)
+ // ============================================
+ console.log('Step 3: Removing angular.json assets config (using only CopyPlugin)...');
+
+ const angularJson = JSON.parse(originalAngularJson);
+ const buildOptions = angularJson.projects['webpack5-angular-cesdk-project'].architect.build.options;
+
+ // Remove ALL plugin asset configurations from angular.json
+ buildOptions.assets = buildOptions.assets.filter((asset: any) => {
+ if (typeof asset === 'string') return true;
+ return !asset.input?.includes('plugin-print-ready-pdfs-web');
+ });
+
+ await fs.writeFile(ANGULAR_JSON_PATH, JSON.stringify(angularJson, null, 2));
+
+ // ============================================
+ // STEP 4: Build and verify assets are copied by CopyPlugin
+ // ============================================
+ console.log('Step 4: Building Angular project with CopyPlugin...');
+ execSync('npm run build', { cwd: PROJECT_DIR, stdio: 'inherit', timeout: 300000 });
+
+ // Verify assets were copied by CopyPlugin to /assets/
+ const distAssetsDir = join(PROJECT_DIR, 'dist', 'webpack5-angular-cesdk-project', 'assets');
+ const expectedFiles = ['gs.js', 'gs.wasm', 'ISOcoated_v2_eci.icc', 'GRACoL2013_CRPC6.icc', 'sRGB_IEC61966-2-1.icc'];
+ for (const file of expectedFiles) {
+ const filePath = join(distAssetsDir, file);
+ if (!existsSync(filePath)) {
+ throw new Error(`CopyPlugin did not copy ${file} to dist/assets/`);
+ }
+ }
+ console.log('Assets copied successfully by copy-webpack-plugin to /assets/');
+
+ // ============================================
+ // STEP 5: Start server and test conversion
+ // ============================================
+ console.log('Step 5: Starting server and testing conversion...');
+
+ serverProcess = spawn('npx', ['http-server', 'dist/webpack5-angular-cesdk-project', '-p', String(WEBPACK_TEST_PORT), '--cors', '-c-1'], {
+ cwd: PROJECT_DIR,
+ stdio: 'pipe',
+ detached: false
+ });
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000);
+ serverProcess!.stdout?.on('data', (data) => {
+ const output = data.toString();
+ console.log('[Webpack Test Server]', output);
+ if (output.includes('Available on') || output.includes('Serving')) {
+ clearTimeout(timeout);
+ setTimeout(resolve, 1000);
+ }
+ });
+ serverProcess!.stderr?.on('data', (data) => console.error('[Webpack Test Server Error]', data.toString()));
+ serverProcess!.on('error', (error) => { clearTimeout(timeout); reject(error); });
+ });
+
+ // Navigate with a custom query param to use /assets/ path (where CopyPlugin put the files)
+ // We need to update the app to support this, or use a different approach
+ await page.goto(`http://localhost:${WEBPACK_TEST_PORT}/?assetPath=/assets/`, { timeout: 60000 });
+ await page.waitForLoadState('networkidle');
+
+ // Wait for CE.SDK
+ await page.waitForFunction(
+ () => (window as any).testResults?.cesdkReady === true,
+ { timeout: 120000 }
+ );
+
+ // Trigger export
+ await page.evaluate(() => (window as any).triggerExport());
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 120000 }
+ );
+
+ const results = await page.evaluate(() => (window as any).testResults);
+ console.log('Test results:', JSON.stringify(results, null, 2));
+
+ // Verify success
+ expect(results.conversionSuccess).toBe(true);
+ expect(results.conversionError).toBeNull();
+ expect(results.pdfSize).toBeGreaterThan(0);
+
+ console.log(`Conversion succeeded with ${results.pdfSize} bytes`);
+ console.log('');
+ console.log('=== Webpack 5 copy-webpack-plugin Test Complete ===');
+ console.log('The documented copy-webpack-plugin configuration works correctly!');
+ });
+});
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/angular.json b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/angular.json
new file mode 100644
index 00000000..938d33aa
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/angular.json
@@ -0,0 +1,70 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "webpack5-angular-runtime-test": {
+ "projectType": "application",
+ "schematics": {},
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-builders/custom-webpack:browser",
+ "options": {
+ "customWebpackConfig": {
+ "path": "./webpack.config.js"
+ },
+ "outputPath": "dist/webpack5-angular-runtime-test",
+ "index": "src/index.html",
+ "main": "src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "tsconfig.json",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets",
+ {
+ "glob": "{gs.js,gs.wasm,*.icc}",
+ "input": "node_modules/@imgly/plugin-print-ready-pdfs-web",
+ "output": "/assets/print-ready-pdfs"
+ }
+ ],
+ "styles": [],
+ "scripts": []
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "5mb"
+ }
+ ],
+ "outputHashing": "none"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-builders/custom-webpack:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "webpack5-angular-runtime-test:build:production"
+ },
+ "development": {
+ "buildTarget": "webpack5-angular-runtime-test:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ }
+ }
+ }
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/package.json b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/package.json
new file mode 100644
index 00000000..28ce6521
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "webpack5-angular-runtime-test",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Test Angular + Webpack 5 runtime compatibility with print-ready-pdfs plugin",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve --port 4299",
+ "build": "ng build --configuration production",
+ "serve-dist": "npx http-server dist/webpack5-angular-runtime-test -p 4299 -c-1 --cors",
+ "test:runtime": "npm run build && npm run serve-dist"
+ },
+ "dependencies": {
+ "@angular/animations": "^18.2.0",
+ "@angular/common": "^18.2.0",
+ "@angular/compiler": "^18.2.0",
+ "@angular/core": "^18.2.0",
+ "@angular/forms": "^18.2.0",
+ "@angular/platform-browser": "^18.2.0",
+ "@angular/platform-browser-dynamic": "^18.2.0",
+ "@angular/router": "^18.2.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.6.0",
+ "zone.js": "~0.14.0"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^18.2.0",
+ "@angular/cli": "^18.2.0",
+ "@angular/compiler-cli": "^18.2.0",
+ "@angular-builders/custom-webpack": "^18.0.0",
+ "typescript": "~5.4.0",
+ "http-server": "^14.1.0"
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/app/app.component.ts b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/app/app.component.ts
new file mode 100644
index 00000000..b5359aa6
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/app/app.component.ts
@@ -0,0 +1,198 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+// @ts-ignore - local development build may not have .d.ts files
+import { convertToPDFX3 } from '@imgly/plugin-print-ready-pdfs-web';
+
+// Extend Window interface for test result exposure
+declare global {
+ interface Window {
+ testResults: {
+ importSuccess: boolean;
+ initializationError: string | null;
+ conversionAttempted: boolean;
+ conversionError: string | null;
+ conversionSuccess: boolean;
+ testComplete: boolean;
+ };
+ }
+}
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+
Webpack 5 + Angular Runtime Test
+
+
+
Test Status
+
+ -
+ Import: {{ importSuccess ? 'SUCCESS' : 'FAILED' }}
+ ({{ importError }})
+
+ -
+ Conversion: {{ conversionStatus }}
+ ({{ conversionError }})
+
+
+
+
+
+
+
+
+
+
Results
+
{{ results | json }}
+
+
+ `,
+ styles: [`
+ #test-container {
+ font-family: sans-serif;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ #status li {
+ margin: 10px 0;
+ }
+ button {
+ padding: 10px 20px;
+ font-size: 16px;
+ cursor: pointer;
+ }
+ button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ pre {
+ background: #f5f5f5;
+ padding: 15px;
+ border-radius: 5px;
+ overflow-x: auto;
+ }
+ `]
+})
+export class AppComponent implements OnInit {
+ importSuccess = false;
+ importError: string | null = null;
+ conversionStatus = 'Not started';
+ conversionError: string | null = null;
+ testing = false;
+ testComplete = false;
+ results: any = null;
+
+ ngOnInit() {
+ // Initialize test results on window for Playwright to access
+ window.testResults = {
+ importSuccess: false,
+ initializationError: null,
+ conversionAttempted: false,
+ conversionError: null,
+ conversionSuccess: false,
+ testComplete: false
+ };
+
+ // Test if import worked
+ try {
+ if (typeof convertToPDFX3 === 'function') {
+ this.importSuccess = true;
+ window.testResults.importSuccess = true;
+ console.log('[TEST] Import SUCCESS: convertToPDFX3 is a function');
+ } else {
+ this.importError = `convertToPDFX3 is ${typeof convertToPDFX3}`;
+ window.testResults.initializationError = this.importError;
+ console.error('[TEST] Import FAILED:', this.importError);
+ }
+ } catch (error: any) {
+ this.importError = error.message;
+ window.testResults.initializationError = error.message;
+ console.error('[TEST] Import ERROR:', error);
+ }
+
+ // Auto-run conversion test on load
+ setTimeout(() => this.testConversion(), 1000);
+ }
+
+ async testConversion() {
+ if (this.testing) return;
+
+ this.testing = true;
+ this.conversionStatus = 'Running...';
+ window.testResults.conversionAttempted = true;
+
+ try {
+ // Create a minimal valid PDF for testing
+ const minimalPDF = this.createMinimalPDF();
+ const inputBlob = new Blob([minimalPDF], { type: 'application/pdf' });
+
+ console.log('[TEST] Starting conversion with blob size:', inputBlob.size);
+
+ // Attempt conversion - this is where the runtime error would occur
+ // assetPath is required for Webpack 5 / Angular because import.meta.url
+ // is transformed to a file:// URL that doesn't work in browsers
+ const outputBlob = await convertToPDFX3(inputBlob, {
+ outputProfile: 'srgb',
+ title: 'Angular Webpack 5 Test',
+ assetPath: '/assets/print-ready-pdfs/'
+ });
+
+ this.conversionStatus = 'SUCCESS';
+ this.conversionError = null;
+ window.testResults.conversionSuccess = true;
+
+ this.results = {
+ inputSize: inputBlob.size,
+ outputSize: outputBlob.size,
+ outputType: outputBlob.type
+ };
+
+ console.log('[TEST] Conversion SUCCESS:', this.results);
+ } catch (error: any) {
+ this.conversionStatus = 'FAILED';
+ this.conversionError = error.message;
+ window.testResults.conversionError = error.message;
+
+ this.results = {
+ error: error.message,
+ stack: error.stack
+ };
+
+ console.error('[TEST] Conversion FAILED:', error);
+ } finally {
+ this.testing = false;
+ this.testComplete = true;
+ window.testResults.testComplete = true;
+ }
+ }
+
+ private createMinimalPDF(): string {
+ // Create a minimal valid PDF document
+ return `%PDF-1.4
+1 0 obj
+<< /Type /Catalog /Pages 2 0 R >>
+endobj
+2 0 obj
+<< /Type /Pages /Kids [3 0 R] /Count 1 >>
+endobj
+3 0 obj
+<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> >>
+endobj
+xref
+0 4
+0000000000 65535 f
+0000000009 00000 n
+0000000058 00000 n
+0000000115 00000 n
+trailer
+<< /Size 4 /Root 1 0 R >>
+startxref
+210
+%%EOF`;
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/assets/.gitkeep b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/assets/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/favicon.ico b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/favicon.ico
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/index.html b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/index.html
new file mode 100644
index 00000000..62311d77
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Webpack5 Angular Runtime Test
+
+
+
+
+
+
+
+
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/main.ts b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/main.ts
new file mode 100644
index 00000000..2086591f
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/src/main.ts
@@ -0,0 +1,5 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent)
+ .catch((err) => console.error(err));
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/tsconfig.json b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/tsconfig.json
new file mode 100644
index 00000000..fb9e5aae
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "bundler",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": ["ES2022", "dom"],
+ "useDefineForClassFields": false
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/webpack.config.js b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/webpack.config.js
new file mode 100644
index 00000000..50d6cb1f
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-project/webpack.config.js
@@ -0,0 +1,43 @@
+/**
+ * Custom Webpack 5 configuration for Angular
+ *
+ * This tests the runtime compatibility of @imgly/plugin-print-ready-pdfs-web
+ * with Angular + Webpack 5 bundling.
+ *
+ * Issue context: Customer reports runtime error:
+ * "Cannot find module 'file:///.../node_modules/@imgly/plugin-print-ready-pdfs-web/dist/gs.js'"
+ */
+module.exports = {
+ // Log webpack version to verify we're using Webpack 5
+ stats: {
+ version: true
+ },
+ resolve: {
+ fallback: {
+ // Node.js modules that shouldn't be bundled for browser
+ "path": false,
+ "fs": false,
+ "module": false,
+ "url": false,
+ "os": false
+ }
+ },
+ module: {
+ rules: [
+ {
+ // Handle .wasm files as assets
+ test: /\.wasm$/,
+ type: 'asset/resource',
+ generator: {
+ filename: 'assets/wasm/[name][ext]'
+ }
+ }
+ ]
+ },
+ // Don't bundle these as they're browser-incompatible
+ externals: {
+ 'module': 'commonjs module',
+ 'path': 'commonjs path',
+ 'fs': 'commonjs fs'
+ }
+};
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-runtime.spec.ts b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-runtime.spec.ts
new file mode 100644
index 00000000..280fb69e
--- /dev/null
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-angular-runtime.spec.ts
@@ -0,0 +1,240 @@
+/**
+ * Playwright test for Angular + Webpack 5 runtime compatibility
+ *
+ * This test verifies that @imgly/plugin-print-ready-pdfs-web works correctly
+ * when bundled with Angular CLI (which uses Webpack 5 internally).
+ *
+ * Customer issue: Runtime error
+ * "Cannot find module 'file:///.../node_modules/@imgly/plugin-print-ready-pdfs-web/dist/gs.js'"
+ *
+ * The test builds an Angular app that imports the plugin and attempts
+ * to use convertToPDFX3 at runtime.
+ */
+import { test, expect } from '@playwright/test';
+import { execSync, spawn, ChildProcess } from 'child_process';
+import { join } from 'path';
+import { existsSync, mkdirSync } from 'fs';
+
+const TEST_PROJECT_DIR = join(__dirname, 'webpack5-angular-project');
+const PLUGIN_DIR = join(__dirname, '..');
+const TEST_PORT = 4299;
+const TEST_URL = `http://localhost:${TEST_PORT}`;
+
+interface TestResults {
+ importSuccess: boolean;
+ initializationError: string | null;
+ conversionAttempted: boolean;
+ conversionError: string | null;
+ conversionSuccess: boolean;
+ testComplete: boolean;
+}
+
+test.describe('Angular + Webpack 5 Runtime Compatibility', () => {
+ let serverProcess: ChildProcess | null = null;
+ let consoleMessages: string[] = [];
+ let consoleErrors: string[] = [];
+
+ test.beforeAll(async () => {
+ console.log('Setting up Angular + Webpack 5 test project...');
+
+ // Ensure plugin is built
+ if (!existsSync(join(PLUGIN_DIR, 'dist', 'index.mjs'))) {
+ console.log('Building plugin...');
+ execSync('pnpm run build', { cwd: PLUGIN_DIR, stdio: 'inherit' });
+ }
+
+ // Install dependencies if needed
+ if (!existsSync(join(TEST_PROJECT_DIR, 'node_modules'))) {
+ console.log('Installing Angular project dependencies...');
+ execSync('npm install', { cwd: TEST_PROJECT_DIR, stdio: 'inherit' });
+
+ // Link local plugin
+ const pluginNodeModulesDir = join(TEST_PROJECT_DIR, 'node_modules', '@imgly', 'plugin-print-ready-pdfs-web');
+ mkdirSync(join(TEST_PROJECT_DIR, 'node_modules', '@imgly'), { recursive: true });
+ execSync(`cp -r "${join(PLUGIN_DIR, 'dist')}" "${pluginNodeModulesDir}"`, { stdio: 'inherit' });
+ execSync(`cp "${join(PLUGIN_DIR, 'package.json')}" "${pluginNodeModulesDir}/"`, { stdio: 'inherit' });
+ }
+
+ // Build Angular project
+ console.log('Building Angular project with Webpack 5...');
+ try {
+ execSync('npm run build', {
+ cwd: TEST_PROJECT_DIR,
+ stdio: 'pipe',
+ timeout: 120000
+ });
+ console.log('Angular build completed successfully');
+ } catch (error: any) {
+ console.error('Angular build failed:');
+ console.error(error.stdout?.toString());
+ console.error(error.stderr?.toString());
+ throw new Error('Angular build failed - see output above');
+ }
+
+ // Start server for built files
+ console.log('Starting HTTP server for Angular app...');
+ serverProcess = spawn('npx', ['http-server', 'dist/webpack5-angular-runtime-test', '-p', String(TEST_PORT), '-c-1', '--cors'], {
+ cwd: TEST_PROJECT_DIR,
+ stdio: 'pipe'
+ });
+
+ // Wait for server to be ready
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000);
+
+ serverProcess!.stdout?.on('data', (data) => {
+ const output = data.toString();
+ console.log('Server:', output);
+ if (output.includes('Available on') || output.includes('Hit CTRL-C')) {
+ clearTimeout(timeout);
+ setTimeout(resolve, 1000); // Give server a moment to fully start
+ }
+ });
+
+ serverProcess!.stderr?.on('data', (data) => {
+ console.error('Server error:', data.toString());
+ });
+ });
+ });
+
+ test.afterAll(async () => {
+ if (serverProcess) {
+ console.log('Stopping HTTP server...');
+ serverProcess.kill('SIGTERM');
+ serverProcess = null;
+ }
+ });
+
+ test.beforeEach(async ({ page }) => {
+ consoleMessages = [];
+ consoleErrors = [];
+
+ // Capture all console output
+ page.on('console', (msg) => {
+ const text = `${msg.type()}: ${msg.text()}`;
+ consoleMessages.push(text);
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text());
+ }
+ });
+
+ page.on('pageerror', (error) => {
+ consoleErrors.push(`Page error: ${error.message}`);
+ });
+
+ page.on('requestfailed', (request) => {
+ const failure = request.failure();
+ consoleErrors.push(`Request failed: ${request.url()} - ${failure?.errorText || 'Unknown'}`);
+ });
+ });
+
+ test('should load Angular app without import errors', async ({ page }) => {
+ await page.goto(TEST_URL);
+ await page.waitForLoadState('networkidle');
+
+ // Wait for Angular to bootstrap
+ await page.waitForSelector('#test-container', { timeout: 10000 });
+
+ // Check import status
+ const importStatus = await page.locator('#import-status').textContent();
+ console.log('Import status:', importStatus);
+ console.log('Console messages:', consoleMessages);
+ console.log('Console errors:', consoleErrors);
+
+ // Check for the specific file:// error the customer reported
+ const hasFileProtocolError = consoleErrors.some(e =>
+ e.includes('file://') && e.includes('gs.js')
+ );
+
+ if (hasFileProtocolError) {
+ console.error('DETECTED: file:// protocol error for gs.js - this is the customer-reported issue');
+ }
+
+ expect(importStatus).toContain('SUCCESS');
+ expect(hasFileProtocolError).toBe(false);
+ });
+
+ test('should initialize Ghostscript WASM module at runtime', async ({ page }) => {
+ await page.goto(TEST_URL);
+ await page.waitForLoadState('networkidle');
+
+ // Wait for test to complete (auto-runs on load)
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 60000 }
+ );
+
+ const results: TestResults = await page.evaluate(() => (window as any).testResults);
+
+ console.log('Test results:', JSON.stringify(results, null, 2));
+ console.log('Console errors:', consoleErrors);
+
+ // Check for gs.js loading errors
+ const gsJsErrors = consoleErrors.filter(e =>
+ e.includes('gs.js') || e.includes('gs.wasm') || e.includes('Ghostscript')
+ );
+
+ if (gsJsErrors.length > 0) {
+ console.error('Ghostscript loading errors detected:', gsJsErrors);
+ }
+
+ expect(results.importSuccess).toBe(true);
+ expect(results.initializationError).toBeNull();
+ });
+
+ test('should successfully convert PDF to PDF/X-3', async ({ page }) => {
+ await page.goto(TEST_URL);
+ await page.waitForLoadState('networkidle');
+
+ // Wait for test to complete
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 90000 }
+ );
+
+ const results: TestResults = await page.evaluate(() => (window as any).testResults);
+
+ console.log('Final test results:', JSON.stringify(results, null, 2));
+ console.log('All console messages:', consoleMessages);
+ console.log('All console errors:', consoleErrors);
+
+ // This is the critical test - conversion should succeed
+ if (!results.conversionSuccess) {
+ console.error('CONVERSION FAILED:', results.conversionError);
+
+ // Check if it's the specific file:// issue
+ if (results.conversionError?.includes('file://')) {
+ console.error('>>> CONFIRMED: This is the customer-reported file:// protocol issue <<<');
+ }
+ }
+
+ expect(results.conversionAttempted).toBe(true);
+ expect(results.conversionSuccess).toBe(true);
+ expect(results.conversionError).toBeNull();
+ });
+
+ test('should not have file:// protocol errors in browser context', async ({ page }) => {
+ await page.goto(TEST_URL);
+ await page.waitForLoadState('networkidle');
+
+ // Wait for test to complete
+ await page.waitForFunction(
+ () => (window as any).testResults?.testComplete === true,
+ { timeout: 60000 }
+ );
+
+ // Specifically check for the customer's reported error pattern
+ const fileProtocolErrors = consoleErrors.filter(e =>
+ e.includes('file://') ||
+ e.includes('Cannot find module') ||
+ (e.includes('gs.js') && e.includes('load'))
+ );
+
+ if (fileProtocolErrors.length > 0) {
+ console.error('File protocol errors detected (customer-reported issue):');
+ fileProtocolErrors.forEach(e => console.error(' -', e));
+ }
+
+ expect(fileProtocolErrors.length).toBe(0);
+ });
+});
diff --git a/packages/plugin-print-ready-pdfs-web/test/webpack5-compatibility-test.sh b/packages/plugin-print-ready-pdfs-web/test/webpack5-compatibility-test.sh
index f64c877f..006e599e 100755
--- a/packages/plugin-print-ready-pdfs-web/test/webpack5-compatibility-test.sh
+++ b/packages/plugin-print-ready-pdfs-web/test/webpack5-compatibility-test.sh
@@ -1,9 +1,12 @@
#!/bin/bash
#
-# Test: Verify @imgly/plugin-print-ready-pdfs-web works with Webpack 5
+# Test: Verify @imgly/plugin-print-ready-pdfs-web works with Webpack 5 (BUILD-TIME TEST)
# Issue: https://github.com/imgly/ubq/issues/11471
#
-# Exit 0 = PASS (plugin is Webpack 5 compatible)
+# This test verifies that the plugin can be bundled with Webpack 5 without build errors.
+# For runtime testing with Angular + Webpack 5, use: pnpm test:webpack5:angular
+#
+# Exit 0 = PASS (plugin is Webpack 5 compatible for bundling)
# Exit 1 = FAIL (plugin has Webpack 5 compatibility issues)
#
@@ -11,7 +14,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_DIR="$(dirname "$SCRIPT_DIR")"
-TEST_PROJECT_DIR="${SCRIPT_DIR}/webpack5-angular-project"
+TEST_PROJECT_DIR="${SCRIPT_DIR}/webpack5-build-test"
echo "Testing Webpack 5 compatibility..."
diff --git a/packages/plugin-print-ready-pdfs-web/vitest.config.ts b/packages/plugin-print-ready-pdfs-web/vitest.config.ts
index 35f3acd9..90a6d80e 100644
--- a/packages/plugin-print-ready-pdfs-web/vitest.config.ts
+++ b/packages/plugin-print-ready-pdfs-web/vitest.config.ts
@@ -10,6 +10,7 @@ export default defineConfig({
// Timeout for tests (30 seconds)
testTimeout: 30000,
+ hookTimeout: 30000,
// Environment
environment: 'node',