diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index b2107fb2..238360c4 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -11,15 +11,34 @@ jobs: strategy: fail-fast: false matrix: - # Node.js 18 is required by jQuery infra - NODE_VERSION: [18.x, 20.x, 22.x] + # Node.js 22 is required by jQuery infra + NODE_VERSION: [20.x, 22.x] steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Install xsltproc & ImageMagick + - name: Install xsltproc run: | - sudo apt-get install xsltproc imagemagick + sudo apt-get install xsltproc + + # When Ubuntu Plucky is available in GitHub Actions, switch to it, remove + # the following section and just install the `imagemagick` package normally + # via apt-get. + - name: Download and build ImageMagick 7 + run: | + sudo apt-get install -y build-essential pkg-config \ + libjpeg-dev libpng-dev libtiff-dev libwebp-dev + + # Replace the version below with the desired release + IM_VERSION="7.1.1-44" + wget https://download.imagemagick.org/ImageMagick/download/releases/ImageMagick-${IM_VERSION}.tar.xz + tar -xf ImageMagick-${IM_VERSION}.tar.xz + cd ImageMagick-${IM_VERSION} + + ./configure + make -j$(nproc) + sudo make install + sudo ldconfig - name: Use Node.js ${{ matrix.NODE_VERSION }} uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 diff --git a/.nvmrc b/.nvmrc index 3c032078..2bd5a0a9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +22 diff --git a/Dockerfile b/Dockerfile index 2536c31d..d03d20aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-alpine +FROM node:22-alpine WORKDIR /app COPY package*.json ./ diff --git a/README.md b/README.md index de2a8ccb..35704f30 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ jQuery UI DownloadBuilder & ThemeRoller backend and frontend application. ## Requirements -- [node >= 18 and npm](https://nodejs.org/en/download/) -- ImageMagick 6.6.x. ([see below for instructions to compile it from source](#compile-and-install-imagemagick-from-source)) +- [node >= 20 and npm](https://nodejs.org/en/download/) +- ImageMagick 7.x. ([See below for instructions how to install it](#install-imagemagick)) - grunt-cli: `npm install -g grunt-cli` ## Getting Started @@ -95,9 +95,11 @@ $ grunt deploy ## Appendix -### Compile and install ImageMagick from source +### Install ImageMagick -Follow instructions from https://legacy.imagemagick.org/script/install-source.php to install ImageMagic `6.6.9-10`. Then, in the ImageMagick directory, invoke: +You will need ImageMagic `7.x` with PNG support. If your distribution doesn't provide such a version (on macOS it is included in the `imagemagick` Homebrew package), you will need to compile ImageMagick from source. + +Follow instructions from https://imagemagick.org/script/install-source.php to install ImageMagic `7.x`. Then, in the ImageMagick directory, invoke: ``` $ ./configure CFLAGS=-O5 CXXFLAGS=-O5 --prefix=/opt --enable-static --with-png --disable-shared ``` @@ -107,7 +109,7 @@ Make sure you have the below in the output. PNG --with-png=yes yes ``` -If "png=yes no", libpng is missing and needs to be installed, `apt-get install libpng-dev` on linux or `brew install libpng` on OS X. +If "png=yes no", `libpng` is missing and needs to be installed, `apt-get install libpng-dev` on linux or `brew install libpng` on macOS. Continuing... ``` @@ -120,8 +122,8 @@ export DYLD_LIBRARY_PATH="$MAGICK_HOME/lib/" Make sure you get the right bin when running it. ``` -$ which convert -/opt/bin/convert +$ which magick +/opt/bin/magick ``` -Hint: add those export statements into your .bash_profile. +Hint: add those export statements into your `.bash_profile`. diff --git a/eslint.config.mjs b/eslint.config.mjs index ac565e73..a4146bbf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -64,11 +64,6 @@ export default [ { files: [ "app/src/**/*.js" ], languageOptions: { - - // No need to keep IE support, so we could bump it to ES2022 as well, - // but we need to switch the minifier to something other than UglifJS - // which is ES5-only. - ecmaVersion: 5, sourceType: "script", parserOptions: { globalReturn: false diff --git a/lib/themeroller-image.js b/lib/themeroller-image.js index 5df5f7df..2c66c271 100644 --- a/lib/themeroller-image.js +++ b/lib/themeroller-image.js @@ -1,14 +1,33 @@ "use strict"; -var cache, imVersion, - async = require( "async" ), - Cache = require( "./cache" ), - im = require( "gm" ).subClass( { imageMagick: true } ), - semver = require( "semver" ), - dimensionLimit = 3000, - namedColors = require( "./themeroller-colors" ); +const path = require( "node:path" ); +const async = require( "async" ); +const spawn = require( "cross-spawn" ); +const Cache = require( "./cache" ); +const semver = require( "semver" ); +const dimensionLimit = 3000; +const namedColors = require( "./themeroller-colors" ); -cache = new Cache( "Image Cache" ); +const cache = new Cache( "Image Cache" ); + +function processImageMagick( args, callback ) { + const proc = spawn( "magick", args ); + + const stdoutBuffers = []; + const stderrBuffers = []; + + proc.stdout.on( "data", data => stdoutBuffers.push( data ) ); + proc.stderr.on( "data", data => stderrBuffers.push( data ) ); + + proc.on( "close", code => { + if ( code !== 0 ) { + return callback( new Error( `magick process exited with code ${ code }: ${ Buffer.concat( stderrBuffers ).toString() }` ) ); + } + callback( null, Buffer.concat( stdoutBuffers ) ); + } ); + + proc.on( "error", callback ); +} function expandColor( color ) { if ( color.length === 3 && /^[0-9a-f]+$/i.test( color ) ) { @@ -26,44 +45,6 @@ function hashColor( color ) { return color; } -// I don't know if there's a better solution, but without the below conversion to Buffer we're not able to use it. -function stream2Buffer( callback ) { - return function( err, stdin, stderr ) { - if ( err ) { - return callback( err ); - } - var chunks = [], - dataLen = 0; - err = ""; - - stdin.on( "data", function( chunk ) { - chunks.push( chunk ); - dataLen += chunk.length; - } ); - - stderr.on( "data", function( chunk ) { - err += chunk; - } ); - - stdin.on( "end", function() { - var i = 0, - buffer = Buffer.alloc( dataLen ); - if ( err.length ) { - return callback( new Error( err ) ); - } - chunks.forEach( function( chunk ) { - chunk.copy( buffer, i, 0, chunk.length ); - i += chunk.length; - } ); - callback( null, buffer ); - } ); - - stdin.on( "error", function( err ) { - callback( err ); - } ); - }; -} - function validateColor( color ) { color = color.replace( /^#/, "" ); if ( ( color.length === 3 || color.length === 6 ) && /^[0-9a-f]+$/i.test( color ) ) { @@ -147,27 +128,21 @@ generateIcon = function( params, callback ) { // Add '#' in the beginning of the colors if needed color = hashColor( params.color ); - // https://www.imagemagick.org/Usage/masking/#shapes - // IM 6.7.9 and below: - // $ convert -background -alpha shape output.png - // IM > 6.7.9: (see https://github.com/jquery/download.jqueryui.com/issues/132) - // $ convert -set colorspace RGB -background -alpha shape -set colorspace sRGB output.png + // https://usage.imagemagick.org/masking/#shapes + // See https://github.com/jquery/download.jqueryui.com/issues/132 for why + // `-set colorspace RGB` is needed (twice) in IM >6.7.9. Full command: + // $ magick -set colorspace RGB -background -alpha shape -set colorspace sRGB output.png imageQueue.push( function( innerCallback ) { try { - if ( semver.gt( imVersion, "6.7.9" ) ) { - im( __dirname + "/../template/themeroller/icon/mask.png" ) - .out( "-set", "colorspace", "RGB" ) - .background( color ) - .out( "-alpha", "shape" ) - .out( "-set", "colorspace", "sRGB" ) - .stream( "png", stream2Buffer( innerCallback ) ); - } else { - im( __dirname + "/../template/themeroller/icon/mask.png" ) - .background( color ) - .out( "-alpha", "shape" ) - .stream( "png", stream2Buffer( innerCallback ) ); - } + processImageMagick( [ + path.join( __dirname, "/../template/themeroller/icon/mask.png" ), + "-set", "colorspace", "RGB", + "-background", color, + "-alpha", "shape", + "-set", "colorspace", "sRGB", + "png:-" + ], innerCallback ); } catch ( err ) { return innerCallback( err ); } @@ -182,14 +157,20 @@ generateTexture = function( params, callback ) { filename = params.type.replace( /-/g, "_" ).replace( /$/, ".png" ); - // https://www.imagemagick.org/Usage/compose/#dissolve - // $ convert -size x 'xc:' -compose dissolve -define compose:args=,100 -composite output.png + // https://usage.imagemagick.org/compose/#dissolve + // $ magick -size x 'xc:' -compose dissolve -define compose:args=,100 -composite output.png imageQueue.push( function( innerCallback ) { try { - im( params.width, params.height, color ) - .out( __dirname + "/../template/themeroller/texture/" + filename, "-compose", "dissolve", "-define", "compose:args=" + params.opacity + ",100", "-composite" ) - .stream( "png", stream2Buffer( innerCallback ) ); + processImageMagick( [ + "-size", `${ params.width }x${ params.height }`, + "canvas:" + color, + path.join( __dirname, "/../template/themeroller/texture/", filename ), + "-compose", "dissolve", + "-define", `compose:args=${ params.opacity },100`, + "-composite", + "png:-" + ], innerCallback ); } catch ( err ) { return innerCallback( err ); } @@ -327,7 +308,7 @@ Image.prototype = { } }; -// Check the ImageMagick installation using node-gm (in a hacky way). +// Check the ImageMagick installation. async.series( [ function( callback ) { var wrappedCallback = function( err ) { @@ -337,24 +318,27 @@ async.series( [ callback(); }; try { - im()._spawn( [ "convert", "-version" ], true, wrappedCallback ); + processImageMagick( [ "-version" ], wrappedCallback ); } catch ( err ) { return wrappedCallback( err ); } }, function( callback ) { - im()._spawn( [ "convert", "-version" ], false, stream2Buffer( function( err, buffer ) { + processImageMagick( [ "-version" ], function( err, buffer ) { + if ( err ) { + return callback( err ); + } var output = buffer.toString( "utf8" ); if ( !( /ImageMagick/ ).test( output ) ) { return callback( new Error( "ImageMagick not installed.\n" + output ) ); } - imVersion = output.split( /\r?\n/ )[ 0 ].replace( /^Version: ImageMagick ([^ ]*).*/, "$1" ); + const imVersion = output.split( /\r?\n/ )[ 0 ].replace( /^Version: ImageMagick ([^ ]*).*/, "$1" ); if ( !semver.valid( imVersion ) ) { return callback( new Error( "Could not identify ImageMagick version.\n" + output ) ); } imageQueue.resume(); callback(); - } ) ); + } ); } ], function( err ) { if ( err ) { diff --git a/package-lock.json b/package-lock.json index b1d73fe5..e53259e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,13 @@ "async": "3.2.6", "builder-amd": "0.0.3", "builder-jquery-css": "0.0.4", + "cross-spawn": "7.0.6", "eslint": "9.21.0", "eslint-config-jquery": "3.0.2", "express": "^4.21.2", "fast-glob": "^3.3.3", "formidable": "3.5.2", "globals": "^16.0.0", - "gm": "^1.25.1", "grunt": "1.6.1", "grunt-check-modules": "1.1.0", "grunt-contrib-clean": "2.0.1", @@ -36,7 +36,7 @@ "wolfy87-eventemitter": "5.2.9" }, "engines": { - "node": "18 || 20 || 22" + "node": ">=20" } }, "node_modules/@colors/colors": { @@ -849,18 +849,6 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/array-parallel": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", - "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==", - "license": "MIT" - }, - "node_modules/array-series": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", - "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==", - "license": "MIT" - }, "node_modules/array-slice": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", @@ -2205,37 +2193,6 @@ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "license": "MIT" }, - "node_modules/gm": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/gm/-/gm-1.25.1.tgz", - "integrity": "sha512-jgcs2vKir9hFogGhXIfs0ODhJTfIrbECCehg38tqFgHm8zqXx7kAJyCYAFK4jTjx71AxrkFtkJBawbAxYUPX9A==", - "deprecated": "The gm module has been sunset. Please migrate to an alternative. https://github.com/aheckmann/gm?tab=readme-ov-file#2025-02-24-this-project-is-not-maintained", - "license": "MIT", - "dependencies": { - "array-parallel": "~0.1.3", - "array-series": "~0.1.5", - "cross-spawn": "^7.0.5", - "debug": "^3.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gm/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/gm/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", diff --git a/package.json b/package.json index df9d84b6..d26dcef8 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,13 @@ "async": "3.2.6", "builder-amd": "0.0.3", "builder-jquery-css": "0.0.4", + "cross-spawn": "7.0.6", "eslint": "9.21.0", "eslint-config-jquery": "3.0.2", "express": "^4.21.2", "fast-glob": "^3.3.3", "formidable": "3.5.2", "globals": "^16.0.0", - "gm": "^1.25.1", "grunt": "1.6.1", "grunt-check-modules": "1.1.0", "grunt-contrib-clean": "2.0.1", @@ -39,6 +39,6 @@ "test": "qunit --require ./test/setup.js test" }, "engines": { - "node": "18 || 20 || 22" + "node": ">=20" } }