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',