diff --git a/.gitattributes b/.gitattributes index 2521e0b3..53fc5ee2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,11 @@ -/tests export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/.travis.yml export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php_cs.dist export-ignore +/.travis.yml export-ignore +/install-gmagick-with-webp.sh export-ignore +/install-imagemagick-with-webp.sh export-ignore +/install-vips.sh export-ignore +/phpdox.xml export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..8dd1d30d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: rosell-dk +ko_fi: rosell diff --git a/.github/install-gmagick-with-webp.sh b/.github/install-gmagick-with-webp.sh new file mode 100644 index 00000000..bab5aa60 --- /dev/null +++ b/.github/install-gmagick-with-webp.sh @@ -0,0 +1,74 @@ +# https://duntuk.com/how-install-graphicsmagick-gmagick-php-extension +# https://gist.github.com/basimhennawi/21c39f9758b0b1cb5e0bd5ee08b5be58 +# https://github.com/rosell-dk/webp-convert/wiki/Installing-gmagick-extension + +#if [ -d "$HOME/vips/bin" ]; then +#fi; + + +$HOME/opt/bin/gm -version | grep -i 'WebP.*yes' && { + gmagick_installed_with_webp=1 +} + +if [[ $gmagick_installed_with_webp == 1 ]]; then + echo "Gmagick is already compiled with webp. Nothing to do :)" + echo ":)" +else + echo "Gmagick is is not installed or not compiled with webp." + compile_libwebp=1 + compile_gmagick=1 +fi; +#ls $HOME/opt/bin + + +cores=$(nproc) +LIBWEBP_VERSION=1.0.2 + +if [[ $compile_libwebp == 2 ]]; then + echo "We are going to be compiling libwebp..." + echo "Using $cores cores." + echo "Downloading libwebp version $LIBWEBP_VERSION" + cd /tmp + curl -O https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$LIBWEBP_VERSION.tar.gz + tar xzf libwebp-$LIBWEBP_VERSION.tar.gz + cd libwebp-* + + echo "./configure --prefix=$HOME/opt" + ./configure --prefix=$HOME/opt + + echo "make -j$CORES" + make -j$CORES + + echo "make install -j$CORES" + make install -j$CORES +fi; + +if [[ $compile_gmagick == 2 ]]; then + echo "Compiling Gmagick" + echo "Using $cores cores." + cd /tmp + echo "Downloading GraphicsMagick-LATEST.tar.gz" + wget http://78.108.103.11/MIRROR/ftp/GraphicsMagick/GraphicsMagick-LATEST.tar.gz + tar xfz GraphicsMagick-LATEST.tar.gz + cd GraphicsMagick-* + + echo "Configuring" + ./configure --prefix=$HOME/opt --enable-shared --with-webp=yes + + echo "make -j$CORES" + make -j$CORES + + echo "make install -j$CORES" + make install -j$CORES +fi; + + +#./configure --prefix=$HOME/opt --with-webp=yes && + +#$HOME/opt/bin/gm -version + +#convert -version | grep 'webp' || { + +#convert -list delegate | grep 'webp =>' || { +#} +##libgraphicsmagick1-dev diff --git a/.github/install-imagemagick-with-webp.sh b/.github/install-imagemagick-with-webp.sh new file mode 100644 index 00000000..5dfc8ba3 --- /dev/null +++ b/.github/install-imagemagick-with-webp.sh @@ -0,0 +1,40 @@ + +# Install imagick with webp support (if not already there) and update library paths +# Got the script from here: +# https://stackoverflow.com/questions/41138404/how-to-install-newer-imagemagick-with-webp-support-in-travis-ci-container + +if ! [[ $IMAGEMAGICK_VERSION ]]; then + export IMAGEMAGICK_VERSION="7.0.8-43" +fi; + +convert -list delegate | grep 'webp =>' && { + echo "Imagick is already compiled with webp. Nothing to do :)" && + echo ":)" +} + +#convert -version | grep 'webp' || { + +convert -list delegate | grep 'webp =>' || { + export CORES=$(nproc) && + export LIBWEBP_VERSION=1.0.2 && + echo "Using $CORES cores for compiling..." && + cd /tmp && + curl -O https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-$LIBWEBP_VERSION.tar.gz && + tar xzf libwebp-$LIBWEBP_VERSION.tar.gz && + cd libwebp-* && + ./configure --prefix=$HOME/opt && + make -j$CORES && + make install -j$CORES && + cd /tmp && + curl -O https://www.imagemagick.org/download/ImageMagick-$IMAGEMAGICK_VERSION.tar.gz && + tar xzf ImageMagick-$IMAGEMAGICK_VERSION.tar.gz && + cd ImageMagick-* && + ./configure --prefix=$HOME/opt --with-webp=yes && + make -j$CORES && + make install -j$CORES && + $HOME/opt/bin/magick -version | grep $IMAGEMAGICK_VERSION +} + +export LD_FLAGS=-L$HOME/opt/lib +export LD_LIBRARY_PATH=/lib:/usr/lib:/usr/local/lib:$HOME/opt/lib +export CPATH=$CPATH:$HOME/opt/include diff --git a/.github/install-vips.sh b/.github/install-vips.sh new file mode 100644 index 00000000..ef081c66 --- /dev/null +++ b/.github/install-vips.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +if ! [[ $VIPS_VERSION ]]; then + export VIPS_VERSION="8.10.6" +fi; + +export PATH=$HOME/vips/bin:$PATH +export LD_LIBRARY_PATH=$HOME/vips/lib:$LD_LIBRARY_PATH +export PKG_CONFIG_PATH=$HOME/vips/lib/pkgconfig:$PKG_CONFIG_PATH +export PYTHONPATH=$HOME/vips/lib/python2.7/site-packages:$PYTHONPATH +export GI_TYPELIB_PATH=$HOME/vips/lib/girepository-1.0:$GI_TYPELIB_PATH + +vips_site=https://github.com/libvips/libvips/releases/download + +set -e + +# do we already have the correct vips built? early exit if yes +# we could check the configure params as well I guess +if [ -d "$HOME/vips/bin" ]; then + installed_version=$($HOME/vips/bin/vips --version) + escaped_version="${VIPS_VERSION//\./\\.}" + echo "Need vips-$version" + echo "Found $installed_version" + if [[ "$installed_version" =~ ^vips-$escaped_version ]]; then + echo "Using cached directory" + exit 0 + fi +fi + +rm -rf $HOME/vips +echo "wget: $vips_site/v$VIPS_VERSION/vips-$VIPS_VERSION.tar.gz" +wget $vips_site/v$VIPS_VERSION/vips-$VIPS_VERSION.tar.gz +tar xf vips-$VIPS_VERSION.tar.gz +cd vips-$VIPS_VERSION +CXXFLAGS=-D_GLIBCXX_USE_CXX11_ABI=0 ./configure --prefix=$HOME/vips --disable-debug --disable-dependency-tracking --disable-introspection --disable-static --enable-gtk-doc-html=no --enable-gtk-doc=no --enable-pyvips8=no --without-orc --without-python +make && make install + +# Install PHP extension +# ---------------------- +yes '' | pecl install vips diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..35c5786d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-20.04, windows-2022] # We pick something other than in coverage.yml, which is also run on pushes + php: [5.6, 7.2] # We pick something other than in coverage.yml, which is also run on pushes + exclude: # phpunit is malfunctioning on Windows / PHP 5.6, so disable those tests + - os: windows-2019 + php: 5.6 + - os: windows-2022 + php: 5.6 + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: exif, mbstring, fileinfo, gd, vips, gmagick, imagick + + - name: Validate composer.json + run: composer validate --strict + + - name: Composer alterations for PHP 7.2 + if: matrix.php == '7.2' + run: | + echo "Downgrading phpunit to ^8.0, which is the highest version that supports PHP 7.2" + composer require "phpunit/phpunit:^8.0" --dev --no-update + + - name: Composer alterations for PHP 7.1 + if: matrix.php == '7.1' + run: | + echo "Removing phpstan, as it does not work on PHP 7.1" + composer remove phpstan/phpstan --dev --no-update + + echo "Downgrading phpunit to ^7.0, which is the highest version that supports PHP 7.1" + composer require "phpunit/phpunit:^7.0" --dev --no-update + + - name: Composer alterations for PHP 7.0 + if: matrix.php == '7.0' + run: | + echo "Remove phpstan, as it does not work on PHP 7.0" + composer remove phpstan/phpstan --dev --no-update + + echo "Downgrading phpunit to ^6.0, which is the highest version that supports PHP 7.0" + composer require "phpunit/phpunit:^6.0" --dev --no-update + + - name: Composer alterations for PHP 5.6 + if: matrix.php == '5.6' + run: | + echo "Remove phpstan, as it does not work on PHP 5.6" + composer remove phpstan/phpstan --dev --no-update + + echo "Downgrading phpunit to ^5.0, which is the highest version that supports PHP 5.6" + composer require "phpunit/phpunit:^5.0" --dev --no-update + + - name: Create composer.lock for cache key (this is a library, so composer.lock is not part of repo) + run: composer update --no-install + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + ${{ runner.os }}-php-${{ matrix.php }} + ${{ runner.os }}-php- + + - name: Composer install + run: composer install --prefer-dist --no-progress + + - name: Run phpunit (test cases) + run: composer run-script test + + - name: Run phpstan on PHP>=7.2 (to check php syntax) + if: (matrix.php != '5.6') && (matrix.php != '7.0') && (matrix.php != '7.1') && (matrix.php != '7.2') + run: composer run-script phpstan + + - name: run phpcs (to check coding style) + run: composer run-script phpcs-all diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..ac215520 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,62 @@ +name: Code Coverage + +on: + workflow_dispatch + +jobs: + + codecov: + runs-on: ubuntu-22.04 + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + WEBPCONVERT_WPC_API_URL: ${{ secrets.WEBPCONVERT_WPC_API_URL }} + WEBPCONVERT_WPC_API_KEY: ${{ secrets.WEBPCONVERT_WPC_API_KEY }} + WEBPCONVERT_WPC_API_URL_API0: ${{ secrets.WEBPCONVERT_WPC_API_URL_API0 }} + steps: + - name: Checkout + uses: actions/checkout@v2 + +# - name: Setup vips +# run: | +# chmod +x ./.github/install-vips.sh +# ./.github/install-vips.sh + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + # Note: Currently, gmagick and imagick are mutually exclusive. + # It seems they are installed in the order indicated in "extensions" and the latter cancels the former + extensions: exif, mbstring, fileinfo, gd, vips, gmagick, imagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-codecov-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-codecov- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test-with-coverage + + - name: Upload Scrutinizer coverage + uses: sudo-bot/action-scrutinizer@latest + with: + cli-args: "--format=php-clover build/coverage.clover" diff --git a/.github/workflows/php-debug.yml b/.github/workflows/php-debug.yml new file mode 100644 index 00000000..551555d8 --- /dev/null +++ b/.github/workflows/php-debug.yml @@ -0,0 +1,51 @@ +name: Debug + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + php73: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [macos-11] + php: [7.3] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php73-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php73- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test diff --git a/.github/workflows/php82.yml b/.github/workflows/php82.yml new file mode 100644 index 00000000..387e1379 --- /dev/null +++ b/.github/workflows/php82.yml @@ -0,0 +1,48 @@ +name: PHP 8.2 + +on: + workflow_dispatch: + schedule: # Run monthly + - cron: "14 3 1 * *" + +permissions: + contents: read + +jobs: + build: + + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-22.04, ubuntu-20.04, windows-2022, windows-2019, macos-13, macos-12] + php: [8.2] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Validate composer.json + run: composer validate --strict + + - name: Composer install + run: composer install --prefer-dist --no-progress + + - name: Run phpunit (test cases) + run: composer run-script test + + - name: Run phpstan + run: composer run-script phpstan + + - name: run phpcs (to check coding style) + run: composer run-script phpcs-all diff --git a/.github/workflows/php83.yml b/.github/workflows/php83.yml new file mode 100644 index 00000000..238b6d91 --- /dev/null +++ b/.github/workflows/php83.yml @@ -0,0 +1,48 @@ +name: PHP 8.3 + +on: + workflow_dispatch: + schedule: # Run monthly + - cron: "14 3 10 * *" + +permissions: + contents: read + +jobs: + build: + + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-22.04, ubuntu-20.04, windows-2022, windows-2019, macos-13, macos-12] + php: [8.3] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Validate composer.json + run: composer validate --strict + + - name: Composer install + run: composer install --prefer-dist --no-progress + + - name: Run phpunit (test cases) + run: composer run-script test + + - name: Run phpstan + run: composer run-script phpstan + + - name: run phpcs (to check coding style) + run: composer run-script phpcs-all diff --git a/.github/workflows/php84.yml b/.github/workflows/php84.yml new file mode 100644 index 00000000..a5706bbc --- /dev/null +++ b/.github/workflows/php84.yml @@ -0,0 +1,49 @@ +name: PHP 8.4 + +on: + workflow_dispatch: + schedule: # Run monthly + - cron: "14 3 20 * *" + +permissions: + contents: read + +jobs: + build: + + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-22.04, ubuntu-20.04, windows-2022, windows-2019, macos-13, macos-12] + php: [8.4] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Validate composer.json + run: composer validate --strict + + - name: Composer install + run: composer install --prefer-dist --no-progress + + - name: Run phpunit (test cases) + run: composer run-script test + + # Disabled phpstan run, as phpstan currently doesn't work in PHP8.4 (phpstan 1.10.66 does not work) + #- name: Run phpstan + # run: composer run-script phpstan + + - name: run phpcs (to check coding style) + run: composer run-script phpcs-all diff --git a/.github/workflows/release-old.yml b/.github/workflows/release-old.yml new file mode 100644 index 00000000..cffbfce9 --- /dev/null +++ b/.github/workflows/release-old.yml @@ -0,0 +1,361 @@ +name: Large testsuite (run manually before releases) + +on: workflow_dispatch + +jobs: + php81: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-20.04, ubuntu-18.04, windows-2022, windows-2019, windows-2016, macos-11, macos-10.15] + php: [8.1] + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php81-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php81- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test + + php80: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [windows-2022, macos-11] + php: [8.0] + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php80-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php80- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test + + php74: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-18.04, windows-2022, windows-2019, windows-2016, macos-11, macos-10.15] + php: [7.4] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php74-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php74- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test + + php73: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-18.04, windows-2022, windows-2019, windows-2016, macos-11, macos-10.15] + php: [7.3] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php73-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php73- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test + + + php71: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-18.04, windows-2022, windows-2019, windows-2016, macos-11, macos-10.15] + php: [7.1] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php71-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php71- + + - name: Downgrade PHP unit to a version that supports PHP 7.1 + run: composer require "phpunit/phpunit:^7.0" --dev + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test + + + with_disabled_functions: + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-18.04] + php: [7.1] + # unfortunately, proc_open is needed by phpunit, so we cannot disable proc_open + disabled_functions: ["exec", "exec,passthru,shell_exec,popen"] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + ini-values: disable_functions="${{ matrix.disabled_functions }}" + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-disabledfunctions-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-disabledfunctions- + + - name: Downgrade PHP unit to a version that supports PHP 7.1 + run: composer require "phpunit/phpunit:^7.0" --dev + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test + + + + codecov: + runs-on: ubuntu-20.04 + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + WEBPCONVERT_WPC_API_URL: ${{ secrets.WEBPCONVERT_WPC_API_URL }} + WEBPCONVERT_WPC_API_KEY: ${{ secrets.WEBPCONVERT_WPC_API_KEY }} + WEBPCONVERT_WPC_API_URL_API0: ${{ secrets.WEBPCONVERT_WPC_API_URL_API0 }} + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + extensions: exif, mbstring, fileinfo, gd, vips, imagick, gmagick + + - name: Setup problem matchers for PHP + run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" + + - name: Setup problem matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Downgrade PHP unit to a version that supports PHP 7.4 + run: composer require "phpunit/phpunit:^8.0" --dev + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-codecov-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-codecov- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: composer run-script test-with-coverage + + - name: Upload Scrutinizer coverage + uses: sudo-bot/action-scrutinizer@latest + with: + cli-args: "--format=php-clover build/logs/coverage.clover" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9d9988b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Giant test + +on: workflow_dispatch + +permissions: + contents: read + +jobs: + build: + + runs-on: ${{ matrix.os }} + env: + WEBPCONVERT_EWWW_API_KEY: ${{ secrets.WEBPCONVERT_EWWW_API_KEY }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-22.04, ubuntu-20.04, windows-2022, windows-2019, macos-13, macos-12] + php: [8.2, 8.1, 8.0, 7.4, 7.3, 7.2, 7.1, 7.0, 5.6] + #os: [windows-2022] + #php: [7.1] + exclude: # phpunit is malfunctioning on Windows / PHP 5.6, so disable those tests + - os: windows-2019 + php: 5.6 + - os: windows-2022 + php: 5.6 + + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + extensions: exif, mbstring, fileinfo, gd + + - name: Validate composer.json + run: composer validate --strict + + - name: Composer alterations for PHP 7.2 + if: matrix.php == '7.2' + run: | + echo "Downgrading phpunit to ^8.0, which is the highest version that supports PHP 7.2" + composer require "phpunit/phpunit:^8.0" --dev --no-update + + - name: Composer alterations for PHP 7.1 + if: matrix.php == '7.1' + run: | + echo "Removing phpstan, as it does not work on PHP 7.1" + composer remove phpstan/phpstan --dev --no-update + + echo "Downgrading phpunit to ^7.0, which is the highest version that supports PHP 7.1" + composer require "phpunit/phpunit:^7.0" --dev --no-update + + - name: Composer alterations for PHP 7.0 + if: matrix.php == '7.0' + run: | + echo "Remove phpstan, as it does not work on PHP 7.0" + composer remove phpstan/phpstan --dev --no-update + + echo "Downgrading phpunit to ^6.0, which is the highest version that supports PHP 7.0" + composer require "phpunit/phpunit:^6.0" --dev --no-update + + - name: Composer alterations for PHP 5.6 + if: matrix.php == '5.6' + run: | + echo "Remove phpstan, as it does not work on PHP 5.6" + composer remove phpstan/phpstan --dev --no-update + + echo "Downgrading phpunit to ^5.0, which is the highest version that supports PHP 5.6" + composer require "phpunit/phpunit:^5.0" --dev --no-update + + # Create composer.lock, which is going to be used in the cache key + - name: Create composer.lock for cache key (this is a library, so composer.lock is not part of repo) + run: composer update --no-install + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + ${{ runner.os }}-php-${{ matrix.php }} + ${{ runner.os }}-php- + + - name: Composer install + run: composer install --prefer-dist --no-progress + + - name: Run phpunit (test cases) + run: composer run-script test-no-cov + + - name: Run phpstan on PHP>=7.2 (to check php syntax) + if: (matrix.php != '5.6') && (matrix.php != '7.0') && (matrix.php != '7.1') && (matrix.php != '7.2') + run: composer run-script phpstan + + - name: run phpcs (to check coding style) + run: composer run-script phpcs-all diff --git a/.gitignore b/.gitignore index 160da55f..5d4a20ab 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ composer.phar /vendor /tests/*.webp /tests/images/*.webp +.idea +/build +coverage.clover +.phpunit.result.cache diff --git a/.travis.yml b/.travis.yml index 766ef98a..5fa44870 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,152 @@ + +# Other projects which deals with images in PHP and uses travis: +# https://github.com/JBZoo/Image +# https://travis-ci.org/peter279k/php-image-converter/builds + language: php -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 +#php: +# - 5.6 +# - 7.1 +# - 7.2 +# - 7.3 + +# cache composer? +# https://blog.wyrihaximus.net/2015/07/composer-cache-on-travis/ +# https://github.com/thephpleague/glide/blob/master/.travis.yml +cache: + #apt: true + directories: + - "$HOME/opt" # cache our imagick/gmagick install + - $HOME/vips -dist: trusty +addons: + apt: + packages: + - libjpeg-dev + - libpng-dev + - libwebp-dev + + # following for vips (taken from https://github.com/libvips/php-vips/blob/master/.travis.yml) + - gobject-introspection + - libcfitsio3-dev + - libfftw3-dev + - libgif-dev + - libgs-dev + - libgsf-1-dev + - libmatio-dev + - libopenslide-dev + - liborc-0.4-dev + - libpango1.0-dev + - libpoppler-glib-dev sudo: false +matrix: + fast_finish: true + include: + - name: "PHP 8.0, Xenial" + php: 8.0 + #dist: trusty + #dist: focal + #dist: bionic + dist: xenial + env: + - UPLOADCOVERAGE=0 + - PHPSTAN=1 + - INSTALLVIPS=1 + #- VIPS_VERSION="8.6.3" # PS: This results in Vips WITH webp support + - VIPS_VERSION="8.10.6" # PS: This results in Vips WITHOUT webp support + - INSTALLIMAGEMAGICK=1 + - IMAGEMAGICK_VERSION="7.0.11-11" + - XDEBUG_MODE=coverage + - PHPUNIT_VERSION="^9.3" + - COMPOSER_TEST_SCRIPT="test" + + - name: "PHP 7.4, Xenial - With upload coverage" + php: 7.4 + dist: xenial + env: + - UPLOADCOVERAGE=1 + - PHPSTAN=1 + - INSTALLVIPS=1 + #- VIPS_VERSION="8.6.3" + - VIPS_VERSION="8.7.4" + - INSTALLIMAGICK=1 + - IMAGEMAGICK_VERSION="7.0.8-43" + - PHPUNIT_VERSION="^8.0" + - COMPOSER_TEST_SCRIPT="test" + + - name: "PHP 7.2, Xenial" + php: 7.2 + dist: xenial + env: + - UPLOADCOVERAGE=0 + - PHPSTAN=1 + - INSTALLVIPS=1 + - VIPS_VERSION="8.6.3" + #- VIPS_VERSION="8.7.4" + - INSTALLIMAGICK=1 + - IMAGEMAGICK_VERSION="7.0.8-43" + - PHPUNIT_VERSION="^8.0" + - COMPOSER_TEST_SCRIPT="test-41" + + - name: "PHP 5.6, Trusty" + php: 5.6 + dist: trusty + #dist: xenial + #dist: bionic + env: + - UPLOADCOVERAGE=0 + - PHPSTAN=0 + - INSTALLVIPS=0 + #- VIPS_VERSION="8.7.4" + - INSTALLIMAGEMAGICK=0 # imagemagick.org is currently down... - so installation cannot download gzip + - PHPUNIT_VERSION="5.7.27" + - COMPOSER_TEST_SCRIPT="test-41" + + #allow_failures: + +before_install: + # VIPS + - if [[ $INSTALLVIPS == 1 ]]; then bash install-vips.sh; fi + + # Update PATH so that travis can find our imagemagick / gmagick + - export PATH=$HOME/opt/bin:$PATH + + # ImageMagick + - if [[ $INSTALLIMAGEMAGICK == 1 ]]; then bash install-imagemagick-with-webp.sh; fi + + # install imagick extension (if not already there) + # hm, not working + #- echo '' | pecl install imagick + + # GMagick + + - bash install-gmagick-with-webp.sh + #- export GMAGICK_PATH=$HOME/opt/bin + + # Hm, extension is not working yet. When I get to install the extension I get the message: + # "Please provide a path to GraphicsMagick-config program." + # According to here: https://stackoverflow.com/questions/8626558/please-provide-the-prefix-of-graphicsmagick-installation-autodetect + # it is because the libgraphicsmagick1-dev package is not installed + # But is that needed when we are building ? + #- echo '' | pecl install gmagick-beta + before_script: + #- echo $PATH - (composer self-update; true) + - composer require "phpunit/phpunit:${PHPUNIT_VERSION}" --dev - composer install + - if [[ $PHPSTAN == 1 ]]; then composer require --dev phpstan/phpstan:"^0.12.8"; fi script: - - composer test + - composer ${COMPOSER_TEST_SCRIPT} + - if [[ $PHPSTAN == 1 ]]; then vendor/bin/phpstan analyse src --level=4; fi + +after_script: + - | + if [[ $UPLOADCOVERAGE == 1 ]]; then + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover coverage.clover + fi diff --git a/BACKERS.md b/BACKERS.md new file mode 100644 index 00000000..da4232b4 --- /dev/null +++ b/BACKERS.md @@ -0,0 +1,32 @@ + +# Backers + +WebP Convert is an MIT-licensed open source project. It is free and always will be. + +How is it financed then? Well, it isn't exactly. However, some people choose to support the development by buying the developer a cup of coffee, and some go even further, by becoming backers. Backers are nice folks making recurring monthly donations, and by doing this, they give me an excuse to put more work into the library than I really should. + +To become a backer yourself, visit [my page at patreon](https://www.patreon.com/rosell) + + +## Active backers via Patron + +| Name | Since date | +| ---------------------- | -------------- | +| Max Kreminsky | 2019-08-02 | +| [Mathieu Gollain-Dupont](https://www.linkedin.com/in/mathieu-gollain-dupont-9938a4a/) | 2020-08-26 | +| Nodeflame | 2019-10-31 | +| Ruben Solvang | 2020-01-08 | + + +Hi-scores: + +| Name | Life time contribution | +| ------------------------ | ------------------------ | +| Tammy Valgardson | $90 | +| Max Kreminsky | $65 | +| Ruben Solvang | $14 | +| Dmitry Verzjikovsky | $5 | + +## Former backers - I'm still grateful :) +- Dmitry Verzjikovsky +- Tammy Valgardson diff --git a/README.md b/README.md index 489df6ac..03f0d5db 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,28 @@ # WebP Convert -[![Build Status](https://travis-ci.org/rosell-dk/webp-convert.png?branch=master)](https://travis-ci.org/rosell-dk/webp-convert) +[![Latest Stable Version](https://img.shields.io/packagist/v/rosell-dk/webp-convert.svg)](https://packagist.org/packages/rosell-dk/webp-convert) +[![Minimum PHP Version](https://img.shields.io/packagist/dependency-v/rosell-dk/webp-convert/php.svg)](https://php.net) +[![Build Status](https://img.shields.io/github/actions/workflow/status/rosell-dk/webp-convert/ci.yml?logo=GitHub&style=flat-square)](https://github.com/rosell-dk/webp-convert/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/endpoint?url=https://little-b.it/webp-convert/code-coverage/coverage-badge.json)](http://little-b.it/webp-convert/code-coverage/coverage/index.html) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/rosell-dk/webp-convert/blob/master/LICENSE) +[![Monthly Downloads](http://poser.pugx.org/rosell-dk/webp-convert/d/monthly)](https://packagist.org/packages/rosell-dk/webp-convert) +[![Dependents](http://poser.pugx.org/rosell-dk/webp-convert/dependents)](https://packagist.org/packages/rosell-dk/webp-convert/dependents?order_by=downloads) *Convert JPEG & PNG to WebP with PHP* -This library enables you to do webp conversion with PHP using *cwebp*, *gd*, *imagick*, *ewww* cloud converter or the open source *wpc* cloud converter. It also allows you to try a whole stack – useful if you do not have control over the environment, and simply want the library to do *everything it can* to convert the image to webp. +This library enables you to do webp conversion with PHP. It supports an abundance of methods for converting and automatically selects the most capable of these that is available on the system. + +The library can convert using the following methods: +- *cwebp* (executing [cwebp](https://developers.google.com/speed/webp/docs/cwebp) binary using an `exec` call) +- *vips* (using [Vips PHP extension](https://github.com/libvips/php-vips-ext)) +- *imagick* (using [Imagick PHP extension](https://github.com/Imagick/imagick)) +- *gmagick* (using [Gmagick PHP extension](https://www.php.net/manual/en/book.gmagick.php)) +- *imagemagick* (executing [imagemagick](https://imagemagick.org/index.php) binary using an `exec` call) +- *graphicsmagick* (executing [graphicsmagick](http://www.graphicsmagick.org/) binary using an `exec` call) +- *ffmpeg* (executing [ffmpeg](https://ffmpeg.org/) binary using an `exec` call) +- *wpc* (using [WebPConvert Cloud Service](https://github.com/rosell-dk/webp-convert-cloud-service/) - an open source webp converter for PHP - based on this library) +- *ewwww* (using the [ewww](https://ewww.io/plans/) cloud converter (1 USD startup and then free webp conversion)) +- *gd* (using the [Gd PHP extension](https://www.php.net/manual/en/book.image.php)) In addition to converting, the library also has a method for *serving* converted images, and we have instructions here on how to set up a solution for automatically serving webp images to browsers that supports webp. @@ -16,81 +34,152 @@ composer require rosell-dk/webp-convert ``` ## Converting images -To convert an image, using a stack of converters, use the *WebPConvert::convert* method. It is documented in [docs/api/convert.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/api/convert.md). - -Here is an example: +Here is a minimal example of converting using the *WebPConvert::convert* method: ```php - 'auto', - 'max-quality' => 80, - 'converters' => ['cwebp', 'gd', 'imagick', 'wpc', 'ewww'], // Specify conversion methods to use, and their order - - 'converter-options' => [ - 'ewww' => [ - 'key' => 'your-api-key-here' - ], - 'wpc' => [ - 'api-version' => 1, - 'url' => 'https://example.com/wpc.php', - 'api-key' => 'my dog is white' - ] - ] - - // more options available! - see the api -]); +$destination = $source . '.webp'; +$options = []; +WebPConvert::convert($source, $destination, $options); ``` -To convert using a specific conversion method, simply set the *converters* option so it only has that method. - -The conversion methods (aka "converters") are documented here: [docs/converters.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/converters.md). +The *WebPConvert::convert* method comes with a bunch of options. The following introduction is a *must-read*: +[docs/v2.0/converting/introduction-for-converting.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md). +If you are migrating from 1.3.9, [read this](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/migrating-to-2.0.md) ## Serving converted images -The *convertAndServe* method tries to serve a converted image. If there already is an image at the destination, it will take that, unless the original is newer or smaller. If the method cannot serve a converted image, it will serve original image, a 404, or whatever the 'fail' option is set to - and return false. It also adds a *X-WebP-Convert-Status* header, which allows you to inspect what happened. +The *WebPConvert::serveConverted* method tries to serve a converted image. If there already is an image at the destination, it will take that, unless the original is newer or smaller. If the method cannot serve a converted image, it will serve original image, a 404, or whatever the 'fail' option is set to. It also adds *X-WebP-Convert-Log* headers, which provides insight into what happened. -Example: +Example (version 2.0): ```php - 'original', // If failure, serve the original image (source). - //'fail' => '404', // If failure, respond with 404. +require 'vendor/autoload.php'; +use WebPConvert\WebPConvert; + +$source = __DIR__ . '/logo.jpg'; +$destination = $source . '.webp'; + +WebPConvert::serveConverted($source, $destination, [ + 'fail' => 'original', // If failure, serve the original image (source). Other options include 'throw', '404' and 'report' //'show-report' => true, // Generates a report instead of serving an image - // Besides the specific options for convertAndServe(), you can also use the options for convert() + 'serve-image' => [ + 'headers' => [ + 'cache-control' => true, + 'vary-accept' => true, + // other headers can be toggled... + ], + 'cache-control-header' => 'max-age=2', + ], + + 'convert' => [ + // all convert option can be entered here (ie "quality") + ], ]); ``` -To see all options, look at the API: [docs/api/convert-and-serve.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/api/convert-and-serve.md) + +The following introduction is a *must-read* (for 2.0): +[docs/v2.0/serving/introduction-for-serving.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/serving/introduction-for-serving.md). + +The old introduction (for 1.3.9) is available here: [docs/v1.3/serving/convert-and-serve.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/serving/convert-and-serve.md) ## WebP on demand -The library can be used to create a *WebP On Demand* solution, which automatically serves WebP images instead of jpeg/pngs for browsers that supports WebP. To set this up, follow what's described [in this tutorial](https://github.com/rosell-dk/webp-convert/blob/master/docs/webp-on-demand/webp-on-demand.md). +The library can be used to create a *WebP On Demand* solution, which automatically serves WebP images instead of jpeg/pngs for browsers that supports WebP. To set this up, follow what's described [in this tutorial (not updated for 2.0 yet)](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/webp-on-demand/webp-on-demand.md). -## WebP Convert in the wild -*WebP Convert* is used in the following projects: +## Projects using WebP Convert +### CMS plugins using WebP Convert +This library is used as the engine to provide webp conversions to a handful of platforms. Hopefully this list will be growing over time. Currently there are plugins / extensions / modules / whatever the term is for the following CMS'es (ordered by [market share](https://w3techs.com/technologies/overview/content_management/all)): -#### [webp-express](https://github.com/rosell-dk/webp-express) -Wordpress integration +- [Wordpress](https://github.com/rosell-dk/webp-express) +- [Drupal 7](https://github.com/HDDen/Webp-Drupal-7) +- [Contao](https://github.com/postyou/contao-webp-bundle) +- [Kirby](https://github.com/S1SYPHOS/kirby-webp) +- [October CMS](https://github.com/OFFLINE-GmbH/oc-responsive-images-plugin/) -#### [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) -A cloud service based on WebPConvert +### Other projects using WebP Convert -#### [kirby-webp](https://github.com/S1SYPHOS/kirby-webp) -Kirby CMS integration +- [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) +A cloud service based on WebPConvert -## Bloat-ware -If you enjoy this software, feel free to stop exhaling, but continue inhaling, until you lift up like a balloon, and fly away to a place where no-one can reach you. +- [webp-convert-concat](https://github.com/rosell-dk/webp-convert-concat) +The webp-convert library and its dependents as a single PHP file (or two) + +## Supporting WebP Convert +Bread on the table don't come for free, even though this library does, and always will. I enjoy developing this, and supporting you guys, but I kind of need the bread too. Please make it possible for me to have both: + +- [Become a backer or sponsor on Patreon](https://www.patreon.com/rosell). +- [Buy me a Coffee](https://ko-fi.com/rosell) + +## Supporters +*Persons currently backing the project via patreon - Thanks!* + +- Max Kreminsky +- Nodeflame +- [Mathieu Gollain-Dupont](https://www.linkedin.com/in/mathieu-gollain-dupont-9938a4a/) +- Ruben Solvang + +*Persons who recently contributed with [ko-fi](https://ko-fi.com/rosell) - Thanks!* +* 18 Oct: Magestyx +* 10 Oct: Jesper +* 4 Oct: Caio Nogueira +* 22 Sep: Mark +* 8 Sep: Brinsley +* 4 Aug: Henri +* 13 Jun: Pat +* 2 May: Label Vier +* 28 Apr: Nealboy +* 11 Apr: Anonymous +* 3 Apr: Jakub +* 28 Mar: ciriman +* 31 Jan: Aron + +*Persons who contributed with extra generously amounts of coffee / lifetime backing (>50$) - thanks!:* + +* Max Kreminsky ($115) +* Justin - BigScoots ($105) +* Bill Vallance ($102) +* Label Vier ($100) +* Sebastian ($99) +* Tammy Lee ($90) +* Steven Sullivan ($51) +* Mathieu Gollain-Dupont ($50) +* Erica Dreisbach ($50) +* Brian Laursen ($50) +* Dimitris Vayenas ($50) + +## New in 2.9.0 (released 7 dec 2021, on my daughters 10 years birthday!) +- When exec() is unavailable, alternatives are now tried (emulations with proc_open(), passthru() etc). Using [this library](https://github.com/rosell-dk/exec-with-fallback) to do it. +- Gd is now marked as not operational when the needed functions for converting palette images to RGB is missing. Rationale: A half-working converter causes more trouble than one that is marked as not operational +- Improved CI tests. It is now tested on Windows, Mac and with deactivated functions (such as when exec() is disabled) +- And more (view closed issues [here](https://github.com/rosell-dk/webp-convert/milestone/25?closed=1) + +## New in 2.8.0: +- Converter option definitions are now accessible along with suggested UI and helptexts. This allows one to auto-generate a frontend based on conversion options. The feature is already in use in the [webp-convert file manager](https://github.com/rosell-dk/webp-convert-filemanager), which is used in WebP Express. New method: `WebPConvert::getConverterOptionDefinitions()` +- The part of the log that displays the options are made more readable. It also now warns about deprecated options. +- Bumped image-mime-type guesser library to 0.4. This version is able to dectect more mime types by sniffing the first couple of bytes. +- And more (view closed issues [here](https://github.com/rosell-dk/webp-convert/milestone/23?closed=1) + +## New in 2.7.0: +- ImageMagick now supports the "near-lossless" option (provided Imagick >= 7.0.10-54) [#299](https://github.com/rosell-dk/webp-convert/issues/299) +- Added "try-common-system-paths" option for ImageMagick (default: true). So ImageMagick will now peek for "convert" in common system paths [#293](https://github.com/rosell-dk/webp-convert/issues/293) +- Fixed memory leak in Gd on very old versions of PHP [#264](https://github.com/rosell-dk/webp-convert/issues/264) +- And more (view closed issues [here](https://github.com/rosell-dk/webp-convert/milestone/24?closed=1) + +## New in 2.6.0: +- Introduced [auto-limit](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#auto-limit) option which replaces setting "quality" to "auto" [#281](https://github.com/rosell-dk/webp-convert/issues/281) +- Added "sharp-yuv" option and made it default on. [Its great](https://www.ctrl.blog/entry/webp-sharp-yuv.html), use it! Works in most converters (works in cwebp, vips, imagemagick, graphicsmagick, imagick and gmagick) [#267](https://github.com/rosell-dk/webp-convert/issues/267), [#280](https://github.com/rosell-dk/webp-convert/issues/280), [#284](https://github.com/rosell-dk/webp-convert/issues/284) +- Bumped cwebp binaries to 1.2.0 [#273](https://github.com/rosell-dk/webp-convert/issues/273) +- vips now supports "method" option and "preset" option. +- graphicsmagick now supports "auto-filter" potion +- vips, imagick, imagemagick, graphicsmagick and gmagick now supports "preset" option [#275](https://github.com/rosell-dk/webp-convert/issues/275) +- cwebp now only validates hash of supplied precompiled binaries when necessary. This cuts down conversion time. [#287](https://github.com/rosell-dk/webp-convert/issues/287) +- Added [new option](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#cwebp-skip-these-precompiled-binaries) to cwebp for skipping precompiled binaries that are known not to work on current system. This will cut down on conversion time. [#288](https://github.com/rosell-dk/webp-convert/issues/288) +- And more (view closed issues [here](https://github.com/rosell-dk/webp-convert/milestone/22?closed=1)) diff --git a/build-scripts/PHPMerger.php b/build-scripts/PHPMerger.php deleted file mode 100644 index c9a16ad9..00000000 --- a/build-scripts/PHPMerger.php +++ /dev/null @@ -1,77 +0,0 @@ - '../build/webp-on-demand-1.inc', - - 'jobs' => [ - [ - 'root' => './', - 'files' => [ - // put base classes here - '../src/WebPConvert.php', - '../src/Serve/ServeBase.php', - '../src/Serve/ServeExistingOrHandOver.php', - //'webp-on-demand-script.inc', - ], - 'dirs' => [ - // dirs will be required in specified order. There is no recursion, so you need to specify subdirs as well. - //'.', - ] - ] - ] -]); - -// Build "webp-on-demand-2.inc" -PhpMerger::generate([ - 'destination' => '../build/webp-on-demand-2.inc', - - 'jobs' => [ - [ - 'root' => '../src/', - - 'files' => [ - // put base classes here - 'Exceptions/WebPConvertBaseException.php', - 'Loggers/BaseLogger.php' - ], - 'dirs' => [ - // dirs will be required in specified order. There is no recursion, so you need to specify subdirs as well. - //'.', - '.', - 'Converters', - 'Exceptions', - 'Converters/Exceptions', - 'Loggers', - 'Serve', - ], - 'exclude' => [ - '/Serve/ServeBase.php', - '/Serve/ServeExistingOrHandOver.php', - '/WebPConvert.php' - ] - ], - ] -]); - -// Build "webp-convert.inc", containing the entire library (for the lazy ones) -PhpMerger::generate([ - 'destination' => '../build/webp-convert.inc', - - 'jobs' => [ - [ - 'root' => '../src/', - - 'files' => [ - // put base classes here - 'Exceptions/WebPConvertBaseException.php', - 'Loggers/BaseLogger.php' - ], - 'dirs' => [ - // dirs will be required in specified order. There is no recursion, so you need to specify subdirs as well. - //'.', - '.', - 'Converters', - 'Exceptions', - 'Converters/Exceptions', - 'Loggers', - 'Serve', - ], - 'exclude' => [ - ] - ], - ] -]); diff --git a/build-scripts/generate-require-all.php b/build-scripts/generate-require-all.php deleted file mode 100644 index bb353358..00000000 --- a/build-scripts/generate-require-all.php +++ /dev/null @@ -1,83 +0,0 @@ - '../src', - 'destination' => '../src/require-all.inc', - 'files' => [ - // put base classes here - 'Exceptions/WebPConvertBaseException.php', - 'Loggers/BaseLogger.php' - ], - 'dirs' => [ - // dirs will be required in specified order. There is no recursion, so you need to specify subdirs as well. - '.', - 'Converters', - 'Exceptions', - 'Converters/Exceptions', - 'Loggers', - 'Serve', - ] -]); diff --git a/build/webp-convert.inc b/build/webp-convert.inc deleted file mode 100644 index 2881346d..00000000 --- a/build/webp-convert.inc +++ /dev/null @@ -1,2845 +0,0 @@ -log($msg, $style); - $this->ln(); - } - - public function logLnLn($msg, $style = '') - { - $this->logLn($msg, $style); - $this->ln(); - } -} - -?> 'auto', - 'max-quality' => 85, - 'default-quality' => 75, - 'metadata' => 'none', - 'method' => 6, - 'low-memory' => false, - 'lossless' => false, - 'converters' => ['cwebp', 'gd', 'imagick'], - 'converter-options' => [] - ]; - - public static function mergeOptions($options, $extraOptions) - { - return $options; - } - - public static function getClassNameOfConverter($converterId) - { - return 'WebPConvert\\Converters\\' . ucfirst($converterId); - } - - /* Call the "convert" method on a converter, by id. - - but also prepares options (merges in the $extraOptions of the converter), - prepares destination folder, and runs some standard validations - If it fails, it throws an exception. Otherwise it don't (there is no return value) - */ - public static function runConverter( - $converterId, - $source, - $destination, - $options = [], - $prepareDestinationFolder = true, - $logger = null - ) { - - - if ($prepareDestinationFolder) { - self::prepareDestinationFolderAndRunCommonValidations($source, $destination); - } - - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - - $className = self::getClassNameOfConverter($converterId); - if (!is_callable([$className, 'convert'])) { - throw new ConverterNotFoundException(); - } - - // Prepare options. - // - Remove 'converters' - $defaultOptions = self::$defaultOptions; - unset($defaultOptions['converters']); - - // - Merge defaults of the converters extra options into the standard default options. - $defaultOptions = array_merge($defaultOptions, array_column($className::$extraOptions, 'default', 'name')); - - // - Merge $defaultOptions into provided options - $options = array_merge($defaultOptions, $options); - - // Individual converters do not accept quality = auto. They need a number. - // Change $options['quality'] to number, based on quality of source and several settings - - self::processQualityOption($source, $options, $logger); - - call_user_func( - [$className, 'doConvert'], - $source, - $destination, - $options, - $logger - ); - - if (!@file_exists($destination)) { - throw new ConverterFailedException('Destination file is not there'); - } else { - $sourceSize = @filesize($source); - if ($sourceSize !== false) { - $msg = 'Success. '; - $msg .= 'Reduced file size with ' . - round((filesize($source) - filesize($destination))/filesize($source) * 100) . '% '; - - if ($sourceSize < 10000) { - $msg .= '(went from ' . round(filesize($source)) . ' bytes to '; - $msg .= round(filesize($destination)) . ' bytes)'; - } else { - $msg .= '(went from ' . round(filesize($source)/1024) . ' kb to '; - $msg .= round(filesize($destination)/1024) . ' kb)'; - } - $logger->logLn($msg); - } - } - } - - public static function runConverterWithTiming( - $converterId, - $source, - $destination, - $options = [], - $prepareDestinationFolder = true, - $logger = null - ) { - $beginTime = microtime(true); - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - try { - self::runConverter($converterId, $source, $destination, $options, $prepareDestinationFolder, $logger); - $logger->logLn( - 'Successfully converted test image in ' . - round((microtime(true) - $beginTime) * 1000) . ' ms' - ); - } catch (\Exception $e) { - $logger->logLn('Failed in ' . round((microtime(true) - $beginTime) * 1000) . ' ms'); - throw $e; - } - } - - /* - @param (string) $source: Absolute path to image to be converted (no backslashes). Image must be jpeg or png - @param (string) $destination: Absolute path (no backslashes) - @param (object) $options: Array of named options, such as 'quality' and 'metadata' - */ - public static function runConverterStack($source, $destination, $options = [], $logger = null) - { - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - self::prepareDestinationFolderAndRunCommonValidations($source, $destination); - - $options = array_merge(self::$defaultOptions, $options); - - self::processQualityOption($source, $options, $logger); - - // Force lossless option to true for PNG images - if (self::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - $defaultConverterOptions = $options; - $defaultConverterOptions['converters'] = null; - - $firstFailException = null; - - // If we have set converter options for a converter, which is not in the converter array, - // then we add it to the array - if (isset($options['converter-options'])) { - foreach ($options['converter-options'] as $converterName => $converterOptions) { - if (!in_array($converterName, $options['converters'])) { - $options['converters'][] = $converterName; - } - } - } - - foreach ($options['converters'] as $converter) { - if (is_array($converter)) { - $converterId = $converter['converter']; - $converterOptions = $converter['options']; - } else { - $converterId = $converter; - $converterOptions = []; - if (isset($options['converter-options'][$converterId])) { - // Note: right now, converter-options are not meant to be used, - // when you have several converters of the same type - $converterOptions = $options['converter-options'][$converterId]; - } - } - - $converterOptions = array_merge($defaultConverterOptions, $converterOptions); - - try { - $logger->logLn('Trying:' . $converterId, 'italic'); - - // If quality is different, we must recalculate - if ($converterOptions['quality'] != $defaultConverterOptions['quality']) { - unset($converterOptions['_calculated_quality']); - self::processQualityOption($source, $converterOptions, $logger); - } - - self::runConverterWithTiming($converterId, $source, $destination, $converterOptions, false, $logger); - - $logger->logLn('ok', 'bold'); - return true; - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { -// $logger->logLnLn($e->description . ' : ' . $e->getMessage()); - $logger->logLnLn($e->getMessage()); - - // The converter is not operational. - // Well, well, we will just have to try the next, then - } catch (\WebPConvert\Converters\Exceptions\ConverterFailedException $e) { - $logger->logLnLn($e->getMessage()); - - // Converter failed in an anticipated, yet somewhat surprising fashion. - // The converter seemed operational - requirements was in order - but it failed anyway. - // This is moderately bad. - // If some other converter can handle the conversion, we will let this one go. - // But if not, we shall throw the exception - - if (!$firstFailException) { - $firstFailException = $e; - } - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - $logger->logLnLn($e->getMessage()); - - // The converter declined. - // Gd is for example throwing this, when asked to convert a PNG, but configured not to - // We also possibly rethrow this, because it may have come as a surprise to the user - // who perhaps only tested jpg - if (!$firstFailException) { - $firstFailException = $e; - } - } - } - - if ($firstFailException) { - // At least one converter failed or declined. - $logger->logLn('Conversion failed. None of the tried converters could convert the image', 'bold'); - } else { - // All converters threw a ConverterNotOperationalException - $logger->logLn('Conversion failed. None of the tried converters are operational', 'bold'); - } - - // No converters could do the job. - // If one of them failed moderately bad, rethrow that exception. - if ($firstFailException) { - throw $firstFailException; - } - - return false; - } - - /* Try to detect quality of jpeg. - If not possible, nothing is returned (null). Otherwise quality is returned (int) - */ - public static function detectQualityOfJpg($filename) - { - // Try Imagick extension - if (extension_loaded('imagick') && class_exists('Imagick')) { - $img = new Imagick($filename); - - // The required function is available as from PECL imagick v2.2.2 - if (method_exists($img, 'getImageCompressionQuality')) { - return $img->getImageCompressionQuality(); - } - } - - // Gmagick extension doesn't seem to support this (yet): - // https://bugs.php.net/bug.php?id=63939 - - if (function_exists('shell_exec')) { - // Try Imagick - $quality = shell_exec("identify -format '%Q' " . $filename); - if ($quality) { - return intval($quality); - } - - // Try GraphicsMagick - $quality = shell_exec("gm identify -format '%Q' " . $filename); - if ($quality) { - return intval($quality); - } - } - } - - public static function processQualityOption($source, &$options, $logger) - { - if (isset($options['_calculated_quality'])) { - return; - } - if ($options['quality'] == 'auto') { - $q = self::detectQualityOfJpg($source); - //$logger->log('Quality set to auto... Quality of source: '); - if (!$q) { - $q = $options['default-quality']; - $logger->logLn( - 'Quality of source could not be established (Imagick or GraphicsMagick is required)' . - ' - Using default instead (' . $options['default-quality'] . ').' - ); - - // this allows the wpc converter to know - $options['_quality_could_not_be_detected'] = true; - } else { - if ($q > $options['max-quality']) { - $logger->log( - 'Quality of source is ' . $q . '. ' . - 'This is higher than max-quality, so using that instead (' . $options['max-quality'] . ')' - ); - } else { - $logger->log('Quality set to same as source: ' . $q); - } - } - $logger->ln(); - $q = min($q, $options['max-quality']); - - $options['_calculated_quality'] = $q; - //$logger->logLn('Using quality: ' . $options['quality']); - } else { - $logger->logLn( - 'Quality: ' . $options['quality'] . '. ' . - 'Consider setting quality to "auto" instead. It is generally a better idea' - ); - $options['_calculated_quality'] = $options['quality']; - } - $logger->ln(); - } - - - public static function getExtension($filePath) - { - $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); - return strtolower($fileExtension); - } - - // Throws an exception if the provided file doesn't exist - public static function isValidTarget($filePath) - { - if (!@file_exists($filePath)) { - throw new TargetNotFoundException('File or directory not found: ' . $filePath); - } - - return true; - } - - // Throws an exception if the provided file's extension is invalid - public static function isAllowedExtension($filePath) - { - $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); - if (!in_array(strtolower($fileExtension), self::$allowedExtensions)) { - throw new InvalidFileExtensionException('Unsupported file extension: ' . $fileExtension); - } - - return true; - } - - // Creates folder in provided path & sets correct permissions - // also deletes the file at filePath (if it already exists) - public static function createWritableFolder($filePath) - { - $folder = dirname($filePath); - if (!@file_exists($folder)) { - // TODO: what if this is outside open basedir? - // see http://php.net/manual/en/ini.core.php#ini.open-basedir - - // First, we have to figure out which permissions to set. - // We want same permissions as parent folder - // But which parent? - the parent to the first missing folder - - $parentFolders = explode('/', $folder); - $poppedFolders = []; - - while (!(@file_exists(implode('/', $parentFolders))) && count($parentFolders) > 0) { - array_unshift($poppedFolders, array_pop($parentFolders)); - } - - // Retrieving permissions of closest existing folder - $closestExistingFolder = implode('/', $parentFolders); - $permissions = @fileperms($closestExistingFolder) & 000777; - $stat = @stat($closestExistingFolder); - - // Trying to create the given folder (recursively) - if (!@mkdir($folder, $permissions, true)) { - throw new CreateDestinationFolderException('Failed creating folder: ' . $folder); - } - - // `mkdir` doesn't always respect permissions, so we have to `chmod` each created subfolder - foreach ($poppedFolders as $subfolder) { - $closestExistingFolder .= '/' . $subfolder; - // Setting directory permissions - if ($permissions !== false) { - @chmod($folder, $permissions); - } - if ($stat !== false) { - if (isset($stat['uid'])) { - @chown($folder, $stat['uid']); - } - if (isset($stat['gid'])) { - @chgrp($folder, $stat['gid']); - } - } - } - } - - if (@file_exists($filePath)) { - // A file already exists in this folder... - // We delete it, to make way for a new webp - if (!@unlink($filePath)) { - throw new CreateDestinationFileException( - 'Existing file cannot be removed: ' . basename($filePath) - ); - } - } - - return true; - } - - public static function prepareDestinationFolderAndRunCommonValidations($source, $destination) - { - self::isValidTarget($source); - self::isAllowedExtension($source); - self::createWritableFolder($destination); - } - - public static function initCurlForConverter() - { - if (!extension_loaded('curl')) { - throw new ConverterNotOperationalException('Required cURL extension is not available.'); - } - - if (!function_exists('curl_init')) { - throw new ConverterNotOperationalException('Required url_init() function is not available.'); - } - - if (!function_exists('curl_file_create')) { - throw new ConverterNotOperationalException( - 'Required curl_file_create() function is not available (requires PHP > 5.5).' - ); - } - - $ch = curl_init(); - if (!$ch) { - throw new ConverterNotOperationalException('Could not initialise cURL.'); - } - return $ch; - } -} - -?> 'use-nice', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => false, - 'required' => false - ], - // low-memory is defined for all, in ConverterHelper - [ - 'name' => 'try-common-system-paths', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - [ - 'name' => 'try-supplied-binary-for-os', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - [ - 'name' => 'size-in-percentage', - 'type' => 'number', - 'sensitive' => false, - 'default' => null, - 'required' => false - ], - [ - 'name' => 'command-line-options', - 'type' => 'string', - 'sensitive' => false, - 'default' => '', - 'required' => false - ], - [ - 'name' => 'rel-path-to-precompiled-binaries', - 'type' => 'string', - 'sensitive' => false, - 'default' => './Binaries', - 'required' => false - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('cwebp', $source, $destination, $options, true); - } - - // System paths to look for cwebp binary - private static $cwebpDefaultPaths = [ - '/usr/bin/cwebp', - '/usr/local/bin/cwebp', - '/usr/gnu/bin/cwebp', - '/usr/syno/bin/cwebp' - ]; - - // OS-specific binaries included in this library, along with hashes - private static $suppliedBinariesInfo = [ - 'WinNT' => [ 'cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873'], - 'Darwin' => [ 'cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379'], - 'SunOS' => [ 'cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f'], - 'FreeBSD' => [ 'cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573'], - 'Linux' => [ 'cwebp-linux', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568'] - ]; - - private static function escapeFilename($string) - { - // Escaping whitespace - $string = preg_replace('/\s/', '\\ ', $string); - - // filter_var() is should normally be available, but it is not always - // - https://stackoverflow.com/questions/11735538/call-to-undefined-function-filter-var - if (function_exists('filter_var')) { - // Sanitize quotes - $string = filter_var($string, FILTER_SANITIZE_MAGIC_QUOTES); - - // Stripping control characters - // see https://stackoverflow.com/questions/12769462/filter-flag-strip-low-vs-filter-flag-strip-high - $string = filter_var($string, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - } - - return $string; - } - - // Checks if 'Nice' is available - private static function hasNiceSupport() - { - exec("nice 2>&1", $niceOutput); - - if (is_array($niceOutput) && isset($niceOutput[0])) { - if (preg_match('/usage/', $niceOutput[0]) || (preg_match('/^\d+$/', $niceOutput[0]))) { - /* - * Nice is available - default niceness (+10) - * https://www.lifewire.com/uses-of-commands-nice-renice-2201087 - * https://www.computerhope.com/unix/unice.htm - */ - - return true; - } - - return false; - } - } - - private static function executeBinary($binary, $commandOptions, $useNice, $logger) - { - $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions; - - //$logger->logLn('command options:' . $commandOptions); - //$logger->logLn('Trying to execute binary:' . $binary); - exec($command, $output, $returnCode); - //$logger->logLn(self::msgForExitCode($returnCode)); - return intval($returnCode); - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - $errorMsg = ''; - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - if (!function_exists('exec')) { - throw new ConverterNotOperationalException('exec() is not enabled.'); - } - - /* - * Prepare cwebp options - */ - - $commandOptionsArray = []; - - // Metadata (all, exif, icc, xmp or none (default)) - // Comma-separated list of existing metadata to copy from input to output - $commandOptionsArray[] = '-metadata ' . $options['metadata']; - - // Size - if (!is_null($options['size-in-percentage'])) { - $sizeSource = @filesize($source); - if ($sizeSource !== false) { - $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100); - } - } - if (isset($targetSize)) { - $commandOptionsArray[] = '-size ' . $targetSize; - } else { - // Image quality - $commandOptionsArray[] = '-q ' . $options['_calculated_quality']; - } - - - // Losless PNG conversion - $commandOptionsArray[] = ($options['lossless'] ? '-lossless' : ''); - - // Built-in method option - $commandOptionsArray[] = '-m ' . strval($options['method']); - - // Built-in low memory option - if ($options['low-memory']) { - $commandOptionsArray[] = '-low_memory'; - } - - // command-line-options - if ($options['command-line-options']) { - $arr = explode(' -', ' ' . $options['command-line-options']); - foreach ($arr as $cmdOption) { - $pos = strpos($cmdOption, ' '); - $cName = ''; - $cValue = ''; - if (!$pos) { - $cName = $cmdOption; - if ($cName == '') { - continue; - } - $commandOptionsArray[] = '-' . $cName; - } else { - $cName = substr($cmdOption, 0, $pos); - $cValues = substr($cmdOption, $pos + 1); - $cValuesArr = explode(' ', $cValues); - foreach ($cValuesArr as &$cArg) { - $cArg = escapeshellarg($cArg); - } - $cValues = implode(' ', $cValuesArr); - $commandOptionsArray[] = '-' . $cName . ' ' . $cValues; - } - } - } - - // Source file - $commandOptionsArray[] = self::escapeFilename($source); - - // Output - $commandOptionsArray[] = '-o ' . self::escapeFilename($destination); - - // Redirect stderr to same place as stdout - // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/ - $commandOptionsArray[] = '2>&1'; - - - $useNice = (($options['use-nice']) && self::hasNiceSupport()) ? true : false; - - $commandOptions = implode(' ', $commandOptionsArray); - - $logger->logLn('cwebp options:' . $commandOptions); - - // Init with common system paths - $cwebpPathsToTest = self::$cwebpDefaultPaths; - - // Remove paths that doesn't exist - /* - $cwebpPathsToTest = array_filter($cwebpPathsToTest, function ($binary) { - //return file_exists($binary); - return @is_readable($binary); - }); - */ - - // Try all common paths that exists - $success = false; - $failures = []; - $failureCodes = []; - - if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) { - $errorMsg .= 'Configured to neither look for cweb binaries in common system locations, ' . - 'nor to use one of the supplied precompiled binaries. But these are the only ways ' . - 'this converter can convert images. No conversion can be made!'; - } - - if ($options['try-common-system-paths']) { - foreach ($cwebpPathsToTest as $index => $binary) { - $returnCode = self::executeBinary($binary, $commandOptions, $useNice, $logger); - if ($returnCode == 0) { - $logger->logLn('Successfully executed binary: ' . $binary); - $success = true; - break; - } else { - $failures[] = [$binary, $returnCode]; - if (!in_array($returnCode, $failureCodes)) { - $failureCodes[] = $returnCode; - } - } - } - $majorFailCode = 0; - if (!$success) { - if (count($failureCodes) == 1) { - $majorFailCode = $failureCodes[0]; - switch ($majorFailCode) { - case 126: - $errorMsg = 'Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute any of the ' . - 'cweb binaries found in common system locations. '; - break; - case 127: - $errorMsg .= 'Found no cwebp binaries in any common system locations. '; - break; - default: - $errorMsg .= 'Tried executing cwebp binaries in common system locations. ' . - 'All failed (exit code: ' . $majorFailCode . '). '; - } - } else { - $failureCodesBesides127 = array_diff($failureCodes, [127]); - - if (count($failureCodesBesides127) == 1) { - $majorFailCode = $failureCodesBesides127[0]; - switch ($returnCode) { - case 126: - $errorMsg = 'Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute any of the cweb ' . - 'binaries found in common system locations. '; - break; - default: - $errorMsg .= 'Tried executing cwebp binaries in common system locations. ' . - 'All failed (exit code: ' . $majorFailCode . '). '; - } - } else { - $errorMsg .= 'None of the cwebp binaries in the common system locations could be executed ' . - '(mixed results - got the following exit codes: ' . implode(',', $failureCodes) . '). '; - } - } - } - } - - if (!$success && $options['try-supplied-binary-for-os']) { - // Try supplied binary (if available for OS, and hash is correct) - if (isset(self::$suppliedBinariesInfo[PHP_OS])) { - $info = self::$suppliedBinariesInfo[PHP_OS]; - - $file = $info[0]; - $hash = $info[1]; - - $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file; - - // The file should exist, but may have been removed manually. - if (@file_exists($binaryFile)) { - // File exists, now generate its hash - - // hash_file() is normally available, but it is not always - // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash - // If available, validate that hash is correct. - $proceedAfterHashCheck = true; - if (function_exists('hash_file')) { - $binaryHash = hash_file('sha256', $binaryFile); - - if ($binaryHash != $hash) { - $errorMsg .= 'Binary checksum of supplied binary is invalid! ' . - 'Did you transfer with FTP, but not in binary mode? ' . - 'File:' . $binaryFile . '. ' . - 'Expected checksum: ' . $hash . '. ' . - 'Actual checksum:' . $binaryHash . '.'; - $proceedAfterHashCheck = false; - } - } - if ($proceedAfterHashCheck) { - $returnCode = self::executeBinary($binaryFile, $commandOptions, $useNice, $logger); - if ($returnCode == 0) { - $success = true; - } else { - $errorMsg .= 'Tried executing supplied binary for ' . PHP_OS . ', ' . - ($options['try-common-system-paths'] ? 'but that failed too' : 'but failed'); - if ($options['try-common-system-paths'] && ($majorFailCode > 0)) { - $errorMsg .= ' (same error)'; - } else { - switch ($returnCode) { - case 0: - $success = true; - ; - break; - case 126: - $errorMsg .= ': Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute that binary.'; - break; - case 127: - $errorMsg .= '. The binary was not found! It ought to be here: ' . $binaryFile; - break; - default: - $errorMsg .= ' (exit code:' . $returnCode . ').'; - } - } - } - } - } else { - $errorMsg .= 'Supplied binary not found! It ought to be here:' . $binaryFile; - } - } else { - $errorMsg .= 'No supplied binaries found for OS:' . PHP_OS; - } - } - - - - // cwebp sets file permissions to 664 but instead .. - // .. $destination's parent folder's permissions should be used (except executable bits) - if ($success) { - $destinationParent = dirname($destination); - $fileStatistics = @stat($destinationParent); - if ($fileStatistics !== false) { - // Apply same permissions as parent folder but strip off the executable bits - $permissions = $fileStatistics['mode'] & 0000666; - @chmod($destination, $permissions); - } - } - - if (!$success) { - throw new ConverterNotOperationalException($errorMsg); - } - } -} - -?> 'key', - 'type' => 'string', - 'sensitive' => true, - 'default' => '', - 'required' => true - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('ewww', $source, $destination, $options, true); - } - - // Took this parser from Drupal - private static function parseSize($size) - { - - $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. - $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. - if ($unit) { - // Find the position of the unit in the ordered string which is the power - // of magnitude to multiply a kilobyte by. - return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); - } else { - return round($size); - } - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if ($options['key'] == '') { - throw new ConverterNotOperationalException('Missing API key.'); - } - if (strlen($options['key']) < 20) { - throw new ConverterNotOperationalException( - 'Key is invalid. Keys are supposed to be 32 characters long - your key is much shorter' - ); - } - - $keyStatus = self::getKeyStatus($options['key']); - switch ($keyStatus) { - case 'great': - break; - case 'exceeded': - throw new ConverterNotOperationalException('quota has exceeded'); - break; - case 'invalid': - throw new ConverterNotOperationalException('key is invalid'); - break; - } - - $fileSize = @filesize($source); - if ($fileSize !== false) { - $uploadMaxSize = self::parseSize(ini_get('upload_max_filesize')); - if (($uploadMaxSize !== false) && ($uploadMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your max upload (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'upload_max_filesize in php.ini: ' . ini_get('upload_max_filesize') . - ' (parsed as ' . round($uploadMaxSize/1024) . ' kb)' - ); - } - - $postMaxSize = self::parseSize(ini_get('post_max_size')); - if (($postMaxSize !== false) && ($postMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your post_max_size limit (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'post_max_size in php.ini: ' . ini_get('post_max_size') . - ' (parsed as ' . round($postMaxSize/1024) . ' kb)' - ); - } - - // ini_get('memory_limit') - } - - - $ch = ConverterHelper::initCurlForConverter(); - - $curlOptions = [ - 'api_key' => $options['key'], - 'webp' => '1', - 'file' => curl_file_create($source), - 'domain' => $_SERVER['HTTP_HOST'], - 'quality' => $options['_calculated_quality'], - 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') - ]; - - curl_setopt_array( - $ch, - [ - CURLOPT_URL => "https://optimize.exactlywww.com/v2/", - CURLOPT_HTTPHEADER => [ - 'User-Agent: WebPConvert', - 'Accept: image/*' - ], - CURLOPT_POSTFIELDS => $curlOptions, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ] - ); - - $response = curl_exec($ch); - - if (curl_errno($ch)) { - throw new ConverterNotOperationalException(curl_error($ch)); - } - - // The API does not always return images. - // For example, it may return a message such as '{"error":"invalid","t":"exceeded"} - // Messages has a http content type of ie 'text/html; charset=UTF-8 - // Images has application/octet-stream. - // So verify that we got an image back. - if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { - //echo curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - curl_close($ch); - - /* May return this: {"error":"invalid","t":"exceeded"} */ - $responseObj = json_decode($response); - if (isset($responseObj->error)) { - //echo 'error:' . $responseObj->error . '
'; - //echo $response; - //self::blacklistKey($key); - //throw new ConverterNotOperationalException('The key is invalid. Blacklisted it!'); - throw new ConverterNotOperationalException('The key is invalid'); - } - - throw new ConverterNotOperationalException( - 'ewww api did not return an image. It could be that the key is invalid. Response: ' - . $response - ); - } - - // Not sure this can happen. So just in case - if ($response == '') { - throw new ConverterNotOperationalException('ewww api did not return anything'); - } - - $success = file_put_contents($destination, $response); - - if (!$success) { - throw new ConverterFailedException('Error saving file'); - } - } - - /* - public static function blacklistKey($key) - { - } - - public static function isKeyBlacklisted($key) - { - }*/ - - /** - * Return "great", "exceeded" or "invalid" - */ - public static function getKeyStatus($key) - { - $ch = ConverterHelper::initCurlForConverter(); - - curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/verify/"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt( - $ch, - CURLOPT_POSTFIELDS, - [ - 'api_key' => $key - ] - ); - - // The 403 forbidden is avoided with this line. - curl_setopt( - $ch, - CURLOPT_USERAGENT, - 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)' - ); - - $response = curl_exec($ch); - // echo $response; - if (curl_errno($ch)) { - throw new \Exception(curl_error($ch)); - } - curl_close($ch); - - // Possible responses: - // “great” = verification successful - // “exceeded” = indicates a valid key with no remaining image credits. - // an empty response indicates that the key is not valid - - if ($response == '') { - return 'invalid'; - } - $responseObj = json_decode($response); - if (isset($responseObj->error)) { - if ($responseObj->error == 'invalid') { - return 'invalid'; - } else { - throw new \Exception('Ewww returned unexpected error: ' . $response); - } - } - if (!isset($responseObj->status)) { - throw new \Exception('Ewww returned unexpected response to verify request: ' . $response); - } - switch ($responseObj->status) { - case 'great': - case 'exceeded': - return $responseObj->status; - } - throw new \Exception('Ewww returned unexpected status to verify request: "' . $responseObj->status . '"'); - } - - public static function isWorkingKey($key) - { - return (self::getKeyStatus($key) == 'great'); - } - - public static function isValidKey($key) - { - return (self::getKeyStatus($key) != 'invalid'); - } - - public static function getQuota($key) - { - $ch = ConverterHelper::initCurlForConverter(); - - curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/quota/"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt( - $ch, - CURLOPT_POSTFIELDS, - [ - 'api_key' => $key - ] - ); - curl_setopt( - $ch, - CURLOPT_USERAGENT, - 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)' - ); - - $response = curl_exec($ch); - return $response; // ie -830 23. Seems to return empty for invalid keys - // or empty - //echo $response; - } -} - -?> 'skip-pngs', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('gd', $source, $destination, $options, true); - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if (!extension_loaded('gd')) { - throw new ConverterNotOperationalException('Required Gd extension is not available.'); - } - - if (!function_exists('imagewebp')) { - throw new ConverterNotOperationalException( - 'Required imagewebp() function is not available. It seems Gd has been compiled without webp support.' - ); - } - - switch (ConverterHelper::getExtension($source)) { - case 'png': - if (!$options['skip-pngs']) { - if (!function_exists('imagecreatefrompng')) { - throw new ConverterNotOperationalException( - 'Required imagecreatefrompng() function is not available.' - ); - } - $image = @imagecreatefrompng($source); - if (!$image) { - throw new ConverterFailedException( - 'imagecreatefrompng("' . $source . '") failed' - ); - } - } else { - throw new ConversionDeclinedException( - 'PNG file skipped. GD is configured not to convert PNGs' - ); - } - break; - default: - if (!function_exists('imagecreatefromjpeg')) { - throw new ConverterNotOperationalException( - 'Required imagecreatefromjpeg() function is not available.' - ); - } - $image = @imagecreatefromjpeg($source); - if (!$image) { - throw new ConverterFailedException('imagecreatefromjpeg("' . $source . '") failed'); - } - } - - // Checks if either imagecreatefromjpeg() or imagecreatefrompng() returned false - - $success = @imagewebp($image, $destination, $options['_calculated_quality']); - - if (!$success) { - throw new ConverterFailedException( - 'Call to imagewebp() failed. Probably failed writing file. Check file permissions!' - ); - } - - /* - * This hack solves an `imagewebp` bug - * See https://stackoverflow.com/questions/30078090/imagewebp-php-creates-corrupted-webp-files - * - */ - if (@filesize($destination) % 2 == 1) { - @file_put_contents($destination, "\0", FILE_APPEND); - } - - imagedestroy($image); - } -} - -?>queryformats())) { - throw new ConverterNotOperationalException('Gmagick was compiled without WebP support.'); - } - - $options = array_merge(ConverterHelper::$defaultOptions, $options); - - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - - /* - Seems there are currently no way to set webp options - As noted in the following link, it should probably be done with a $im->addDefinition() method - - but that isn't exposed (yet) - (TODO: see if anyone has answered...) - https://stackoverflow.com/questions/47294962/how-to-write-lossless-webp-files-with-perlmagick - */ - // The following two does not have any effect... How to set WebP options? - //$im->setimageoption('webp', 'webp:lossless', $options['lossless'] ? 'true' : 'false'); - //$im->setimageoption('WEBP', 'method', strval($options['method'])); - - // It seems there is no COMPRESSION_WEBP... - // http://php.net/manual/en/imagick.setimagecompression.php - //$im->setImageCompression(Imagick::COMPRESSION_JPEG); - //$im->setImageCompression(Imagick::COMPRESSION_UNDEFINED); - - - - $im->setimageformat('WEBP'); - - if ($options['metadata'] == 'none') { - // Strip metadata and profiles - $im->stripImage(); - } - - // Ps: Imagick automatically uses same quality as source, when no quality is set - // This feature is however not present in Gmagick - $im->setcompressionquality($options['_calculated_quality']); - - //$success = $im->writeimagefile(fopen($destination, 'wb')); - $success = @file_put_contents($destination, $im->getImageBlob()); - - if (!$success) { - throw new ConverterFailedException('Failed writing file'); - } else { - //$logger->logLn('sooms we made it!'); - } - } -} - -?>readImage($source); - - // Throws an exception if iMagick does not support WebP conversion - if (!in_array('WEBP', $im->queryFormats())) { - throw new ConverterNotOperationalException('iMagick was compiled without WebP support.'); - } - - $options = array_merge(ConverterHelper::$defaultOptions, $options); - - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - $im->setImageFormat('WEBP'); - - /* - * More about iMagick's WebP options: - * http://www.imagemagick.org/script/webp.php - * https://developers.google.com/speed/webp/docs/cwebp - * https://stackoverflow.com/questions/37711492/imagemagick-specific-webp-calls-in-php - */ - - // TODO: We could easily support all webp options with a loop - - /* - After using getImageBlob() to write image, the following setOption() calls - makes settings makes imagick fail. So can't use those. But its a small price - to get a converter that actually makes great quality conversions. - - $im->setOption('webp:method', strval($options['method'])); - $im->setOption('webp:low-memory', strval($options['low-memory'])); - $im->setOption('webp:lossless', strval($options['lossless'])); - */ - - if ($options['metadata'] == 'none') { - // Strip metadata and profiles - $im->stripImage(); - } - - if (isset($options['_quality_could_not_be_detected'])) { - // quality was set to "auto", but we could not meassure the quality of the jpeg locally - // but luckily imagick is a big boy, and automatically converts with same quality as - // source, when the quality isn't set. - // So we simply do not set quality. - // This actually kills the max-height functionality. But I deem that this is more important - // because setting image quality to something higher than source generates bigger files, - // but gets you no extra quality. When failing to limit quality, you at least get something - // out of it - } else { - $im->setImageCompressionQuality($options['_calculated_quality']); - } - - - - // https://stackoverflow.com/questions/29171248/php-imagick-jpeg-optimization - // setImageFormat - - // TODO: Read up on - // https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/ - // https://github.com/nwtn/php-respimg - - // TODO: - // Should we set alpha channel for PNG's like suggested here: - // https://gauntface.com/blog/2014/09/02/webp-support-with-imagemagick-and-php ?? - // It seems that alpha channel works without... (at least I see completely transparerent pixels) - - // TODO: Check out other iMagick methods, see http://php.net/manual/de/imagick.writeimage.php#114714 - // 1. file_put_contents($destination, $im) - // 2. $im->writeImage($destination) - - // We used to use writeImageFile() method. But we now use getImageBlob(). See issue #43 - //$success = $im->writeImageFile(fopen($destination, 'wb')); - - $success = @file_put_contents($destination, $im->getImageBlob()); - - if (!$success) { - throw new ConverterFailedException('Failed writing file'); - } - } -} - -?> 0); - */ - - $command = 'convert -version'; - exec($command, $output, $returnCode); - $hasDelegate = false; - foreach ($output as $line) { - if (preg_match('/Delegate.*webp.*/i', $line)) { - return true; - } - } - return false; - } - - - public static function escapeFilename($string) - { - // Escaping whitespace - $string = preg_replace('/\s/', '\\ ', $string); - - // filter_var() is should normally be available, but it is not always - // - https://stackoverflow.com/questions/11735538/call-to-undefined-function-filter-var - if (function_exists('filter_var')) { - // Sanitize quotes - $string = filter_var($string, FILTER_SANITIZE_MAGIC_QUOTES); - - // Stripping control characters - // see https://stackoverflow.com/questions/12769462/filter-flag-strip-low-vs-filter-flag-strip-high - $string = filter_var($string, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - } - - return $string; - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if (!function_exists('exec')) { - throw new ConverterNotOperationalException('exec() is not enabled.'); - } - - if (!self::imagickInstalled()) { - throw new ConverterNotOperationalException('imagick is not installed'); - } - - if (!self::webPDelegateInstalled()) { - throw new ConverterNotOperationalException('webp delegate missing'); - } - - // Should we use "magick" or "convert" command? - // It seems they do the same. But which is best supported? Which is mostly available (whitelisted)? - // Should we perhaps try both? - // For now, we just go with "convert" - $command = 'convert ' . self::escapeFilename($source) . ' webp:' . self::escapeFilename($destination); - exec($command, $output, $returnCode); - - if ($returnCode == 127) { - throw new ConverterNotOperationalException('imagick is not installed'); - } - - if ($returnCode != 0) { - $logger->logLn('command:' . $command); - $logger->logLn('return code:' . $returnCode); - $logger->logLn('output:' . print_r(implode("\n", $output), true)); - - throw new ConverterNotOperationalException('The exec call failed'); - } - } -} - -?> 'api-version', /* Can currently be 0 or 1 */ - 'type' => 'number', - 'sensitive' => false, - 'default' => 0, - 'required' => false - ], - [ - 'name' => 'secret', /* only in api v.0 */ - 'type' => 'string', - 'sensitive' => true, - 'default' => 'my dog is white', - 'required' => false - ], - [ - 'name' => 'api-key', /* new in api v.1 (renamed 'secret' to 'api-key') */ - 'type' => 'string', - 'sensitive' => true, - 'default' => 'my dog is white', - 'required' => false - ], - [ - 'name' => 'url', - 'type' => 'string', - 'sensitive' => true, - 'default' => '', - 'required' => true - ], - [ - 'name' => 'crypt-api-key-in-transfer', /* new in api v.1 */ - 'type' => 'boolean', - 'sensitive' => false, - 'default' => false, - 'required' => false - ], - - /* - [ - 'name' => 'web-services', - 'type' => 'array', - 'sensitive' => true, - 'default' => [ - [ - 'label' => 'test', - 'api-key' => 'my dog is white', - 'url' => 'http://we0/wordpress/webp-express-server', - 'crypt-api-key-in-transfer' => true - ] - ], - 'required' => true - ], - */ - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('wpc', $source, $destination, $options, true); - } - - // Took this parser from Drupal - private static function parseSize($size) - { - - $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. - $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. - if ($unit) { - // Find the position of the unit in the ordered string which is the power - // of magnitude to multiply a kilobyte by. - return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); - } else { - return round($size); - } - } - - private static function createRandomSaltForBlowfish() - { - $salt = ''; - $validCharsForSalt = array_merge( - range('A', 'Z'), - range('a', 'z'), - range('0', '9'), - ['.', '/'] - ); - - for ($i=0; $i<22; $i++) { - $salt .= $validCharsForSalt[array_rand($validCharsForSalt)]; - } - return $salt; - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - - if (!extension_loaded('curl')) { - throw new ConverterNotOperationalException('Required cURL extension is not available.'); - } - - if (!function_exists('curl_init')) { - throw new ConverterNotOperationalException('Required url_init() function is not available.'); - } - - $apiVersion = $options['api-version']; - - if (!function_exists('curl_file_create')) { - throw new ConverterNotOperationalException( - 'Required curl_file_create() PHP function is not available (requires PHP > 5.5).' - ); - } - - if ($apiVersion == 0) { - if (!empty($options['secret'])) { - // if secret is set, we need md5() and md5_file() functions - if (!function_exists('md5')) { - throw new ConverterNotOperationalException( - 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . - 'contents. ' . - 'But the required md5() PHP function is not available.' - ); - } - if (!function_exists('md5_file')) { - throw new ConverterNotOperationalException( - 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . - 'contents. But the required md5_file() PHP function is not available.' - ); - } - } - } - - if ($apiVersion == 1) { - /* - if (count($options['web-services']) == 0) { - throw new ConverterNotOperationalException('No remote host has been set up'); - }*/ - } - - if ($options['url'] == '') { - throw new ConverterNotOperationalException( - 'Missing URL. You must install Webp Convert Cloud Service on a server, ' . - 'or the WebP Express plugin for Wordpress - and supply the url.' - ); - } - - $fileSize = @filesize($source); - if ($fileSize !== false) { - $uploadMaxSize = self::parseSize(ini_get('upload_max_filesize')); - if (($uploadMaxSize !== false) && ($uploadMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your max upload (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'upload_max_filesize in php.ini: ' . ini_get('upload_max_filesize') . - ' (parsed as ' . round($uploadMaxSize/1024) . ' kb)' - ); - } - - $postMaxSize = self::parseSize(ini_get('post_max_size')); - if (($postMaxSize !== false) && ($postMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your post_max_size limit (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'post_max_size in php.ini: ' . ini_get('post_max_size') . - ' (parsed as ' . round($postMaxSize/1024) . ' kb)' - ); - } - - // ini_get('memory_limit') - } - - // Got some code here: - // https://coderwall.com/p/v4ps1a/send-a-file-via-post-with-curl-and-php - - $ch = curl_init(); - if (!$ch) { - throw new ConverterNotOperationalException('Could not initialise cURL.'); - } - - $optionsToSend = $options; - - if (isset($options['_quality_could_not_be_detected'])) { - // quality was set to "auto", but we could not meassure the quality of the jpeg locally - // Ask the cloud service to do it, rather than using what we came up with. - $optionsToSend['quality'] = 'auto'; - } else { - $optionsToSend['quality'] = $options['_calculated_quality']; - } - - unset($optionsToSend['converters']); - unset($optionsToSend['secret']); - unset($optionsToSend['_quality_could_not_be_detected']); - unset($optionsToSend['_calculated_quality']); - - $postData = [ - 'file' => curl_file_create($source), - 'options' => json_encode($optionsToSend), - 'servername' => (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '') - ]; - - if ($apiVersion == 0) { - $postData['hash'] = md5(md5_file($source) . $options['secret']); - } - - if ($apiVersion == 1) { - $apiKey = $options['api-key']; - - if ($options['crypt-api-key-in-transfer']) { - if (CRYPT_BLOWFISH == 1) { - $salt = self::createRandomSaltForBlowfish(); - $postData['salt'] = $salt; - - // Strip off the first 28 characters (the first 6 are always "$2y$10$". The next 22 is the salt) - $postData['api-key-crypted'] = substr(crypt($apiKey, '$2y$10$' . $salt . '$'), 28); - } else { - if (!function_exists('crypt')) { - throw new ConverterNotOperationalException( - 'Configured to crypt the api-key, but crypt() function is not available.' - ); - } else { - throw new ConverterNotOperationalException( - 'Configured to crypt the api-key. ' . - 'That requires Blowfish encryption, which is not available on your current setup.' - ); - } - } - } else { - $postData['api-key'] = $apiKey; - } - } - - - // Try one host at the time - // TODO: shuffle the array first - /* - foreach ($options['web-services'] as $webService) { - - } - */ - - - curl_setopt_array($ch, [ - CURLOPT_URL => $options['url'], - CURLOPT_POST => 1, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ]); - - $response = curl_exec($ch); - if (curl_errno($ch)) { - throw new ConverterNotOperationalException('Curl error:' . curl_error($ch)); - } - - // Check if we got a 404 - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($httpCode == 404) { - curl_close($ch); - throw new ConverterFailedException( - 'WPC was not found at the specified URL - we got a 404 response.' - ); - } - - // The WPC cloud service either returns an image or an error message - // Images has application/octet-stream. - // Verify that we got an image back. - if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { - curl_close($ch); - - if (substr($response, 0, 1) == '{') { - $responseObj = json_decode($response, true); - if (isset($responseObj['errorCode'])) { - switch ($responseObj['errorCode']) { - case 0: - throw new ConverterFailedException( - 'There are problems with the server setup: "' . - $responseObj['errorMessage'] . '"' - ); - case 1: - throw new ConverterFailedException( - 'Access denied. ' . $responseObj['errorMessage'] - ); - default: - throw new ConverterFailedException( - 'Conversion failed: "' . $responseObj['errorMessage'] . '"' - ); - } - } - } - - // WPC 0.1 returns 'failed![error messag]' when conversion fails. Handle that. - if (substr($response, 0, 7) == 'failed!') { - throw new ConverterFailedException( - 'WPC failed converting image: "' . substr($response, 7) . '"' - ); - } - - if (empty($response)) { - $errorMsg = 'Error: Unexpected result. We got nothing back. HTTP CODE: ' . $httpCode; - throw new ConverterFailedException($errorMsg); - } else { - $errorMsg = 'Error: Unexpected result. We did not receive an image. We received: "'; - $errorMsg .= str_replace("\r", '', str_replace("\n", '', htmlentities(substr($response, 0, 400)))); - throw new ConverterFailedException($errorMsg . '..."'); - } - //throw new ConverterNotOperationalException($response); - } - - $success = @file_put_contents($destination, $response); - curl_close($ch); - - if (!$success) { - throw new ConverterFailedException('Error saving file. Check file permissions'); - } - /* - $curlOptions = [ - 'api_key' => $options['key'], - 'webp' => '1', - 'file' => curl_file_create($source), - 'domain' => $_SERVER['HTTP_HOST'], - 'quality' => $options['quality'], - 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') - ]; - - curl_setopt_array($ch, [ - CURLOPT_URL => "https://optimize.exactlywww.com/v2/", - CURLOPT_HTTPHEADER => [ - 'User-Agent: WebPConvert', - 'Accept: image/*' - ], - CURLOPT_POSTFIELDS => $curlOptions, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ]);*/ - } -} - -?>entries[] = [$msg, $style]; - } - - public function ln() - { - $this->entries[] = ''; - } - - public function getHtml() - { - $html = ''; - foreach ($this->entries as $entry) { - if ($entry == '') { - $html .= '
'; - } else { - list($msg, $style) = $entry; - $msg = htmlspecialchars($msg); - if ($style == 'bold') { - $html .= '' . $msg . ''; - } elseif ($style == 'italic') { - $html .= '' . $msg . ''; - } else { - $html .= $msg; - } - } - } - return $html; - } - - public function getText($newLineChar = ' ') - { - $text = ''; - foreach ($this->entries as $entry) { - if ($entry == '') { - if (substr($text, -2) != '. ') { - $text .= '. '; - } - } else { - list($msg, $style) = $entry; - $text .= $msg; - } - } - - return $text; - } -} - -?>' . $msg . ''; - } elseif ($style == 'italic') { - echo '' . $msg . ''; - } else { - echo $msg; - } - } - - public function ln() - { - echo '
'; - } -} - -?> &$converterOptions) { - $className = ConverterHelper::getClassNameOfConverter($converterName); - - // (pstt: the isset check is needed in order to work with WebPConvert v1.0) - if (isset($className::$extraOptions)) { - foreach ($className::$extraOptions as $extraOption) { - if ($extraOption['sensitive']) { - if (isset($converterOptions[$extraOption['name']])) { - $converterOptions[$extraOption['name']] = '*******'; - } - } - } - } - } - } - } - return $printable_options; - } - - public static function getPrintableOptionsAsString($options, $glue = '. ') - { - $optionsForPrint = []; - foreach (self::getPrintableOptions($options) as $optionName => $optionValue) { - $printValue = ''; - if ($optionName == 'converter-options') { - $converterNames = []; - $extraConvertOptions = $optionValue; - //print_r($extraConvertOptions); - /* - foreach ($optionValue as $converterName => $converterOptions) { - - if (is_array($converter)) { - $converterName = $converter['converter']; - if (isset($converter['options'])) { - $extraConvertOptions[$converter['converter']] = $converter['options']; - } - } else { - $converterName = $converter; - } - $converterNames[] = $converterName; - }*/ - $glueMe = []; - foreach ($extraConvertOptions as $converter => $extraOptions) { - $opt = []; - foreach ($extraOptions as $oName => $oValue) { - $opt[] = $oName . ':"' . $oValue . '"'; - } - $glueMe[] = '(' . $converter . ': (' . implode($opt, ', ') . '))'; - } - $printValue = implode(',', $glueMe); - } else { - switch (gettype($optionValue)) { - case 'boolean': - if ($optionValue === true) { - $printValue = 'true'; - } elseif ($optionValue === false) { - $printValue = 'false'; - } - break; - case 'string': - $printValue = '"' . $optionValue . '"'; - break; - case 'array': - $printValue = implode(', ', $optionValue); - break; - case 'integer': - $printValue = $optionValue; - break; - default: - $printValue = $optionValue; - } - } - $optionsForPrint[] = $optionName . ': ' . $printValue; - } - return implode($glue, $optionsForPrint); - } - - public static function convertAndReport($source, $destination, $options) - { - ?> - - - - - - - - - - - - - -
source:
destination:
options: - click to see - - - -
-
- getMessage(); - - echo '' . $msg . ''; - exit; - } - - if ($success) { - //echo 'ok'; - } else { - echo 'Conversion failed. None of the tried converters are operational'; - } - ?> - - - source = $source; - $this->destination = $destination; - $this->options = array_merge(self::$defaultOptions, $options); - - $this->setErrorReporting(); - } - - public static $defaultOptions = [ - 'add-content-type-header' => true, - 'add-vary-header' => true, - 'add-x-header-status' => true, - 'add-x-header-options' => false, - 'aboutToServeImageCallBack' => null, - 'aboutToPerformFailAction' => null, - 'cache-control-header' => 'public, max-age=86400', - 'converters' => ['cwebp', 'gd', 'imagick'], - 'error-reporting' => 'auto', - 'fail' => 'original', - 'fail-when-original-unavailable' => '404', - 'reconvert' => false, - 'serve-original' => false, - 'show-report' => false, - ]; - - protected function setErrorReporting() - { - if (($this->options['error-reporting'] === true) || - (($this->options['error-reporting'] === 'auto') && ($this->options['show-report'] === true)) - ) { - error_reporting(E_ALL); - ini_set('display_errors', 'On'); - } elseif (($this->options['error-reporting'] === false) || - (($this->options['error-reporting'] === 'auto') && ($this->options['show-report'] === false)) - ) { - error_reporting(0); - ini_set('display_errors', 'Off'); - } - } - - protected function header($header, $replace = true) - { - header($header, $replace); - } - - public function addXStatusHeader($text) - { - if ($this->options['add-x-header-status']) { - $this->header('X-WebP-Convert-Status: ' . $text, true); - } - } - - public function addVaryHeader() - { - if ($this->options['add-vary-header']) { - $this->header('Vary: Accept'); - } - } - - public function addContentTypeHeader($cType) - { - if ($this->options['add-content-type-header']) { - $this->header('Content-type: ' . $cType); - } - } - - public function addCacheControlHeader() - { - if (!empty($this->options['cache-control-header'])) { - $this->header('Cache-Control: ' . $this->options['cache-control-header'], true); - } - } - - public function serveExisting() - { - if (!$this->callAboutToServeImageCallBack('destination')) { - return; - } - - $this->addXStatusHeader('Serving existing converted image'); - $this->addVaryHeader(); - $this->addContentTypeHeader('image/webp'); - $this->addCacheControlHeader(); - - if (@readfile($this->destination) === false) { - $this->header('X-WebP-Convert-Error: Could not read file'); - return false; - } - return true; - } - - /** - * Called immidiately before serving image (either original, already converted or fresh) - * $whatToServe can be 'source' | 'destination' | 'fresh-conversion' - * $whyServingThis can be: - * for 'source': - * - "explicitly-told-to" (when the "original" option is set) - * - "source-lighter" (when original image is actually smaller than the converted) - * for 'fresh-conversion': - * - "explicitly-told-to" (when the "reconvert" option is set) - * - "source-modified" (when source is newer than existing) - * - "no-existing" (when there is no existing at the destination) - * for 'destination': - * - "no-reason-not-to" (it is lighter than source, its not older, - * and we were not told to do otherwise) - */ - protected function callAboutToServeImageCallBack($whatToServe) - { - if (!isset($this->options['aboutToServeImageCallBack'])) { - return true; - } - $result = call_user_func( - $this->options['aboutToServeImageCallBack'], - $whatToServe, - $this->whyServingThis, - $this - ); - return ($result !== false); - } - - /** - * Decides what to serve. - * Returns array. First item is what to do, second is additional info. - * First item can be one of these: - * - "destination" (serve existing converted image at the destination path) - * - "no-reason-not-to" - * - "source" - * - "explicitly-told-to" - * - "source-lighter" - * - "fresh-conversion" (note: this may still fail) - * - "explicitly-told-to" - * - "source-modified" - * - "no-existing" - * - "fail" - * - "Missing destination argument" - * - "critical-fail" (a failure where the source file cannot be served) - * - "Missing source argument" - * - "Source file was not found!" - * - "report" - */ - public function decideWhatToServe() - { - $decisionArr = $this->doDecideWhatToServe(); - $this->whatToServe = $decisionArr[0]; - $this->whyServingThis = $decisionArr[1]; - } - - private function doDecideWhatToServe() - { - if (empty($this->source)) { - return ['critical-fail', 'Missing source argument']; - } - if (@!file_exists($this->source)) { - return ['critical-fail', 'Source file was not found!']; - } - if (empty($this->destination)) { - return ['fail', 'Missing destination argument']; - } - if ($this->options['show-report']) { - return ['report', '']; - } - if ($this->options['serve-original']) { - return ['source', 'explicitly-told-to']; - } - if ($this->options['reconvert']) { - return ['fresh-conversion', 'explicitly-told-to']; - } - - if (@file_exists($this->destination)) { - // Reconvert if source file is newer than destination - $timestampSource = @filemtime($this->source); - $timestampDestination = @filemtime($this->destination); - if (($timestampSource !== false) && - ($timestampDestination !== false) && - ($timestampSource > $timestampDestination)) { - return ['fresh-conversion', 'source-modified']; - } - - // Serve source if it is smaller than destination - $filesizeDestination = @filesize($this->destination); - $filesizeSource = @filesize($this->source); - if (($filesizeSource !== false) && - ($filesizeDestination !== false) && - ($filesizeDestination > $filesizeSource)) { - return ['source', 'source-lighter']; - } - - // Destination exists, and there is no reason left not to serve it - return ['destination', 'no-reason-not-to']; - } else { - return ['fresh-conversion', 'no-existing']; - } - } -} - -?>options['add-x-header-options']) { - $this->header('X-WebP-Convert-Options:' . Report::getPrintableOptionsAsString($this->options)); - } - } - - private function addHeadersPreventingCaching() - { - $this->header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); - $this->header("Cache-Control: post-check=0, pre-check=0", false); - $this->header("Pragma: no-cache"); - } - - public function serve404() - { - $protocol = isset($_SERVER["SERVER_PROTOCOL"]) ? $_SERVER["SERVER_PROTOCOL"] : 'HTTP/1.0'; - $this->header($protocol . " 404 Not Found"); - } - - public function serveOriginal() - { - if (!$this->callAboutToServeImageCallBack('source')) { - return true; // we shall not trigger the fail callback - } - - if ($this->options['add-content-type-header']) { - $arr = explode('.', $this->source); - $ext = array_pop($arr); - switch (strtolower($ext)) { - case 'jpg': - case 'jpeg': - $this->header('Content-type: image/jpeg'); - break; - case 'png': - $this->header('Content-type: image/png'); - break; - } - } - - $this->addVaryHeader(); - - if ($this->whyServingThis == 'source-lighter') { - $this->addCacheControlHeader(); - } else { - $this->addHeadersPreventingCaching(); - } - - if (@readfile($this->source) === false) { - $this->header('X-WebP-Convert: Could not read file'); - return false; - } - return true; - } - - public function serveFreshlyConverted() - { - - $criticalFail = false; - $success = false; - $bufferLogger = new BufferLogger(); - - try { - $success = WebPConvert::convert($this->source, $this->destination, $this->options, $bufferLogger); - - if ($success) { - // Serve source if it is smaller than destination - $filesizeDestination = @filesize($this->destination); - $filesizeSource = @filesize($this->source); - if (($filesizeSource !== false) && - ($filesizeDestination !== false) && - ($filesizeDestination > $filesizeSource)) { - $this->whatToServe = 'original'; - $this->whyServingThis = 'source-lighter'; - return $this->serveOriginal(); - } - - if (!$this->callAboutToServeImageCallBack('fresh-conversion')) { - return; - } - - if ($this->options['add-content-type-header']) { - $this->header('Content-type: image/webp'); - } - if ($this->whyServingThis == 'explicitly-told-to') { - $this->addXStatusHeader( - 'Serving freshly converted image (was explicitly told to reconvert)' - ); - } elseif ($this->whyServingThis == 'source-modified') { - $this->addXStatusHeader( - 'Serving freshly converted image (the original had changed)' - ); - } elseif ($this->whyServingThis == 'no-existing') { - $this->addXStatusHeader( - 'Serving freshly converted image (there were no existing to serve)' - ); - } else { - $this->addXStatusHeader( - 'Serving freshly converted image (dont know why!)' - ); - } - - if ($this->options['add-vary-header']) { - $this->header('Vary: Accept'); - } - - if ($this->whyServingThis == 'no-existing') { - $this->addCacheControlHeader(); - } else { - $this->addHeadersPreventingCaching(); - } - - // Should we add Content-Length header? - // $this->header('Content-Length: ' . filesize($file)); - if (@readfile($this->destination)) { - return true; - } else { - $this->fail('Error', 'could not read the freshly converted file'); - return false; - } - } else { - $description = 'No converters are operational'; - $msg = ''; - } - } catch (\WebPConvert\Exceptions\InvalidFileExtensionException $e) { - $criticalFail = true; - $description = 'Invalid file extension'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\TargetNotFoundException $e) { - $criticalFail = true; - $description = 'Source file not found'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Converters\Exceptions\ConverterFailedException $e) { - // No converters could convert the image. At least one converter failed, even though it appears to be - // operational - $description = 'No converters could convert the image'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - // (no converters could convert the image. At least one converter declined - $description = 'No converters could/wanted to convert the image'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\ConverterNotFoundException $e) { - $description = 'A converter was not found!'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\CreateDestinationFileException $e) { - $description = 'Cannot create destination file'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\CreateDestinationFolderException $e) { - $description = 'Cannot create destination folder'; - $msg = $e->getMessage(); - } catch (\Exception $e) { - $description = 'An unanticipated exception was thrown'; - $msg = $e->getMessage(); - } - - // Next line is commented out, because we need to be absolute sure that the details does not violate syntax - // We could either try to filter it, or we could change WebPConvert, such that it only provides safe texts. - // $this->header('X-WebP-Convert-And-Serve-Details: ' . $bufferLogger->getText()); - - $this->fail('Conversion failed', $description, $criticalFail); - return false; - //echo '

This is how conversion process went:

' . $bufferLogger->getHtml(); - } - - protected function serveErrorMessageImage($msg) - { - // Generate image containing error message - if ($this->options['add-content-type-header']) { - $this->header('Content-type: image/gif'); - } - - // TODO: handle if this fails... - $image = imagecreatetruecolor(620, 200); - imagestring($image, 1, 5, 5, $msg, imagecolorallocate($image, 233, 214, 291)); - // echo imagewebp($image); - echo imagegif($image); - imagedestroy($image); - } - - protected function fail($title, $description, $critical = false) - { - $action = $critical ? $this->options['fail-when-original-unavailable'] : $this->options['fail']; - - if (isset($this->options['aboutToPerformFailActionCallback'])) { - if (call_user_func( - $this->options['aboutToPerformFailActionCallback'], - $title, - $description, - $action, - $this - ) === false) { - return; - } - } - - $this->addXStatusHeader('Failed (' . $description . ')'); - - $this->addHeadersPreventingCaching(); - - - $title = 'Conversion failed'; - switch ($action) { - case 'serve-original': - if (!$this->serveOriginal()) { - $this->serve404(); - }; - break; - case '404': - $this->serve404(); - break; - case 'report-as-image': - // todo: handle if this fails - self::serveErrorMessageImage($title . '. ' . $description); - break; - case 'report': - echo '

' . $title . '

' . $description; - break; - } - } - - protected function criticalFail($title, $description) - { - return $this->fail($title, $description, true); - } - - /** - * Serve the thing specified in $whatToServe and $whyServingThis - * These are first set my the decideWhatToServe() method, but may later change, if a fresh - * conversion is made - */ - public function serve() - { - - //$this->addXOptionsHeader(); - - switch ($this->whatToServe) { - case 'destination': - return $this->serveExisting(); - case 'source': - if ($this->whyServingThis == 'explicitly-told-to') { - $this->addXStatusHeader( - 'Serving original image (was explicitly told to)' - ); - } else { - $this->addXStatusHeader( - 'Serving original image (it is smaller than the already converted)' - ); - } - if (!$this->serveOriginal()) { - $this->criticalFail('Error', 'could not serve original'); - return false; - } - return true; - case 'fresh-conversion': - return $this->serveFreshlyConverted(); - break; - case 'critical-fail': - $this->criticalFail('Error', $this->whyServingThis); - return false; - case 'fail': - $this->fail('Error', $this->whyServingThis); - return false; - case 'report': - $this->addXStatusHeader('Reporting...'); - Report::convertAndReport($this->source, $this->destination, $this->options); - return true; // yeah, lets say that a report is always a success, even if conversion is a failure - } - } - - public function decideWhatToServeAndServeIt() - { - $this->decideWhatToServe(); - return $this->serve(); - } - - /** - * Main method - */ - public static function serveConverted($source, $destination, $options) - { - if (isset($options['fail']) && ($options['fail'] == 'original')) { - $options['fail'] = 'serve-original'; - } - // For backward compatability: - if (isset($options['critical-fail']) && !isset($options['fail-when-original-unavailable'])) { - $options['fail-when-original-unavailable'] = $options['critical-fail']; - } - - $cs = new static($source, $destination, $options); - - return $cs->decideWhatToServeAndServeIt(); - } -} - -?>decideWhatToServe(); - if ($server->whatToServe == 'destination') { - return $server->serveExisting(); - } else { - // Load extra php classes, if told to - if (isset($options['require-for-conversion'])) { - require($options['require-for-conversion']); - } - ServeConverted::serveConverted($source, $destination, $options); - } - } -} - diff --git a/build/webp-on-demand-1.inc b/build/webp-on-demand-1.inc deleted file mode 100644 index 4eb75877..00000000 --- a/build/webp-on-demand-1.inc +++ /dev/null @@ -1,285 +0,0 @@ -source = $source; - $this->destination = $destination; - $this->options = array_merge(self::$defaultOptions, $options); - - $this->setErrorReporting(); - } - - public static $defaultOptions = [ - 'add-content-type-header' => true, - 'add-vary-header' => true, - 'add-x-header-status' => true, - 'add-x-header-options' => false, - 'aboutToServeImageCallBack' => null, - 'aboutToPerformFailAction' => null, - 'cache-control-header' => 'public, max-age=86400', - 'converters' => ['cwebp', 'gd', 'imagick'], - 'error-reporting' => 'auto', - 'fail' => 'original', - 'fail-when-original-unavailable' => '404', - 'reconvert' => false, - 'serve-original' => false, - 'show-report' => false, - ]; - - protected function setErrorReporting() - { - if (($this->options['error-reporting'] === true) || - (($this->options['error-reporting'] === 'auto') && ($this->options['show-report'] === true)) - ) { - error_reporting(E_ALL); - ini_set('display_errors', 'On'); - } elseif (($this->options['error-reporting'] === false) || - (($this->options['error-reporting'] === 'auto') && ($this->options['show-report'] === false)) - ) { - error_reporting(0); - ini_set('display_errors', 'Off'); - } - } - - protected function header($header, $replace = true) - { - header($header, $replace); - } - - public function addXStatusHeader($text) - { - if ($this->options['add-x-header-status']) { - $this->header('X-WebP-Convert-Status: ' . $text, true); - } - } - - public function addVaryHeader() - { - if ($this->options['add-vary-header']) { - $this->header('Vary: Accept'); - } - } - - public function addContentTypeHeader($cType) - { - if ($this->options['add-content-type-header']) { - $this->header('Content-type: ' . $cType); - } - } - - public function addCacheControlHeader() - { - if (!empty($this->options['cache-control-header'])) { - $this->header('Cache-Control: ' . $this->options['cache-control-header'], true); - } - } - - public function serveExisting() - { - if (!$this->callAboutToServeImageCallBack('destination')) { - return; - } - - $this->addXStatusHeader('Serving existing converted image'); - $this->addVaryHeader(); - $this->addContentTypeHeader('image/webp'); - $this->addCacheControlHeader(); - - if (@readfile($this->destination) === false) { - $this->header('X-WebP-Convert-Error: Could not read file'); - return false; - } - return true; - } - - /** - * Called immidiately before serving image (either original, already converted or fresh) - * $whatToServe can be 'source' | 'destination' | 'fresh-conversion' - * $whyServingThis can be: - * for 'source': - * - "explicitly-told-to" (when the "original" option is set) - * - "source-lighter" (when original image is actually smaller than the converted) - * for 'fresh-conversion': - * - "explicitly-told-to" (when the "reconvert" option is set) - * - "source-modified" (when source is newer than existing) - * - "no-existing" (when there is no existing at the destination) - * for 'destination': - * - "no-reason-not-to" (it is lighter than source, its not older, - * and we were not told to do otherwise) - */ - protected function callAboutToServeImageCallBack($whatToServe) - { - if (!isset($this->options['aboutToServeImageCallBack'])) { - return true; - } - $result = call_user_func( - $this->options['aboutToServeImageCallBack'], - $whatToServe, - $this->whyServingThis, - $this - ); - return ($result !== false); - } - - /** - * Decides what to serve. - * Returns array. First item is what to do, second is additional info. - * First item can be one of these: - * - "destination" (serve existing converted image at the destination path) - * - "no-reason-not-to" - * - "source" - * - "explicitly-told-to" - * - "source-lighter" - * - "fresh-conversion" (note: this may still fail) - * - "explicitly-told-to" - * - "source-modified" - * - "no-existing" - * - "fail" - * - "Missing destination argument" - * - "critical-fail" (a failure where the source file cannot be served) - * - "Missing source argument" - * - "Source file was not found!" - * - "report" - */ - public function decideWhatToServe() - { - $decisionArr = $this->doDecideWhatToServe(); - $this->whatToServe = $decisionArr[0]; - $this->whyServingThis = $decisionArr[1]; - } - - private function doDecideWhatToServe() - { - if (empty($this->source)) { - return ['critical-fail', 'Missing source argument']; - } - if (@!file_exists($this->source)) { - return ['critical-fail', 'Source file was not found!']; - } - if (empty($this->destination)) { - return ['fail', 'Missing destination argument']; - } - if ($this->options['show-report']) { - return ['report', '']; - } - if ($this->options['serve-original']) { - return ['source', 'explicitly-told-to']; - } - if ($this->options['reconvert']) { - return ['fresh-conversion', 'explicitly-told-to']; - } - - if (@file_exists($this->destination)) { - // Reconvert if source file is newer than destination - $timestampSource = @filemtime($this->source); - $timestampDestination = @filemtime($this->destination); - if (($timestampSource !== false) && - ($timestampDestination !== false) && - ($timestampSource > $timestampDestination)) { - return ['fresh-conversion', 'source-modified']; - } - - // Serve source if it is smaller than destination - $filesizeDestination = @filesize($this->destination); - $filesizeSource = @filesize($this->source); - if (($filesizeSource !== false) && - ($filesizeDestination !== false) && - ($filesizeDestination > $filesizeSource)) { - return ['source', 'source-lighter']; - } - - // Destination exists, and there is no reason left not to serve it - return ['destination', 'no-reason-not-to']; - } else { - return ['fresh-conversion', 'no-existing']; - } - } -} - -?>decideWhatToServe(); - if ($server->whatToServe == 'destination') { - return $server->serveExisting(); - } else { - // Load extra php classes, if told to - if (isset($options['require-for-conversion'])) { - require($options['require-for-conversion']); - } - ServeConverted::serveConverted($source, $destination, $options); - } - } -} - diff --git a/build/webp-on-demand-2.inc b/build/webp-on-demand-2.inc deleted file mode 100644 index cb1db93f..00000000 --- a/build/webp-on-demand-2.inc +++ /dev/null @@ -1,2561 +0,0 @@ -log($msg, $style); - $this->ln(); - } - - public function logLnLn($msg, $style = '') - { - $this->logLn($msg, $style); - $this->ln(); - } -} - -?> 'auto', - 'max-quality' => 85, - 'default-quality' => 75, - 'metadata' => 'none', - 'method' => 6, - 'low-memory' => false, - 'lossless' => false, - 'converters' => ['cwebp', 'gd', 'imagick'], - 'converter-options' => [] - ]; - - public static function mergeOptions($options, $extraOptions) - { - return $options; - } - - public static function getClassNameOfConverter($converterId) - { - return 'WebPConvert\\Converters\\' . ucfirst($converterId); - } - - /* Call the "convert" method on a converter, by id. - - but also prepares options (merges in the $extraOptions of the converter), - prepares destination folder, and runs some standard validations - If it fails, it throws an exception. Otherwise it don't (there is no return value) - */ - public static function runConverter( - $converterId, - $source, - $destination, - $options = [], - $prepareDestinationFolder = true, - $logger = null - ) { - - - if ($prepareDestinationFolder) { - self::prepareDestinationFolderAndRunCommonValidations($source, $destination); - } - - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - - $className = self::getClassNameOfConverter($converterId); - if (!is_callable([$className, 'convert'])) { - throw new ConverterNotFoundException(); - } - - // Prepare options. - // - Remove 'converters' - $defaultOptions = self::$defaultOptions; - unset($defaultOptions['converters']); - - // - Merge defaults of the converters extra options into the standard default options. - $defaultOptions = array_merge($defaultOptions, array_column($className::$extraOptions, 'default', 'name')); - - // - Merge $defaultOptions into provided options - $options = array_merge($defaultOptions, $options); - - // Individual converters do not accept quality = auto. They need a number. - // Change $options['quality'] to number, based on quality of source and several settings - - self::processQualityOption($source, $options, $logger); - - call_user_func( - [$className, 'doConvert'], - $source, - $destination, - $options, - $logger - ); - - if (!@file_exists($destination)) { - throw new ConverterFailedException('Destination file is not there'); - } else { - $sourceSize = @filesize($source); - if ($sourceSize !== false) { - $msg = 'Success. '; - $msg .= 'Reduced file size with ' . - round((filesize($source) - filesize($destination))/filesize($source) * 100) . '% '; - - if ($sourceSize < 10000) { - $msg .= '(went from ' . round(filesize($source)) . ' bytes to '; - $msg .= round(filesize($destination)) . ' bytes)'; - } else { - $msg .= '(went from ' . round(filesize($source)/1024) . ' kb to '; - $msg .= round(filesize($destination)/1024) . ' kb)'; - } - $logger->logLn($msg); - } - } - } - - public static function runConverterWithTiming( - $converterId, - $source, - $destination, - $options = [], - $prepareDestinationFolder = true, - $logger = null - ) { - $beginTime = microtime(true); - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - try { - self::runConverter($converterId, $source, $destination, $options, $prepareDestinationFolder, $logger); - $logger->logLn( - 'Successfully converted test image in ' . - round((microtime(true) - $beginTime) * 1000) . ' ms' - ); - } catch (\Exception $e) { - $logger->logLn('Failed in ' . round((microtime(true) - $beginTime) * 1000) . ' ms'); - throw $e; - } - } - - /* - @param (string) $source: Absolute path to image to be converted (no backslashes). Image must be jpeg or png - @param (string) $destination: Absolute path (no backslashes) - @param (object) $options: Array of named options, such as 'quality' and 'metadata' - */ - public static function runConverterStack($source, $destination, $options = [], $logger = null) - { - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - self::prepareDestinationFolderAndRunCommonValidations($source, $destination); - - $options = array_merge(self::$defaultOptions, $options); - - self::processQualityOption($source, $options, $logger); - - // Force lossless option to true for PNG images - if (self::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - $defaultConverterOptions = $options; - $defaultConverterOptions['converters'] = null; - - $firstFailException = null; - - // If we have set converter options for a converter, which is not in the converter array, - // then we add it to the array - if (isset($options['converter-options'])) { - foreach ($options['converter-options'] as $converterName => $converterOptions) { - if (!in_array($converterName, $options['converters'])) { - $options['converters'][] = $converterName; - } - } - } - - foreach ($options['converters'] as $converter) { - if (is_array($converter)) { - $converterId = $converter['converter']; - $converterOptions = $converter['options']; - } else { - $converterId = $converter; - $converterOptions = []; - if (isset($options['converter-options'][$converterId])) { - // Note: right now, converter-options are not meant to be used, - // when you have several converters of the same type - $converterOptions = $options['converter-options'][$converterId]; - } - } - - $converterOptions = array_merge($defaultConverterOptions, $converterOptions); - - try { - $logger->logLn('Trying:' . $converterId, 'italic'); - - // If quality is different, we must recalculate - if ($converterOptions['quality'] != $defaultConverterOptions['quality']) { - unset($converterOptions['_calculated_quality']); - self::processQualityOption($source, $converterOptions, $logger); - } - - self::runConverterWithTiming($converterId, $source, $destination, $converterOptions, false, $logger); - - $logger->logLn('ok', 'bold'); - return true; - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { -// $logger->logLnLn($e->description . ' : ' . $e->getMessage()); - $logger->logLnLn($e->getMessage()); - - // The converter is not operational. - // Well, well, we will just have to try the next, then - } catch (\WebPConvert\Converters\Exceptions\ConverterFailedException $e) { - $logger->logLnLn($e->getMessage()); - - // Converter failed in an anticipated, yet somewhat surprising fashion. - // The converter seemed operational - requirements was in order - but it failed anyway. - // This is moderately bad. - // If some other converter can handle the conversion, we will let this one go. - // But if not, we shall throw the exception - - if (!$firstFailException) { - $firstFailException = $e; - } - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - $logger->logLnLn($e->getMessage()); - - // The converter declined. - // Gd is for example throwing this, when asked to convert a PNG, but configured not to - // We also possibly rethrow this, because it may have come as a surprise to the user - // who perhaps only tested jpg - if (!$firstFailException) { - $firstFailException = $e; - } - } - } - - if ($firstFailException) { - // At least one converter failed or declined. - $logger->logLn('Conversion failed. None of the tried converters could convert the image', 'bold'); - } else { - // All converters threw a ConverterNotOperationalException - $logger->logLn('Conversion failed. None of the tried converters are operational', 'bold'); - } - - // No converters could do the job. - // If one of them failed moderately bad, rethrow that exception. - if ($firstFailException) { - throw $firstFailException; - } - - return false; - } - - /* Try to detect quality of jpeg. - If not possible, nothing is returned (null). Otherwise quality is returned (int) - */ - public static function detectQualityOfJpg($filename) - { - // Try Imagick extension - if (extension_loaded('imagick') && class_exists('Imagick')) { - $img = new Imagick($filename); - - // The required function is available as from PECL imagick v2.2.2 - if (method_exists($img, 'getImageCompressionQuality')) { - return $img->getImageCompressionQuality(); - } - } - - // Gmagick extension doesn't seem to support this (yet): - // https://bugs.php.net/bug.php?id=63939 - - if (function_exists('shell_exec')) { - // Try Imagick - $quality = shell_exec("identify -format '%Q' " . $filename); - if ($quality) { - return intval($quality); - } - - // Try GraphicsMagick - $quality = shell_exec("gm identify -format '%Q' " . $filename); - if ($quality) { - return intval($quality); - } - } - } - - public static function processQualityOption($source, &$options, $logger) - { - if (isset($options['_calculated_quality'])) { - return; - } - if ($options['quality'] == 'auto') { - $q = self::detectQualityOfJpg($source); - //$logger->log('Quality set to auto... Quality of source: '); - if (!$q) { - $q = $options['default-quality']; - $logger->logLn( - 'Quality of source could not be established (Imagick or GraphicsMagick is required)' . - ' - Using default instead (' . $options['default-quality'] . ').' - ); - - // this allows the wpc converter to know - $options['_quality_could_not_be_detected'] = true; - } else { - if ($q > $options['max-quality']) { - $logger->log( - 'Quality of source is ' . $q . '. ' . - 'This is higher than max-quality, so using that instead (' . $options['max-quality'] . ')' - ); - } else { - $logger->log('Quality set to same as source: ' . $q); - } - } - $logger->ln(); - $q = min($q, $options['max-quality']); - - $options['_calculated_quality'] = $q; - //$logger->logLn('Using quality: ' . $options['quality']); - } else { - $logger->logLn( - 'Quality: ' . $options['quality'] . '. ' . - 'Consider setting quality to "auto" instead. It is generally a better idea' - ); - $options['_calculated_quality'] = $options['quality']; - } - $logger->ln(); - } - - - public static function getExtension($filePath) - { - $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); - return strtolower($fileExtension); - } - - // Throws an exception if the provided file doesn't exist - public static function isValidTarget($filePath) - { - if (!@file_exists($filePath)) { - throw new TargetNotFoundException('File or directory not found: ' . $filePath); - } - - return true; - } - - // Throws an exception if the provided file's extension is invalid - public static function isAllowedExtension($filePath) - { - $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); - if (!in_array(strtolower($fileExtension), self::$allowedExtensions)) { - throw new InvalidFileExtensionException('Unsupported file extension: ' . $fileExtension); - } - - return true; - } - - // Creates folder in provided path & sets correct permissions - // also deletes the file at filePath (if it already exists) - public static function createWritableFolder($filePath) - { - $folder = dirname($filePath); - if (!@file_exists($folder)) { - // TODO: what if this is outside open basedir? - // see http://php.net/manual/en/ini.core.php#ini.open-basedir - - // First, we have to figure out which permissions to set. - // We want same permissions as parent folder - // But which parent? - the parent to the first missing folder - - $parentFolders = explode('/', $folder); - $poppedFolders = []; - - while (!(@file_exists(implode('/', $parentFolders))) && count($parentFolders) > 0) { - array_unshift($poppedFolders, array_pop($parentFolders)); - } - - // Retrieving permissions of closest existing folder - $closestExistingFolder = implode('/', $parentFolders); - $permissions = @fileperms($closestExistingFolder) & 000777; - $stat = @stat($closestExistingFolder); - - // Trying to create the given folder (recursively) - if (!@mkdir($folder, $permissions, true)) { - throw new CreateDestinationFolderException('Failed creating folder: ' . $folder); - } - - // `mkdir` doesn't always respect permissions, so we have to `chmod` each created subfolder - foreach ($poppedFolders as $subfolder) { - $closestExistingFolder .= '/' . $subfolder; - // Setting directory permissions - if ($permissions !== false) { - @chmod($folder, $permissions); - } - if ($stat !== false) { - if (isset($stat['uid'])) { - @chown($folder, $stat['uid']); - } - if (isset($stat['gid'])) { - @chgrp($folder, $stat['gid']); - } - } - } - } - - if (@file_exists($filePath)) { - // A file already exists in this folder... - // We delete it, to make way for a new webp - if (!@unlink($filePath)) { - throw new CreateDestinationFileException( - 'Existing file cannot be removed: ' . basename($filePath) - ); - } - } - - return true; - } - - public static function prepareDestinationFolderAndRunCommonValidations($source, $destination) - { - self::isValidTarget($source); - self::isAllowedExtension($source); - self::createWritableFolder($destination); - } - - public static function initCurlForConverter() - { - if (!extension_loaded('curl')) { - throw new ConverterNotOperationalException('Required cURL extension is not available.'); - } - - if (!function_exists('curl_init')) { - throw new ConverterNotOperationalException('Required url_init() function is not available.'); - } - - if (!function_exists('curl_file_create')) { - throw new ConverterNotOperationalException( - 'Required curl_file_create() function is not available (requires PHP > 5.5).' - ); - } - - $ch = curl_init(); - if (!$ch) { - throw new ConverterNotOperationalException('Could not initialise cURL.'); - } - return $ch; - } -} - -?> 'use-nice', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => false, - 'required' => false - ], - // low-memory is defined for all, in ConverterHelper - [ - 'name' => 'try-common-system-paths', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - [ - 'name' => 'try-supplied-binary-for-os', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - [ - 'name' => 'size-in-percentage', - 'type' => 'number', - 'sensitive' => false, - 'default' => null, - 'required' => false - ], - [ - 'name' => 'command-line-options', - 'type' => 'string', - 'sensitive' => false, - 'default' => '', - 'required' => false - ], - [ - 'name' => 'rel-path-to-precompiled-binaries', - 'type' => 'string', - 'sensitive' => false, - 'default' => './Binaries', - 'required' => false - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('cwebp', $source, $destination, $options, true); - } - - // System paths to look for cwebp binary - private static $cwebpDefaultPaths = [ - '/usr/bin/cwebp', - '/usr/local/bin/cwebp', - '/usr/gnu/bin/cwebp', - '/usr/syno/bin/cwebp' - ]; - - // OS-specific binaries included in this library, along with hashes - private static $suppliedBinariesInfo = [ - 'WinNT' => [ 'cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873'], - 'Darwin' => [ 'cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379'], - 'SunOS' => [ 'cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f'], - 'FreeBSD' => [ 'cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573'], - 'Linux' => [ 'cwebp-linux', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568'] - ]; - - private static function escapeFilename($string) - { - // Escaping whitespace - $string = preg_replace('/\s/', '\\ ', $string); - - // filter_var() is should normally be available, but it is not always - // - https://stackoverflow.com/questions/11735538/call-to-undefined-function-filter-var - if (function_exists('filter_var')) { - // Sanitize quotes - $string = filter_var($string, FILTER_SANITIZE_MAGIC_QUOTES); - - // Stripping control characters - // see https://stackoverflow.com/questions/12769462/filter-flag-strip-low-vs-filter-flag-strip-high - $string = filter_var($string, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - } - - return $string; - } - - // Checks if 'Nice' is available - private static function hasNiceSupport() - { - exec("nice 2>&1", $niceOutput); - - if (is_array($niceOutput) && isset($niceOutput[0])) { - if (preg_match('/usage/', $niceOutput[0]) || (preg_match('/^\d+$/', $niceOutput[0]))) { - /* - * Nice is available - default niceness (+10) - * https://www.lifewire.com/uses-of-commands-nice-renice-2201087 - * https://www.computerhope.com/unix/unice.htm - */ - - return true; - } - - return false; - } - } - - private static function executeBinary($binary, $commandOptions, $useNice, $logger) - { - $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions; - - //$logger->logLn('command options:' . $commandOptions); - //$logger->logLn('Trying to execute binary:' . $binary); - exec($command, $output, $returnCode); - //$logger->logLn(self::msgForExitCode($returnCode)); - return intval($returnCode); - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - $errorMsg = ''; - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - if (!function_exists('exec')) { - throw new ConverterNotOperationalException('exec() is not enabled.'); - } - - /* - * Prepare cwebp options - */ - - $commandOptionsArray = []; - - // Metadata (all, exif, icc, xmp or none (default)) - // Comma-separated list of existing metadata to copy from input to output - $commandOptionsArray[] = '-metadata ' . $options['metadata']; - - // Size - if (!is_null($options['size-in-percentage'])) { - $sizeSource = @filesize($source); - if ($sizeSource !== false) { - $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100); - } - } - if (isset($targetSize)) { - $commandOptionsArray[] = '-size ' . $targetSize; - } else { - // Image quality - $commandOptionsArray[] = '-q ' . $options['_calculated_quality']; - } - - - // Losless PNG conversion - $commandOptionsArray[] = ($options['lossless'] ? '-lossless' : ''); - - // Built-in method option - $commandOptionsArray[] = '-m ' . strval($options['method']); - - // Built-in low memory option - if ($options['low-memory']) { - $commandOptionsArray[] = '-low_memory'; - } - - // command-line-options - if ($options['command-line-options']) { - $arr = explode(' -', ' ' . $options['command-line-options']); - foreach ($arr as $cmdOption) { - $pos = strpos($cmdOption, ' '); - $cName = ''; - $cValue = ''; - if (!$pos) { - $cName = $cmdOption; - if ($cName == '') { - continue; - } - $commandOptionsArray[] = '-' . $cName; - } else { - $cName = substr($cmdOption, 0, $pos); - $cValues = substr($cmdOption, $pos + 1); - $cValuesArr = explode(' ', $cValues); - foreach ($cValuesArr as &$cArg) { - $cArg = escapeshellarg($cArg); - } - $cValues = implode(' ', $cValuesArr); - $commandOptionsArray[] = '-' . $cName . ' ' . $cValues; - } - } - } - - // Source file - $commandOptionsArray[] = self::escapeFilename($source); - - // Output - $commandOptionsArray[] = '-o ' . self::escapeFilename($destination); - - // Redirect stderr to same place as stdout - // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/ - $commandOptionsArray[] = '2>&1'; - - - $useNice = (($options['use-nice']) && self::hasNiceSupport()) ? true : false; - - $commandOptions = implode(' ', $commandOptionsArray); - - $logger->logLn('cwebp options:' . $commandOptions); - - // Init with common system paths - $cwebpPathsToTest = self::$cwebpDefaultPaths; - - // Remove paths that doesn't exist - /* - $cwebpPathsToTest = array_filter($cwebpPathsToTest, function ($binary) { - //return file_exists($binary); - return @is_readable($binary); - }); - */ - - // Try all common paths that exists - $success = false; - $failures = []; - $failureCodes = []; - - if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) { - $errorMsg .= 'Configured to neither look for cweb binaries in common system locations, ' . - 'nor to use one of the supplied precompiled binaries. But these are the only ways ' . - 'this converter can convert images. No conversion can be made!'; - } - - if ($options['try-common-system-paths']) { - foreach ($cwebpPathsToTest as $index => $binary) { - $returnCode = self::executeBinary($binary, $commandOptions, $useNice, $logger); - if ($returnCode == 0) { - $logger->logLn('Successfully executed binary: ' . $binary); - $success = true; - break; - } else { - $failures[] = [$binary, $returnCode]; - if (!in_array($returnCode, $failureCodes)) { - $failureCodes[] = $returnCode; - } - } - } - $majorFailCode = 0; - if (!$success) { - if (count($failureCodes) == 1) { - $majorFailCode = $failureCodes[0]; - switch ($majorFailCode) { - case 126: - $errorMsg = 'Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute any of the ' . - 'cweb binaries found in common system locations. '; - break; - case 127: - $errorMsg .= 'Found no cwebp binaries in any common system locations. '; - break; - default: - $errorMsg .= 'Tried executing cwebp binaries in common system locations. ' . - 'All failed (exit code: ' . $majorFailCode . '). '; - } - } else { - $failureCodesBesides127 = array_diff($failureCodes, [127]); - - if (count($failureCodesBesides127) == 1) { - $majorFailCode = $failureCodesBesides127[0]; - switch ($returnCode) { - case 126: - $errorMsg = 'Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute any of the cweb ' . - 'binaries found in common system locations. '; - break; - default: - $errorMsg .= 'Tried executing cwebp binaries in common system locations. ' . - 'All failed (exit code: ' . $majorFailCode . '). '; - } - } else { - $errorMsg .= 'None of the cwebp binaries in the common system locations could be executed ' . - '(mixed results - got the following exit codes: ' . implode(',', $failureCodes) . '). '; - } - } - } - } - - if (!$success && $options['try-supplied-binary-for-os']) { - // Try supplied binary (if available for OS, and hash is correct) - if (isset(self::$suppliedBinariesInfo[PHP_OS])) { - $info = self::$suppliedBinariesInfo[PHP_OS]; - - $file = $info[0]; - $hash = $info[1]; - - $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file; - - // The file should exist, but may have been removed manually. - if (@file_exists($binaryFile)) { - // File exists, now generate its hash - - // hash_file() is normally available, but it is not always - // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash - // If available, validate that hash is correct. - $proceedAfterHashCheck = true; - if (function_exists('hash_file')) { - $binaryHash = hash_file('sha256', $binaryFile); - - if ($binaryHash != $hash) { - $errorMsg .= 'Binary checksum of supplied binary is invalid! ' . - 'Did you transfer with FTP, but not in binary mode? ' . - 'File:' . $binaryFile . '. ' . - 'Expected checksum: ' . $hash . '. ' . - 'Actual checksum:' . $binaryHash . '.'; - $proceedAfterHashCheck = false; - } - } - if ($proceedAfterHashCheck) { - $returnCode = self::executeBinary($binaryFile, $commandOptions, $useNice, $logger); - if ($returnCode == 0) { - $success = true; - } else { - $errorMsg .= 'Tried executing supplied binary for ' . PHP_OS . ', ' . - ($options['try-common-system-paths'] ? 'but that failed too' : 'but failed'); - if ($options['try-common-system-paths'] && ($majorFailCode > 0)) { - $errorMsg .= ' (same error)'; - } else { - switch ($returnCode) { - case 0: - $success = true; - ; - break; - case 126: - $errorMsg .= ': Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute that binary.'; - break; - case 127: - $errorMsg .= '. The binary was not found! It ought to be here: ' . $binaryFile; - break; - default: - $errorMsg .= ' (exit code:' . $returnCode . ').'; - } - } - } - } - } else { - $errorMsg .= 'Supplied binary not found! It ought to be here:' . $binaryFile; - } - } else { - $errorMsg .= 'No supplied binaries found for OS:' . PHP_OS; - } - } - - - - // cwebp sets file permissions to 664 but instead .. - // .. $destination's parent folder's permissions should be used (except executable bits) - if ($success) { - $destinationParent = dirname($destination); - $fileStatistics = @stat($destinationParent); - if ($fileStatistics !== false) { - // Apply same permissions as parent folder but strip off the executable bits - $permissions = $fileStatistics['mode'] & 0000666; - @chmod($destination, $permissions); - } - } - - if (!$success) { - throw new ConverterNotOperationalException($errorMsg); - } - } -} - -?> 'key', - 'type' => 'string', - 'sensitive' => true, - 'default' => '', - 'required' => true - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('ewww', $source, $destination, $options, true); - } - - // Took this parser from Drupal - private static function parseSize($size) - { - - $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. - $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. - if ($unit) { - // Find the position of the unit in the ordered string which is the power - // of magnitude to multiply a kilobyte by. - return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); - } else { - return round($size); - } - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if ($options['key'] == '') { - throw new ConverterNotOperationalException('Missing API key.'); - } - if (strlen($options['key']) < 20) { - throw new ConverterNotOperationalException( - 'Key is invalid. Keys are supposed to be 32 characters long - your key is much shorter' - ); - } - - $keyStatus = self::getKeyStatus($options['key']); - switch ($keyStatus) { - case 'great': - break; - case 'exceeded': - throw new ConverterNotOperationalException('quota has exceeded'); - break; - case 'invalid': - throw new ConverterNotOperationalException('key is invalid'); - break; - } - - $fileSize = @filesize($source); - if ($fileSize !== false) { - $uploadMaxSize = self::parseSize(ini_get('upload_max_filesize')); - if (($uploadMaxSize !== false) && ($uploadMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your max upload (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'upload_max_filesize in php.ini: ' . ini_get('upload_max_filesize') . - ' (parsed as ' . round($uploadMaxSize/1024) . ' kb)' - ); - } - - $postMaxSize = self::parseSize(ini_get('post_max_size')); - if (($postMaxSize !== false) && ($postMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your post_max_size limit (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'post_max_size in php.ini: ' . ini_get('post_max_size') . - ' (parsed as ' . round($postMaxSize/1024) . ' kb)' - ); - } - - // ini_get('memory_limit') - } - - - $ch = ConverterHelper::initCurlForConverter(); - - $curlOptions = [ - 'api_key' => $options['key'], - 'webp' => '1', - 'file' => curl_file_create($source), - 'domain' => $_SERVER['HTTP_HOST'], - 'quality' => $options['_calculated_quality'], - 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') - ]; - - curl_setopt_array( - $ch, - [ - CURLOPT_URL => "https://optimize.exactlywww.com/v2/", - CURLOPT_HTTPHEADER => [ - 'User-Agent: WebPConvert', - 'Accept: image/*' - ], - CURLOPT_POSTFIELDS => $curlOptions, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ] - ); - - $response = curl_exec($ch); - - if (curl_errno($ch)) { - throw new ConverterNotOperationalException(curl_error($ch)); - } - - // The API does not always return images. - // For example, it may return a message such as '{"error":"invalid","t":"exceeded"} - // Messages has a http content type of ie 'text/html; charset=UTF-8 - // Images has application/octet-stream. - // So verify that we got an image back. - if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { - //echo curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - curl_close($ch); - - /* May return this: {"error":"invalid","t":"exceeded"} */ - $responseObj = json_decode($response); - if (isset($responseObj->error)) { - //echo 'error:' . $responseObj->error . '
'; - //echo $response; - //self::blacklistKey($key); - //throw new ConverterNotOperationalException('The key is invalid. Blacklisted it!'); - throw new ConverterNotOperationalException('The key is invalid'); - } - - throw new ConverterNotOperationalException( - 'ewww api did not return an image. It could be that the key is invalid. Response: ' - . $response - ); - } - - // Not sure this can happen. So just in case - if ($response == '') { - throw new ConverterNotOperationalException('ewww api did not return anything'); - } - - $success = file_put_contents($destination, $response); - - if (!$success) { - throw new ConverterFailedException('Error saving file'); - } - } - - /* - public static function blacklistKey($key) - { - } - - public static function isKeyBlacklisted($key) - { - }*/ - - /** - * Return "great", "exceeded" or "invalid" - */ - public static function getKeyStatus($key) - { - $ch = ConverterHelper::initCurlForConverter(); - - curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/verify/"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt( - $ch, - CURLOPT_POSTFIELDS, - [ - 'api_key' => $key - ] - ); - - // The 403 forbidden is avoided with this line. - curl_setopt( - $ch, - CURLOPT_USERAGENT, - 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)' - ); - - $response = curl_exec($ch); - // echo $response; - if (curl_errno($ch)) { - throw new \Exception(curl_error($ch)); - } - curl_close($ch); - - // Possible responses: - // “great” = verification successful - // “exceeded” = indicates a valid key with no remaining image credits. - // an empty response indicates that the key is not valid - - if ($response == '') { - return 'invalid'; - } - $responseObj = json_decode($response); - if (isset($responseObj->error)) { - if ($responseObj->error == 'invalid') { - return 'invalid'; - } else { - throw new \Exception('Ewww returned unexpected error: ' . $response); - } - } - if (!isset($responseObj->status)) { - throw new \Exception('Ewww returned unexpected response to verify request: ' . $response); - } - switch ($responseObj->status) { - case 'great': - case 'exceeded': - return $responseObj->status; - } - throw new \Exception('Ewww returned unexpected status to verify request: "' . $responseObj->status . '"'); - } - - public static function isWorkingKey($key) - { - return (self::getKeyStatus($key) == 'great'); - } - - public static function isValidKey($key) - { - return (self::getKeyStatus($key) != 'invalid'); - } - - public static function getQuota($key) - { - $ch = ConverterHelper::initCurlForConverter(); - - curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/quota/"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt( - $ch, - CURLOPT_POSTFIELDS, - [ - 'api_key' => $key - ] - ); - curl_setopt( - $ch, - CURLOPT_USERAGENT, - 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)' - ); - - $response = curl_exec($ch); - return $response; // ie -830 23. Seems to return empty for invalid keys - // or empty - //echo $response; - } -} - -?> 'skip-pngs', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('gd', $source, $destination, $options, true); - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if (!extension_loaded('gd')) { - throw new ConverterNotOperationalException('Required Gd extension is not available.'); - } - - if (!function_exists('imagewebp')) { - throw new ConverterNotOperationalException( - 'Required imagewebp() function is not available. It seems Gd has been compiled without webp support.' - ); - } - - switch (ConverterHelper::getExtension($source)) { - case 'png': - if (!$options['skip-pngs']) { - if (!function_exists('imagecreatefrompng')) { - throw new ConverterNotOperationalException( - 'Required imagecreatefrompng() function is not available.' - ); - } - $image = @imagecreatefrompng($source); - if (!$image) { - throw new ConverterFailedException( - 'imagecreatefrompng("' . $source . '") failed' - ); - } - } else { - throw new ConversionDeclinedException( - 'PNG file skipped. GD is configured not to convert PNGs' - ); - } - break; - default: - if (!function_exists('imagecreatefromjpeg')) { - throw new ConverterNotOperationalException( - 'Required imagecreatefromjpeg() function is not available.' - ); - } - $image = @imagecreatefromjpeg($source); - if (!$image) { - throw new ConverterFailedException('imagecreatefromjpeg("' . $source . '") failed'); - } - } - - // Checks if either imagecreatefromjpeg() or imagecreatefrompng() returned false - - $success = @imagewebp($image, $destination, $options['_calculated_quality']); - - if (!$success) { - throw new ConverterFailedException( - 'Call to imagewebp() failed. Probably failed writing file. Check file permissions!' - ); - } - - /* - * This hack solves an `imagewebp` bug - * See https://stackoverflow.com/questions/30078090/imagewebp-php-creates-corrupted-webp-files - * - */ - if (@filesize($destination) % 2 == 1) { - @file_put_contents($destination, "\0", FILE_APPEND); - } - - imagedestroy($image); - } -} - -?>queryformats())) { - throw new ConverterNotOperationalException('Gmagick was compiled without WebP support.'); - } - - $options = array_merge(ConverterHelper::$defaultOptions, $options); - - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - - /* - Seems there are currently no way to set webp options - As noted in the following link, it should probably be done with a $im->addDefinition() method - - but that isn't exposed (yet) - (TODO: see if anyone has answered...) - https://stackoverflow.com/questions/47294962/how-to-write-lossless-webp-files-with-perlmagick - */ - // The following two does not have any effect... How to set WebP options? - //$im->setimageoption('webp', 'webp:lossless', $options['lossless'] ? 'true' : 'false'); - //$im->setimageoption('WEBP', 'method', strval($options['method'])); - - // It seems there is no COMPRESSION_WEBP... - // http://php.net/manual/en/imagick.setimagecompression.php - //$im->setImageCompression(Imagick::COMPRESSION_JPEG); - //$im->setImageCompression(Imagick::COMPRESSION_UNDEFINED); - - - - $im->setimageformat('WEBP'); - - if ($options['metadata'] == 'none') { - // Strip metadata and profiles - $im->stripImage(); - } - - // Ps: Imagick automatically uses same quality as source, when no quality is set - // This feature is however not present in Gmagick - $im->setcompressionquality($options['_calculated_quality']); - - //$success = $im->writeimagefile(fopen($destination, 'wb')); - $success = @file_put_contents($destination, $im->getImageBlob()); - - if (!$success) { - throw new ConverterFailedException('Failed writing file'); - } else { - //$logger->logLn('sooms we made it!'); - } - } -} - -?>readImage($source); - - // Throws an exception if iMagick does not support WebP conversion - if (!in_array('WEBP', $im->queryFormats())) { - throw new ConverterNotOperationalException('iMagick was compiled without WebP support.'); - } - - $options = array_merge(ConverterHelper::$defaultOptions, $options); - - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - $im->setImageFormat('WEBP'); - - /* - * More about iMagick's WebP options: - * http://www.imagemagick.org/script/webp.php - * https://developers.google.com/speed/webp/docs/cwebp - * https://stackoverflow.com/questions/37711492/imagemagick-specific-webp-calls-in-php - */ - - // TODO: We could easily support all webp options with a loop - - /* - After using getImageBlob() to write image, the following setOption() calls - makes settings makes imagick fail. So can't use those. But its a small price - to get a converter that actually makes great quality conversions. - - $im->setOption('webp:method', strval($options['method'])); - $im->setOption('webp:low-memory', strval($options['low-memory'])); - $im->setOption('webp:lossless', strval($options['lossless'])); - */ - - if ($options['metadata'] == 'none') { - // Strip metadata and profiles - $im->stripImage(); - } - - if (isset($options['_quality_could_not_be_detected'])) { - // quality was set to "auto", but we could not meassure the quality of the jpeg locally - // but luckily imagick is a big boy, and automatically converts with same quality as - // source, when the quality isn't set. - // So we simply do not set quality. - // This actually kills the max-height functionality. But I deem that this is more important - // because setting image quality to something higher than source generates bigger files, - // but gets you no extra quality. When failing to limit quality, you at least get something - // out of it - } else { - $im->setImageCompressionQuality($options['_calculated_quality']); - } - - - - // https://stackoverflow.com/questions/29171248/php-imagick-jpeg-optimization - // setImageFormat - - // TODO: Read up on - // https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/ - // https://github.com/nwtn/php-respimg - - // TODO: - // Should we set alpha channel for PNG's like suggested here: - // https://gauntface.com/blog/2014/09/02/webp-support-with-imagemagick-and-php ?? - // It seems that alpha channel works without... (at least I see completely transparerent pixels) - - // TODO: Check out other iMagick methods, see http://php.net/manual/de/imagick.writeimage.php#114714 - // 1. file_put_contents($destination, $im) - // 2. $im->writeImage($destination) - - // We used to use writeImageFile() method. But we now use getImageBlob(). See issue #43 - //$success = $im->writeImageFile(fopen($destination, 'wb')); - - $success = @file_put_contents($destination, $im->getImageBlob()); - - if (!$success) { - throw new ConverterFailedException('Failed writing file'); - } - } -} - -?> 0); - */ - - $command = 'convert -version'; - exec($command, $output, $returnCode); - $hasDelegate = false; - foreach ($output as $line) { - if (preg_match('/Delegate.*webp.*/i', $line)) { - return true; - } - } - return false; - } - - - public static function escapeFilename($string) - { - // Escaping whitespace - $string = preg_replace('/\s/', '\\ ', $string); - - // filter_var() is should normally be available, but it is not always - // - https://stackoverflow.com/questions/11735538/call-to-undefined-function-filter-var - if (function_exists('filter_var')) { - // Sanitize quotes - $string = filter_var($string, FILTER_SANITIZE_MAGIC_QUOTES); - - // Stripping control characters - // see https://stackoverflow.com/questions/12769462/filter-flag-strip-low-vs-filter-flag-strip-high - $string = filter_var($string, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - } - - return $string; - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if (!function_exists('exec')) { - throw new ConverterNotOperationalException('exec() is not enabled.'); - } - - if (!self::imagickInstalled()) { - throw new ConverterNotOperationalException('imagick is not installed'); - } - - if (!self::webPDelegateInstalled()) { - throw new ConverterNotOperationalException('webp delegate missing'); - } - - // Should we use "magick" or "convert" command? - // It seems they do the same. But which is best supported? Which is mostly available (whitelisted)? - // Should we perhaps try both? - // For now, we just go with "convert" - $command = 'convert ' . self::escapeFilename($source) . ' webp:' . self::escapeFilename($destination); - exec($command, $output, $returnCode); - - if ($returnCode == 127) { - throw new ConverterNotOperationalException('imagick is not installed'); - } - - if ($returnCode != 0) { - $logger->logLn('command:' . $command); - $logger->logLn('return code:' . $returnCode); - $logger->logLn('output:' . print_r(implode("\n", $output), true)); - - throw new ConverterNotOperationalException('The exec call failed'); - } - } -} - -?> 'api-version', /* Can currently be 0 or 1 */ - 'type' => 'number', - 'sensitive' => false, - 'default' => 0, - 'required' => false - ], - [ - 'name' => 'secret', /* only in api v.0 */ - 'type' => 'string', - 'sensitive' => true, - 'default' => 'my dog is white', - 'required' => false - ], - [ - 'name' => 'api-key', /* new in api v.1 (renamed 'secret' to 'api-key') */ - 'type' => 'string', - 'sensitive' => true, - 'default' => 'my dog is white', - 'required' => false - ], - [ - 'name' => 'url', - 'type' => 'string', - 'sensitive' => true, - 'default' => '', - 'required' => true - ], - [ - 'name' => 'crypt-api-key-in-transfer', /* new in api v.1 */ - 'type' => 'boolean', - 'sensitive' => false, - 'default' => false, - 'required' => false - ], - - /* - [ - 'name' => 'web-services', - 'type' => 'array', - 'sensitive' => true, - 'default' => [ - [ - 'label' => 'test', - 'api-key' => 'my dog is white', - 'url' => 'http://we0/wordpress/webp-express-server', - 'crypt-api-key-in-transfer' => true - ] - ], - 'required' => true - ], - */ - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('wpc', $source, $destination, $options, true); - } - - // Took this parser from Drupal - private static function parseSize($size) - { - - $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. - $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. - if ($unit) { - // Find the position of the unit in the ordered string which is the power - // of magnitude to multiply a kilobyte by. - return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); - } else { - return round($size); - } - } - - private static function createRandomSaltForBlowfish() - { - $salt = ''; - $validCharsForSalt = array_merge( - range('A', 'Z'), - range('a', 'z'), - range('0', '9'), - ['.', '/'] - ); - - for ($i=0; $i<22; $i++) { - $salt .= $validCharsForSalt[array_rand($validCharsForSalt)]; - } - return $salt; - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - - if (!extension_loaded('curl')) { - throw new ConverterNotOperationalException('Required cURL extension is not available.'); - } - - if (!function_exists('curl_init')) { - throw new ConverterNotOperationalException('Required url_init() function is not available.'); - } - - $apiVersion = $options['api-version']; - - if (!function_exists('curl_file_create')) { - throw new ConverterNotOperationalException( - 'Required curl_file_create() PHP function is not available (requires PHP > 5.5).' - ); - } - - if ($apiVersion == 0) { - if (!empty($options['secret'])) { - // if secret is set, we need md5() and md5_file() functions - if (!function_exists('md5')) { - throw new ConverterNotOperationalException( - 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . - 'contents. ' . - 'But the required md5() PHP function is not available.' - ); - } - if (!function_exists('md5_file')) { - throw new ConverterNotOperationalException( - 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . - 'contents. But the required md5_file() PHP function is not available.' - ); - } - } - } - - if ($apiVersion == 1) { - /* - if (count($options['web-services']) == 0) { - throw new ConverterNotOperationalException('No remote host has been set up'); - }*/ - } - - if ($options['url'] == '') { - throw new ConverterNotOperationalException( - 'Missing URL. You must install Webp Convert Cloud Service on a server, ' . - 'or the WebP Express plugin for Wordpress - and supply the url.' - ); - } - - $fileSize = @filesize($source); - if ($fileSize !== false) { - $uploadMaxSize = self::parseSize(ini_get('upload_max_filesize')); - if (($uploadMaxSize !== false) && ($uploadMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your max upload (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'upload_max_filesize in php.ini: ' . ini_get('upload_max_filesize') . - ' (parsed as ' . round($uploadMaxSize/1024) . ' kb)' - ); - } - - $postMaxSize = self::parseSize(ini_get('post_max_size')); - if (($postMaxSize !== false) && ($postMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your post_max_size limit (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'post_max_size in php.ini: ' . ini_get('post_max_size') . - ' (parsed as ' . round($postMaxSize/1024) . ' kb)' - ); - } - - // ini_get('memory_limit') - } - - // Got some code here: - // https://coderwall.com/p/v4ps1a/send-a-file-via-post-with-curl-and-php - - $ch = curl_init(); - if (!$ch) { - throw new ConverterNotOperationalException('Could not initialise cURL.'); - } - - $optionsToSend = $options; - - if (isset($options['_quality_could_not_be_detected'])) { - // quality was set to "auto", but we could not meassure the quality of the jpeg locally - // Ask the cloud service to do it, rather than using what we came up with. - $optionsToSend['quality'] = 'auto'; - } else { - $optionsToSend['quality'] = $options['_calculated_quality']; - } - - unset($optionsToSend['converters']); - unset($optionsToSend['secret']); - unset($optionsToSend['_quality_could_not_be_detected']); - unset($optionsToSend['_calculated_quality']); - - $postData = [ - 'file' => curl_file_create($source), - 'options' => json_encode($optionsToSend), - 'servername' => (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '') - ]; - - if ($apiVersion == 0) { - $postData['hash'] = md5(md5_file($source) . $options['secret']); - } - - if ($apiVersion == 1) { - $apiKey = $options['api-key']; - - if ($options['crypt-api-key-in-transfer']) { - if (CRYPT_BLOWFISH == 1) { - $salt = self::createRandomSaltForBlowfish(); - $postData['salt'] = $salt; - - // Strip off the first 28 characters (the first 6 are always "$2y$10$". The next 22 is the salt) - $postData['api-key-crypted'] = substr(crypt($apiKey, '$2y$10$' . $salt . '$'), 28); - } else { - if (!function_exists('crypt')) { - throw new ConverterNotOperationalException( - 'Configured to crypt the api-key, but crypt() function is not available.' - ); - } else { - throw new ConverterNotOperationalException( - 'Configured to crypt the api-key. ' . - 'That requires Blowfish encryption, which is not available on your current setup.' - ); - } - } - } else { - $postData['api-key'] = $apiKey; - } - } - - - // Try one host at the time - // TODO: shuffle the array first - /* - foreach ($options['web-services'] as $webService) { - - } - */ - - - curl_setopt_array($ch, [ - CURLOPT_URL => $options['url'], - CURLOPT_POST => 1, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ]); - - $response = curl_exec($ch); - if (curl_errno($ch)) { - throw new ConverterNotOperationalException('Curl error:' . curl_error($ch)); - } - - // Check if we got a 404 - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($httpCode == 404) { - curl_close($ch); - throw new ConverterFailedException( - 'WPC was not found at the specified URL - we got a 404 response.' - ); - } - - // The WPC cloud service either returns an image or an error message - // Images has application/octet-stream. - // Verify that we got an image back. - if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { - curl_close($ch); - - if (substr($response, 0, 1) == '{') { - $responseObj = json_decode($response, true); - if (isset($responseObj['errorCode'])) { - switch ($responseObj['errorCode']) { - case 0: - throw new ConverterFailedException( - 'There are problems with the server setup: "' . - $responseObj['errorMessage'] . '"' - ); - case 1: - throw new ConverterFailedException( - 'Access denied. ' . $responseObj['errorMessage'] - ); - default: - throw new ConverterFailedException( - 'Conversion failed: "' . $responseObj['errorMessage'] . '"' - ); - } - } - } - - // WPC 0.1 returns 'failed![error messag]' when conversion fails. Handle that. - if (substr($response, 0, 7) == 'failed!') { - throw new ConverterFailedException( - 'WPC failed converting image: "' . substr($response, 7) . '"' - ); - } - - if (empty($response)) { - $errorMsg = 'Error: Unexpected result. We got nothing back. HTTP CODE: ' . $httpCode; - throw new ConverterFailedException($errorMsg); - } else { - $errorMsg = 'Error: Unexpected result. We did not receive an image. We received: "'; - $errorMsg .= str_replace("\r", '', str_replace("\n", '', htmlentities(substr($response, 0, 400)))); - throw new ConverterFailedException($errorMsg . '..."'); - } - //throw new ConverterNotOperationalException($response); - } - - $success = @file_put_contents($destination, $response); - curl_close($ch); - - if (!$success) { - throw new ConverterFailedException('Error saving file. Check file permissions'); - } - /* - $curlOptions = [ - 'api_key' => $options['key'], - 'webp' => '1', - 'file' => curl_file_create($source), - 'domain' => $_SERVER['HTTP_HOST'], - 'quality' => $options['quality'], - 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') - ]; - - curl_setopt_array($ch, [ - CURLOPT_URL => "https://optimize.exactlywww.com/v2/", - CURLOPT_HTTPHEADER => [ - 'User-Agent: WebPConvert', - 'Accept: image/*' - ], - CURLOPT_POSTFIELDS => $curlOptions, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ]);*/ - } -} - -?>entries[] = [$msg, $style]; - } - - public function ln() - { - $this->entries[] = ''; - } - - public function getHtml() - { - $html = ''; - foreach ($this->entries as $entry) { - if ($entry == '') { - $html .= '
'; - } else { - list($msg, $style) = $entry; - $msg = htmlspecialchars($msg); - if ($style == 'bold') { - $html .= '' . $msg . ''; - } elseif ($style == 'italic') { - $html .= '' . $msg . ''; - } else { - $html .= $msg; - } - } - } - return $html; - } - - public function getText($newLineChar = ' ') - { - $text = ''; - foreach ($this->entries as $entry) { - if ($entry == '') { - if (substr($text, -2) != '. ') { - $text .= '. '; - } - } else { - list($msg, $style) = $entry; - $text .= $msg; - } - } - - return $text; - } -} - -?>' . $msg . ''; - } elseif ($style == 'italic') { - echo '' . $msg . ''; - } else { - echo $msg; - } - } - - public function ln() - { - echo '
'; - } -} - -?> &$converterOptions) { - $className = ConverterHelper::getClassNameOfConverter($converterName); - - // (pstt: the isset check is needed in order to work with WebPConvert v1.0) - if (isset($className::$extraOptions)) { - foreach ($className::$extraOptions as $extraOption) { - if ($extraOption['sensitive']) { - if (isset($converterOptions[$extraOption['name']])) { - $converterOptions[$extraOption['name']] = '*******'; - } - } - } - } - } - } - } - return $printable_options; - } - - public static function getPrintableOptionsAsString($options, $glue = '. ') - { - $optionsForPrint = []; - foreach (self::getPrintableOptions($options) as $optionName => $optionValue) { - $printValue = ''; - if ($optionName == 'converter-options') { - $converterNames = []; - $extraConvertOptions = $optionValue; - //print_r($extraConvertOptions); - /* - foreach ($optionValue as $converterName => $converterOptions) { - - if (is_array($converter)) { - $converterName = $converter['converter']; - if (isset($converter['options'])) { - $extraConvertOptions[$converter['converter']] = $converter['options']; - } - } else { - $converterName = $converter; - } - $converterNames[] = $converterName; - }*/ - $glueMe = []; - foreach ($extraConvertOptions as $converter => $extraOptions) { - $opt = []; - foreach ($extraOptions as $oName => $oValue) { - $opt[] = $oName . ':"' . $oValue . '"'; - } - $glueMe[] = '(' . $converter . ': (' . implode($opt, ', ') . '))'; - } - $printValue = implode(',', $glueMe); - } else { - switch (gettype($optionValue)) { - case 'boolean': - if ($optionValue === true) { - $printValue = 'true'; - } elseif ($optionValue === false) { - $printValue = 'false'; - } - break; - case 'string': - $printValue = '"' . $optionValue . '"'; - break; - case 'array': - $printValue = implode(', ', $optionValue); - break; - case 'integer': - $printValue = $optionValue; - break; - default: - $printValue = $optionValue; - } - } - $optionsForPrint[] = $optionName . ': ' . $printValue; - } - return implode($glue, $optionsForPrint); - } - - public static function convertAndReport($source, $destination, $options) - { - ?> - - - - - - - - - - - - - -
source:
destination:
options: - click to see - - - -
-
- getMessage(); - - echo '' . $msg . ''; - exit; - } - - if ($success) { - //echo 'ok'; - } else { - echo 'Conversion failed. None of the tried converters are operational'; - } - ?> - - - options['add-x-header-options']) { - $this->header('X-WebP-Convert-Options:' . Report::getPrintableOptionsAsString($this->options)); - } - } - - private function addHeadersPreventingCaching() - { - $this->header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); - $this->header("Cache-Control: post-check=0, pre-check=0", false); - $this->header("Pragma: no-cache"); - } - - public function serve404() - { - $protocol = isset($_SERVER["SERVER_PROTOCOL"]) ? $_SERVER["SERVER_PROTOCOL"] : 'HTTP/1.0'; - $this->header($protocol . " 404 Not Found"); - } - - public function serveOriginal() - { - if (!$this->callAboutToServeImageCallBack('source')) { - return true; // we shall not trigger the fail callback - } - - if ($this->options['add-content-type-header']) { - $arr = explode('.', $this->source); - $ext = array_pop($arr); - switch (strtolower($ext)) { - case 'jpg': - case 'jpeg': - $this->header('Content-type: image/jpeg'); - break; - case 'png': - $this->header('Content-type: image/png'); - break; - } - } - - $this->addVaryHeader(); - - if ($this->whyServingThis == 'source-lighter') { - $this->addCacheControlHeader(); - } else { - $this->addHeadersPreventingCaching(); - } - - if (@readfile($this->source) === false) { - $this->header('X-WebP-Convert: Could not read file'); - return false; - } - return true; - } - - public function serveFreshlyConverted() - { - - $criticalFail = false; - $success = false; - $bufferLogger = new BufferLogger(); - - try { - $success = WebPConvert::convert($this->source, $this->destination, $this->options, $bufferLogger); - - if ($success) { - // Serve source if it is smaller than destination - $filesizeDestination = @filesize($this->destination); - $filesizeSource = @filesize($this->source); - if (($filesizeSource !== false) && - ($filesizeDestination !== false) && - ($filesizeDestination > $filesizeSource)) { - $this->whatToServe = 'original'; - $this->whyServingThis = 'source-lighter'; - return $this->serveOriginal(); - } - - if (!$this->callAboutToServeImageCallBack('fresh-conversion')) { - return; - } - - if ($this->options['add-content-type-header']) { - $this->header('Content-type: image/webp'); - } - if ($this->whyServingThis == 'explicitly-told-to') { - $this->addXStatusHeader( - 'Serving freshly converted image (was explicitly told to reconvert)' - ); - } elseif ($this->whyServingThis == 'source-modified') { - $this->addXStatusHeader( - 'Serving freshly converted image (the original had changed)' - ); - } elseif ($this->whyServingThis == 'no-existing') { - $this->addXStatusHeader( - 'Serving freshly converted image (there were no existing to serve)' - ); - } else { - $this->addXStatusHeader( - 'Serving freshly converted image (dont know why!)' - ); - } - - if ($this->options['add-vary-header']) { - $this->header('Vary: Accept'); - } - - if ($this->whyServingThis == 'no-existing') { - $this->addCacheControlHeader(); - } else { - $this->addHeadersPreventingCaching(); - } - - // Should we add Content-Length header? - // $this->header('Content-Length: ' . filesize($file)); - if (@readfile($this->destination)) { - return true; - } else { - $this->fail('Error', 'could not read the freshly converted file'); - return false; - } - } else { - $description = 'No converters are operational'; - $msg = ''; - } - } catch (\WebPConvert\Exceptions\InvalidFileExtensionException $e) { - $criticalFail = true; - $description = 'Invalid file extension'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\TargetNotFoundException $e) { - $criticalFail = true; - $description = 'Source file not found'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Converters\Exceptions\ConverterFailedException $e) { - // No converters could convert the image. At least one converter failed, even though it appears to be - // operational - $description = 'No converters could convert the image'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - // (no converters could convert the image. At least one converter declined - $description = 'No converters could/wanted to convert the image'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\ConverterNotFoundException $e) { - $description = 'A converter was not found!'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\CreateDestinationFileException $e) { - $description = 'Cannot create destination file'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\CreateDestinationFolderException $e) { - $description = 'Cannot create destination folder'; - $msg = $e->getMessage(); - } catch (\Exception $e) { - $description = 'An unanticipated exception was thrown'; - $msg = $e->getMessage(); - } - - // Next line is commented out, because we need to be absolute sure that the details does not violate syntax - // We could either try to filter it, or we could change WebPConvert, such that it only provides safe texts. - // $this->header('X-WebP-Convert-And-Serve-Details: ' . $bufferLogger->getText()); - - $this->fail('Conversion failed', $description, $criticalFail); - return false; - //echo '

This is how conversion process went:

' . $bufferLogger->getHtml(); - } - - protected function serveErrorMessageImage($msg) - { - // Generate image containing error message - if ($this->options['add-content-type-header']) { - $this->header('Content-type: image/gif'); - } - - // TODO: handle if this fails... - $image = imagecreatetruecolor(620, 200); - imagestring($image, 1, 5, 5, $msg, imagecolorallocate($image, 233, 214, 291)); - // echo imagewebp($image); - echo imagegif($image); - imagedestroy($image); - } - - protected function fail($title, $description, $critical = false) - { - $action = $critical ? $this->options['fail-when-original-unavailable'] : $this->options['fail']; - - if (isset($this->options['aboutToPerformFailActionCallback'])) { - if (call_user_func( - $this->options['aboutToPerformFailActionCallback'], - $title, - $description, - $action, - $this - ) === false) { - return; - } - } - - $this->addXStatusHeader('Failed (' . $description . ')'); - - $this->addHeadersPreventingCaching(); - - - $title = 'Conversion failed'; - switch ($action) { - case 'serve-original': - if (!$this->serveOriginal()) { - $this->serve404(); - }; - break; - case '404': - $this->serve404(); - break; - case 'report-as-image': - // todo: handle if this fails - self::serveErrorMessageImage($title . '. ' . $description); - break; - case 'report': - echo '

' . $title . '

' . $description; - break; - } - } - - protected function criticalFail($title, $description) - { - return $this->fail($title, $description, true); - } - - /** - * Serve the thing specified in $whatToServe and $whyServingThis - * These are first set my the decideWhatToServe() method, but may later change, if a fresh - * conversion is made - */ - public function serve() - { - - //$this->addXOptionsHeader(); - - switch ($this->whatToServe) { - case 'destination': - return $this->serveExisting(); - case 'source': - if ($this->whyServingThis == 'explicitly-told-to') { - $this->addXStatusHeader( - 'Serving original image (was explicitly told to)' - ); - } else { - $this->addXStatusHeader( - 'Serving original image (it is smaller than the already converted)' - ); - } - if (!$this->serveOriginal()) { - $this->criticalFail('Error', 'could not serve original'); - return false; - } - return true; - case 'fresh-conversion': - return $this->serveFreshlyConverted(); - break; - case 'critical-fail': - $this->criticalFail('Error', $this->whyServingThis); - return false; - case 'fail': - $this->fail('Error', $this->whyServingThis); - return false; - case 'report': - $this->addXStatusHeader('Reporting...'); - Report::convertAndReport($this->source, $this->destination, $this->options); - return true; // yeah, lets say that a report is always a success, even if conversion is a failure - } - } - - public function decideWhatToServeAndServeIt() - { - $this->decideWhatToServe(); - return $this->serve(); - } - - /** - * Main method - */ - public static function serveConverted($source, $destination, $options) - { - if (isset($options['fail']) && ($options['fail'] == 'original')) { - $options['fail'] = 'serve-original'; - } - // For backward compatability: - if (isset($options['critical-fail']) && !isset($options['fail-when-original-unavailable'])) { - $options['fail-when-original-unavailable'] = $options['critical-fail']; - } - - $cs = new static($source, $destination, $options); - - return $cs->decideWhatToServeAndServeIt(); - } -} - diff --git a/composer-php56.json b/composer-php56.json new file mode 100644 index 00000000..3a767c95 --- /dev/null +++ b/composer-php56.json @@ -0,0 +1,75 @@ +{ + "name": "rosell-dk/webp-convert", + "description": "Convert JPEG & PNG to WebP with PHP", + "type": "library", + "license": "MIT", + "keywords": ["webp", "images", "cwebp", "imagick", "gd", "jpg2webp", "png2webp", "jpg", "png", "image conversion"], + "scripts": { + "ci": [ + "@test", + "@phpcs-all", + "@composer validate --no-check-all --strict", + "@phpstan-global" + ], + "test": "phpunit --coverage-text", + "phpunit": "phpunit --coverage-text", + "test-no-cov": "phpunit --no-coverage", + "cs-fix-all": [ + "php-cs-fixer fix src" + ], + "cs-fix": "php-cs-fixer fix", + "cs-dry": "php-cs-fixer fix --dry-run --diff", + "phpcs": "phpcs --standard=PSR2", + "phpcs-all": "phpcs --standard=PSR2 src", + "phpcbf": "phpcbf --standard=PSR2", + "phpstan": "vendor/bin/phpstan analyse src --level=4", + "phpstan-global-old": "~/.composer/vendor/bin/phpstan analyse src --level=4", + "phpstan-global": "~/.config/composer/vendor/bin/phpstan analyse src --level=4" + }, + "extra": { + "scripts-descriptions": { + "ci": "Run tests before CI", + "phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'", + "phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'", + "cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard", + "cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.", + "test": "Launches the preconfigured PHPUnit" + } + }, + "autoload": { + "psr-4": { "WebPConvert\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "WebPConvert\\Tests\\": "tests/" } + }, + "authors": [ + { + "name": "Bjørn Rosell", + "homepage": "https://www.bitwise-it.dk/contact", + "role": "Project Author" + }, + { + "name": "Martin Folkers", + "homepage": "https://twobrain.io", + "role": "Collaborator" + } + ], + "require": { + "php": "^5.6", + "rosell-dk/image-mime-type-guesser": "^0.3" + }, + "suggest": { + "ext-gd": "to use GD extension for converting. Note: Gd must be compiled with webp support", + "ext-imagick": "to use Imagick extension for converting. Note: Gd must be compiled with webp support", + "ext-vips": "to use Vips extension for converting.", + "php-stan/php-stan": "Suggested for dev, in order to analyse code before committing" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.11", + "phpunit/phpunit": "5.7.27", + "squizlabs/php_codesniffer": "3.*" + }, + "config": { + "sort-packages": true + } +} diff --git a/composer-php72.json b/composer-php72.json new file mode 100644 index 00000000..2135779e --- /dev/null +++ b/composer-php72.json @@ -0,0 +1,75 @@ +{ + "name": "rosell-dk/webp-convert", + "description": "Convert JPEG & PNG to WebP with PHP", + "type": "library", + "license": "MIT", + "keywords": ["webp", "images", "cwebp", "imagick", "gd", "jpg2webp", "png2webp", "jpg", "png", "image conversion"], + "scripts": { + "ci": [ + "@test", + "@phpcs-all", + "@composer validate --no-check-all --strict", + "@phpstan-global" + ], + "test": "phpunit --coverage-text", + "phpunit": "phpunit --coverage-text", + "test-no-cov": "phpunit --no-coverage", + "cs-fix-all": [ + "php-cs-fixer fix src" + ], + "cs-fix": "php-cs-fixer fix", + "cs-dry": "php-cs-fixer fix --dry-run --diff", + "phpcs": "phpcs --standard=PSR2", + "phpcs-all": "phpcs --standard=PSR2 src", + "phpcbf": "phpcbf --standard=PSR2", + "phpstan": "vendor/bin/phpstan analyse src --level=4", + "phpstan-global-old": "~/.composer/vendor/bin/phpstan analyse src --level=4", + "phpstan-global": "~/.config/composer/vendor/bin/phpstan analyse src --level=4" + }, + "extra": { + "scripts-descriptions": { + "ci": "Run tests before CI", + "phpcs": "Checks coding styles (PSR2) of file/dir, which you must supply. To check all, supply 'src'", + "phpcbf": "Fix coding styles (PSR2) of file/dir, which you must supply. To fix all, supply 'src'", + "cs-fix-all": "Fix the coding style of all the source files, to comply with the PSR-2 coding standard", + "cs-fix": "Fix the coding style of a PHP file or directory, which you must specify.", + "test": "Launches the preconfigured PHPUnit" + } + }, + "autoload": { + "psr-4": { "WebPConvert\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "WebPConvert\\Tests\\": "tests/" } + }, + "authors": [ + { + "name": "Bjørn Rosell", + "homepage": "https://www.bitwise-it.dk/contact", + "role": "Project Author" + }, + { + "name": "Martin Folkers", + "homepage": "https://twobrain.io", + "role": "Collaborator" + } + ], + "require": { + "php": "^7.2", + "rosell-dk/image-mime-type-guesser": "^0.3" + }, + "suggest": { + "ext-gd": "to use GD extension for converting. Note: Gd must be compiled with webp support", + "ext-imagick": "to use Imagick extension for converting. Note: Gd must be compiled with webp support", + "ext-vips": "to use Vips extension for converting.", + "php-stan/php-stan": "Suggested for dev, in order to analyse code before committing" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.11", + "phpunit/phpunit": "^8.0", + "squizlabs/php_codesniffer": "3.*" + }, + "config": { + "sort-packages": true + } +} diff --git a/composer.json b/composer.json index c10b3106..bb4aa48a 100644 --- a/composer.json +++ b/composer.json @@ -6,26 +6,28 @@ "keywords": ["webp", "images", "cwebp", "imagick", "gd", "jpg2webp", "png2webp", "jpg", "png", "image conversion"], "scripts": { "ci": [ - "@build", "@test", "@phpcs-all", - "@composer validate --no-check-all --strict" - ], - "build": [ - "@build-wod", - "@build-require-all" + "@composer validate --no-check-all --strict", + "@phpstan" ], + "phpunit": "phpunit --coverage-text", + "test": "phpunit --coverage-text=build/coverage.txt --coverage-clover=build/coverage.clover --coverage-html=build/coverage --whitelist=src tests", + "test-41": "phpunit --no-coverage --configuration 'phpunit-41.xml.dist'", + "test-with-coverage": "phpunit --coverage-text --configuration 'phpunit-with-coverage.xml.dist'", + "test-41-with-coverage": "phpunit --coverage-text --configuration 'phpunit-41.xml.dist'", + "test-no-cov": "phpunit --no-coverage tests", "cs-fix-all": [ "php-cs-fixer fix src" ], "cs-fix": "php-cs-fixer fix", "cs-dry": "php-cs-fixer fix --dry-run --diff", - "test": "phpunit", - "phpcs": "phpcs --standard=PSR2", - "phpcs-all": "phpcs --standard=PSR2 src", + "phpcs": "phpcs --standard=phpcs-ruleset.xml", + "phpcs-all": "phpcs --standard=phpcs-ruleset.xml src", "phpcbf": "phpcbf --standard=PSR2", - "build-wod": "php build-scripts/build-webp-on-demand.php", - "build-require-all": "php build-scripts/generate-require-all.php" + "phpstan": "vendor/bin/phpstan analyse src --level=4", + "phpstan-global-old": "~/.composer/vendor/bin/phpstan analyse src --level=4", + "phpstan-global": "~/.config/composer/vendor/bin/phpstan analyse src --level=4" }, "extra": { "scripts-descriptions": { @@ -55,10 +57,23 @@ "role": "Collaborator" } ], + "require": { + "php": "^5.6 | ^7.0 | ^8.0", + "rosell-dk/exec-with-fallback": "^1.2.0", + "rosell-dk/image-mime-type-guesser": "^1.1.1", + "rosell-dk/locate-binaries": "^1.0" + }, + "suggest": { + "ext-gd": "to use GD extension for converting. Note: Gd must be compiled with webp support", + "ext-imagick": "to use Imagick extension for converting. Note: Gd must be compiled with webp support", + "ext-vips": "to use Vips extension for converting." + }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.11", - "phpunit/phpunit": "5.7.27", - "squizlabs/php_codesniffer": "3.*" + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "3.*", + "phpstan/phpstan": "^1.10" + }, "config": { "sort-packages": true diff --git a/docs/development.md b/docs/development.md index 62618577..169a1b4b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,18 +14,26 @@ Then install the dev tools with composer: composer install ``` +If you don't have composer yet: +- Get it ([download phar](https://getcomposer.org/composer.phar) and move it to /usr/local/bin/composer) +- PS: PHPUnit requires php-xml, php-mbstring and php-curl. To install: `sudo apt install php-xml php-mbstring curl php-curl` + + ## Unit Testing To run all the unit tests do this: ``` composer test ``` +This also runs tests on the builds. + Individual test files can be executed like this: ``` -composer test tests/Converters/WPCTest -composer test tests/Serve/ServeConvertedTest +composer phpunit tests/Convert/Converters/WPCTest.php +composer phpunit tests/Serve/ServeConvertedTest.php ``` + ## Coding styles WebPConvert complies with the [PSR-2](https://www.php-fig.org/psr/psr-2/) coding standard. @@ -45,7 +53,27 @@ composer cs-fix ``` ## Running all tests in one command -The following script runs the unit tests, checks the coding styles and validates composer.json. Run this before pushing to github +The following script runs the unit tests, checks the coding styles, validates `composer.json` and runs the builds. +Run this before pushing anything to github. "ci" btw stands for *continuous integration*. ``` composer ci ``` + +## Generating api docs +Install phpdox and run it in the project root: +``` +phpdox +``` + +## Committing +Before committing, first make sure to: +- run `composer ci` + +## Releasing +Before releasing: +- Update the version number in `Converters/AbstractConverter.php` (search for "WebP Convert") +- Make sure that ci build is successful + +When releasing: +- update the [webp-convert-concat](https://github.com/rosell-dk/webp-convert-concat) library +- consider updating the require in the composer file in libraries that uses webp-convert (ie `webp-convert-cloud-service` and `webp-express`) diff --git a/docs/converters.md b/docs/v1.3/converting/convert-options.md similarity index 66% rename from docs/converters.md rename to docs/v1.3/converting/convert-options.md index 0503a783..0dd79deb 100644 --- a/docs/converters.md +++ b/docs/v1.3/converting/convert-options.md @@ -9,26 +9,45 @@ Speed-wise, there is too little difference for it to matter, considering that im Of course, as we here have to call a binary directly, *cwebp* requires the *exec* function to be enabled, and that the webserver user is allowed to execute the `cwebp` binary (either at known system locations, or one of the precompiled binaries, that comes with this library). -[`imagick`](#imagick) does not support any special webp options, but is at least able to strip all metadata, if metadata is set to none. Imagick has a very nice feature - that it is able to detect the quality of a jpeg file. This enables it to automatically use same quality for destination as for source, which eliminates the risk of setting quality higher for the destination than for source (the result of that is that the file size gets higher, but the quality remains the same). As the other converters lends this capability from Imagick, this is however no reason for using Imagick rather than the other converters. +[`vips`](#vips) (**new in 2.0**) works by using the vips extension, if available. Vips is great! It offers many webp options, it is fast and installation is easier than imagick and gd, as it does not need to be configured for webp support. -[`gmagick`](#gmagick) uses the *gmagick* extension. It is very similar to *imagick*. +[`imagick`](#imagick) does not support any special webp options, but is at least able to strip all metadata, if metadata is set to none. Imagick has a very nice feature - that it is able to detect the quality of a jpeg file. This enables it to automatically use same quality for destination as for source, which eliminates the risk of setting quality higher for the destination than for source (the result of that is that the file size gets higher, but the quality remains the same). As the other converters lends this capability from Imagick, this is however no reason for using Imagick rather than the other converters. Requirements: Imagick PHP extension compiled with WebP support -[`gd`](#gd) uses the *Gd* extension to do the conversion. The *Gd* extension is pretty common, so the main feature of this converter is that it may work out of the box. It does not support any webp options, and does not support stripping metadata. +[`gmagick`](#gmagick) uses the *gmagick* extension. It is very similar to *imagick*. Requirements: Gmagick PHP extension compiled with WebP support. -[`wpc`](#wpc) is an open source cloud service for converting images to webp. To use it, you must either install [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) directly on a remote server, or install the Wordpress plugin, [WebP Express](https://github.com/rosell-dk/webp-express) in Wordpress. Btw: Beware that upload limits will prevent conversion of big images. The converter checks your *php.ini* settings and abandons upload right away, if an image is larger than your *upload_max_filesize* or your *post_max_size* setting. +[`gd`](#gd) uses the *Gd* extension to do the conversion. The *Gd* extension is pretty common, so the main feature of this converter is that it may work out of the box. It does not support any webp options, and does not support stripping metadata. Requirements: GD PHP extension compiled with WebP support. + +[`wpc`](#wpc) is an open source cloud service for converting images to webp. To use it, you must either install [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) directly on a remote server, or install the Wordpress plugin, [WebP Express](https://github.com/rosell-dk/webp-express) in Wordpress. Btw: Beware that upload limits will prevent conversion of big images. The converter checks your *php.ini* settings and abandons upload right away, if an image is larger than your *upload_max_filesize* or your *post_max_size* setting. Requirements: Access to a running service. The service can be installed [directly](https://github.com/rosell-dk/webp-convert-cloud-service) or by using [this Wordpress plugin](https://wordpress.org/plugins/webp-express/) + +[`ewww`](#ewww) is also a cloud service. Not free, but cheap enough to be considered *practically* free. It supports lossless encoding, but this cannot be controlled. *Ewww* always uses lossy encoding for jpeg and lossless for png. For jpegs this is usually a good choice, however, many pngs are compressed better using lossy encoding. As lossless cannot be controlled, the "lossless:auto" option cannot be used for automatically trying both lossy and lossless and picking the smallest file. Also, unfortunately, *ewww* does not support quality=auto, like *wpc*, and it does not support *size-in-percentage* like *cwebp*, either. I have requested such features, and he is considering... As with *wpc*, beware of upload limits. Requirements: A key to the *EWWW Image Optimizer* cloud service. Can be purchaced [here](https://ewww.io/plans/) + +[`stack`](#stack) takes a stack of converters and tries it from the top, until success. The main convert method actually calls this converter. Stacks within stacks are supported (not really needed, though). -[`ewww`](#ewww) is also a cloud service. Not free, but cheap enough to be considered *practically* free. It produces webp files a bit smalle than the rest. It seems to produce same size as *cwebp*, when method is set to 3. Unfortunately, *ewww* does not support quality=auto, like *wpc*, and it does not support *size-in-percentage* like *cwebp*, either. I have requested such features, and he is considering... As with *wpc*, beware of upload limits. **Summary:** +| | cwebp | vips | imagick / gmagick | imagickbinary | gd | ewww | +| ------------------------------------------ | --------- | ------ | ----------------- | ------------- | --------- | ------ | +| supports lossless encoding ? | yes | yes | no | no | no | yes | +| supports lossless auto ? | yes | yes | no | no | no | no | +| supports near-lossless ? | yes | yes | no | no | no | ? | +| supports metadata stripping / preserving | yes | yes | yes | no | no | ? | +| supports setting alpha quality | no | yes | no | no | no | no | +| supports fixed quality (for lossy) | yes | yes | yes | yes | yes | yes | +| supports auto quality without help | no | no | yes | yes | no | no | + + + *WebPConvert* currently supports the following converters: | Converter | Method | Requirements | | ------------------------------------ | ------------------------------------------------ | -------------------------------------------------- | | [`cwebp`](#cwebp) | Calls `cwebp` binary directly | `exec()` function *and* that the webserver user has permission to run `cwebp` binary | +| [`vips`](#vips) (new in 2.0) | Vips extension | Vips extension | | [`imagick`](#imagick) | Imagick extension (`ImageMagick` wrapper) | Imagick PHP extension compiled with WebP support | | [`gmagick`](#gmagick) | Gmagick extension (`ImageMagick` wrapper) | Gmagick PHP extension compiled with WebP support | | [`gd`](#gd) | GD Graphics (Draw) extension (`LibGD` wrapper) | GD PHP extension compiled with WebP support | +| [`imagickbinary`](#imagickbinary) | Calls imagick binary directly | exec() and imagick installed and compiled with WebP support | | [`wpc`](#wpc) | Connects to an open source cloud service | Access to a running service. The service can be installed [directly](https://github.com/rosell-dk/webp-convert-cloud-service) or by using [this Wordpress plugin](https://wordpress.org/plugins/webp-express/). | [`ewww`](#ewww) | Connects to *EWWW Image Optimizer* cloud service | Purchasing a key | @@ -41,8 +60,8 @@ Instructions regarding getting the individual converters to work are [on the wik Performance~40-120ms to convert a 40kb image (depending on *method* option) ReliabilityNo problems detected so far! AvailabilityAccording to ewww docs, requirements are met on surprisingly many webhosts. Look here for a list - General options supportedAll (`quality`, `metadata`, `method`, `low-memory`, `lossless`) - Extra options`use-nice` (boolean)
`try-common-system-paths` (boolean)
`try-supplied-binary-for-os` (boolean)
`autofilter` (boolean)
`size-in-percentage` (number / null)
`command-line-options` (string) + General options supportedAll (`quality`, `metadata`, `lossless`) + Extra options`method` (0-6)
`use-nice` (boolean)
`try-common-system-paths` (boolean)
`try-supplied-binary-for-os` (boolean)
`autofilter` (boolean)
`size-in-percentage` (number / null)
`command-line-options` (string)
`low-memory` (boolean) [cwebp](https://developers.google.com/speed/webp/docs/cwebp) is a WebP conversion command line converter released by Google. Our implementation ships with precompiled binaries for Linux, FreeBSD, WinNT, Darwin and SunOS. If however a cwebp binary is found in a usual location, that binary will be preferred. It is executed with [exec()](http://php.net/manual/en/function.exec.php). @@ -54,26 +73,68 @@ In more detail, the implementation does this: - If [`nice`]( https://en.wikipedia.org/wiki/Nice_(Unix)) command is found on host, binary is executed with low priority in order to save system resources - Permissions of the generated file are set to be the same as parent folder -#### The `method` option -This parameter controls the trade off between encoding speed and the compressed file size and quality. Possible values range from 0 to 6. 0 is fastest. 6 results in best quality. +### Cwebp options -#### The `size-in-percentage` option -This option sets the file size, *cwebp* should aim for, in percentage of the original. If you for example set it to *45*, and the source file is 100 kb, *cwebp* will try to create a file with size 45 kb (we use the `-size` option). This is an excellent alternative to the "quality:auto" option. If the quality detection isn't working on your system (and you do not have the rights to install imagick or gmagick), you should consider using this options instead. *Cwebp* is generally able to create webp files with the same quality at about 45% the size. So *45* would be a good choice. The option overrides the quality option. And note that it slows down the conversion - it takes about 2.5 times longer to do a conversion this way, than when quality is specified. Default is *off* (null) +The following options are supported, besides the general options (such as quality, lossless etc): + +| Option | Type | Default | +| -------------------------- | ------------------------- | -------------------------- | +| autofilter | boolean | false | +| command-line-options | string | '' | +| low-memory | boolean | false | +| method | integer (0-6) | 6 | +| near-lossless | integer (0-100) | 60 | +| size-in-percentage | integer (0-100) (or null) | null | +| rel-path-to-precompiled-binaries | string | './Binaries' | +| size-in-percentage | number (or null) | is_null | +| try-common-system-paths | boolean | true | +| try-supplied-binary-for-os | boolean | true | +| use-nice | boolean | false | + +Descriptions (only of some of the options): #### the `autofilter` option Turns auto-filter on. This algorithm will spend additional time optimizing the filtering strength to reach a well-balanced quality. Unfortunately, it is extremely expensive in terms of computation. It takes about 5-10 times longer to do a conversion. A 1MB picture which perhaps typically takes about 2 seconds to convert, will takes about 15 seconds to convert with auto-filter. So in most cases, you will want to leave this at its default, which is off. -#### the `low-memory` option -Reduce memory usage of lossy encoding at the cost of ~30% longer encoding time and marginally larger output size. Default: `false`. Read more in [the docs](https://developers.google.com/speed/webp/docs/cwebp) - #### the `command-line-options` option This allows you to set any parameter available for cwebp in the same way as you would do when executing *cwebp*. You could ie set it to "-sharpness 5 -mt -crop 10 10 40 40". Read more about all the available parameters in [the docs](https://developers.google.com/speed/webp/docs/cwebp) +#### the `low-memory` option +Reduce memory usage of lossy encoding at the cost of ~30% longer encoding time and marginally larger output size. Default: `false`. Read more in [the docs](https://developers.google.com/speed/webp/docs/cwebp). Default: *false* + +#### The `method` option +This parameter controls the trade off between encoding speed and the compressed file size and quality. Possible values range from 0 to 6. 0 is fastest. 6 results in best quality. + +#### the `near-lossless` option +Specify the level of near-lossless image preprocessing. This option adjusts pixel values to help compressibility, but has minimal impact on the visual quality. It triggers lossless compression mode automatically. The range is 0 (maximum preprocessing) to 100 (no preprocessing). The typical value is around 60. Read more [here](https://groups.google.com/a/webmproject.org/forum/#!topic/webp-discuss/0GmxDmlexek). Default: 60 + +#### The `size-in-percentage` option +This option sets the file size, *cwebp* should aim for, in percentage of the original. If you for example set it to *45*, and the source file is 100 kb, *cwebp* will try to create a file with size 45 kb (we use the `-size` option). This is an excellent alternative to the "quality:auto" option. If the quality detection isn't working on your system (and you do not have the rights to install imagick or gmagick), you should consider using this options instead. *Cwebp* is generally able to create webp files with the same quality at about 45% the size. So *45* would be a good choice. The option overrides the quality option. And note that it slows down the conversion - it takes about 2.5 times longer to do a conversion this way, than when quality is specified. Default is *off* (null) + + #### final words on cwebp The implementation is based on the work of Shane Bishop for his plugin, [EWWW Image Optimizer](https://ewww.io). Thanks for letting us do that! See [the wiki](https://github.com/rosell-dk/webp-convert/wiki/Installing-cwebp---using-official-precompilations) for instructions regarding installing cwebp or using official precompilations. +## vips + + + + + + + +
RequirementsVips extension
PerformanceGreat
ReliabilityNo problems detected so far!
AvailabilityNot that widespread yet, but gaining popularity
General options supportedAll (`quality`, `metadata`, `lossless`)
Extra options`smart-subsample`(boolean)
`alpha-quality`(0-100)
`near-lossless` (0-100)
`preset` (0-6)
+ +For installation instructions, go [here](https://github.com/libvips/php-vips-ext). + +The options are described [here](https://jcupitt.github.io/libvips/API/current/VipsForeignSave.html#vips-webpsave) + +*near-lossless* is however an integer (0-100), in order to have the option behave like in cwebp. + + + ## wpc *WebPConvert Cloud Service* @@ -82,7 +143,7 @@ See [the wiki](https://github.com/rosell-dk/webp-convert/wiki/Installing-cwebp-- PerformanceDepends on the server where [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) is set up, and the speed of internet connections. But perhaps ~1000ms to convert a 40kb image ReliabilityGreat (depends on the reliability on the server where it is set up) AvailabilityShould work on almost any webhost - General options supportedAll (`quality`, `metadata`, `method`, `low-memory`, `lossless`) + General options supportedAll (`quality`, `metadata`, `lossless`) Extra options (old api)`url`, `secret` Extra options (new api)`url`, `api-version`, `api-key`, `crypt-api-key-in-transfer` @@ -118,7 +179,7 @@ WebPConvert::convert($source, $destination, [ 'converter-options' => [ 'wpc' => [ 'api-version' => 1, - 'url' => 'http://example.com/wpc.php', + 'url' => 'https://example.com/wpc.php', 'api-key' => 'my dog is white', 'crypt-api-key-in-transfer' => true ], @@ -126,6 +187,16 @@ WebPConvert::convert($source, $destination, [ )); ``` +In 2.0, you can alternatively set the api key and urls through through the *WPC_API_KEY* and *WPC_API_URL* environment variables. This is a safer place to store it. + +To set an environment variable in Apache, you can use the `SetEnv` directory. Ie, place something like the following in your virtual host / or .htaccess file (replace the key with the one you purchased!) + +``` +SetEnv WPC_API_KEY my-dog-is-dashed +SetEnv WPC_API_URL https://wpc.example.com/wpc.php +``` + + #### Example, old API: ```php @@ -134,7 +205,7 @@ WebPConvert::convert($source, $destination, [ 'converters' => ['cwebp', 'wpc'], 'converter-options' => [ 'wpc' => [ - 'url' => 'http://example.com/wpc.php', + 'url' => 'https://example.com/wpc.php', 'secret' => 'my dog is white', ], ] @@ -181,6 +252,13 @@ WebPConvert::convert($source, $destination, [ ] )); ``` +In 2.0, you can alternatively set the api key by through the *EWWW_API_KEY* environment variable. This is a safer place to store it. + +To set an environment variable in Apache, you can use the `SetEnv` directory. Ie, place something like the following in your virtual host / or .htaccess file (replace the key with the one you purchased!) + +``` + SetEnv EWWW_API_KEY sP3LyPpsKWZy8CVBTYegzEGN6VsKKKKA +``` ## gd @@ -209,9 +287,9 @@ Due to a [bug](https://bugs.php.net/bug.php?id=66590), some versions sometimes c - + - +
RequirementsImagick PHP extension (compiled with WebP support)
QualityPoor. [See this issue]( https://github.com/rosell-dk/webp-convert/issues/43)
General options supported`quality`, `method`, `low-memory`, `lossless`
General options supported`quality`
Extra optionsNone
Performance~20-320ms to convert a 40kb image (depending on `method` option)
Performance~20-320ms to convert a 40kb image
ReliabilityNo problems detected so far
AvailabilityProbably only available on few shared hosts (if any)
@@ -219,3 +297,26 @@ Due to a [bug](https://bugs.php.net/bug.php?id=66590), some versions sometimes c WebP conversion with `imagick` is fast and [exposes many WebP options](http://www.imagemagick.org/script/webp.php). Unfortunately, WebP support for the `imagick` extension is pretty uncommon. At least not on the systems I have tried (Ubuntu 16.04 and Ubuntu 17.04). But if installed, it works great and has several WebP options. See [this page](https://github.com/rosell-dk/webp-convert/wiki/Installing-Imagick-extension) in the Wiki for instructions on installing the extension. + +## imagickbinary + + + + + + + +
Requirementsexec() function and that imagick is installed on webserver, compiled with webp support
Performancejust fine
ReliabilityNo problems detected so far!
AvailabilityNot sure
General options supported`quality`
Extra options`use-nice` (boolean)
+ +This converter tryes to execute `convert source.jpg webp:destination.jpg.webp`. + +## stack + + + + +
General options supportedall (passed to the converters in the stack )
Extra options`converters` (array) and `converter-options` (array)
+ +Stack implements the functionality you know from `WebPConvert::convert`. In fact, all `WebPConvert::convert` does is to call `Stack::convert($source, $destination, $options, $logger);` + +It has two special options: `converters` and `converter-options`. You can read about those in `docs/api/convert.md` diff --git a/docs/api/convert.md b/docs/v1.3/converting/convert.md similarity index 84% rename from docs/api/convert.md rename to docs/v1.3/converting/convert.md index c918bb93..b3ed0804 100644 --- a/docs/api/convert.md +++ b/docs/v1.3/converting/convert.md @@ -11,7 +11,7 @@ Returns true if success or false if no converters are *operational*. If any converter seems to have its requirements met (are *operational*), but fails anyway, and no other converters in the stack could convert the image, an the exception from that converter is rethrown (either *ConverterFailedException* or *ConversionDeclinedException*). Exceptions are also thrown if something is wrong entirely (*InvalidFileExtensionException*, *TargetNotFoundException*, *ConverterNotFoundException*, *CreateDestinationFileException*, *CreateDestinationFolderException*, or any unanticipated exceptions thrown by the converters). -### Available options +### Available options for all converters Many options correspond to options of *cwebp*. These are documented [here](https://developers.google.com/speed/webp/docs/cwebp) @@ -22,11 +22,13 @@ Many options correspond to options of *cwebp*. These are documented [here](https | max-quality | An integer between 0-100 | 85 | See the `quality` option. Only relevant, when quality is set to "auto". | default-quality | An integer between 0-100 | 75 | See the `quality` option. Only relevant, when quality is set to "auto". | metadata | String | 'none' | Valid values: all, none, exif, icc, xmp. Note: Only *cwebp* supports all values. *gd* will always remove all metadata. *ewww*, *imagick* and *gmagick* can either strip all, or keep all (they will keep all, unless metadata is set to *none*) | -| method | Integer | 6 | Specify the compression method to use (0-6). When higher values are used, the encoder will spend more time inspecting additional encoding possibilities and decide on the quality gain. Lower value can result in faster processing time at the expense of larger file size and lower compression quality. | -| low-memory | Boolean | false | Reduce memory usage of lossy encoding by saving four times the compressed size (typically) | -| lossless | Boolean | false | Encode the image without any loss. The option is ignored for PNG's (forced true) | +| lossless | Boolean | false ("auto" for pngs in 2.0) | Encode the image without any loss. The option is ignored for PNG's (forced true). In 2.0, it can also be "auto", and it is not forced to anything - rather it deafaults to false for Jpegs and "auto" for PNGs | | converters | Array | ['cwebp', 'gd', 'imagick'] | Specify conversion methods to use, and their order. Also optionally set converter options (see below) | -| converter-options | Array | [] | Upcoming in v1.2.0. Set options of the individual converters (see below) | +| converter-options | Array | [] | Set options of the individual converters (see below) | +| jpeg | Array | null | These options will be merged into the other options when source is jpeg | +| png | Array | null | These options will be merged into the other options when source is jpeg | +| skip (new in 2.0) | Boolean | false | If true, conversion will be skipped (ie for skipping png conversion for some converters) | +| skip-png (removed in 2.0) | Boolean | false | If true, conversion will be skipped for png (ie for skipping png conversion for some converters) | #### More on quality=auto Unfortunately, *libwebp* does not provide a way to use the same quality for the converted image, as for source. This feature is implemented by *imagick* and *gmagick*. No matter which conversion method you choose, if you set *quality* to *auto*, our library will try to detect the quality of the source file using one of these libraries. If this isn't available, it will revert to the value set in the *default-quality* option (75 per default). *However*, with the *wpc* converter you have a second chance: If quality cannot be detected locally, it will send quality="auto" to *wpc*. @@ -74,7 +76,7 @@ WebPConvert::convert($source, $destination, [ ]; ) ``` - +In 2.0, it will be possible to use your own custom converter. Instead of the "converter id" (ie "ewww"), specify the full class name of your custom converter. Ie '\\MyProject\\BraveConverter'. The converter must extend `\WebPConvert\Convert\Converters\AbstractConverters\AbstractConverter` and you must implement `doConvert()` and the define the extra options it takes (check out how it is done in the build-in converters). ### More on the `$logger` parameter WebPConvert and the individual converters can provide information regarding the conversion process. Per default (when the parameter isn't provided), they write this to `\WebPConvert\Loggers\VoidLogger`, which does nothing with it. diff --git a/docs/v1.3/converting/converters.md b/docs/v1.3/converting/converters.md new file mode 100644 index 00000000..4d9b0722 --- /dev/null +++ b/docs/v1.3/converting/converters.md @@ -0,0 +1,322 @@ +# The webp converters + +## The converters at a glance +When it comes to webp conversion, there is actually only one library in town: *libwebp* from Google. All conversion methods below ultimately uses that very same library for conversion. This means that it does not matter much, which conversion method you use. Whatever works. There is however one thing to take note of, if you set *quality* to *auto*, and your system cannot determine the quality of the source (this requires imagick or gmagick), and you do not have access to install those, then the only way to get quality-detection is to connect to a *wpc* cloud converter. However, with *cwebp*, you can specify the desired reduction (the *size-in-percentage* option) - at the cost of doubling the conversion time. Read more about those considerations in the API. + +Speed-wise, there is too little difference for it to matter, considering that images usually needs to be converted just once. Anyway, here are the results: *cweb* is the fastest (with method=3). *gd* is right behind, merely 3% slower than *cwebp*. *gmagick* are third place, ~8% slower than *cwebp*. *imagick* comes in ~22% slower than *cwebp*. *ewww* depends on connection speed. On my *digital ocean* account, it takes ~2 seconds to upload, convert, and download a tiny image (10 times longer than the local *cwebp*). A 1MB image however only takes ~4.5 seconds to upload, convert and download (1.5 seconds longer). A 2 MB image takes ~5 seconds to convert (only 16% longer than my *cwebp*). The *ewww* thus converts at a very decent speeds. Probably faster than your average shared host. If multiple big images needs to be converted at the same time, *ewww* will probably perform much better than the local converters. + +[`cwebp`](#cwebp) works by executing the *cwebp* binary from Google, which is build upon the *libwebp* (also from Google). That library is actually the only library in town for generating webp images, which means that the other conversion methods ultimately uses that very same library. Which again means that the results using the different methods are very similar. However, with *cwebp*, we have more parameters to tweak than with the rest. We for example have the *method* option, which controls the trade off between encoding speed and the compressed file size and quality. Setting this to max, we can squeeze the images a few percent extra - without loosing quality (the converter is still pretty fast, so in most cases it is probably worth it). + +Of course, as we here have to call a binary directly, *cwebp* requires the *exec* function to be enabled, and that the webserver user is allowed to execute the `cwebp` binary (either at known system locations, or one of the precompiled binaries, that comes with this library). + +[`vips`](#vips) (**new in 2.0**) works by using the vips extension, if available. Vips is great! It offers many webp options, it is fast and installation is easier than imagick and gd, as it does not need to be configured for webp support. + +[`imagick`](#imagick) does not support any special webp options, but is at least able to strip all metadata, if metadata is set to none. Imagick has a very nice feature - that it is able to detect the quality of a jpeg file. This enables it to automatically use same quality for destination as for source, which eliminates the risk of setting quality higher for the destination than for source (the result of that is that the file size gets higher, but the quality remains the same). As the other converters lends this capability from Imagick, this is however no reason for using Imagick rather than the other converters. Requirements: Imagick PHP extension compiled with WebP support + +[`gmagick`](#gmagick) uses the *gmagick* extension. It is very similar to *imagick*. Requirements: Gmagick PHP extension compiled with WebP support. + +[`gd`](#gd) uses the *Gd* extension to do the conversion. The *Gd* extension is pretty common, so the main feature of this converter is that it may work out of the box. It does not support any webp options, and does not support stripping metadata. Requirements: GD PHP extension compiled with WebP support. + +[`wpc`](#wpc) is an open source cloud service for converting images to webp. To use it, you must either install [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) directly on a remote server, or install the Wordpress plugin, [WebP Express](https://github.com/rosell-dk/webp-express) in Wordpress. Btw: Beware that upload limits will prevent conversion of big images. The converter checks your *php.ini* settings and abandons upload right away, if an image is larger than your *upload_max_filesize* or your *post_max_size* setting. Requirements: Access to a running service. The service can be installed [directly](https://github.com/rosell-dk/webp-convert-cloud-service) or by using [this Wordpress plugin](https://wordpress.org/plugins/webp-express/) + +[`ewww`](#ewww) is also a cloud service. Not free, but cheap enough to be considered *practically* free. It supports lossless encoding, but this cannot be controlled. *Ewww* always uses lossy encoding for jpeg and lossless for png. For jpegs this is usually a good choice, however, many pngs are compressed better using lossy encoding. As lossless cannot be controlled, the "lossless:auto" option cannot be used for automatically trying both lossy and lossless and picking the smallest file. Also, unfortunately, *ewww* does not support quality=auto, like *wpc*, and it does not support *size-in-percentage* like *cwebp*, either. I have requested such features, and he is considering... As with *wpc*, beware of upload limits. Requirements: A key to the *EWWW Image Optimizer* cloud service. Can be purchaced [here](https://ewww.io/plans/) + +[`stack`](#stack) takes a stack of converters and tries it from the top, until success. The main convert method actually calls this converter. Stacks within stacks are supported (not really needed, though). + + +**Summary:** + +| | cwebp | vips | imagickbinary | imagick / gmagick | gd | ewww | +| ------------------------------------------ | --------- | ------ | -------------- | ----------------- | --------- | ------ | +| supports lossless encoding ? | yes | yes | yes | no | no | yes | +| supports lossless auto ? | yes | yes | yes | no | no | no | +| supports near-lossless ? | yes | yes | no | no | no | ? | +| supports metadata stripping / preserving | yes | yes | yes | yes | no | ? | +| supports setting alpha quality | yes | yes | yes | no | no | no | +| supports fixed quality (for lossy) | yes | yes | yes | yes | yes | yes | +| supports auto quality without help | no | no | yes | yes | no | no | + + + +*WebPConvert* currently supports the following converters: + +| Converter | Method | Requirements | +| ------------------------------------ | ------------------------------------------------ | -------------------------------------------------- | +| [`cwebp`](#cwebp) | Calls `cwebp` binary directly | `exec()` function *and* that the webserver user has permission to run `cwebp` binary | +| [`vips`](#vips) (new in 2.0) | Vips extension | Vips extension | +| [`imagick`](#imagick) | Imagick extension (`ImageMagick` wrapper) | Imagick PHP extension compiled with WebP support | +| [`gmagick`](#gmagick) | Gmagick extension (`ImageMagick` wrapper) | Gmagick PHP extension compiled with WebP support | +| [`gd`](#gd) | GD Graphics (Draw) extension (`LibGD` wrapper) | GD PHP extension compiled with WebP support | +| [`imagickbinary`](#imagickbinary) | Calls imagick binary directly | exec() and imagick installed and compiled with WebP support | +| [`wpc`](#wpc) | Connects to an open source cloud service | Access to a running service. The service can be installed [directly](https://github.com/rosell-dk/webp-convert-cloud-service) or by using [this Wordpress plugin](https://wordpress.org/plugins/webp-express/). +| [`ewww`](#ewww) | Connects to *EWWW Image Optimizer* cloud service | Purchasing a key | + +## Installation +Instructions regarding getting the individual converters to work are [on the wiki](https://github.com/rosell-dk/webp-convert/wiki) + +## cwebp + + + + + + + +
Requirementsexec() function and that the webserver has permission to run `cwebp` binary (either found in system path, or a precompiled version supplied with this library)
Performance~40-120ms to convert a 40kb image (depending on *method* option)
ReliabilityNo problems detected so far!
AvailabilityAccording to ewww docs, requirements are met on surprisingly many webhosts. Look here for a list
General options supportedAll (`quality`, `metadata`, `lossless`)
Extra options`method` (0-6)
`use-nice` (boolean)
`try-common-system-paths` (boolean)
`try-supplied-binary-for-os` (boolean)
`autofilter` (boolean)
`size-in-percentage` (number / null)
`command-line-options` (string)
`low-memory` (boolean)
+ +[cwebp](https://developers.google.com/speed/webp/docs/cwebp) is a WebP conversion command line converter released by Google. Our implementation ships with precompiled binaries for Linux, FreeBSD, WinNT, Darwin and SunOS. If however a cwebp binary is found in a usual location, that binary will be preferred. It is executed with [exec()](http://php.net/manual/en/function.exec.php). + +In more detail, the implementation does this: +- It is tested whether cwebp is available in a common system path (eg `/usr/bin/cwebp`, ..) +- If not, then supplied binary is selected from `Converters/Binaries` (according to OS) - after validating checksum +- Command-line options are generated from the options +- If [`nice`]( https://en.wikipedia.org/wiki/Nice_(Unix)) command is found on host, binary is executed with low priority in order to save system resources +- Permissions of the generated file are set to be the same as parent folder + +### Cwebp options + +The following options are supported, besides the general options (such as quality, lossless etc): + +| Option | Type | Default | +| -------------------------- | ------------------------- | -------------------------- | +| autofilter | boolean | false | +| command-line-options | string | '' | +| low-memory | boolean | false | +| method | integer (0-6) | 6 | +| near-lossless | integer (0-100) | 60 | +| size-in-percentage | integer (0-100) (or null) | null | +| rel-path-to-precompiled-binaries | string | './Binaries' | +| size-in-percentage | number (or null) | is_null | +| try-common-system-paths | boolean | true | +| try-supplied-binary-for-os | boolean | true | +| use-nice | boolean | false | + +Descriptions (only of some of the options): + +#### the `autofilter` option +Turns auto-filter on. This algorithm will spend additional time optimizing the filtering strength to reach a well-balanced quality. Unfortunately, it is extremely expensive in terms of computation. It takes about 5-10 times longer to do a conversion. A 1MB picture which perhaps typically takes about 2 seconds to convert, will takes about 15 seconds to convert with auto-filter. So in most cases, you will want to leave this at its default, which is off. + +#### the `command-line-options` option +This allows you to set any parameter available for cwebp in the same way as you would do when executing *cwebp*. You could ie set it to "-sharpness 5 -mt -crop 10 10 40 40". Read more about all the available parameters in [the docs](https://developers.google.com/speed/webp/docs/cwebp) + +#### the `low-memory` option +Reduce memory usage of lossy encoding at the cost of ~30% longer encoding time and marginally larger output size. Default: `false`. Read more in [the docs](https://developers.google.com/speed/webp/docs/cwebp). Default: *false* + +#### The `method` option +This parameter controls the trade off between encoding speed and the compressed file size and quality. Possible values range from 0 to 6. 0 is fastest. 6 results in best quality. + +#### the `near-lossless` option +Specify the level of near-lossless image preprocessing. This option adjusts pixel values to help compressibility, but has minimal impact on the visual quality. It triggers lossless compression mode automatically. The range is 0 (maximum preprocessing) to 100 (no preprocessing). The typical value is around 60. Read more [here](https://groups.google.com/a/webmproject.org/forum/#!topic/webp-discuss/0GmxDmlexek). Default: 60 + +#### The `size-in-percentage` option +This option sets the file size, *cwebp* should aim for, in percentage of the original. If you for example set it to *45*, and the source file is 100 kb, *cwebp* will try to create a file with size 45 kb (we use the `-size` option). This is an excellent alternative to the "quality:auto" option. If the quality detection isn't working on your system (and you do not have the rights to install imagick or gmagick), you should consider using this options instead. *Cwebp* is generally able to create webp files with the same quality at about 45% the size. So *45* would be a good choice. The option overrides the quality option. And note that it slows down the conversion - it takes about 2.5 times longer to do a conversion this way, than when quality is specified. Default is *off* (null) + + +#### final words on cwebp +The implementation is based on the work of Shane Bishop for his plugin, [EWWW Image Optimizer](https://ewww.io). Thanks for letting us do that! + +See [the wiki](https://github.com/rosell-dk/webp-convert/wiki/Installing-cwebp---using-official-precompilations) for instructions regarding installing cwebp or using official precompilations. + +## vips + + + + + + + +
RequirementsVips extension
PerformanceGreat
ReliabilityNo problems detected so far!
AvailabilityNot that widespread yet, but gaining popularity
General options supportedAll (`quality`, `metadata`, `lossless`)
Extra options`smart-subsample`(boolean)
`alpha-quality`(0-100)
`near-lossless` (0-100)
`preset` (0-6)
+ +For installation instructions, go [here](https://github.com/libvips/php-vips-ext). + +The options are described [here](https://jcupitt.github.io/libvips/API/current/VipsForeignSave.html#vips-webpsave) + +*near-lossless* is however an integer (0-100), in order to have the option behave like in cwebp. + + + +## wpc +*WebPConvert Cloud Service* + + + + + + + + + +
RequirementsAccess to a server with [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) installed, cURL and PHP >= 5.5.0
PerformanceDepends on the server where [webp-convert-cloud-service](https://github.com/rosell-dk/webp-convert-cloud-service) is set up, and the speed of internet connections. But perhaps ~1000ms to convert a 40kb image
ReliabilityGreat (depends on the reliability on the server where it is set up)
AvailabilityShould work on almost any webhost
General options supportedAll (`quality`, `metadata`, `lossless`)
Extra options (old api)`url`, `secret`
Extra options (new api)`url`, `api-version`, `api-key`, `crypt-api-key-in-transfer`
+ +[wpc](https://github.com/rosell-dk/webp-convert-cloud-service) is an open source cloud service. You do not buy a key, you set it up on a server, or you set up [the Wordpress plugin](https://wordpress.org/plugins/webp-express/). As WebPConvert Cloud Service itself is based on WebPConvert, all options are supported. + +To use it, you need to set the `converter-options` (to add url etc). + +#### Example, where api-key is not crypted, on new API: + +```php +WebPConvert::convert($source, $destination, [ + 'max-quality' => 80, + 'converters' => ['cwebp', 'wpc'], + 'converter-options' => [ + 'wpc' => [ + 'api-version' => 1, /* from wpc release 1.0.0 */ + 'url' => 'http://example.com/wpc.php', + 'api-key' => 'my dog is white', + 'crypt-api-key-in-transfer' => false + ] + ] +)); +``` + +#### Example, where api-key is crypted: + +```php + +WebPConvert::convert($source, $destination, [ + 'max-quality' => 80, + 'converters' => ['cwebp', 'wpc'], + 'converter-options' => [ + 'wpc' => [ + 'api-version' => 1, + 'url' => 'https://example.com/wpc.php', + 'api-key' => 'my dog is white', + 'crypt-api-key-in-transfer' => true + ], + ] +)); +``` + +In 2.0, you can alternatively set the api key and urls through through the *WPC_API_KEY* and *WPC_API_URL* environment variables. This is a safer place to store it. + +To set an environment variable in Apache, you can use the `SetEnv` directory. Ie, place something like the following in your virtual host / or .htaccess file (replace the key with the one you purchased!) + +``` +SetEnv WPC_API_KEY my-dog-is-dashed +SetEnv WPC_API_URL https://wpc.example.com/wpc.php +``` + + +#### Example, old API: + +```php +WebPConvert::convert($source, $destination, [ + 'max-quality' => 80, + 'converters' => ['cwebp', 'wpc'], + 'converter-options' => [ + 'wpc' => [ + 'url' => 'https://example.com/wpc.php', + 'secret' => 'my dog is white', + ], + ] +)); +``` + + +## ewww + + + + + + + + +
RequirementsValid EWWW Image Optimizer API key, cURL and PHP >= 5.5.0
Performance~1300ms to convert a 40kb image
ReliabilityGreat (but, as with any cloud service, there is a risk of downtime)
AvailabilityShould work on almost any webhost
General options supported`quality`, `metadata` (partly)
Extra options`key`
+ +EWWW Image Optimizer is a very cheap cloud service for optimizing images. After purchasing an API key, add the converter in the `extra-converters` option, with `key` set to the key. Be aware that the `key` should be stored safely to avoid exploitation - preferably in the environment, ie with [dotenv](https://github.com/vlucas/phpdotenv). + +The EWWW api doesn't support the `lossless` option, but it does automatically convert PNG's losslessly. Metadata is either all or none. If you have set it to something else than one of these, all metadata will be preserved. + +In more detail, the implementation does this: +- Validates that there is a key, and that `curl` extension is working +- Validates the key, using the [/verify/ endpoint](https://ewww.io/api/) (in order to [protect the EWWW service from unnecessary file uploads, when key has expired](https://github.com/rosell-dk/webp-convert/issues/38)) +- Converts, using the [/ endpoint](https://ewww.io/api/). + +
+Roadmap 👁 + +The converter could be improved by using `fsockopen` when `cURL` is not available - which is extremely rare. PHP >= 5.5.0 is also widely available (PHP 5.4.0 reached end of life [more than two years ago!](http://php.net/supported-versions.php)). +
+ +#### Example: + +```php +WebPConvert::convert($source, $destination, [ + 'max-quality' => 80, + 'converters' => ['gd', 'ewww'], + 'converter-options' => [ + 'ewww' => [ + 'key' => 'your-api-key-here' + ], + ] +)); +``` +In 2.0, you can alternatively set the api key by through the *EWWW_API_KEY* environment variable. This is a safer place to store it. + +To set an environment variable in Apache, you can use the `SetEnv` directory. Ie, place something like the following in your virtual host / or .htaccess file (replace the key with the one you purchased!) + +``` + SetEnv EWWW_API_KEY sP3LyPpsKWZy8CVBTYegzEGN6VsKKKKA +``` + +## gd + + + + + + + + +
RequirementsGD PHP extension and PHP >= 5.5.0 (compiled with WebP support)
Performance~30ms to convert a 40kb image
ReliabilityNot sure - I have experienced corrupted images, but cannot reproduce
AvailabilityUnfortunately, according to this link, WebP support on shared hosts is rare.
General options supported`quality`
Extra options`skip-pngs`
+ +[imagewebp](http://php.net/manual/en/function.imagewebp.php) is a function that comes with PHP (>5.5.0), *provided* that PHP has been compiled with WebP support. + +`gd` neither supports copying metadata nor exposes any WebP options. Lacking the option to set lossless encoding results in poor encoding of PNGs - the filesize is generally much larger than the original. For this reason, PNG conversion is *disabled* by default, but it can be enabled my setting `skip-pngs` option to `false`. + +Installaition instructions are [available in the wiki](https://github.com/rosell-dk/webp-convert/wiki/Installing-Gd-extension). + +
+Known bugs 👁 +Due to a [bug](https://bugs.php.net/bug.php?id=66590), some versions sometimes created corrupted images. That bug can however easily be fixed in PHP (fix was released [here](https://stackoverflow.com/questions/30078090/imagewebp-php-creates-corrupted-webp-files)). However, I have experienced corrupted images *anyway* (but cannot reproduce that bug). So use this converter with caution. The corrupted images look completely transparent in Google Chrome, but have the correct size. +
+ +## imagick + + + + + + + + + +
RequirementsImagick PHP extension (compiled with WebP support)
QualityPoor. [See this issue]( https://github.com/rosell-dk/webp-convert/issues/43)
General options supported`quality`
Extra optionsNone
Performance~20-320ms to convert a 40kb image
ReliabilityNo problems detected so far
AvailabilityProbably only available on few shared hosts (if any)
+ +WebP conversion with `imagick` is fast and [exposes many WebP options](http://www.imagemagick.org/script/webp.php). Unfortunately, WebP support for the `imagick` extension is pretty uncommon. At least not on the systems I have tried (Ubuntu 16.04 and Ubuntu 17.04). But if installed, it works great and has several WebP options. + +See [this page](https://github.com/rosell-dk/webp-convert/wiki/Installing-Imagick-extension) in the Wiki for instructions on installing the extension. + +## imagickbinary + + + + + + + +
Requirementsexec() function and that imagick is installed on webserver, compiled with webp support
Performancejust fine
ReliabilityNo problems detected so far!
AvailabilityNot sure
General options supported`quality`
Extra options`use-nice` (boolean)
+ +This converter tryes to execute `convert source.jpg webp:destination.jpg.webp`. + +## stack + + + + +
General options supportedall (passed to the converters in the stack )
Extra options`converters` (array) and `converter-options` (array)
+ +Stack implements the functionality you know from `WebPConvert::convert`. In fact, all `WebPConvert::convert` does is to call `Stack::convert($source, $destination, $options, $logger);` + +It has two special options: `converters` and `converter-options`. You can read about those in `docs/api/convert.md` diff --git a/docs/api/convert-and-serve.md b/docs/v1.3/serving/convert-and-serve.md similarity index 80% rename from docs/api/convert-and-serve.md rename to docs/v1.3/serving/convert-and-serve.md index 48d823c7..96a673fa 100644 --- a/docs/api/convert-and-serve.md +++ b/docs/v1.3/serving/convert-and-serve.md @@ -1,5 +1,7 @@ # API: The WebPConvert::convertAndServe() method +*NOTE:* In 2.0, the method is renamed to *serveConverted* ("convertAndServe" was implying that a conversion was always made, but the method simply serves destination if it exists and is smaller and newer than source) + The method tries to serve a converted image. If destination already exists, the already converted image will be served. Unless the original is newer or smaller. If the method fails, it will serve original image, a 404, or whatever the 'fail' option is set to. **WebPConvert::convertAndServe($source, $destination, $options)** @@ -13,6 +15,9 @@ The method tries to serve a converted image. If destination already exists, the ## The *$options* argument The options argument is a named array. Besides the options described below, you can also use any options that the *convert* method takes (if a fresh convertion needs to be created, this method will call the *convert* method and hand over the options argument) +### *convert* +Conversion options, handed over to the convert method, in case a conversion needs to be made. The convert options are documented [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md). + ### *fail* Indicate what to do, in case of normal conversion failure. Default value: *"original"* @@ -37,7 +42,7 @@ Force a conversion, discarding existing converted image (if any). Default value: *false* ### *serve-original* -Forces serving original image. +Forces serving original image. This will skip conversion. Default value: *false* ### *add-x-header-status* @@ -64,12 +69,17 @@ Default value: *true* ### *add-content-type-header* Add a "Content-Type" header Default value: *true* -If set, a Content-Type header will be added. It will be set to "image/webp" if a converted image is served, "image/jpeg" or "image/png", if the original is served or "image/gif", if an error message is served (as image). You can set it to false when debugging (to check if any errors are being outputted) +If set, a *Content-Type* header will be added. It will be set to "image/webp" if a converted image is served, "image/jpeg" or "image/png", if the original is served or "image/gif", if an error message is served (as image). You can set it to false when debugging (to check if any errors are being outputted) + +### *add-last-modified-header* +Add a "Last-Modified" header +Default value: *true* +If set, a *Last-Modified* header will be added. When a cached image is served, it will be set to the modified time of the converted file. When a fresh image is served, it is set to current time. ### *cache-control-header* Specify a cache control header, which will be served when caching is appropriate. Default value: "public, max-age=86400" (1 day) -Caching is "deemed appropriate", when destination is served, source is served, because it is lighter or a fresh conversion is made, due to there not being any converted image at the destination yet. Caching is not deemed appropriate when something fails, a report is requested, or the *serve-original* or *reconvert* options have been set. In those cases, standard headers will be used for preventing caching. +Caching is "deemed appropriate", when destination is served, source is served, because it is lighter or a fresh conversion is made, due to there not being any converted image at the destination yet. Caching is not deemed appropriate when something fails, a report is requested, or the *reconvert* option have been set. Note: in version 1.3.2 and below, the *serve-original* option also prevented caching, but it no longer does. previous In those cases, standard headers will be used for preventing caching. For your convenience, here is a little table: | duration | max-age | @@ -104,7 +114,7 @@ If set to "auto", errors will be turned off, unless the `show-report` option is If set to "dont-mess", error reporting will not be touched. ### *aboutToServeImageCallBack* -This callback is called right before an image is served. This is a great chance to adding headers. You can stop the image from being served by returning *false*. +This callback is called right before response headers and image is served. This is a great chance to adding headers. You can stop the image and the headers from being served by returning *false*. **Arguments:** The first argument to the callback contains a string that tells what is about to be served. It can be 'fresh-conversion', 'destination' or 'source'. @@ -126,18 +136,9 @@ Example of callback: ``` function aboutToServeImageCallBack($servingWhat, $whyServingThis, $obj) { - $messages = [ - 'source' => [ - 'explicitly-told-to' - ] - ] - switch ($servingWhat) { - case 'destination': - break; - } echo 'about to serve: ' . $servingWhat . '
'; echo 'Why? - because: ' . $whyServingThis; - return false; // Do not serve! + return false; // Do not serve! (this also prevents any response headers from being added) } ``` diff --git a/docs/webp-on-demand/tweaks.md b/docs/v1.3/webp-on-demand/tweaks.md similarity index 94% rename from docs/webp-on-demand/tweaks.md rename to docs/v1.3/webp-on-demand/tweaks.md index b6000493..56e2acfb 100644 --- a/docs/webp-on-demand/tweaks.md +++ b/docs/v1.3/webp-on-demand/tweaks.md @@ -91,8 +91,9 @@ Add the following to the *.htaccess* to make it route to existing converted imag # Redirect to existing converted image (under appropriate circumstances) RewriteCond %{HTTP_ACCEPT} image/webp RewriteCond %{DOCUMENT_ROOT}/[[your-base-path]]/[[your-destination-root]]/$1.$2.webp -f - RewriteRule ^\/?(.*)\.(jpe?g|png)$ /[[your-base-path]]/[[your-destination-root]]/$1.$2.webp [NC,T=image/webp,QSD,L] + RewriteRule ^\/?(.*)\.(jpe?g|png)$ /[[your-base-path]]/[[your-destination-root]]/$1.$2.webp [NC,T=image/webp,L] ``` +*edit:* Removed the QSD flag from the RewriteRule because it is not supported in Apache < 2.4 (and it [triggers error](https://github.com/rosell-dk/webp-express/issues/155)) ### Redirect with CDN support If you are using a CDN, and want to redirect to existing images with the .htaccess, it is a good idea to add a "Vary Accept" header. This instructs the CDN that the response varies with the *Accept* header (we do not need to do that when routing to webp-on-demand.php, because the script takes care of adding this header, when appropriate.) @@ -148,6 +149,7 @@ That condition will always be met. The side effect is that it stores the match ( AddType image/webp .webp ``` + Of course, in order to *do* something with that querystring, you must use them in your *webp-on-demand.php* script. You could for example use them directly in the options array sent to the *convertAndServe()* method. To achieve the mentioned "debug" and "reconvert" features, do this: ```php $options = [ @@ -156,3 +158,10 @@ $options = [ 'serve-original' => isset($_GET['original']), ]; ``` + +*EDIT:* +I have just discovered a simpler way to achieve the querystring forward: The [QSA flag](https://httpd.apache.org/docs/trunk/rewrite/flags.html). +So, simply set the QSA flag in the RewriteRule, and nothing more: +``` +RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME} [NC,QSA,L] +``` diff --git a/docs/webp-on-demand/webp-on-demand.md b/docs/v1.3/webp-on-demand/webp-on-demand.md similarity index 76% rename from docs/webp-on-demand/webp-on-demand.md rename to docs/v1.3/webp-on-demand/webp-on-demand.md index 905f2016..83439020 100644 --- a/docs/webp-on-demand/webp-on-demand.md +++ b/docs/v1.3/webp-on-demand/webp-on-demand.md @@ -18,7 +18,7 @@ A setup consists of a PHP script that serves converted images and some *redirect ## Installation -Here we assume you are using Composer. [Not using composer? - Follow me!](https://github.com/rosell-dk/webp-convert/blob/master/docs/webp-on-demand/without-composer.md) +Here we assume you are using Composer. [Not using composer? - Follow me!](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/webp-on-demand/without-composer.md) ### 1. Require the webp-convert library with composer ``` @@ -60,6 +60,7 @@ Place the following rewrite rules in a *.htaccess* file in the directory where y # Redirect images to webp-on-demand.php (if browser supports webp) RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{REQUEST_FILENAME} -f RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME} [NC,L] @@ -67,6 +68,7 @@ AddType image/webp .webp ``` If you have placed *webp-on-demand.php* in a subfolder, you will need to change the rewrite rule accordingly. +The `RewriteCond %{REQUEST_FILENAME} -f` is not strictly necessary, but there to be sure that we got an existing file, and it could perhaps also prevent some undiscovered way of misuse. ### 4. Validate that it works @@ -83,16 +85,35 @@ It should work now, but to be absolute sure: - Find a jpeg or png image in the list. In the "type" column, it should say "webp". There should also be a *X-WebP-Convert-Status* header on the image that provides some insights on how things went. +### 5. Try this improvement and see if it works -### 5. Customizing and tweaking +It seems that it is not necessary to pass the filename in the query string. -Basic customizing is done by setting options in the `$options` array. Check out the [docs on convert()](https://github.com/rosell-dk/webp-convert/blob/master/docs/api/convert.md) and the [docs on convertAndServe()](https://github.com/rosell-dk/webp-convert/blob/master/docs/api/convert-and-serve.md) +Try replacing `$source = $_GET['source'];` in the script with the following: + +```php +$docRoot = rtrim($_SERVER["DOCUMENT_ROOT"], '/'); +$requestUriNoQS = explode('?', $_SERVER['REQUEST_URI'])[0]; +$source = $docRoot . urldecode($requestUriNoQS); +``` + +And you can then remove `?source=%{SCRIPT_FILENAME}` from the `.htaccess` file. + +There are some benefits of not passing in query string: +1. Passing a path in the query string may be blocked by a firewall, as it looks suspicious. +2. The script called to convert arbitrary files +3. One person experienced problems with spaces in filenames passed in the query string. See [this issue](https://github.com/rosell-dk/webp-convert/issues/95) + + +### 6. Customizing and tweaking + +Basic customizing is done by setting options in the `$options` array. Check out the [docs on convert()](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/converting/convert.md) and the [docs on convertAndServe()](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/serving/convert-and-serve.md) Other tweaking is described in *docs/webp-on-demand/tweaks.md*: -- [Store converted images in separate folder](https://github.com/rosell-dk/webp-convert/blob/master/docs/webp-on-demand/tweaks.md#store-converted-images-in-separate-folder) -- [CDN](https://github.com/rosell-dk/webp-convert/blob/master/docs/webp-on-demand/tweaks.md#cdn) -- [Make .htaccess route directly to existing images](https://github.com/rosell-dk/webp-convert/blob/master/docs/webp-on-demand/tweaks.md#make-htaccess-route-directly-to-existing-images) -- [Forward the query string](https://github.com/rosell-dk/webp-convert/blob/master/docs/webp-on-demand/tweaks.md#forward-the-querystring) +- [Store converted images in separate folder](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/webp-on-demand/tweaks.md#store-converted-images-in-separate-folder) +- [CDN](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/webp-on-demand/tweaks.md#cdn) +- [Make .htaccess route directly to existing images](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/webp-on-demand/tweaks.md#make-htaccess-route-directly-to-existing-images) +- [Forward the query string](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/webp-on-demand/tweaks.md#forward-the-querystring) ## Troubleshooting diff --git a/docs/webp-on-demand/without-composer.md b/docs/v1.3/webp-on-demand/without-composer.md similarity index 79% rename from docs/webp-on-demand/without-composer.md rename to docs/v1.3/webp-on-demand/without-composer.md index 666aea0d..bc450612 100644 --- a/docs/webp-on-demand/without-composer.md +++ b/docs/v1.3/webp-on-demand/without-composer.md @@ -5,13 +5,13 @@ For your convenience, the library has been cooked down to two files: *webp-on-de ## Installing ### 1. Copy the latest build files into your website -Copy *webp-on-demand-1.inc* and *webp-on-demand-2.inc* from the *build* folder into your website. They can be located wherever you like. +Copy *webp-on-demand-1.inc* and *webp-on-demand-2.inc* from the *build* folder into your website (in 2.0, they are located in "src-build"). They can be located wherever you like. ### 2. Create a *webp-on-demand.php* Create a file *webp-on-demand.php*, and place it in webroot, or where-ever you like in you web-application. -Here is a minimal example to get started with: +Here is a minimal example to get started with. Note that this example only works in version 1.x. In 2.0, the `require-for-conversion` option has been removed, so the [procedure is different](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/without-composer.md). ```php 'all', +]; +``` + +If you need the option to be different for a single converter there are several ways to do it: + +#### 1. Prefixing + +Options prefixed with a converter id are only effective for that converter, and overrides the non-prefixed option. + +Ie, the following will set "metadata" to "all" for all converters, except *cwebp*, where "metadata" is set to "exif" + +```php +$options = [ + 'metadata' => 'all', + 'cwebp-metadata' => 'exif' +]; +``` + +Prefixing is by the way a general feature in the way options are handled and thus not confined to the stack converter. (though it admittedly only finds its use in the context of a stack converter). + + +#### 2. Using the `converter-options` option +The *converter-options* option is convenient for setting a whole bunch of converter-specific options in one go. + +Example: +```php +$options = [ + 'converter-options' => [ + 'wpc' => [ + 'crypt-api-key-in-transfer' => true + 'api-key' => 'my dog is white', + 'api-url' => 'https://example.com/wpc.php', + 'api-version' => 1, + ], + ], +] +``` + +#### 3. As part of the `converters` option +This option is explained further down this document. + + +## Modifying the stack + +The default stack consists of the following converters: +- cwebp +- vips +- imagick +- gmagick +- imagemagick +- graphicsmagick +- wpc +- ewww +- gd + +The order has carefully been chosen based on the capabilities of the converters. It is a rank, if you will. + +Now, say that on your system, you only have *gd* working. With the default stack, this means that eight converters will be tested for operationality before getting to *gd* – each time a conversion is made. You might be tempted to optimizing the flow by putting *gd* on the top. *I would generally advise against this* for the following reasons: + +1. It might be that one of the other (and better) converters starts working without you noticing. You will then miss out. +2. All converters have all been designed to exit very quickly when they are not operational. It only takes a few milliseconds for the library to detect that a converter is not operational - literally. For example, if no api key is provided for ewww, it will exit immediately. + +However, there are valid reasons to modify the stack. For example, you may prefer *vips* over *cwebp*, or you may wish to remove a converter completely due to problems with that converter on your platform. + +### Changing the order of the converters +To change the order, you can use the `preferred-converters` option. With this option you move selected converters to the top of the stack. + +So, if you want the stack to start with *vips* and then *ewww*, but keep the rest of the order, you can set the following: + +```php +$options[ + 'preferred-converters' => ['vips', 'ewww']; +]; +``` + +### Removing converters from the stack +To remove converters, you can use the `skip` option and prefixing. For example, to remove *cwebp* and *gd*: + +```php +$options = [ + 'ewww-skip' => true, + 'cwebp-skip' => true, +]; +``` + +### Adding converters to the stack +If you are using a custom converter, you can add it to the stack like this: + +```php +$options = [ + 'extra-converters' => [ + '\\MyNameSpace\\WonderConverter' + ], +]; +``` + +It will be added to the bottom of the stack. To place it differently, use the `preferred-converters` option and set it to ie `'preferred-converters' => ['vips','\\MyNameSpace\\WonderConverter']` + + +Here is an example which adds an extra ewww converter. This way you can have a backup api-key in case the quota of the first has been exceeded. + +``` +$options = [ + 'extra-converters' => [ + [ + 'converter' => 'ewww', + 'options' => [ + 'api-key' => 'provide-backup-key-here', + ] + ] + ] +]; +``` +Note however that you will not be able to reorder that new ewww converter using `preferred-converters`, as there are now two converters with id=ewww, and that option has not been designed for that. Instead, you can add a sub-stack of ewww converters - see the "Stacking" section below. + + +### Setting the converter array explicitly +Using the `converters` option, you can set the converter array explicitly. What differentiates this from the `preferred-converters` option (besides that it completely redefines the converter ordering) is that it allows you to set both the converters *and* options for each converter in one go and that it allows a complex structure - such as a stack within a stack. Also, this structure can simplify things in some cases, such as when the options is generated by a GUI, as it is in WebP Express. + +The array specifies the converters to try and their order. Each item can be: + +- An id (ie "cwebp") +- A fully qualified class name (in case you have programmed your own custom converter) +- An array with two keys: "converter" and "options". + +Example: + +```php +$options = [ + 'quality' => 71, + 'converters' => [ + 'cwebp', + [ + 'converter' => 'vips', + 'options' => [ + 'quality' => 72 + ] + ], + [ + 'converter' => 'ewww', + 'options' => [ + 'quality' => 73 + ] + ], + 'wpc', + 'imagemagick', + '\\MyNameSpace\\WonderConverter' + ], +]; +``` + +### Stacking +Stack converters behave just like regular converters. They ARE in fact "regular", as they extend the same base class as all converters. This means that you can have a stack within a stack. You can for example utilize this for supplying a backup api key for the ewww converter. Like this: + +```php +$options = [ + 'ewww-skip' => true, // skip the default ewww converter (we use stack of ewww converters instead) + 'extra-converters' => [ + [ + // stack of ewww converters + 'converter' => 'stack', + 'options' => [ + 'ewww-skip' => false, // do not skip ewww from here on + 'converters' => [ + [ + 'converter' => 'ewww', + 'options' => [ + 'api-key' => 'provide-preferred-key-here', + ] + ], + [ + 'converter' => 'ewww', + 'options' => [ + 'api-key' => 'provide-backup-key-here', + ] + ] + ], + ] + ] + ], + 'preferred-converters' => ['cwebp', 'vips', 'stack'], // set our stack of ewww converters third in queue +]; +``` +Note that we set `ewww-skip` in order to disable the *ewww* converter which is part of the defaults. As options are inherited, we have to reset this option again. These steps are not necessary when using the `converters` option. + +Also note that the options for modifying the converters (`converters`, `extra-converters`, `converter-options`) does not get passed down. + +Also note that if you want to add two stacks with `extra-converters`, the `preferred-converters` option will not work, as there are two converters called "stack". One workaround is to add those two stacks to their own stack, so you have three levels. Or you can of course simply use the `converters` option to get complete control. + + +### Shuffling + +The stack can be configured to shuffling, meaning that the the order will be random. This can for example be used to balance load between several wpc instances in a sub stack. + +Shuffling is enabled with the `shuffle` option. + +Here is an example of balancing load between several *wpc* instances: + +```php +$options = [ + 'wpc-skip' => true, // skip the default wpc converter (we use stack of wpc converters instead) + 'extra-converters' => [ + [ + // stack of wpc converters + 'converter' => 'stack', + 'options' => [ + 'wpc-skip' => false, // do not skip wpc from here on + 'shuffle' => true, + + 'converters' => [ + [ + 'converter' => 'wpc', + 'options' => [ + 'api-key' => 'my-dog', + 'api-url' => 'my-wpc.com/wpc.php', + 'api-version' => 1, + 'crypt-api-key-in-transfer' => true, + ] + ], + [ + 'converter' => 'wpc', + 'options' => [ + 'api-key' => 'my-other-dog', + 'api-url' => 'my-other-wpc.com/wpc.php', + 'api-version' => 1, + 'crypt-api-key-in-transfer' => true, + ] + ] + ], + ] + ] + ], + 'preferred-converters' => ['cwebp', 'vips', 'stack'], // set our stack of wpc converters third in queue +]; +``` diff --git a/docs/v2.0/converting/dice.png b/docs/v2.0/converting/dice.png new file mode 100644 index 00000000..80c7e958 Binary files /dev/null and b/docs/v2.0/converting/dice.png differ diff --git a/docs/v2.0/converting/introduction-for-converting.md b/docs/v2.0/converting/introduction-for-converting.md new file mode 100644 index 00000000..5958e3e0 --- /dev/null +++ b/docs/v2.0/converting/introduction-for-converting.md @@ -0,0 +1,251 @@ +# Introduction to converting with WebPConvert + +The library is able to convert images to webp using a variety of methods (*gd*, *imagick*, *vips* etc.), which we call "converters". A converter is called like this: + +```php +use WebPConvert\Convert\Converters\Gd; + +Gd::convert($source, $destination, $options=[], $logger=null); +``` + +All converters comes with requirements. For example, the *Gd* converter requires that Gd is installed and compiled with webp support. The cloud converters requires an api key. In case the conversion fails, an exception is thrown. + +## Insights to the process +If *$logger* is supplied, the converter will log the details of how the conversion process went to that logger. You can for example use the supplied *EchoLogger* to print directly to screen or the *BufferLogger* to collect the log entries. Here is a simple example which prints the process to screen: + +```php +use WebPConvert\Convert\Converters\Gd; +use WebPConvert\Loggers\EchoLogger; + +Gd::convert($source, $destination, $options=[], new EchoLogger()); +``` + +It will output something like this: + +```text +GD Version: 2.2.5 +image is true color +Quality set to same as source: 61 + +Converted image in 20 ms, reducing file size with 34% (went from 12 kb to 8 kb) +``` + +## The stack converter +When your software is going to be installed on a variety of systems which you do not control, you can try the converters one at the time until success. The converters has been designed to exit quickly when system requirements are not met. To make this task easy, a *Stack* converter has been created. + +The stack converter has two special options: + +| option | description | +| ------------------------- | ----------- | +| converters (array) | Converters to try (ids or class names, in case you have your own custom converter) | +| converter-options (array) | Extra options for specific converters. | + +Alternatively to the converter-options array, you can simply prefix options with the converter id. + +I recommend leave the converters array at the default unless you have strong reasons not to. Otherwise you might miss out when new converters are added. + +### Example: + +```php +use WebPConvert\Convert\Converters\Stack; + +Stack::convert($source, $destination, $options = [ + + // PS: only set converters if you have strong reasons to do so + 'converters' => [ + 'cwebp', 'vips', 'imagick', 'gmagick', 'imagemagick', 'graphicsmagick', 'wpc', 'ewww', 'gd' + ], + + // Any available options can be set here, they dribble down to all converters. + 'metadata' => 'all', + + // To override for specific converter, you can prefix with converter id: + 'cwebp-metadata' => 'exif', + + // This can for example be used for setting ewww api key: + 'ewww-api-key' => 'your-ewww-api-key-here', + + // As an alternative to prefixing, you can use "converter-options" to set a whole bunch of overrides in one go: + 'converter-options' => [ + 'wpc' => [ + 'crypt-api-key-in-transfer' => true + 'api-key' => 'my dog is white', + 'api-url' => 'https://example.com/wpc.php', + 'api-version' => 1, + ], + ], +], $logger=null); +``` + +Note: As an alternative to setting the third party credentials in the options, you can set them through constants or environment variables ("WEBPCONVERT_EWWW_API_KEY", "WEBPCONVERT_WPC_API_KEY", "WEBPCONVERT_WPC_API_URL"). Paths to binaries can also be set like that (it is rarely needed to do this): "WEBPCONVERT_CWEBP_PATH", "WEBPCONVERT_GRAPHICSMAGICK_PATH" and WEBPCONVERT_IMAGEMAGICK_PATH" + +To set an environment variable in Apache, you can add a line like this in your `.htaccess` or vhost configuration: +``` +# Set ewww api key for WebP Convert +SetEnv WEBPCONVERT_EWWW_API_KEY yourVerySecretApiKeyGoesHere + +# Set custom path to imagick for WebP Convert +SetEnv WEBPCONVERT_IMAGEMAGICK_PATH /usr/local/bin/magick +``` +To set a constant: +```php +define('WEBPCONVERT_IMAGEMAGICK_PATH', '/usr/local/bin/magick'); +``` + + +## Configuring the options + +### Preventing unnecessarily high quality setting for low quality jpegs +**Q:** What do you get if you convert a low quality jpeg (ie q=50) into a high quality webp (ie q=90) ?\ +**A:** You maintain the low quality, but you get a large file` + +What should we have done instead? We should have converted with a quality around 50. Of course, quality is still low - we cannot fix that - but it will not be less, *and the converted file will be much smaller*. + +As unnecessary large conversions are rarely desirable, this library per default limits the quality setting so it does not exceed that of the source. This functionality requires that either *imagemagick*, *graphicsmagick* or *imagick* is installed (not necessarily compiled with webp support). When they are, all converters will have the "auto-limit" functionality. Otherwise, only *wpc* will support it (provided that one of these libraries is installed on the server of the cloud service). + +How much can be gained? A lot! +The following low quality (q=50) jpeg weighs 54 kb. If this is converted to webp with quality=80, the size of the converted file is 52kb - almost no reduction! With auto-limit, the quality of the webp will be set to 50, and the size will be 34kb. Visually, the results are indistinguable. + +![A low quality jpeg](https://raw.githubusercontent.com/rosell-dk/webp-convert/master/docs/v2.0/converting/architecture-q50-w600.jpg) + +### Auto selecting between lossless/lossy encoding +WebP files can be encoded using either *lossless* or *lossy* encoding. The JPEG format is lossy and the PNG is lossless. However, this does not mean that you necessarily get the best conversion by always encoding JPEG to lossy and PNG to lossless. With JPEGs it is often the case, as they are usually pictures and pictures usually best encoded as lossy. With PNG it is however a different story, as you often can get a better compression using lossy encoding, also when using high quality level of say 85, which should be enough for the web. + +As unnecessary large conversions are rarely desirable, this library per default tries to convert images using both lossy and lossless encoding and automatically selects the smallest. This is controlled using the *encoding* option, which per default is "auto", but can also be set to "lossy" or "lossless". + +As an example, the following PNG (231 kb) will be compressed to 156 kb when converting to *lossless* webp. But when converting to *lossy* (quality: 85), it is compressed to merely 68 kb - less than half. (in case you are confused about the combination of lossy and transparency: Yes, you can have both at the same time with webp). + +![Dice](https://raw.githubusercontent.com/rosell-dk/webp-convert/master/docs/v2.0/converting/dice.png) + +Unless you changed the `near-lossless` option described below, the choice is actually between lossy and *near-lossless*. + +Note that *gd* and *ewww* does not support this feature. *gd* can only produce lossy, and will simply do that. *ewww* can not be configured to use a certain encoding, but automatically chooses *lossless* encoding for PNGs and lossy for JPEGs. + +### Near-lossless +*cwebp* and *vips* supports "near-lossless" mode. Near lossless produces a webp with lossless encoding but adjusts pixel values to help compressibility. The result is a smaller file. The price is described as a minimal impact on the visual quality. + +As unnecessary large conversions are rarely desirable, this library per default sets *near-lossless* to 60. To disable near-lossless, set it to 100. + +When compressing the image above (231 kb) to lossless, it compressed to 156 kb when near-lossless is set to 100. Setting near-lossless to 60 gets the size down to 110 kb while still looking great. + +You can read more about the near-lossless mode [here](https://groups.google.com/a/webmproject.org/forum/#!topic/webp-discuss/0GmxDmlexek) + +### Alpha-quality +All converters, except *gd* and *ewww* supports "alpha-quality" option. This allows lossy compressing of the alpha channel. + +As unnecessary large conversions are rarely desirable, this library per default sets *alpha-quality* to 85. Set it to 100 to achieve lossless compression of alhpa. + +Btw, the image above gets compressed to 68 kb with alpha quality set to 100. Surprisingly, it gets slightly larger (70 kb) with alpha quality set to 85. Setting alpha quality to 50 gets it down to merely 35 kb - about half - while still looking great. + +You can read more about the alpha-quality option [here](https://developers.google.com/speed/webp/docs/cwebp) and [here](https://www.smashingmagazine.com/2018/07/converting-images-to-webp/) + +### Sharp YUV +libwebp has an overlooked option which improves accuracy for RGB to YUV mapping at the price for longer conversion time. You can control it with the new 'sharp-yuv' option (introduced in webp-convert 2.6.0). Read an appraisal of the option [here](https://www.ctrl.blog/entry/webp-sharp-yuv.html). + +### Tip: don't set quality too high... +**Q:** What do you get if you convert an excessively high quality jpeg into an excessively high quality webp?\ +**A:** An excessively big file + +The size of a webp file grows enormously with the quality setting. For the web however, a quality above 75 is rarely needed. For this reason the library has a per default sets the quality to 75 for jpegs. + +So, how much can be gained? A lot! +The following excessively high quality jpeg (q=100) weighs 146 kb. Converting it to webp with quality=100 results in a 99kb image. Converting it to quality=85 results in a 40kb image. + +![A (too) high quality jpeg](https://raw.githubusercontent.com/rosell-dk/webp-convert/master/docs/v2.0/converting/mouse-q100.jpg) + +### PNG og JPEG-specific options. + +To have options depending on the image type of the source, you can use the `png` and `jpeg` keys. + +The following options mimics the default behaviour (version 2.0 - 2.5): + +```php +$options = [ + 'png' => [ + 'encoding' => 'auto', /* Try both lossy and lossless and pick smallest */ + 'near-lossless' => 60, /* The level of near-lossless image preprocessing (when trying lossless) */ + 'quality' => 85, /* Quality when trying lossy. It is set high because pngs is often selected to ensure high quality */ + 'sharp-yuv' => true, + ], + 'jpeg' => [ + 'encoding' => 'auto', /* If you are worried about the longer conversion time, you could set it to "lossy" instead (lossy will often be smaller than lossless for jpegs) */ + 'quality' => 'auto', /* Set to same as jpeg (requires imagick or gmagick extension, not necessarily compiled with webp) */ + 'max-quality' => 80, /* Only relevant if quality is set to "auto" */ + 'default-quality' => 75, /* Fallback quality if quality detection isnt working */ + 'sharp-yuv' => true, + ] +]; +``` +PS: From version 2.6 on, you should use the new "auto-limit" option instead of setting quality to "auto". + +The following options mimics the default behaviour (version 2.6 and forth): + +```php +$options = [ + 'png' => [ + 'encoding' => 'auto', /* Try both lossy and lossless and pick smallest */ + 'near-lossless' => 60, /* The level of near-lossless image preprocessing (when trying lossless) */ + 'quality' => 85, /* Quality when trying lossy. It is set high because pngs is often selected to ensure high quality */ + 'sharp-yuv' => true, + ], + 'jpeg' => [ + 'encoding' => 'auto', /* If you are worried about the longer conversion time, you could set it to "lossy" instead (lossy will often be smaller than lossless for jpegs) */ + 'quality' => 75, /* Quality when trying lossy. It is set a bit lower for jpeg than png */ + 'auto-limit' => true, /* Prevents using a higher quality than that of the source (requires imagick or gmagick extension, not necessarily compiled with webp) */ + 'sharp-yuv' => true, + ] +]; +``` + +The *png* and *jpeg* options can hold any other option - also the converter specific options. +A use case could for example be to use different converters for png and jpeg: + +```php +$options = [ + 'png' => [ + 'converters' => ['ewww'], + ], + 'jpeg' => [ + 'converters' => ['gd'], + ] +]; +``` + +## Available options + +**All** available options are documented [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md). + +Here is a quick overview of the few ones discussed here. + +### Version 2.0 - 2.5 + +| Option | Default (jpeg) | Default (png) | Description | +| ----------------- | ------------------ | ------------------- | ---------------------------------------------------------------------------------- | +| quality | "auto" | 85 | See the "Auto quality" section above. | +| max-quality | 85 | 85 | Only relevant for jpegs and when quality is set to "auto". | +| default-quality | 75 | 85 | | +| metadata | "none" | "none" | Valid values: "all", "none", "exif", "icc", "xmp".

Note: Currently only *cwebp* supports all values. *gd* will always remove all metadata. *ewww*, *imagick* and *gmagick* can either strip all, or keep all (they will keep all, unless metadata is set to *none*) | +| encoding | "auto" | "auto" | See the "Auto selecting between lossless/lossy encoding" section above | +| jpeg | - | - | Array of options which will be merged into the other options when source is a JPEG | +| png | - | - | Array of options which will be merged into the other options when source is a PNG | +| skip | false | false | If true, conversion will be skipped (ie for skipping png conversion for some converters) | + +### Version > 2.6 + +| Option | Default (jpeg) | Default (png) | Description | +| ----------------- | ------------------ | ------------------- | ---------------------------------------------------------------------------------- | +| quality | 75 | 85 | Quality for lossy encoding | +| auto-limit | true | true | Only relevant for jpegs and lossy encoding | +| metadata | "none" | "none" | Valid values: "all", "none", "exif", "icc", "xmp".

Note: Currently only *cwebp* supports all values. *gd* will always remove all metadata. *ewww*, *imagick* and *gmagick* can either strip all, or keep all (they will keep all, unless metadata is set to *none*) | +| encoding | "auto" | "auto" | See the "Auto selecting between lossless/lossy encoding" section above | +| jpeg | - | - | Array of options which will be merged into the other options when source is a JPEG | +| png | - | - | Array of options which will be merged into the other options when source is a PNG | +| skip | false | false | If true, conversion will be skipped (ie for skipping png conversion for some converters) | + + +## More info + +- The complete api is available [here](https://www.bitwise-it.dk/webp-convert/api/2.0/html/index.xhtml) +- The converters are described in more detail here (for 1.3.9): [docs/v1.3/converting/converters.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/converting/converters.md). +- On the github wiki you can find installation instructions for imagick with webp, gd with webp, etc. +- This document is a newly written introduction to the convert api, which has been created as part of the 2.0 release. The old introduction, which was made for 1.3 is available here: [docs/converting/v1.3/convert.md](https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/converting/convert.md). diff --git a/docs/v2.0/converting/mouse-q100.jpg b/docs/v2.0/converting/mouse-q100.jpg new file mode 100644 index 00000000..bb586d83 Binary files /dev/null and b/docs/v2.0/converting/mouse-q100.jpg differ diff --git a/docs/v2.0/converting/options.md b/docs/v2.0/converting/options.md new file mode 100644 index 00000000..4ff42922 --- /dev/null +++ b/docs/v2.0/converting/options.md @@ -0,0 +1,403 @@ +# Options + +This is a list of all options available for converting. + +Note that as the *stack* and *wpc* converters delegates the options to their containing converters, the options that they supports depend upon the converters they have been configured to use (and which of them that are operational)

+ +## General options + +### `alpha-quality` +``` +Type: integer (0-100) +Default: 85 +Supported by: cwebp, vips, imagick, gmagick, imagemagick and graphicsmagick +``` +Quality of alpha channel. Often, there is no need for high quality transparency layer and in some cases you can tweak this all the way down to 10 and save a lot in file size. The option only has effect with lossy encoding, and of course only on images with transparency (so it is irrelevant when converting jpegs). Read more about tweaking the option [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#alpha-quality)

+ +### `auto-filter` +``` +Type: boolean +Default: false +Supported by: cwebp, vips, imagick, gmagick, imagemagick and graphicsmagick +``` +Turns auto-filter on. This algorithm will spend additional time optimizing the filtering strength to reach a well-balanced quality. Unfortunately, it is extremely expensive in terms of computation. It takes about 5-10 times longer to do a conversion. A 1MB picture which perhaps typically takes about 2 seconds to convert, will takes about 15 seconds to convert with auto-filter. So in most cases, you will want to leave this at its default, which is off.

+ +### `auto-limit` +``` +Type: boolean +Default: true +Supported by: all +``` +Limits the quality to be no more than that of the jpeg. The option is only relevant when converting jpegs to lossy webp. To be functional, webp-convert needs to be able to detect the quality of the jpeg, which requires ImageMagick or GraphicsMagick. Read about the option in the [introduction](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#auto-quality). In 2.7.0, it will become possible to adjust the limit with a new option. I'm currently debating with myself how this should work. Your comments and opinions would be appreciated - [here](https://github.com/rosell-dk/webp-convert/issues/289) + +### `converter` (new in 2.8.0) +``` +Type: string +Default: null +Supported by: WebPConvert::convert method +``` +Simplifies using a specific converter. Before this option, you would either need to call the converter class (ie `Ewww::convert`) (not very flexible), or set the stack to contain just one converter (unnecessary overhead). If you do not use this option, `WebPConvert::convert` works as normal (it calls `Stack::convert`), if you do use it, it hands over the converting to the converter specified (specified by id, ie. "cwebp"). + +### `default-quality` (DEPRECATED) +``` +Type: integer (0-100) +Default: 75 for jpegs and 85 for pngs +Supported by: all (cwebp, ewww, gd, ffmpeg, gmagick, graphicsmagick, imagick, imagemagick, vips) +``` +This option has been deprecated. See why [here](https://github.com/rosell-dk/webp-convert/issues/281). It was used to determine the quality in case auto limiting was not available.

+ +### `encoding` +``` +Type: string ("lossy" | "lossless" | "auto") +Default: "auto" +Supported by: cwebp, vips, ffmpeg, imagick, gmagick, imagemagick and graphicsmagick (gd always uses lossy encoding, ewww uses lossless for pngs and lossy for jpegs) +``` +Set encoding for the webp. If you choose "auto", webp-convert will convert to both lossy and lossless and pick the smallest result. Read more about this option in the ["lossy/lossless" section in the introduction](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#auto-selecting-between-losslesslossy-encoding).

+ +### `ewww-api-key` +``` +Type: string +Default: '' +Supported by: ewww +``` +Api key for the ewww converter. The option is actually called *api-key*, however, any option can be prefixed with a converter id to only apply to that converter. As this option is only for the ewww converter, it is natural to use the "ewww-" prefix. + +Note: This option can alternatively be set through the *EWWW_API_KEY* environment variable.

+ +### `ewww-check-key-status-before-converting` +``` +Type: boolean +Default: true +Supported by: ewww +``` +Decides whether or not the ewww service should be invoked in order to check if the api key is valid. Doing this for every conversion is not optimal. However, it would be worse if the service was contacted repeatedly to do conversions with an invalid api key - as conversion requests carries a big upload with them. As this library cannot prevent such repeated failures (it is stateless), it per default does the additional check. However, your application can prevent it from happening by picking up invalid / exceeded api keys discovered during conversion. Such failures are stored in `Ewww::$nonFunctionalApiKeysDiscoveredDuringConversion` (this is also set even though a converter later in the stack succeeds. Do not only read this value off in a catch clauses). + +You should only set this option to *false* if you handle when the converter discovers invalid api keys during conversion. + +### `jpeg` +``` +Type: array +Default: [] +Supported by: all +``` +Override selected options when the source is a jpeg. The options provided here are simply merged into the other options when the source is a jpeg. +Read about this option in the [introduction](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#png-og-jpeg-specific-options).

+ +### `log-call-arguments` +``` +Type: boolean +Default: false +Supported by: all +``` +Enabling this simply puts some more in the log - namely the arguments that was supplied to the call. Sensitive information is starred out. + +### `low-memory` +``` +Type: boolean +Default: false +Supported by: cwebp, imagick, imagemagick and graphicsmagick +``` +Reduce memory usage of lossy encoding at the cost of ~30% longer encoding time and marginally larger output size. Only effective when the *method* option is 3 or more. Read more in [the docs](https://developers.google.com/speed/webp/docs/cwebp).

+ +### `max-quality` (DEPRECATED) +``` +Type: integer (0-100) +Default: 85 +Supported by: all (cwebp, ewww, ffmpeg, gd, gmagick, graphicsmagick, imagick, imagemagick, vips) +``` +This option has been deprecated. See why [here](https://github.com/rosell-dk/webp-convert/issues/281)

+ +### `metadata` +``` +Type: string ("all" | "none" | "exif" | "icc" | "xmp" | "exif,icc" | "exif,xmp" | "icc,xmp") +Default: 'none' +Supported by: Only *cwebp* supports "exif", "icc" and "xmp". *gd* cannot copy metadata. *ffmpeg* always copies metadata. The rest supports "all" and "none" (ewww, gmagick, graphicsmagick, imagick, imagemagick, vips) +``` +Determines which metadata that should be copied over to the webp. Setting it to "all" preserves all metadata, setting it to "none" strips all metadata. *cwebp* can take a comma-separated list of which kinds of metadata that should be copied (ie "exif,icc"). *gd* will always remove all metadata and *ffmpeg* will always keep all metadata. The rest can either strip all or keep all (they will keep all, unless the option is set to *none*).

+ +### `method` +``` +Type: integer (0-6) +Default: 6 +Supported by: cwebp, vips, imagick, gmagick, imagemagick, graphicsmagick and ffmpeg +``` +This parameter controls the trade off between encoding speed and the compressed file size and quality. Possible values range from 0 to 6. 0 is fastest. 6 results in best quality. PS: "method" is not a very descriptive name, but this is what its called in libwebp, which is why we also choose it for webpconvert. In ffmpeg, they renamed it "compression_level", in vips, they call it "reduction_effort". Both better names, but as said, use "method" with webpconvert

+ +### `near-lossless` +``` +Type: integer (0-100) +Default: 60 +Supported by: cwebp, vips +``` +This option allows you to get impressively better compression for lossless encoding, with minimal impact on visual quality. The result is still lossless (lossless encoding). What libwebp does is that it preprocesses the image before encoding it, in order to make it better suited for compression. The range is 0 (maximum preprocessing) to 100 (no preprocessing). A good compromise would be around 60. The option is ignored when encoding is set to lossy. Read more [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#near-lossless).

+ +### `png` +``` +Type: array +Default: [] +Supported by: all +``` +Override selected options when the source is a png. The options provided here are simply merged into the other options when the source is a png. +Read about this option in the [introduction](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#png-og-jpeg-specific-options).

+ +### `preset` +``` +Type: string ('none', 'default', 'photo', 'picture', 'drawing', 'icon' or 'text') +Default: "none" +Supported by: cwebp, vips, gmagick, graphicsmagick, imagick, imagemagick, ffmpeg +``` +Using a preset will set many of the other options to suit a particular type of source material. It even overrides them. It does however not override the quality option. "none" means that no preset will be set. PS: The imagemagick family only partly supports this setting, as they have grouped three of the options ("drawing", "icon" and "text") into "graph". So if you for example set "preset" to "icon" with the imagemagick converter, imagemagick will be executed like this: "-define webp:image-hint='graph'".

+ +### `quality` +``` +Type: integer (0-100) | "auto" ("auto" is now deprecated - use the "auto-limit" option instead) +Default: 75 for jpegs and 85 for pngs +Supported by: all (cwebp, ewww, gd, gmagick, graphicsmagick, imagick, imagemagick, vips, ffmpeg) +``` +Quality for lossy encoding.

+ +### `sharp-yuv` +``` +Type: boolean +Default: true +Supported by: cwebp, vips, gmagick, graphicsmagick, imagick, imagemagick +``` +Better RGB->YUV color conversion (sharper and more accurate) at the expense of a little extra conversion time. Read more [here](https://www.ctrl.blog/entry/webp-sharp-yuv.html). + +### `size-in-percentage` +``` +Type: integer (0-100) | null +Default: null +Supported by: cwebp +``` +This option sets the file size, *cwebp* should aim for, in percentage of the original. If you for example set it to *45*, and the source file is 100 kb, *cwebp* will try to create a file with size 45 kb (we use the `-size` option). This is an excellent alternative to the "quality:auto" option. If the quality detection isn't working on your system (and you do not have the rights to install imagick or gmagick), you should consider using this options instead. *Cwebp* is generally able to create webp files with the same quality at about 45% the size. So *45* would be a good choice. The option overrides the quality option. And note that it slows down the conversion - it takes about 2.5 times longer to do a conversion this way, than when quality is specified. Default is *off* (null).

+ +### `skip` +``` +Type: boolean +Default: false +Supported by: all +``` +Simply skips conversion. For example this can be used to skip png conversion for a specific converter like this: +```php +$options = [ + 'png' => [ + 'gd-skip' => true, + ] +]; +``` + +Or it can be used to skip unwanted converters from the default stack, like this: +```php +$options = [ + 'ewww-skip' => true, + 'wpc-skip' => true, + 'gd-skip' => true, + 'imagick-skip' => true, + 'gmagick-skip' => true, +]; +``` +
+ +### `use-nice` +``` +Type: boolean +Default: false +Supported by: cwebp, graphicsmagick, imagemagick, ffmpeg +``` +This option only applies to converters which are using exec() to execute a binary directly on the host. If *use-nice* is set, it will be examined if the [`nice`]( https://en.wikipedia.org/wiki/Nice_(Unix)) command is available on the host. If it is, the binary is executed using *nice*. This assigns low priority to the process and will save system resources - but result in slower conversion.

+ + +# Options unique for individual converters + +## cwebp options +Options unique to the "cwebp" converter + +### `command-line-options` +``` +Type: string +Default: '' +Supported by: cwebp +``` +This allows you to set any parameter available for cwebp in the same way as you would do when executing *cwebp*. You could ie set it to "-sharpness 5 -mt -crop 10 10 40 40". Read more about all the available parameters in [the docs](https://developers.google.com/speed/webp/docs/cwebp).

+ +### `rel-path-to-precompiled-binaries` +``` +Type: string +Default: './Binaries' +Supported by: cwebp +``` +Allows you to change where to look for the precompiled binaries. While this may look as a risk, it is completely safe, as the binaries are hash-checked before being executed. The option is needed when you are using two-file version of webp-on-demand. + +### `try-cwebp` +``` +Type: boolean +Default: true +Supported by: cwebp +``` +If set, the converter will try executing "cwebp -version". In case it succeeds, and the version is higher than those working cwebp's found using other methods, the conversion will be done by executing this cwebp. + +### `try-common-system-paths` +``` +Type: boolean +Default: true +Supported by: cwebp +``` +If set, the converter will look for a cwebp binaries residing in common system locations such as `/usr/bin/cwebp`. If such exist, it is assumed that they are valid cwebp binaries. A version check will be run on the binaries found (they are executed with the "-version" flag. The cwebp with the highest version found using this method and the other enabled methods will be used for the actual conversion. + +This method might find a cwebp binary something that isn't found using `try-discovering-cwebp` if these common paths are not within PATH or neither `which` or `whereis` are available. + +Note: All methods for discovering cwebp binaries are per default enabled. You can save a few microseconds by disabling all, but the one that discovers the cwebp binary with the highest version (check the conversion log to find out). However, it is probably not worth it, as your setup will then become less resilient to system changes. + +### `try-discovering-cwebp` +``` +Type: boolean +Default: true +Supported by: cwebp +``` +If set, the converter will try to discover installed cwebp binaries using the `which -a cwebp` command, or in case that fails, the `whereis -b cwebp` command. These commands will find cwebp binaries residing in PATH + +### `try-supplied-binary-for-os` +``` +Type: boolean +Default: true +Supported by: cwebp +``` +If set, the converter will try use a precompiled cwebp binary that comes with webp-convert. But only if it has a higher version that those found by other methods. As the library knows the versions of its cwebps, no additional time is spent executing them with the "-version" parameter. The binaries are hash-checked before executed. The library btw. comes with several versions of precompiled cwebps because they have different dependencies - some works on some systems and others on others. + +### `skip-these-precompiled-binaries` +``` +Type: string +Default: '' +Supported by: cwebp +``` +The precompiled binaries from google have dependencies, and they are different. This means that some of them works on some systems, others on others. For this reason, several precompiled binaries are shipped with the library - we want it to simply work on as many systems as possible. Of course, the binary with the highest version number is tried first. But if it doesn't work, time has been wasted running an executable that doesn't work, and validating the hash before running it. To avoid this, use this option to bypass precompiled binaries that you know doesn't work on your current system. You pass in the filenames (comma separated), ie "cwebp-120-linux-x86-64,cwebp-110-linux-x86-64". In order to see if time is wasted on a supplied binary, that doesn't work, check the conversion log. You can also get info about the filenames of the binaries in the conversion log. Instructions on viewing the conversion log are available [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#insights-to-the-process). +Btw: If minimizing the overhead is a priority, there are alternatives to this option that speeds up conversion time more. It is the hash-check that is costly. Hash-checking is only done on the cwebps shipped with the library. + +Alternative 1: Disabling the "cwebp-try-supplied-binary-for-os" option thus avoids the rather expensive job of hash-checking the binary each time it is run. The cost of this is that you don't get the newest cwebp available (the ones shipped with the library will only be used when you don't have a newer one available). + +Alternative 2: If you set an environment variable called "WEBPCONVERT_CWEBP_PATH" (or define a "WEBPCONVERT_CWEBP_PATH" variable in PHP), cwebp will simply execute the binary found at that path and not examine other alternatives. Also, there will be no hash check either. Doing so however makes your system a little bit less secure - exactly because it bypasses the hash-checking. If some security whole allows an attacker to upload a binary, replacing the one set like this, an attacker would then have a way to have that binary executed. Here is how you define the variable in PHP: `define("WEBPCONVERT_CWEBP_PATH", "/path/to/working/cwebp/for/example/one/in/src/Convert/Converters/Binaries/dir");`. Also beware that by doing this, you will need to update your code in order to take advantage of future cwebp releases. + +## stack options +Options unique to the "stack" converter + +### `stack-converters` +``` +Type: array +Default: ['cwebp', 'vips', 'imagick', 'gmagick', 'imagemagick', 'graphicsmagick', 'wpc', 'ewww', 'gd'] +Supported by: stack +``` + +Specify the converters to try and their order. + +Beware that if you use this option, you will miss out when more converters are added in future updates. If the purpose of setting this option is to remove converters that you do not want to use, you can use the *skip* option instead. Ie, to skip ewww, set *ewww-skip* to true. On the other hand, if what you actually want is to change the order, you can use the *stack-preferred-converters* option, ie setting *stack-preferred-converters* to `['vips', 'wpc']` will move vips and wpc in front of the others. Should they start to fail, you will still have the others as backup. + +The array specifies the converters to try and their order. Each item can be: + +- An id (ie "cwebp") +- A fully qualified class name (in case you have programmed your own custom converter) +- An array with two keys: "converter" and "options". + +` +Alternatively, converter options can be set using the *converter-options* option. + +Read more about the stack converter in the [introduction](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#the-stack-converter).

+ +### `stack-converter-options` +``` +Type: array +Default: [] +Supported by: stack +``` +Extra options for specific converters. Example: + +```php +$options = [ + 'converter-options' => [ + 'vips' => [ + 'quality' => 72 + ], + ] +] +``` +
+ +### `stack-extra-converters` +``` +Type: array +Default: [] +Supported by: stack +``` +Add extra converters to the bottom of the stack. The items are similar to those in the `stack-converters` option.

+ +### `stack-preferred-converters` +``` +Type: array +Default: [] +Supported by: stack +``` +With this option you can move specified converters to the top of the stack. The converters are specified by id. For example, setting this option to ['vips', 'wpc'] ensures that *vips* will be tried first and - in case that fails - *wpc* will be tried. The rest of the converters keeps their relative order.

+ +### `stack-shuffle` +``` +Type: boolean +Default: false +Supported by: stack +``` +Shuffle the converters in the stack. This can for example be used to balance load between several wpc instances in a substack, as illustrated [here](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/converters/stack.md)

+ +## vips options + +### `vips-smart-subsample` (DEPRECATED) +``` +Type: boolean +Default: false +Supported by: vips +``` +This feature seemed not to be part of *libwebp* but intrinsic to vips. However, we were wrong - the feature is the same as 'sharp-yuv'. Use that instead.

+ + +## wcp options + +### `wpc-api-key` +``` +Type: string +Default: '' +Supported by: wpc +``` +Api key for the wpc converter. The option is actually called *api-key*, however, any option can be prefixed with a converter id to only apply to that converter. As this option is only for the wpc converter, it is natural to use the "wpc-" prefix. Same goes for the other "wpc-" options. + +Note: You can alternatively set the api key through the *WPC_API_KEY* environment variable.

+ +### `wpc-api-url` +``` +Type: string +Default: '' +Supported by: wpc +``` +Note: You can alternatively set the api url through the *WPC_API_URL* environment variable.

+ +### `wpc-api-version` +``` +Type: integer (0 - 1 - 2) +Default: 2 +Supported by: wpc +``` +PS: In many releases, you had to set this to 1 even though you were running on 2. This will be fixed in 2.9.0 +
+ +### `wpc-crypt-api-key-in-transfer` +``` +Type: boolean +Default: false +Supported by: wpc +``` +
+ +### `wpc-secret` +``` +Type: string +Default: '' +Supported by: wpc +``` +Note: This option is only relevant for api version 0. diff --git a/docs/v2.0/installing-converters/cwebp.md b/docs/v2.0/installing-converters/cwebp.md new file mode 100644 index 00000000..c749a086 --- /dev/null +++ b/docs/v2.0/installing-converters/cwebp.md @@ -0,0 +1,3 @@ +# Installing cwebp using official precompilations + +Official precompilations are available [here](https://developers.google.com/speed/webp/docs/precompiled). Since `WebPConvert` compares each binary's checksum first, you will have to change the checksums hardcoded in `Converters/Cwebp.php` if you want to replace any of them. If you feel the need of using another binary, please let us know - chances are that it should be added to the project! diff --git a/docs/v2.0/installing-converters/ffmpeg.md b/docs/v2.0/installing-converters/ffmpeg.md new file mode 100644 index 00000000..73cba30e --- /dev/null +++ b/docs/v2.0/installing-converters/ffmpeg.md @@ -0,0 +1,15 @@ +# Installing FFMpeg +Its very easy. + + +# Ubuntu +Here is a tutorial for ubuntu: https://linuxize.com/post/how-to-install-ffmpeg-on-ubuntu-18-04/ + +## GitHub actions workflow +As easy as adding this step to your workflow yaml: + +```yaml +- name: Setup ffmpeg + uses: FedericoCarboni/setup-ffmpeg@v1 +``` +docs: https://github.com/marketplace/actions/setup-ffmpeg diff --git a/docs/v2.0/installing-converters/gd.md b/docs/v2.0/installing-converters/gd.md new file mode 100644 index 00000000..41981755 --- /dev/null +++ b/docs/v2.0/installing-converters/gd.md @@ -0,0 +1,19 @@ +# Installing Gd extension with WebP support + +## Ubuntu 18.04 + +On Ubuntu 18.04, I did not have to do anything special to configure Gd for WebP support. The following worked right away: +``` +sudo apt-get install php7.2-gd +``` + +## Ubuntu 16.04 +The official page with installation instructions is [available here](http://il1.php.net/manual/en/image.installation.php) + +In summary: + +PHP 5.5.0: +To get WebP support for `gd` in PHP 5.5.0, PHP must be configured with the `--with-vpx-dir` flag. + +PHP >7.0.0: +PHP has to be configured with the `--with-webp-dir` flag diff --git a/docs/v2.0/installing-converters/gmagick-extension.md b/docs/v2.0/installing-converters/gmagick-extension.md new file mode 100644 index 00000000..ea1ee9d4 --- /dev/null +++ b/docs/v2.0/installing-converters/gmagick-extension.md @@ -0,0 +1,40 @@ +# Installing GMagick PHP extension with WebP support + +See: +https://github.com/rosell-dk/webp-convert/issues/37 + +## MX-19.4 +I succeeded by simply doing the following after installing graphicsmagick, libwebp and libwebp-dev: +``` +sudo apt install php-gmagick +sudo service apache2 restart +``` +Note: For some reason this disables the imagick extension. It seems they cannot both be installed at the same time. + + +## Ubuntu 18.04, using *PECL* +In Ubuntu 18.04, you will not have to do any special steps in order to compile with webp :) + +1. Find out which version of PHP you are using and the location of the relevant php.ini file. Both of these can be obtained with `phpinfo();` +2. Find out which is the latest version of *gmagick* on pecl. https://pecl.php.net/package/gmagick +3. Do the following - but alter to use the info you just collected + +``` +sudo apt-get update +sudo apt-get install graphicsmagick gcc libgraphicsmagick1-dev php-pear php7.2-dev +sudo pecl install gmagick-2.0.5RC1 +sudo echo "extension=gmagick.so" >> /etc/php/7.2/apache2/php.ini +sudo service apache2 restart +``` + +Notes: +- The php-pear contains *pecl*. +- *php7.2-dev* provides *phpize*, which is needed by pecl. Use *php7.1-dev*, if you are on PHP 7.1 +- We do not simply do a `pecl install gmagick` because the latest package is in beta, and pecl would not allow. You should however be able to do *pecl install gmagick-beta*, which should install the latest beta. +- If you are on *fpm*, remember to restart that as well (ie `sudo service php7.2-fpm restart`) + +## Plesk +https://support.plesk.com/hc/en-us/articles/115003511013-How-to-install-Gmagick-PHP-extension-on-Ubuntu-Debian- + +## From source +https://duntuk.com/how-install-graphicsmagick-gmagick-php-extension diff --git a/docs/v2.0/installing-converters/imagick-extension.md b/docs/v2.0/installing-converters/imagick-extension.md new file mode 100644 index 00000000..9453be8b --- /dev/null +++ b/docs/v2.0/installing-converters/imagick-extension.md @@ -0,0 +1,80 @@ +# Installing Imagick extension with WebP support + +## MX-19.4 +I succeeded by simply doing the following after installing imagemagick, libwebp and libwebp-dev: +``` +sudo apt install php-imagick +sudo service apache2 restart +``` + +## Ubuntu 16.04 +In order to get imagick with WebP on Ubuntu 16.04, you (currently) need to: +1. [Compile libwebp from source](https://developers.google.com/speed/webp/docs/compiling) +2. [Compile imagemagick from source](https://www.imagemagick.org/script/install-source.php) (```./configure --with-webp=yes```) +3. Compile php-imagick from source, phpize it and add ```extension=/path/to/imagick.so``` to php.ini + +## Ubuntu 18.04 (from source) +A simple `sudo apt-get install php-imagick` unfortunately does not give you webp support. +Again, you must: + +### 1. Compile libwebp from source +Instructions are [here](https://developers.google.com/speed/webp/docs/compiling). +In short, you need to: +``` +sudo apt-get install libjpeg-dev libpng-dev +wget https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.1.0.tar.gz +tar xvzf libwebp-1.1.0.tar.gz +cd into the dir +./configure +make +sudo make install +``` + +### 2. Compile *imagemagick* from source, configured with *webp* +See tutorial [here](https://linuxconfig.org/how-to-install-imagemagick-7-on-ubuntu-18-04-linux), but configure with *webp* (`./configure --with-webp=yes`) + +``` +sudo apt-get update +sudo apt build-dep imagemagick +wget https://imagemagick.org/download/ImageMagick.tar.gz +tar xvzf ImageMagick.tar.gz +cd into the dir +./configure --with-webp=yes +sudo make +sudo make install +sudo ldconfig /usr/local/lib +sudo identify -version # to check if installed ok +make check # optional run in-depth check +``` +Check it this way: `identify -list format | grep WEBP` +- It should print a line + +### 3a. Install extension with pecl +First find out which version of PHP you are using and the location of the relevant *php.ini* file. Both of these can be obtained with `phpinfo();`. Next do the following (but alter to use the info you just collected): + +``` +sudo apt-get update +sudo apt-get install imagemagick gcc libmagickwand-dev php-pear php7.2-dev +sudo pecl install imagick +sudo echo "extension=imagick.so" >> /etc/php/7.2/apache2/php.ini +sudo service apache2 restart +``` +Related: +https://askubuntu.com/questions/769396/how-to-install-imagemagick-for-php7-on-ubuntu-16-04 + + +### 3b. Alternively to using pecl, compile php-imagick from source +https://github.com/mkoppanen/imagick +First find out which version of PHP you are using and the location of the relevant *php.ini* file. Both of these can be obtained with `phpinfo();`. Next do the following (but alter to use the info you just collected): + +``` +wget https://pecl.php.net/get/imagick-3.4.3.tgz +tar xvzf imagick-3.4.3.tgz +cd into the dir +sudo /usr/bin/phpize7.2 # note: find you version of phpize with locate phpize +./configure +make +make install +sudo echo "extension=imagick.so" >> /etc/php/7.2/apache2/php.ini +sudo service apache2 restart +``` diff --git a/docs/v2.0/installing-converters/vips.md b/docs/v2.0/installing-converters/vips.md new file mode 100644 index 00000000..e7353a0c --- /dev/null +++ b/docs/v2.0/installing-converters/vips.md @@ -0,0 +1,35 @@ +# Installing vips extension + +### Step 1: Install the vips library +Follow the instructions on the [vips library github page](https://github.com/libvips/libvips/) + +Don't forget to install required packages before running `./configure`: +``` +sudo apt-get install libglib2.0-dev pkg-config build-essential libexpat1-dev libjpeg-dev libpng-dev libwebp-dev gobject-introspection libgs-dev +``` + +### Step 2: Install the vips extension + +``` +sudo pecl install vips +``` +– And add the following to the relevant php.ini: +``` +extension=vips +``` + +(or `extension=vips.so` if you are in older PHP) + +The vips extension is btw [also on github](https://github.com/libvips/php-vips-ext): + + +## GitHub actions workflow +As easy as adding "vips" to the extensions for `setup-php@v2`: + +```yaml +- name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + extensions: vips +``` diff --git a/docs/v2.0/migrating-to-2.0.md b/docs/v2.0/migrating-to-2.0.md new file mode 100644 index 00000000..94bb8fcd --- /dev/null +++ b/docs/v2.0/migrating-to-2.0.md @@ -0,0 +1,73 @@ +convert# Migrating to 2.0 + +## Converting + +### Changes in conversion api +While the code have been refactored quite extensively, if you have stuck to `WebPConvert::convert()` and/or `WebPConvert::convertAndServe()`, there is only a few things you need to know. + +First and foremost: *`WebPConvert::convert` no longer returns a boolean indicating the result*. So, if conversion fails, an exception is thrown, no matter what the reason is. When migrating, you will probably need to remove some lines of code where you test the result. + +Also, a few options has been renamed and a few option defaults has been changed. + +#### The options that has been renamed are the following: + +- Two converters have changed IDs and class names: The ids that are changed are: *imagickbinary* => *imagemagick* and *gmagickbinary* => *graphicsmagick* +- In *ewww*, the `key` option has been renamed to `api-key` (or [`ewww-api-key`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#ewww-api-key)) +- In *wpc*, the `url` option has been renamed to `api-url` (or [`wpc-api-url`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#wpc-api-url)) +* In *cwebp*, the [`lossless`] option is now replaced with the new `encoding` option (which is not boolean, but "lossy", "lossless" or ["auto"](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md#auto-selecting-between-losslesslossy-encoding)) +* In *cwebp*, the [`autofilter`] option has been renamed to "auto-filter" +- In *gd*, the `skip-pngs` option has been removed and replaced with the general `skip` option and prefixing. So `gd-skip` amounts to the same thing, but notice that Gd no longer skips per default. + +#### The option defaults that has been changed are the following: +- the `converters` default now includes the cloud converters (*ewww* and *wpc*) and also two new converters, *vips* and *graphicsmagick*. So it is not necessary to add *ewww* or *wpc* explicitly. Also, when you set options with `converter-options` and point to a converter that isn't in the stack, in 1.3.9, this resulted in the converter automatically being added. This behavior has been removed. +- *gd* no longer skips pngs per default. To make it skip pngs, set `gd-skip` to *true* +- Default quality is now 75 for jpegs and 85 for pngs (it was 75 for both) +- For *cwebp*, the `lossless` has been removed. Use the new `encoding` option instead. +- For *wpc*, default `secret` and `api-key` are now "" (they were "my dog is white") + +### New convert options +You might also be interested in the new options available in 2.0: + +- Added a syntax for conveniently targeting specific converters. If you for example prefix the "quality" option with "gd-", it will override the "quality" option, but only for gd. +- Certain options can now be set with environment variables too ("EWWW_API_KEY", "WPC_API_KEY" and "WPC_API_URL") +- Added new *vips* converter. +- Added new *graphicsmagick* converter. +- Added new *stack* converter (the stack functionality has been moved into a converter) +- Added [`jpeg`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#jpeg) and [`png`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#png) options +- Added [`alpha-quality`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#alpha-quality) option for *cwebp*, *vips*, *imagick*, *imagemagick* and *graphicsmagick*. +- Added [`auto-filter`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#autofilter) option for *cwebp*, *imagick*, *imagemagick* and the new *vips* converter. +- Added [`encoding`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#encoding) option (lossy | lossless | auto). lossless and auto is supported for *cwebp*, *imagick*, *imagemagick*, *graphicsmagick* and the new *vips* converter. +- Added [`near-lossless`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#near-lossless) option for *cwebp* and *imagemagick*. +- Added [`preset`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#preset) option for *cwebp* and the new *vips* converter. +- Added [`skip`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#skip) option (its general and works for all converters) +- Besides the ones mentioned above, *imagemagick* now also supports [`low-memory`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#low-memory), [`metadata`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#metadata) ("all" or "none") and [`method`](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md#method). *imagemagick* has become very potent! + +## Serving +The classes for serving has also been refactored quite extensively, but again, if you have stuck to `WebPConvert::convertAndServe`, there is only a few things you need to know. + +First and foremost, *`WebPConvert::convertAndServe` has been renamed to `WebPConvert::serveConverted()`*. The reason for this change is that it more accurately describes what is happening: A converted file is served. The old name implied that a conversion was always going on, which is not the case (if the file at destination already exists, which is not bigger or older than the source, that file is served directly). + +Besides this, there is the following changes in options: + +- A new option `convert` has been created for supplying the conversion options. So the conversion options are no longer "mingled" with the serving options, but has its own option. +- Options regarding serving the image are now organized into its own `serve-image` setting, which again has been reorganized. +- A new option `serve-image > headers > cache-control` controls whether to set cache control header (default: false). +- The `fail` option no longer support the "report-as-image" value. It however supports a new value: "throw". +- The `fail-when-original-unavailable` option has been renamed to `fail-when-fail-fails`. In 2.0, the original not being available is no longer the only thing that can cause the fail action to fail – the library now checks the mime type of the source file and only serves it if it is either png or jpeg. +- The `error-reporting` option has been removed. The reason for it being removed is that it is considered bad practice for a library to mess with error handling. However, *this pushes the responsibility to you*. You should make sure that no warnings ends up in the output, as this will corrupt the image being served. You can for example ensure that by calling `ini_set('display_errors', '0');` or `error_reporting(0);` (or both), or by creating your own error handler. +- The `aboutToServeImageCallBack` option has been removed. You can instead extend the `ServeConvertedWebP` class and override `serveOriginal` and `serveDestination`. You can call the serve method of your extended class, but then you will not have the error handling (the `fail` and `fail-if-fail-fails` options). Too add this, you can call `ServeConvertedWebPWithErrorHandling::serve` and make sure to override the default of the last argument. +- The `aboutToPerformFailAction` option has been removed. You can instead set `fail` to `throw` and handle the exception in a *catch* clause. Or you can extend the `ServeConvertedWebPWithErrorHandling` class and override the `performFailAction` method. +- The `add-x-header-status` and `add-x-header-options` options have been removed. +- The `require-for-conversion` option has been removed. You must either use with composer or create a simple autoloader (see next section) + +## WebP On demand +If you are using the "non-composer" version of webp demand (the one where you only upload two files - `webp-on-demand-1.inc` and `webp-on-demand-2.inc`), you were probably using the `require-for-conversion` option. This option is no longer supported. But you never really needed it in the first place, because the you create and register an autoloader instead: + +```php +function autoloader($class) { + if (strpos($class, 'WebPConvert\\') === 0) { + require_once __DIR__ . '/webp-on-demand-2.inc'; + } +} +spl_autoload_register('autoloader', true, true); +``` diff --git a/docs/v2.0/serving/introduction-for-serving.md b/docs/v2.0/serving/introduction-for-serving.md new file mode 100644 index 00000000..08e605d0 --- /dev/null +++ b/docs/v2.0/serving/introduction-for-serving.md @@ -0,0 +1,157 @@ +# Introduction to serving converted WebP files with WebPConvert + +**NOTE: This document only applies to the upcoming 2.0 version** + +The classes for serving first and foremost helps you handle the cached files intelligently (not serving them if they are larger or older than the original). It also provides a convenient way to deal with conversion failures and setting headers. + + +In the following example, all available *serve* options are explicitly set to their default values. + +```php +use WebPConvert\WebPConvert; + +WebPConvert::serveConverted($source, $destination, [ + + // failure handling + 'fail' => 'original', // ('original' | 404' | 'throw' | 'report') + 'fail-when-fail-fails' => 'throw', // ('original' | 404' | 'throw' | 'report') + + // options influencing the decision process of what to be served + 'reconvert' => false, // if true, existing (cached) image will be discarded + 'serve-original' => false, // if true, the original image will be served rather than the converted + 'show-report' => false, // if true, a report will be output rather than the raw image + + // warning handling + 'suppress-warnings' => true, // if you set to false, make sure that warnings are not echoed out! + + // options when serving an image (be it the webp or the original, if the original is smaller than the webp) + 'serve-image' => [ + 'headers' => [ + 'cache-control' => true, + 'content-length' => true, + 'content-type' => true, + 'expires' => false, + 'last-modified' => true, + 'vary-accept' => false + ], + 'cache-control-header' => 'public, max-age=31536000', + ], + + // redirect tweak + 'redirect-to-self-instead-of-serving' => false, // if true, a redirect will be issues rather than serving + + 'convert' => [ + // options for converting goes here + 'quality' => 'auto', + ] +]); +``` + +## Failure handling +The `fail` option gives you an easy way to handle errors. Setting it to 'original' tells it to handle errors by serving the original file instead (*$source*). This could be a good choice on production servers. On development servers, 'throw' might be a good option. It simply rethrows the exception that was thrown by *WebPConvert::convert()*. '404' could also be an option, but it has the weakness that it will probably only be discovered by real persons seeing a missing image. + +The fail action might fail too. For example, if it is set to 'original' and the failure is that the original file doesn't exist. Or, more delicately, it may have a wrong mime type - our serve method will not let itself be tricked into serving *exe* files as the 'original'. Anyway, you can control what to do when fail fails using the *fail-when-fail-fails* option. If that fails too, the original exception is thrown. The fun stops there, there is no "fail-when-fail-when-fail-fails" option to customize this. + +The failure handling is implemented as an extra layer. You can bypass it by calling `WebPConvert\Serve\ServeConvertedWebP::serve()` directly. Doing that will give the same result as if you set `fail` to 'throw'. + +## Options influencing the decision process +The default process is like this: + +1. Is there a file at the destination? If not, trigger conversion +2. Is the destination older than the source? If yes, delete destination and trigger conversion +3. Serve the smallest file (destination or source) + +You can influence the process with the following options: + +*reconvert* +If you set *reconvert* to true, the destination and conversion is triggered (between step 1 and 2) + +*serve-original* +If you set *serve-original* to true, process will take its cause from (1) to (2) and then end with source being served. + +*show-report* +If you set `show-report`, the process is skipped entirely, and instead a report is generated of how a fresh conversion using the supplied options goes. + +## Headers +Leaving errors and reports out of account for a moment, the *WebPConvert::serveConverted()* ultimately has two possible outcomes: Either a converted image is served or - if smaller - the source image. If the source is to be served, its mime type will be detected in order to make sure it is an image and to be able to set the content type header. Either way, the actual serving is passed to `Serve\ServeFile::serve`. The main purpose of this class is to add/set headers. + +#### *Cache-Control* and *Expires* headers +Default behavior is to neither set the *Cache-Control* nor the *Expires* header. Once you are on production, you will probably want to turn these on. The default is btw one year (31536000 seconds). I recommend the following for production: + +``` +'serve-image' => [ + 'headers' => [ + 'cache-control' => true, + 'expires' => false, + ], + 'cache-control-header' => 'public, max-age=31536000', +], +``` + +The value for the *Expires* header is calculated from "max-age" found in the *cache-control-header* option and the time of the request. The result is an absolute time, ie "Expires: Thu, 07 May 2020 07:02:37 GMT". As most browsers now supports the *Cache-Control* header, *from a performance perspective*, there is no need to also add the expires header. However, some tools complains if you don't (gtmetrix allegedly), and there is no harm in adding both headers. More on this discussion [[here]](https://github.com/rosell-dk/webp-convert/issues/126). + +#### *Vary: Accept* header +This library can be used as part of a solution that serves webp files to browsers that supports it, while serving the original file to browsers that does not *on the same URL*. Such a solution typically inspects the *Accept* request header in order to determine if the client supports webp or not. Thus, the response will *vary* along with the "Accept" header and the world (and proxies) should be informed about this, so they don't end up serving cached webps to browsers that does not support it. To add the "Vary: Accept" header, simply set the *serve-image > headers > vary-accept* option to true. + +#### *Last-Modified* header +The Last-Modified header is also used for caching purposes. You should leave that setting on, unless you set it by other means. You control it with the *serve-image > headers > last-modified* option. + +#### *Content-Type* header +The *Content-Type* header tells browsers what they are receiving. This is important information and you should leave the *serve-image > headers > content-type* option at its default (true), unless you set it by other means. + +When the outcome is to serve a webp, the header will be set to: "Content-Type: image/webp". When the original is to be served, the library will try to detect the mime type of the file and set the content type accordingly. The [image-mime-type-guesser](https://github.com/rosell-dk/image-mime-type-guesser) library is used for that. + +#### *Content-Length* header +The *Content-Length* header tells browsers the length of the content. According to [the specs](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.13), it should be set unless it is prohibited by rules in [section 4.4](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.4). In that section we learn that it should not be set when the *Transfer-Encoding* header is set (which it often is, to "chunked"). However, no harm done, because it also says that clients should ignore the header in case *Transfer-Encoding* is set. From this I concluded that it makes sense to default the *serve-image > headers > content-length* to true. I might however change this in case I should learn that the header could be problematic in some way. So if you decided you want it, do not rely on the default, but set it to *true*. See discussion on this subject [here](https://stackoverflow.com/questions/3854842/content-length-header-with-head-requests/3854983#3854983). + +#### *X-WebP-Convert-Log* headers +The serve method adds *X-WebP-Convert-Log* headers in order to let you know what went on. +For example, if there is no converted image and conversion was successful, the following headers will be sent: + +``` +X-WebP-Convert-Log: Converting (there were no file at destination) +X-WebP-Convert-Log: Serving converted file +``` + +On the next call (presuming the webp has not been deleted), no conversion is needed and you should simply see: +``` +X-WebP-Convert-Log: Serving converted file +``` + +But say that the first conversion actually failed. In case you have permission problems, the output could be: +``` +X-WebP-Convert-Log: Converting (there were no file at destination) +X-WebP-Convert-Log: Failed creating folder. Check the permissions! +X-WebP-Convert-Log: Performing fail action: original +``` + +In case the problem is that the conversion failed, you could see the following: +``` +X-WebP-Convert-Log: Converting (there were no file at destination) +X-WebP-Convert-Log: None of the converters in the stack are operational +X-WebP-Convert-Log: Performing fail action: original +``` + +If you need more info about the conversion process in order to learn why the converters aren't working, enable the *show-report* option. + +As a last example, say you have supplied a non-existing file as source and `fail` is set to "original" (which will also fail). Result: +``` +X-WebP-Convert-Log: Source file was not found +X-WebP-Convert-Log: Performing fail action: original +X-WebP-Convert-Log: Performing fail action: throw +``` + +## The redirect tweak (will be available in 2.3.0) +There are cases where serving the image directly with PHP isn't optimal. + +One case is WP Engine. Even though webp-convert adds a Vary:Accept header, the header is not present in the response on WP Engine. It is somehow overwritten by the caching machinery and set to Vary:Accept-Encoding, Cookie. + +If however rules have been set up to redirect images directly to existing webps, one can overcome the problem by redirecting the image request back to itself rather than serving the webp directly. + +You can achieve this by setting the *redirect-to-self-instead-of-serving* option to true. + +Beware of risk of an endless redirect loop. Such loop will happen if the redirection to existing webp rules aren't set up correctly. To prevent this, it is recommended that you only set the option to true after checking that the destination file does not exist. But note that this check does not completely prevent such loops occurring when redirection to existing rules are missing - as the 302 redirect could get cached (it does that on WP Engine). So bottom line: Only use this feature when you have server rules set up for redirecting images to their corresponding webp images (for client that supports webp) - *and you are certain that these rules works*. + +## More info + +- The complete api is available [here](https://www.bitwise-it.dk/webp-convert/api/2.0/html/index.xhtml) diff --git a/docs/v2.0/serving/laravel-nginx-serving.md b/docs/v2.0/serving/laravel-nginx-serving.md new file mode 100644 index 00000000..d4a91186 --- /dev/null +++ b/docs/v2.0/serving/laravel-nginx-serving.md @@ -0,0 +1,116 @@ +# Serving WebP from a Laravel Nginx site + +**NOTE: This document only applies to the upcoming 2.0 version** + +This should work with most php sites although I'm basing the Nginx configuration around what's commonly seen with Laravel installations. + +Create webp converter script in ```project_root/public/webp-on-demand.php``` + +``` + 'original', // If failure, serve the original image (source). Other options include 'throw', '404' and 'report' + // 'show-report' => true, // Generates a report instead of serving an image + + 'serve-image' => [ + 'headers' => [ + 'cache-control' => true, + 'vary-accept' => true, + // other headers can be toggled... + ], + 'cache-control-header' => 'max-age=2', + ], + +'convert' => [ + // all convert option can be entered here (ie "quality") + ], +]); + +``` + + +### Configure Nginx + +We just need to add the following block to our site in ```/etc/sites-enabled/``` + +``` +location ~* ^/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; + if ($http_accept !~* "webp"){ + break; + } + try_files + $uri.webp + /webp-on-demand.php?source=$uri + ; +} +``` + +Then reload Nginx ```sudo systemctl restart nginx``` + +The full Nginx block should look like + +``` +server { + server_name webp-testing.com; + root /home/forge/webp-testing.com/public; + + index index.html index.htm index.php; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~* ^/.*\.(png|jpe?g)$ { + add_header Vary Accept; + expires 365d; + if ($http_accept !~* "webp"){ + break; + } + try_files + $uri.webp + /webp-on-demand.php?source=$uri + ; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + access_log off; + error_log /var/log/nginx/webp-testing.com-error.log error; + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass unix:/var/run/php/php7.3-fpm.sock; + fastcgi_index index.php; + include fastcgi_params; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + # cache static assets + location ~* \.(gif|ico|css|pdf|svg)$ { + expires 365d; + } + + location ~* \.(js)$ { + add_header Cache-Control no-cache; + } + +} +``` diff --git a/docs/v2.0/webp-on-demand/tweaks.md b/docs/v2.0/webp-on-demand/tweaks.md new file mode 100644 index 00000000..585b81c0 --- /dev/null +++ b/docs/v2.0/webp-on-demand/tweaks.md @@ -0,0 +1,181 @@ +# Tweaks + +## Store converted images in separate folder + +In most cases, you probably want the cache of converted images to be stored in their own folder rather than have them mingled with the source files. + +To have have the cache folder contain a file structure mirroring the structure of the original files, you can do this: + +```php +$applicationRoot = $_SERVER["DOCUMENT_ROOT"]; // If your application is not in document root, you can change accordingly. +$imageRoot = $applicationRoot . '/webp-images'; // Change to where you want the webp images to be saved +$sourceRel = substr($source, strlen($applicationRoot)); +$destination = $imageRoot . $sourceRel . '.webp'; +``` + +If your images are stored outside document root (a rare case), you can simply use the complete absolute path: +```php +$destination = $imageRoot . $source . '.webp'; // pst: $source is an absolute path, and starts with '/' +``` +This will ie store a converted image in */var/www/example.com/public_html/app/webp-images/var/www/example.com/images/logo.jpg.webp* + +If your application can be configured to store outside document root, but rarely is, you can go for this structure: + +```php +$docRoot = $_SERVER["DOCUMENT_ROOT"]; +$imageRoot = $contentDirAbs . '/webp-images'; + +if (substr($source, 0, strlen($docRoot)) === $docRoot) { + // Source file is residing inside document root. + // We can store relative to that. + $sourceRel = substr($source, strlen($docRoot)); + $destination = $imageRoot . '/doc-root' . $sourceRel . '.webp'; +} else { + // Source file is residing outside document root. + // we must add complete path to structure + $destination = $imageRoot . '/abs' . $source . '.webp'; +} +``` + +If you do not know the application root beforehand, and thus do not know the appropriate root for the converted images, see next tweak. + + +## Get the application root automatically +When you want destination files to be put in their own folder, you need to know the root of the application (the folder in which the .htaccess rules resides). In most applications, you know the root. In many cases, it is simply the document root. However, if you are writing an extension, plugin or module to a framework that can be installed in a subfolder, you may have trouble finding it. Many applications have a *index.php* in the root, which can get it with `__DIR__`. However, you do not want to run an entire bootstrap each time you serve an image. Obviously, to get around this, you can place *webp-on-demand.php* in the webroot. However, some frameworks, such as Wordpress, will not allow a plugin to put a file in the root. Now, how could we determine the application root from a file inside some subdir? Here are three suggestions: + +1. You could traverse parent folders until you find a file you expect to be in application root (ie a .htaccess containing the string "webp-on-demand.php"). This should work. +2. If the rules in the *.htaccess* file are generated by your application, you probably have access to the path at generation time. You can then simply put the path in the *.htaccess*, as an extra parameter to the script (or better: the relative path from document root to the application). +3. You can use the following hack: + +### The hack +The idea is to grab the URL path of the image in the *.htaccess* and pass it to the script. Assuming that the URL paths always matches the file paths, we can get the application root by subtracting that relative path to source from the absolute path to source. + +In *.htaccess*, we grab the url-path by appending "&url-path=$1.$2" to the rewrite rule: +``` +RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME}&url-path=$1.$2 [NC,L] +``` + +In the script, we can then calculate the application root like this: + +```php +$applicationRoot = substr($_GET['source'], 0, -strlen($_GET['url-path'])); +``` + +## CDN +To work properly with a CDN, a "Vary Accept" header should be added when serving images. This is a declaration that the response varies with the *Accept* header (recall that we inspect *Accept* header in the .htaccess to determine if the browsers supports webp images). If this header is missing, the CDN will see no reason to cache separate images depending on the Accept header. + +Add this snippet to the *.htaccess* to make webp-on-demand work with CDN's: + +``` + + SetEnvIf Request_URI "\.(jpe?g|png)" ADDVARY + + # Declare that the response varies depending on the accept header. + # The purpose is to make CDN cache both original images and converted images. + Header append "Vary" "Accept" env=ADDVARY + +``` + +***Note:*** When configuring the CDN, you must make sure to set it up to forward the the "Accept" header to your origin server. + + + +## Make .htaccess route directly to existing images + +There may be a performance benefit of using the *.htaccess* file to route to already converted images, instead of letting the PHP script serve it. Note however: +- If you do the routing in .htaccess, the solution will not be able to discard converted images when original images are updated. +- Performance benefit may be insignificant (*WebPConvertAndServe* class is not autoloaded when serving existing images) + +Add the following to the *.htaccess* to make it route to existing converted images. Place it above the # Redirect images to webp-on-demand.php" comment. Take care of replacing [[your-base-path]] with the directory your *.htaccess* lives in (relative to document root, and [[your-destination-root]] with the directory the converted images resides. +``` + # Redirect to existing converted image (under appropriate circumstances) + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{DOCUMENT_ROOT}/[[your-base-path]]/[[your-destination-root]]/$1.$2.webp -f + RewriteRule ^\/?(.*)\.(jpe?g|png)$ /[[your-base-path]]/[[your-destination-root]]/$1.$2.webp [NC,T=image/webp,L] +``` +*edit:* Removed the QSD flag from the RewriteRule because it is not supported in Apache < 2.4 (and it [triggers error](https://github.com/rosell-dk/webp-express/issues/155)) + +Note however that DOCUMENT_ROOT can be unreliable. + +If you store the webp images in the same folder as the originals and append ".webp" (rather than replace the file extension), you can do this instead: + +``` +# Redirect to existing converted image (under appropriate circumstances) +RewriteCond %{HTTP_ACCEPT} image/webp +RewriteCond %{REQUEST_FILENAME}.webp -f +RewriteRule ^/?(.+)\.(jpe?g|png)$ $1.$2.webp [T=image/webp,L] +``` + + +RewriteCond %{REQUEST_FILENAME}.webp -f + +### Redirect with CDN support +If you are using a CDN, and want to redirect to existing images with the .htaccess, it is a good idea to add a "Vary Accept" header. This instructs the CDN that the response varies with the *Accept* header (we do not need to do that when routing to webp-on-demand.php, because the script takes care of adding this header, when appropriate.) + +You can achieve redirect with CDN support with the following rules: +``` + + + RewriteEngine On + + # Redirect to existing converted image (under appropriate circumstances) + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{DOCUMENT_ROOT}/[[your-base-path]]/[[your-destination-root]]/$1.$2.webp -f + RewriteRule ^\/?(.*)\.(jpe?g|png)$ /[[your-base-path]]/[[your-destination-root]]/$1.$2.webp [NC,T=image/webp,QSD,E=WEBPACCEPT:1,L] + + # Redirect images to webp-on-demand.php (if browser supports webp) + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME}&url-path=$1.$2 [NC,L] + + + + + # Apache appends "REDIRECT_" in front of the environment variables, but LiteSpeed does not. + # These next line is for Apache, in order to set environment variables without "REDIRECT_" + SetEnvIf REDIRECT_WEBPACCEPT 1 WEBPACCEPT=1 + + # Make CDN caching possible. + # The effect is that the CDN will cache both the webp image and the jpeg/png image and return the proper + # image to the proper clients (for this to work, make sure to set up CDN to forward the "Accept" header) + Header append Vary Accept env=WEBPACCEPT + + +AddType image/webp .webp +``` + +## Forward the querystring +By forwarding the query string, you can allow control directly from the URL. You could for example make it possible to add "?debug" to an image URL, and thereby getting a conversion report. Or make "?reconvert" force reconversion. + +In order to forward the query string, you need to add this condition before the RewriteRule that redirects to *webp-on-demand.php*: +``` +RewriteCond %{QUERY_STRING} (.*) +``` +That condition will always be met. The side effect is that it stores the match (the complete querystring). That match will be available as %1 in the RewriteRule. So, in the RewriteRule, we will have to add "&%1" after the last argument. Here is a complete solution: +``` + + RewriteEngine On + + # Redirect images to webp-on-demand.php (if browser supports webp) + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{QUERY_STRING} (.*) + RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME}&%1 [NC,L] + + +AddType image/webp .webp +``` + +Of course, in order to *do* something with that querystring, you must use them in your *webp-on-demand.php* script. You could for example use them directly in the options array sent to the *convertAndServe()* method. To achieve the mentioned "debug" and "reconvert" features, do this: +```php +$options = [ + 'show-report' => isset($_GET['debug']), + 'reconvert' => isset($_GET['reconvert']), + 'serve-original' => isset($_GET['original']), +]; +``` + +*EDIT:* +I have just discovered a simpler way to achieve the querystring forward: The [QSA flag](https://httpd.apache.org/docs/trunk/rewrite/flags.html). +So, simply set the QSA flag in the RewriteRule, and nothing more: +``` +RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME} [NC,QSA,L] +``` diff --git a/docs/v2.0/webp-on-demand/webp-on-demand.md b/docs/v2.0/webp-on-demand/webp-on-demand.md new file mode 100644 index 00000000..68445243 --- /dev/null +++ b/docs/v2.0/webp-on-demand/webp-on-demand.md @@ -0,0 +1,145 @@ +# WebP on demand + +This is a solution for automatically serving WebP images instead of jpeg/pngs [for browsers that supports WebP](https://caniuse.com/#feat=webp) (At the time of writing, 78% of all mobile users and 72% of all desktop users uses browsers supporting webp) + +Once set up, it will automatically convert images, no matter how they are referenced. It for example also works on images referenced in CSS. As the solution does not require any change in the HTML, it can easily be integrated into any website / framework + +## Overview + +A setup consists of a PHP script that serves converted images and some *redirect rules* that redirects JPG/PNG images to the script. + + +## Requirements + +* *Apache* or *LiteSpeed* web server. Can be made to work with *NGINX* as well. Documentation is on the roadmap. +* *mod_rewrite* module for Apache +* PHP >= 5.6 (we are only testing down to 5.6. It should however work in 5.5 as well) +* That one of the *webp-convert* converters are working (these have different requirements) + +## Installation + +Here we assume you are using Composer. [Not using composer? - Follow me!](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/without-composer.md) + +### 1. Require the webp-convert library with composer +``` +composer require rosell-dk/webp-convert +``` + +### 2. Create the script + +Create a file *webp-on-demand.php*, and place it in webroot, or where-ever you like in you web-application. + +Here is a minimal example to get started with: + +```php + true // Show a conversion report instead of serving the converted image. + + // More options available! + // https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md + // https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/serving/introduction-for-serving.md +]; +WebPConvert::serveConverted($source, $destination, $options); +``` + +### 3. Add redirect rules +Place the following rewrite rules in a *.htaccess* file in the directory where you want the solution to take effect: + +``` + + RewriteEngine On + + # Redirect images to webp-on-demand.php (if browser supports webp) + RewriteCond %{HTTP_ACCEPT} image/webp + RewriteCond %{REQUEST_FILENAME} -f + RewriteRule ^(.*)\.(jpe?g|png)$ webp-on-demand.php?source=%{SCRIPT_FILENAME} [NC,L] + + +AddType image/webp .webp +``` +If you have placed *webp-on-demand.php* in a subfolder, you will need to change the rewrite rule accordingly. + +The `RewriteCond %{REQUEST_FILENAME} -f` is not strictly necessary, but there to be sure that we got an existing file, and it could perhaps also prevent some undiscovered way of misuse. + +### 4. Validate that it works + +Browse to a JPEG image. Instead of an image, you should see a conversion report. Hopefully, you get a success. Otherwise, you need to hook up to a cloud converter or try to meet the requirements for cwebp, gd or imagick. + +Once you get a successful conversion, you can uncomment the "show-report" option in the script. + +It should work now, but to be absolute sure: + +- Visit a page on your site with an image on it, using *Google Chrome*. +- Right-click the page and choose "Inspect" +- Click the "Network" tab +- Reload the page +- Find a jpeg or png image in the list. In the "type" column, it should say "webp". There should also be a *X-WebP-Convert-Status* header on the image that provides some insights on how things went. + + +### 5. Try this improvement and see if it works + +It seems that it is not necessary to pass the filename in the query string. + +Try replacing `$source = $_GET['source'];` in the script with the following: + +```php +$docRoot = rtrim($_SERVER["DOCUMENT_ROOT"], '/'); +$requestUriNoQS = explode('?', $_SERVER['REQUEST_URI'])[0]; +$source = $docRoot . urldecode($requestUriNoQS); +``` + +And you can then remove `?source=%{SCRIPT_FILENAME}` from the `.htaccess` file. + +There are some benefits of not passing in query string: +1. Passing a path in the query string may be blocked by a firewall, as it looks suspicious. +2. The script called to convert arbitrary files +3. One person experienced problems with spaces in filenames passed in the query string. See [this issue](https://github.com/rosell-dk/webp-convert/issues/95) + + +### 6. Customizing and tweaking + +Basic customizing is done by setting options in the `$options` array. Check out the [docs on convert()](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/convert.md) and the [docs on convertAndServe()](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/serving/convert-and-serve.md) + +Other tweaking is described in *docs/webp-on-demand/tweaks.md*: +- [Store converted images in separate folder](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/tweaks.md#store-converted-images-in-separate-folder) +- [CDN](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/tweaks.md#cdn) +- [Make .htaccess route directly to existing images](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/tweaks.md#make-htaccess-route-directly-to-existing-images) +- [Forward the query string](https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/webp-on-demand/tweaks.md#forward-the-querystring) + + +## Troubleshooting + +### The redirect rule doesn't seem to be working +If images are neither routed to the converter or a 404, it means that the redirect rule isn't taking effect. Common reasons for this includes: + +- Perhaps there are other rules in your *.htaccess* that interfere with the rules? +- Perhaps your site is on *Apache*, but it has been configured to use *Nginx* to serve image files. To find out which server that is handling the images, browse to an image and eximine the "Server" response header. In case *NGINX* are serving images, see if you can reconfigure your server setup. Alternatively, you can create *NGINX* rewrite rules. There are some [here](https://github.com/S1SYPHOS/kirby-webp#nginx) and [there](https://github.com/uhop/grunt-tight-sprite/wiki/Recipe:-serve-WebP-with-nginx-conditionally). +- Perhaps the server isn't configured to allow *.htaccess* files? Try inserting rubbish in the top of the *.htaccess* file and refresh. You should now see an *Internal Server Error* error page. If you don't, your *.htaccess* file is ignored. Probably you will need to set *AllowOverride All* in your Virtual Host. [Look here for more help]( +https://docs.bolt.cm/3.4/howto/making-sure-htaccess-works#test-if-htaccess-is-working) +- Perhaps the Apache *mod_rewrite* extension isn't enabled? Try removing both `` and `` lines: if you get an *Internal Server Error* error page after this change, it's probably that it's indeed not enabled. + + +## Related +* https://www.maxcdn.com/blog/how-to-reduce-image-size-with-webp-automagically/ +* https://www.digitalocean.com/community/tutorials/how-to-create-and-serve-webp-images-to-speed-up-your-website diff --git a/docs/v2.0/webp-on-demand/without-composer.md b/docs/v2.0/webp-on-demand/without-composer.md new file mode 100644 index 00000000..9707c7b3 --- /dev/null +++ b/docs/v2.0/webp-on-demand/without-composer.md @@ -0,0 +1,58 @@ +# WebP On Demand without composer + +For your convenience, the library has been cooked down to two files: *webp-on-demand-1.inc* and *webp-on-demand-2.inc*. The second one is loaded when the first one decides it needs to do a conversion (and not simply serve existing image). + +## Installing + +### 1. Copy the latest build files into your website +The build files are distributed [here](https://github.com/rosell-dk/webp-convert-concat/tree/master/build). Open the "latest" folder and copy *webp-on-demand-1.inc* and *webp-on-demand-2.inc* into your website. They can be located wherever you like. + +### 2. Create a *webp-on-demand.php* + +Create a file *webp-on-demand.php*, and place it in webroot, or where-ever you like in you web-application. + +Here is a minimal example to get started with: + +```php + true // Show a conversion report instead of serving the converted image. + + // More options available! + // https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/introduction-for-converting.md + // https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/serving/introduction-for-serving.md +]; +WebPConvert::serveConverted($source, $destination, $options); +``` + +Note that the procedure has changed in 2.0. In 1.x, the library supported a `require-for-conversion` option, but this option has been removed in 2.0. It was not really needed, as the example above illustrates. + +### 3. Continue the regular install instructions from step 3 +[Click here to continue...](https://github.com/rosell-dk/webp-on-demand#3-add-redirect-rules) diff --git a/phpcs-ruleset.xml b/phpcs-ruleset.xml new file mode 100644 index 00000000..37d5689d --- /dev/null +++ b/phpcs-ruleset.xml @@ -0,0 +1,8 @@ + + + PSR2 without line ending rule - let git manage the EOL cross the platforms + + + + + diff --git a/phpdox.xml b/phpdox.xml new file mode 100644 index 00000000..f36de322 --- /dev/null +++ b/phpdox.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..7c68b53f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,36 @@ +parameters: + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - '#Instantiated class Imagick not found.#' + - '#Instantiated class Gmagick not found.#' + - '#Caught class ImagickException not found.#' + - '# on an unknown class Imagick.#' + - '# on an unknown class Gmagick.#' + - '#Call to an undefined method Gmagick::getImageBlob\(\).#' + - '#Strict comparison using === between resource and false will always evaluate to false.#' + - '#Strict comparison using === between CurlHandle and false will always evaluate to false.#' + - '#Return typehint of method WebPConvert\\Convert\\Converters\\Ewww::initCurl\(\) has invalid type CurlHandle.#' + - '#Return typehint of method WebPConvert\\Convert\\Converters\\Wpc::initCurl\(\) has invalid type CurlHandle.#' + - '#Function vips_call invoked with 4 parameters, 2 required.#' + - '#Function vips_.* not found.#' + - + message: '#SystemRequirementsNotMet#' + path: %currentWorkingDirectory%/src/Convert/Converters/ConverterTraits/ExecTrait.php + - + message: '#Call to function is_callable\(\) with array.* always evaluate to false#' + path: %currentWorkingDirectory%/src/Convert/ConverterFactory.php + - '#.+invalid typehint type GdImage#' + - '#.+invalid type GdImage#' + - '#.+never returns GdImage so it can be removed from the return typehint.#' + - '#Method WebPConvert\\Convert\\Converters\\Gd::createImageResource\(\) never returns resource so it can be removed from the return typehint.#' + - '#Method WebPConvert\\Convert\\Converters\\Gd::createImageResource\(\) never returns resource so it can be removed from the return type.#' + - '#Method WebPConvert\\Convert\\Converters\\Gd::createImageResource\(\) has invalid return type GdImage.#' + - '#Method WebPConvert\\Convert\\Converters\\Gd::createImageResource\(\) never returns GdImage so it can be removed from the return type.#' + - + message: '#Dead catch - Exception is never thrown in the try block.#' + path: %currentWorkingDirectory%/src/Convert/Converters/Ewww.php + - '#initCurl\(\) has invalid return type CurlHandle.#' + - + message: '#Unreachable statement - code above always terminates.#' + path: %currentWorkingDirectory%/src/Convert/Helpers/PhpIniSizes.php + - '#WebPConvert\\Convert\\Converters\\Cwebp::composeMeaningfullErrorMessageNoVersionsWorking\(\) is unused.#' diff --git a/phpunit-41.xml.dist b/phpunit-41.xml.dist new file mode 100644 index 00000000..cc512382 --- /dev/null +++ b/phpunit-41.xml.dist @@ -0,0 +1,39 @@ + + + + + + ./tests/ + + + + + + src/ + + ./vendor + ./tests + + + + + + + + + + + + diff --git a/phpunit-with-coverage.xml.dist b/phpunit-with-coverage.xml.dist new file mode 100644 index 00000000..15c96730 --- /dev/null +++ b/phpunit-with-coverage.xml.dist @@ -0,0 +1,25 @@ + + + + + src/ + + + ./vendor + ./tests + + + + + + + + + + ./tests/ + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b031889a..dfa1fc4e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,30 +1,8 @@ - - - - - - ./tests/ - - - - - - ./ - - ./vendor - ./tests - - - - + + + + + ./tests/ + + + diff --git a/src/Convert/ConverterFactory.php b/src/Convert/ConverterFactory.php new file mode 100644 index 00000000..01a16b46 --- /dev/null +++ b/src/Convert/ConverterFactory.php @@ -0,0 +1,112 @@ + + * @since Class available since Release 2.0.0 + */ +class ConverterFactory +{ + /** + * Get classname of a converter (by id) + * + * @param string $converterId Id of converter (ie "cwebp") + * + * @throws ConverterNotFoundException If there is no converter with that id. + * @return string Fully qualified class name of converter + */ + public static function converterIdToClassname($converterId) + { + switch ($converterId) { + case 'ffmpeg': + $classNameShort = 'FFMpeg'; + break; + case 'imagickbinary': + $classNameShort = 'ImagickBinary'; + break; + case 'imagemagick': + $classNameShort = 'ImageMagick'; + break; + case 'gmagickbinary': + $classNameShort = 'GmagickBinary'; + break; + case 'graphicsmagick': + $classNameShort = 'GraphicsMagick'; + break; + default: + $classNameShort = ucfirst($converterId); + } + $className = 'WebPConvert\\Convert\\Converters\\' . $classNameShort; + if (is_callable([$className, 'convert'])) { + return $className; + } else { + throw new ConverterNotFoundException('There is no converter with id:' . $converterId); + } + } + + /** + * Make a converter instance by class name. + * + * @param string $converterClassName Fully qualified class name + * @param string $source The path to the file to convert + * @param string $destination The path to save the converted file to + * @param array $options (optional) + * @param \WebPConvert\Loggers\BaseLogger $logger (optional) + * + * @throws ConverterNotFoundException If the specified converter class isn't found + * @return AbstractConverter An instance of the specified converter + */ + public static function makeConverterFromClassname( + $converterClassName, + $source, + $destination, + $options = [], + $logger = null + ) { + if (!is_callable([$converterClassName, 'convert'])) { + throw new ConverterNotFoundException( + 'There is no converter with class name:' . $converterClassName . ' (or it is not a converter)' + ); + } + //$converter = new $converterClassName($source, $destination, $options, $logger); + + return call_user_func( + [$converterClassName, 'createInstance'], + $source, + $destination, + $options, + $logger + ); + } + + /** + * Make a converter instance by either id or class name. + * + * @param string $converterIdOrClassName Either a converter ID or a fully qualified class name + * @param string $source The path to the file to convert + * @param string $destination The path to save the converted file to + * @param array $options (optional) + * @param \WebPConvert\Loggers\BaseLogger $logger (optional) + * + * @throws ConverterNotFoundException If the specified converter class isn't found + * @return AbstractConverter An instance of the specified converter + */ + public static function makeConverter($converterIdOrClassName, $source, $destination, $options = [], $logger = null) + { + // We take it that all lowercase means it is an id rather than a class name + if (strtolower($converterIdOrClassName) == $converterIdOrClassName) { + $converterClassName = self::converterIdToClassname($converterIdOrClassName); + } else { + $converterClassName = $converterIdOrClassName; + } + + return self::makeConverterFromClassname($converterClassName, $source, $destination, $options, $logger); + } +} diff --git a/src/Convert/Converters/AbstractConverter.php b/src/Convert/Converters/AbstractConverter.php new file mode 100644 index 00000000..685785d9 --- /dev/null +++ b/src/Convert/Converters/AbstractConverter.php @@ -0,0 +1,387 @@ + + * @since Class available since Release 2.0.0 + */ +abstract class AbstractConverter +{ + use AutoQualityTrait; + use OptionsTrait; + use WarningLoggerTrait; + use DestinationPreparationTrait; + use LoggerTrait; + + /** + * The actual conversion is be done by a concrete converter extending this class. + * + * At the stage this method is called, the abstract converter has taken preparational steps. + * - It has created the destination folder (if neccesary) + * - It has checked the input (valid mime type) + * - It has set up an error handler, mostly in order to catch and log warnings during the doConvert fase + * + * Note: This method is not meant to be called from the outside. Use the static *convert* method for converting + * or, if you wish, create an instance with ::createInstance() and then call ::doConvert() + * + * @throws ConversionFailedException in case conversion failed in an antipiciated way (or subclass) + * @throws \Exception in case conversion failed in an unantipiciated way + */ + abstract protected function doActualConvert(); + + /** + * Whether or not the converter supports lossless encoding (even for jpegs) + * + * PS: Converters that supports lossless encoding all use the EncodingAutoTrait, which + * overrides this function. + * + * @return boolean Whether the converter supports lossless encoding (even for jpegs). + */ + public function supportsLossless() + { + return false; + } + + /** @var string The filename of the image to convert (complete path) */ + protected $source; + + /** @var string Where to save the webp (complete path) */ + protected $destination; + + /** + * Check basis operationality + * + * Converters may override this method for the purpose of performing basic operationaly checks. It is for + * running general operation checks for a conversion method. + * If some requirement is not met, it should throw a ConverterNotOperationalException (or subtype) + * + * The method is called internally right before calling doActualConvert() method. + * - It SHOULD take options into account when relevant. For example, a missing api key for a + * cloud converter should be detected here + * - It should NOT take the actual filename into consideration, as the purpose is *general* + * For that pupose, converters should override checkConvertability + * Also note that doConvert method is allowed to throw ConverterNotOperationalException too. + * + * @return void + */ + public function checkOperationality() + { + } + + /** + * Converters may override this for the purpose of performing checks on the concrete file. + * + * This can for example be used for rejecting big uploads in cloud converters or rejecting unsupported + * image types. + * + * @return void + */ + public function checkConvertability() + { + } + + /** + * Constructor. + * + * @param string $source path to source file + * @param string $destination path to destination + * @param array $options (optional) options for conversion + * @param BaseLogger $logger (optional) + */ + final public function __construct($source = '', $destination = '', $options = [], $logger = null) + { + if ($source == '') { + return; + } + InputValidator::checkSourceAndDestination($source, $destination); + + $this->source = $source; + $this->destination = $destination; + + $this->setLogger($logger); + $this->setProvidedOptions($options); + + if (!isset($this->options['_skip_input_check'])) { + $this->logLn('WebP Convert 2.9.0 ignited', 'bold'); + $this->logLn('PHP version: ' . phpversion()); + if (isset($_SERVER['SERVER_SOFTWARE'])) { + $this->logLn('Server software: ' . $_SERVER['SERVER_SOFTWARE']); + } + $this->logLn(''); + if (isset($this->options['log-call-arguments']) && $this->options['log-call-arguments']) { + $this->logLn('source: ' . $this->source); + $this->logLn('destination: ' . $this->destination); + $this->logLn(''); + } + + $this->logLn(self::getConverterDisplayName() . ' converter ignited', 'bold'); + } + } + + /** + * Get source. + * + * @return string The source. + */ + public function getSource() + { + return $this->source; + } + + /** + * Get destination. + * + * @return string The destination. + */ + public function getDestination() + { + return $this->destination; + } + + /** + * Set destination. + * + * @param string $destination path to destination + * @return void + */ + public function setDestination($destination) + { + $this->destination = $destination; + } + + + /** + * Get converter name for display (defaults to the class name (short)). + * + * Converters can override this. + * + * @return string A display name, ie "Gd" + */ + protected static function getConverterDisplayName() + { + // https://stackoverflow.com/questions/19901850/how-do-i-get-an-objects-unqualified-short-class-name/25308464 + return substr(strrchr('\\' . static::class, '\\'), 1); + } + + + /** + * Get converter id (defaults to the class name lowercased) + * + * Converters can override this. + * + * @return string A display name, ie "Gd" + */ + protected static function getConverterId() + { + return strtolower(self::getConverterDisplayName()); + } + + + /** + * Create an instance of this class + * + * @param string $source The path to the file to convert + * @param string $destination The path to save the converted file to + * @param array $options (optional) + * @param \WebPConvert\Loggers\BaseLogger $logger (optional) + * + * @return static + */ + public static function createInstance($source, $destination, $options = [], $logger = null) + { + return new static($source, $destination, $options, $logger); + } + + protected function logReduction($source, $destination) + { + $sourceSize = filesize($source); + $destSize = filesize($destination); + $this->log(round(($sourceSize - $destSize) / $sourceSize * 100) . '% '); + if ($sourceSize < 10000) { + $this->logLn('(went from ' . strval($sourceSize) . ' bytes to ' . strval($destSize) . ' bytes)'); + } else { + $this->logLn('(went from ' . round($sourceSize / 1024) . ' kb to ' . round($destSize / 1024) . ' kb)'); + } + } + + /** + * Run conversion. + * + * @return void + */ + private function doConvertImplementation() + { + $beginTime = microtime(true); + + $this->activateWarningLogger(); + + $this->checkOptions(); + + // Prepare destination folder + $this->createWritableDestinationFolder(); + $this->removeExistingDestinationIfExists(); + + if (!isset($this->options['_skip_input_check'])) { + // Check that a file can be written to destination + $this->checkDestinationWritable(); + } + + $this->checkOperationality(); + $this->checkConvertability(); + + if ($this->options['log-call-arguments']) { + $this->logOptions(); + $this->logLn(''); + } + + $this->runActualConvert(); + + $source = $this->source; + $destination = $this->destination; + + if (!@file_exists($destination)) { + throw new ConversionFailedException('Destination file is not there: ' . $destination); + } elseif (@filesize($destination) === 0) { + @unlink($destination); + throw new ConversionFailedException('Destination file was completely empty'); + } else { + if (!isset($this->options['_suppress_success_message'])) { + $this->ln(); + $this->log('Converted image in ' . round((microtime(true) - $beginTime) * 1000) . ' ms'); + + $sourceSize = @filesize($source); + if ($sourceSize !== false) { + $this->log(', reducing file size with '); + $this->logReduction($source, $destination); + } + } + } + + $this->deactivateWarningLogger(); + } + + //private function logEx + /** + * Start conversion. + * + * Usually it would be more convenience to call the static convert method, but alternatively you can call + * call ::createInstance to get an instance and then ::doConvert(). + * + * @return void + */ + public function doConvert() + { + try { + //trigger_error('hello', E_USER_ERROR); + $this->doConvertImplementation(); + } catch (WebPConvertException $e) { + $this->logLn(''); + /* + if (isset($e->description) && ($e->description != '')) { + $this->log('Error: ' . $e->description . '. ', 'bold'); + } else { + $this->log('Error: ', 'bold'); + } + */ + $this->log('Error: ', 'bold'); + $this->logLn($e->getMessage(), 'bold'); + throw $e; + } catch (\Exception $e) { + $className = get_class($e); + $classNameParts = explode("\\", $className); + $shortClassName = array_pop($classNameParts); + + $this->logLn(''); + $this->logLn($shortClassName . ' thrown in ' . $e->getFile() . ':' . $e->getLine(), 'bold'); + $this->logLn('Message: "' . $e->getMessage() . '"', 'bold'); + + $this->logLn('Trace:'); + foreach ($e->getTrace() as $trace) { + //$this->logLn(print_r($trace, true)); + if (isset($trace['file']) && isset($trace['line'])) { + $this->logLn( + $trace['file'] . ':' . $trace['line'] + ); + } + } + throw $e; + } catch (\Throwable $e) { + $className = get_class($e); + $classNameParts = explode("\\", $className); + $shortClassName = array_pop($classNameParts); + + $this->logLn(''); + $this->logLn($shortClassName . ' thrown in ' . $e->getFile() . ':' . $e->getLine(), 'bold'); + $this->logLn('Message: "' . $e->getMessage() . '"', 'bold'); + throw $e; + } + } + + /** + * Runs the actual conversion (after setup and checks) + * Simply calls the doActualConvert() of the actual converter. + * However, in the EncodingAutoTrait, this method is overridden to make two conversions + * and select the smallest. + * + * @return void + */ + protected function runActualConvert() + { + $this->doActualConvert(); + } + + /** + * Convert an image to webp. + * + * @param string $source path to source file + * @param string $destination path to destination + * @param array $options (optional) options for conversion + * @param BaseLogger $logger (optional) + * + * @throws ConversionFailedException in case conversion fails in an antipiciated way + * @throws \Exception in case conversion fails in an unantipiciated way + * @return void + */ + public static function convert($source, $destination, $options = [], $logger = null) + { + $c = self::createInstance($source, $destination, $options, $logger); + $c->doConvert(); + //echo $instance->id; + } + + /** + * Get mime type for image (best guess). + * + * It falls back to using file extension. If that fails too, false is returned + * + * PS: Is it a security risk to fall back on file extension? + * - By setting file extension to "jpg", one can lure our library into trying to convert a file, which isn't a jpg. + * hmm, seems very unlikely, though not unthinkable that one of the converters could be exploited + * + * @return string|false|null mimetype (if it is an image, and type could be determined / guessed), + * false (if it is not an image type that the server knowns about) + * or null (if nothing can be determined) + */ + public function getMimeTypeOfSource() + { + return MimeType::getMimeTypeDetectionResult($this->source); + } +} diff --git a/src/Convert/Converters/BaseTraits/AutoQualityTrait.php b/src/Convert/Converters/BaseTraits/AutoQualityTrait.php new file mode 100644 index 00000000..71eee649 --- /dev/null +++ b/src/Convert/Converters/BaseTraits/AutoQualityTrait.php @@ -0,0 +1,186 @@ + + * @since Class available since Release 2.0.0 + */ +trait AutoQualityTrait +{ + + abstract public function logLn($msg, $style = ''); + abstract public function getMimeTypeOfSource(); + + /** @var boolean Whether the quality option has been processed or not */ + private $processed = false; + + /** @var boolean Whether the quality of the source could be detected or not (set upon processing) */ + private $qualityCouldNotBeDetected = false; + + /** @var integer The calculated quality (set upon processing - on successful detection) */ + private $calculatedQuality; + + + /** + * Determine if quality detection is required but failing. + * + * It is considered "required" when: + * - Mime type is "image/jpeg" + * - Quality is set to "auto" + * + * If quality option hasn't been proccessed yet, it is triggered. + * + * @return boolean + */ + public function isQualityDetectionRequiredButFailing() + { + $this->processQualityOptionIfNotAlready(); + return $this->qualityCouldNotBeDetected; + } + + /** + * Get calculated quality. + * + * If the "quality" option is a number, that number is returned. + * If mime type of source is something else than "image/jpeg", the "default-quality" option is returned + * If quality is "auto" and source is a jpeg image, it will be attempted to detect jpeg quality. + * In case of failure, the value of the "default-quality" option is returned. + * In case of success, the detected quality is returned, or the value of the "max-quality" if that is lower. + * + * @return int + */ + public function getCalculatedQuality() + { + $this->processQualityOptionIfNotAlready(); + return $this->calculatedQuality; + } + + /** + * Process the quality option if it is not already processed. + * + * @return void + */ + private function processQualityOptionIfNotAlready() + { + if (!$this->processed) { + $this->processed = true; + $this->processQualityOption(); + } + } + + /** + * Process the quality option. + * + * Sets the private property "calculatedQuality" according to the description for the getCalculatedQuality + * function. + * In case quality detection was attempted and failed, the private property "qualityCouldNotBeDetected" is set + * to true. This is used by the "isQualityDetectionRequiredButFailing" (and documented there too). + * + * @return void + */ + private function processQualityOption() + { + $options = $this->options; + $source = $this->source; + + /* + Mapping from old options to new options: + quality: "auto", max-quality: 85, default-quality: 75 + becomes: quality: 85, auto-limit: true + + quality: 80 + becomes: quality: 80, auto-limit: false + */ + $q = $options['quality']; + $useDeprecatedDefaultQuality = false; + if ($q == 'auto') { + $q = $options['quality'] = $options['max-quality']; + $this->logLn( + '*Setting "quality" to "auto" is deprecated. ' . + 'Instead, set "quality" to a number (0-100) and "auto-limit" to true. ' + ); + $this->logLn( + '*"quality" has been set to: ' . $options['max-quality'] . ' (took the value of "max-quality").*' + ); + if (!$this->options2->getOptionById('auto-limit')->isValueExplicitlySet()) { + $options['auto-limit'] = true; + $this->logLn( + '*"auto-limit" has been set to: true."*' + ); + } else { + $this->logLn( + '*PS: "auto-limit" is set to false, as it was set explicitly to false in the options."*' + ); + } + $useDeprecatedDefaultQuality = true; + } + + if ($options['auto-limit']) { + if (($this->/** @scrutinizer ignore-call */getMimeTypeOfSource() == 'image/jpeg')) { + $this->logLn('Running auto-limit'); + $this->logLn( + 'Quality setting: ' . $q . '. ' + ); + $q = JpegQualityDetector::detectQualityOfJpg($source); + if (is_null($q)) { + $this->/** @scrutinizer ignore-call */logLn( + 'Quality of source image could not be established (Imagick or GraphicsMagick is required). ' . + 'Sorry, no auto-limit functionality for you. ' + ); + if ($useDeprecatedDefaultQuality) { + $q = $options['default-quality']; + $this->/** @scrutinizer ignore-call */logLn( + 'Using default-quality (' . $q . ').' + ); + } else { + $q = $options['quality']; + $this->/** @scrutinizer ignore-call */logLn( + 'Using supplied quality (' . $q . ').' + ); + } + + $this->qualityCouldNotBeDetected = true; + } else { + $this->logLn( + 'Quality of jpeg: ' . $q . '. ' + ); + if ($q < $options['quality']) { + $this->logLn( + 'Auto-limit result: ' . $q . ' ' . + '(limiting applied).' + ); + } else { + $q = $options['quality']; + $this->logLn( + 'Auto-limit result: ' . $q . ' ' . + '(no limiting needed this time).' + ); + } + } + $q = min($q, $options['max-quality']); + } else { + $this->logLn('Bypassing auto-limit (it is only active for jpegs)'); + $this->logLn('Quality: ' . $q . '. '); + } + } else { + $this->logLn( + 'Quality: ' . $q . '. ' + ); + if (($this->getMimeTypeOfSource() == 'image/jpeg')) { + $this->logLn( + 'Consider enabling "auto-limit" option. This will prevent unnecessary high quality' + ); + } + } + $this->calculatedQuality = $q; + } +} diff --git a/src/Convert/Converters/BaseTraits/DestinationPreparationTrait.php b/src/Convert/Converters/BaseTraits/DestinationPreparationTrait.php new file mode 100644 index 00000000..707e0d54 --- /dev/null +++ b/src/Convert/Converters/BaseTraits/DestinationPreparationTrait.php @@ -0,0 +1,101 @@ + + * @since Class available since Release 2.0.0 + */ +trait DestinationPreparationTrait +{ + + abstract public function getDestination(); + abstract public function logLn($msg, $style = ''); + + /** + * Create writable folder in provided path (if it does not exist already) + * + * @throws CreateDestinationFolderException if folder cannot be removed + * @return void + */ + private function createWritableDestinationFolder() + { + $destination = $this->getDestination(); + + $folder = dirname($destination); + if (!file_exists($folder)) { + $this->logLn('Destination folder does not exist. Creating folder: ' . $folder); + // TODO: what if this is outside open basedir? + // see http://php.net/manual/en/ini.core.php#ini.open-basedir + + // Trying to create the given folder (recursively) + if (!mkdir($folder, 0777, true)) { + throw new CreateDestinationFolderException( + 'Failed creating folder. Check the permissions!', + 'Failed creating folder: ' . $folder . '. Check permissions!' + ); + } + } + } + + /** + * Check that we can write file at destination. + * + * It is assumed that the folder already exists (that ::createWritableDestinationFolder() was called first) + * + * @throws CreateDestinationFileException if file cannot be created at destination + * @return void + */ + private function checkDestinationWritable() + { + $destination = $this->getDestination(); + $dirName = dirname($destination); + + if (@is_writable($dirName) && @is_executable($dirName)) { + // all is well + return; + } + + // The above might fail on Windows, even though dir is writable + // So, to be absolute sure that we cannot write, we make an actual write test (writing a dummy file) + // No harm in doing that for non-Windows systems either. + if (file_put_contents($destination, 'dummy') !== false) { + // all is well, after all + @unlink($destination); + return; + } + + throw new CreateDestinationFileException( + 'Cannot create file: ' . basename($destination) . ' in dir:' . dirname($destination) + ); + } + + /** + * Remove existing destination. + * + * @throws CreateDestinationFileException if file cannot be removed + * @return void + */ + private function removeExistingDestinationIfExists() + { + $destination = $this->getDestination(); + if (file_exists($destination)) { + // A file already exists in this folder... + // We delete it, to make way for a new webp + if (!@unlink($destination)) { + throw new CreateDestinationFileException( + 'Existing file cannot be removed: ' . basename($destination) + ); + } + } + } +} diff --git a/src/Convert/Converters/BaseTraits/LoggerTrait.php b/src/Convert/Converters/BaseTraits/LoggerTrait.php new file mode 100644 index 00000000..79329fee --- /dev/null +++ b/src/Convert/Converters/BaseTraits/LoggerTrait.php @@ -0,0 +1,71 @@ + + * @since Class available since Release 2.0.0 + */ +trait LoggerTrait +{ + + /** @var \WebPConvert\Loggers\BaseLogger|null The logger (or null if not set) */ + protected $logger; + + /** + * Set logger + * + * @param \WebPConvert\Loggers\BaseLogger $logger (optional) $logger + * @return void + */ + public function setLogger($logger = null) + { + $this->logger = $logger; + } + + /** + * Write a line to the logger. + * + * @param string $msg The line to write. + * @param string $style (optional) Ie "italic" or "bold" + * @return void + */ + public function logLn($msg, $style = '') + { + if (isset($this->logger)) { + $this->logger->logLn($msg, $style); + } + } + + /** + * New line + * + * @return void + */ + protected function ln() + { + if (isset($this->logger)) { + $this->logger->ln(); + } + } + + /** + * Write to the logger, without newline + * + * @param string $msg What to write. + * @param string $style (optional) Ie "italic" or "bold" + * @return void + */ + public function log($msg, $style = '') + { + if (isset($this->logger)) { + $this->logger->log($msg, $style); + } + } +} diff --git a/src/Convert/Converters/BaseTraits/OptionsTrait.php b/src/Convert/Converters/BaseTraits/OptionsTrait.php new file mode 100644 index 00000000..2606f443 --- /dev/null +++ b/src/Convert/Converters/BaseTraits/OptionsTrait.php @@ -0,0 +1,581 @@ + + * @since Class available since Release 2.0.0 + */ +trait OptionsTrait +{ + + abstract public function log($msg, $style = ''); + abstract public function logLn($msg, $style = ''); + abstract protected function getMimeTypeOfSource(); + + /** @var array Provided conversion options (array of simple objects)*/ + public $providedOptions; + + /** @var array Calculated conversion options (merge of default options and provided options)*/ + protected $options; + + /** @var Options */ + protected $options2; + + /** + * Get the "general" options (options that are standard in the meaning that they + * are generally available (unless specifically marked as unsupported by a given converter) + * + * @param string $imageType (png | jpeg) The image type - determines the defaults + * + * @return array Array of options + */ + public function getGeneralOptions($imageType) + { + $isPng = ($imageType == 'png'); + + /* + return [ + //new IntegerOption('auto-limit-adjustment', 5, -100, 100), + new BooleanOption('log-call-arguments', false), + new BooleanOption('skip', false), + new BooleanOption('use-nice', false), + new ArrayOption('jpeg', []), + new ArrayOption('png', []) + ];*/ + + $introMd = 'https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/' . + 'converting/introduction-for-converting.md'; + + return OptionFactory::createOptions([ + ['encoding', 'string', [ + 'title' => 'Encoding', + 'description' => 'Set encoding for the webp. ' . + 'If you choose "auto", webp-convert will ' . + 'convert to both lossy and lossless and pick the smallest result', + 'default' => 'auto', + 'enum' => ['auto', 'lossy', 'lossless'], + 'ui' => [ + 'component' => 'select', + 'links' => [['Guide', $introMd . '#auto-selecting-between-losslesslossy-encoding']], + ] + ]], + ['quality', 'int', [ + 'title' => 'Quality (Lossy)', + 'description' => + 'Quality for lossy encoding. ' . + 'In case you enable "auto-limit", you can consider this property a maximum quality.', + 'default' => ($isPng ? 85 : 75), + 'default-png' => 85, + 'default-jpeg' => 75, + //'minimum' => 0, + //'maximum' => 100, + "oneOf" => [ + ["type" => "number", "minimum" => 0, 'maximum' => 100], + ["type" => "string", "enum" => ["auto"]] + ], + 'ui' => [ + 'component' => 'slider', + 'display' => "option('encoding') != 'lossless'" + ] + ]], + ['auto-limit', 'boolean', [ + 'title' => 'Auto-limit', + 'description' => + 'Enable this option to prevent an unnecessarily high quality setting for low ' . + 'quality jpegs. It works by adjusting quality setting down to the quality of the jpeg. ' . + 'Converting ie a jpeg with quality:50 to ie quality:80 does not get you better quality ' . + 'than converting it to quality:80, but it does get you a much bigger file - so you ' . + 'really should enable this option.' . "\n\n" . + 'The option is ignored for PNG and never adjusts quality up. ' . "\n\n" . + 'The feature requires Imagick, ImageMagick or Gmagick in order to detect the quality of ' . + 'the jpeg. ' . "\n\n" . + 'PS: The "auto-limit" option is relative new. However, before this option, you could achieve ' . + 'the same by setting quality to "auto" and specifying a "max-quality" and a "default-quality". ' . + 'These are deprecated now, but still works.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true, + 'links' => [ + [ + 'Guide', + $introMd . '#preventing-unnecessarily-high-quality-setting-for-low-quality-jpegs' + ] + ], + 'display' => "option('encoding') != 'lossless'" + ] + ]], + ['alpha-quality', 'int', [ + 'title' => 'Alpha quality', + 'description' => + 'Quality of alpha channel. ' . + 'Often, there is no need for high quality transparency layer and in some cases you ' . + 'can tweak this all the way down to 10 and save a lot in file size. The option only ' . + 'has effect with lossy encoding, and of course only on images with transparency.', + 'default' => 85, + 'minimum' => 0, + 'maximum' => 100, + 'ui' => [ + 'component' => 'slider', + 'links' => [['Guide', $introMd . '#alpha-quality']], + 'display' => "(option('encoding') != 'lossless') && (imageType!='jpeg')" + ] + ]], + ['near-lossless', 'int', [ + 'title' => '"Near lossless" quality', + 'description' => + 'This option allows you to get impressively better compression for lossless encoding, with ' . + 'minimal impact on visual quality. The range is 0 (maximum preprocessing) to 100 (no ' . + 'preprocessing). Read the guide for more info.', + 'default' => 60, + 'minimum' => 0, + 'maximum' => 100, + 'ui' => [ + 'component' => 'slider', + 'links' => [['Guide', $introMd . '#near-lossless']], + 'display' => "option('encoding') != 'lossy'" + ] + ]], + ['metadata', 'string', [ + 'title' => 'Metadata', + 'description' => + 'Determines which metadata that should be copied over to the webp. ' . + 'Setting it to "all" preserves all metadata, setting it to "none" strips all metadata. ' . + '*cwebp* can take a comma-separated list of which kinds of metadata that should be copied ' . + '(ie "exif,icc"). *gd* will always remove all metadata and *ffmpeg* will always keep all ' . + 'metadata. The rest can either strip all or keep all (they will keep all, unless the option ' . + 'is set to *none*)', + 'default' => 'none', + 'ui' => [ + 'component' => 'multi-select', + 'options' => ['all', 'none', 'exif', 'icc', 'xmp'], + ] + // TODO: set regex validation + ]], + ['method', 'int', [ + 'title' => 'Reduction effort (0-6)', + 'description' => + 'Controls the trade off between encoding speed and the compressed file size and quality. ' . + 'Possible values range from 0 to 6. 0 is fastest. 6 results in best quality and compression. ' . + 'PS: The option corresponds to the "method" option in libwebp', + 'default' => 6, + 'minimum' => 0, + 'maximum' => 6, + 'ui' => [ + 'component' => 'slider', + 'advanced' => true, + ] + ]], + ['sharp-yuv', 'boolean', [ + 'title' => 'Sharp YUV', + 'description' => + 'Better RGB->YUV color conversion (sharper and more accurate) at the expense of a little extra ' . + 'conversion time.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true, + 'links' => [ + ['Ctrl.blog', 'https://www.ctrl.blog/entry/webp-sharp-yuv.html'], + ], + ] + ]], + ['auto-filter', 'boolean', [ + 'title' => 'Auto-filter', + 'description' => + 'Turns auto-filter on. ' . + 'This algorithm will spend additional time optimizing the filtering strength to reach a well-' . + 'balanced quality. Unfortunately, it is extremely expensive in terms of computation. It takes ' . + 'about 5-10 times longer to do a conversion. A 1MB picture which perhaps typically takes about ' . + '2 seconds to convert, will takes about 15 seconds to convert with auto-filter. ', + 'default' => false, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true, + ] + ]], + ['low-memory', 'boolean', [ + 'title' => 'Low memory', + 'description' => + 'Reduce memory usage of lossy encoding at the cost of ~30% longer encoding time and marginally ' . + 'larger output size. Only effective when the *method* option is 3 or more. Read more in ' . + '[the docs](https://developers.google.com/speed/webp/docs/cwebp)', + 'default' => false, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true, + 'display' => "(option('encoding') != 'lossless') && (option('method')>2)" + ] + ]], + ['preset', 'string', [ + 'title' => 'Preset', + 'description' => + 'Using a preset will set many of the other options to suit a particular type of ' . + 'source material. It even overrides them. It does however not override the quality option. ' . + '"none" means that no preset will be set', + 'default' => 'none', + 'enum' => ['none', 'default', 'photo', 'picture', 'drawing', 'icon', 'text'], + 'ui' => [ + 'component' => 'select', + 'advanced' => true, + ] + ]], + ['size-in-percentage', 'int', ['default' => null, 'minimum' => 0, 'maximum' => 100, 'allow-null' => true]], + ['skip', 'boolean', ['default' => false]], + ['log-call-arguments', 'boolean', ['default' => false]], + // TODO: use-nice should not be a "general" option + //['use-nice', 'boolean', ['default' => false]], + ['jpeg', 'array', ['default' => []]], + ['png', 'array', ['default' => []]], + + // Deprecated options + ['default-quality', 'int', [ + 'default' => ($isPng ? 85 : 75), + 'minimum' => 0, + 'maximum' => 100, + 'deprecated' => true] + ], + ['max-quality', 'int', ['default' => 85, 'minimum' => 0, 'maximum' => 100, 'deprecated' => true]], + ]); + } + + /** + * Get the unique options for a converter + * + * @param string $imageType (png | jpeg) The image type - determines the defaults + * + * @return array Array of options + */ + public function getUniqueOptions($imageType) + { + return []; + } + + /** + * Create options. + * + * The options created here will be available to all converters. + * Individual converters may add options by overriding this method. + * + * @param string $imageType (png | jpeg) The image type - determines the defaults + * + * @return void + */ + protected function createOptions($imageType = 'png') + { + $this->options2 = new Options(); + $this->options2->addOptions(... $this->getGeneralOptions($imageType)); + $this->options2->addOptions(... $this->getUniqueOptions($imageType)); + } + + /** + * Set "provided options" (options provided by the user when calling convert(). + * + * This also calculates the protected options array, by merging in the default options, merging + * jpeg and png options and merging prefixed options (such as 'vips-quality'). + * The resulting options array are set in the protected property $this->options and can be + * retrieved using the public ::getOptions() function. + * + * @param array $providedOptions (optional) + * @return void + */ + public function setProvidedOptions($providedOptions = []) + { + $imageType = ($this->getMimeTypeOfSource() == 'image/png' ? 'png' : 'jpeg'); + $this->createOptions($imageType); + + $this->providedOptions = $providedOptions; + + if (isset($this->providedOptions['png'])) { + if ($this->getMimeTypeOfSource() == 'image/png') { + $this->providedOptions = array_merge($this->providedOptions, $this->providedOptions['png']); +// $this->logLn(print_r($this->providedOptions, true)); + unset($this->providedOptions['png']); + } + } + + if (isset($this->providedOptions['jpeg'])) { + if ($this->getMimeTypeOfSource() == 'image/jpeg') { + $this->providedOptions = array_merge($this->providedOptions, $this->providedOptions['jpeg']); + unset($this->providedOptions['jpeg']); + } + } + + // merge down converter-prefixed options + $converterId = self::getConverterId(); + $strLen = strlen($converterId); + foreach ($this->providedOptions as $optionKey => $optionValue) { + if (substr($optionKey, 0, $strLen + 1) == ($converterId . '-')) { + $this->providedOptions[substr($optionKey, $strLen + 1)] = $optionValue; + unset($this->providedOptions[$optionKey]); + } + } + + // Create options (Option objects) + foreach ($this->providedOptions as $optionId => $optionValue) { + $this->options2->setOrCreateOption($optionId, $optionValue); + } + //$this->logLn(print_r($this->options2->getOptions(), true)); +//$this->logLn($this->options2->getOption('hello')); + + // Create flat associative array of options + $this->options = $this->options2->getOptions(); + + // - Merge $defaultOptions into provided options + //$this->options = array_merge($this->getDefaultOptions(), $this->providedOptions); + + //$this->logOptions(); + } + + /** + * Get the resulting options after merging provided options with default options. + * + * Note that the defaults depends on the mime type of the source. For example, the default value for quality + * is "auto" for jpegs, and 85 for pngs. + * + * @return array An associative array of options: ['metadata' => 'none', ...] + */ + public function getOptions() + { + return $this->options; + } + + /** + * Change an option specifically. + * + * This method is probably rarely neeeded. We are using it to change the "encoding" option temporarily + * in the EncodingAutoTrait. + * + * @param string $id Id of option (ie "metadata") + * @param mixed $value The new value. + * @return void + */ + protected function setOption($id, $value) + { + $this->options[$id] = $value; + $this->options2->setOrCreateOption($id, $value); + } + + /** + * Check options. + * + * @throws InvalidOptionTypeException if an option have wrong type + * @throws InvalidOptionValueException if an option value is out of range + * @throws ConversionSkippedException if 'skip' option is set to true + * @return void + */ + protected function checkOptions() + { + $this->options2->check(); + + if ($this->options['skip']) { + if (($this->getMimeTypeOfSource() == 'image/png') && isset($this->options['png']['skip'])) { + throw new ConversionSkippedException( + 'skipped conversion (configured to do so for PNG)' + ); + } else { + throw new ConversionSkippedException( + 'skipped conversion (configured to do so)' + ); + } + } + } + + public function logOptions() + { + $this->logLn(''); + $this->logLn('Options:'); + $this->logLn('------------'); + + $unsupported = $this->getUnsupportedDefaultOptions(); + $received = []; + $implicitlySet = []; + foreach ($this->options2->getOptionsMap() as $id => $option) { + if (in_array($id, [ + 'png', 'jpeg', '_skip_input_check', '_suppress_success_message', 'skip', 'log_call_arguments' + ])) { + continue; + } + if ($option->isValueExplicitlySet()) { + $received[] = $option; + } else { + if (($option instanceof GhostOption) || in_array($id, $unsupported)) { + //$received[] = $option; + } else { + if (!$option->isDeprecated()) { + $implicitlySet[] = $option; + } + } + } + } + + if (count($received) > 0) { + foreach ($received as $option) { + $this->log('- ' . $option->getId() . ': '); + if ($option instanceof GhostOption) { + $this->log(' (unknown to ' . $this->getConverterId() . ')', 'bold'); + $this->logLn(''); + continue; + } + $this->log($option->getValueForPrint()); + if ($option->isDeprecated()) { + $this->log(' (deprecated)', 'bold'); + } + if (in_array($option->getId(), $unsupported)) { + if ($this instanceof Stack) { + //$this->log(' *(passed on)*'); + } else { + $this->log(' (unsupported by ' . $this->getConverterId() . ')', 'bold'); + } + } + $this->logLn(''); + } + $this->logLn(''); + $this->logLn( + 'Note that these are the resulting options after merging down the "jpeg" and "png" options and any ' . + 'converter-prefixed options' + ); + } + + if (count($implicitlySet) > 0) { + $this->logLn(''); + $this->logLn('Defaults:'); + $this->logLn('------------'); + $this->logLn( + 'The following options was not set, so using the following defaults:' + ); + foreach ($implicitlySet as $option) { + $this->log('- ' . $option->getId() . ': '); + $this->log($option->getValueForPrint()); + /*if ($option instanceof GhostOption) { + $this->log(' **(ghost)**'); + }*/ + $this->logLn(''); + } + } + } + + // to be overridden by converters + protected function getUnsupportedDefaultOptions() + { + return []; + } + + public function getUnsupportedGeneralOptions() + { + return $this->getUnsupportedDefaultOptions(); + } + + /** + * Get unique option definitions. + * + * Gets definitions of the converters "unique" options (that is, those options that + * are not general). It was added in order to give GUI's a way to automatically adjust + * their setting screens. + * + * @param bool $filterOutOptionsWithoutUI If options without UI defined should be filtered out + * @param string $imageType (png | jpeg) The image type - determines the defaults + * + * @return array Array of options definitions - ready to be json encoded, or whatever + */ + public function getUniqueOptionDefinitions($filterOutOptionsWithoutUI = true, $imageType = 'jpeg') + { + $uniqueOptions = new Options(); + //$uniqueOptions->addOptions(... $this->getUniqueOptions($imageType)); + foreach ($this->getUniqueOptions($imageType) as $uoption) { + $uoption->setId(self::getConverterId() . '-' . $uoption->getId()); + $uniqueOptions->addOption($uoption); + } + + $optionDefinitions = $uniqueOptions->getDefinitions(); + if ($filterOutOptionsWithoutUI) { + $optionDefinitions = array_filter($optionDefinitions, function ($value) { + return !is_null($value['ui']); + }); + $optionDefinitions = array_values($optionDefinitions); // re-index + } + return $optionDefinitions; + } + + /** + * Get general option definitions. + * + * Gets definitions of all general options (not just the ones supported by current converter) + * For UI's, as a way to automatically adjust their setting screens. + * + * @param bool $filterOutOptionsWithoutUI If options without UI defined should be filtered out + * @param string $imageType (png | jpeg) The image type - determines the defaults + * + * @return array Array of options definitions - ready to be json encoded, or whatever + */ + public function getGeneralOptionDefinitions($filterOutOptionsWithoutUI = true, $imageType = 'jpeg') + { + $generalOptions = new Options(); + $generalOptions->addOptions(... $this->getGeneralOptions($imageType)); + //$generalOptions->setUI($this->getUIForGeneralOptions($imageType)); + $optionDefinitions = $generalOptions->getDefinitions(); + if ($filterOutOptionsWithoutUI) { + $optionDefinitions = array_filter($optionDefinitions, function ($value) { + return !is_null($value['ui']); + }); + $optionDefinitions = array_values($optionDefinitions); // re-index + } + return $optionDefinitions; + } + + public function getSupportedGeneralOptions($imageType = 'png') + { + $unsupportedGeneral = $this->getUnsupportedDefaultOptions(); + $generalOptionsArr = $this->getGeneralOptions($imageType); + $supportedIds = []; + foreach ($generalOptionsArr as $i => $option) { + if (in_array($option->getId(), $unsupportedGeneral)) { + unset($generalOptionsArr[$i]); + } + } + return $generalOptionsArr; + } + + /** + * Get general option definitions. + * + * Gets definitions of the converters "general" options. (that is, those options that + * It was added in order to give GUI's a way to automatically adjust their setting screens. + * + * @param string $imageType (png | jpeg) The image type - determines the defaults + * + * @return array Array of options definitions - ready to be json encoded, or whatever + */ + public function getSupportedGeneralOptionDefinitions($imageType = 'png') + { + $generalOptions = new Options(); + $generalOptions->addOptions(... $this->getSupportedGeneralOptions($imageType)); + return $generalOptions->getDefinitions(); + } + + public function getSupportedGeneralOptionIds() + { + $supportedGeneralOptions = $this->getSupportedGeneralOptions(); + $supportedGeneralIds = []; + foreach ($supportedGeneralOptions as $option) { + $supportedGeneralIds[] = $option->getId(); + } + return $supportedGeneralIds; + } +} diff --git a/src/Convert/Converters/BaseTraits/WarningLoggerTrait.php b/src/Convert/Converters/BaseTraits/WarningLoggerTrait.php new file mode 100644 index 00000000..25a6aed9 --- /dev/null +++ b/src/Convert/Converters/BaseTraits/WarningLoggerTrait.php @@ -0,0 +1,175 @@ + + * @since Class available since Release 2.0.0 + */ +trait WarningLoggerTrait +{ + abstract public function logLn($msg, $style = ''); + + /** @var string|array|null Previous error handler (stored in order to be able pass warnings on) */ + private $previousErrorHandler; + + /** @var boolean Suppress ALL warnings? (both from log and from bubbling up) */ + private $suppressWarnings; + + /** @var int Count number of warnings */ + private $warningCounter; + + /** + * Handle warnings and notices during conversion by logging them and passing them on. + * + * The function is a callback used with "set_error_handler". + * It is declared public because it needs to be accessible from the point where the warning is triggered. + * + * PS: The fifth parameter ($errcontext) of an error handler is deprecated since PHP 7.2, however we have + * it here to avoid calling another error handler with too few parameters (see #266) + * + * @param integer $errno + * @param string $errstr + * @param string $errfile + * @param integer $errline + * @param array $errcontext + * + * @return false|null|void + */ + public function warningHandler($errno, $errstr, $errfile, $errline, $errcontext = null) + { + /* + We do NOT do the following (even though it is generally recommended): + + if (!(error_reporting() & $errno)) { + // This error code is not included in error_reporting, so let it fall + // through to the standard PHP error handler + return false; + } + + - Because we want to log all warnings and errors (also the ones that was suppressed with @) + https://secure.php.net/manual/en/language.operators.errorcontrol.php + + If we were to decide suppressing the ones with @, I could do this: + + if (error_reporting() == 0) { + /// @ sign temporary disabled error reporting + return; + } + [https://stackoverflow.com/questions/7380782/error-suppression-operator-and-set-error-handler] + + However, that would also disable the warnings on systems with error reporting set to E_NONE. + And I really want the conversion log file to contain these warnings on all systems. + + If it was possible to suppress the warnings with @ without suppressing warnings on systems + with error reporting set to E_NONE, I would do that. + */ + + $this->warningCounter++; + if ($this->suppressWarnings) { + return; + } + + $errorTypes = [ + E_WARNING => "Warning", + E_NOTICE => "Notice", + E_STRICT => "Strict Notice", + E_DEPRECATED => "Deprecated", + E_USER_DEPRECATED => "User Deprecated", + + /* + The following can never be catched by a custom error handler: + E_PARSE, E_ERROR, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING + + We do do not currently trigger the following: + E_USER_ERROR, E_USER_WARNING, E_USER_NOTICE + + But we may want to do that at some point, like this: + trigger_error('Your version of Gd is very old', E_USER_WARNING); + in that case, remember to add them to this array + */ + ]; + + if (isset($errorTypes[$errno])) { + $errType = $errorTypes[$errno]; + } else { + $errType = "Unknown error/warning/notice ($errno)"; + } + + $msg = $errType . ': ' . $errstr . ' in ' . $errfile . ', line ' . $errline . ', PHP ' . PHP_VERSION . + ' (' . PHP_OS . ')'; + $this->logLn(''); + $this->logLn($msg, 'italic'); + $this->logLn(''); + + if (!is_null($this->previousErrorHandler)) { + // If previousErrorHandler is this very error handler, exit to avoid recursion + // (this could happen if ::activateWarningLogger() were called twice) + if (is_array($this->previousErrorHandler) && + isset($this->previousErrorHandler[0]) && + ($this->previousErrorHandler[0] == $this) + ) { + return false; + } else { + return call_user_func($this->previousErrorHandler, $errno, $errstr, $errfile, $errline, $errcontext); + } + } else { + return false; + } + } + + /** + * Activate warning logger. + * + * Sets the error handler and stores the previous so our error handler can bubble up warnings + * + * @return void + */ + protected function activateWarningLogger() + { + $this->suppressWarnings = false; + $this->warningCounter = 0; + $this->previousErrorHandler = set_error_handler( + array($this, "warningHandler"), + E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE + ); + } + + /** + * Deactivate warning logger. + * + * Restores the previous error handler. + * + * @return void + */ + protected function deactivateWarningLogger() + { + restore_error_handler(); + } + + protected function disableWarningsTemporarily() + { + $this->suppressWarnings = true; + } + + protected function reenableWarnings() + { + $this->suppressWarnings = false; + } + + protected function getWarningCount() + { + return $this->warningCounter; + } + + protected function resetWarningCount() + { + $this->warningCounter = 0; + } +} diff --git a/src/Converters/Binaries/cwebp-fbsd b/src/Convert/Converters/Binaries/cwebp-060-fbsd similarity index 100% rename from src/Converters/Binaries/cwebp-fbsd rename to src/Convert/Converters/Binaries/cwebp-060-fbsd diff --git a/src/Converters/Binaries/cwebp-sol b/src/Convert/Converters/Binaries/cwebp-060-solaris similarity index 100% rename from src/Converters/Binaries/cwebp-sol rename to src/Convert/Converters/Binaries/cwebp-060-solaris diff --git a/src/Converters/Binaries/cwebp-linux b/src/Convert/Converters/Binaries/cwebp-061-linux-x86-64 similarity index 100% rename from src/Converters/Binaries/cwebp-linux rename to src/Convert/Converters/Binaries/cwebp-061-linux-x86-64 diff --git a/src/Convert/Converters/Binaries/cwebp-103-linux-x86-64-static b/src/Convert/Converters/Binaries/cwebp-103-linux-x86-64-static new file mode 100755 index 00000000..14ec7f7b Binary files /dev/null and b/src/Convert/Converters/Binaries/cwebp-103-linux-x86-64-static differ diff --git a/src/Convert/Converters/Binaries/cwebp-110-linux-x86-64 b/src/Convert/Converters/Binaries/cwebp-110-linux-x86-64 new file mode 100755 index 00000000..d0689916 Binary files /dev/null and b/src/Convert/Converters/Binaries/cwebp-110-linux-x86-64 differ diff --git a/src/Convert/Converters/Binaries/cwebp-110-mac-10_15 b/src/Convert/Converters/Binaries/cwebp-110-mac-10_15 new file mode 100755 index 00000000..b6c6c0f7 Binary files /dev/null and b/src/Convert/Converters/Binaries/cwebp-110-mac-10_15 differ diff --git a/src/Convert/Converters/Binaries/cwebp-110-windows-x64.exe b/src/Convert/Converters/Binaries/cwebp-110-windows-x64.exe new file mode 100755 index 00000000..ed85dba4 Binary files /dev/null and b/src/Convert/Converters/Binaries/cwebp-110-windows-x64.exe differ diff --git a/src/Convert/Converters/Binaries/cwebp-120-linux-x86-64 b/src/Convert/Converters/Binaries/cwebp-120-linux-x86-64 new file mode 100755 index 00000000..bfabe848 Binary files /dev/null and b/src/Convert/Converters/Binaries/cwebp-120-linux-x86-64 differ diff --git a/src/Convert/Converters/Binaries/cwebp-120-windows-x64.exe b/src/Convert/Converters/Binaries/cwebp-120-windows-x64.exe new file mode 100755 index 00000000..477b337a Binary files /dev/null and b/src/Convert/Converters/Binaries/cwebp-120-windows-x64.exe differ diff --git a/src/Convert/Converters/ConverterTraits/CloudConverterTrait.php b/src/Convert/Converters/ConverterTraits/CloudConverterTrait.php new file mode 100644 index 00000000..9bd1d4a0 --- /dev/null +++ b/src/Convert/Converters/ConverterTraits/CloudConverterTrait.php @@ -0,0 +1,72 @@ + + * @since Class available since Release 2.0.0 + */ +trait CloudConverterTrait +{ + + /** + * Test that filesize is below "upload_max_filesize" and "post_max_size" values in php.ini. + * + * @param string $iniSettingId Id of ini setting (ie "upload_max_filesize") + * + * @throws ConversionFailedException if filesize is larger than the ini setting + * @return void + */ + private function checkFileSizeVsIniSetting($iniSettingId) + { + $fileSize = @filesize($this->source); + if ($fileSize === false) { + return; + } + $sizeInIni = PhpIniSizes::getIniBytes($iniSettingId); + if ($sizeInIni === false) { + // Not sure if we should throw an exception here, or not... + return; + } + if ($sizeInIni < $fileSize) { + throw new ConversionFailedException( + 'File is larger than your ' . $iniSettingId . ' (set in your php.ini). File size:' . + round($fileSize / 1024) . ' kb. ' . + $iniSettingId . ' in php.ini: ' . ini_get($iniSettingId) . + ' (parsed as ' . round($sizeInIni / 1024) . ' kb)' + ); + } + } + + /** + * Check convertability of cloud converters (that file is not bigger than limits set in php.ini). + * + * Performs the same as ::Convertability(). It is here so converters that overrides the + * ::Convertability() still has a chance to do the checks. + * + * @throws ConversionFailedException if filesize is larger than "upload_max_filesize" or "post_max_size" + * @return void + */ + public function checkConvertabilityCloudConverterTrait() + { + $this->checkFileSizeVsIniSetting('upload_max_filesize'); + $this->checkFileSizeVsIniSetting('post_max_size'); + } + + /** + * Check convertability of cloud converters (file upload limits). + */ + public function checkConvertability() + { + $this->checkConvertabilityCloudConverterTrait(); + } +} diff --git a/src/Convert/Converters/ConverterTraits/CurlTrait.php b/src/Convert/Converters/ConverterTraits/CurlTrait.php new file mode 100644 index 00000000..2a485938 --- /dev/null +++ b/src/Convert/Converters/ConverterTraits/CurlTrait.php @@ -0,0 +1,72 @@ + + * @since Class available since Release 2.0.0 + */ +trait CurlTrait +{ + + /** + * Check basis operationality for converters relying on curl. + * + * Performs the same as ::checkOperationality(). It is here so converters that overrides the + * ::checkOperationality() still has a chance to do the checks. + * + * @throws SystemRequirementsNotMetException + * @return void + */ + public function checkOperationalityForCurlTrait() + { + if (!extension_loaded('curl')) { + throw new SystemRequirementsNotMetException('Required cURL extension is not available.'); + } + + if (!function_exists('curl_init')) { + throw new SystemRequirementsNotMetException('Required url_init() function is not available.'); + } + + if (!function_exists('curl_file_create')) { + throw new SystemRequirementsNotMetException( + 'Required curl_file_create() function is not available (requires PHP > 5.5).' + ); + } + } + + /** + * Check basis operationality for converters relying on curl + * + * @throws SystemRequirementsNotMetException + * @return void + */ + public function checkOperationality() + { + $this->checkOperationalityForCurlTrait(); + } + + /** + * Init curl. + * + * @throws SystemRequirementsNotMetException if curl could not be initialized + * @return resource|\CurlHandle curl handle (from PHP8: CurlHandle) + */ + protected static function initCurl() + { + // Get curl handle + $ch = \curl_init(); + if ($ch === false) { + throw new SystemRequirementsNotMetException('Could not initialise cURL.'); + } + return $ch; + } +} diff --git a/src/Convert/Converters/ConverterTraits/EncodingAutoTrait.php b/src/Convert/Converters/ConverterTraits/EncodingAutoTrait.php new file mode 100644 index 00000000..4d52ae99 --- /dev/null +++ b/src/Convert/Converters/ConverterTraits/EncodingAutoTrait.php @@ -0,0 +1,91 @@ + + * @since Class available since Release 2.0.0 + */ +trait EncodingAutoTrait +{ + + abstract protected function doActualConvert(); + abstract public function getSource(); + abstract public function getDestination(); + abstract public function setDestination($destination); + abstract public function getOptions(); + abstract protected function setOption($optionName, $optionValue); + abstract protected function logLn($msg, $style = ''); + abstract protected function log($msg, $style = ''); + abstract protected function ln(); + abstract protected function logReduction($source, $destination); + + public function supportsLossless() + { + return true; + } + + /** Default is to not pass "lossless:auto" on, but implement it. + * + * The Stack converter passes it on (it does not even use this trait) + * WPC currently implements it, but this might be configurable in the future. + * + */ + public function passOnEncodingAuto() + { + return false; + } + + private function convertTwoAndSelectSmallest() + { + $destination = $this->getDestination(); + $destinationLossless = $destination . '.lossless.webp'; + $destinationLossy = $destination . '.lossy.webp'; + + $this->logLn( + 'Encoding is set to auto - converting to both lossless and lossy and selecting the smallest file' + ); + + $this->ln(); + $this->logLn('Converting to lossy'); + $this->setDestination($destinationLossy); + $this->setOption('encoding', 'lossy'); + $this->doActualConvert(); + $this->log('Reduction: '); + $this->logReduction($this->getSource(), $destinationLossy); + $this->ln(); + + $this->logLn('Converting to lossless'); + $this->setDestination($destinationLossless); + $this->setOption('encoding', 'lossless'); + $this->doActualConvert(); + $this->log('Reduction: '); + $this->logReduction($this->getSource(), $destinationLossless); + $this->ln(); + + if (filesize($destinationLossless) > filesize($destinationLossy)) { + $this->logLn('Picking lossy'); + @unlink($destinationLossless); + @rename($destinationLossy, $destination); + } else { + $this->logLn('Picking lossless'); + @unlink($destinationLossy); + @rename($destinationLossless, $destination); + } + $this->setDestination($destination); + $this->setOption('encoding', 'auto'); + } + + protected function runActualConvert() + { + if (!$this->passOnEncodingAuto() && ($this->getOptions()['encoding'] == 'auto') && $this->supportsLossless()) { + $this->convertTwoAndSelectSmallest(); + } else { + $this->doActualConvert(); + } + } +} diff --git a/src/Convert/Converters/ConverterTraits/ExecTrait.php b/src/Convert/Converters/ConverterTraits/ExecTrait.php new file mode 100644 index 00000000..aa6c8562 --- /dev/null +++ b/src/Convert/Converters/ConverterTraits/ExecTrait.php @@ -0,0 +1,107 @@ + + * @since Class available since Release 2.0.0 + */ +trait ExecTrait +{ + + abstract protected function logLn($msg, $style = ''); + + + /** + * Helper function for examining if "nice" command is available + * + * @return boolean true if nice is available + */ + protected static function hasNiceSupport() + { + ExecWithFallback::exec("nice 2>&1", $niceOutput); + + if (is_array($niceOutput) && isset($niceOutput[0])) { + if (preg_match('/usage/', $niceOutput[0]) || (preg_match('/^\d+$/', $niceOutput[0]))) { + /* + * Nice is available - default niceness (+10) + * https://www.lifewire.com/uses-of-commands-nice-renice-2201087 + * https://www.computerhope.com/unix/unice.htm + */ + + return true; + } + return false; + } + return false; // to satisfy phpstan + } + + protected function checkNiceSupport() + { + $ok = self::hasNiceSupport(); + if ($ok) { + $this->logLn('Tested "nice" command - it works :)'); + } else { + $this->logLn( + '**No "nice" support. To save a few ms, you can disable the "use-nice" option.**' + ); + } + return $ok; + } + + protected static function niceOption() + { + return ['use-nice', 'boolean', [ + 'title' => 'Use nice', + 'description' => + 'If *use-nice* is set, it will be examined if the *nice* command is available. ' . + 'If it is, the binary is executed using *nice*. This assigns low priority to the process and ' . + 'will save system resources - but result in slower conversion.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]]; + } + + /** + * Logs output from the exec call. + * + * @param array $output + * + * @return void + */ + protected function logExecOutput($output) + { + if (is_array($output) && count($output) > 0) { + $this->logLn(''); + $this->logLn('Output:', 'italic'); + foreach ($output as $line) { + $this->logLn(print_r($line, true)); + } + $this->logLn(''); + } + } + + /** + * Check basic operationality of exec converters (that the "exec" or similar function is available) + * + * @throws WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException + * @return void + */ + public function checkOperationalityExecTrait() + { + if (!ExecWithFallback::anyAvailable()) { + throw new SystemRequirementsNotMetException( + 'exec() is not enabled (nor is alternative methods, such as proc_open())' + ); + } + } +} diff --git a/src/Convert/Converters/Cwebp.php b/src/Convert/Converters/Cwebp.php new file mode 100644 index 00000000..03e293ba --- /dev/null +++ b/src/Convert/Converters/Cwebp.php @@ -0,0 +1,980 @@ + + * @since Class available since Release 2.0.0 + */ +class Cwebp extends AbstractConverter +{ + + use EncodingAutoTrait; + use ExecTrait; + + protected function getUnsupportedDefaultOptions() + { + return []; + } + + /** + * Get the options unique for this converter + * + * @return array Array of options + */ + public function getUniqueOptions($imageType) + { + $binariesForOS = []; + if (isset(self::$suppliedBinariesInfo[PHP_OS])) { + foreach (self::$suppliedBinariesInfo[PHP_OS] as $i => list($file, $hash, $version)) { + $binariesForOS[] = $file; + } + } + + return OptionFactory::createOptions([ + self::niceOption(), + ['try-cwebp', 'boolean', [ + 'title' => 'Try plain cwebp command', + 'description' => + 'If set, the converter will try executing "cwebp -version". In case it succeeds, ' . + 'and the version is higher than those working cwebps found using other methods, ' . + 'the conversion will be done by executing this cwebp.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]], + ['try-discovering-cwebp', 'boolean', [ + 'title' => 'Try discovering cwebp binary', + 'description' => + 'If set, the converter will try to discover installed cwebp binaries using a "which -a cwebp" ' . + 'command, or in case that fails, a "whereis -b cwebp" command. These commands will find ' . + 'cwebp binaries residing in PATH', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]], + ['try-common-system-paths', 'boolean', [ + 'title' => 'Try locating cwebp in common system paths', + 'description' => + 'If set, the converter will look for a cwebp binaries residing in common system locations ' . + 'such as "/usr/bin/cwebp". If such exist, it is assumed that they are valid cwebp binaries. ' . + 'A version check will be run on the binaries found (they are executed with the "-version" flag. ' . + 'The cwebp with the highest version found using this method and the other enabled methods will ' . + 'be used for the actual conversion.' . + 'Note: All methods for discovering cwebp binaries are per default enabled. You can save a few ' . + 'microseconds by disabling some, but it is probably not worth it, as your ' . + 'setup will then become less resilient to system changes.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]], + ['try-supplied-binary-for-os', 'boolean', [ + 'title' => 'Try precompiled cwebp binaries', + 'description' => + 'If set, the converter will try use a precompiled cwebp binary that comes with webp-convert. ' . + 'But only if it has a higher version that those found by other methods. As the library knows ' . + 'the versions of its bundled binaries, no additional time is spent executing them with the ' . + '"-version" parameter. The binaries are hash-checked before executed. ' . + 'The library btw. comes with several versions of precompiled cwebps because they have different ' . + 'dependencies - some works on some systems and others on others.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]], + ['skip-these-precompiled-binaries', 'string', [ + 'title' => 'Skip these precompiled binaries', + 'description' => + '', + 'default' => '', + 'ui' => [ + 'component' => 'multi-select', + 'advanced' => true, + 'options' => $binariesForOS, + 'display' => "option('cwebp-try-supplied-binary-for-os') == true" + ] + + ]], + ['rel-path-to-precompiled-binaries', 'string', [ + 'title' => 'Rel path to precompiled binaries', + 'description' => + '', + 'default' => './Binaries', + 'ui' => [ + 'component' => '', + 'advanced' => true, + 'display' => "option('cwebp-try-supplied-binary-for-os') == true" + ], + 'sensitive' => true + ]], + ['command-line-options', 'string', [ + 'title' => 'Command line options', + 'description' => + '', + 'default' => '', + 'ui' => [ + 'component' => 'input', + 'advanced' => true, + ] + + ]], + ]); + } + + + // OS-specific binaries included in this library, along with hashes + // If other binaries are going to be added, notice that the first argument is what PHP_OS returns. + // (possible values, see here: https://stackoverflow.com/questions/738823/possible-values-for-php-os) + // Got the precompiled binaries here: https://developers.google.com/speed/webp/docs/precompiled + // Note when changing binaries: + // 1: Do NOT use "." in filename. It causes unzipping to fail on some hosts + // 2: Set permission to 775. 755 causes unzipping to fail on some hosts + private static $suppliedBinariesInfo = [ + 'WINNT' => [ + ['cwebp-120-windows-x64.exe', '2849fd06012a9eb311b02a4f8918ae4b16775693bc21e95f4cc6a382eac299f9', '1.2.0'], + + // Keep the 1.1.0 version a while, in case some may have problems with the 1.2.0 version + ['cwebp-110-windows-x64.exe', '442682869402f92ad2c8b3186c02b0ea6d6da68d2f908df38bf905b3411eb9fb', '1.1.0'], + ], + 'Darwin' => [ + ['cwebp-110-mac-10_15', 'bfce742da09b959f9f2929ba808fed9ade25c8025530434b6a47d217a6d2ceb5', '1.1.0'], + ], + 'SunOS' => [ + // Got this from ewww Wordpress plugin, which unfortunately still uses the old 0.6.0 versions + // Can you help me get a 1.0.3 version? + ['cwebp-060-solaris', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f', '0.6.0'] + ], + 'FreeBSD' => [ + // Got this from ewww Wordpress plugin, which unfortunately still uses the old 0.6.0 versions + // Can you help me get a 1.0.3 version? + ['cwebp-060-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573', '0.6.0'] + ], + 'Linux' => [ + + // PS: Some experience the following error with 1.20: + // /lib/x86_64-linux-gnu/libm.so.6: version `GLIBC_2.29' not found + // (see #278) + + ['cwebp-120-linux-x86-64', 'f1b7dc03e95535a6b65852de07c0404be4dba078af48369f434ee39b2abf8f4e', '1.2.0'], + + // As some experience the an error with 1.20 (see #278), we keep the 1.10 + ['cwebp-110-linux-x86-64', '1603b07b592876dd9fdaa62b44aead800234c9474ff26dc7dd01bc0f4785c9c6', '1.1.0'], + + // Statically linked executable + // It may be that it on some systems works, where the dynamically linked does not (see #196) + [ + 'cwebp-103-linux-x86-64-static', + 'ab96f01b49336da8b976c498528080ff614112d5985da69943b48e0cb1c5228a', + '1.0.3' + ], + + // Old executable for systems in case all of the above fails + ['cwebp-061-linux-x86-64', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568', '0.6.1'], + ] + ]; + + /** + * Check all hashes of the precompiled binaries. + * + * This isn't used when converting, but can be used as a startup check. + */ + public static function checkAllHashes() + { + foreach (self::$suppliedBinariesInfo as $os => $arr) { + foreach ($arr as $i => list($filename, $expectedHash)) { + $actualHash = hash_file("sha256", __DIR__ . '/Binaries/' . $filename); + if ($expectedHash != $actualHash) { + throw new \Exception( + 'Hash for ' . $filename . ' is incorrect! ' . + 'Checksum is: ' . $actualHash . ', ' . + ', but expected: ' . $expectedHash . + '. Did you transfer with FTP, but not in binary mode? ' + ); + } + } + } + } + + public function checkOperationality() + { + $this->checkOperationalityExecTrait(); + + $options = $this->options; + if (!$options['try-supplied-binary-for-os'] && + !$options['try-common-system-paths'] && + !$options['try-cwebp'] && + !$options['try-discovering-cwebp'] + ) { + throw new ConverterNotOperationalException( + 'Configured to neither try pure cwebp command, ' . + 'nor look for cweb binaries in common system locations and ' . + 'nor to use one of the supplied precompiled binaries. ' . + 'But these are the only ways this converter can convert images. No conversion can be made!' + ); + } + } + + private function executeBinary($binary, $commandOptions, $useNice) + { + //$version = $this->detectVersion($binary); + + // Redirect stderr to same place as stdout with "2>&1" + // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/ + + $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions . ' 2>&1'; + + //$logger->logLn('command options:' . $commandOptions); + $this->logLn('Trying to convert by executing the following command:'); + $startExecuteBinaryTime = self::startTimer(); + ; + $this->logLn($command); + ExecWithFallback::exec($command, $output, $returnCode); + $this->logExecOutput($output); + $this->logTimeSpent($startExecuteBinaryTime, 'Executing cwebp binary took: '); + $this->logLn(''); + /* + if ($returnCode == 255) { + if (isset($output[0])) { + // Could be an error like 'Error! Cannot open output file' or 'Error! ...preset... ' + $this->logLn(print_r($output[0], true)); + } + }*/ + //$logger->logLn(self::msgForExitCode($returnCode)); + return intval($returnCode); + } + + /** + * Use "escapeshellarg()" on all arguments in a commandline string of options + * + * For example, passing '-sharpness 5 -crop 10 10 40 40 -low_memory' will result in: + * [ + * "-sharpness '5'" + * "-crop '10' '10' '40' '40'" + * "-low_memory" + * ] + * @param string $commandLineOptions string which can contain multiple commandline options + * @return array Array of command options + */ + private static function escapeShellArgOnCommandLineOptions($commandLineOptions) + { + if (!ctype_print($commandLineOptions)) { + throw new ConversionFailedException( + 'Non-printable characters are not allowed in the extra command line options' + ); + } + + if (preg_match('#[^a-zA-Z0-9_\s\-]#', $commandLineOptions)) { + throw new ConversionFailedException('The extra command line options contains inacceptable characters'); + } + + $cmdOptions = []; + $arr = explode(' -', ' ' . $commandLineOptions); + foreach ($arr as $cmdOption) { + $pos = strpos($cmdOption, ' '); + $cName = ''; + if (!$pos) { + $cName = $cmdOption; + if ($cName == '') { + continue; + } + $cmdOptions[] = '-' . $cName; + } else { + $cName = substr($cmdOption, 0, $pos); + $cValues = substr($cmdOption, $pos + 1); + $cValuesArr = explode(' ', $cValues); + foreach ($cValuesArr as &$cArg) { + $cArg = escapeshellarg($cArg); + } + $cValues = implode(' ', $cValuesArr); + $cmdOptions[] = '-' . $cName . ' ' . $cValues; + } + } + return $cmdOptions; + } + + /** + * Build command line options for a given version of cwebp. + * + * The "-near_lossless" param is not supported on older versions of cwebp, so skip on those. + * + * @param string $version Version of cwebp (ie "1.0.3") + * @return string + */ + private function createCommandLineOptions($version) + { + + $this->logLn('Creating command line options for version: ' . $version); + + // we only need two decimal places for version. + // convert to number to make it easier to compare + $version = preg_match('#^\d+\.\d+#', $version, $matches); + $versionNum = 0; + if (isset($matches[0])) { + $versionNum = floatval($matches[0]); + } else { + $this->logLn( + 'Could not extract version number from the following version string: ' . $version, + 'bold' + ); + } + + //$this->logLn('version:' . strval($versionNum)); + + $options = $this->options; + + $cmdOptions = []; + + // Metadata (all, exif, icc, xmp or none (default)) + // Comma-separated list of existing metadata to copy from input to output + if ($versionNum >= 0.3) { + $cmdOptions[] = '-metadata ' . $options['metadata']; + } else { + $this->logLn('Ignoring metadata option (requires cwebp 0.3)', 'italic'); + } + + // preset. Appears first in the list as recommended in the docs + if (!is_null($options['preset'])) { + if ($options['preset'] != 'none') { + $cmdOptions[] = '-preset ' . escapeshellarg($options['preset']); + } + } + + // Size + $addedSizeOption = false; + if (!is_null($options['size-in-percentage'])) { + $sizeSource = filesize($this->source); + if ($sizeSource !== false) { + $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100); + $cmdOptions[] = '-size ' . $targetSize; + $addedSizeOption = true; + } + } + + // quality + if (!$addedSizeOption) { + $cmdOptions[] = '-q ' . $this->getCalculatedQuality(); + } + + // alpha-quality + if ($this->options['alpha-quality'] !== 100) { + $cmdOptions[] = '-alpha_q ' . escapeshellarg($this->options['alpha-quality']); + } + + // Losless PNG conversion + if ($options['encoding'] == 'lossless') { + // No need to add -lossless when near-lossless is used (on version >= 0.5) + if (($options['near-lossless'] === 100) || ($versionNum < 0.5)) { + $cmdOptions[] = '-lossless'; + } + } + + // Near-lossles + if ($options['near-lossless'] !== 100) { + if ($versionNum < 0.5) { + $this->logLn('Ignoring near-lossless option (requires cwebp 0.5)', 'italic'); + } else { + // The "-near_lossless" flag triggers lossless encoding. We don't want that to happen, + // we want the "encoding" option to be respected, and we need it to be in order for + // encoding=auto to work. + // So: Only set when "encoding" is set to "lossless" + if ($options['encoding'] == 'lossless') { + $cmdOptions[] = '-near_lossless ' . $options['near-lossless']; + } else { + $this->logLn( + 'The near-lossless option ignored for lossy' + ); + } + } + } + + // Autofilter + if ($options['auto-filter'] === true) { + $cmdOptions[] = '-af'; + } + + // SharpYUV + if ($options['sharp-yuv'] === true) { + if ($versionNum >= 0.6) { // #284 + $cmdOptions[] = '-sharp_yuv'; + } else { + $this->logLn('Ignoring sharp-yuv option (requires cwebp 0.6)', 'italic'); + } + } + + + // Built-in method option + $cmdOptions[] = '-m ' . strval($options['method']); + + // Built-in low memory option + if ($options['low-memory']) { + $cmdOptions[] = '-low_memory'; + } + + // command-line-options + if ($options['command-line-options']) { + /* + In some years, we can use the splat instead (requires PHP 5.6) + array_push( + $cmdOptions, + ...self::escapeShellArgOnCommandLineOptions($options['command-line-options']) + ); + */ + foreach (self::escapeShellArgOnCommandLineOptions($options['command-line-options']) as $cmdLineOption) { + array_push($cmdOptions, $cmdLineOption); + } + } + + // Source file + $cmdOptions[] = escapeshellarg($this->source); + + // Output + $cmdOptions[] = '-o ' . escapeshellarg($this->destination); + + $commandOptions = implode(' ', $cmdOptions); + //$this->logLn('command line options:' . $commandOptions); + + return $commandOptions; + } + + private function checkHashForSuppliedBinary($binaryFile, $hash) + { + // File exists, now generate its hash + // hash_file() is normally available, but it is not always + // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash + // If available, validate that hash is correct. + + if (function_exists('hash_file')) { + $this->logLn( + 'Checking checksum for supplied binary: ' . $binaryFile + ); + $startHashCheckTime = self::startTimer(); + + $binaryHash = hash_file('sha256', $binaryFile); + + if ($binaryHash != $hash) { + $this->logLn( + 'Binary checksum of supplied binary is invalid! ' . + 'Did you transfer with FTP, but not in binary mode? ' . + 'File:' . $binaryFile . '. ' . + 'Expected checksum: ' . $hash . '. ' . + 'Actual checksum:' . $binaryHash . '.', + 'bold' + ); + return false; + ; + } + + $this->logTimeSpent($startHashCheckTime, 'Checksum test took: '); + } + return true; + } + + /** + * Get supplied binary info for current OS. + * paths are made absolute and checked. Missing are removed + * + * @return array Two arrays. + * First array: array of files (absolute paths) + * Second array: array of info objects (absolute path, hash and version) + */ + private function getSuppliedBinaryInfoForCurrentOS() + { + $this->log('Checking if we have a supplied precompiled binary for your OS (' . PHP_OS . ')... '); + + // Try supplied binary (if available for OS, and hash is correct) + $options = $this->options; + if (!isset(self::$suppliedBinariesInfo[PHP_OS])) { + $this->logLn('No we dont - not for that OS'); + return []; + } + + $filesFound = []; + $info = []; + $files = self::$suppliedBinariesInfo[PHP_OS]; + if (count($files) == 1) { + $this->logLn('We do.'); + } else { + $this->logLn('We do. We in fact have ' . count($files)); + } + + $skipThese = explode(',', $this->options['skip-these-precompiled-binaries']); + + //$this->logLn('However, skipping' . print_r($skipThese, true)); + + foreach ($files as $i => list($file, $hash, $version)) { + //$file = $info[0]; + //$hash = $info[1]; + + $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file; + + // Replace "/./" with "/" in path (we could alternatively use realpath) + //$binaryFile = preg_replace('#\/\.\/#', '/', $binaryFile); + // The file should exist, but may have been removed manually. + /* + if (!file_exists($binaryFile)) { + $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic'); + return false; + }*/ + if (in_array($file, $skipThese)) { + $this->logLn('Skipped: ' . $file . ' (was told to in the "skip-these-precompiled-binaries" option)'); + continue; + } + + + $realPathResult = realpath($binaryFile); + if ($realPathResult === false) { + $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic'); + continue; + } + $binaryFile = $realPathResult; + $filesFound[] = $realPathResult; + $info[] = [$realPathResult, $hash, $version, $file]; + } + return [$filesFound, $info]; + } + + private function who() + { + ExecWithFallback::exec('whoami 2>&1', $whoOutput, $whoReturnCode); + if (($whoReturnCode == 0) && (isset($whoOutput[0]))) { + return 'user: "' . $whoOutput[0] . '"'; + } else { + return 'the user that the command was run with'; + } + } + + /** + * Detect the version of a cwebp binary. + * + * @param string $binary The binary to detect version for (path to cwebp or simply "cwebp") + * + * @return string|int Version string (ie "1.0.2") OR return code, in case of failure + */ + private function detectVersion($binary) + { + $command = $binary . ' -version 2>&1'; + $this->log('- Executing: ' . $command); + ExecWithFallback::exec($command, $output, $returnCode); + + if ($returnCode == 0) { + if (isset($output[0])) { + $this->logLn('. Result: version: *' . $output[0] . '*'); + return $output[0]; + } + } else { + $this->log('. Result: '); + if ($returnCode == 127) { + $this->logLn( + '*Exec failed* (the cwebp binary was not found at path: ' . $binary . + ', or it had missing library dependencies)' + ); + } else { + if ($returnCode == 126) { + $this->logLn( + '*Exec failed*. ' . + 'Permission denied (' . $this->who() . ' does not have permission to execute that binary)' + ); + } else { + $this->logLn( + '*Exec failed* (return code: ' . $returnCode . ')' + ); + $this->logExecOutput($output); + } + } + return $returnCode; + } + return ''; // Will not happen. Just so phpstan doesn't complain + } + + /** + * Check versions for an array of binaries. + * + * @param array $binaries array of binaries to detect the version of + * + * @return array the "detected" key holds working binaries and their version numbers, the + * the "failed" key holds failed binaries and their error codes. + */ + private function detectVersions($binaries) + { + $binariesWithVersions = []; + $binariesWithFailCodes = []; + + foreach ($binaries as $binary) { + $versionStringOrFailCode = $this->detectVersion($binary); + // $this->logLn($binary . ': ' . $versionString); + if (gettype($versionStringOrFailCode) == 'string') { + $binariesWithVersions[$binary] = $versionStringOrFailCode; + } else { + $binariesWithFailCodes[$binary] = $versionStringOrFailCode; + } + } + return ['detected' => $binariesWithVersions, 'failed' => $binariesWithFailCodes]; + } + + private function logBinariesFound($binaries, $startTime) + { + if (count($binaries) == 0) { + $this->logLn('Found 0 binaries' . self::getTimeStr($startTime)); + } else { + $this->logLn('Found ' . count($binaries) . ' binaries' . self::getTimeStr($startTime)); + foreach ($binaries as $binary) { + $this->logLn('- ' . $binary); + } + } + } + + private function logDiscoverAction($optionName, $description) + { + if ($this->options[$optionName]) { + $this->logLn( + 'Discovering binaries ' . $description . ' ' . + '(to skip this step, disable the "' . $optionName . '" option)' + ); + } else { + $this->logLn( + 'Skipped discovering binaries ' . $description . ' ' . + '(enable "' . $optionName . '" if you do not want to skip that step)' + ); + } + } + + private static function startTimer() + { + if (function_exists('microtime')) { + return microtime(true); + } else { + return 0; + } + } + + private static function readTimer($startTime) + { + if (function_exists('microtime')) { + $endTime = microtime(true); + $seconds = ($endTime - $startTime); + return round(($seconds * 1000)); + } else { + return 0; + } + } + + private static function getTimeStr($startTime, $pre = ' (spent ', $post = ')') + { + if (function_exists('microtime')) { + $ms = self::readTimer($startTime); + return $pre . $ms . ' ms' . $post; + } + return ''; + } + + private function logTimeSpent($startTime, $pre = 'Spent: ') + { + if (function_exists('microtime')) { + $ms = self::readTimer($startTime); + $this->logLn($pre . $ms . ' ms'); + } + } + + /** + * @return array Two arrays (in an array). + * First array: binaries found, + * Second array: supplied binaries info for current OS + */ + private function discoverCwebpBinaries() + { + $this->logLn( + 'Looking for cwebp binaries.' + ); + + $startDiscoveryTime = self::startTimer(); + + $binaries = []; + + if (defined('WEBPCONVERT_CWEBP_PATH')) { + $this->logLn('WEBPCONVERT_CWEBP_PATH was defined, so using that path and ignoring any other'); + return [[constant('WEBPCONVERT_CWEBP_PATH')],[[], []]]; + } + if (!empty(getenv('WEBPCONVERT_CWEBP_PATH'))) { + $this->logLn( + 'WEBPCONVERT_CWEBP_PATH environment variable was set, so using that path and ignoring any other' + ); + return [[getenv('WEBPCONVERT_CWEBP_PATH')],[[], []]]; + } + + if ($this->options['try-cwebp']) { + $startTime = self::startTimer(); + $this->logLn( + 'Discovering if a plain cwebp call works (to skip this step, disable the "try-cwebp" option)' + ); + $result = $this->detectVersion('cwebp'); + if (gettype($result) == 'string') { + $this->logLn('We could get the version, so yes, a plain cwebp call works ' . + '(spent ' . self::readTimer($startTime) . ' ms)'); + $binaries[] = 'cwebp'; + } else { + $this->logLn('Nope a plain cwebp call does not work' . self::getTimeStr($startTime)); + } + } else { + $this->logLn( + 'Skipped discovering if a plain cwebp call works' . + ' (enable the "try-cwebp" option if you do not want to skip that step)' + ); + } + + // try-discovering-cwebp + $startTime = self::startTimer(); + $this->logDiscoverAction('try-discovering-cwebp', 'using "which -a cwebp" command.'); + if ($this->options['try-discovering-cwebp']) { + $moreBinaries = LocateBinaries::locateInstalledBinaries('cwebp'); + $this->logBinariesFound($moreBinaries, $startTime); + $binaries = array_merge($binaries, $moreBinaries); + } + + // 'try-common-system-paths' + $startTime = self::startTimer(); + $this->logDiscoverAction('try-common-system-paths', 'by peeking in common system paths'); + if ($this->options['try-common-system-paths']) { + $moreBinaries = LocateBinaries::locateInCommonSystemPaths('cwebp'); + $this->logBinariesFound($moreBinaries, $startTime); + $binaries = array_merge($binaries, $moreBinaries); + } + + // try-supplied-binary-for-os + $suppliedBinariesInfo = [[], []]; + $startTime = self::startTimer(); + $this->logDiscoverAction('try-supplied-binary-for-os', 'which are distributed with the webp-convert library'); + if ($this->options['try-supplied-binary-for-os']) { + $suppliedBinariesInfo = $this->getSuppliedBinaryInfoForCurrentOS(); + $moreBinaries = $suppliedBinariesInfo[0]; + $this->logBinariesFound($moreBinaries, $startTime); + //$binaries = array_merge($binaries, $moreBinaries); + } + + $this->logTimeSpent($startDiscoveryTime, 'Discovering cwebp binaries took: '); + $this->logLn(''); + + return [array_values(array_unique($binaries)), $suppliedBinariesInfo]; + } + + /** + * Try executing a cwebp binary (or command, like: "cwebp") + * + * @param string $binary + * @param string $version Version of cwebp (ie "1.0.3") + * @param boolean $useNice Whether to use "nice" command or not + * + * @return boolean success or not. + */ + private function tryCwebpBinary($binary, $version, $useNice) + { + + //$this->logLn('Trying binary: ' . $binary); + $commandOptions = $this->createCommandLineOptions($version); + + $returnCode = $this->executeBinary($binary, $commandOptions, $useNice); + if ($returnCode == 0) { + // It has happened that even with return code 0, there was no file at destination. + if (!file_exists($this->destination)) { + $this->logLn('executing cweb returned success code - but no file was found at destination!'); + return false; + } else { + $this->logLn('Success'); + return true; + } + } else { + $this->logLn( + 'Exec failed (return code: ' . $returnCode . ')' + ); + return false; + } + } + + /** + * Helper for composing an error message when no converters are working. + * + * @param array $versions The array which we get from calling ::detectVersions($binaries) + * @return string An informative and to the point error message. + */ + private function composeMeaningfullErrorMessageNoVersionsWorking($versions) + { + // TODO: Take "supplied" into account + + // PS: array_values() is used to reindex + $uniqueFailCodes = array_values(array_unique(array_values($versions['failed']))); + $justOne = (count($versions['failed']) == 1); + + if (count($uniqueFailCodes) == 1) { + if ($uniqueFailCodes[0] == 127) { + return 'No cwebp binaries located. Check the conversion log for details.'; + } + } + // If there are more failures than 127, the 127 failures are unintesting. + // It is to be expected that some of the common system paths does not contain a cwebp. + $uniqueFailCodesBesides127 = array_values(array_diff($uniqueFailCodes, [127])); + + if (count($uniqueFailCodesBesides127) == 1) { + if ($uniqueFailCodesBesides127[0] == 126) { + return 'No cwebp binaries could be executed (permission denied for ' . $this->who() . ').'; + } + } + + $errorMsg = ''; + if ($justOne) { + $errorMsg .= 'The cwebp file found cannot be can be executed '; + } else { + $errorMsg .= 'None of the cwebp files can be executed '; + } + if (count($uniqueFailCodesBesides127) == 1) { + $errorMsg .= '(failure code: ' . $uniqueFailCodesBesides127[0] . ')'; + } else { + $errorMsg .= '(failure codes: ' . implode(', ', $uniqueFailCodesBesides127) . ')'; + } + return $errorMsg; + } + + protected function doActualConvert() + { + list($foundBinaries, $suppliedBinariesInfo) = $this->discoverCwebpBinaries(); + $suppliedBinaries = $suppliedBinariesInfo[0]; + $allBinaries = array_merge($foundBinaries, $suppliedBinaries); + + //$binaries = $this->discoverCwebpBinaries(); + if (count($allBinaries) == 0) { + $this->logLn('No cwebp binaries found!'); + + $discoverOptions = [ + 'try-supplied-binary-for-os', + 'try-common-system-paths', + 'try-cwebp', + 'try-discovering-cwebp' + ]; + $disabledDiscoverOptions = []; + foreach ($discoverOptions as $discoverOption) { + if (!$this->options[$discoverOption]) { + $disabledDiscoverOptions[] = $discoverOption; + } + } + if (count($disabledDiscoverOptions) == 0) { + throw new SystemRequirementsNotMetException( + 'No cwebp binaries found.' + ); + } else { + throw new SystemRequirementsNotMetException( + 'No cwebp binaries found. Try enabling the "' . + implode('" option or the "', $disabledDiscoverOptions) . '" option.' + ); + } + } + + $detectedVersions = []; + if (count($foundBinaries) > 0) { + $this->logLn( + 'Detecting versions of the cwebp binaries found' . + (count($suppliedBinaries) > 0 ? ' (except supplied binaries)' : '.') + ); + $startDetectionTime = self::startTimer(); + $versions = $this->detectVersions($foundBinaries); + $detectedVersions = $versions['detected']; + + $this->logTimeSpent($startDetectionTime, 'Detecting versions took: '); + } + + //$suppliedVersions = []; + $suppliedBinariesHash = []; + $suppliedBinariesFilename = []; + + $binaryVersions = $detectedVersions; + foreach ($suppliedBinariesInfo[1] as list($path, $hash, $version, $filename)) { + $binaryVersions[$path] = $version; + $suppliedBinariesHash[$path] = $hash; + $suppliedBinariesFilename[$path] = $filename; + } + + //$binaryVersions = array_merge($detectedVersions, $suppliedBinariesInfo); + + // TODO: reimplement + /* + $versions['supplied'] = $suppliedBinariesInfo; + + $binaryVersions = $versions['detected']; + if ((count($binaryVersions) == 0) && (count($suppliedBinaries) == 0)) { + // No working cwebp binaries found, no supplied binaries found + + throw new SystemRequirementsNotMetException( + $this->composeMeaningfullErrorMessageNoVersionsWorking($versions) + ); + }*/ + + // Sort binaries so those with highest numbers comes first + arsort($binaryVersions); + $this->logLn( + 'Binaries ordered by version number.' + ); + foreach ($binaryVersions as $binary => $version) { + $this->logLn('- ' . $binary . ': (version: ' . $version . ')'); + } + + // Execute! + $this->logLn( + 'Starting conversion, using the first of these. If that should fail, ' . + 'the next will be tried and so on.' + ); + $useNice = ($this->options['use-nice'] && $this->checkNiceSupport()); + + $success = false; + foreach ($binaryVersions as $binary => $version) { + if (isset($suppliedBinariesHash[$binary])) { + if (!$this->checkHashForSuppliedBinary($binary, $suppliedBinariesHash[$binary])) { + continue; + } + } + if ($this->tryCwebpBinary($binary, $version, $useNice)) { + $success = true; + break; + } else { + if (isset($suppliedBinariesFilename[$binary])) { + $this->logLn( + 'Note: You can prevent trying this precompiled binary, by setting the ' . + '"skip-these-precompiled-binaries" option to "' . $suppliedBinariesFilename[$binary] . '"' + ); + } + } + } + + // cwebp sets file permissions to 664 but instead .. + // .. $this->source file permissions should be used + + if ($success) { + $fileStatistics = stat($this->source); + if ($fileStatistics !== false) { + // Apply same permissions as source file, but strip off the executable bits + $permissions = $fileStatistics['mode'] & 0000666; + chmod($this->destination, $permissions); + } + } else { + throw new SystemRequirementsNotMetException('Failed converting. Check the conversion log for details.'); + } + } +} diff --git a/src/Convert/Converters/Ewww.php b/src/Convert/Converters/Ewww.php new file mode 100644 index 00000000..97724185 --- /dev/null +++ b/src/Convert/Converters/Ewww.php @@ -0,0 +1,397 @@ + + * @since Class available since Release 2.0.0 + */ +class Ewww extends AbstractConverter +{ + use CloudConverterTrait; + use CurlTrait; + + /** @var array|null Array of invalid or exceeded api keys discovered during conversions (during the request) */ + public static $nonFunctionalApiKeysDiscoveredDuringConversion; + + public function getUniqueOptions($imageType) + { + return OptionFactory::createOptions([ + ['api-key', 'string', [ + 'title' => 'Ewww API key', + 'description' => 'ewww API key. ' . + 'If you choose "auto", webp-convert will ' . + 'convert to both lossy and lossless and pick the smallest result', + 'default' => '', + 'sensitive' => true, + 'ui' => [ + 'component' => 'password', + ] + ]], + ['check-key-status-before-converting', 'boolean', [ + 'title' => 'Check key status before converting', + 'description' => + 'If enabled, the api key will be validated (relative inexpensive) before trying ' . + 'to convert. For automatic conversions, you should enable it. Otherwise you run the ' . + 'risk that the same files will be uploaded to ewww cloud service over and over again, ' . + 'in case the key has expired. For manually triggered conversions, you can safely disable ' . + 'the option.', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + ] + ]], + ]); + } + + protected function getUnsupportedDefaultOptions() + { + return [ + 'alpha-quality', + 'auto-filter', + 'encoding', + 'low-memory', + 'method', + 'near-lossless', + 'preset', + 'sharp-yuv', + 'size-in-percentage', + ]; + } + + /** + * Get api key from options or environment variable + * + * @return string|false api key or false if none is set + */ + private function getKey() + { + if (!empty($this->options['api-key'])) { + return $this->options['api-key']; + } + if (defined('WEBPCONVERT_EWWW_API_KEY')) { + return constant('WEBPCONVERT_EWWW_API_KEY'); + } + if (!empty(getenv('WEBPCONVERT_EWWW_API_KEY'))) { + return getenv('WEBPCONVERT_EWWW_API_KEY'); + } + return false; + } + + + /** + * Check operationality of Ewww converter. + * + * @throws SystemRequirementsNotMetException if system requirements are not met (curl) + * @throws ConverterNotOperationalException if key is missing or invalid, or quota has exceeded + */ + public function checkOperationality() + { + + $apiKey = $this->getKey(); + + if ($apiKey === false) { + if (isset($this->options['key'])) { + throw new InvalidApiKeyException( + 'The "key" option has been renamed to "api-key" in webp-convert 2.0. ' . + 'You must change the configuration accordingly.' + ); + } + + throw new InvalidApiKeyException('Missing API key.'); + } + + if (strlen($apiKey) < 20) { + throw new InvalidApiKeyException( + 'Api key is invalid. Api keys are supposed to be 32 characters long - ' . + 'the provided api key is much shorter' + ); + } + + // Check for curl requirements + $this->checkOperationalityForCurlTrait(); + + if ($this->options['check-key-status-before-converting']) { + $keyStatus = self::getKeyStatus($apiKey); + switch ($keyStatus) { + case 'great': + break; + case 'exceeded': + throw new ConverterNotOperationalException('Quota has exceeded'); + //break; + case 'invalid': + throw new InvalidApiKeyException('Api key is invalid'); + //break; + } + } + } + + /* + public function checkConvertability() + { + // check upload limits + $this->checkConvertabilityCloudConverterTrait(); + } + */ + + // Although this method is public, do not call directly. + // You should rather call the static convert() function, defined in AbstractConverter, which + // takes care of preparing stuff before calling doConvert, and validating after. + protected function doActualConvert() + { + + $options = $this->options; + + $ch = self::initCurl(); + + //$this->logLn('api key:' . $this->getKey()); + + $postData = [ + 'api_key' => $this->getKey(), + 'webp' => '1', + 'file' => curl_file_create($this->source), + 'quality' => $this->getCalculatedQuality(), + 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') + ]; + + curl_setopt_array( + $ch, + [ + CURLOPT_URL => "https://optimize.exactlywww.com/v2/", + CURLOPT_HTTPHEADER => [ + 'User-Agent: WebPConvert', + 'Accept: image/*' + ], + CURLOPT_POSTFIELDS => $postData, + CURLOPT_BINARYTRANSFER => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_SSL_VERIFYPEER => false + ] + ); + + $response = curl_exec($ch); + + if (curl_errno($ch)) { + throw new ConversionFailedException(curl_error($ch)); + } + + // The API does not always return images. + // For example, it may return a message such as '{"error":"invalid","t":"exceeded"} + // Messages has a http content type of ie 'text/html; charset=UTF-8 + // Images has application/octet-stream. + // So verify that we got an image back. + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + if (($contentType != 'application/octet-stream') && ($contentType != 'image/webp')) { + //echo curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + curl_close($ch); + + /* + For bogus or expired key it returns: {"error":"invalid","t":"exceeded"} + For exceeded key it returns: {"error":"exceeded"} + */ + $responseObj = json_decode($response); + if (isset($responseObj->error)) { + $this->logLn('We received the following error response: ' . $responseObj->error); + $this->logLn('Complete response: ' . json_encode($responseObj)); + + // Store the invalid key in array so it can be received once the Stack is completed + // (even when stack succeeds) + if (!isset(self::$nonFunctionalApiKeysDiscoveredDuringConversion)) { + self::$nonFunctionalApiKeysDiscoveredDuringConversion = []; + } + if (!in_array($options['api-key'], self::$nonFunctionalApiKeysDiscoveredDuringConversion)) { + self::$nonFunctionalApiKeysDiscoveredDuringConversion[] = $options['api-key']; + } + if ($responseObj->error == "invalid") { + throw new InvalidApiKeyException('The api key is invalid (or expired)'); + } else { + throw new InvalidApiKeyException('The quota is exceeded for the api-key'); + } + } + + throw new ConversionFailedException( + 'ewww api did not return an image. It could be that the key is invalid. Response: ' + . $response + . ". Content type: " + . curl_getinfo($ch, CURLINFO_CONTENT_TYPE) + ); + } + + // Not sure this can happen. So just in case + if ($response == '') { + throw new ConversionFailedException('ewww api did not return anything'); + } + + $success = file_put_contents($this->destination, $response); + + if (!$success) { + throw new ConversionFailedException('Error saving file'); + } + } + + /** + * Keep subscription alive by optimizing a jpeg + * (ewww closes accounts after 6 months of inactivity - and webp conversions seems not to be counted? ) + */ + public static function keepSubscriptionAlive($source, $key) + { + try { + $ch = curl_init(); + } catch (\Exception $e) { + return 'curl is not installed'; + } + if ($ch === false) { + return 'curl could not be initialized'; + } + curl_setopt_array( + $ch, + [ + CURLOPT_URL => "https://optimize.exactlywww.com/v2/", + CURLOPT_HTTPHEADER => [ + 'User-Agent: WebPConvert', + 'Accept: image/*' + ], + CURLOPT_POSTFIELDS => [ + 'api_key' => $key, + 'webp' => '0', + 'file' => curl_file_create($source), + 'domain' => $_SERVER['HTTP_HOST'], + 'quality' => 60, + 'metadata' => 0 + ], + CURLOPT_BINARYTRANSFER => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_SSL_VERIFYPEER => false + ] + ); + + $response = curl_exec($ch); + if (curl_errno($ch)) { + return 'curl error' . curl_error($ch); + } + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + if (($contentType != 'application/octet-stream') && ($contentType != 'image/webp')) { + curl_close($ch); + + /* May return this: {"error":"invalid","t":"exceeded"} */ + $responseObj = json_decode($response); + if (isset($responseObj->error)) { + return 'The key is invalid'; + } + + return 'ewww api did not return an image. It could be that the key is invalid. Response: ' . $response; + } + + // Not sure this can happen. So just in case + if ($response == '') { + return 'ewww api did not return anything'; + } + + return true; + } + + /* + public static function blacklistKey($key) + { + } + + public static function isKeyBlacklisted($key) + { + }*/ + + /** + * Return "great", "exceeded" or "invalid" + */ + public static function getKeyStatus($key) + { + $ch = self::initCurl(); + + curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/verify/"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, [ + 'api_key' => $key + ]); + + curl_setopt($ch, CURLOPT_USERAGENT, 'WebPConvert'); + + $response = curl_exec($ch); + // echo $response; + if (curl_errno($ch)) { + throw new \Exception(curl_error($ch)); + } + curl_close($ch); + + // Possible responses: + // “great” = verification successful + // “exceeded” = indicates a valid key with no remaining image credits. + // an empty response indicates that the key is not valid + + if ($response == '') { + return 'invalid'; + } + $responseObj = json_decode($response); + if (isset($responseObj->error)) { + if (($responseObj->error == 'invalid') || ($responseObj->error == 'bye invalid')) { + return 'invalid'; + } else { + if ($responseObj->error == 'bye invalid') { + return 'invalid'; + } else { + throw new \Exception('Ewww returned unexpected error: ' . $response); + } + } + } + if (!isset($responseObj->status)) { + throw new \Exception('Ewww returned unexpected response to verify request: ' . $response); + } + switch ($responseObj->status) { + case 'great': + case 'exceeded': + return $responseObj->status; + } + throw new \Exception('Ewww returned unexpected status to verify request: "' . $responseObj->status . '"'); + } + + public static function isWorkingKey($key) + { + return (self::getKeyStatus($key) == 'great'); + } + + public static function isValidKey($key) + { + return (self::getKeyStatus($key) != 'invalid'); + } + + public static function getQuota($key) + { + $ch = self::initCurl(); + + curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/quota/"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, [ + 'api_key' => $key + ]); + curl_setopt($ch, CURLOPT_USERAGENT, 'WebPConvert'); + + $response = curl_exec($ch); + return $response; // ie -830 23. Seems to return empty for invalid keys + // or empty + //echo $response; + } +} diff --git a/src/Convert/Converters/FFMpeg.php b/src/Convert/Converters/FFMpeg.php new file mode 100644 index 00000000..bfaaf086 --- /dev/null +++ b/src/Convert/Converters/FFMpeg.php @@ -0,0 +1,178 @@ + + * @since Class available since Release 2.0.0 + */ +class FFMpeg extends AbstractConverter +{ + use ExecTrait; + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return [ + 'alpha-quality', + 'auto-filter', + 'low-memory', + 'metadata', + 'near-lossless', + 'sharp-yuv', + 'size-in-percentage', + ]; + } + + /** + * Get the options unique for this converter + * + * @return array Array of options + */ + public function getUniqueOptions($imageType) + { + return OptionFactory::createOptions([ + self::niceOption() + ]); + } + + private function getPath() + { + if (defined('WEBPCONVERT_FFMPEG_PATH')) { + return constant('WEBPCONVERT_FFMPEG_PATH'); + } + if (!empty(getenv('WEBPCONVERT_FFMPEG_PATH'))) { + return getenv('WEBPCONVERT_FFMPEG_PATH'); + } + return 'ffmpeg'; + } + + public function isInstalled() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + return ($returnCode == 0); + } + + // Check if webp delegate is installed + public function isWebPDelegateInstalled() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + foreach ($output as $line) { + if (preg_match('# --enable-libwebp#i', $line)) { + return true; + } + } + return false; + } + + /** + * Check (general) operationality of imagack converter executable + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + $this->checkOperationalityExecTrait(); + + if (!$this->isInstalled()) { + throw new SystemRequirementsNotMetException( + 'ffmpeg is not installed (cannot execute: "' . $this->getPath() . '")' + ); + } + if (!$this->isWebPDelegateInstalled()) { + throw new SystemRequirementsNotMetException('ffmpeg was compiled without libwebp'); + } + } + + /** + * Build command line options + * + * @return string + */ + private function createCommandLineOptions() + { + // PS: Available webp options for ffmpeg are documented here: + // https://www.ffmpeg.org/ffmpeg-codecs.html#libwebp + + $commandArguments = []; + + $commandArguments[] = '-i'; + $commandArguments[] = escapeshellarg($this->source); + + // preset. Appears first in the list as recommended in the cwebp docs + if (!is_null($this->options['preset'])) { + if ($this->options['preset'] != 'none') { + $commandArguments[] = '-preset ' . $this->options['preset']; + } + } + + // Overwrite existing files?, yes! + $commandArguments[] = '-y'; + + if ($this->isQualityDetectionRequiredButFailing()) { + // quality:auto was specified, but could not be determined. + // we cannot apply the max-quality logic, but we can provide auto quality + // simply by not specifying the quality option. + } else { + $commandArguments[] = '-qscale ' . escapeshellarg($this->getCalculatedQuality()); + } + if ($this->options['encoding'] == 'lossless') { + $commandArguments[] = '-lossless 1'; + } else { + $commandArguments[] = '-lossless 0'; + } + + if ($this->options['metadata'] == 'none') { + // Unfortunately there seems to be no easy solution available for removing all metadata. + } + + // compression_level maps to method, according to https://www.ffmpeg.org/ffmpeg-codecs.html#libwebp + $commandArguments[] = '-compression_level ' . $this->options['method']; + + $commandArguments[] = escapeshellarg($this->destination); + + + return implode(' ', $commandArguments); + } + + protected function doActualConvert() + { + //$this->logLn($this->getVersion()); + + $command = $this->getPath() . ' ' . $this->createCommandLineOptions() . ' 2>&1'; + + $useNice = ($this->options['use-nice'] && $this->checkNiceSupport()); + if ($useNice) { + $command = 'nice ' . $command; + } + $this->logLn('Executing command: ' . $command); + ExecWithFallback::exec($command, $output, $returnCode); + + $this->logExecOutput($output); + if ($returnCode == 0) { + $this->logLn('success'); + } else { + $this->logLn('return code: ' . $returnCode); + } + + if ($returnCode == 127) { + throw new SystemRequirementsNotMetException('ffmpeg is not installed'); + } + if ($returnCode != 0) { + throw new SystemRequirementsNotMetException('The exec() call failed'); + } + } +} diff --git a/src/Convert/Converters/Gd.php b/src/Convert/Converters/Gd.php new file mode 100644 index 00000000..2303c5e4 --- /dev/null +++ b/src/Convert/Converters/Gd.php @@ -0,0 +1,536 @@ + + * @since Class available since Release 2.0.0 + */ +class Gd extends AbstractConverter +{ + public function supportsLossless() + { + return false; + } + + protected function getUnsupportedDefaultOptions() + { + return [ + 'alpha-quality', + 'auto-filter', + 'encoding', + 'low-memory', + 'metadata', + 'method', + 'near-lossless', + 'preset', + 'sharp-yuv', + 'size-in-percentage', + ]; + } + + private $errorMessageWhileCreating = ''; + private $errorNumberWhileCreating; + + /** + * Check (general) operationality of Gd converter. + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + if (!extension_loaded('gd')) { + throw new SystemRequirementsNotMetException('Required Gd extension is not available.'); + } + + if (!function_exists('imagewebp')) { + throw new SystemRequirementsNotMetException( + 'Gd has been compiled without webp support.' + ); + } + + if (!function_exists('imagepalettetotruecolor')) { + if (!self::functionsExist([ + 'imagecreatetruecolor', 'imagealphablending', 'imagecolorallocatealpha', + 'imagefilledrectangle', 'imagecopy', 'imagedestroy', 'imagesx', 'imagesy' + ])) { + throw new SystemRequirementsNotMetException( + 'Gd cannot convert palette color images to RGB. ' . + 'Even though it would be possible to convert RGB images to webp with Gd, ' . + 'we refuse to do it. A partial working converter causes more trouble than ' . + 'a non-working. To make this converter work, you need the imagepalettetotruecolor() ' . + 'function to be enabled on your system.' + ); + } + } + } + + /** + * Check if specific file is convertable with current converter / converter settings. + * + * @throws SystemRequirementsNotMetException if Gd has been compiled without support for image type + */ + public function checkConvertability() + { + $mimeType = $this->getMimeTypeOfSource(); + switch ($mimeType) { + case 'image/png': + if (!function_exists('imagecreatefrompng')) { + throw new SystemRequirementsNotMetException( + 'Gd has been compiled without PNG support and can therefore not convert this PNG image.' + ); + } + break; + + case 'image/jpeg': + if (!function_exists('imagecreatefromjpeg')) { + throw new SystemRequirementsNotMetException( + 'Gd has been compiled without Jpeg support and can therefore not convert this jpeg image.' + ); + } + } + } + + /** + * Find out if all functions exists. + * + * @return boolean + */ + private static function functionsExist($functionNamesArr) + { + foreach ($functionNamesArr as $functionName) { + if (!function_exists($functionName)) { + return false; + } + } + return true; + } + + /** + * Try to convert image pallette to true color on older systems that does not have imagepalettetotruecolor(). + * + * The aim is to function as imagepalettetotruecolor, but for older systems. + * So, if the image is already rgb, nothing will be done, and true will be returned + * PS: Got the workaround here: https://secure.php.net/manual/en/function.imagepalettetotruecolor.php + * + * @param resource|\GdImage $image + * @return boolean TRUE if the convertion was complete, or if the source image already is a true color image, + * otherwise FALSE is returned. + */ + private function makeTrueColorUsingWorkaround(&$image) + { + //return $this->makeTrueColor($image); + /* + if (function_exists('imageistruecolor') && imageistruecolor($image)) { + return true; + }*/ + if (self::functionsExist(['imagecreatetruecolor', 'imagealphablending', 'imagecolorallocatealpha', + 'imagefilledrectangle', 'imagecopy', 'imagedestroy', 'imagesx', 'imagesy'])) { + $dst = imagecreatetruecolor(imagesx($image), imagesy($image)); + + if ($dst === false) { + return false; + } + + $success = false; + + //prevent blending with default black + if (imagealphablending($dst, false) !== false) { + //change the RGB values if you need, but leave alpha at 127 + $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127); + + if ($transparent !== false) { + //simpler than flood fill + if (imagefilledrectangle($dst, 0, 0, imagesx($image), imagesy($image), $transparent) !== false) { + //restore default blending + if (imagealphablending($dst, true) !== false) { + if (imagecopy($dst, $image, 0, 0, 0, 0, imagesx($image), imagesy($image)) !== false) { + $success = true; + } + }; + } + } + } + if ($success) { + imagedestroy($image); + $image = $dst; + } else { + imagedestroy($dst); + } + return $success; + } else { + // The necessary methods for converting color palette are not avalaible + return false; + } + } + + /** + * Try to convert image pallette to true color. + * + * Try to convert image pallette to true color. If imagepalettetotruecolor() exists, that is used (available from + * PHP >= 5.5.0). Otherwise using workaround found on the net. + * + * @param resource|\GdImage $image + * @return boolean TRUE if the convertion was complete, or if the source image already is a true color image, + * otherwise FALSE is returned. + */ + private function makeTrueColor(&$image) + { + if (function_exists('imagepalettetotruecolor')) { + return imagepalettetotruecolor($image); + } else { + $this->logLn( + 'imagepalettetotruecolor() is not available on this system - using custom implementation instead' + ); + return $this->makeTrueColorUsingWorkaround($image); + } + } + + /** + * Create Gd image resource from source + * + * @throws InvalidInputException if mime type is unsupported or could not be detected + * @throws ConversionFailedException if imagecreatefrompng or imagecreatefromjpeg fails + * @return resource|\GdImage $image The created image + */ + private function createImageResource() + { + $mimeType = $this->getMimeTypeOfSource(); + + switch ($mimeType) { + case 'image/png': + $image = imagecreatefrompng($this->source); + if ($image === false) { + throw new ConversionFailedException( + 'Gd failed when trying to load/create image (imagecreatefrompng() failed)' + ); + } + return $image; + + case 'image/jpeg': + $image = imagecreatefromjpeg($this->source); + if ($image === false) { + throw new ConversionFailedException( + 'Gd failed when trying to load/create image (imagecreatefromjpeg() failed)' + ); + } + return $image; + } + + throw new InvalidInputException('Unsupported mime type'); + } + + /** + * Try to make image resource true color if it is not already. + * + * @param resource|\GdImage $image The image to work on + * @return boolean|null True if it is now true color. False if it is NOT true color. null, if we cannot tell + */ + protected function tryToMakeTrueColorIfNot(&$image) + { + $whatIsItNow = null; + $mustMakeTrueColor = false; + if (function_exists('imageistruecolor')) { + if (imageistruecolor($image)) { + $this->logLn('image is true color'); + $whatIsItNow = true; + } else { + $this->logLn('image is not true color'); + $mustMakeTrueColor = true; + $whatIsItNow = false; + } + } else { + $this->logLn('It can not be determined if image is true color'); + $mustMakeTrueColor = true; + } + + if ($mustMakeTrueColor) { + $this->logLn('converting color palette to true color'); + $success = $this->makeTrueColor($image); + if ($success) { + return true; + } else { + $this->logLn( + 'FAILED converting color palette to true color. ' + ); + } + } + return $whatIsItNow; + } + + /** + * + * @param resource|\GdImage $image + * @return boolean true if alpha blending was set successfully, false otherwise + */ + protected function trySettingAlphaBlending($image) + { + if (function_exists('imagealphablending')) { + // TODO: Should we set second parameter to false instead? + // As here: https://www.texelate.co.uk/blog/retaining-png-transparency-with-php-gd + // (PS: I have backed up some local changes - to Gd.php, which includes changing that param + // to false. But I didn't finish up back then and now I forgot, so need to retest before + // changing anything... + if (!imagealphablending($image, true)) { + $this->logLn('Warning: imagealphablending() failed'); + return false; + } + } else { + $this->logLn( + 'Warning: imagealphablending() is not available on your system.' . + ' Converting PNGs with transparency might fail on some systems' + ); + return false; + } + + if (function_exists('imagesavealpha')) { + if (!imagesavealpha($image, true)) { + $this->logLn('Warning: imagesavealpha() failed'); + return false; + } + } else { + $this->logLn( + 'Warning: imagesavealpha() is not available on your system. ' . + 'Converting PNGs with transparency might fail on some systems' + ); + return false; + } + return true; + } + + protected function errorHandlerWhileCreatingWebP($errno, $errstr, $errfile, $errline) + { + $this->errorNumberWhileCreating = $errno; + $this->errorMessageWhileCreating = $errstr . ' in ' . $errfile . ', line ' . $errline . + ', PHP ' . PHP_VERSION . ' (' . PHP_OS . ')'; + //return false; + } + + /** + * + * @param resource|\GdImage $image + * @return void + */ + protected function destroyAndRemove($image) + { + imagedestroy($image); + if (file_exists($this->destination)) { + @unlink($this->destination); + } + } + + /** + * + * @param resource|\GdImage $image + * @return void + */ + protected function tryConverting($image) + { + + // Danger zone! + // Using output buffering to generate image. + // In this zone, Do NOT do anything that might produce unwanted output + // Do NOT call $this->logLn + // --------------------------------- (start of danger zone) + + $addedZeroPadding = false; + set_error_handler(array($this, "errorHandlerWhileCreatingWebP")); + + // This line may trigger log, so we need to do it BEFORE ob_start() ! + $q = $this->getCalculatedQuality(); + + ob_start(); + + // Adding this try/catch is perhaps not neccessary. + // I'm not certain that the error handler takes care of Throwable errors. + // and - sorry - was to lazy to find out right now. So for now: better safe than sorry. #320 + $error = null; + $success = false; + + try { + // Beware: This call can throw FATAL on windows (cannot be catched) + // This for example happens on palette images + $success = imagewebp($image, null, $q); + } catch (\Exception $e) { + $error = $e; + } catch (\Throwable $e) { + $error = $e; + } + if (!is_null($error)) { + restore_error_handler(); + ob_end_clean(); + $this->destroyAndRemove($image); + throw $error; + } + if (!$success) { + $this->destroyAndRemove($image); + ob_end_clean(); + restore_error_handler(); + throw new ConversionFailedException( + 'Failed creating image. Call to imagewebp() failed.', + $this->errorMessageWhileCreating + ); + } + + + // The following hack solves an `imagewebp` bug + // See https://stackoverflow.com/questions/30078090/imagewebp-php-creates-corrupted-webp-files + if (ob_get_length() % 2 == 1) { + echo "\0"; + $addedZeroPadding = true; + } + $output = ob_get_clean(); + restore_error_handler(); + + if ($output == '') { + $this->destroyAndRemove($image); + throw new ConversionFailedException( + 'Gd failed: imagewebp() returned empty string' + ); + } + + // --------------------------------- (end of danger zone). + + + if ($this->errorMessageWhileCreating != '') { + switch ($this->errorNumberWhileCreating) { + case E_WARNING: + $this->logLn('An warning was produced during conversion: ' . $this->errorMessageWhileCreating); + break; + case E_NOTICE: + $this->logLn('An notice was produced during conversion: ' . $this->errorMessageWhileCreating); + break; + default: + $this->destroyAndRemove($image); + throw new ConversionFailedException( + 'An error was produced during conversion', + $this->errorMessageWhileCreating + ); + //break; + } + } + + if ($addedZeroPadding) { + $this->logLn( + 'Fixing corrupt webp by adding a zero byte ' . + '(older versions of Gd had a bug, but this hack fixes it)' + ); + } + + $success = file_put_contents($this->destination, $output); + + if (!$success) { + $this->destroyAndRemove($image); + throw new ConversionFailedException( + 'Gd failed when trying to save the image. Check file permissions!' + ); + } + + /* + Previous code was much simpler, but on a system, the hack was not activated (a file with uneven number of bytes + was created). This is puzzeling. And the old code did not provide any insights. + Also, perhaps having two subsequent writes to the same file could perhaps cause a problem. + In the new code, there is only one write. + However, a bad thing about the new code is that the entire webp file is read into memory. This might cause + memory overflow with big files. + Perhaps we should check the filesize of the original and only use the new code when it is smaller than + memory limit set in PHP by a certain factor. + Or perhaps only use the new code on older versions of Gd + https://wordpress.org/support/topic/images-not-seen-on-chrome/#post-11390284 + + Here is the old code: + + $success = imagewebp($image, $this->destination, $this->getCalculatedQuality()); + + if (!$success) { + throw new ConversionFailedException( + 'Gd failed when trying to save the image as webp (call to imagewebp() failed). ' . + 'It probably failed writing file. Check file permissions!' + ); + } + + + // This hack solves an `imagewebp` bug + // See https://stackoverflow.com/questions/30078090/imagewebp-php-creates-corrupted-webp-files + if (filesize($this->destination) % 2 == 1) { + file_put_contents($this->destination, "\0", FILE_APPEND); + } + */ + } + + // Although this method is public, do not call directly. + // You should rather call the static convert() function, defined in AbstractConverter, which + // takes care of preparing stuff before calling doConvert, and validating after. + protected function doActualConvert() + { + $versionString = gd_info()["GD Version"]; + $this->logLn('GD Version: ' . $versionString); + + // Btw: Check out processWebp here: + // https://github.com/Intervention/image/blob/master/src/Intervention/Image/Gd/Encoder.php + + // Create image resource + $image = $this->createImageResource(); + + // Try to convert color palette if it is not true color + $isItTrueColorNow = $this->tryToMakeTrueColorIfNot($image); + if ($isItTrueColorNow === false) { + // our tests shows that converting palette fails on all systems, + throw new ConversionFailedException( + 'Cannot convert image because it is a palette image and the palette image cannot ' . + 'be converted to RGB (which is required). To convert to RGB, we would need ' . + 'imagepalettetotruecolor(), which is not available on your system. ' . + 'Our workaround does not have the neccasary functions for converting to RGB either.' + ); + } + if (is_null($isItTrueColorNow)) { + $isWindows = preg_match('/^win/i', PHP_OS); + $isMacDarwin = preg_match('/^darwin/i', PHP_OS); // actually no problem in PHP 7.4 and 8.0 + if ($isWindows || $isMacDarwin) { + throw new ConversionFailedException( + 'Cannot convert image because it appears to be a palette image and the palette image ' . + 'cannot be converted to RGB, as you do not have imagepalettetotruecolor() enabled. ' . + 'Converting palette on ' . + ($isWindows ? 'Windows causes FATAL error' : 'Mac causes halt') . + 'So we abort now' + ); + } + } + + if ($this->getMimeTypeOfSource() == 'image/png') { + if (function_exists('version_compare')) { + if (version_compare($versionString, "2.1.1", "<=")) { + $this->logLn( + 'BEWARE: Your version of Gd looses the alpha chanel when converting to webp.' . + 'You should upgrade Gd, use another converter or stop converting PNGs. ' . + 'See: https://github.com/rosell-dk/webp-convert/issues/238' + ); + } elseif (version_compare($versionString, "2.2.4", "<=")) { + $this->logLn( + 'BEWARE: Older versions of Gd looses the alpha chanel when converting to webp.' . + 'We have not tested if transparency fails on your (rather old) version of Gd. ' . + 'Please let us know. ' . + 'See: https://github.com/rosell-dk/webp-convert/issues/238' + ); + } + } + + // Try to set alpha blending + $this->trySettingAlphaBlending($image); + } + + // Try to convert it to webp + $this->tryConverting($image); + + // End of story + imagedestroy($image); + } +} diff --git a/src/Convert/Converters/Gmagick.php b/src/Convert/Converters/Gmagick.php new file mode 100644 index 00000000..0108c724 --- /dev/null +++ b/src/Convert/Converters/Gmagick.php @@ -0,0 +1,173 @@ + + * @since Class available since Release 2.0.0 + */ +class Gmagick extends AbstractConverter +{ + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return [ + 'near-lossless', + 'size-in-percentage', + ]; + } + + /** + * Check (general) operationality of Gmagick converter. + * + * Note: + * It may be that Gd has been compiled without jpeg support or png support. + * We do not check for this here, as the converter could still be used for the other. + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + if (!extension_loaded('Gmagick')) { + throw new SystemRequirementsNotMetException('Required Gmagick extension is not available.'); + } + + if (!class_exists('Gmagick')) { + throw new SystemRequirementsNotMetException( + 'Gmagick is installed, but not correctly. The class Gmagick is not available' + ); + } + + $im = new \Gmagick($this->source); + + if (!in_array('WEBP', $im->queryformats())) { + throw new SystemRequirementsNotMetException('Gmagick was compiled without WebP support.'); + } + } + + /** + * Check if specific file is convertable with current converter / converter settings. + * + * @throws SystemRequirementsNotMetException if Gmagick does not support image type + */ + public function checkConvertability() + { + $im = new \Gmagick(); + $mimeType = $this->getMimeTypeOfSource(); + switch ($mimeType) { + case 'image/png': + if (!in_array('PNG', $im->queryFormats())) { + throw new SystemRequirementsNotMetException( + 'Gmagick has been compiled without PNG support and can therefore not convert this PNG image.' + ); + } + break; + case 'image/jpeg': + if (!in_array('JPEG', $im->queryFormats())) { + throw new SystemRequirementsNotMetException( + 'Gmagick has been compiled without Jpeg support and can therefore not convert this Jpeg image.' + ); + } + break; + } + } + + // Although this method is public, do not call directly. + // You should rather call the static convert() function, defined in AbstractConverter, which + // takes care of preparing stuff before calling doConvert, and validating after. + protected function doActualConvert() + { + + // PS: graphicsmagick options are documented here: (search for "webp:") + // http://www.graphicsmagick.org/GraphicsMagick.html + + $options = $this->options; + + try { + $im = new \Gmagick($this->source); + } catch (\Exception $e) { + throw new ConversionFailedException( + 'Failed creating Gmagick object of file', + 'Failed creating Gmagick object of file: "' . $this->source . '" - Gmagick threw an exception.', + $e + ); + } + + $im->setimageformat('WEBP'); + + // setimageoption() has not always been there, so check first. #169 + if (method_exists($im, 'setimageoption')) { + // Finally cracked setting webp options. + // See #167 + // - and https://stackoverflow.com/questions/47294962/how-to-write-lossless-webp-files-with-perlmagick + + if (!is_null($options['preset'])) { + if ($options['preset'] != 'none') { + $imageHint = $options['preset']; + switch ($imageHint) { + case 'drawing': + case 'icon': + case 'text': + $imageHint = 'graph'; + $this->logLn( + 'The "preset" value was mapped to "graph" because gmagick does not support "drawing",' . + ' "icon" and "text", but grouped these into one option: "graph".' + ); + } + $im->setimageoption('webp', 'image-hint', $imageHint); + } + } + $im->setimageoption('webp', 'method', $options['method']); + $im->setimageoption('webp', 'lossless', $options['encoding'] == 'lossless' ? 'true' : 'false'); + $im->setimageoption('webp', 'alpha-quality', $options['alpha-quality']); + + if ($options['auto-filter'] === true) { + $im->setimageoption('webp', 'auto-filter', 'true'); + } + + if ($options['sharp-yuv'] === true) { + $im->setimageoption('webp', 'use-sharp-yuv', 'true'); + } + } + + /* + low-memory seems not to be supported: + $im->setimageoption('webp', 'low-memory', $options['low-memory'] ? true : false); + */ + + if ($options['metadata'] == 'none') { + // Strip metadata and profiles + $im->stripImage(); + } + + // Ps: Imagick automatically uses same quality as source, when no quality is set + // This feature is however not present in Gmagick + // TODO: However, it might be possible after all - see #91 + $im->setcompressionquality($this->getCalculatedQuality()); + + // We call getImageBlob(). + // That method is undocumented, but it is there! + // - just like it is in imagick, as pointed out here: + // https://www.php.net/manual/ru/gmagick.readimageblob.php + + /** @scrutinizer ignore-call */ + $imageBlob = $im->getImageBlob(); + + $success = @file_put_contents($this->destination, $imageBlob); + + if (!$success) { + throw new ConversionFailedException('Failed writing file'); + } + } +} diff --git a/src/Convert/Converters/GmagickBinary.php b/src/Convert/Converters/GmagickBinary.php new file mode 100644 index 00000000..c3cd5b89 --- /dev/null +++ b/src/Convert/Converters/GmagickBinary.php @@ -0,0 +1,28 @@ + + * @since Class available since Release 2.0.0 + */ +class GmagickBinary extends AbstractConverter +{ + public function checkOperationality() + { + throw new ConversionFailedException( + 'This converter has changed ID from "gmagickbinary" to "graphicsmagick". You need to change!' + ); + } + + protected function doActualConvert() + { + $this->checkOperationality(); + } +} diff --git a/src/Convert/Converters/GraphicsMagick.php b/src/Convert/Converters/GraphicsMagick.php new file mode 100644 index 00000000..86d1e308 --- /dev/null +++ b/src/Convert/Converters/GraphicsMagick.php @@ -0,0 +1,220 @@ + + * @since Class available since Release 2.0.0 + */ +class GraphicsMagick extends AbstractConverter +{ + use ExecTrait; + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return [ + 'near-lossless', + 'size-in-percentage', + ]; + } + + /** + * Get the options unique for this converter + * + * @return array Array of options + */ + public function getUniqueOptions($imageType) + { + return OptionFactory::createOptions([ + self::niceOption() + ]); + } + + private function getPath() + { + if (defined('WEBPCONVERT_GRAPHICSMAGICK_PATH')) { + return constant('WEBPCONVERT_GRAPHICSMAGICK_PATH'); + } + if (!empty(getenv('WEBPCONVERT_GRAPHICSMAGICK_PATH'))) { + return getenv('WEBPCONVERT_GRAPHICSMAGICK_PATH'); + } + return 'gm'; + } + + public function isInstalled() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + return ($returnCode == 0); + } + + public function getVersion() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + if (($returnCode == 0) && isset($output[0])) { + return preg_replace('#http.*#', '', $output[0]); + } + return 'unknown'; + } + + // Check if webp delegate is installed + public function isWebPDelegateInstalled() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + foreach ($output as $line) { + if (preg_match('#WebP.*yes#i', $line)) { + return true; + } + } + return false; + } + + /** + * Check (general) operationality of imagack converter executable + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + $this->checkOperationalityExecTrait(); + + if (!$this->isInstalled()) { + throw new SystemRequirementsNotMetException('gmagick is not installed'); + } + if (!$this->isWebPDelegateInstalled()) { + throw new SystemRequirementsNotMetException('webp delegate missing'); + } + } + + /** + * Build command line options + * + * @return string + */ + private function createCommandLineOptions() + { + // For available webp options, check out: + // https://github.com/kstep/graphicsmagick/blob/master/coders/webp.c + + $commandArguments = []; + + /* + if ($this->isQualityDetectionRequiredButFailing()) { + // Unlike imagick binary, it seems gmagick binary uses a fixed + // quality (75) when quality is omitted + // So we cannot simply omit in order to get same quality as source. + // But perhaps there is another way? + // Check out #91 - it is perhaps as easy as this: "-define jpeg:preserve-settings" + } + */ + $commandArguments[] = '-quality ' . escapeshellarg($this->getCalculatedQuality()); + + $options = $this->options; + + // preset + if (!is_null($options['preset'])) { + if ($options['preset'] != 'none') { + $imageHint = $options['preset']; + switch ($imageHint) { + case 'drawing': + case 'icon': + case 'text': + $imageHint = 'graph'; + $this->logLn( + 'Note: the preset was mapped to "graph" because graphicsmagick does not support ' . + '"drawing", "icon" and "text", but grouped these into one option: "graph".' + ); + } + $commandArguments[] = '-define webp:image-hint=' . escapeshellarg($imageHint); + } + } + + // encoding + if ($options['encoding'] == 'lossless') { + // Btw: + // I am not sure if we should set "quality" for lossless. + // Quality should not apply to lossless, but my tests shows that it does in some way for gmagick + // setting it low, you get bad visual quality and small filesize. Setting it high, you get the opposite + // Some claim it is a bad idea to set quality, but I'm not so sure. + // https://stackoverflow.com/questions/4228027/ + // First, I do not just get bigger images when setting quality, as toc777 does. + // Secondly, the answer is very old and that bad behaviour is probably fixed by now. + $commandArguments[] = '-define webp:lossless=true'; + } else { + $commandArguments[] = '-define webp:lossless=false'; + } + + if ($options['auto-filter'] === true) { + $commandArguments[] = '-define webp:auto-filter=true'; + } + + if ($options['alpha-quality'] !== 100) { + $commandArguments[] = '-define webp:alpha-quality=' . strval($options['alpha-quality']); + } + + if ($options['low-memory']) { + $commandArguments[] = '-define webp:low-memory=true'; + } + + if ($options['sharp-yuv'] === true) { + $commandArguments[] = '-define webp:use-sharp-yuv=true'; + } + + if ($options['metadata'] == 'none') { + $commandArguments[] = '-strip'; + } + + $commandArguments[] = '-define webp:method=' . $options['method']; + + $commandArguments[] = escapeshellarg($this->source); + $commandArguments[] = escapeshellarg('webp:' . $this->destination); + + return implode(' ', $commandArguments); + } + + protected function doActualConvert() + { + //$this->logLn('Using quality:' . $this->getCalculatedQuality()); + + $this->logLn('Version: ' . $this->getVersion()); + + $command = $this->getPath() . ' convert ' . $this->createCommandLineOptions() . ' 2>&1'; + + $useNice = ($this->options['use-nice'] && $this->checkNiceSupport()); + if ($useNice) { + $command = 'nice ' . $command; + } + $this->logLn('Executing command: ' . $command); + ExecWithFallback::exec($command, $output, $returnCode); + + $this->logExecOutput($output); + if ($returnCode == 0) { + $this->logLn('success'); + } else { + $this->logLn('return code: ' . $returnCode); + } + + if ($returnCode == 127) { + throw new SystemRequirementsNotMetException('gmagick is not installed'); + } + if ($returnCode != 0) { + $this->logLn('command:' . $command); + $this->logLn('return code:' . $returnCode); + $this->logLn('output:' . print_r(implode("\n", $output), true)); + throw new SystemRequirementsNotMetException('The exec() call failed'); + } + } +} diff --git a/src/Convert/Converters/ImageMagick.php b/src/Convert/Converters/ImageMagick.php new file mode 100644 index 00000000..2470b8ef --- /dev/null +++ b/src/Convert/Converters/ImageMagick.php @@ -0,0 +1,275 @@ + + * @since Class available since Release 2.0.0 + */ +class ImageMagick extends AbstractConverter +{ + use ExecTrait; + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return [ + 'size-in-percentage', + ]; + } + + /** + * Get the options unique for this converter + * + * @return array Array of options + */ + public function getUniqueOptions($imageType) + { + return OptionFactory::createOptions([ + self::niceOption(), + ['try-common-system-paths', 'boolean', [ + 'title' => 'Try locating ImageMagick in common system paths', + 'description' => + 'If set, the converter will look for a ImageMagick binaries residing in common system locations ' . + 'such as "/usr/bin/convert". ' . + 'If such exist, it is assumed that they are valid ImageMagick binaries. ', + 'default' => true, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]], + ]); + } + + // To futher improve this converter, I could check out: + // https://github.com/Orbitale/ImageMagickPHP + + private function getPath() + { + if (defined('WEBPCONVERT_IMAGEMAGICK_PATH')) { + return constant('WEBPCONVERT_IMAGEMAGICK_PATH'); + } + if (!empty(getenv('WEBPCONVERT_IMAGEMAGICK_PATH'))) { + return getenv('WEBPCONVERT_IMAGEMAGICK_PATH'); + } + + if ($this->options['try-common-system-paths']) { + $binaries = LocateBinaries::locateInCommonSystemPaths('convert'); + if (!empty($binaries)) { + return $binaries[0]; + } + } + + return 'convert'; + } + + private function getVersion() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + if (($returnCode == 0) && isset($output[0])) { + return $output[0]; + } else { + return 'unknown'; + } + } + + public function isInstalled() + { + ExecWithFallback::exec($this->getPath() . ' -version 2>&1', $output, $returnCode); + return ($returnCode == 0); + } + + // Check if webp delegate is installed + public function isWebPDelegateInstalled() + { + ExecWithFallback::exec($this->getPath() . ' -list delegate 2>&1', $output, $returnCode); + foreach ($output as $line) { + if (preg_match('#webp\\s*=#i', $line)) { + return true; + } + } + + // try other command + ExecWithFallback::exec($this->getPath() . ' -list configure 2>&1', $output, $returnCode); + foreach ($output as $line) { + if (preg_match('#DELEGATE.*webp#i', $line)) { + return true; + } + } + + return false; + + // PS, convert -version does not output delegates on travis, so it is not reliable + } + + /** + * Check (general) operationality of imagack converter executable + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + $this->checkOperationalityExecTrait(); + + if (!$this->isInstalled()) { + throw new SystemRequirementsNotMetException( + 'imagemagick is not installed (cannot execute: "' . $this->getPath() . '")' + ); + } + if (!$this->isWebPDelegateInstalled()) { + throw new SystemRequirementsNotMetException('webp delegate missing'); + } + } + + /** + * Build command line options + * + * @param string $versionNumber. Ie "6.9.10-23" + * @return string + */ + private function createCommandLineOptions($versionNumber = 'unknown') + { + // Available webp options for imagemagick are documented here: + // - https://imagemagick.org/script/webp.php + // - https://github.com/ImageMagick/ImageMagick/blob/main/coders/webp.c + + // We should perhaps implement low-memory. Its already in cwebp, it + // could perhaps be promoted to a general option + + $commandArguments = []; + if ($this->isQualityDetectionRequiredButFailing()) { + // quality:auto was specified, but could not be determined. + // we cannot apply the max-quality logic, but we can provide auto quality + // simply by not specifying the quality option. + } else { + $commandArguments[] = '-quality ' . escapeshellarg($this->getCalculatedQuality()); + } + + $options = $this->options; + + if (!is_null($options['preset'])) { + // "image-hint" is at least available from 6.9.4-0 (I can't see further back) + if ($options['preset'] != 'none') { + $imageHint = $options['preset']; + switch ($imageHint) { + case 'drawing': + case 'icon': + case 'text': + $imageHint = 'graph'; + $this->logLn( + 'The "preset" value was mapped to "graph" because imagemagick does not support "drawing",' . + ' "icon" and "text", but grouped these into one option: "graph".' + ); + } + $commandArguments[] = '-define webp:image-hint=' . escapeshellarg($imageHint); + } + } + + if ($options['encoding'] == 'lossless') { + // lossless is at least available from 6.9.4-0 (I can't see further back) + $commandArguments[] = '-define webp:lossless=true'; + } + + if ($options['low-memory']) { + // low-memory is at least available from 6.9.4-0 (I can't see further back) + $commandArguments[] = '-define webp:low-memory=true'; + } + + if ($options['auto-filter'] === true) { + // auto-filter is at least available from 6.9.4-0 (I can't see further back) + $commandArguments[] = '-define webp:auto-filter=true'; + } + + if ($options['metadata'] == 'none') { + $commandArguments[] = '-strip'; + } + + if ($options['alpha-quality'] !== 100) { + // alpha-quality is at least available from 6.9.4-0 (I can't see further back) + $commandArguments[] = '-define webp:alpha-quality=' . strval($options['alpha-quality']); + } + + if ($options['sharp-yuv'] === true) { + if (version_compare($versionNumber, '7.0.8-26', '>=')) { + $commandArguments[] = '-define webp:use-sharp-yuv=true'; + } else { + $this->logLn( + 'Note: "sharp-yuv" option is not supported in your version of ImageMagick. ' . + 'ImageMagic >= 7.0.8-26 is required', + 'italic' + ); + } + } + + if ($options['near-lossless'] != 100) { + if (version_compare($versionNumber, '7.0.10-54', '>=')) { // #299 + $commandArguments[] = '-define webp:near-lossless=' . escapeshellarg($options['near-lossless']); + } else { + $this->logLn( + 'Note: "near-lossless" option is not supported in your version of ImageMagick. ' . + 'ImageMagic >= 7.0.10-54 is required', + 'italic' + ); + } + } + + // "method" is at least available from 6.9.4-0 (I can't see further back) + $commandArguments[] = '-define webp:method=' . $options['method']; + + $commandArguments[] = escapeshellarg($this->source); + $commandArguments[] = escapeshellarg('webp:' . $this->destination); + + return implode(' ', $commandArguments); + } + + protected function doActualConvert() + { + $version = $this->getVersion(); + + $this->logLn($version); + + preg_match('#\d+\.\d+\.\d+[\d\.\-]+#', $version, $matches); + $versionNumber = (isset($matches[0]) ? $matches[0] : 'unknown'); + + $this->logLn('Extracted version number: ' . $versionNumber); + + $command = $this->getPath() . ' ' . $this->createCommandLineOptions($versionNumber) . ' 2>&1'; + + $useNice = ($this->options['use-nice'] && $this->checkNiceSupport()); + if ($useNice) { + $command = 'nice ' . $command; + } + $this->logLn('Executing command: ' . $command); + ExecWithFallback::exec($command, $output, $returnCode); + + $this->logExecOutput($output); + if ($returnCode == 0) { + $this->logLn('success'); + } else { + $this->logLn('return code: ' . $returnCode); + } + + if ($returnCode == 127) { + throw new SystemRequirementsNotMetException('imagemagick is not installed'); + } + if ($returnCode != 0) { + throw new SystemRequirementsNotMetException('The exec call failed'); + } + } +} diff --git a/src/Convert/Converters/Imagick.php b/src/Convert/Converters/Imagick.php new file mode 100644 index 00000000..eee71c57 --- /dev/null +++ b/src/Convert/Converters/Imagick.php @@ -0,0 +1,229 @@ + + * @since Class available since Release 2.0.0 + */ +class Imagick extends AbstractConverter +{ + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return [ + 'size-in-percentage', + ]; + } + + /** + * Check operationality of Imagick converter. + * + * Note: + * It may be that Gd has been compiled without jpeg support or png support. + * We do not check for this here, as the converter could still be used for the other. + * + * @throws SystemRequirementsNotMetException if system requirements are not met + * @return void + */ + public function checkOperationality() + { + if (!extension_loaded('imagick')) { + throw new SystemRequirementsNotMetException('Required iMagick extension is not available.'); + } + + if (!class_exists('\\Imagick')) { + throw new SystemRequirementsNotMetException( + 'iMagick is installed, but not correctly. The class Imagick is not available' + ); + } + + $im = new \Imagick(); + if (!in_array('WEBP', $im->queryFormats('WEBP'))) { + throw new SystemRequirementsNotMetException('iMagick was compiled without WebP support.'); + } + } + + /** + * Check if specific file is convertable with current converter / converter settings. + * + * @throws SystemRequirementsNotMetException if Imagick does not support image type + */ + public function checkConvertability() + { + $im = new \Imagick(); + $mimeType = $this->getMimeTypeOfSource(); + switch ($mimeType) { + case 'image/png': + if (!in_array('PNG', $im->queryFormats('PNG'))) { + throw new SystemRequirementsNotMetException( + 'Imagick has been compiled without PNG support and can therefore not convert this PNG image.' + ); + } + break; + case 'image/jpeg': + if (!in_array('JPEG', $im->queryFormats('JPEG'))) { + throw new SystemRequirementsNotMetException( + 'Imagick has been compiled without Jpeg support and can therefore not convert this Jpeg image.' + ); + } + break; + } + } + + /** + * + * It may also throw an ImagickException if imagick throws an exception + * @throws CreateDestinationFileException if imageblob could not be saved to file + */ + protected function doActualConvert() + { + /* + * More about iMagick's WebP options: + * - Inspect source code: https://github.com/ImageMagick/ImageMagick/blob/master/coders/webp.c#L559 + * (search for "webp:") + * - http://www.imagemagick.org/script/webp.php + * - https://developers.google.com/speed/webp/docs/cwebp + * - https://stackoverflow.com/questions/37711492/imagemagick-specific-webp-calls-in-php + */ + + $options = $this->options; + + // This might throw - we let it! + $im = new \Imagick($this->source); + //$im = new \Imagick(); + //$im->pingImage($this->source); + //$im->readImage($this->source); + + $version = \Imagick::getVersion(); + $this->logLn('ImageMagic API version (full): ' . $version['versionString']); + + preg_match('#\d+\.\d+\.\d+[\d\.\-]+#', $version['versionString'], $matches); + $versionNumber = (isset($matches[0]) ? $matches[0] : 'unknown'); + $this->logLn('ImageMagic API version (just the number): ' . $versionNumber); + + // Note: good enough for info, but not entirely reliable - see #304 + $extVersion = (defined('\Imagick::IMAGICK_EXTVER') ? \Imagick::IMAGICK_EXTVER : phpversion('imagick')); + $this->logLn('Imagic extension version: ' . $extVersion); + + $im->setImageFormat('WEBP'); + + if (!is_null($options['preset'])) { + if ($options['preset'] != 'none') { + $imageHint = $options['preset']; + switch ($imageHint) { + case 'drawing': + case 'icon': + case 'text': + $imageHint = 'graph'; + $this->logLn( + 'The "preset" value was mapped to "graph" because imagick does not support "drawing",' . + ' "icon" and "text", but grouped these into one option: "graph".' + ); + } + $im->setOption('webp:image-hint', $imageHint); + } + } + + $im->setOption('webp:method', $options['method']); + $im->setOption('webp:lossless', $options['encoding'] == 'lossless' ? 'true' : 'false'); + $im->setOption('webp:low-memory', $options['low-memory'] ? 'true' : 'false'); + $im->setOption('webp:alpha-quality', $options['alpha-quality']); + + if ($options['near-lossless'] != 100) { + if (version_compare($versionNumber, '7.0.10-54', '>=')) { + $im->setOption('webp:near-lossless', $options['near-lossless']); + } else { + $this->logLn( + 'Note: near-lossless is not supported in your version of ImageMagick. ' . + 'ImageMagic >= 7.0.10-54 is required', + 'italic' + ); + } + } + + if ($options['auto-filter'] === true) { + $im->setOption('webp:auto-filter', 'true'); + } + + if ($options['sharp-yuv'] === true) { + if (version_compare($versionNumber, '7.0.8-26', '>=')) { + $im->setOption('webp:use-sharp-yuv', 'true'); + } else { + $this->logLn( + 'Note: "sharp-yuv" option is not supported in your version of ImageMagick. ' . + 'ImageMagic >= 7.0.8-26 is required', + 'italic' + ); + } + } + + if ($options['metadata'] == 'none') { + // To strip metadata, we need to use the stripImage() method. However, that method does not only remove + // metadata, but color profiles as well. We want to keep the color profiles, so we grab it now to be able + // to restore it. (Thanks, Max Eremin: https://www.php.net/manual/en/imagick.stripimage.php#120380) + + // Grab color profile (to be able to restore them) + $profiles = $im->getImageProfiles("icc", true); + + // Strip metadata (and color profiles) + $im->stripImage(); + + // Restore color profiles + if (!empty($profiles)) { + $im->profileImage("icc", $profiles['icc']); + } + } + + if ($this->isQualityDetectionRequiredButFailing()) { + // Luckily imagick is a big boy, and automatically converts with same quality as + // source, when the quality isn't set. + // So we simply do not set quality. + // This actually kills the max-quality functionality. But I deem that this is more important + // because setting image quality to something higher than source generates bigger files, + // but gets you no extra quality. When failing to limit quality, you at least get something + // out of it + $this->logLn('Converting without setting quality in order to achieve auto quality'); + } else { + $im->setImageCompressionQuality($this->getCalculatedQuality()); + } + + // https://stackoverflow.com/questions/29171248/php-imagick-jpeg-optimization + // setImageFormat + + // TODO: Read up on + // https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/ + // https://github.com/nwtn/php-respimg + + // TODO: + // Should we set alpha channel for PNG's like suggested here: + // https://gauntface.com/blog/2014/09/02/webp-support-with-imagemagick-and-php ?? + // It seems that alpha channel works without... (at least I see completely transparerent pixels) + + // We used to use writeImageFile() method. But we now use getImageBlob(). See issue #43 + + // This might throw - we let it! + $imageBlob = $im->getImageBlob(); + + $success = file_put_contents($this->destination, $imageBlob); + + if (!$success) { + throw new CreateDestinationFileException('Failed writing file'); + } + + // Btw: check out processWebp() method here: + // https://github.com/Intervention/image/blob/master/src/Intervention/Image/Imagick/Encoder.php + } +} diff --git a/src/Convert/Converters/ImagickBinary.php b/src/Convert/Converters/ImagickBinary.php new file mode 100644 index 00000000..1869ec30 --- /dev/null +++ b/src/Convert/Converters/ImagickBinary.php @@ -0,0 +1,28 @@ + + * @since Class available since Release 2.0.0 + */ +class ImagickBinary extends AbstractConverter +{ + public function checkOperationality() + { + throw new ConversionFailedException( + 'This converter has changed ID from "imagickbinary" to "imagemagick". You need to change!' + ); + } + + protected function doActualConvert() + { + $this->checkOperationality(); + } +} diff --git a/src/Convert/Converters/Stack.php b/src/Convert/Converters/Stack.php new file mode 100644 index 00000000..c81cf4e3 --- /dev/null +++ b/src/Convert/Converters/Stack.php @@ -0,0 +1,283 @@ + + * @since Class available since Release 2.0.0 + */ +class Stack extends AbstractConverter +{ + + protected function getUnsupportedDefaultOptions() + { + return [ + 'alpha-quality', + 'auto-filter', + 'encoding', + 'low-memory', + 'metadata', + 'method', + 'near-lossless', + 'preset', + 'sharp-yuv', + 'size-in-percentage', + 'skip', + 'default-quality', + 'quality', + 'max-quality', + ]; + } + + public function getUniqueOptions($imageType) + { + return OptionFactory::createOptions([ + ['converters', 'array', [ + 'title' => 'Converters', + 'description' => 'Converters to try, ordered by priority.', + 'default' => self::getAvailableConverters(), + 'sensitive' => true, + 'ui' => [ + 'component' => 'multi-select', + 'options' => self::getAvailableConverters(), + 'advanced' => true + ] + ]], + ['converter-options', 'array', [ + 'title' => 'Converter options', + 'description' => + 'Extra options for specific converters.', + 'default' => [], + 'sensitive' => true, + 'ui' => null + ]], + ['preferred-converters', 'array', [ + 'title' => 'Preferred converters', + 'description' => + 'With this option you can move specified converters to the top of the stack. ' . + 'The converters are specified by id.', + 'default' => [], + 'ui' => null + ]], + ['extra-converters', 'array', [ + 'title' => 'Extra converters', + 'description' => + 'Add extra converters to the bottom of the stack', + 'default' => [], + 'sensitive' => true, + 'ui' => null + ]], + ['shuffle', 'boolean', [ + 'title' => 'Shuffle', + 'description' => + 'Shuffles the converter order on each conversion. ' . + 'Can for example be used to spread out requests on multiple cloud converters', + 'default' => false, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true + ] + ]], + ]); + + +/* + return [ + new SensitiveArrayOption('converters', self::getAvailableConverters()), + new SensitiveArrayOption('converter-options', []), + new BooleanOption('shuffle', false), + new ArrayOption('preferred-converters', []), + new SensitiveArrayOption('extra-converters', []) + ];*/ + } + + /** + * Get available converters (ids) - ordered by awesomeness. + * + * @return array An array of ids of converters that comes with this library + */ + public static function getAvailableConverters() + { + return [ + 'cwebp', 'vips', 'imagick', 'gmagick', 'imagemagick', 'graphicsmagick', 'wpc', 'ffmpeg', 'ewww', 'gd' + ]; + } + + /** + * Check (general) operationality of imagack converter executable + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + if (count($this->options['converters']) == 0) { + throw new ConverterNotOperationalException( + 'Converter stack is empty! - no converters to try, no conversion can be made!' + ); + } + + // TODO: We should test if all converters are found in order to detect problems early + + //$this->logLn('Stack converter ignited'); + } + + protected function doActualConvert() + { + $options = $this->options; + + $beginTimeStack = microtime(true); + + $anyRuntimeErrors = false; + + $converters = $options['converters']; + if (count($options['extra-converters']) > 0) { + $converters = array_merge($converters, $options['extra-converters']); + /*foreach ($options['extra-converters'] as $extra) { + $converters[] = $extra; + }*/ + } + + // preferred-converters + if (count($options['preferred-converters']) > 0) { + foreach (array_reverse($options['preferred-converters']) as $prioritizedConverter) { + foreach ($converters as $i => $converter) { + if (is_array($converter)) { + $converterId = $converter['converter']; + } else { + $converterId = $converter; + } + if ($converterId == $prioritizedConverter) { + unset($converters[$i]); + array_unshift($converters, $converter); + break; + } + } + } + // perhaps write the order to the log? (without options) - but this requires some effort + } + + // shuffle + if ($options['shuffle']) { + shuffle($converters); + } + + //$this->logLn(print_r($converters)); + //$options['converters'] = $converters; + //$defaultConverterOptions = $options; + $defaultConverterOptions = []; + + foreach ($this->options2->getOptionsMap() as $id => $option) { + // Right here, there used to be a check that ensured that unknown options was not passed down to the + // converters (" && !($option instanceof GhostOption)"). But well, as the Stack doesn't know about + // converter specific options, such as "try-cwebp", these was not passed down (see #259) + // I'm not sure why the check was made in the first place, but it does not seem neccessary, as the + // converters simply ignore unknown options. So the check has now been removed. + if ($option->isValueExplicitlySet()) { + $defaultConverterOptions[$id] = $option->getValue(); + } + } + + //unset($defaultConverterOptions['converters']); + //unset($defaultConverterOptions['converter-options']); + $defaultConverterOptions['_skip_input_check'] = true; + $defaultConverterOptions['_suppress_success_message'] = true; + unset($defaultConverterOptions['converters']); + unset($defaultConverterOptions['extra-converters']); + unset($defaultConverterOptions['converter-options']); + unset($defaultConverterOptions['preferred-converters']); + unset($defaultConverterOptions['shuffle']); + +// $this->logLn('converters: ' . print_r($converters, true)); + + //return; + foreach ($converters as $converter) { + if (is_array($converter)) { + $converterId = $converter['converter']; + $converterOptions = isset($converter['options']) ? $converter['options'] : []; + } else { + $converterId = $converter; + $converterOptions = []; + if (isset($options['converter-options'][$converterId])) { + // Note: right now, converter-options are not meant to be used, + // when you have several converters of the same type + $converterOptions = $options['converter-options'][$converterId]; + } + } + $converterOptions = array_merge($defaultConverterOptions, $converterOptions); + /* + if ($converterId != 'stack') { + //unset($converterOptions['converters']); + //unset($converterOptions['converter-options']); + } else { + //$converterOptions['converter-options'] = + $this->logLn('STACK'); + $this->logLn('converterOptions: ' . print_r($converterOptions, true)); + }*/ + + $beginTime = microtime(true); + + $this->ln(); + $this->logLn($converterId . ' converter ignited', 'bold'); + + $converter = ConverterFactory::makeConverter( + $converterId, + $this->source, + $this->destination, + $converterOptions, + $this->logger + ); + + try { + $converter->doConvert(); + + //self::runConverterWithTiming($converterId, $source, $destination, $converterOptions, false, $logger); + + $this->logLn($converterId . ' succeeded :)'); + //throw new ConverterNotOperationalException('...'); + return; + } catch (ConverterNotOperationalException $e) { + $this->logLn($e->getMessage()); + } catch (ConversionSkippedException $e) { + $this->logLn($e->getMessage()); + } catch (ConversionFailedException $e) { + $this->logLn($e->getMessage(), 'italic'); + $prev = $e->getPrevious(); + if (!is_null($prev)) { + $this->logLn($prev->getMessage(), 'italic'); + $this->logLn(' in ' . $prev->getFile() . ', line ' . $prev->getLine(), 'italic'); + $this->ln(); + } + //$this->logLn($e->getTraceAsString()); + $anyRuntimeErrors = true; + } + $this->logLn($converterId . ' failed in ' . round((microtime(true) - $beginTime) * 1000) . ' ms'); + } + + $this->ln(); + $this->logLn('Stack failed in ' . round((microtime(true) - $beginTimeStack) * 1000) . ' ms'); + + // Hm, Scrutinizer complains that $anyRuntimeErrors is always false. But that is not true! + if ($anyRuntimeErrors) { + // At least one converter failed + throw new ConversionFailedException( + 'None of the converters in the stack could convert the image.' + ); + } else { + // All converters threw a SystemRequirementsNotMetException + throw new ConverterNotOperationalException('None of the converters in the stack are operational'); + } + } +} diff --git a/src/Convert/Converters/Vips.php b/src/Convert/Converters/Vips.php new file mode 100644 index 00000000..dd3fd9b2 --- /dev/null +++ b/src/Convert/Converters/Vips.php @@ -0,0 +1,306 @@ + + * @since Class available since Release 2.0.0 + */ +class Vips extends AbstractConverter +{ + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return [ + 'auto-filter', + 'size-in-percentage', + ]; + } + + /** + * Get the options unique for this converter + * + * @return array Array of options + */ + public function getUniqueOptions($imageType) + { + $ssOption = new BooleanOption('smart-subsample', false); + $ssOption->markDeprecated(); + return [ + $ssOption + ]; + } + + /** + * Check operationality of Vips converter. + * + * @throws SystemRequirementsNotMetException if system requirements are not met + */ + public function checkOperationality() + { + if (!extension_loaded('vips')) { + throw new SystemRequirementsNotMetException('Required Vips extension is not available.'); + } + + if (!function_exists('vips_image_new_from_file')) { + throw new SystemRequirementsNotMetException( + 'Vips extension seems to be installed, however something is not right: ' . + 'the function "vips_image_new_from_file" is not available.' + ); + } + + if (!function_exists('vips_call')) { + throw new SystemRequirementsNotMetException( + 'Vips extension seems to be installed, however something is not right: ' . + 'the function "vips_call" is not available.' + ); + } + + if (!function_exists('vips_error_buffer')) { + throw new SystemRequirementsNotMetException( + 'Vips extension seems to be installed, however something is not right: ' . + 'the function "vips_error_buffer" is not available.' + ); + } + + + vips_error_buffer(); // clear error buffer + $result = vips_call('webpsave', null); + if ($result === -1) { + $message = vips_error_buffer(); + if (strpos($message, 'VipsOperation: class "webpsave" not found') === 0) { + throw new SystemRequirementsNotMetException( + 'Vips has not been compiled with webp support.' + ); + } + } + } + + /** + * Check if specific file is convertable with current converter / converter settings. + * + * @throws SystemRequirementsNotMetException if Vips does not support image type + */ + public function checkConvertability() + { + // It seems that png and jpeg are always supported by Vips + // - so nothing needs to be done here + + if (function_exists('vips_version')) { + $this->logLn('vipslib version: ' . vips_version()); + } + $this->logLn('vips extension version: ' . phpversion('vips')); + } + + /** + * Create vips image resource from source file + * + * @throws ConversionFailedException if image resource cannot be created + * @return resource vips image resource + */ + private function createImageResource() + { + // We are currently using vips_image_new_from_file(), but we could consider + // calling vips_jpegload / vips_pngload instead + $result = /** @scrutinizer ignore-call */ vips_image_new_from_file($this->source, []); + if ($result === -1) { + /*throw new ConversionFailedException( + 'Failed creating new vips image from file: ' . $this->source + );*/ + $message = /** @scrutinizer ignore-call */ vips_error_buffer(); + throw new ConversionFailedException($message); + } + + if (!is_array($result)) { + throw new ConversionFailedException( + 'vips_image_new_from_file did not return an array, which we expected' + ); + } + + if (count($result) != 1) { + throw new ConversionFailedException( + 'vips_image_new_from_file did not return an array of length 1 as we expected ' . + '- length was: ' . count($result) + ); + } + + $im = array_shift($result); + return $im; + } + + /** + * Create parameters for webpsave + * + * @return array the parameters as an array + */ + private function createParamsForVipsWebPSave() + { + // webpsave options are described here: + // https://libvips.github.io/libvips/API/current/VipsForeignSave.html#vips-webpsave + // near_lossless option is described here: https://github.com/libvips/libvips/pull/430 + + // NOTE: When a new option becomes available, we MUST remember to add + // it to the array of possibly unsupported options in webpsave() ! + $options = [ + "Q" => $this->getCalculatedQuality(), + 'lossless' => ($this->options['encoding'] == 'lossless'), + 'strip' => $this->options['metadata'] == 'none', + ]; + + // Only set the following options if they differ from the default of vipslib + // This ensures we do not get warning if that property isn't supported + if ($this->options['smart-subsample'] !== false) { + // PS: The smart-subsample option is now deprecated, as it turned out + // it was corresponding to the "sharp-yuv" option (see #280) + $options['smart_subsample'] = $this->options['smart-subsample']; + $this->logLn( + '*Note: the "smart-subsample" option is now deprecated. It turned out it corresponded to ' . + 'the general option "sharp-yuv". You should use "sharp-yuv" instead.*' + ); + } + if ($this->options['sharp-yuv'] !== false) { + $options['smart_subsample'] = $this->options['sharp-yuv']; + } + + if ($this->options['alpha-quality'] !== 100) { + $options['alpha_q'] = $this->options['alpha-quality']; + } + + if (!is_null($this->options['preset']) && ($this->options['preset'] != 'none')) { + // preset. 0:default, 1:picture, 2:photo, 3:drawing, 4:icon, 5:text, 6:last + $options['preset'] = array_search( + $this->options['preset'], + ['default', 'picture', 'photo', 'drawing', 'icon', 'text'] + ); + } + if ($this->options['near-lossless'] !== 100) { + if ($this->options['encoding'] == 'lossless') { + // We only let near_lossless have effect when encoding is set to lossless + // otherwise encoding=auto would not work as expected + // Available in https://github.com/libvips/libvips/pull/430, merged 1 may 2016 + // seems it corresponds to release 8.4.2 + $options['near_lossless'] = true; + + // In Vips, the near-lossless value is controlled by Q. + // this differs from how it is done in cwebp, where it is an integer. + // We have chosen same option syntax as cwebp + $options['Q'] = $this->options['near-lossless']; + } + } + if ($this->options['method'] !== 4) { + $options['reduction_effort'] = $this->options['method']; + } + + return $options; + } + + /** + * Save as webp, using vips extension. + * + * Tries to save image resource as webp, using the supplied options. + * Vips fails when a parameter is not supported, but we detect this and unset that parameter and try again + * (recursively call itself until there is no more of these kind of errors). + * + * @param resource $im A vips image resource to save + * @throws ConversionFailedException if conversion fails. + */ + private function webpsave($im, $options) + { + /** @scrutinizer ignore-call */ vips_error_buffer(); // clear error buffer + $result = /** @scrutinizer ignore-call */ vips_call('webpsave', $im, $this->destination, $options); + + //trigger_error('test-warning', E_USER_WARNING); + if ($result === -1) { + $message = /** @scrutinizer ignore-call */ vips_error_buffer(); + + $nameOfPropertyNotFound = ''; + if (preg_match("#no property named .(.*).#", $message, $matches)) { + $nameOfPropertyNotFound = $matches[1]; + } elseif (preg_match("#(.*)\\sunsupported$#", $message, $matches)) { + // Actually, I am not quite sure if this ever happens. + // I got a "near_lossless unsupported" error message in a build, but perhaps it rather a warning + if (in_array($matches[1], [ + 'lossless', + 'alpha_q', + 'near_lossless', + 'smart_subsample', + 'reduction_effort', + 'preset' + ])) { + $nameOfPropertyNotFound = $matches[1]; + } + } + + if ($nameOfPropertyNotFound != '') { + $msg = 'Note: Your version of vipslib does not support the "' . + $nameOfPropertyNotFound . '" property'; + + switch ($nameOfPropertyNotFound) { + case 'alpha_q': + $msg .= ' (It was introduced in vips 8.4)'; + break; + case 'near_lossless': + $msg .= ' (It was introduced in vips 8.4)'; + break; + case 'smart_subsample': + $msg .= ' (its the vips equalent to the "sharp-yuv" option. It was introduced in vips 8.4)'; + break; + case 'reduction_effort': + $msg .= ' (its the vips equalent to the "method" option. It was introduced in vips 8.8.0)'; + break; + case 'preset': + $msg .= ' (It was introduced in vips 8.4)'; + break; + } + $msg .= '. The option is ignored.'; + + + $this->logLn($msg, 'bold'); + + unset($options[$nameOfPropertyNotFound]); + $this->webpsave($im, $options); + } else { + throw new ConversionFailedException($message); + } + } + } + + /** + * Convert, using vips extension. + * + * Tries to create image resource and save it as webp using the calculated options. + * PS: The Vips "webpsave" call fails when a parameter is not supported, but our webpsave() method + * detect this and unset that parameter and try again (repeat until success). + * + * @throws ConversionFailedException if conversion fails. + */ + protected function doActualConvert() + { +/* + $im = \Jcupitt\Vips\Image::newFromFile($this->source); + //$im->writeToFile(__DIR__ . '/images/small-vips.webp', ["Q" => 10]); + + $im->webpsave($this->destination, [ + "Q" => 80, + //'near_lossless' => true + ]); + return;*/ + + $im = $this->createImageResource(); + $options = $this->createParamsForVipsWebPSave(); + $this->webpsave($im, $options); + } +} diff --git a/src/Convert/Converters/Wpc.php b/src/Convert/Converters/Wpc.php new file mode 100644 index 00000000..5b9fd471 --- /dev/null +++ b/src/Convert/Converters/Wpc.php @@ -0,0 +1,415 @@ + + * @since Class available since Release 2.0.0 + */ +class Wpc extends AbstractConverter +{ + use CloudConverterTrait; + use CurlTrait; + use EncodingAutoTrait; + + protected function getUnsupportedDefaultOptions() + { + return []; + } + + public function getUniqueOptions($imageType) + { + return OptionFactory::createOptions([ + ['api-key', 'string', [ + 'title' => 'API key', + 'description' => 'The API key is set up on the remote. Copy that.', + 'default' => '', + 'sensitive' => true, + 'ui' => [ + 'component' => 'password', + 'advanced' => false, + 'display' => "option('wpc-api-version') != 0" + ] + ]], + ['secret', 'string', [ + 'title' => 'Secret', + 'description' => '', + 'default' => '', + 'sensitive' => true, + 'ui' => [ + 'component' => 'password', + 'advanced' => false, + 'display' => "option('wpc-api-version') == 0" + ] + ]], + ['api-url', 'string', [ + 'title' => 'API url', + 'description' => 'The endpoint of the web service. Copy it from the remote setup', + 'default' => '', + 'sensitive' => true, + 'ui' => [ + 'component' => 'password', + 'advanced' => false, + ] + ]], + ['api-version', 'int', [ + 'title' => 'API version', + 'description' => + 'Refers to the major version of Wpc. ' . + 'It is probably 2, as it is a long time since 2.0 was released', + 'default' => 2, + 'minimum' => 0, + 'maximum' => 2, + 'ui' => [ + 'component' => 'select', + 'advanced' => false, + 'options' => [0, 1, 2], + ] + ]], + ['crypt-api-key-in-transfer', 'boolean', [ + 'title' => 'Crypt API key in transfer', + 'description' => + 'If checked, the api key will be crypted in requests. ' . + 'Crypting the api-key protects it from being stolen during transfer', + 'default' => false, + 'ui' => [ + 'component' => 'checkbox', + 'advanced' => true, + 'display' => "option('wpc-api-version') >= 1" + ] + ]], + ]); + + /*return [ + new SensitiveStringOption('api-key', ''), + new SensitiveStringOption('secret', ''), + new SensitiveStringOption('api-url', ''), + new SensitiveStringOption('url', ''), // DO NOT USE. Only here to keep the protection + new IntegerOption('api-version', 2, 0, 2), + new BooleanOption('crypt-api-key-in-transfer', false) // new in api v.1 + ];*/ + } + + public function supportsLossless() + { + return ($this->options['api-version'] >= 2); + } + + public function passOnEncodingAuto() + { + // We could make this configurable. But I guess passing it on is always to be preferred + // for api >= 2. + return ($this->options['api-version'] >= 2); + } + + private static function createRandomSaltForBlowfish() + { + $salt = ''; + $validCharsForSalt = array_merge( + range('A', 'Z'), + range('a', 'z'), + range('0', '9'), + ['.', '/'] + ); + + for ($i = 0; $i < 22; $i++) { + $salt .= $validCharsForSalt[array_rand($validCharsForSalt)]; + } + return $salt; + } + + /** + * Get api key from options or environment variable + * + * @return string api key or empty string if none is set + */ + private function getApiKey() + { + if ($this->options['api-version'] == 0) { + if (!empty($this->options['secret'])) { + return $this->options['secret']; + } + } elseif ($this->options['api-version'] >= 1) { + if (!empty($this->options['api-key'])) { + return $this->options['api-key']; + } + } + if (defined('WEBPCONVERT_WPC_API_KEY')) { + return constant('WEBPCONVERT_WPC_API_KEY'); + } + if (!empty(getenv('WEBPCONVERT_WPC_API_KEY'))) { + return getenv('WEBPCONVERT_WPC_API_KEY'); + } + return ''; + } + + /** + * Get url from options or environment variable + * + * @return string URL to WPC or empty string if none is set + */ + private function getApiUrl() + { + if (!empty($this->options['api-url'])) { + return $this->options['api-url']; + } + if (defined('WEBPCONVERT_WPC_API_URL')) { + return constant('WEBPCONVERT_WPC_API_URL'); + } + if (!empty(getenv('WEBPCONVERT_WPC_API_URL'))) { + return getenv('WEBPCONVERT_WPC_API_URL'); + } + return ''; + } + + + /** + * Check operationality of Wpc converter. + * + * @throws SystemRequirementsNotMetException if system requirements are not met (curl) + * @throws ConverterNotOperationalException if key is missing or invalid, or quota has exceeded + */ + public function checkOperationality() + { + + $options = $this->options; + + $apiVersion = $options['api-version']; + + if ($this->getApiUrl() == '') { + if (isset($this->options['url']) && ($this->options['url'] != '')) { + throw new ConverterNotOperationalException( + 'The "url" option has been renamed to "api-url" in webp-convert 2.0. ' . + 'You must change the configuration accordingly.' + ); + } + throw new ConverterNotOperationalException( + 'Missing URL. You must install Webp Convert Cloud Service on a server, ' . + 'or the WebP Express plugin for Wordpress - and supply the url.' + ); + } + + if ($apiVersion == 0) { + if (!empty($this->getApiKey())) { + // if secret is set, we need md5() and md5_file() functions + if (!function_exists('md5')) { + throw new ConverterNotOperationalException( + 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . + 'contents. ' . + 'But the required md5() PHP function is not available.' + ); + } + if (!function_exists('md5_file')) { + throw new ConverterNotOperationalException( + 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . + 'contents. But the required md5_file() PHP function is not available.' + ); + } + } + } else { + if ($options['crypt-api-key-in-transfer']) { + if (!function_exists('crypt')) { + throw new ConverterNotOperationalException( + 'Configured to crypt the api-key, but crypt() function is not available.' + ); + } + + if (!defined('CRYPT_BLOWFISH')) { + throw new ConverterNotOperationalException( + 'Configured to crypt the api-key. ' . + 'That requires Blowfish encryption, which is not available on your current setup.' + ); + } + } + } + + // Check for curl requirements + $this->checkOperationalityForCurlTrait(); + } + + /* + public function checkConvertability() + { + // check upload limits + $this->checkConvertabilityCloudConverterTrait(); + + // TODO: some from below can be moved up here + } + */ + + private function createOptionsToSend() + { + $optionsToSend = $this->options; + + if ($this->isQualityDetectionRequiredButFailing()) { + // quality was set to "auto", but we could not meassure the quality of the jpeg locally + // Ask the cloud service to do it, rather than using what we came up with. + $optionsToSend['quality'] = 'auto'; + } else { + $optionsToSend['quality'] = $this->getCalculatedQuality(); + } + + // The following are unset for security reasons. + unset($optionsToSend['converters']); + unset($optionsToSend['secret']); + unset($optionsToSend['api-key']); + unset($optionsToSend['api-url']); + + $apiVersion = $optionsToSend['api-version']; + + if ($apiVersion == 1) { + // Lossless can be "auto" in api 2, but in api 1 "auto" is not supported + //unset($optionsToSend['lossless']); + } elseif ($apiVersion == 2) { + //unset($optionsToSend['png']); + //unset($optionsToSend['jpeg']); + + // The following are unset for security reasons. + unset($optionsToSend['cwebp-command-line-options']); + unset($optionsToSend['command-line-options']); + } + + return $optionsToSend; + } + + private function createPostData() + { + $options = $this->options; + + $postData = [ + 'file' => curl_file_create($this->source), + 'options' => json_encode($this->createOptionsToSend()), + 'servername' => (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '') + ]; + + $apiVersion = $options['api-version']; + + $apiKey = $this->getApiKey(); + + if ($apiVersion == 0) { + $postData['hash'] = md5(md5_file($this->source) . $apiKey); + } else { + //$this->logLn('api key: ' . $apiKey); + + if ($options['crypt-api-key-in-transfer']) { + $salt = self::createRandomSaltForBlowfish(); + $postData['salt'] = $salt; + + // Strip off the first 28 characters (the first 6 are always "$2y$10$". The next 22 is the salt) + $postData['api-key-crypted'] = substr(crypt($apiKey, '$2y$10$' . $salt . '$'), 28); + } else { + $postData['api-key'] = $apiKey; + } + } + return $postData; + } + + protected function doActualConvert() + { + $ch = self::initCurl(); + + //$this->logLn('api url: ' . $this->getApiUrl()); + + curl_setopt_array($ch, [ + CURLOPT_URL => $this->getApiUrl(), + CURLOPT_POST => 1, + CURLOPT_POSTFIELDS => $this->createPostData(), + CURLOPT_BINARYTRANSFER => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_SSL_VERIFYPEER => false + ]); + + $response = curl_exec($ch); + if (curl_errno($ch)) { + $this->logLn('Curl error: ', 'bold'); + $this->logLn(curl_error($ch)); + throw new ConverterNotOperationalException('Curl error:'); + } + + // Check if we got a 404 + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpCode == 404) { + curl_close($ch); + throw new ConversionFailedException( + 'WPC was not found at the specified URL - we got a 404 response.' + ); + } + + // Check for empty response + if (empty($response)) { + throw new ConversionFailedException( + 'Error: Unexpected result. We got nothing back. ' . + 'HTTP CODE: ' . $httpCode . '. ' . + 'Content type:' . curl_getinfo($ch, CURLINFO_CONTENT_TYPE) + ); + }; + + // The WPC cloud service either returns an image or an error message + // Images has application/octet-stream. + // Verify that we got an image back. + if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { + curl_close($ch); + + if (substr($response, 0, 1) == '{') { + $responseObj = json_decode($response, true); + if (isset($responseObj['errorCode'])) { + switch ($responseObj['errorCode']) { + case 0: + throw new ConverterNotOperationalException( + 'There are problems with the server setup: "' . + $responseObj['errorMessage'] . '"' + ); + case 1: + throw new InvalidApiKeyException( + 'Access denied. ' . $responseObj['errorMessage'] + ); + default: + throw new ConversionFailedException( + 'Conversion failed: "' . $responseObj['errorMessage'] . '"' + ); + } + } + } + + // WPC 0.1 returns 'failed![error messag]' when conversion fails. Handle that. + if (substr($response, 0, 7) == 'failed!') { + throw new ConversionFailedException( + 'WPC failed converting image: "' . substr($response, 7) . '"' + ); + } + + $this->logLn('Bummer, we did not receive an image'); + $this->log('What we received starts with: "'); + $this->logLn( + str_replace("\r", '', str_replace("\n", '', htmlentities(substr($response, 0, 400)))) . '..."' + ); + + throw new ConversionFailedException('Unexpected result. We did not receive an image but something else.'); + //throw new ConverterNotOperationalException($response); + } + + $success = file_put_contents($this->destination, $response); + curl_close($ch); + + if (!$success) { + throw new ConversionFailedException('Error saving file. Check file permissions'); + } + } +} diff --git a/src/Convert/Exceptions/ConversionFailed/ConversionSkippedException.php b/src/Convert/Exceptions/ConversionFailed/ConversionSkippedException.php new file mode 100644 index 00000000..82b54752 --- /dev/null +++ b/src/Convert/Exceptions/ConversionFailed/ConversionSkippedException.php @@ -0,0 +1,10 @@ + + * @since Class available since Release 2.0.0 + */ +class JpegQualityDetector +{ + + /** + * Try to detect quality of jpeg using imagick extension. + * + * Note that the detection might fail for two different reasons: + * 1) Imagick is not installed + * 2) Imagick for some reason fails to detect quality for some images + * + * In both cases, null is returned. + * + * @param string $filename A complete file path to file to be examined + * @return int|null Quality, or null if it was not possible to detect quality + */ + private static function detectQualityOfJpgUsingImagick($filename) + { + if (extension_loaded('imagick') && class_exists('\\Imagick')) { + try { + $img = new \Imagick($filename); + + // The required function is available as from PECL imagick v2.2.2 + if (method_exists($img, 'getImageCompressionQuality')) { + $quality = $img->getImageCompressionQuality(); + if ($quality === 0) { + // We have experienced that this Imagick method returns 0 for some images, + // (even though the imagemagick binary is able to detect the quality) + // ie "/test/images/quality-undetectable-with-imagick.jpg". See #208 + $quality = null; + } + return $quality; + } + } catch (\Exception $e) { + // Well well, it just didn't work out. + // - But perhaps next method will work... + } catch (\Throwable $e) { + } + } + return null; + } + + + /** + * Try to detect quality of jpeg using imagick binary. + * + * Note that the detection might fail for three different reasons: + * 1) exec function is not available + * 2) the 'identify' command is not available on the system + * 3) imagemagick for some reason fails to detect quality for some images + * + * In the first two cases, null is returned. + * In the third case, 92 is returned. This is what imagemagick returns when it cannot detect the quality. + * and unfortunately we cannot distinguish between the situation where the quality is undetectable + * and the situation where the quality is actually 92 (at least, I have not found a way to do so) + * + * @param string $filename A complete file path to file to be examined + * @return int|null Quality, or null if it was not possible to detect quality + */ + private static function detectQualityOfJpgUsingImageMagick($filename) + { + if (ExecWithFallback::anyAvailable()) { + // Try Imagick using exec, and routing stderr to stdout (the "2>$1" magic) + + try { + ExecWithFallback::exec( + "identify -format '%Q' " . escapeshellarg($filename) . " 2>&1", + $output, + $returnCode + ); + //echo 'out:' . print_r($output, true); + if ((intval($returnCode) == 0) && (is_array($output)) && (count($output) == 1)) { + return intval($output[0]); + } + } catch (\Exception $e) { + // its ok, there are other fish in the sea + } catch (\Throwable $e) { + } + } + return null; + } + + + /** + * Try to detect quality of jpeg using graphicsmagick binary. + * + * It seems that graphicsmagick is never able to detect the quality! - and always returns + * the default quality, which is 75. + * However, as this might be solved in future versions, the method might be useful one day. + * But we treat "75" as a failure to detect and shall return null in that case. + * + * @param string $filename A complete file path to file to be examined + * @return int|null Quality, or null if it was not possible to detect quality + */ + private static function detectQualityOfJpgUsingGraphicsMagick($filename) + { + if (ExecWithFallback::anyAvailable()) { + // Try GraphicsMagick + try { + ExecWithFallback::exec( + "gm identify -format '%Q' " . escapeshellarg($filename) . " 2>&1", + $output, + $returnCode + ); + if ((intval($returnCode) == 0) && (is_array($output)) && (count($output) == 1)) { + $quality = intval($output[0]); + + // It seems that graphicsmagick is (currently) never able to detect the quality! + // - and always returns 75 as a fallback + // We shall therefore treat 75 as a failure to detect. (#209) + if ($quality == 75) { + return null; + } + return $quality; + } + } catch (\Exception $e) { + } catch (\Throwable $e) { + } + } + return null; + } + + + /** + * Try to detect quality of jpeg. + * + * Note: This method does not throw errors, but might dispatch warnings. + * You can use the WarningsIntoExceptions class if it is critical to you that nothing gets "printed" + * + * @param string $filename A complete file path to file to be examined + * @return int|null Quality, or null if it was not possible to detect quality + */ + public static function detectQualityOfJpg($filename) + { + + //trigger_error('warning test', E_USER_WARNING); + + // Test that file exists in order not to break things. + if (!file_exists($filename)) { + // One could argue that it would be better to throw an Exception...? + return null; + } + + // Try Imagick extension, if available + $quality = self::detectQualityOfJpgUsingImagick($filename); + + if (is_null($quality)) { + $quality = self::detectQualityOfJpgUsingImageMagick($filename); + } + + if (is_null($quality)) { + $quality = self::detectQualityOfJpgUsingGraphicsMagick($filename); + } + + return $quality; + } +} diff --git a/src/Convert/Helpers/PhpIniSizes.php b/src/Convert/Helpers/PhpIniSizes.php new file mode 100644 index 00000000..c8c79df9 --- /dev/null +++ b/src/Convert/Helpers/PhpIniSizes.php @@ -0,0 +1,70 @@ + + * @since Class available since Release 2.0.0 + */ +class PhpIniSizes +{ + + /** + * Parse a shordhandsize string as the ones returned by ini_get() + * + * Parse a shorthandsize string having the syntax allowed in php.ini and returned by ini_get(). + * Ie "1K" => 1024. + * Strings without units are also accepted. + * The shorthandbytes syntax is described here: https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes + * + * @param string $shortHandSize A size string of the type returned by ini_get() + * @return float|false The parsed size (beware: it is float, do not check high numbers for equality), + * or false if parse error + */ + public static function parseShortHandSize($shortHandSize) + { + + $result = preg_match("#^\\s*(\\d+(?:\\.\\d+)?)([bkmgtpezy]?)\\s*$#i", $shortHandSize, $matches); + if ($result !== 1) { + return false; + } + + // Truncate, because that is what php does. + $digitsValue = floor($matches[1]); + + if ((count($matches) >= 3) && ($matches[2] != '')) { + $unit = $matches[2]; + + // Find the position of the unit in the ordered string which is the power + // of magnitude to multiply a kilobyte by. + $position = stripos('bkmgtpezy', $unit); + + return floatval($digitsValue * pow(1024, $position)); + } else { + return $digitsValue; + } + } + + /* + * Get the size of an php.ini option. + * + * Calls ini_get() and parses the size to a number. + * If the configuration option is null, does not exist, or cannot be parsed as a shorthandsize, false is returned + * + * @param string $varname The configuration option name. + * @return float|false The parsed size or false if the configuration option does not exist + */ + public static function getIniBytes($iniVarName) + { + $iniVarValue = ini_get($iniVarName); + if (($iniVarValue == '') || $iniVarValue === false) { + return false; + } + return self::parseShortHandSize($iniVarValue); + } +} diff --git a/src/Converters/Binaries/cwebp-mac12 b/src/Converters/Binaries/cwebp-mac12 deleted file mode 100755 index ba3914f2..00000000 Binary files a/src/Converters/Binaries/cwebp-mac12 and /dev/null differ diff --git a/src/Converters/Binaries/cwebp.exe b/src/Converters/Binaries/cwebp.exe deleted file mode 100755 index eecb9cb6..00000000 Binary files a/src/Converters/Binaries/cwebp.exe and /dev/null differ diff --git a/src/Converters/ConverterHelper.php b/src/Converters/ConverterHelper.php deleted file mode 100644 index 44b5a869..00000000 --- a/src/Converters/ConverterHelper.php +++ /dev/null @@ -1,448 +0,0 @@ - 'auto', - 'max-quality' => 85, - 'default-quality' => 75, - 'metadata' => 'none', - 'method' => 6, - 'low-memory' => false, - 'lossless' => false, - 'converters' => ['cwebp', 'gd', 'imagick', 'gmagick'], - 'converter-options' => [] - ]; - - public static function mergeOptions($options, $extraOptions) - { - return $options; - } - - public static function getClassNameOfConverter($converterId) - { - return 'WebPConvert\\Converters\\' . ucfirst($converterId); - } - - /* Call the "convert" method on a converter, by id. - - but also prepares options (merges in the $extraOptions of the converter), - prepares destination folder, and runs some standard validations - If it fails, it throws an exception. Otherwise it don't (there is no return value) - */ - public static function runConverter( - $converterId, - $source, - $destination, - $options = [], - $prepareDestinationFolder = true, - $logger = null - ) { - - - if ($prepareDestinationFolder) { - self::prepareDestinationFolderAndRunCommonValidations($source, $destination); - } - - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - - $className = self::getClassNameOfConverter($converterId); - if (!is_callable([$className, 'convert'])) { - throw new ConverterNotFoundException(); - } - - // Prepare options. - // - Remove 'converters' - $defaultOptions = self::$defaultOptions; - unset($defaultOptions['converters']); - - // - Merge defaults of the converters extra options into the standard default options. - $defaultOptions = array_merge($defaultOptions, array_column($className::$extraOptions, 'default', 'name')); - - // - Merge $defaultOptions into provided options - $options = array_merge($defaultOptions, $options); - - // Individual converters do not accept quality = auto. They need a number. - // Change $options['quality'] to number, based on quality of source and several settings - - self::processQualityOption($source, $options, $logger); - - call_user_func( - [$className, 'doConvert'], - $source, - $destination, - $options, - $logger - ); - - if (!@file_exists($destination)) { - throw new ConverterFailedException('Destination file is not there'); - } else { - $sourceSize = @filesize($source); - if ($sourceSize !== false) { - $msg = 'Success. '; - $msg .= 'Reduced file size with ' . - round((filesize($source) - filesize($destination))/filesize($source) * 100) . '% '; - - if ($sourceSize < 10000) { - $msg .= '(went from ' . round(filesize($source)) . ' bytes to '; - $msg .= round(filesize($destination)) . ' bytes)'; - } else { - $msg .= '(went from ' . round(filesize($source)/1024) . ' kb to '; - $msg .= round(filesize($destination)/1024) . ' kb)'; - } - $logger->logLn($msg); - } - } - } - - public static function runConverterWithTiming( - $converterId, - $source, - $destination, - $options = [], - $prepareDestinationFolder = true, - $logger = null - ) { - $beginTime = microtime(true); - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - try { - self::runConverter($converterId, $source, $destination, $options, $prepareDestinationFolder, $logger); - $logger->logLn( - 'Successfully converted image in ' . - round((microtime(true) - $beginTime) * 1000) . ' ms' - ); - } catch (\Exception $e) { - $logger->logLn('Failed in ' . round((microtime(true) - $beginTime) * 1000) . ' ms'); - throw $e; - } - } - - /* - @param (string) $source: Absolute path to image to be converted (no backslashes). Image must be jpeg or png - @param (string) $destination: Absolute path (no backslashes) - @param (object) $options: Array of named options, such as 'quality' and 'metadata' - */ - public static function runConverterStack($source, $destination, $options = [], $logger = null) - { - if (!isset($logger)) { - $logger = new \WebPConvert\Loggers\VoidLogger(); - } - self::prepareDestinationFolderAndRunCommonValidations($source, $destination); - - $options = array_merge(self::$defaultOptions, $options); - - self::processQualityOption($source, $options, $logger); - - // Force lossless option to true for PNG images - if (self::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - $defaultConverterOptions = $options; - $defaultConverterOptions['converters'] = null; - - $firstFailException = null; - - // If we have set converter options for a converter, which is not in the converter array, - // then we add it to the array - if (isset($options['converter-options'])) { - foreach ($options['converter-options'] as $converterName => $converterOptions) { - if (!in_array($converterName, $options['converters'])) { - $options['converters'][] = $converterName; - } - } - } - - foreach ($options['converters'] as $converter) { - if (is_array($converter)) { - $converterId = $converter['converter']; - $converterOptions = $converter['options']; - } else { - $converterId = $converter; - $converterOptions = []; - if (isset($options['converter-options'][$converterId])) { - // Note: right now, converter-options are not meant to be used, - // when you have several converters of the same type - $converterOptions = $options['converter-options'][$converterId]; - } - } - - $converterOptions = array_merge($defaultConverterOptions, $converterOptions); - - try { - $logger->logLn('Trying:' . $converterId, 'italic'); - - // If quality is different, we must recalculate - if ($converterOptions['quality'] != $defaultConverterOptions['quality']) { - unset($converterOptions['_calculated_quality']); - self::processQualityOption($source, $converterOptions, $logger); - } - - self::runConverterWithTiming($converterId, $source, $destination, $converterOptions, false, $logger); - - $logger->logLn('ok', 'bold'); - return true; - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { -// $logger->logLnLn($e->description . ' : ' . $e->getMessage()); - $logger->logLnLn($e->getMessage()); - - // The converter is not operational. - // Well, well, we will just have to try the next, then - } catch (\WebPConvert\Converters\Exceptions\ConverterFailedException $e) { - $logger->logLnLn($e->getMessage()); - - // Converter failed in an anticipated, yet somewhat surprising fashion. - // The converter seemed operational - requirements was in order - but it failed anyway. - // This is moderately bad. - // If some other converter can handle the conversion, we will let this one go. - // But if not, we shall throw the exception - - if (!$firstFailException) { - $firstFailException = $e; - } - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - $logger->logLnLn($e->getMessage()); - - // The converter declined. - // Gd is for example throwing this, when asked to convert a PNG, but configured not to - // We also possibly rethrow this, because it may have come as a surprise to the user - // who perhaps only tested jpg - if (!$firstFailException) { - $firstFailException = $e; - } - } - } - - if ($firstFailException) { - // At least one converter failed or declined. - $logger->logLn('Conversion failed. None of the tried converters could convert the image', 'bold'); - } else { - // All converters threw a ConverterNotOperationalException - $logger->logLn('Conversion failed. None of the tried converters are operational', 'bold'); - } - - // No converters could do the job. - // If one of them failed moderately bad, rethrow that exception. - if ($firstFailException) { - throw $firstFailException; - } - - return false; - } - - /* Try to detect quality of jpeg. - If not possible, nothing is returned (null). Otherwise quality is returned (int) - */ - public static function detectQualityOfJpg($filename) - { - // Try Imagick extension - if (extension_loaded('imagick') && class_exists('\\Imagick')) { - $img = new \Imagick($filename); - - // The required function is available as from PECL imagick v2.2.2 - if (method_exists($img, 'getImageCompressionQuality')) { - return $img->getImageCompressionQuality(); - } - } - - // Gmagick extension doesn't seem to support this (yet): - // https://bugs.php.net/bug.php?id=63939 - - if (function_exists('shell_exec')) { - // Try Imagick - $quality = shell_exec("identify -format '%Q' " . $filename); - if ($quality) { - return intval($quality); - } - - // Try GraphicsMagick - $quality = shell_exec("gm identify -format '%Q' " . $filename); - if ($quality) { - return intval($quality); - } - } - } - - public static function processQualityOption($source, &$options, $logger) - { - if (isset($options['_calculated_quality'])) { - return; - } - if ($options['quality'] == 'auto') { - $q = self::detectQualityOfJpg($source); - //$logger->log('Quality set to auto... Quality of source: '); - if (!$q) { - $q = $options['default-quality']; - $logger->logLn( - 'Quality of source could not be established (Imagick or GraphicsMagick is required)' . - ' - Using default instead (' . $options['default-quality'] . ').' - ); - - // this allows the wpc converter to know - $options['_quality_could_not_be_detected'] = true; - } else { - if ($q > $options['max-quality']) { - $logger->log( - 'Quality of source is ' . $q . '. ' . - 'This is higher than max-quality, so using that instead (' . $options['max-quality'] . ')' - ); - } else { - $logger->log('Quality set to same as source: ' . $q); - } - } - $logger->ln(); - $q = min($q, $options['max-quality']); - - $options['_calculated_quality'] = $q; - //$logger->logLn('Using quality: ' . $options['quality']); - } else { - $logger->logLn( - 'Quality: ' . $options['quality'] . '. ' . - 'Consider setting quality to "auto" instead. It is generally a better idea' - ); - $options['_calculated_quality'] = $options['quality']; - } - $logger->ln(); - } - - - public static function getExtension($filePath) - { - $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); - return strtolower($fileExtension); - } - - // Throws an exception if the provided file doesn't exist - public static function isValidTarget($filePath) - { - if (!@file_exists($filePath)) { - throw new TargetNotFoundException('File or directory not found: ' . $filePath); - } - - return true; - } - - // Throws an exception if the provided file's extension is invalid - public static function isAllowedExtension($filePath) - { - $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); - if (!in_array(strtolower($fileExtension), self::$allowedExtensions)) { - throw new InvalidFileExtensionException('Unsupported file extension: ' . $fileExtension); - } - - return true; - } - - // Creates folder in provided path & sets correct permissions - // also deletes the file at filePath (if it already exists) - public static function createWritableFolder($filePath) - { - $folder = dirname($filePath); - if (!@file_exists($folder)) { - // TODO: what if this is outside open basedir? - // see http://php.net/manual/en/ini.core.php#ini.open-basedir - - // First, we have to figure out which permissions to set. - // We want same permissions as parent folder - // But which parent? - the parent to the first missing folder - - $parentFolders = explode('/', $folder); - $poppedFolders = []; - - while (!(@file_exists(implode('/', $parentFolders))) && count($parentFolders) > 0) { - array_unshift($poppedFolders, array_pop($parentFolders)); - } - - // Retrieving permissions of closest existing folder - $closestExistingFolder = implode('/', $parentFolders); - $permissions = @fileperms($closestExistingFolder) & 000777; - $stat = @stat($closestExistingFolder); - - // Trying to create the given folder (recursively) - if (!@mkdir($folder, $permissions, true)) { - throw new CreateDestinationFolderException('Failed creating folder: ' . $folder); - } - - // `mkdir` doesn't always respect permissions, so we have to `chmod` each created subfolder - foreach ($poppedFolders as $subfolder) { - $closestExistingFolder .= '/' . $subfolder; - // Setting directory permissions - if ($permissions !== false) { - @chmod($folder, $permissions); - } - if ($stat !== false) { - if (isset($stat['uid'])) { - @chown($folder, $stat['uid']); - } - if (isset($stat['gid'])) { - @chgrp($folder, $stat['gid']); - } - } - } - } - - if (@file_exists($filePath)) { - // A file already exists in this folder... - // We delete it, to make way for a new webp - if (!@unlink($filePath)) { - throw new CreateDestinationFileException( - 'Existing file cannot be removed: ' . basename($filePath) - ); - } - } - - return true; - } - - public static function prepareDestinationFolderAndRunCommonValidations($source, $destination) - { - self::isValidTarget($source); - self::isAllowedExtension($source); - self::createWritableFolder($destination); - } - - public static function initCurlForConverter() - { - if (!extension_loaded('curl')) { - throw new ConverterNotOperationalException('Required cURL extension is not available.'); - } - - if (!function_exists('curl_init')) { - throw new ConverterNotOperationalException('Required url_init() function is not available.'); - } - - if (!function_exists('curl_file_create')) { - throw new ConverterNotOperationalException( - 'Required curl_file_create() function is not available (requires PHP > 5.5).' - ); - } - - $ch = curl_init(); - if (!$ch) { - throw new ConverterNotOperationalException('Could not initialise cURL.'); - } - return $ch; - } -} diff --git a/src/Converters/Cwebp.php b/src/Converters/Cwebp.php deleted file mode 100644 index 76f8235e..00000000 --- a/src/Converters/Cwebp.php +++ /dev/null @@ -1,380 +0,0 @@ - 'use-nice', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => false, - 'required' => false - ], - // low-memory is defined for all, in ConverterHelper - [ - 'name' => 'try-common-system-paths', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - [ - 'name' => 'try-supplied-binary-for-os', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - [ - 'name' => 'size-in-percentage', - 'type' => 'number', - 'sensitive' => false, - 'default' => null, - 'required' => false - ], - [ - 'name' => 'command-line-options', - 'type' => 'string', - 'sensitive' => false, - 'default' => '', - 'required' => false - ], - [ - 'name' => 'rel-path-to-precompiled-binaries', - 'type' => 'string', - 'sensitive' => false, - 'default' => './Binaries', - 'required' => false - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('cwebp', $source, $destination, $options, true); - } - - // System paths to look for cwebp binary - private static $cwebpDefaultPaths = [ - '/usr/bin/cwebp', - '/usr/local/bin/cwebp', - '/usr/gnu/bin/cwebp', - '/usr/syno/bin/cwebp' - ]; - - // OS-specific binaries included in this library, along with hashes - private static $suppliedBinariesInfo = [ - 'WinNT' => [ 'cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873'], - 'Darwin' => [ 'cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379'], - 'SunOS' => [ 'cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f'], - 'FreeBSD' => [ 'cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573'], - 'Linux' => [ 'cwebp-linux', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568'] - ]; - - private static function escapeFilename($string) - { - // Escaping whitespace - $string = preg_replace('/\s/', '\\ ', $string); - - // filter_var() is should normally be available, but it is not always - // - https://stackoverflow.com/questions/11735538/call-to-undefined-function-filter-var - if (function_exists('filter_var')) { - // Sanitize quotes - $string = filter_var($string, FILTER_SANITIZE_MAGIC_QUOTES); - - // Stripping control characters - // see https://stackoverflow.com/questions/12769462/filter-flag-strip-low-vs-filter-flag-strip-high - $string = filter_var($string, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - } - - return $string; - } - - // Checks if 'Nice' is available - private static function hasNiceSupport() - { - exec("nice 2>&1", $niceOutput); - - if (is_array($niceOutput) && isset($niceOutput[0])) { - if (preg_match('/usage/', $niceOutput[0]) || (preg_match('/^\d+$/', $niceOutput[0]))) { - /* - * Nice is available - default niceness (+10) - * https://www.lifewire.com/uses-of-commands-nice-renice-2201087 - * https://www.computerhope.com/unix/unice.htm - */ - - return true; - } - - return false; - } - } - - private static function executeBinary($binary, $commandOptions, $useNice, $logger) - { - $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions; - - //$logger->logLn('command options:' . $commandOptions); - //$logger->logLn('Trying to execute binary:' . $binary); - exec($command, $output, $returnCode); - //$logger->logLn(self::msgForExitCode($returnCode)); - return intval($returnCode); - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - $errorMsg = ''; - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - if (!function_exists('exec')) { - throw new ConverterNotOperationalException('exec() is not enabled.'); - } - - /* - * Prepare cwebp options - */ - - $commandOptionsArray = []; - - // Metadata (all, exif, icc, xmp or none (default)) - // Comma-separated list of existing metadata to copy from input to output - $commandOptionsArray[] = '-metadata ' . $options['metadata']; - - // Size - if (!is_null($options['size-in-percentage'])) { - $sizeSource = @filesize($source); - if ($sizeSource !== false) { - $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100); - } - } - if (isset($targetSize)) { - $commandOptionsArray[] = '-size ' . $targetSize; - } else { - // Image quality - $commandOptionsArray[] = '-q ' . $options['_calculated_quality']; - } - - - // Losless PNG conversion - $commandOptionsArray[] = ($options['lossless'] ? '-lossless' : ''); - - // Built-in method option - $commandOptionsArray[] = '-m ' . strval($options['method']); - - // Built-in low memory option - if ($options['low-memory']) { - $commandOptionsArray[] = '-low_memory'; - } - - // command-line-options - if ($options['command-line-options']) { - $arr = explode(' -', ' ' . $options['command-line-options']); - foreach ($arr as $cmdOption) { - $pos = strpos($cmdOption, ' '); - $cName = ''; - $cValue = ''; - if (!$pos) { - $cName = $cmdOption; - if ($cName == '') { - continue; - } - $commandOptionsArray[] = '-' . $cName; - } else { - $cName = substr($cmdOption, 0, $pos); - $cValues = substr($cmdOption, $pos + 1); - $cValuesArr = explode(' ', $cValues); - foreach ($cValuesArr as &$cArg) { - $cArg = escapeshellarg($cArg); - } - $cValues = implode(' ', $cValuesArr); - $commandOptionsArray[] = '-' . $cName . ' ' . $cValues; - } - } - } - - // Source file - $commandOptionsArray[] = self::escapeFilename($source); - - // Output - $commandOptionsArray[] = '-o ' . self::escapeFilename($destination); - - // Redirect stderr to same place as stdout - // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/ - $commandOptionsArray[] = '2>&1'; - - - $useNice = (($options['use-nice']) && self::hasNiceSupport()) ? true : false; - - $commandOptions = implode(' ', $commandOptionsArray); - - $logger->logLn('cwebp options:' . $commandOptions); - - // Init with common system paths - $cwebpPathsToTest = self::$cwebpDefaultPaths; - - // Remove paths that doesn't exist - /* - $cwebpPathsToTest = array_filter($cwebpPathsToTest, function ($binary) { - //return file_exists($binary); - return @is_readable($binary); - }); - */ - - // Try all common paths that exists - $success = false; - $failures = []; - $failureCodes = []; - - if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) { - $errorMsg .= 'Configured to neither look for cweb binaries in common system locations, ' . - 'nor to use one of the supplied precompiled binaries. But these are the only ways ' . - 'this converter can convert images. No conversion can be made!'; - } - - if ($options['try-common-system-paths']) { - foreach ($cwebpPathsToTest as $index => $binary) { - $returnCode = self::executeBinary($binary, $commandOptions, $useNice, $logger); - if ($returnCode == 0) { - $logger->logLn('Successfully executed binary: ' . $binary); - $success = true; - break; - } else { - $failures[] = [$binary, $returnCode]; - if (!in_array($returnCode, $failureCodes)) { - $failureCodes[] = $returnCode; - } - } - } - $majorFailCode = 0; - if (!$success) { - if (count($failureCodes) == 1) { - $majorFailCode = $failureCodes[0]; - switch ($majorFailCode) { - case 126: - $errorMsg = 'Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute any of the ' . - 'cweb binaries found in common system locations. '; - break; - case 127: - $errorMsg .= 'Found no cwebp binaries in any common system locations. '; - break; - default: - $errorMsg .= 'Tried executing cwebp binaries in common system locations. ' . - 'All failed (exit code: ' . $majorFailCode . '). '; - } - } else { - $failureCodesBesides127 = array_diff($failureCodes, [127]); - - if (count($failureCodesBesides127) == 1) { - $majorFailCode = $failureCodesBesides127[0]; - switch ($returnCode) { - case 126: - $errorMsg = 'Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute any of the cweb ' . - 'binaries found in common system locations. '; - break; - default: - $errorMsg .= 'Tried executing cwebp binaries in common system locations. ' . - 'All failed (exit code: ' . $majorFailCode . '). '; - } - } else { - $errorMsg .= 'None of the cwebp binaries in the common system locations could be executed ' . - '(mixed results - got the following exit codes: ' . implode(',', $failureCodes) . '). '; - } - } - } - } - - if (!$success && $options['try-supplied-binary-for-os']) { - // Try supplied binary (if available for OS, and hash is correct) - if (isset(self::$suppliedBinariesInfo[PHP_OS])) { - $info = self::$suppliedBinariesInfo[PHP_OS]; - - $file = $info[0]; - $hash = $info[1]; - - $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file; - - // The file should exist, but may have been removed manually. - if (@file_exists($binaryFile)) { - // File exists, now generate its hash - - // hash_file() is normally available, but it is not always - // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash - // If available, validate that hash is correct. - $proceedAfterHashCheck = true; - if (function_exists('hash_file')) { - $binaryHash = hash_file('sha256', $binaryFile); - - if ($binaryHash != $hash) { - $errorMsg .= 'Binary checksum of supplied binary is invalid! ' . - 'Did you transfer with FTP, but not in binary mode? ' . - 'File:' . $binaryFile . '. ' . - 'Expected checksum: ' . $hash . '. ' . - 'Actual checksum:' . $binaryHash . '.'; - $proceedAfterHashCheck = false; - } - } - if ($proceedAfterHashCheck) { - $returnCode = self::executeBinary($binaryFile, $commandOptions, $useNice, $logger); - if ($returnCode == 0) { - $success = true; - } else { - $errorMsg .= 'Tried executing supplied binary for ' . PHP_OS . ', ' . - ($options['try-common-system-paths'] ? 'but that failed too' : 'but failed'); - if ($options['try-common-system-paths'] && ($majorFailCode > 0)) { - $errorMsg .= ' (same error)'; - } else { - switch ($returnCode) { - case 0: - $success = true; - ; - break; - case 126: - $errorMsg .= ': Permission denied. The user that the command was run with (' . - shell_exec('whoami') . ') does not have permission to execute that binary.'; - break; - case 127: - $errorMsg .= '. The binary was not found! It ought to be here: ' . $binaryFile; - break; - default: - $errorMsg .= ' (exit code:' . $returnCode . ').'; - } - } - } - } - } else { - $errorMsg .= 'Supplied binary not found! It ought to be here:' . $binaryFile; - } - } else { - $errorMsg .= 'No supplied binaries found for OS:' . PHP_OS; - } - } - - - - // cwebp sets file permissions to 664 but instead .. - // .. $destination's parent folder's permissions should be used (except executable bits) - if ($success) { - $destinationParent = dirname($destination); - $fileStatistics = @stat($destinationParent); - if ($fileStatistics !== false) { - // Apply same permissions as parent folder but strip off the executable bits - $permissions = $fileStatistics['mode'] & 0000666; - @chmod($destination, $permissions); - } - } - - if (!$success) { - throw new ConverterNotOperationalException($errorMsg); - } - } -} diff --git a/src/Converters/Ewww.php b/src/Converters/Ewww.php deleted file mode 100644 index 34391c7a..00000000 --- a/src/Converters/Ewww.php +++ /dev/null @@ -1,261 +0,0 @@ - 'key', - 'type' => 'string', - 'sensitive' => true, - 'default' => '', - 'required' => true - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('ewww', $source, $destination, $options, true); - } - - // Took this parser from Drupal - private static function parseSize($size) - { - - $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. - $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. - if ($unit) { - // Find the position of the unit in the ordered string which is the power - // of magnitude to multiply a kilobyte by. - return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); - } else { - return round($size); - } - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if ($options['key'] == '') { - throw new ConverterNotOperationalException('Missing API key.'); - } - if (strlen($options['key']) < 20) { - throw new ConverterNotOperationalException( - 'Key is invalid. Keys are supposed to be 32 characters long - your key is much shorter' - ); - } - - $keyStatus = self::getKeyStatus($options['key']); - switch ($keyStatus) { - case 'great': - break; - case 'exceeded': - throw new ConverterNotOperationalException('quota has exceeded'); - break; - case 'invalid': - throw new ConverterNotOperationalException('key is invalid'); - break; - } - - $fileSize = @filesize($source); - if ($fileSize !== false) { - $uploadMaxSize = self::parseSize(ini_get('upload_max_filesize')); - if (($uploadMaxSize !== false) && ($uploadMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your max upload (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'upload_max_filesize in php.ini: ' . ini_get('upload_max_filesize') . - ' (parsed as ' . round($uploadMaxSize/1024) . ' kb)' - ); - } - - $postMaxSize = self::parseSize(ini_get('post_max_size')); - if (($postMaxSize !== false) && ($postMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your post_max_size limit (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'post_max_size in php.ini: ' . ini_get('post_max_size') . - ' (parsed as ' . round($postMaxSize/1024) . ' kb)' - ); - } - - // ini_get('memory_limit') - } - - - $ch = ConverterHelper::initCurlForConverter(); - - $curlOptions = [ - 'api_key' => $options['key'], - 'webp' => '1', - 'file' => curl_file_create($source), - 'domain' => $_SERVER['HTTP_HOST'], - 'quality' => $options['_calculated_quality'], - 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') - ]; - - curl_setopt_array( - $ch, - [ - CURLOPT_URL => "https://optimize.exactlywww.com/v2/", - CURLOPT_HTTPHEADER => [ - 'User-Agent: WebPConvert', - 'Accept: image/*' - ], - CURLOPT_POSTFIELDS => $curlOptions, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ] - ); - - $response = curl_exec($ch); - - if (curl_errno($ch)) { - throw new ConverterNotOperationalException(curl_error($ch)); - } - - // The API does not always return images. - // For example, it may return a message such as '{"error":"invalid","t":"exceeded"} - // Messages has a http content type of ie 'text/html; charset=UTF-8 - // Images has application/octet-stream. - // So verify that we got an image back. - if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { - //echo curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - curl_close($ch); - - /* May return this: {"error":"invalid","t":"exceeded"} */ - $responseObj = json_decode($response); - if (isset($responseObj->error)) { - //echo 'error:' . $responseObj->error . '
'; - //echo $response; - //self::blacklistKey($key); - //throw new ConverterNotOperationalException('The key is invalid. Blacklisted it!'); - throw new ConverterNotOperationalException('The key is invalid'); - } - - throw new ConverterNotOperationalException( - 'ewww api did not return an image. It could be that the key is invalid. Response: ' - . $response - ); - } - - // Not sure this can happen. So just in case - if ($response == '') { - throw new ConverterNotOperationalException('ewww api did not return anything'); - } - - $success = file_put_contents($destination, $response); - - if (!$success) { - throw new ConverterFailedException('Error saving file'); - } - } - - /* - public static function blacklistKey($key) - { - } - - public static function isKeyBlacklisted($key) - { - }*/ - - /** - * Return "great", "exceeded" or "invalid" - */ - public static function getKeyStatus($key) - { - $ch = ConverterHelper::initCurlForConverter(); - - curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/verify/"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt( - $ch, - CURLOPT_POSTFIELDS, - [ - 'api_key' => $key - ] - ); - - // The 403 forbidden is avoided with this line. - curl_setopt( - $ch, - CURLOPT_USERAGENT, - 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)' - ); - - $response = curl_exec($ch); - // echo $response; - if (curl_errno($ch)) { - throw new \Exception(curl_error($ch)); - } - curl_close($ch); - - // Possible responses: - // “great” = verification successful - // “exceeded” = indicates a valid key with no remaining image credits. - // an empty response indicates that the key is not valid - - if ($response == '') { - return 'invalid'; - } - $responseObj = json_decode($response); - if (isset($responseObj->error)) { - if ($responseObj->error == 'invalid') { - return 'invalid'; - } else { - throw new \Exception('Ewww returned unexpected error: ' . $response); - } - } - if (!isset($responseObj->status)) { - throw new \Exception('Ewww returned unexpected response to verify request: ' . $response); - } - switch ($responseObj->status) { - case 'great': - case 'exceeded': - return $responseObj->status; - } - throw new \Exception('Ewww returned unexpected status to verify request: "' . $responseObj->status . '"'); - } - - public static function isWorkingKey($key) - { - return (self::getKeyStatus($key) == 'great'); - } - - public static function isValidKey($key) - { - return (self::getKeyStatus($key) != 'invalid'); - } - - public static function getQuota($key) - { - $ch = ConverterHelper::initCurlForConverter(); - - curl_setopt($ch, CURLOPT_URL, "https://optimize.exactlywww.com/quota/"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt( - $ch, - CURLOPT_POSTFIELDS, - [ - 'api_key' => $key - ] - ); - curl_setopt( - $ch, - CURLOPT_USERAGENT, - 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322)' - ); - - $response = curl_exec($ch); - return $response; // ie -830 23. Seems to return empty for invalid keys - // or empty - //echo $response; - } -} diff --git a/src/Converters/Exceptions/ConversionDeclinedException.php b/src/Converters/Exceptions/ConversionDeclinedException.php deleted file mode 100644 index 6d7122a0..00000000 --- a/src/Converters/Exceptions/ConversionDeclinedException.php +++ /dev/null @@ -1,10 +0,0 @@ - 'skip-pngs', - 'type' => 'boolean', - 'sensitive' => false, - 'default' => true, - 'required' => false - ], - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('gd', $source, $destination, $options, true); - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if (!extension_loaded('gd')) { - throw new ConverterNotOperationalException('Required Gd extension is not available.'); - } - - if (!function_exists('imagewebp')) { - throw new ConverterNotOperationalException( - 'Required imagewebp() function is not available. It seems Gd has been compiled without webp support.' - ); - } - - switch (ConverterHelper::getExtension($source)) { - case 'png': - if (!$options['skip-pngs']) { - if (!function_exists('imagecreatefrompng')) { - throw new ConverterNotOperationalException( - 'Required imagecreatefrompng() function is not available.' - ); - } - $image = @imagecreatefrompng($source); - if (!$image) { - throw new ConverterFailedException( - 'imagecreatefrompng("' . $source . '") failed' - ); - } - } else { - throw new ConversionDeclinedException( - 'PNG file skipped. GD is configured not to convert PNGs' - ); - } - break; - default: - if (!function_exists('imagecreatefromjpeg')) { - throw new ConverterNotOperationalException( - 'Required imagecreatefromjpeg() function is not available.' - ); - } - $image = @imagecreatefromjpeg($source); - if (!$image) { - throw new ConverterFailedException('imagecreatefromjpeg("' . $source . '") failed'); - } - } - - // Checks if either imagecreatefromjpeg() or imagecreatefrompng() returned false - - $success = @imagewebp($image, $destination, $options['_calculated_quality']); - - if (!$success) { - throw new ConverterFailedException( - 'Call to imagewebp() failed. Probably failed writing file. Check file permissions!' - ); - } - - /* - * This hack solves an `imagewebp` bug - * See https://stackoverflow.com/questions/30078090/imagewebp-php-creates-corrupted-webp-files - * - */ - if (@filesize($destination) % 2 == 1) { - @file_put_contents($destination, "\0", FILE_APPEND); - } - - imagedestroy($image); - } -} diff --git a/src/Converters/Gmagick.php b/src/Converters/Gmagick.php deleted file mode 100644 index 893b3bde..00000000 --- a/src/Converters/Gmagick.php +++ /dev/null @@ -1,88 +0,0 @@ -queryformats())) { - throw new ConverterNotOperationalException('Gmagick was compiled without WebP support.'); - } - - $options = array_merge(ConverterHelper::$defaultOptions, $options); - - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - - /* - Seems there are currently no way to set webp options - As noted in the following link, it should probably be done with a $im->addDefinition() method - - but that isn't exposed (yet) - (TODO: see if anyone has answered...) - https://stackoverflow.com/questions/47294962/how-to-write-lossless-webp-files-with-perlmagick - */ - // The following two does not have any effect... How to set WebP options? - //$im->setimageoption('webp', 'webp:lossless', $options['lossless'] ? 'true' : 'false'); - //$im->setimageoption('WEBP', 'method', strval($options['method'])); - - // It seems there is no COMPRESSION_WEBP... - // http://php.net/manual/en/imagick.setimagecompression.php - //$im->setImageCompression(Imagick::COMPRESSION_JPEG); - //$im->setImageCompression(Imagick::COMPRESSION_UNDEFINED); - - - - $im->setimageformat('WEBP'); - - if ($options['metadata'] == 'none') { - // Strip metadata and profiles - $im->stripImage(); - } - - // Ps: Imagick automatically uses same quality as source, when no quality is set - // This feature is however not present in Gmagick - $im->setcompressionquality($options['_calculated_quality']); - - //$success = $im->writeimagefile(fopen($destination, 'wb')); - $success = @file_put_contents($destination, $im->getImageBlob()); - - if (!$success) { - throw new ConverterFailedException('Failed writing file'); - } else { - //$logger->logLn('sooms we made it!'); - } - } -} diff --git a/src/Converters/Imagick.php b/src/Converters/Imagick.php deleted file mode 100644 index 7ce22407..00000000 --- a/src/Converters/Imagick.php +++ /dev/null @@ -1,117 +0,0 @@ -readImage($source); - - // Throws an exception if iMagick does not support WebP conversion - if (!in_array('WEBP', $im->queryFormats())) { - throw new ConverterNotOperationalException('iMagick was compiled without WebP support.'); - } - - $options = array_merge(ConverterHelper::$defaultOptions, $options); - - // Force lossless option to true for PNG images - if (ConverterHelper::getExtension($source) == 'png') { - $options['lossless'] = true; - } - - $im->setImageFormat('WEBP'); - - /* - * More about iMagick's WebP options: - * http://www.imagemagick.org/script/webp.php - * https://developers.google.com/speed/webp/docs/cwebp - * https://stackoverflow.com/questions/37711492/imagemagick-specific-webp-calls-in-php - */ - - // TODO: We could easily support all webp options with a loop - - /* - After using getImageBlob() to write image, the following setOption() calls - makes settings makes imagick fail. So can't use those. But its a small price - to get a converter that actually makes great quality conversions. - - $im->setOption('webp:method', strval($options['method'])); - $im->setOption('webp:low-memory', strval($options['low-memory'])); - $im->setOption('webp:lossless', strval($options['lossless'])); - */ - - if ($options['metadata'] == 'none') { - // Strip metadata and profiles - $im->stripImage(); - } - - if (isset($options['_quality_could_not_be_detected'])) { - // quality was set to "auto", but we could not meassure the quality of the jpeg locally - // but luckily imagick is a big boy, and automatically converts with same quality as - // source, when the quality isn't set. - // So we simply do not set quality. - // This actually kills the max-height functionality. But I deem that this is more important - // because setting image quality to something higher than source generates bigger files, - // but gets you no extra quality. When failing to limit quality, you at least get something - // out of it - } else { - $im->setImageCompressionQuality($options['_calculated_quality']); - } - - - - // https://stackoverflow.com/questions/29171248/php-imagick-jpeg-optimization - // setImageFormat - - // TODO: Read up on - // https://www.smashingmagazine.com/2015/06/efficient-image-resizing-with-imagemagick/ - // https://github.com/nwtn/php-respimg - - // TODO: - // Should we set alpha channel for PNG's like suggested here: - // https://gauntface.com/blog/2014/09/02/webp-support-with-imagemagick-and-php ?? - // It seems that alpha channel works without... (at least I see completely transparerent pixels) - - // TODO: Check out other iMagick methods, see http://php.net/manual/de/imagick.writeimage.php#114714 - // 1. file_put_contents($destination, $im) - // 2. $im->writeImage($destination) - - // We used to use writeImageFile() method. But we now use getImageBlob(). See issue #43 - //$success = $im->writeImageFile(fopen($destination, 'wb')); - - $success = @file_put_contents($destination, $im->getImageBlob()); - - if (!$success) { - throw new ConverterFailedException('Failed writing file'); - } - } -} diff --git a/src/Converters/Imagickbinary.php b/src/Converters/Imagickbinary.php deleted file mode 100644 index c961d62b..00000000 --- a/src/Converters/Imagickbinary.php +++ /dev/null @@ -1,99 +0,0 @@ - 0); - */ - - $command = 'convert -version'; - exec($command, $output, $returnCode); - $hasDelegate = false; - foreach ($output as $line) { - if (preg_match('/Delegate.*webp.*/i', $line)) { - return true; - } - } - return false; - } - - - public static function escapeFilename($string) - { - // Escaping whitespace - $string = preg_replace('/\s/', '\\ ', $string); - - // filter_var() is should normally be available, but it is not always - // - https://stackoverflow.com/questions/11735538/call-to-undefined-function-filter-var - if (function_exists('filter_var')) { - // Sanitize quotes - $string = filter_var($string, FILTER_SANITIZE_MAGIC_QUOTES); - - // Stripping control characters - // see https://stackoverflow.com/questions/12769462/filter-flag-strip-low-vs-filter-flag-strip-high - $string = filter_var($string, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - } - - return $string; - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - if (!function_exists('exec')) { - throw new ConverterNotOperationalException('exec() is not enabled.'); - } - - if (!self::imagickInstalled()) { - throw new ConverterNotOperationalException('imagick is not installed'); - } - - if (!self::webPDelegateInstalled()) { - throw new ConverterNotOperationalException('webp delegate missing'); - } - - // Should we use "magick" or "convert" command? - // It seems they do the same. But which is best supported? Which is mostly available (whitelisted)? - // Should we perhaps try both? - // For now, we just go with "convert" - $command = 'convert ' . self::escapeFilename($source) . ' webp:' . self::escapeFilename($destination); - exec($command, $output, $returnCode); - - if ($returnCode == 127) { - throw new ConverterNotOperationalException('imagick is not installed'); - } - - if ($returnCode != 0) { - $logger->logLn('command:' . $command); - $logger->logLn('return code:' . $returnCode); - $logger->logLn('output:' . print_r(implode("\n", $output), true)); - - throw new ConverterNotOperationalException('The exec call failed'); - } - } -} diff --git a/src/Converters/Wpc.php b/src/Converters/Wpc.php deleted file mode 100644 index e5798f63..00000000 --- a/src/Converters/Wpc.php +++ /dev/null @@ -1,347 +0,0 @@ - 'api-version', /* Can currently be 0 or 1 */ - 'type' => 'number', - 'sensitive' => false, - 'default' => 0, - 'required' => false - ], - [ - 'name' => 'secret', /* only in api v.0 */ - 'type' => 'string', - 'sensitive' => true, - 'default' => 'my dog is white', - 'required' => false - ], - [ - 'name' => 'api-key', /* new in api v.1 (renamed 'secret' to 'api-key') */ - 'type' => 'string', - 'sensitive' => true, - 'default' => 'my dog is white', - 'required' => false - ], - [ - 'name' => 'url', - 'type' => 'string', - 'sensitive' => true, - 'default' => '', - 'required' => true - ], - [ - 'name' => 'crypt-api-key-in-transfer', /* new in api v.1 */ - 'type' => 'boolean', - 'sensitive' => false, - 'default' => false, - 'required' => false - ], - - /* - [ - 'name' => 'web-services', - 'type' => 'array', - 'sensitive' => true, - 'default' => [ - [ - 'label' => 'test', - 'api-key' => 'my dog is white', - 'url' => 'http://we0/wordpress/webp-express-server', - 'crypt-api-key-in-transfer' => true - ] - ], - 'required' => true - ], - */ - ]; - - public static function convert($source, $destination, $options = []) - { - ConverterHelper::runConverter('wpc', $source, $destination, $options, true); - } - - // Took this parser from Drupal - private static function parseSize($size) - { - - $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size. - $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size. - if ($unit) { - // Find the position of the unit in the ordered string which is the power - // of magnitude to multiply a kilobyte by. - return round($size * pow(1024, stripos('bkmgtpezy', $unit[0]))); - } else { - return round($size); - } - } - - private static function createRandomSaltForBlowfish() - { - $salt = ''; - $validCharsForSalt = array_merge( - range('A', 'Z'), - range('a', 'z'), - range('0', '9'), - ['.', '/'] - ); - - for ($i=0; $i<22; $i++) { - $salt .= $validCharsForSalt[array_rand($validCharsForSalt)]; - } - return $salt; - } - - // Although this method is public, do not call directly. - public static function doConvert($source, $destination, $options, $logger) - { - - if (!extension_loaded('curl')) { - throw new ConverterNotOperationalException('Required cURL extension is not available.'); - } - - if (!function_exists('curl_init')) { - throw new ConverterNotOperationalException('Required url_init() function is not available.'); - } - - $apiVersion = $options['api-version']; - - if (!function_exists('curl_file_create')) { - throw new ConverterNotOperationalException( - 'Required curl_file_create() PHP function is not available (requires PHP > 5.5).' - ); - } - - if ($apiVersion == 0) { - if (!empty($options['secret'])) { - // if secret is set, we need md5() and md5_file() functions - if (!function_exists('md5')) { - throw new ConverterNotOperationalException( - 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . - 'contents. ' . - 'But the required md5() PHP function is not available.' - ); - } - if (!function_exists('md5_file')) { - throw new ConverterNotOperationalException( - 'A secret has been set, which requires us to create a md5 hash from the secret and the file ' . - 'contents. But the required md5_file() PHP function is not available.' - ); - } - } - } - - if ($apiVersion == 1) { - /* - if (count($options['web-services']) == 0) { - throw new ConverterNotOperationalException('No remote host has been set up'); - }*/ - } - - if ($options['url'] == '') { - throw new ConverterNotOperationalException( - 'Missing URL. You must install Webp Convert Cloud Service on a server, ' . - 'or the WebP Express plugin for Wordpress - and supply the url.' - ); - } - - $fileSize = @filesize($source); - if ($fileSize !== false) { - $uploadMaxSize = self::parseSize(ini_get('upload_max_filesize')); - if (($uploadMaxSize !== false) && ($uploadMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your max upload (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'upload_max_filesize in php.ini: ' . ini_get('upload_max_filesize') . - ' (parsed as ' . round($uploadMaxSize/1024) . ' kb)' - ); - } - - $postMaxSize = self::parseSize(ini_get('post_max_size')); - if (($postMaxSize !== false) && ($postMaxSize < $fileSize)) { - throw new ConverterFailedException( - 'File is larger than your post_max_size limit (set in your php.ini). File size:' . - round($fileSize/1024) . ' kb. ' . - 'post_max_size in php.ini: ' . ini_get('post_max_size') . - ' (parsed as ' . round($postMaxSize/1024) . ' kb)' - ); - } - - // ini_get('memory_limit') - } - - // Got some code here: - // https://coderwall.com/p/v4ps1a/send-a-file-via-post-with-curl-and-php - - $ch = curl_init(); - if (!$ch) { - throw new ConverterNotOperationalException('Could not initialise cURL.'); - } - - $optionsToSend = $options; - - if (isset($options['_quality_could_not_be_detected'])) { - // quality was set to "auto", but we could not meassure the quality of the jpeg locally - // Ask the cloud service to do it, rather than using what we came up with. - $optionsToSend['quality'] = 'auto'; - } else { - $optionsToSend['quality'] = $options['_calculated_quality']; - } - - unset($optionsToSend['converters']); - unset($optionsToSend['secret']); - unset($optionsToSend['_quality_could_not_be_detected']); - unset($optionsToSend['_calculated_quality']); - - $postData = [ - 'file' => curl_file_create($source), - 'options' => json_encode($optionsToSend), - 'servername' => (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : '') - ]; - - if ($apiVersion == 0) { - $postData['hash'] = md5(md5_file($source) . $options['secret']); - } - - if ($apiVersion == 1) { - $apiKey = $options['api-key']; - - if ($options['crypt-api-key-in-transfer']) { - if (CRYPT_BLOWFISH == 1) { - $salt = self::createRandomSaltForBlowfish(); - $postData['salt'] = $salt; - - // Strip off the first 28 characters (the first 6 are always "$2y$10$". The next 22 is the salt) - $postData['api-key-crypted'] = substr(crypt($apiKey, '$2y$10$' . $salt . '$'), 28); - } else { - if (!function_exists('crypt')) { - throw new ConverterNotOperationalException( - 'Configured to crypt the api-key, but crypt() function is not available.' - ); - } else { - throw new ConverterNotOperationalException( - 'Configured to crypt the api-key. ' . - 'That requires Blowfish encryption, which is not available on your current setup.' - ); - } - } - } else { - $postData['api-key'] = $apiKey; - } - } - - - // Try one host at the time - // TODO: shuffle the array first - /* - foreach ($options['web-services'] as $webService) { - - } - */ - - - curl_setopt_array($ch, [ - CURLOPT_URL => $options['url'], - CURLOPT_POST => 1, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ]); - - $response = curl_exec($ch); - if (curl_errno($ch)) { - throw new ConverterNotOperationalException('Curl error:' . curl_error($ch)); - } - - // Check if we got a 404 - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($httpCode == 404) { - curl_close($ch); - throw new ConverterFailedException( - 'WPC was not found at the specified URL - we got a 404 response.' - ); - } - - // The WPC cloud service either returns an image or an error message - // Images has application/octet-stream. - // Verify that we got an image back. - if (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) != 'application/octet-stream') { - curl_close($ch); - - if (substr($response, 0, 1) == '{') { - $responseObj = json_decode($response, true); - if (isset($responseObj['errorCode'])) { - switch ($responseObj['errorCode']) { - case 0: - throw new ConverterFailedException( - 'There are problems with the server setup: "' . - $responseObj['errorMessage'] . '"' - ); - case 1: - throw new ConverterFailedException( - 'Access denied. ' . $responseObj['errorMessage'] - ); - default: - throw new ConverterFailedException( - 'Conversion failed: "' . $responseObj['errorMessage'] . '"' - ); - } - } - } - - // WPC 0.1 returns 'failed![error messag]' when conversion fails. Handle that. - if (substr($response, 0, 7) == 'failed!') { - throw new ConverterFailedException( - 'WPC failed converting image: "' . substr($response, 7) . '"' - ); - } - - if (empty($response)) { - $errorMsg = 'Error: Unexpected result. We got nothing back. HTTP CODE: ' . $httpCode; - throw new ConverterFailedException($errorMsg); - } else { - $errorMsg = 'Error: Unexpected result. We did not receive an image. We received: "'; - $errorMsg .= str_replace("\r", '', str_replace("\n", '', htmlentities(substr($response, 0, 400)))); - throw new ConverterFailedException($errorMsg . '..."'); - } - //throw new ConverterNotOperationalException($response); - } - - $success = @file_put_contents($destination, $response); - curl_close($ch); - - if (!$success) { - throw new ConverterFailedException('Error saving file. Check file permissions'); - } - /* - $curlOptions = [ - 'api_key' => $options['key'], - 'webp' => '1', - 'file' => curl_file_create($source), - 'domain' => $_SERVER['HTTP_HOST'], - 'quality' => $options['quality'], - 'metadata' => ($options['metadata'] == 'none' ? '0' : '1') - ]; - - curl_setopt_array($ch, [ - CURLOPT_URL => "https://optimize.exactlywww.com/v2/", - CURLOPT_HTTPHEADER => [ - 'User-Agent: WebPConvert', - 'Accept: image/*' - ], - CURLOPT_POSTFIELDS => $curlOptions, - CURLOPT_BINARYTRANSFER => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_SSL_VERIFYPEER => false - ]);*/ - } -} diff --git a/src/Exceptions/ConverterNotFoundException.php b/src/Exceptions/ConverterNotFoundException.php deleted file mode 100644 index 185d1da1..00000000 --- a/src/Exceptions/ConverterNotFoundException.php +++ /dev/null @@ -1,10 +0,0 @@ -detailedMessage; + } + + public function getShortMessage() + { + return $this->shortMessage; + } + + public function __construct($shortMessage = "", $detailedMessage = "", $previous = null) + { + $detailedMessage = ($detailedMessage != '') ? $detailedMessage : $shortMessage; + $this->detailedMessage = $detailedMessage; + $this->shortMessage = $shortMessage; + + parent::__construct( + $detailedMessage, + 0, + $previous + ); + } +} diff --git a/src/Helpers/InputValidator.php b/src/Helpers/InputValidator.php new file mode 100644 index 00000000..c791d98b --- /dev/null +++ b/src/Helpers/InputValidator.php @@ -0,0 +1,61 @@ + + * @since Class available since Release 2.0.6 + */ +class InputValidator +{ + + private static $allowedMimeTypes = [ + 'image/jpeg', + 'image/png' + ]; + + /** + * Check mimetype and if file path is ok and exists + */ + public static function checkMimeType($filePath, $allowedMimeTypes = null) + { + if (is_null($allowedMimeTypes)) { + $allowedMimeTypes = self::$allowedMimeTypes; + } + // the following also tests that file path is ok and file exists + $fileMimeType = MimeType::getMimeTypeDetectionResult($filePath); + + if (is_null($fileMimeType)) { + throw new InvalidImageTypeException('Image type could not be detected'); + } elseif ($fileMimeType === false) { + throw new InvalidImageTypeException('File seems not to be an image.'); + } elseif (!in_array($fileMimeType, $allowedMimeTypes)) { + throw new InvalidImageTypeException('Unsupported mime type: ' . $fileMimeType); + } + } + + public static function checkSource($source) + { + PathChecker::checkSourcePath($source); + self::checkMimeType($source); + } + + public static function checkDestination($destination) + { + PathChecker::checkDestinationPath($destination); + } + + public static function checkSourceAndDestination($source, $destination) + { + self::checkSource($source); + self::checkDestination($destination); + } +} diff --git a/src/Helpers/MimeType.php b/src/Helpers/MimeType.php new file mode 100644 index 00000000..47227284 --- /dev/null +++ b/src/Helpers/MimeType.php @@ -0,0 +1,40 @@ + + * @since Class available since Release 2.0.6 + */ +class MimeType +{ + private static $cachedDetections = []; + + /** + * Get mime type for image (best guess). + * + * It falls back to using file extension. If that fails too, false is returned + * + * @return string|false|null mimetype (if it is an image, and type could be determined / guessed), + * false (if it is not an image type that the server knowns about) + * or null (if nothing can be determined) + */ + public static function getMimeTypeDetectionResult($absFilePath) + { + PathChecker::checkAbsolutePathAndExists($absFilePath); + + if (isset(self::$cachedDetections[$absFilePath])) { + return self::$cachedDetections[$absFilePath]; + } + self::$cachedDetections[$absFilePath] = ImageMimeTypeGuesser::lenientGuess($absFilePath); + return self::$cachedDetections[$absFilePath]; + } +} diff --git a/src/Helpers/PathChecker.php b/src/Helpers/PathChecker.php new file mode 100644 index 00000000..0a7573ae --- /dev/null +++ b/src/Helpers/PathChecker.php @@ -0,0 +1,115 @@ + + * @since Class available since Release 2.0.6 + */ +class PathChecker +{ + + /** + * Check absolute file path to prevent attacks. + * + * - Prevents non printable characters + * - Prevents stream wrappers + * - Prevents directory traversal + * + * Preventing non printable characters is especially done to prevent the NUL character, which can be used + * to bypass other tests. See https://st-g.de/2011/04/doing-filename-checks-securely-in-PHP. + * + * Preventeng stream wrappers is especially done to protect against Phar Deserialization. + * See https://blog.ripstech.com/2018/new-php-exploitation-technique/ + * + * @param string $absFilePath + * @return void + */ + public static function checkAbsolutePath($absFilePath, $text = 'file') + { + if (empty($absFilePath)) { + throw new InvalidInputException('Empty filepath for ' . $text); + } + + // Prevent non printable characters + /* + if (!ctype_print($absFilePath)) { + throw new InvalidInputException('Non-printable characters are not allowed in ' . $text); + }*/ + + // Prevent control characters (at least the first 32 (#0 - #1f) + if (preg_match('#[\x{0}-\x{1f}]#', $absFilePath)) { + throw new InvalidInputException('Non-printable characters are not allowed'); + } + + // Prevent directory traversal + /* Disabled. We DO allow it again (#203) + if (preg_match('#\.\.\/#', $absFilePath)) { + throw new InvalidInputException('Directory traversal is not allowed in ' . $text . ' path'); + }*/ + + // Prevent stream wrappers ("phar://", "php://" and the like) + // https://www.php.net/manual/en/wrappers.phar.php + if (preg_match('#^\\w+://#', $absFilePath)) { + throw new InvalidInputException('Stream wrappers are not allowed in ' . $text . ' path'); + } + } + + public static function checkAbsolutePathAndExists($absFilePath, $text = 'file') + { + if (empty($absFilePath)) { + throw new TargetNotFoundException($text . ' argument missing'); + } + self::checkAbsolutePath($absFilePath, $text); + if (@!file_exists($absFilePath)) { + throw new TargetNotFoundException($text . ' file was not found'); + } + if (@is_dir($absFilePath)) { + throw new InvalidInputException($text . ' is a directory'); + } + } + + /** + * Checks that source path is secure, file exists and it is not a dir. + * + * To also check mime type, use InputValidator::checkSource + */ + public static function checkSourcePath($source) + { + self::checkAbsolutePathAndExists($source, 'source'); + } + + public static function checkDestinationPath($destination) + { + if (empty($destination)) { + throw new InvalidInputException('Destination argument missing'); + } + self::checkAbsolutePath($destination, 'destination'); + + if (!preg_match('#\.webp$#i', $destination)) { + // Prevent overriding important files. + // Overriding an .htaccess file would lay down the website. + throw new InvalidInputException( + 'Destination file must end with ".webp". ' . + 'If you deliberately want to store the webp files with another extension, you must rename ' . + 'the file after successful conversion' + ); + } + + if (@is_dir($destination)) { + throw new InvalidInputException('Destination is a directory'); + } + } + + public static function checkSourceAndDestinationPaths($source, $destination) + { + self::checkSourcePath($source); + self::checkDestinationPath($destination); + } +} diff --git a/src/Helpers/Sanitize.php b/src/Helpers/Sanitize.php new file mode 100644 index 00000000..0e3ebc71 --- /dev/null +++ b/src/Helpers/Sanitize.php @@ -0,0 +1,30 @@ +'; + $realPathResult = realpath($dir); + if ($realPathResult !== false) { + return $realPathResult; + } + if (($dir == '/') || (strlen($dir) < 4)) { + return $dir; + } + $levelsUp++; + } + return '/'; + } + + public static function pathBeginsWithSymLinksExpanded($input, $beginsWith, $errorMsg = 'Path is outside allowed path') { + $closestExistingFolder = self::findClosestExistingFolderSymLinksExpanded($input); + //throw new SanityException('hm.' . $input . ' :
' . $closestExistingFolder); + self::pathBeginsWith($closestExistingFolder, $beginsWith, $errorMsg); + } + + + + public static function absPathExists($input, $errorMsg = 'Path does not exist') + { + self::absPath($input); + if (@!file_exists($input)) { + throw new SanityException($errorMsg); + } + return $input; + } + + public static function absPathExistsAndIsDir( + $input, + $errorMsg = 'Path points to a file (it should point to a directory)' + ) { + self::absPathExists($input); + if (!is_dir($input)) { + throw new SanityException($errorMsg); + } + return $input; + } + + public static function absPathExistsAndIsFile( + $input, + $errorMsg = 'Path points to a directory (it should not do that)' + ) { + self::absPathExists($input, 'File does not exist'); + if (@is_dir($input)) { + throw new SanityException($errorMsg); + } + return $input; + } + + public static function absPathExistsAndIsNotDir( + $input, + $errorMsg = 'Path points to a directory (it should point to a file)' + ) { + self::absPathExistsAndIsFile($input, $errorMsg); + return $input; + } + + + public static function pregMatch($pattern, $input, $errorMsg = 'Does not match expected pattern') + { + self::noNUL($input); + self::mustBeString($input); + if (!preg_match($pattern, $input)) { + throw new SanityException($errorMsg); + } + return $input; + } + + public static function isJSONArray($input, $errorMsg = 'Not a JSON array') + { + self::noNUL($input); + self::mustBeString($input); + self::notEmpty($input); + if ((strpos($input, '[') !== 0) || (!is_array(json_decode($input)))) { + throw new SanityException($errorMsg); + } + return $input; + } + + public static function isJSONObject($input, $errorMsg = 'Not a JSON object') + { + self::noNUL($input); + self::mustBeString($input); + self::notEmpty($input); + if ((strpos($input, '{') !== 0) || (!is_object(json_decode($input)))) { + throw new SanityException($errorMsg); + } + return $input; + } + +} diff --git a/src/Loggers/BaseLogger.php b/src/Loggers/BaseLogger.php index 9475a06b..a251b126 100644 --- a/src/Loggers/BaseLogger.php +++ b/src/Loggers/BaseLogger.php @@ -2,25 +2,43 @@ namespace WebPConvert\Loggers; +/** + * Base for all logger classes. + * + * WebPConvert can provide insights into the conversion process by means of accepting a logger which + * extends this class. + * + * @package WebPConvert + * @author Bjørn Rosell + * @since Class available since Release 2.0.0 + */ abstract class BaseLogger { - /* - $msg: message to log - $style: null | bold | italic - */ + /** + * Write a message to the log + * + * @param string $msg message to log + * @param string $style style (null | bold | italic) + * @return void + */ abstract public function log($msg, $style = ''); + /** + * Add new line to the log + * @return void + */ abstract public function ln(); + /** + * Write a line to the log + * + * @param string $msg message to log + * @param string $style style (null | bold | italic) + * @return void + */ public function logLn($msg, $style = '') { $this->log($msg, $style); $this->ln(); } - - public function logLnLn($msg, $style = '') - { - $this->logLn($msg, $style); - $this->ln(); - } } diff --git a/src/Loggers/BufferLogger.php b/src/Loggers/BufferLogger.php index a6e4dfa0..47d77248 100644 --- a/src/Loggers/BufferLogger.php +++ b/src/Loggers/BufferLogger.php @@ -4,20 +4,44 @@ use WebPConvert\Loggers\BaseLogger; +/** + * Collect the logging and retrieve it later in HTML or plain text format. + * + * @package WebPConvert + * @author Bjørn Rosell + * @since Class available since Release 2.0.0 + */ class BufferLogger extends BaseLogger { public $entries = array(); + /** + * Write a message to the buffer - all entries can later be retrieved with getText() or getHtlm(). + * + * @param string $msg message to log + * @param string $style style (null | bold | italic) + * @return void + */ public function log($msg, $style = '') { $this->entries[] = [$msg, $style]; } + /** + * Write a new line to the buffer. + * + * @return void + */ public function ln() { $this->entries[] = ''; } + /** + * Get everything logged - as HTML. + * + * @return string The log, formatted as HTML. + */ public function getHtml() { $html = ''; @@ -39,13 +63,44 @@ public function getHtml() return $html; } + /** + * Get everything logged - as markdown. + * + * @return string The log, formatted as MarkDown. + */ + public function getMarkDown($newLineChar = "\n\r") + { + $md = ''; + foreach ($this->entries as $entry) { + if ($entry == '') { + $md .= $newLineChar; + } else { + list($msg, $style) = $entry; + if ($style == 'bold') { + $md .= '**' . $msg . '** '; + } elseif ($style == 'italic') { + $md .= '*' . $msg . '* '; + } else { + $md .= $msg; + } + } + } + return $md; + } + + /** + * Get everything logged - as plain text. + * + * @param string $newLineChar. The character used for new lines. + * @return string The log, formatted as plain text. + */ public function getText($newLineChar = ' ') { $text = ''; foreach ($this->entries as $entry) { - if ($entry == '') { - if (substr($text, -2) != '. ') { - $text .= '. '; + if ($entry == '') { // empty string means new line + if (substr($text, -2) != '.' . $newLineChar) { + $text .= '.' . $newLineChar; } } else { list($msg, $style) = $entry; diff --git a/src/Loggers/EchoLogger.php b/src/Loggers/EchoLogger.php index f2022809..12f45cac 100644 --- a/src/Loggers/EchoLogger.php +++ b/src/Loggers/EchoLogger.php @@ -2,8 +2,23 @@ namespace WebPConvert\Loggers; +/** + * Echo the logs immediately (in HTML) + * + * @package WebPConvert + * @author Bjørn Rosell + * @since Class available since Release 2.0.0 + */ class EchoLogger extends BaseLogger { + + /** + * Handle log() by echoing the message. + * + * @param string $msg message to log + * @param string $style style (null | bold | italic) + * @return void + */ public function log($msg, $style = '') { $msg = htmlspecialchars($msg); @@ -16,6 +31,11 @@ public function log($msg, $style = '') } } + /** + * Handle ln by echoing a
tag. + * + * @return void + */ public function ln() { echo '
'; diff --git a/src/Loggers/VoidLogger.php b/src/Loggers/VoidLogger.php deleted file mode 100644 index 2e7ec56e..00000000 --- a/src/Loggers/VoidLogger.php +++ /dev/null @@ -1,14 +0,0 @@ - + * @since Class available since Release 2.0.0 + */ +class ArrayOption extends Option +{ + + protected $typeId = 'array'; + protected $schemaType = ['array']; + + public function check() + { + $this->checkType('array'); + } + + public function getValueForPrint() + { + if (count($this->getValue()) == 0) { + return '(empty array)'; + } else { + return parent::getValueForPrint(); + } + } + + public function getDefinition() + { + $obj = parent::getDefinition(); + $obj['sensitive'] = false; + return $obj; + } +} diff --git a/src/Options/BooleanOption.php b/src/Options/BooleanOption.php new file mode 100644 index 00000000..4fde6900 --- /dev/null +++ b/src/Options/BooleanOption.php @@ -0,0 +1,30 @@ + + * @since Class available since Release 2.0.0 + */ +class BooleanOption extends Option +{ + + protected $typeId = 'boolean'; + protected $schemaType = ['boolean']; + + public function check() + { + $this->checkType('boolean'); + } + + public function getValueForPrint() + { + return ($this->getValue() === true ? 'true' : 'false'); + } +} diff --git a/src/Options/Exceptions/InvalidOptionTypeException.php b/src/Options/Exceptions/InvalidOptionTypeException.php new file mode 100644 index 00000000..14a0fa9b --- /dev/null +++ b/src/Options/Exceptions/InvalidOptionTypeException.php @@ -0,0 +1,10 @@ + + * @since Class available since Release 2.0.0 + */ +class GhostOption extends Option +{ + + protected $typeId = 'ghost'; + + public function getValueForPrint() + { + return '(not defined for this converter)'; + } +} diff --git a/src/Options/IntegerOption.php b/src/Options/IntegerOption.php new file mode 100644 index 00000000..db442db4 --- /dev/null +++ b/src/Options/IntegerOption.php @@ -0,0 +1,76 @@ + + * @since Class available since Release 2.0.0 + */ +class IntegerOption extends Option +{ + protected $typeId = 'int'; + protected $schemaType = ['integer']; + protected $minValue; + protected $maxValue; + + /** + * Constructor. + * + * @param string $id id of the option + * @param integer $defaultValue default value for the option + * @throws InvalidOptionValueException if the default value cannot pass the check + * @return void + */ + public function __construct($id, $defaultValue, $minValue = null, $maxValue = null) + { + $this->minValue = $minValue; + $this->maxValue = $maxValue; + parent::__construct($id, $defaultValue); + } + + protected function checkMin() + { + if (!is_null($this->minValue) && $this->getValue() < $this->minValue) { + throw new InvalidOptionValueException( + '"' . $this->id . '" option must be set to minimum ' . $this->minValue . '. ' . + 'It was however set to: ' . $this->getValue() + ); + } + } + + protected function checkMax() + { + if (!is_null($this->maxValue) && $this->getValue() > $this->maxValue) { + throw new InvalidOptionValueException( + '"' . $this->id . '" option must be set to max ' . $this->maxValue . '. ' . + 'It was however set to: ' . $this->getValue() + ); + } + } + + protected function checkMinMax() + { + $this->checkMin(); + $this->checkMax(); + } + + public function check() + { + $this->checkType('integer'); + $this->checkMinMax(); + } + + public function getSchema() + { + $obj = parent::getSchema(); + $obj['minimum'] = $this->minValue; + $obj['maximum'] = $this->maxValue; + return $obj; + } +} diff --git a/src/Options/IntegerOrNullOption.php b/src/Options/IntegerOrNullOption.php new file mode 100644 index 00000000..82a399f5 --- /dev/null +++ b/src/Options/IntegerOrNullOption.php @@ -0,0 +1,50 @@ + + * @since Class available since Release 2.0.0 + */ +class IntegerOrNullOption extends IntegerOption +{ + protected $schemaType = ['integer', 'null']; + + public function __construct($id, $defaultValue, $minValue = null, $maxValue = null) + { + parent::__construct($id, $defaultValue, $minValue, $maxValue); + } + + public function check() + { + $this->checkMinMax(); + + $valueType = gettype($this->getValue()); + if (!in_array($valueType, ['integer', 'NULL'])) { + throw new InvalidOptionValueException( + 'The "' . $this->id . '" option must be either integer or NULL. ' . + 'You however provided a value of type: ' . $valueType + ); + } + } + + public function getValueForPrint() + { + if (gettype($this->getValue()) == 'NULL') { + return 'null (not set)'; + } + return parent::getValueForPrint(); + } + + public function getDefinition() + { + $obj = parent::getDefinition(); + return $obj; + } +} diff --git a/src/Options/MetadataOption.php b/src/Options/MetadataOption.php new file mode 100644 index 00000000..a56601f5 --- /dev/null +++ b/src/Options/MetadataOption.php @@ -0,0 +1,47 @@ + + * @since Class available since Release 2.0.0 + */ +class MetadataOption extends StringOption +{ + + protected $typeId = 'metadata'; + protected $schemaType = ['string']; + + public function __construct($id, $defaultValue) + { + parent::__construct($id, $defaultValue); + } + + public function check() + { + parent::check(); + + $value = $this->getValue(); + + if (($value == 'all') || ($value == 'none')) { + return; + } + + foreach (explode(',', $value) as $item) { + if (!in_array($value, ['exif', 'icc', 'xmp'])) { + throw new InvalidOptionValueException( + '"metadata" option must be "all", "none" or a comma-separated list of "exif", "icc" or "xmp". ' . + 'It was however set to: "' . $value . '"' + ); + } + } + + //$this->checkType('string'); + } +} diff --git a/src/Options/Option.php b/src/Options/Option.php new file mode 100644 index 00000000..0d874ae1 --- /dev/null +++ b/src/Options/Option.php @@ -0,0 +1,254 @@ + + * @since Class available since Release 2.0.0 + */ +class Option +{ + /** @var string The id of the option */ + protected $id; + + /** @var mixed The default value of the option */ + protected $defaultValue; + + /** @var mixed The value of the option */ + protected $value; + + /** @var boolean Whether the value has been set (if not, getValue() will return the default value) */ + protected $isExplicitlySet = false; + + /** @var string An option must supply a type id */ + protected $typeId; + + /** @var array Type constraints for the value (JSON schema syntax) */ + protected $schemaType = []; + + /** @var array|null Array of allowed values (JSON schema syntax) */ + protected $enum = null; //https://json-schema.org/understanding-json-schema/reference/generic.html#enumerated-values + + /** @var boolean Whether the option has been deprecated */ + protected $deprecated = false; + + /** @var string Help text */ + protected $helpText = ''; + + /** @var array UI Def */ + protected $ui; + + /** @var array|null Extra Schema Def (ie holding 'title', 'description' or other)*/ + protected $extraSchemaDefs; + + + /** + * Constructor. + * + * @param string $id id of the option + * @param mixed $defaultValue default value for the option + * @throws InvalidOptionValueException if the default value cannot pass the check + * @throws InvalidOptionTypeException if the default value is wrong type + * @return void + */ + public function __construct($id, $defaultValue) + { + $this->id = $id; + $this->defaultValue = $defaultValue; + + // Check that default value is ok + $this->check(); + } + + /** + * Get Id. + * + * @return string The id of the option + */ + public function getId() + { + return $this->id; + } + + /** + * Get Id. + * + * @param string $id The id of the option + */ + public function setId($id) + { + $this->id = $id; + } + + /** + * Get default value. + * + * @return mixed The default value for the option + */ + public function getDefaultValue() + { + return $this->defaultValue; + } + + + /** + * Get value, or default value if value has not been explicitly set. + * + * @return mixed The value/default value + */ + public function getValue() + { + if (!$this->isExplicitlySet) { + return $this->defaultValue; + } else { + return $this->value; + } + } + + /** + * Get to know if value has been set. + * + * @return boolean Whether or not the value has been set explicitly + */ + public function isValueExplicitlySet() + { + return $this->isExplicitlySet; + } + + /** + * Set value + * + * @param mixed $value The value + * @return void + */ + public function setValue($value) + { + $this->isExplicitlySet = true; + $this->value = $value; + } + + /** + * Check if the value is valid. + * + * This base class does no checking, but this method is overridden by most other options. + * @return void + */ + public function check() + { + } + + /** + * Helpful function for checking type - used by subclasses. + * + * @param string $expectedType The expected type, ie 'string' + * @throws InvalidOptionTypeException If the type is invalid + * @return void + */ + protected function checkType($expectedType) + { + if (gettype($this->getValue()) != $expectedType) { + throw new InvalidOptionTypeException( + 'The "' . $this->id . '" option must be a ' . $expectedType . + ' (you provided a ' . gettype($this->getValue()) . ')' + ); + } + } + + public function markDeprecated() + { + $this->deprecated = true; + } + + public function isDeprecated() + { + return $this->deprecated; + } + + public function getValueForPrint() + { + return print_r($this->getValue(), true); + } + + /** + * Set help text for the option + * + * @param string $helpText The help text + * @return void + */ + public function setHelpText($helpText) + { + $this->helpText = $helpText; + } + + /** + * Get help text for the option + * + * @return string $helpText The help text + */ + public function getHelpText() + { + return $this->helpText; + } + + /** + * Set ui definition for the option + * + * @param array $ui The UI def + * @return void + */ + public function setUI($ui) + { + $this->ui = $ui; + } + + public function setExtraSchemaDefs($def) + { + $this->extraSchemaDefs = $def; + } + + + /** + * Get ui definition for the option + * + * @return array $ui The UI def + */ + public function getUI() + { + return $this->ui; + } + + public function getSchema() + { + if (isset($this->extraSchemaDefs)) { + $schema = $this->extraSchemaDefs; + } else { + $schema = []; + } + $schema['type'] = $this->schemaType; + $schema['default'] = $this->defaultValue; + if (!is_null($this->enum)) { + $schema['enum'] = $this->enum; + } + return $schema; + } + + + public function getDefinition() + { + $obj = [ + 'id' => $this->id, + 'schema' => $this->getSchema(), + 'ui' => $this->ui, + ]; + if ($this->deprecated) { + $obj['deprecated'] = true; + } + return $obj; + } +} diff --git a/src/Options/OptionFactory.php b/src/Options/OptionFactory.php new file mode 100644 index 00000000..f69cfd9b --- /dev/null +++ b/src/Options/OptionFactory.php @@ -0,0 +1,96 @@ + + * @since Class available since Release 2.7.0 + */ +class OptionFactory +{ + + public static function createOption($optionName, $optionType, $def) + { + $option = null; + switch ($optionType) { + case 'int': + $minValue = (isset($def['minimum']) ? $def['minimum'] : null); + $maxValue = (isset($def['maximum']) ? $def['maximum'] : null); + unset($def['minimum']); + unset($def['maximum']); + if (isset($def['allow-null']) && $def['allow-null']) { + $option = new IntegerOrNullOption($optionName, $def['default'], $minValue, $maxValue); + } else { + if ($optionName == 'quality') { + $option = new QualityOption($optionName, $def['default']); + } else { + $option = new IntegerOption($optionName, $def['default'], $minValue, $maxValue); + } + } + break; + + case 'string': + if ($optionName == 'metadata') { + $option = new MetadataOption($optionName, $def['default']); + } else { + $enum = (isset($def['enum']) ? $def['enum'] : null); + if (isset($def['sensitive']) && ($def['sensitive'] == true)) { + unset($def['sensitive']); + $option = new SensitiveStringOption($optionName, $def['default'], $enum); + } else { + $option = new StringOption($optionName, $def['default'], $enum); + } + } + break; + + case 'boolean': + $option = new BooleanOption($optionName, $def['default']); + break; + + case 'array': + if (isset($def['sensitive']) && ($def['sensitive'] == true)) { + $option = new SensitiveArrayOption($optionName, $def['default']); + } else { + $option = new ArrayOption($optionName, $def['default']); + } + break; + } + unset($def['default']); + + if (!is_null($option)) { + if (isset($def['deprecated'])) { + $option->markDeprecated(); + } + if (isset($def['ui'])) { + $option->setUI($def['ui']); + unset($def['ui']); + } + } + $option->setExtraSchemaDefs($def); + return $option; + } + + public static function createOptions($def) + { + $result = []; + foreach ($def as $i => list($optionName, $optionType, $optionDef)) { + $option = self::createOption($optionName, $optionType, $optionDef); + if (!is_null($option)) { + $result[] = $option; + } + } + return $result; + } +} diff --git a/src/Options/Options.php b/src/Options/Options.php new file mode 100644 index 00000000..d8c64bf6 --- /dev/null +++ b/src/Options/Options.php @@ -0,0 +1,209 @@ + + * @since Class available since Release 2.0.0 + */ +class Options +{ + + /** @var array A map of options, keyed by their id */ + private $options = []; + + /** + * Add option. + * + * @param Option $option The option object to add to collection. + * @return void + */ + public function addOption($option) + { + $this->options[$option->getId()] = $option; + } + + /** + * Add options. + * + * Conveniently add several options in one call. + * + * @return void + */ + public function addOptions() + { + $options = func_get_args(); + foreach ($options as $option) { + $this->addOption($option); + } + } + + /* + In some years, we can use the splat instead (requires PHP 5.6): + @param Option[] ...$options Array of options objects to add + public function addOptions(...$options) + { + foreach ($options as $option) { + $this->addOption($option); + } + }*/ + + /** + * Set the value of an option. + * + * @param string $id Id of the option + * @param mixed $value Value of the option + * @return void + */ + public function setOption($id, $value) + { + if (!isset($this->options[$id])) { + throw new OptionNotFoundException( + 'Could not set option. There is no option called "' . $id . '" in the collection.' + ); + } + $option = $this->options[$id]; + $option->setValue($value); + } + + /** + * Set option, or create a new, if no such option exists. + * + * @param string $id Id of option to set/create + * @param mixed $value Value of option + * @return void + */ + public function setOrCreateOption($id, $value) + { + if (!isset($this->options[$id])) { + $newOption = new GhostOption($id, null); + $newOption->setValue($value); + //$newOption = new Option($id, $value); + $this->addOption($newOption); + } else { + $this->setOption($id, $value); + } + } + + /** + * Get the value of an option in the collection - by id. + * + * @deprecated Use getOptionValue() instead + * @param string $id Id of the option to get + * @throws OptionNotFoundException if the option is not in the collection + * @return mixed The value of the option + */ + public function getOption($id) + { + return $this->getOptionValue($id); + } + + /** + * Get the Option in the collection by id. + * + * @param string $id Id of the option to get + * @throws OptionNotFoundException if the option is not in the collection + * @return mixed The value of the option + */ + public function getOptionById($id) + { + if (!isset($this->options[$id])) { + throw new OptionNotFoundException( + 'There is no option called "' . $id . '" in the collection.' + ); + } + return $this->options[$id]; + } + + /** + * Get the value of an option in the collection - by id. + * + * @param string $id Id of the option to get + * @throws OptionNotFoundException if the option is not in the collection + * @return mixed The value of the option + */ + public function getOptionValue($id) + { + $option = $this->getOptionById($id); + return $option->getValue(); + } + + /** + * Return map of Option objects. + * + * @return array map of option objects + */ + public function getOptionsMap() + { + return $this->options; + } + + /** + * Return flat associative array of options (simple objects). + * + * @return array associative array of options + */ + public function getOptions() + { + $values = []; + foreach ($this->options as $id => $option) { + $values[$id] = $option->getValue(); + } + return $values; + } + + /** + * Check all options in the collection. + */ + public function check() + { + foreach ($this->options as $id => $option) { + $option->check(); + } + } + + /** + * Set help texts on multiple options + * + * @param array $helpTexts Hash of helptexts indexed by option id + */ + public function setHelpTexts($helpTexts) + { + foreach ($this->options as $option) { + if (array_key_exists($option->getId(), $helpTexts)) { + $option->setHelpText($helpTexts[$option->getId()]); + } + } + } + + /** + * Set ui definitions on multiple options + * + * @param array $uis Hash of ui definitions indexed by option id + */ + public function setUI($uis) + { + foreach ($this->options as $option) { + if (array_key_exists($option->getId(), $uis)) { + $option->setUI($uis[$option->getId()]); + } + } + } + + public function getDefinitions($deprecatedToo = false) + { + $defs = []; + foreach ($this->options as $option) { + if ($deprecatedToo || !($option->isDeprecated())) { + $defs[] = $option->getDefinition(); + } + } + return $defs; + } +} diff --git a/src/Options/QualityOption.php b/src/Options/QualityOption.php new file mode 100644 index 00000000..11f82953 --- /dev/null +++ b/src/Options/QualityOption.php @@ -0,0 +1,59 @@ + + * @since Class available since Release 2.0.0 + */ +class QualityOption extends Option +{ + protected $typeId = 'int'; + protected $schemaType = ['integer', 'string']; + + public function __construct($id, $defaultValue) + { + parent::__construct($id, $defaultValue); + } + + public function check() + { + $value = $this->getValue(); + if (gettype($value) == 'string') { + if ($value != 'auto') { + throw new InvalidOptionValueException( + 'The "quality" option must be either "auto" or a number between 0-100. ' . + 'A string, different from "auto" was given' + ); + } + } elseif (gettype($value) == 'integer') { + if (($value < 0) || ($value > 100)) { + throw new InvalidOptionValueException( + 'The "quality" option must be either "auto" or a number between 0-100. ' . + 'The number you provided (' . strval($value) . ') is out of range.' + ); + } + } else { + throw new InvalidOptionValueException( + 'The "quality" option must be either "auto" or an integer. ' . + 'You however provided a value of type: ' . gettype($value) + ); + } + } + + public function getValueForPrint() + { + if (gettype($this->getValue()) == 'string') { + return '"' . $this->getValue() . '"'; + } + return $this->getValue(); + } +} diff --git a/src/Options/SensitiveArrayOption.php b/src/Options/SensitiveArrayOption.php new file mode 100644 index 00000000..1d36cdc6 --- /dev/null +++ b/src/Options/SensitiveArrayOption.php @@ -0,0 +1,39 @@ + + * @since Class available since Release 2.0.0 + */ +class SensitiveArrayOption extends ArrayOption +{ + + public function check() + { + parent::check(); + } + + public function getValueForPrint() + { + if (count($this->getValue()) == 0) { + return '(empty array)'; + } else { + return '(array of ' . count($this->getValue()) . ' items)'; + } + //return '*****'; + } + + public function getDefinition() + { + $obj = parent::getDefinition(); + $obj['sensitive'] = true; + return $obj; + } +} diff --git a/src/Options/SensitiveStringOption.php b/src/Options/SensitiveStringOption.php new file mode 100644 index 00000000..cba7c0ff --- /dev/null +++ b/src/Options/SensitiveStringOption.php @@ -0,0 +1,42 @@ + + * @since Class available since Release 2.0.0 + */ +class SensitiveStringOption extends StringOption +{ + + public function __construct($id, $defaultValue, $enum = null) + { + parent::__construct($id, $defaultValue, $enum); + } + + public function check() + { + parent::check(); + } + + public function getValueForPrint() + { + if (strlen($this->getValue()) == 0) { + return '""'; + } + return '*****'; + } + + public function getDefinition() + { + $obj = parent::getDefinition(); + $obj['sensitive'] = true; + return $obj; + } +} diff --git a/src/Options/StringOption.php b/src/Options/StringOption.php new file mode 100644 index 00000000..bacc7ded --- /dev/null +++ b/src/Options/StringOption.php @@ -0,0 +1,55 @@ + + * @since Class available since Release 2.0.0 + */ +class StringOption extends Option +{ + + protected $typeId = 'string'; + protected $enum; + protected $schemaType = ['string']; + + public function __construct($id, $defaultValue, $enum = null) + { + $this->enum = $enum; + parent::__construct($id, $defaultValue); + } + + public function check() + { + $this->checkType('string'); + + if (!is_null($this->enum) && (!in_array($this->getValue(), $this->enum))) { + throw new InvalidOptionValueException( + '"' . $this->id . '" option must be on of these values: ' . + '[' . implode(', ', $this->enum) . ']. ' . + 'It was however set to: "' . $this->getValue() . '"' + ); + } + } + + public function getValueForPrint() + { + return '"' . $this->getValue() . '"'; + } + + public function getDefinition() + { + $obj = parent::getDefinition(); + $obj['sensitive'] = false; + if (!is_null($this->enum)) { + $obj['options'] = $this->enum; + } + return $obj; + } +} diff --git a/src/Serve/Exceptions/ServeFailedException.php b/src/Serve/Exceptions/ServeFailedException.php new file mode 100644 index 00000000..3d9c2e72 --- /dev/null +++ b/src/Serve/Exceptions/ServeFailedException.php @@ -0,0 +1,10 @@ + + * @since Class available since Release 2.0.0 + */ +class Header +{ + /** + * Convenience function for adding header (append). + * + * @param string $header The header to add. + * @return void + */ + public static function addHeader($header) + { + header($header, false); + } + + /** + * Convenience function for replacing header. + * + * @param string $header The header to set. + * @return void + */ + public static function setHeader($header) + { + header($header, true); + } + + /** + * Add log header and optionally send it to a logger as well. + * + * @param string $msg Message to add to "X-WebP-Convert-Log" header + * @param \WebPConvert\Loggers\BaseLogger $logger (optional) + * @return void + */ + public static function addLogHeader($msg, $logger = null) + { + self::addHeader('X-WebP-Convert-Log: ' . $msg); + if (!is_null($logger)) { + $logger->logLn($msg); + } + } +} diff --git a/src/Serve/Report.php b/src/Serve/Report.php index 8335873f..0cc1b6c7 100644 --- a/src/Serve/Report.php +++ b/src/Serve/Report.php @@ -1,134 +1,22 @@ + * @since Class available since Release 2.0.0 + */ class Report { - - /** - * Input: We have a converter array where the options are defined - * Output: the converter array is "flattened" to be just names. - * and the options have been moved to the "converter-options" option. - */ - public static function flattenConvertersArray($options) - { - // TODO: If there are more of the same converters, - // they should be added as ie 'wpc-2', 'wpc-3', etc - - $result = $options; - $result['converters'] = []; - foreach ($options['converters'] as $converter) { - if (is_array($converter)) { - $converterName = $converter['converter']; - if (!isset($options['converter-options'][$converterName])) { - if (isset($converter['options'])) { - if (!isset($result['converter-options'])) { - $result['converter-options'] = []; - } - $result['converter-options'][$converterName] = $converter['options']; - } - } - $result['converters'][] = $converterName; - } else { - $result['converters'][] = $converter; - } - } - return $result; - } - - /* Hides sensitive options */ - public static function getPrintableOptions($options) - { - $printable_options = []; - - // (psst: the is_callable check is needed in order to work with WebPConvert v1.0) - if (is_callable('ConverterHelper', 'getClassNameOfConverter')) { - $printable_options = self::flattenConvertersArray($options); - if (isset($printable_options['converter-options'])) { - foreach ($printable_options['converter-options'] as $converterName => &$converterOptions) { - $className = ConverterHelper::getClassNameOfConverter($converterName); - - // (pstt: the isset check is needed in order to work with WebPConvert v1.0) - if (isset($className::$extraOptions)) { - foreach ($className::$extraOptions as $extraOption) { - if ($extraOption['sensitive']) { - if (isset($converterOptions[$extraOption['name']])) { - $converterOptions[$extraOption['name']] = '*******'; - } - } - } - } - } - } - } - return $printable_options; - } - - public static function getPrintableOptionsAsString($options, $glue = '. ') - { - $optionsForPrint = []; - foreach (self::getPrintableOptions($options) as $optionName => $optionValue) { - $printValue = ''; - if ($optionName == 'converter-options') { - $converterNames = []; - $extraConvertOptions = $optionValue; - //print_r($extraConvertOptions); - /* - foreach ($optionValue as $converterName => $converterOptions) { - - if (is_array($converter)) { - $converterName = $converter['converter']; - if (isset($converter['options'])) { - $extraConvertOptions[$converter['converter']] = $converter['options']; - } - } else { - $converterName = $converter; - } - $converterNames[] = $converterName; - }*/ - $glueMe = []; - foreach ($extraConvertOptions as $converter => $extraOptions) { - $opt = []; - foreach ($extraOptions as $oName => $oValue) { - $opt[] = $oName . ':"' . $oValue . '"'; - } - $glueMe[] = '(' . $converter . ': (' . implode($opt, ', ') . '))'; - } - $printValue = implode(',', $glueMe); - } else { - switch (gettype($optionValue)) { - case 'boolean': - if ($optionValue === true) { - $printValue = 'true'; - } elseif ($optionValue === false) { - $printValue = 'false'; - } - break; - case 'string': - $printValue = '"' . $optionValue . '"'; - break; - case 'array': - $printValue = implode(', ', $optionValue); - break; - case 'integer': - $printValue = $optionValue; - break; - default: - $printValue = $optionValue; - } - } - $optionsForPrint[] = $optionName . ': ' . $printValue; - } - return implode($glue, $optionsForPrint); - } - public static function convertAndReport($source, $destination, $options) { + InputValidator::checkSourceAndDestination($source, $destination); ?> @@ -142,42 +30,21 @@ function showOptions(elToHide) { - - - - - - + +
source:
destination:
options: - click to see - - - -
source:
destination:

getMessage(); - echo '' . $msg . ''; - exit; - } - if ($success) { - //echo 'ok'; - } else { - echo 'Conversion failed. None of the tried converters are operational'; + //echo '

Rethrowing exception for your convenience

'; + //throw ($e); } ?> diff --git a/src/Serve/ServeBase.php b/src/Serve/ServeBase.php deleted file mode 100644 index dbede5ce..00000000 --- a/src/Serve/ServeBase.php +++ /dev/null @@ -1,214 +0,0 @@ -source = $source; - $this->destination = $destination; - $this->options = array_merge(self::$defaultOptions, $options); - - $this->setErrorReporting(); - } - - public static $defaultOptions = [ - 'add-content-type-header' => true, - 'add-vary-header' => true, - 'add-x-header-status' => true, - 'add-x-header-options' => false, - 'aboutToServeImageCallBack' => null, - 'aboutToPerformFailAction' => null, - 'cache-control-header' => 'public, max-age=86400', - 'converters' => ['cwebp', 'gd', 'imagick'], - 'error-reporting' => 'auto', - 'fail' => 'original', - 'fail-when-original-unavailable' => '404', - 'reconvert' => false, - 'serve-original' => false, - 'show-report' => false, - ]; - - protected function setErrorReporting() - { - if (($this->options['error-reporting'] === true) || - (($this->options['error-reporting'] === 'auto') && ($this->options['show-report'] === true)) - ) { - error_reporting(E_ALL); - ini_set('display_errors', 'On'); - } elseif (($this->options['error-reporting'] === false) || - (($this->options['error-reporting'] === 'auto') && ($this->options['show-report'] === false)) - ) { - error_reporting(0); - ini_set('display_errors', 'Off'); - } - } - - protected function header($header, $replace = true) - { - header($header, $replace); - } - - public function addXStatusHeader($text) - { - if ($this->options['add-x-header-status']) { - $this->header('X-WebP-Convert-Status: ' . $text, true); - } - } - - public function addVaryHeader() - { - if ($this->options['add-vary-header']) { - $this->header('Vary: Accept'); - } - } - - public function addContentTypeHeader($cType) - { - if ($this->options['add-content-type-header']) { - $this->header('Content-type: ' . $cType); - } - } - - public function addCacheControlHeader() - { - if (!empty($this->options['cache-control-header'])) { - $this->header('Cache-Control: ' . $this->options['cache-control-header'], true); - } - } - - public function serveExisting() - { - if (!$this->callAboutToServeImageCallBack('destination')) { - return; - } - - $this->addXStatusHeader('Serving existing converted image'); - $this->addVaryHeader(); - $this->addContentTypeHeader('image/webp'); - $this->addCacheControlHeader(); - - if (@readfile($this->destination) === false) { - $this->header('X-WebP-Convert-Error: Could not read file'); - return false; - } - return true; - } - - /** - * Called immidiately before serving image (either original, already converted or fresh) - * $whatToServe can be 'source' | 'destination' | 'fresh-conversion' - * $whyServingThis can be: - * for 'source': - * - "explicitly-told-to" (when the "original" option is set) - * - "source-lighter" (when original image is actually smaller than the converted) - * for 'fresh-conversion': - * - "explicitly-told-to" (when the "reconvert" option is set) - * - "source-modified" (when source is newer than existing) - * - "no-existing" (when there is no existing at the destination) - * for 'destination': - * - "no-reason-not-to" (it is lighter than source, its not older, - * and we were not told to do otherwise) - */ - protected function callAboutToServeImageCallBack($whatToServe) - { - if (!isset($this->options['aboutToServeImageCallBack'])) { - return true; - } - $result = call_user_func( - $this->options['aboutToServeImageCallBack'], - $whatToServe, - $this->whyServingThis, - $this - ); - return ($result !== false); - } - - /** - * Decides what to serve. - * Returns array. First item is what to do, second is additional info. - * First item can be one of these: - * - "destination" (serve existing converted image at the destination path) - * - "no-reason-not-to" - * - "source" - * - "explicitly-told-to" - * - "source-lighter" - * - "fresh-conversion" (note: this may still fail) - * - "explicitly-told-to" - * - "source-modified" - * - "no-existing" - * - "fail" - * - "Missing destination argument" - * - "critical-fail" (a failure where the source file cannot be served) - * - "Missing source argument" - * - "Source file was not found!" - * - "report" - */ - public function decideWhatToServe() - { - $decisionArr = $this->doDecideWhatToServe(); - $this->whatToServe = $decisionArr[0]; - $this->whyServingThis = $decisionArr[1]; - } - - private function doDecideWhatToServe() - { - if (empty($this->source)) { - return ['critical-fail', 'Missing source argument']; - } - if (@!file_exists($this->source)) { - return ['critical-fail', 'Source file was not found!']; - } - if (empty($this->destination)) { - return ['fail', 'Missing destination argument']; - } - if ($this->options['show-report']) { - return ['report', '']; - } - if ($this->options['serve-original']) { - return ['source', 'explicitly-told-to']; - } - if ($this->options['reconvert']) { - return ['fresh-conversion', 'explicitly-told-to']; - } - - if (@file_exists($this->destination)) { - // Reconvert if source file is newer than destination - $timestampSource = @filemtime($this->source); - $timestampDestination = @filemtime($this->destination); - if (($timestampSource !== false) && - ($timestampDestination !== false) && - ($timestampSource > $timestampDestination)) { - return ['fresh-conversion', 'source-modified']; - } - - // Serve source if it is smaller than destination - $filesizeDestination = @filesize($this->destination); - $filesizeSource = @filesize($this->source); - if (($filesizeSource !== false) && - ($filesizeDestination !== false) && - ($filesizeDestination > $filesizeSource)) { - return ['source', 'source-lighter']; - } - - // Destination exists, and there is no reason left not to serve it - return ['destination', 'no-reason-not-to']; - } else { - return ['fresh-conversion', 'no-existing']; - } - } -} diff --git a/src/Serve/ServeConverted.php b/src/Serve/ServeConverted.php deleted file mode 100644 index 0e1fd77f..00000000 --- a/src/Serve/ServeConverted.php +++ /dev/null @@ -1,308 +0,0 @@ -options['add-x-header-options']) { - $this->header('X-WebP-Convert-Options:' . Report::getPrintableOptionsAsString($this->options)); - } - } - - private function addHeadersPreventingCaching() - { - $this->header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); - $this->header("Cache-Control: post-check=0, pre-check=0", false); - $this->header("Pragma: no-cache"); - } - - public function serve404() - { - $protocol = isset($_SERVER["SERVER_PROTOCOL"]) ? $_SERVER["SERVER_PROTOCOL"] : 'HTTP/1.0'; - $this->header($protocol . " 404 Not Found"); - } - - public function serveOriginal() - { - if (!$this->callAboutToServeImageCallBack('source')) { - return true; // we shall not trigger the fail callback - } - - if ($this->options['add-content-type-header']) { - $arr = explode('.', $this->source); - $ext = array_pop($arr); - switch (strtolower($ext)) { - case 'jpg': - case 'jpeg': - $this->header('Content-type: image/jpeg'); - break; - case 'png': - $this->header('Content-type: image/png'); - break; - } - } - - $this->addVaryHeader(); - - if ($this->whyServingThis == 'source-lighter') { - $this->addCacheControlHeader(); - } else { - $this->addHeadersPreventingCaching(); - } - - if (@readfile($this->source) === false) { - $this->header('X-WebP-Convert: Could not read file'); - return false; - } - return true; - } - - public function serveFreshlyConverted() - { - - $criticalFail = false; - $success = false; - $bufferLogger = new BufferLogger(); - - try { - $success = WebPConvert::convert($this->source, $this->destination, $this->options, $bufferLogger); - - if ($success) { - // Serve source if it is smaller than destination - $filesizeDestination = @filesize($this->destination); - $filesizeSource = @filesize($this->source); - if (($filesizeSource !== false) && - ($filesizeDestination !== false) && - ($filesizeDestination > $filesizeSource)) { - $this->whatToServe = 'original'; - $this->whyServingThis = 'source-lighter'; - return $this->serveOriginal(); - } - - if (!$this->callAboutToServeImageCallBack('fresh-conversion')) { - return; - } - - if ($this->options['add-content-type-header']) { - $this->header('Content-type: image/webp'); - } - if ($this->whyServingThis == 'explicitly-told-to') { - $this->addXStatusHeader( - 'Serving freshly converted image (was explicitly told to reconvert)' - ); - } elseif ($this->whyServingThis == 'source-modified') { - $this->addXStatusHeader( - 'Serving freshly converted image (the original had changed)' - ); - } elseif ($this->whyServingThis == 'no-existing') { - $this->addXStatusHeader( - 'Serving freshly converted image (there were no existing to serve)' - ); - } else { - $this->addXStatusHeader( - 'Serving freshly converted image (dont know why!)' - ); - } - - if ($this->options['add-vary-header']) { - $this->header('Vary: Accept'); - } - - if ($this->whyServingThis == 'no-existing') { - $this->addCacheControlHeader(); - } else { - $this->addHeadersPreventingCaching(); - } - - // Should we add Content-Length header? - // $this->header('Content-Length: ' . filesize($file)); - if (@readfile($this->destination)) { - return true; - } else { - $this->fail('Error', 'could not read the freshly converted file'); - return false; - } - } else { - $description = 'No converters are operational'; - $msg = ''; - } - } catch (\WebPConvert\Exceptions\InvalidFileExtensionException $e) { - $criticalFail = true; - $description = 'Invalid file extension'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\TargetNotFoundException $e) { - $criticalFail = true; - $description = 'Source file not found'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Converters\Exceptions\ConverterFailedException $e) { - // No converters could convert the image. At least one converter failed, even though it appears to be - // operational - $description = 'No converters could convert the image'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - // (no converters could convert the image. At least one converter declined - $description = 'No converters could/wanted to convert the image'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\ConverterNotFoundException $e) { - $description = 'A converter was not found!'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\CreateDestinationFileException $e) { - $description = 'Cannot create destination file'; - $msg = $e->getMessage(); - } catch (\WebPConvert\Exceptions\CreateDestinationFolderException $e) { - $description = 'Cannot create destination folder'; - $msg = $e->getMessage(); - } catch (\Exception $e) { - $description = 'An unanticipated exception was thrown'; - $msg = $e->getMessage(); - } - - // Next line is commented out, because we need to be absolute sure that the details does not violate syntax - // We could either try to filter it, or we could change WebPConvert, such that it only provides safe texts. - // $this->header('X-WebP-Convert-And-Serve-Details: ' . $bufferLogger->getText()); - - $this->fail('Conversion failed', $description, $criticalFail); - return false; - //echo '

This is how conversion process went:

' . $bufferLogger->getHtml(); - } - - protected function serveErrorMessageImage($msg) - { - // Generate image containing error message - if ($this->options['add-content-type-header']) { - $this->header('Content-type: image/gif'); - } - - // TODO: handle if this fails... - $image = imagecreatetruecolor(620, 200); - imagestring($image, 1, 5, 5, $msg, imagecolorallocate($image, 233, 214, 291)); - // echo imagewebp($image); - echo imagegif($image); - imagedestroy($image); - } - - protected function fail($title, $description, $critical = false) - { - $action = $critical ? $this->options['fail-when-original-unavailable'] : $this->options['fail']; - - if (isset($this->options['aboutToPerformFailActionCallback'])) { - if (call_user_func( - $this->options['aboutToPerformFailActionCallback'], - $title, - $description, - $action, - $this - ) === false) { - return; - } - } - - $this->addXStatusHeader('Failed (' . $description . ')'); - - $this->addHeadersPreventingCaching(); - - - $title = 'Conversion failed'; - switch ($action) { - case 'serve-original': - if (!$this->serveOriginal()) { - $this->serve404(); - }; - break; - case '404': - $this->serve404(); - break; - case 'report-as-image': - // todo: handle if this fails - self::serveErrorMessageImage($title . '. ' . $description); - break; - case 'report': - echo '

' . $title . '

' . $description; - break; - } - } - - protected function criticalFail($title, $description) - { - return $this->fail($title, $description, true); - } - - /** - * Serve the thing specified in $whatToServe and $whyServingThis - * These are first set my the decideWhatToServe() method, but may later change, if a fresh - * conversion is made - */ - public function serve() - { - - //$this->addXOptionsHeader(); - - switch ($this->whatToServe) { - case 'destination': - return $this->serveExisting(); - case 'source': - if ($this->whyServingThis == 'explicitly-told-to') { - $this->addXStatusHeader( - 'Serving original image (was explicitly told to)' - ); - } else { - $this->addXStatusHeader( - 'Serving original image (it is smaller than the already converted)' - ); - } - if (!$this->serveOriginal()) { - $this->criticalFail('Error', 'could not serve original'); - return false; - } - return true; - case 'fresh-conversion': - return $this->serveFreshlyConverted(); - break; - case 'critical-fail': - $this->criticalFail('Error', $this->whyServingThis); - return false; - case 'fail': - $this->fail('Error', $this->whyServingThis); - return false; - case 'report': - $this->addXStatusHeader('Reporting...'); - Report::convertAndReport($this->source, $this->destination, $this->options); - return true; // yeah, lets say that a report is always a success, even if conversion is a failure - } - } - - public function decideWhatToServeAndServeIt() - { - $this->decideWhatToServe(); - return $this->serve(); - } - - /** - * Main method - */ - public static function serveConverted($source, $destination, $options) - { - if (isset($options['fail']) && ($options['fail'] == 'original')) { - $options['fail'] = 'serve-original'; - } - // For backward compatability: - if (isset($options['critical-fail']) && !isset($options['fail-when-original-unavailable'])) { - $options['fail-when-original-unavailable'] = $options['critical-fail']; - } - - $cs = new static($source, $destination, $options); - - return $cs->decideWhatToServeAndServeIt(); - } -} diff --git a/src/Serve/ServeConvertedWebP.php b/src/Serve/ServeConvertedWebP.php new file mode 100644 index 00000000..d123d300 --- /dev/null +++ b/src/Serve/ServeConvertedWebP.php @@ -0,0 +1,216 @@ + + * @since Class available since Release 2.0.0 + */ +class ServeConvertedWebP +{ + + /** + * Process options. + * + * @throws \WebPConvert\Options\Exceptions\InvalidOptionTypeException If the type of an option is invalid + * @throws \WebPConvert\Options\Exceptions\InvalidOptionValueException If the value of an option is invalid + * @param array $options + */ + private static function processOptions($options) + { + $options2 = new Options(); + $options2->addOptions( + new BooleanOption('reconvert', false), + new BooleanOption('serve-original', false), + new BooleanOption('show-report', false), + new BooleanOption('suppress-warnings', true), + new BooleanOption('redirect-to-self-instead-of-serving', false), + new ArrayOption('serve-image', []), + new SensitiveArrayOption('convert', []) + ); + foreach ($options as $optionId => $optionValue) { + $options2->setOrCreateOption($optionId, $optionValue); + } + $options2->check(); + return $options2->getOptions(); + } + + /** + * Serve original file (source). + * + * @param string $source path to source file + * @param array $serveImageOptions (optional) options for serving an image + * Supported options: + * - All options supported by ServeFile::serve() + * @throws ServeFailedException if source is not an image or mime type cannot be determined + * @return void + */ + public static function serveOriginal($source, $serveImageOptions = []) + { + // PS: We do not use InputValidator::checkSource($source) because we want to be + // a bit more lenient here and allow any image to be served (even though ie webp does not + // qualify for being used as a source when converting) + + // Check that the filename is ok (no control chars, streamwrappers), and that the file exists + // and is not a dir + PathChecker::checkSourcePath($source); + + $contentType = MimeType::getMimeTypeDetectionResult($source); + if (is_null($contentType)) { + throw new ServeFailedException('Rejecting to serve original (mime type cannot be determined)'); + } elseif ($contentType === false) { + throw new ServeFailedException('Rejecting to serve original (it is not an image)'); + } else { + ServeFile::serve($source, $contentType, $serveImageOptions); + } + } + + /** + * Serve destination file. + * + * TODO: SHould this really be public? + * + * @param string $destination path to destination file + * @param array $serveImageOptions (optional) options for serving (such as which headers to add) + * Supported options: + * - All options supported by ServeFile::serve() + * @return void + */ + public static function serveDestination($destination, $serveImageOptions = []) + { + InputValidator::checkDestination($destination); + ServeFile::serve($destination, 'image/webp', $serveImageOptions); + } + + + public static function warningHandler() + { + // do nothing! - as we do not return anything, the warning is suppressed + } + + /** + * Serve converted webp. + * + * Serve a converted webp. If a file already exists at the destination, that is served (unless it is + * older than the source - in that case a fresh conversion will be made, or the file at the destination + * is larger than the source - in that case the source is served). Some options may alter this logic. + * In case no file exists at the destination, a fresh conversion is made and served. + * + * @param string $source path to source file + * @param string $destination path to destination + * @param array $options (optional) options for serving/converting + * Supported options: + * 'show-report' => (boolean) If true, the decision will always be 'report' + * 'serve-original' => (boolean) If true, the decision will be 'source' (unless above option is set) + * 'reconvert ' => (boolean) If true, the decision will be 'fresh-conversion' (unless one of the + * above options is set) + * - All options supported by WebPConvert::convert() + * - All options supported by ServeFile::serve() + * @param \WebPConvert\Loggers\BaseLogger $serveLogger (optional) + * @param \WebPConvert\Loggers\BaseLogger $convertLogger (optional) + * + * @throws \WebPConvert\Exceptions\WebPConvertException If something went wrong. + * @return void + */ + public static function serve($source, $destination, $options = [], $serveLogger = null, $convertLogger = null) + { + InputValidator::checkSourceAndDestination($source, $destination); + + $options = self::processOptions($options); + + if ($options['suppress-warnings']) { + set_error_handler( + array('\\WebPConvert\\Serve\\ServeConvertedWebP', "warningHandler"), + E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE + ); + } + + + //$options = array_merge(self::$defaultOptions, $options); + + // Step 1: Is there a file at the destination? If not, trigger conversion + // However 1: if "show-report" option is set, serve the report instead + // However 2: "reconvert" option should also trigger conversion + if ($options['show-report']) { + Header::addLogHeader('Showing report', $serveLogger); + Report::convertAndReport($source, $destination, $options); + return; + } + + if (!@file_exists($destination)) { + Header::addLogHeader('Converting (there were no file at destination)', $serveLogger); + WebPConvert::convert($source, $destination, $options['convert'], $convertLogger); + } elseif ($options['reconvert']) { + Header::addLogHeader('Converting (told to reconvert)', $serveLogger); + WebPConvert::convert($source, $destination, $options['convert'], $convertLogger); + } else { + // Step 2: Is the destination older than the source? + // If yes, trigger conversion (deleting destination is implicit) + $timestampSource = @filemtime($source); + $timestampDestination = @filemtime($destination); + if (($timestampSource !== false) && + ($timestampDestination !== false) && + ($timestampSource > $timestampDestination)) { + Header::addLogHeader('Converting (destination was older than the source)', $serveLogger); + WebPConvert::convert($source, $destination, $options['convert'], $convertLogger); + } + } + + // Step 3: Serve the smallest file (destination or source) + // However, first check if 'serve-original' is set + if ($options['serve-original']) { + Header::addLogHeader('Serving original (told to)', $serveLogger); + self::serveOriginal($source, $options['serve-image']); + return; + } + + if ($options['redirect-to-self-instead-of-serving']) { + Header::addLogHeader( + 'Redirecting to self! ' . + '(hope you got redirection to existing webps set up, otherwise you will get a loop!)', + $serveLogger + ); + header('Location: ?fresh', 302); + return; + } + + $filesizeDestination = @filesize($destination); + $filesizeSource = @filesize($source); + if (($filesizeSource !== false) && + ($filesizeDestination !== false) && + ($filesizeDestination > $filesizeSource)) { + Header::addLogHeader('Serving original (it is smaller)', $serveLogger); + self::serveOriginal($source, $options['serve-image']); + return; + } + + Header::addLogHeader('Serving converted file', $serveLogger); + self::serveDestination($destination, $options['serve-image']); + } +} diff --git a/src/Serve/ServeConvertedWebPWithErrorHandling.php b/src/Serve/ServeConvertedWebPWithErrorHandling.php new file mode 100644 index 00000000..883a622d --- /dev/null +++ b/src/Serve/ServeConvertedWebPWithErrorHandling.php @@ -0,0 +1,160 @@ + + * @since Class available since Release 2.0.0 + */ +class ServeConvertedWebPWithErrorHandling +{ + + /** + * Process options. + * + * @throws \WebPConvert\Options\Exceptions\InvalidOptionTypeException If the type of an option is invalid + * @throws \WebPConvert\Options\Exceptions\InvalidOptionValueException If the value of an option is invalid + * @param array $options + */ + private static function processOptions($options) + { + $options2 = new Options(); + $options2->addOptions( + new StringOption('fail', 'original', ['original', '404', 'throw', 'report']), + new StringOption('fail-when-fail-fails', 'throw', ['original', '404', 'throw', 'report']) + ); + foreach ($options as $optionId => $optionValue) { + $options2->setOrCreateOption($optionId, $optionValue); + } + $options2->check(); + return $options2->getOptions(); + } + + /** + * Add headers for preventing caching. + * + * @return void + */ + private static function addHeadersPreventingCaching() + { + Header::setHeader("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); + Header::addHeader("Cache-Control: post-check=0, pre-check=0"); + Header::setHeader("Pragma: no-cache"); + } + + /** + * Perform fail action. + * + * @param string $fail Action to perform (original | 404 | report) + * @param string $failIfFailFails Action to perform if $fail action fails + * @param string $source path to source file + * @param string $destination path to destination + * @param array $options (optional) options for serving/converting + * @param \Exception $e exception that was thrown when trying to serve + * @param string $serveClass (optional) Full class name to a class that has a serveOriginal() method + * @return void + */ + public static function performFailAction($fail, $failIfFailFails, $source, $destination, $options, $e, $serveClass) + { + self::addHeadersPreventingCaching(); + + Header::addLogHeader('Performing fail action: ' . $fail); + + switch ($fail) { + case 'original': + try { + //ServeConvertedWebP::serveOriginal($source, $options); + call_user_func($serveClass . '::serveOriginal', $source, $options); + } catch (\Exception $e) { + self::performFailAction($failIfFailFails, '404', $source, $destination, $options, $e, $serveClass); + } + break; + + case '404': + $protocol = isset($_SERVER["SERVER_PROTOCOL"]) ? $_SERVER["SERVER_PROTOCOL"] : 'HTTP/1.0'; + Header::setHeader($protocol . " 404 Not Found"); + break; + + case 'report': + $options['show-report'] = true; + Report::convertAndReport($source, $destination, $options); + break; + + case 'throw': + throw $e; + //break; commented out as phpstan complains. But do something else complain now? + + case 'report-as-image': + // TODO: Implement or discard ? + break; + } + } + + /** + * Serve webp image and handle errors as specified in the 'fail' option. + * + * This method basically wraps ServeConvertedWebP:serve in order to provide exception handling. + * The error handling is set with the 'fail' option and can be either '404', 'original' or 'report'. + * If set to '404', errors results in 404 Not Found headers being issued. If set to 'original', an + * error results in the original being served. + * Look up the ServeConvertedWebP:serve method to learn more. + * + * @param string $source path to source file + * @param string $destination path to destination + * @param array $options (optional) options for serving/converting + * Supported options: + * - 'fail' => (string) Action to take on failure (404 | original | report | throw). + * "404" or "throw" is recommended for development and "original" is recommended for production. + * Default: 'original'. + * - 'fail-when-fail-fails' => (string) Action to take if fail action also fails. Default: '404'. + * - All options supported by WebPConvert::convert() + * - All options supported by ServeFile::serve() + * - All options supported by DecideWhatToServe::decide) + * @param \WebPConvert\Loggers\BaseLogger $serveLogger (optional) + * @param \WebPConvert\Loggers\BaseLogger $convertLogger (optional) + * @param string $serveClass (optional) Full class name to a class that has a serve() method and a + * serveOriginal() method + * @return void + */ + public static function serve( + $source, + $destination, + $options = [], + $serveLogger = null, + $convertLogger = null, + $serveClass = '\\WebPConvert\\Serve\\ServeConvertedWebP' + ) { + + $options = self::processOptions($options); + try { + InputValidator::checkSourceAndDestination($source, $destination); + //ServeConvertedWebP::serve($source, $destination, $options, $serveLogger); + call_user_func($serveClass . '::serve', $source, $destination, $options, $serveLogger, $convertLogger); + } catch (\Exception $e) { + if ($e instanceof \WebPConvert\Exceptions\WebPConvertException) { + Header::addLogHeader($e->getShortMessage(), $serveLogger); + } + + self::performFailAction( + $options['fail'], + $options['fail-when-fail-fails'], + $source, + $destination, + $options, + $e, + $serveClass + ); + } + } +} diff --git a/src/Serve/ServeExistingOrHandOver.php b/src/Serve/ServeExistingOrHandOver.php deleted file mode 100644 index 3bb18605..00000000 --- a/src/Serve/ServeExistingOrHandOver.php +++ /dev/null @@ -1,40 +0,0 @@ -decideWhatToServe(); - if ($server->whatToServe == 'destination') { - return $server->serveExisting(); - } else { - // Load extra php classes, if told to - if (isset($options['require-for-conversion'])) { - require($options['require-for-conversion']); - } - ServeConverted::serveConverted($source, $destination, $options); - } - } -} diff --git a/src/Serve/ServeFile.php b/src/Serve/ServeFile.php new file mode 100644 index 00000000..726087c1 --- /dev/null +++ b/src/Serve/ServeFile.php @@ -0,0 +1,133 @@ + + * @since Class available since Release 2.0.0 + */ +class ServeFile +{ + + /** + * Process options. + * + * @throws \WebPConvert\Options\Exceptions\InvalidOptionTypeException If the type of an option is invalid + * @throws \WebPConvert\Options\Exceptions\InvalidOptionValueException If the value of an option is invalid + * @param array $options + */ + private static function processOptions($options) + { + $options2 = new Options(); + $options2->addOptions( + new ArrayOption('headers', []), + new StringOption('cache-control-header', 'public, max-age=31536000') + ); + foreach ($options as $optionId => $optionValue) { + $options2->setOrCreateOption($optionId, $optionValue); + } + $options2->check(); + $options = $options2->getOptions(); + + // headers option + // -------------- + + $headerOptions = new Options(); + $headerOptions->addOptions( + new BooleanOption('cache-control', false), + new BooleanOption('content-length', true), + new BooleanOption('content-type', true), + new BooleanOption('expires', false), + new BooleanOption('last-modified', true), + new BooleanOption('vary-accept', false) + ); + foreach ($options['headers'] as $optionId => $optionValue) { + $headerOptions->setOrCreateOption($optionId, $optionValue); + } + $options['headers'] = $headerOptions->getOptions(); + return $options; + } + + /** + * Serve existing file. + * + * @param string $filename File to serve (absolute path) + * @param string $contentType Content-type (used to set header). + * Only used when the "set-content-type-header" option is set. + * Set to ie "image/jpeg" for serving jpeg file. + * @param array $options Array of named options (optional). + * Supported options: + * 'add-vary-accept-header' => (boolean) Whether to add *Vary: Accept* header or not. Default: true. + * 'set-content-type-header' => (boolean) Whether to set *Content-Type* header or not. Default: true. + * 'set-last-modified-header' => (boolean) Whether to set *Last-Modified* header or not. Default: true. + * 'set-cache-control-header' => (boolean) Whether to set *Cache-Control* header or not. Default: true. + * 'cache-control-header' => string Cache control header. Default: "public, max-age=86400" + * + * @throws ServeFailedException if serving failed + * @return void + */ + public static function serve($filename, $contentType, $options = []) + { + // Check mimetype - this also checks that path is secure and file exists + InputValidator::checkMimeType($filename, [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif' + ]); + + /* + if (!file_exists($filename)) { + Header::addHeader('X-WebP-Convert-Error: Could not read file'); + throw new ServeFailedException('Could not read file'); + }*/ + + $options = self::processOptions($options); + + if ($options['headers']['last-modified']) { + Header::setHeader("Last-Modified: " . gmdate("D, d M Y H:i:s", @filemtime($filename)) . " GMT"); + } + + if ($options['headers']['content-type']) { + Header::setHeader('Content-Type: ' . $contentType); + } + + if ($options['headers']['vary-accept']) { + Header::addHeader('Vary: Accept'); + } + + if (!empty($options['cache-control-header'])) { + if ($options['headers']['cache-control']) { + Header::setHeader('Cache-Control: ' . $options['cache-control-header']); + } + if ($options['headers']['expires']) { + // Add exprires header too (#126) + // Check string for something like this: max-age:86400 + if (preg_match('#max-age\\s*=\\s*(\\d*)#', $options['cache-control-header'], $matches)) { + $seconds = $matches[1]; + Header::setHeader('Expires: ' . gmdate('D, d M Y H:i:s \G\M\T', time() + intval($seconds))); + } + } + } + + if ($options['headers']['content-length']) { + Header::setHeader('Content-Length: ' . filesize($filename)); + } + + if (@readfile($filename) === false) { + Header::addHeader('X-WebP-Convert-Error: Could not read file'); + throw new ServeFailedException('Could not read file'); + } + } +} diff --git a/src/WebPConvert.php b/src/WebPConvert.php index c7d94db9..a65a6cfa 100644 --- a/src/WebPConvert.php +++ b/src/WebPConvert.php @@ -2,26 +2,158 @@ namespace WebPConvert; -use WebPConvert\Converters\ConverterHelper; -use WebPConvert\ServeExistingOrConvert; -use WebPConvert\Serve\ServeExistingOrHandOver; +//use WebPConvert\Convert\Converters\ConverterHelper; +use WebPConvert\Convert\Converters\Stack; +//use WebPConvert\Serve\ServeExistingOrHandOver; +use WebPConvert\Convert\ConverterFactory; +use WebPConvert\Options\OptionFactory; +use WebPConvert\Serve\ServeConvertedWebP; +use WebPConvert\Serve\ServeConvertedWebPWithErrorHandling; +/** + * Convert images to webp and/or serve them. + * + * This class is just a couple of convenience methods for doing conversion and/or + * serving. + * + * @package WebPConvert + * @author Bjørn Rosell + * @since Class available since Release 2.0.0 + */ class WebPConvert { - /* - @param (string) $source: Absolute path to image to be converted (no backslashes). Image must be jpeg or png - @param (string) $destination: Absolute path (no backslashes) - @param (object) $options: Array of named options, such as 'quality' and 'metadata' - */ + /** + * Convert jpeg or png into webp + * + * Convenience method for calling Stack::convert. + * + * @param string $source The image to convert (absolute,no backslashes) + * Image must be jpeg or png. + * @param string $destination Where to store the converted file (absolute path, no backslashes). + * @param array $options (optional) Array of named options + * The options are documented here: + * https://github.com/rosell-dk/webp-convert/blob/master/docs/v2.0/converting/options.md + * @param \WebPConvert\Loggers\BaseLogger $logger (optional) + * + * @throws \WebPConvert\Convert\Exceptions\ConversionFailedException in case conversion fails + * @return void + */ public static function convert($source, $destination, $options = [], $logger = null) { - return ConverterHelper::runConverterStack($source, $destination, $options, $logger); + if (isset($options['converter'])) { + $converter = $options['converter']; + unset($options['converter']); + $c = ConverterFactory::makeConverter($converter, $source, $destination, $options, $logger); + $c->doConvert(); + } else { + Stack::convert($source, $destination, $options, $logger); + } } - public static function convertAndServe($source, $destination, $options = []) + /** + * Serve webp image, converting first if neccessary. + * + * If an image already exists, it will be served, unless it is older or larger than the source. (If it is larger, + * the original is served, if it is older, the existing webp will be deleted and a fresh conversion will be made + * and served). In case of error, the action indicated in the 'fail' option will be triggered (default is to serve + * the original). Look up the ServeConvertedWebP:serve() and the ServeConvertedWebPWithErrorHandling::serve() + * methods to learn more. + * + * @param string $source path to source file + * @param string $destination path to destination + * @param array $options (optional) options for serving/converting. The options are documented in the + * ServeConvertedWebPWithErrorHandling::serve() method + * @param \WebPConvert\Loggers\BaseLogger $serveLogger (optional) + * @param \WebPConvert\Loggers\BaseLogger $convertLogger (optional) + * @return void + */ + public static function serveConverted( + $source, + $destination, + $options = [], + $serveLogger = null, + $convertLogger = null + ) { + //return ServeExistingOrHandOver::serveConverted($source, $destination, $options); + //if (isset($options['handle-errors']) && $options['handle-errors'] === true) { + if (isset($options['fail']) && ($options['fail'] != 'throw')) { + ServeConvertedWebPWithErrorHandling::serve($source, $destination, $options, $serveLogger, $convertLogger); + } else { + ServeConvertedWebP::serve($source, $destination, $options, $serveLogger, $convertLogger); + } + } + + /** + * Get ids of all converters available in webp-convert. + * + * @return array Array of ids. + */ + public static function getConverterIds() + { + $all = Stack::getAvailableConverters(); + $all[] = 'stack'; + return $all; + } + + /** + * Get option definitions for all converters + * + * Added in order to give GUI's a way to automatically adjust their setting screens. + * + * @param bool $filterOutOptionsWithoutUI If options without UI defined should be filtered out + * + * @return array Array of options definitions - ready to be json encoded, or whatever + * @since 2.8.0 + */ + public static function getConverterOptionDefinitions($filterOutOptionsWithoutUI = true) { - //return ServeExistingOrConvert::serveExistingOrConvert($source, $destination, $options); - return ServeExistingOrHandOver::serveConverted($source, $destination, $options); + $converterIds = self::getConverterIds(); + $result = []; + + $ewww = ConverterFactory::makeConverter('ewww', '', ''); + $result['general'] = $ewww->getGeneralOptionDefinitions($filterOutOptionsWithoutUI); + + $generalOptionHash = []; + $generalOptionIds = []; + foreach ($result['general'] as &$option) { + $generalOptionIds[] = $option['id']; + $option['unsupportedBy'] = []; + $generalOptionHash[$option['id']] = &$option; + } + //$result['general'] = $generalOptionIds; + array_unshift($result['general'], OptionFactory::createOption('converter', 'string', [ + 'title' => 'Converter', + 'description' => 'Conversion method. ' . + "Cwebp and vips are best. " . + 'the *magick are nearly as good, but only recent versions supports near-lossless. ' . + 'gd is poor, as it does not support any webp options. ' . + 'For full discussion, check the guide', + 'default' => 'stack', + 'enum' => $converterIds, + 'ui' => [ + 'component' => 'select', + 'links' => [ + [ + 'Guide', + 'https://github.com/rosell-dk/webp-convert/blob/master/docs/v1.3/converting/converters.md' + ] + ], + ] + ])->getDefinition()); + + $supportedBy = []; + $uniqueOptions = []; + + foreach ($converterIds as $converterId) { + $c = ConverterFactory::makeConverter($converterId, '', ''); + foreach ($c->getUnsupportedGeneralOptions() as $optionId) { + $generalOptionHash[$optionId]['unsupportedBy'][] = $converterId; + } + $optionDefinitions = $c->getUniqueOptionDefinitions($filterOutOptionsWithoutUI); + $uniqueOptions[$converterId] = $optionDefinitions; + } + $result['unique'] = $uniqueOptions; + return $result; } } diff --git a/src/require-all.inc b/src/require-all.inc deleted file mode 100644 index 4e6f165a..00000000 --- a/src/require-all.inc +++ /dev/null @@ -1,27 +0,0 @@ -objectToExposeFrom = $objectToExposeFrom; + } + + protected function bindDynamicFunctionToObjectAndCallIt($functionToBindToObject, $class = null, ...$args) + { + if (is_null($class)) { + $class = get_class($this->objectToExposeFrom); + } + //$functionNowBinded = $functionToBindToObject->bindTo($this->objectToExposeFrom, AbstractConverter::class); + $functionNowBinded = $functionToBindToObject->bindTo($this->objectToExposeFrom, $class); + //$functionNowBinded = $functionToBindToObject->bindTo($this->objectToExposeFrom, get_class($this->objectToExposeFrom)); + return $functionNowBinded(...$args); + } + + /** + * @param string $functionNameToCall + * @param string $class The class to inject into, ie a base class of the object to expose from (optional). If none is specified, it will be the class of the exposed object + */ + protected function callPrivateFunction($functionNameToCall, $class = null, ...$args) + { + self::$currentlyCalling = $functionNameToCall; + $cb = function() { + return call_user_func_array( + array($this, BaseExposer::$currentlyCalling), + func_get_args() + ); + }; + return $this->bindDynamicFunctionToObjectAndCallIt($cb, $class, ...$args); + } + + protected function callPrivateFunctionByRef($functionNameToCall, &$arg1) + { + self::$currentlyCalling = $functionNameToCall; + $cb = function(&$arg1) { + //echo 'callback...' . gettype($arg1); + return $this->{BaseExposer::$currentlyCalling}($arg1); + /* + return call_user_func_array( + array($this, BaseExposer::$currentlyCalling), + $arg1 + );*/ + }; + $class = get_class($this->objectToExposeFrom); + $functionNowBinded = $cb->bindTo($this->objectToExposeFrom, $class); + + return $functionNowBinded($arg1); + //return $this->bindDynamicFunctionToObjectAndCallIt($cb, $class, $arg1); + } + +/* work in progress + protected function callPrivateStaticFunction($functionNameToCall, $class = null) + { + self::$currentlyCalling = $functionNameToCall; + + $cb = function() { + return call_user_func_array( + array(self, BaseExposer::$currentlyCalling), + func_get_args() + ); + }; + return $this->bindDynamicFunctionToObjectAndCallIt($cb, $class); + }*/ + + + /** + * @param string $propertyToSteal + */ + protected function getPrivateProperty($propertyToSteal, $class = null) + { + self::$currentlyStealing = $propertyToSteal; + + $thief = function() { + return $this->{BaseExposer::$currentlyStealing}; + }; + + return $this->bindDynamicFunctionToObjectAndCallIt($thief, $class); + } + + /** + * @param string $propertyToSteal + */ + protected function getPrivateStaticProperty($propertyToSteal, $class = null) + { + self::$currentlyStealing = $propertyToSteal; + + $thief = function() { + $propertyName = BaseExposer::$currentlyStealing; + return static::$$propertyName; + }; + + return $this->bindDynamicFunctionToObjectAndCallIt($thief, $class); + } + + +} diff --git a/tests/CompatibleTestCase.php b/tests/CompatibleTestCase.php new file mode 100644 index 00000000..116048e6 --- /dev/null +++ b/tests/CompatibleTestCase.php @@ -0,0 +1,53 @@ +=9) && ($phpUnitMinorVersion >=1)); + */ + + if (method_exists(TestCase::class, 'assertDoesNotMatchRegularExpression')) { + $this->assertDoesNotMatchRegularExpression($arg1, $arg2); + } else { + $this->assertNotRegExp($arg1, $arg2); + } + } + + public function assertMatchesRegularExpression2($arg1, $arg2) + { + // https://stackoverflow.com/questions/4837748/how-to-detect-version-of-phpunit + + if (method_exists(TestCase::class, 'assertMatchesRegularExpression')) { + $this->assertMatchesRegularExpression($arg1, $arg2); + } else { + $this->assertRegExp($arg1, $arg2); + } + } + +} diff --git a/tests/Convert/Converters/AbstractConverterTest.php b/tests/Convert/Converters/AbstractConverterTest.php new file mode 100644 index 00000000..f38cb5dd --- /dev/null +++ b/tests/Convert/Converters/AbstractConverterTest.php @@ -0,0 +1,108 @@ +addToAssertionCount(1); + } + + public function testMimeTypeGuesser() + { + + //$this->assertEquals('image/jpeg', ExposedConverter::exposedGetMimeType(self::$imgDir . '/test.jpg')); + //$this->assertEquals('image/png', ExposedConverter::exposedGetMimeType(self::$imgDir . '/test.png')); + //$mimeTypeMaybeDetected = ExposedConverter::exposedGetMimeType(self::$imgDir . '/png-without-extension'); + + $successConverterJpeg = SuccessGuaranteedConverter::createInstance( + self::getImagePath('test.jpg'), + self::getImagePath('test.jpg.webp') + ); + $this->assertEquals('image/jpeg', $successConverterJpeg->getMimeTypeOfSource()); + + $successConverterPng = SuccessGuaranteedConverter::createInstance( + self::getImagePath('test.png'), + self::getImagePath('test.png.webp') + ); + $this->assertEquals('image/png', $successConverterPng->getMimeTypeOfSource()); + + $successConverterPngMaybeDetected = SuccessGuaranteedConverter::createInstance( + self::getImagePath('png-without-extension'), + self::getImagePath('png-without-extension.webp') + ); + + $mimeTypeMaybeDetected = $successConverterPngMaybeDetected->getMimeTypeOfSource(); + + if ($mimeTypeMaybeDetected === false) { + // It was not detected, and that is ok! + // - it is not possible to detect mime type on all platforms. In case it could not be detected, + // - and file extension could not be mapped either, the method returns false. + $this->addToAssertionCount(1); + } else { + $this->assertEquals('image/png', $mimeTypeMaybeDetected); + } + } + + public function testDefaultOptions() + { + $converter = new SuccessGuaranteedConverter( + self::getImagePath('test.jpg'), + self::getImagePath('test.jpg.webp') + ); + + $exposer = new AbstractConverterExposer($converter); + + $defaultOptions = $exposer->getOptions(); + + //$this->assertSame('auto', $defaultOptions['quality']); + $this->assertSame(75, $defaultOptions['quality']); + $this->assertSame(85, $defaultOptions['max-quality']); + $this->assertSame(75, $defaultOptions['default-quality']); + $this->assertSame('none', $defaultOptions['metadata']); + } + + + public function testOptionMerging() + { + $converter = new SuccessGuaranteedConverter( + self::getImagePath('test.jpg'), + self::getImagePath('test.webp'), + [ + 'quality' => 80 + ] + ); + + $exposer = new AbstractConverterExposer($converter); + + //$exposer->prepareOptions(); + + $mergedOptions = $exposer->getOptions(); + + $this->assertSame(80, $mergedOptions['quality']); + } +} diff --git a/tests/Convert/Converters/BaseTraits/AutoQualityTraitTest.php b/tests/Convert/Converters/BaseTraits/AutoQualityTraitTest.php new file mode 100644 index 00000000..9160331b --- /dev/null +++ b/tests/Convert/Converters/BaseTraits/AutoQualityTraitTest.php @@ -0,0 +1,190 @@ + 77, + 'auto-limit' => false, + ] + ); + + $result = $converter->getCalculatedQuality(); + $this->assertSame(77, $result); + + $this->assertFalse($converter->isQualityDetectionRequiredButFailing()); + + // Test that it is still the same (testing caching) + $this->assertFalse($converter->isQualityDetectionRequiredButFailing()); + + } + +/* + public function testAutoQualityWhenQualityCannotBeDetected() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::$imgDir . '/non-existant', + self::$imgDir . '/non-existant.webp', + [ + 'max-quality' => 80, + 'quality' => 'auto', + 'default-quality' => 70, + ] + ); + + $result = $converter->getCalculatedQuality(); + + $this->assertSame(70, $result); + }*/ + + public function testAutoQuality() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::getImagePath('small-q61.jpg'), + self::getImagePath('small-q61.jpg.webp'), + [ + 'quality' => 61, + 'auto-limit' => true, + ] + ); + + $result = $converter->getCalculatedQuality(); + + // "Cheating" a bit here... + // - If quality detection fails, it will be 61 (because default-quality is set to 61) + // - If quality detection succeeds, it will also be 61 + $this->assertSame(61, $result); + } + + public function testAutoQualityDeprecatedOptions() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::getImagePath('small-q61.jpg'), + self::getImagePath('small-q61.jpg.webp'), + [ + 'max-quality' => 80, + 'quality' => 'auto', + 'default-quality' => 61, + ] + ); + + $result = $converter->getCalculatedQuality(); + + // "Cheating" a bit here... + // - If quality detection fails, it will be 61 (because default-quality is set to 61) + // - If quality detection succeeds, it will also be 61 + $this->assertSame(61, $result); + } + + public function testAutoQualityMaxQuality() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::getImagePath('small-q61.jpg'), + self::getImagePath('small-q61.jpg.webp'), + [ + 'max-quality' => 80, + 'quality' => 'auto', + 'default-quality' => 61, + ] + ); + + //$this->assertTrue(file_exists(self::$imgDir . '/small-q61.jpg')); + //$this->assertEquals('image/jpeg', $converter->getMimeTypeOfSource()); + + $this->assertSame(61, $converter->getCalculatedQuality()); + + // Test that it is still the same (testing caching) + $this->assertSame(61, $converter->getCalculatedQuality()); + } + + public function testAutoQualityMaxQualityOnNonJpeg() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::getImagePath('test.png'), + self::getImagePath('test.png.webp'), + [ + 'max-quality' => 60, + 'quality' => 'auto', + 'default-quality' => 70, + ] + ); + + $this->assertSame(60, $converter->getCalculatedQuality()); + $this->assertFalse($converter->isQualityDetectionRequiredButFailing()); + } +/* + public function testAutoQualityOnQualityDetectionFail1() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::$imgDir . '/non-existing.jpg', + self::$imgDir . '/non-existant.webp', + [ + 'max-quality' => 70, + 'quality' => 'auto', + 'default-quality' => 60, + ] + ); + + $this->assertFalse(file_exists(self::$imgDir . '/non-existing.jpg')); + + // MimeType guesser returns false when mime type cannot be established. + $this->assertEquals(false, $converter->getMimeTypeOfSource()); + + // - so this can actually not be used for testing isQualityDetectionRequiredButFailing + + //$this->assertSame(60, $converter->getCalculatedQuality()); + //$this->assertTrue($converter->isQualityDetectionRequiredButFailing()); + } +*/ + public function testAutoQualityOnQualityDetectionFail2DeprecatedOptions() + { + $converter = SuccessGuaranteedConverter::createInstance( + self::getImagePath('text-with-jpg-extension.jpg'), + self::getImagePath('text-with-jpg-extension.jpg.webp'), + [ + 'max-quality' => 70, + 'quality' => 'auto', + 'default-quality' => 60, + ] + ); + + $this->assertFalse(file_exists(self::getImagePath('non-existing.jpg'))); + + // We are using the lenient MimeType guesser. + // So we get "image/jpeg" even though the file is not a jpeg file + $this->assertEquals('image/jpeg', $converter->getMimeTypeOfSource()); + + // Now we got a file that we should not be able to detect quality of + // lets validate that statement: + + $this->assertTrue($converter->isQualityDetectionRequiredButFailing()); + + // Test that it is still the same (testing caching) + $this->assertTrue($converter->isQualityDetectionRequiredButFailing()); + + $this->assertSame(60, $converter->getCalculatedQuality()); + } + +} diff --git a/tests/Convert/Converters/ConverterTestHelper.php b/tests/Convert/Converters/ConverterTestHelper.php new file mode 100644 index 00000000..924f8dff --- /dev/null +++ b/tests/Convert/Converters/ConverterTestHelper.php @@ -0,0 +1,207 @@ + true, + )); + $testCase->fail('The conversion should have failed, because PNG should have been skipped'); + + } catch (SystemRequirementsNotMetException $e) { + // System requirements are not met, so could not make the test + return; + } catch (ConversionSkippedException $e) { + // Yeah, this is what we wanted to test. And it went well! + $testCase->assertTrue(true); + } catch (ConversionFailedException $e) { + $testCase->fail("A ConversionFailedException was thrown (and it was not the SystemRequirementsNotMetException)"); + } catch (\Exception $e) { + $testCase->fail("An unexpected exception was thrown"); + } + } +*/ + public static function getImageFolder() + { + return realpath(__DIR__ . '/../../images'); + } + + public static function getImagePath($image) + { + return self::getImageFolder() . '/' . $image; + } + + private static function callConvert($converterClassName, $source, $destination, $converterOptions) + { + return call_user_func( + ['WebPConvert\\Convert\\Converters\\' . $converterClassName, 'convert'], + $source, + $destination, + $converterOptions + ); + //$logger + + /* + TODO: Consider using mikey179/vfsStream + https://github.com/mikey179/vfsStream + https://phpunit.de/manual/6.5/en/test-doubles.html#test-doubles.mocking-the-filesystem + */ + } + + public static function testInvalidDestinationFolder($testCase, $converterClassName, $converterOptions) + { + $testCase->expectException(CreateDestinationFolderException::class); + + try { + $source = self::getImagePath('test.jpg'); + $destination = '/you-can-delete-me/'; + $result = self::callConvert($converterClassName, $source, $destination); + } catch (ConverterNotOperationalException $e) { + // Converter not operational, and that is ok! + // We shall pretend that the expected exception was thrown, by throwing it! + throw new CreateDestinationFolderException(); + } +/* + try { + // We can only do this test, if the converter is operational. + // In order to test that, we first do a normal conversion + $source = (__DIR__ . '/../../test.jpg'); + $destination = (__DIR__ . '/../../test.webp'); + + Imagick::convert($source, $destination); + + // if we are here, it means that the converter is operational. + // Now do something that tests that the converter fails the way it should, + // when it cannot create the destination file + + $this->expectException(\WebPConvert\Convert\Exceptions\ConverterFailedException::class); + + // I here assume that no system grants write access to their root folder + // this is perhaps wrong to assume? + $destinationFolder = '/you-can-delete-me/'; + + Imagick::convert(__DIR__ . '/../test.jpg', $destinationFolder . 'you-can-delete-me.webp'); + } catch (\Exception $e) { + // its ok... + }*/ + } + + public static function testTargetNotFound($testCase, $converterClassName, $converterOptions) + { + $testCase->expectException(TargetNotFoundException::class); + + try { + $result = self::callConvert( + $converterClassName, + __DIR__ . '/i-dont-exist.jpg', + __DIR__ . '/i-dont-exist.webp', + $converterOptions + ); + } catch (ConverterNotOperationalException $e) { + // Converter not operational, and that is ok! + // We shall pretend that the expected exception was thrown, by throwing it! + throw new TargetNotFoundException(); + } + } + + /** + * Test convert. + * - It must either make a successful conversion, or throw the SystemRequirementsNotMetException + * Other exceptions are unexpected and will result in test failure + * - It must not return anything (as of 2.0, there is no return value) + * - If conversion is successful, there must be a file at the destination + */ + public static function testConvert($src, $testCase, $converterClassName, $converterOptions) + { + + try { + $source = self::getImagePath($src); + $destination = self::getImagePath($src . '.webp'); + + $result = self::callConvert($converterClassName, $source, $destination, $converterOptions); + + // Conversion was successful. + + // make sure the function did not return anything (as of 2.0) + $testCase->assertEmpty($result, 'The doActualConvert() method returned something. As of 2.0, converters should never return anything'); + + // verify that there indeed is a file + $testCase->assertTrue(file_exists($destination), 'There is not a converted file at the destinaiton'); + + } catch (ConverterNotOperationalException $e) { + // Converter not operational, and that is ok! + // (ie if system requirements are not met, or the quota of a cloud converter is used up) + + } catch (UnhandledException $e) { + // Handle the UnhandledException specially, so we can display the original error + $prevEx = $e->getPrevious(); + $testCase->fail( + 'An UnhandledException was thrown: ' . + get_class($prevEx). '. ' . + $prevEx->getMessage() . '. ' . + $prevEx->getFile() . ', line:' . $prevEx->getLine() + //'Trace:' . $prevEx->getTraceAsString() + ); + } catch (ConversionFailedException $e) { + $testCase->fail( + "A ConversionFailedException was thrown (and it was not a ConverterNotOperationalException). The exception was: " . + get_class($e) . + ". The message was: '" . $e->getMessage() . "'"); + } catch (\Exception $e) { + $testCase->fail("An unexpected exception was thrown:" . get_class($e) . '. Message:' . $e->getMessage()); + } + } + + public static function warnIfNotOperational($converterClassName) + { + $converter = ConverterFactory::makeConverterFromClassname( + 'WebPConvert\\Convert\\Converters\\' . $converterClassName, + $source = self::getImagePath('test.jpg'), + $destination = self::getImagePath('test.jpg.webp') + ); + try { + $converter->checkOperationality(); + //echo "\n" . $converterClassName . ' is operational.' . "\n"; + } catch (\Exception $e) { + echo "\n" . 'NOTICE: ' . $converterClassName . ' is not operational: ' . $e->getMessage() . "\n"; + } + } + + public static function runAllConvertTests($testCase, $converterClassName, $converterOptions = []) + { + self::warnIfNotOperational($converterClassName); + + $converterOptions['encoding'] = 'auto'; + self::testConvert('test.jpg', $testCase, $converterClassName, $converterOptions); + self::testConvert('test.png', $testCase, $converterClassName, $converterOptions); + //self::testConvert('not-true-color.png', $testCase, $converterClassName, $converterOptions); + + self::testTargetNotFound($testCase, $converterClassName, $converterOptions); + self::testInvalidDestinationFolder($testCase, $converterClassName, $converterOptions); + } +} diff --git a/tests/Convert/Converters/CwebpTest.php b/tests/Convert/Converters/CwebpTest.php new file mode 100644 index 00000000..0c92cdf1 --- /dev/null +++ b/tests/Convert/Converters/CwebpTest.php @@ -0,0 +1,322 @@ +assertEquals($source, $cwebpExposer->getSource()); + $this->assertTrue(file_exists($source), 'source does not exist'); + } + + + /** + * @covers ::createCommandLineOptions + */ + public function testCreateCommandLineOptions() + { + $source = self::getImagePath('test.png'); + $options = [ + 'quality' => 'auto', + 'method' => 3, + 'command-line-options' => '-sharpness 5 -crop 10 10 40 40 -low_memory', + ]; + $cwebp = new Cwebp($source, $source . '.webp', $options); + $cwebpExposer = new CwebpExposer($cwebp); + + //$cwebpExposer->prepareOptions(); + + $commandLineOptions = $cwebpExposer->createCommandLineOptions(); + //$this->assertEquals('e', $commandLineOption); // use this to quickly see it... + + // Per default we have no preset set + $this->assertDoesNotMatchRegularExpression2('#-preset#', $commandLineOptions); + + // Metadata is per default none + $this->assertMatchesRegularExpression2('#-metadata none#', $commandLineOptions); + + // We passed the method option and set it to 3 + $this->assertMatchesRegularExpression2('#-m 3#', $commandLineOptions); + + // There must be an output option, and it must be quoted + $this->assertMatchesRegularExpression2('#-o [\'"]#', $commandLineOptions); + + // There must be a quality option, and it must be digits + $this->assertMatchesRegularExpression2('#-q \\d+#', $commandLineOptions); + + // -sharpness '5' + $this->assertMatchesRegularExpression2('#-sharpness [\'"]5[\'"]#', $commandLineOptions); + + // Extra command line option with multiple values. Each are escapeshellarg'ed + $this->assertMatchesRegularExpression2( + '#-crop [\'"]10[\'"] [\'"]10[\'"] [\'"]40[\'"] [\'"]40[\'"]#', + $commandLineOptions + ); + + // Command line option (flag) + $this->assertMatchesRegularExpression2('#-low_memory#', $commandLineOptions); + + // -sharpness '5' + $this->assertMatchesRegularExpression2('#-sharpness [\'"]5[\'"]#', $commandLineOptions); + } + + /** + * @covers ::createCommandLineOptions + */ + public function testCreateCommandLineOptions2() + { + $source = self::getImagePath('test.png'); + $options = [ + 'quality' => 70, + 'method' => 3, + 'size-in-percentage' => 55, + 'preset' => 'picture' + ]; + $cwebp = new Cwebp($source, $source . '.webp', $options); + $cwebpExposer = new CwebpExposer($cwebp); + + //$cwebpExposer->prepareOptions(); + + $commandLineOptions = $cwebpExposer->createCommandLineOptions(); + + // Preset + // Note that escapeshellarg uses doublequotes on Windows + $this->assertMatchesRegularExpression2("#-preset ['\"]picture['\"]#", $commandLineOptions); + + // Size + $fileSizeInBytes = floor($options['size-in-percentage']/100 * filesize($source)); + $this->assertEquals(1714, $fileSizeInBytes); + $this->assertMatchesRegularExpression2('#-size ' . $fileSizeInBytes . '#', $commandLineOptions); + + // There must be no quality option, because -size overrules it. + $this->assertDoesNotMatchRegularExpression2('#-q \\d+#', $commandLineOptions); + } + + /** + * @covers ::createCommandLineOptions + */ + public function testCreateCommandLineOptions3() + { + $source = self::getImagePath('test.png'); + $options = [ + 'encoding' => 'lossless', + 'near-lossless' => 75, + 'auto-filter' => true, + ]; + $cwebp = new Cwebp($source, $source . '.webp', $options); + $cwebpExposer = new CwebpExposer($cwebp); + + $commandLineOptions = $cwebpExposer->createCommandLineOptions(); + + // near-lossless + $this->assertMatchesRegularExpression2('#-near_lossless 75#', $commandLineOptions); + + // There must be no -lossless option, because -near-lossless overrules it. + $this->assertDoesNotMatchRegularExpression2('#-lossless#', $commandLineOptions); + + // auto-filter + $this->assertMatchesRegularExpression2('#-af#', $commandLineOptions); + + // no low-memory + $this->assertDoesNotMatchRegularExpression2('#-low_memory#', $commandLineOptions); + } + + /** + * @covers ::createCommandLineOptions + */ + public function testCreateCommandLineOptions4() + { + $source = self::getImagePath('test.png'); + $options = [ + 'encoding' => 'lossless', + 'near-lossless' => 100, + 'low-memory' => true, + ]; + $cwebp = new Cwebp($source, $source . '.webp', $options); + $cwebpExposer = new CwebpExposer($cwebp); + + $commandLineOptions = $cwebpExposer->createCommandLineOptions(); + + // lossless + $this->assertMatchesRegularExpression2('#-lossless#', $commandLineOptions); + + // There must be no -near_lossless option, because -lossless overrules it. + $this->assertDoesNotMatchRegularExpression2('#-near_lossless#', $commandLineOptions); + + // low-memory + $this->assertMatchesRegularExpression2('#-low_memory#', $commandLineOptions); + + // No auto-filter + $this->assertDoesNotMatchRegularExpression2('#-af#', $commandLineOptions); + } + + /** + * @covers ::checkOperationality + */ + public function testOperatinalityException() + { + $source = self::getImagePath('test.png'); + $options = [ + 'try-cwebp' => false, + 'try-supplied-binary-for-os' => false, + 'try-common-system-paths' => false, + 'try-discovering-cwebp' => false, + ]; + $this->expectException(ConverterNotOperationalException::class); + //$cwebp = new Cwebp($source, $source . '.webp', $options); + Cwebp::convert($source, $source . '.webp', $options); + } + + public function testUsingSuppliedBinaryForOS() + { + $source = self::getImagePath('test.png'); + $options = [ + 'try-cwebp' => false, + 'try-supplied-binary-for-os' => true, + 'try-common-system-paths' => false, + 'try-discovering-cwebp' => false, + ]; + //$this->expectException(ConverterNotOperationalException::class); + //$cwebp = new Cwebp($source, $source . '.webp', $options); + try { + Cwebp::convert($source, $source . '.webp', $options); + } catch (ConversionFailedException $e) { + // this is ok. + // - but other exceptions are not! + } + $this->addToAssertionCount(1); + } + + public function testUsingCommonSystemPaths() + { + $source = self::getImagePath('test.png'); + $options = [ + 'try-cwebp' => false, + 'try-supplied-binary-for-os' => false, + 'try-common-system-paths' => true, + 'try-discovering-cwebp' => false, + ]; + //$this->expectException(ConverterNotOperationalException::class); + //$cwebp = new Cwebp($source, $source . '.webp', $options); + try { + Cwebp::convert($source, $source . '.webp', $options); + } catch (ConversionFailedException $e) { + // this is ok. + // - but other exceptions are not! + } + $this->addToAssertionCount(1); + + } + + /* + public function testCwebpDefaultPaths() + { + $default = [ + '/usr/bin/cwebp', + '/usr/local/bin/cwebp', + '/usr/gnu/bin/cwebp', + '/usr/syno/bin/cwebp' + ]; + + foreach ($default as $key) { + $this->assertContains($key, Cwebp::$cwebpDefaultPaths); + } + }*/ + + /** + * @expectedException \Exception + */ + /* + public function testUpdateBinariesInvalidFile() + { + $array = []; + + Cwebp::updateBinaries('InvalidFile', 'Hash', $array); + }*/ + + /** + * @expectedException \Exception + */ + /* + public function testUpdateBinariesInvalidHash() + { + $array = []; + + Cwebp::updateBinaries('cwebp-linux', 'InvalidHash', $array); + } + + public function testUpdateBinaries() + { + $file = 'cwebp.exe'; + $filePath = realpath(__DIR__ . '/../../Converters/Binaries/' . $file); + $hash = hash_file('sha256', $filePath); + $array = []; + + $this->assertContains($filePath, Cwebp::updateBinaries($file, $hash, $array)); + } + + public function testEscapeFilename() + { + $wrong = '/path/to/file Na<>me."ext"'; + $right = '/path/to/file\\\ Name.\"ext\"'; + + $this->assertEquals($right, Cwebp::escapeFilename($wrong)); + } + + public function testHasNiceSupport() + { + $this->assertNotNull(Cwebp::hasNiceSupport()); + }*/ +/* + public function testConvert() + { + $source = realpath(__DIR__ . '/../test.jpg'); + $destination = realpath(__DIR__ . '/../test.webp'); + $quality = 85; + $stripMetadata = true; + + $this->assertTrue(Cwebp::convert($source, $destination, $quality, $stripMetadata)); + }*/ + +} diff --git a/tests/Convert/Converters/EwwwTest.php b/tests/Convert/Converters/EwwwTest.php new file mode 100644 index 00000000..0c4b2882 --- /dev/null +++ b/tests/Convert/Converters/EwwwTest.php @@ -0,0 +1,97 @@ + '' + ]); + } + + public function testConvertInvalidKeyLessThan20() + { + $this->expectException(InvalidApiKeyException::class); + + $source = self::getImagePath('test.png'); + Ewww::convert($source, $source . '.webp', [ + 'api-key' => 'wrong-key!' + ]); + } + + public function testConvertInvalidKeyLess32() + { + $this->expectException(InvalidApiKeyException::class); + + $wrongKeyRightLength = 'invalid-key-but-hasright-length'; + + $source = self::getImagePath('test.png'); + Ewww::convert($source, $source . '.webp', [ + 'api-key' => $wrongKeyRightLength + ]); + } + + public function testConvertInvalidKeyDuringConversion() + { + $this->expectException(InvalidApiKeyException::class); + + $wrongKeyRightLength = 'invalid-key-but-hasright-length'; + + $source = self::getImagePath('test.png'); + + $ee = EwwwExtended::createInstance($source, $source . '.webp', [ + 'api-key' => $wrongKeyRightLength + ]); + + $ee->callDoActualConvert(); + } + + + public function testIsValidKey() + { + $invalidKey = 'notvalidno'; + $this->assertFalse(Ewww::isValidKey($invalidKey)); + + $demoKey = 'abc123'; + $this->assertTrue(Ewww::isValidKey($demoKey)); + + + //InvalidApiKeyException + } + + public function testIsWorkingKey() + { + $invalidKey = 'notvalidno'; + $this->assertFalse(Ewww::isWorkingKey($invalidKey)); + + if (!empty(getenv('WEBPCONVERT_EWWW_API_KEY'))) { + $realWorkingKey = getenv('WEBPCONVERT_EWWW_API_KEY'); + $this->assertTrue(Ewww::isWorkingKey($realWorkingKey)); + } + } +} diff --git a/tests/Convert/Converters/FFMpegTest.php b/tests/Convert/Converters/FFMpegTest.php new file mode 100644 index 00000000..2707a03d --- /dev/null +++ b/tests/Convert/Converters/FFMpegTest.php @@ -0,0 +1,71 @@ +getText() + throw $e; + } catch (ConverterNotOperationalException $e) { + // (SystemRequirementsNotMetException is also a ConverterNotOperationalException) + // this is ok. + return true; + } + return true; + } + + public function testWithNice() { + $source = self::getImagePath('test.png'); + $options = [ + 'use-nice' => true, + 'encoding' => 'lossless', + ]; + $this->assertTrue(self::tryThis($this, $source, $options)); + } + +} diff --git a/tests/Convert/Converters/GdTest.php b/tests/Convert/Converters/GdTest.php new file mode 100644 index 00000000..82b9ebc3 --- /dev/null +++ b/tests/Convert/Converters/GdTest.php @@ -0,0 +1,286 @@ +assertTrue(file_exists($source), 'source does not exist:' . $source); + + return new Gd($source, $source . '.webp'); + } + + private function createGdExposer($src) + { + $gd = $this->createGd($src); + return new GdExposer($gd); + } + + private static function resetPretending() + { + reset_pretending(); + } + + // pretend imagewebp is missing + public function testNotOperational1() + { + global $pretend; + + $gd = $this->createGd('test.png'); + self::resetPretending(); + + $pretend['functionsNotExisting'] = ['imagewebp']; + $this->expectException(SystemRequirementsNotMetException::class); + $gd->checkOperationality(); + } + + + // pretend gd is not loaded + public function testNotOperational2() + { + global $pretend; + + $gd = $this->createGd('test.png'); + self::resetPretending(); + + $pretend['extensionsNotExisting'] = ['gd']; + $this->expectException(SystemRequirementsNotMetException::class); + $gd->checkOperationality(); + $pretend['extensionsNotExisting'] = []; + } + + // pretend imagecreatefrompng is missing + public function testCheckConvertability1() + { + global $pretend; + + $gd = $this->createGd('test.png'); + self::resetPretending(); + + $pretend['functionsNotExisting'] = ['imagecreatefrompng']; + $this->expectException(SystemRequirementsNotMetException::class); + $gd->checkConvertability(); + $pretend['functionsNotExisting'] = []; + } + + // pretend imagecreatefrompng is working + public function testCheckConvertability2() + { + global $pretend; + + $gd = $this->createGd('test.png'); + self::resetPretending(); + + $pretend['functionsExisting'] = ['imagecreatefrompng']; + $gd->checkConvertability(); + $pretend['functionsExisting'] = []; + } + + // pretend imagecreatefromjpeg is missing + public function testCheckConvertability3() + { + global $pretend; + + $gd = $this->createGd('test.jpg'); + self::resetPretending(); + + $pretend['functionsNotExisting'] = ['imagecreatefromjpeg']; + $this->expectException(SystemRequirementsNotMetException::class); + $gd->checkConvertability(); + $pretend['functionsNotExisting'] = []; + } + + public function testSource() + { + + $source = self::getImagePath('test.png'); + $gd = new Gd($source, $source . '.webp'); + + self::resetPretending(); + + $gdExposer = new GdExposer($gd); + + $this->assertEquals($source, $gdExposer->getSource()); + $this->assertTrue(file_exists($source), 'source does not exist'); + } + + public function testCreateImageResource() + { + $gd = $this->createGd('test.png'); + self::resetPretending(); + + $gdExposer = new GdExposer($gd); + + if (!$gdExposer->isOperating()) { + //$this->assertTrue(false); + return; + } + + // It is operating and image should be ok. + // - so it should be able to create image resource (or, for PHP 8, an \GdImage object) + $image = $gdExposer->createImageResource(); + $isResourceOrObject = ((gettype($image) == 'resource') || (gettype($image) == 'object')); + $this->assertTrue($isResourceOrObject, 'Expected createImageResource to return a resource or an object but got:' . gettype($image)); + +/* + // Try the workaround method. + $result = $gdExposer->makeTrueColorUsingWorkaround($image); + + // As the workaround is pretty sturdy, let us assert that it simply works. + // It would be good to find out if it doesn't, anyway! + $this->assertTrue($result); */ + + //$gdExposer->tryToMakeTrueColorIfNot($image); + $this->assertTrue(imageistruecolor($image), 'image is not true color'); + + $result = $gdExposer->trySettingAlphaBlending($image); + $this->assertTrue($result, 'failed setting alpha blending'); + } + + public function testStuffOnNotTrueColor() + { + $gd = $this->createGd('not-true-color.png'); + self::resetPretending(); + + $gdExposer = new GdExposer($gd); + + if (!$gdExposer->isOperating()) { + return; + } + + // It is operating and image should be ok. + // - so it should be able to create image resource + $image = $gdExposer->createImageResource(); + $isResourceOrObject = ((gettype($image) == 'resource') || (gettype($image) == 'object')); + $this->assertTrue($isResourceOrObject, 'Expected createImageResource to return a resource or an object but got:' . gettype($image)); + + $this->assertFalse(imageistruecolor($image), 'image is already true color'); + $gdExposer->tryToMakeTrueColorIfNot($image); + $this->assertTrue(imageistruecolor($image), 'image is not true color after trying to make it'); + $result = $gdExposer->trySettingAlphaBlending($image); + $this->assertTrue($result, 'failed setting alpha blending'); + + // Test the workaround method. + $gd = $this->createGd('not-true-color.png'); + $gdExposer = new GdExposer($gd); + $image = $gdExposer->createImageResource(); + $this->assertFalse(imageistruecolor($image), 'image is already true color'); + + //$image = imagecreatetruecolor(imagesx($image), imagesy($image)); + $result = $gdExposer->makeTrueColorUsingWorkaround($image); + //$result = $gd->makeTrueColorUsingWorkaround($image); + $this->assertTrue($result); + $this->assertTrue(imageistruecolor($image), 'image is not true color after trying to make it (with workaround method)'); + $result = $gdExposer->trySettingAlphaBlending($image); + $this->assertTrue($result, 'failed setting alpha blending'); + } + + public function testConvertFailure() + { + echo 'OS: ' . PHP_OS; + $gdExposer = $this->createGdExposer('not-true-color.png'); + + self::resetPretending(); + + // The next requires imagewebp... + if (!function_exists('imagewebp')) { + return; + } + + $image = $gdExposer->createImageResource(); + + // This image is not true color. + // Trying to convert it fails (empty string is generated) + // Assert that I am right! + + // In most cases the following triggers a warning: + // Warning: imagewebp(): Palette image not supported by webp in /var/www/wc/wc0/webp-convert/tests/Convert/Converters/GdTest.php on line 215 + // + // However, in Windows-2022 (PHP 8), it throws A FATAL! + // Error: PHP Fatal error: Paletter image not supported by webp in D:\a\webp-convert\webp-convert\tests\Convert\Converters\GdTest.php on line 222 + // + // And its worse and Mac (PHP 7.1 and 7.3, not 7.4 and 8.0) + // It just halts execution - see ##322 + // + // In Ubuntu, PHP 8.1 it throws fatal too. I think this behaviour starts from PHP 8.0.0. + + $isWindows = preg_match('/^win/i', PHP_OS); + $isMacDarwin = preg_match('/^darwin/i', PHP_OS); + $isPHP8orAbove = (version_compare(PHP_VERSION, '8.0.0') >= 0); + + if (!$isWindows && !$isMacDarwin && !$isPHP8orAbove) { + ob_start(); + + try { + @imagewebp($image, null, 80); + } catch (\Exception $e) { + } catch (\Throwable $e) { + } + $output = ob_get_clean(); + + // The failure results in no output: + $this->assertEquals($output, ''); + + // similary, we should get an exception when calling tryConverting ('Gd failed: imagewebp() returned empty string') + //$this->expectException(ConversionFailedException::class); + $gotExpectedException = false; + try { + $gdExposer->tryConverting($image); + } catch (ConversionFailedException $e) { + $gotExpectedException = true; + } + $this->assertTrue( + $gotExpectedException, + 'did not get expected exception when converting palette image with Gd, ' . + 'bypassing the code that converts to true color' + ); + + } + + //$gdExposer->tryToMakeTrueColorIfNot($image); + + //$pretend['functionsNotExisting'] = ['imagewebp']; + + } + /* + public function testMakeTrueColorUsingWorkaround() + { + $gd = $this->createGd('test.png'); + self::resetPretending(); + + $gdExposer = new GdExposer($gd); + + if (!$gdExposer->isOperating()) { + return; + } + + }*/ +} + +require_once('pretend.inc'); diff --git a/tests/Convert/Converters/GmagickTest.php b/tests/Convert/Converters/GmagickTest.php new file mode 100644 index 00000000..ca9baee5 --- /dev/null +++ b/tests/Convert/Converters/GmagickTest.php @@ -0,0 +1,29 @@ +assertSame([], $im->queryformats()); + + } +*/ +} diff --git a/tests/Convert/Converters/GraphicsMagickTest.php b/tests/Convert/Converters/GraphicsMagickTest.php new file mode 100644 index 00000000..7453098b --- /dev/null +++ b/tests/Convert/Converters/GraphicsMagickTest.php @@ -0,0 +1,74 @@ +getText() + throw $e; + } catch (ConverterNotOperationalException $e) { + // (SystemRequirementsNotMetException is also a ConverterNotOperationalException) + // this is ok. + return true; + } + return true; + } + + public function testWithNice() { + $source = self::getImagePath('test.png'); + $options = [ + 'use-nice' => true, + 'lossless' => true, + ]; + $this->assertTrue(self::tryThis($this, $source, $options)); + } + +} diff --git a/tests/Convert/Converters/ImageMagickTest.php b/tests/Convert/Converters/ImageMagickTest.php new file mode 100644 index 00000000..7eda7464 --- /dev/null +++ b/tests/Convert/Converters/ImageMagickTest.php @@ -0,0 +1,63 @@ +addToAssertionCount(1); + } catch (ConversionFailedException $e) { + + //$bufferLogger->getText() + throw $e; + } catch (ConverterNotOperationalException $e) { + // (SystemRequirementsNotMetException is also a ConverterNotOperationalException) + // this is ok. + return; + } + } + + public function testWithNice() { + $source = self::getImagePath('test.png'); + $options = [ + 'use-nice' => true, + 'encoding' => 'lossless', + ]; + self::tryThis($this, $source, $options); + } + +} diff --git a/tests/Convert/Converters/ImagickTest.php b/tests/Convert/Converters/ImagickTest.php new file mode 100644 index 00000000..b023ec8f --- /dev/null +++ b/tests/Convert/Converters/ImagickTest.php @@ -0,0 +1,63 @@ +markTestSkipped( + 'The imagick extension is not available.' + ); + return; + } + //if (!class_exists('\\Imagick')) {} + + $im = new \Imagick(); + + $this->assertEquals(1, count($im->queryFormats('JPEG'))); + $this->assertGreaterThan(2, count($im->queryFormats('*'))); + $this->assertGreaterThan(2, count($im->queryFormats())); + $this->assertEquals(count($im->queryFormats('*')), count($im->queryFormats())); + } + + /** + * @coversNothing + */ + public function testThatImagickFunctionsUsedDoesNotThrow() + { + if (!extension_loaded('imagick')) { + $this->markTestSkipped( + 'The imagick extension is not available.' + ); + return; + } + $im = new \Imagick(self::$imageDir . '/test.jpg'); + $im->setImageFormat('JPEG'); + $im->stripImage(); + $im->setImageCompressionQuality(100); + $imageBlob = $im->getImageBlob(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/Convert/Converters/StackTest.php b/tests/Convert/Converters/StackTest.php new file mode 100644 index 00000000..7b78cbeb --- /dev/null +++ b/tests/Convert/Converters/StackTest.php @@ -0,0 +1,57 @@ +expectException(ConverterNotFoundException::class); + + Stack::convert( + self::getImagePath('test.jpg'), + self::getImagePath('test.webp'), + [ + 'converters' => ['invalid-id'] + ] + ); + } + + public function testCustomConverter() + { + Stack::convert( + self::getImagePath('test.jpg'), + self::getImagePath('test.webp'), + [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ); + $this->addToAssertionCount(1); + } + +} diff --git a/tests/Convert/Converters/TestTest.php b/tests/Convert/Converters/TestTest.php new file mode 100644 index 00000000..925b13dc --- /dev/null +++ b/tests/Convert/Converters/TestTest.php @@ -0,0 +1,23 @@ +assertEquals( + '1', + '1' + ); + } + +} + +require_once('pretend.inc'); diff --git a/tests/Convert/Converters/VipsTest.php b/tests/Convert/Converters/VipsTest.php new file mode 100644 index 00000000..8a69c6e3 --- /dev/null +++ b/tests/Convert/Converters/VipsTest.php @@ -0,0 +1,258 @@ +getImageFolder() . '/' . $image; + } + + public function testConvert() + { + $options = []; + ConverterTestHelper::runAllConvertTests($this, 'Vips', $options); + + $options = [ + 'smart-subsample' => true, + 'preset' => 'text', + ]; + ConverterTestHelper::runAllConvertTests($this, 'Vips', $options); + } + + + private function createVips($src, $options = []) + { + $source = $this->getImagePath($src); + $this->assertTrue(file_exists($source), 'source does not exist:' . $source); + + return new Vips($source, $source . '.webp', $options); + } + + private function createVipsExposer($src, $options = []) + { + return new VipsExposer($this->createVips($src, $options)); + } + + private function isVipsOperational() + { + try { + $vips = $this->createVips('test.png'); + $vips->checkOperationality(); + $vips->checkConvertability(); + } catch (\Exception $e) { + return false; + } + return true; + } + + public function testCreateParamsForVipsWebPSave1() + { + $options = [ + 'method' => 3, + 'encoding' => 'lossless', + 'smart-subsample' => true, // note: deprecated + 'near-lossless' => 90, + 'preset' => 'picture', // In vips, this has the constant: 1 + ]; + $vipsExposer = $this->createVipsExposer('test.png', $options); + + $vipsParams = $vipsExposer->createParamsForVipsWebPSave(); + + // Check some options that are straightforwardly copied + + $this->assertSame($options['method'], $vipsParams['reduction_effort']); + $this->assertSame(true, $vipsParams['lossless']); + $this->assertSame($options['smart-subsample'], $vipsParams['smart_subsample']); + $this->assertSame(1, $vipsParams['preset']); + + // When near-lossless is set, the value should be copied to Q + $this->assertSame($options['near-lossless'], $vipsParams['Q']); + } + + public function testCreateParamsForVipsWebPSave2() + { + $options = [ + 'alpha-quality' => 100, + 'sharp-yuv' => true, + ]; + $vipsExposer = $this->createVipsExposer('test.png', $options); + + $vipsParams = $vipsExposer->createParamsForVipsWebPSave(); + + $this->assertSame($options['sharp-yuv'], isset($vipsParams['smart_subsample'])); + + // Some options are only set if they differ from default + $this->assertFalse(isset($vipsParams['alpha_q'])); + } + + + public function testCreateImageResource1() + { + + $source = $this->getImagePath('non-existing'); + + // Next must fail with a TargetNotFoundException + $this->expectException(TargetNotFoundException::class); + + $vips = new Vips($source, $source . '.webp', []); + + // Exit if vips is not operational + if (!$this->isVipsOperational()) { + return; + } + + /* + $vipsExposer = new VipsExposer($vips); + + // It must fail because it should not be able to create resource when file does not exist + $this->expectException(ConversionFailedException::class); + + $vipsExposer->createImageResource();*/ + } + + public function testNotOperational1() + { + global $pretend; + + $vips = $this->createVips('test.png'); + reset_pretending(); + + // pretend vips_image_new_from_file + $pretend['functionsNotExisting'] = ['vips_image_new_from_file']; + $this->expectException(SystemRequirementsNotMetException::class); + $vips->checkOperationality(); + } + + public function testNotOperational2() + { + global $pretend; + + $vips = $this->createVips('test.png'); + reset_pretending(); + + // pretend vips_image_new_from_file + $pretend['extensionsNotExisting'] = ['vips']; + $this->expectException(SystemRequirementsNotMetException::class); + $vips->checkOperationality(); + } + + /* + Commented out because it is no good anymore, after the checkOperationality actually + itself relies on the function_exists, because it now calls "vips_call" in order to + detect if webp is supported + + public function testOperational1() + { + global $pretend; + + $vips = $this->createVips('test.png'); + reset_pretending(); + + // pretend vips_image_new_from_file + $pretend['functionsExisting'] = ['vips_image_new_from_file', 'vips_call', 'vips_error_buffer']; + $pretend['extensionsExisting'] = ['vips']; + $vips->checkOperationality(); + + $this->addToAssertionCount(1); + } + */ + + /** + * @covers ::webpsave + */ + public function testWebpsave() + { + reset_pretending(); + + $vips = $this->createVips('test.png', []); + $vipsExposer = new VipsExposer($vips); + + // Exit if vips is not operational + if (!$this->isVipsOperational()) { + $this->markTestSkipped('vips is not operational'); + return; + } + + $im = $vipsExposer->createImageResource(); + $options = $vipsExposer->createParamsForVipsWebPSave(); + + // Create non-existing vips option. + // - The converter must be able to ignore this without failing + $options['non-existing-vips-option'] = true; + $vipsExposer->webpsave($im, $options); + } + + /** + * @covers ::createImageResource + */ + public function testCreateImageResourceWhenFileNotFound() + { + // + reset_pretending(); + + $source = $this->getImagePath('i-do-not-exist.jpg'); + + $this->assertFalse(file_exists($source)); + + $options = []; + + // Next must fail with a TargetNotFoundException + $this->expectException(TargetNotFoundException::class); + + $vips = new Vips($source, $source . '.webp', $options); + + // Exit if vips is not operational + /* + if (!$this->isVipsOperational()) { + $this->markTestSkipped('vips is not operational'); + return; + }*/ + + /* + $vipsExposer = new VipsExposer($vips); + + // this should fail! + try { + $im = $vipsExposer->createImageResource(); + $this->fail('exception was expected'); + } catch (ConversionFailedException $e) { + $this->assertRegExp('#not found#', $e->getMessage()); + }*/ + + } +/* + public function testDoActualConvert() + { + + $options = [ + 'alpha-quality' => 100 + ]; + $vipsExposer = $this->createVipsExposer('test.png', $options); + + $vips = $this->createVips('not-existing.png'); + + $this->addToAssertionCount(1); + }*/ +} + +require_once('pretend.inc'); diff --git a/tests/Convert/Converters/WPCTest.php b/tests/Convert/Converters/WPCTest.php new file mode 100644 index 00000000..8f99c1f8 --- /dev/null +++ b/tests/Convert/Converters/WPCTest.php @@ -0,0 +1,297 @@ +getImageFolder() . '/' . $image; + } + +/* public function testApi0() + { + if (!empty(getenv('WEBPCONVERT_WPC_API_URL_API0'))) { + $source = $this->imageDir . '/test.png'; + Wpc::convert($source, $source . '.webp', [ + 'api-version' => 0, + 'api-url' => getenv('WEBPCONVERT_WPC_API_URL_API0') + ]); + } + } +*/ + + private static function tryThis($test, $source, $options) + { + $bufferLogger = new BufferLogger(); + + try { + Wpc::convert($source, $source . '.webp', $options, $bufferLogger); + } catch (ConversionFailedException $e) { + + // we accept this failure that seems to happen when WPC gets stressed: + if (strpos($e->getMessage(), 'unable to open image') !== false) { + return true; + } + + // we also accept this failure that also seems to happen when WPC gets stressed: + if (strpos($e->getMessage(), 'We got nothing back') !== false) { + return true; + } + + if ($e->getMessage() == 'Error saving file. Check file permissions') { + throw new ConversionFailedException( + 'Failed saving file. Here is the log:' . $bufferLogger->getText() + ); + } + + throw $e; + } + return true; + } + + public function testWarnIfNotOperational() + { + ConverterTestHelper::warnIfNotOperational('Wpc'); + $this->addToAssertionCount(1); + } + + public function testApi0() + { + if (empty(getenv('WEBPCONVERT_WPC_API_URL'))) { + echo "\n" . 'NOTICE: WPC is not operational. It needs api-key and api-url. '; + echo 'You can set this up by setting environment varibles WEBPCONVERT_WPC_API_URL_API and WEBPCONVERT_WPC_API_KEY. '; + echo 'To also test old api=0, use WEBPCONVERT_WPC_API_URL_API0.'; + echo "\n"; + } else { + if (empty(getenv('WEBPCONVERT_WPC_API_URL_API0'))) { + echo "\n" . 'NOTICE: WPC is not tested with api-version=0. To test this, you must set environment varibles WEBPCONVERT_WPC_API_URL_API0 and WEBPCONVERT_WPC_API_KEY' . "\n"; + } + } + if (empty(getenv('WEBPCONVERT_WPC_API_URL_API0'))) { + $this->addToAssertionCount(1); + return; + } + + $source = $this->getImagePath('test.png'); + $options = [ + 'api-version' => 0, + 'api-url' => getenv('WEBPCONVERT_WPC_API_URL_API0'), + 'lossless' => true, + ]; + + $this->assertTrue(self::tryThis($this, $source, $options)); + + + } + + public function testApi1() + { + if (empty(getenv('WEBPCONVERT_WPC_API_URL'))) { + $this->addToAssertionCount(1); + return; + } + + $source = $this->getImagePath('test.png'); + $options = [ + 'api-version' => 1, + 'crypt-api-key-in-transfer' => true, + 'lossless' => true, + ]; + + $this->assertTrue(self::tryThis($this, $source, $options)); + } + + public function testApi2() + { + if (empty(getenv('WEBPCONVERT_WPC_API_URL'))) { + $this->addToAssertionCount(1); + return; + } + + $source = $this->getImagePath('test.png'); + $options = [ + 'api-version' => 2, + 'crypt-api-key-in-transfer' => true, + 'lossless' => true, + ]; + + $this->assertTrue(self::tryThis($this, $source, $options)); + } + + public function testWrongSecretButRightUrl() + { + if (empty(getenv('WEBPCONVERT_WPC_API_URL'))) { + return; + } + + $source = $this->getImagePath('test.png'); + $options = [ + 'api-version' => 1, + 'crypt-api-key-in-transfer' => true, + 'api-key' => 'wrong!', + ]; + + $this->expectException(InvalidApiKeyException::class); + $this->assertTrue(self::tryThis($this, $source, $options)); + } + + public function testBadURL() + { + $this->expectException(ConverterNotOperationalException::class); + + Wpc::convert( + $this->getImagePath('test.png'), + $this->getImagePath('test.webp'), + [ + 'api-url' => 'badurl!', + 'secret' => 'bad dog!', + ] + ); + } + + public function test404() + { + //$this->expectException(ConversionFailedException::class); + + try { + Wpc::convert( + $this->getImagePath('test.png'), + $this->getImagePath('test.webp'), + [ + 'api-url' => 'https://google.com/hello', + 'secret' => 'bad dog!', + ] + ); + $this->fail('Expected an exception'); + + } catch (ConversionFailedException $e) { + // this is expected! + $this->addToAssertionCount(1); + + $this->assertMatchesRegularExpression2('#we got a 404 response#', $e->getMessage()); + } + + } + + public function testUnexpectedResponse() + { + //$this->expectException(ConversionFailedException::class); + + try { + Wpc::convert( + $this->getImagePath('test.png'), + $this->getImagePath('test.webp'), + [ + 'api-url' => 'https://www.google.com/', + 'secret' => 'bad dog!', + ] + ); + $this->fail('Expected an exception'); + + } catch (ConversionFailedException $e) { + // this is expected! + $this->addToAssertionCount(1); + + $this->assertMatchesRegularExpression2('#We did not receive an image#', $e->getMessage()); + } + } + + +/* + HMM.. Apparently wpc can't handle much stress. + The runAllConvertTests often results in an error like this: + + 'WPC failed converting image: "unable to open image '../conversions/80c80b20834edd62456fe9e6da4d24d64be51dc1.jpg': No such file or directory @ error/blob.c/OpenBlob/3489"' + + public function testApi0() + { + if (!empty(getenv('WEBPCONVERT_WPC_API_URL_API0'))) { + ConverterTestHelper::runAllConvertTests($this, 'Wpc', [ + 'api-version' => 0, + 'api-url' => getenv('WEBPCONVERT_WPC_API_URL_API0') + ]); + } + } + + public function testApi1() + { + if (empty(getenv('WEBPCONVERT_WPC_API_URL')) || empty(getenv('WEBPCONVERT_WPC_API_KEY'))) { + return; + } + + ConverterTestHelper::runAllConvertTests($this, 'Wpc', [ + 'api-version' => 1, + 'crypt-api-key-in-transfer' => true + ]); + + // TODO: Also test without crypt + } +*/ + +/* + public function testMissingURL() + { + $this->expectException(ConverterNotOperationalException::class); + + Wpc::convert($this->imageDir . '/test.png', $this->imageDir . '/test.webp', [ + 'api-url' => '', + 'secret' => 'bad dog!', + ]); + }*/ + + +/* + public function testWrongSecretButRightUrl() + { + if (empty(getenv('WEBPCONVERT_WPC_API_URL'))) { + return; + } + + $this->expectException(InvalidApiKeyException::class); + + Wpc::convert($this->imageDir . '/test.png', $this->imageDir . '/test.webp', [ + 'api-version' => 0, + 'api-url' => getenv('WEBPCONVERT_WPC_API_URL'), + 'secret' => 'purposely-wrong-secret!' + ]); + } + + public function testBadURL() + { + $this->expectException(ConverterNotOperationalException::class); + + Wpc::convert($this->imageDir . '/test.png', $this->imageDir . '/test.webp', [ + 'api-url' => 'badurl!', + 'secret' => 'bad dog!', + ]); + }*/ + +} diff --git a/tests/Convert/Converters/pretend.inc b/tests/Convert/Converters/pretend.inc new file mode 100644 index 00000000..9283a349 --- /dev/null +++ b/tests/Convert/Converters/pretend.inc @@ -0,0 +1,55 @@ + [], + 'functionsExisting' => [], + 'extensionsNotExisting' => [], + 'extensionsExisting' => [] + ]; + $hasDeclaredMockFunctions = false; + + function reset_pretending() + { + global $pretend; + $pretend = [ + 'functionsNotExisting' => [], + 'functionsExisting' => [], + 'extensionsNotExisting' => [], + 'extensionsExisting' => [] + ]; + + } +} + +namespace WebPConvert\Convert\Converters { + + + global $hasDeclaredMockFunctions; + + if(!$hasDeclaredMockFunctions) { + $hasDeclaredMockFunctions = true; + function function_exists($function) { + + global $pretend; + if (in_array($function, $pretend['functionsNotExisting'])) { + return false; + } + if (in_array($function, $pretend['functionsExisting'])) { + return true; + } + return \function_exists($function); + } + + function extension_loaded($extension) { + global $pretend; + if (in_array($extension, $pretend['extensionsNotExisting'])) { + return false; + } + if (in_array($extension, $pretend['extensionsExisting'])) { + return true; + } + return \extension_loaded($extension); + } + } +} diff --git a/tests/Convert/Exposers/AbstractConverterExposer.php b/tests/Convert/Exposers/AbstractConverterExposer.php new file mode 100644 index 00000000..7bba90ea --- /dev/null +++ b/tests/Convert/Exposers/AbstractConverterExposer.php @@ -0,0 +1,63 @@ +getPrivateProperty('source'); + } + + public function isOperating() + { + $inject = function() { + try { + $this->checkOperationality(); + $this->checkConvertability(); + } catch (\Exception $e) { + return false; + } + return true; + }; + return $this->bindDynamicFunctionToObjectAndCallIt($inject); + } + + /* + public function prepareOptions() + { + $this->callPrivateFunction('prepareOptions', AbstractConverter::class); + }*/ + + public function getOptions() + { + return $this->getPrivateProperty('options', AbstractConverter::class); + } + +/* + public function getDefaultOptions() + { + //return $this->getPrivateStaticProperty('defaultOptions', AbstractConverter::class); + return $this->callPrivateFunction('getDefaultOptions', AbstractConverter::class); + }*/ + +} diff --git a/tests/Convert/Exposers/CwebpExposer.php b/tests/Convert/Exposers/CwebpExposer.php new file mode 100644 index 00000000..25d95e07 --- /dev/null +++ b/tests/Convert/Exposers/CwebpExposer.php @@ -0,0 +1,23 @@ +callPrivateFunction('createCommandLineOptions', null, $version); + } + +} diff --git a/tests/Convert/Exposers/GdExposer.php b/tests/Convert/Exposers/GdExposer.php new file mode 100644 index 00000000..95dad365 --- /dev/null +++ b/tests/Convert/Exposers/GdExposer.php @@ -0,0 +1,110 @@ +callPrivateFunction('createImageResource', null); + } + + + public function makeTrueColorUsingWorkaround(&$image) + { + return $this->callPrivateFunctionByRef('makeTrueColorUsingWorkaround', $image); + +// return $this->callPrivateFunction('makeTrueColorUsingWorkaround', null, $image); + /* + The following would also work: + + $cb = function(&$image) { + echo 'callback:...' . gettype($image); + return $this->makeTrueColorUsingWorkaround($image); + }; + //$class = get_class(Gd::class); + $functionNowBinded = $cb->bindTo($this->objectToExposeFrom, Gd::class); + + return $functionNowBinded($image);*/ + } + + public function trySettingAlphaBlending(&$image) + { + return $this->callPrivateFunctionByRef('trySettingAlphaBlending', $image); + } + + public function tryToMakeTrueColorIfNot(&$image) + { + return $this->callPrivateFunctionByRef('tryToMakeTrueColorIfNot', $image); + } + + public function tryConverting(&$image) + { + return $this->callPrivateFunctionByRef('tryConverting', $image); + } + + +/* + public function checkOperationality() + { + $this->checkOperationality(); + } + + public function exposedCheckConvertability() + { + $this->checkConvertability(); + } + + public function exposedGetImage() + { + return $this->image; + } + + public function exposedCreateImageResource() + { + $this->createImageResource(); + } + +*/ +/* +Other method for calling pnivate: + + https://stackoverflow.com/questions/2738663/call-private-methods-and-private-properties-from-outside-a-class-in-php/2738847#2738847 + $reflector = new \ReflectionClass(Gd::class); + $reflector->getMethod('createImageResource')->setAccessible(true); + $unlockedGate = $reflector->newInstance($source, $source . '.webp'); + $unlockedGate->createImageResource(); +*/ + +/* + $gd = new Gd($source, $source . '.webp'); + $reflectedGd = new \ReflectionObject($gd); + $createImageResourceMethod = $reflectedGd->getMethod('createImageResource'); + $createImageResourceMethod->setAccessible(true); + $createImageResourceMethod->invoke(); + + */ + // https://ocramius.github.io/blog/accessing-private-php-class-members-without-reflection/ + /* + $sourceThief = function($gd) { + return $gd->source; + }; + + $gd = new Gd($source, $source . '.webp'); + $sourceThief = \Closure::bind($sourceThief, null, $gd); + $this->assertEquals($source, $sourceThief($gd)); + */ +} diff --git a/tests/Convert/Exposers/VipsExposer.php b/tests/Convert/Exposers/VipsExposer.php new file mode 100644 index 00000000..577a4491 --- /dev/null +++ b/tests/Convert/Exposers/VipsExposer.php @@ -0,0 +1,39 @@ +callPrivateFunction('createParamsForVipsWebPSave', null); + } + + public function createImageResource() + { + return $this->callPrivateFunction('createImageResource', null); + } + + public function doActualConvert() + { + return $this->callPrivateFunction('doActualConvert', null); + } + + public function webpsave($im, $options) + { + return $this->callPrivateFunction('webpsave', null, $im, $options); + } +} diff --git a/tests/Convert/Helpers/JpegQualityDetectorTest.php b/tests/Convert/Helpers/JpegQualityDetectorTest.php new file mode 100644 index 00000000..31897836 --- /dev/null +++ b/tests/Convert/Helpers/JpegQualityDetectorTest.php @@ -0,0 +1,33 @@ +addToAssertionCount(1); + } else { + $this->assertSame(61, $result); + } + } + + + public function testDetectQualityOfJpgNonExistantFile() + { + $result = JpegQualityDetector::detectQualityOfJpg('i dont exist'); + + $this->assertNull($result); + } + + // TODO: Test when PNG is supplied +} diff --git a/tests/Convert/Helpers/PhpIniSizesTest.php b/tests/Convert/Helpers/PhpIniSizesTest.php new file mode 100644 index 00000000..8cdc110a --- /dev/null +++ b/tests/Convert/Helpers/PhpIniSizesTest.php @@ -0,0 +1,85 @@ +assertEquals(0, PhpIniSizes::parseShortHandSize('0')); + $this->assertEquals(10, PhpIniSizes::parseShortHandSize('10')); + + // Test "k" unit + $this->assertEquals(1024, PhpIniSizes::parseShortHandSize('1k')); + + // Test capitial "K" + $this->assertEquals(1024, PhpIniSizes::parseShortHandSize('1K')); + + // Test "M" unit + $this->assertEquals(1024 * 1024, PhpIniSizes::parseShortHandSize('1M')); + + // Test "G" unit + $this->assertEquals(1024 * 1024 * 1024, PhpIniSizes::parseShortHandSize('1G')); + + + // Moving to terrabytes, we have to be careful. + // Terrabytes cannot be represented as integers on 32 bit systems. + // (on 32 bit systems, max integer value is 107.374.182.400, which can represent up to ~107G) + + // Testing floating point numbers for equality is prone to errors. + //$this->assertInternalType('int', PhpIniSizes::parseShortHandSize('10')); + //$this->assertEquals(10.0, 10); + + + // So, ie "200G" can not be represented by an int. + + // The computation goes: + // floor($size) * pow(1024, stripos('bkmgtpezy', $unit[0])); + + // floor() always returns float, according to docs (but may also + // pow() returns int unless the number is too high, in that case it returns float. + // And the result? What do you get if you multiply an int and a float (which is in fact representating an integer), + // and the result is more than PHP_INT_MAX? + // In the docs, it states the following: + // "an operation which results in a number beyond the bounds of the integer type will return a float instead." + // [https://www.php.net/manual/en/language.types.integer.php] + // Se it seems we are good. + // But let's check! + + $greatComputation = floor(100) * PHP_INT_MAX; + $this->assertGreaterThan(PHP_INT_MAX, $greatComputation); + + $greaterComputation = floatval(200) * floatval(PHP_INT_MAX); + $this->assertGreaterThan($greatComputation, $greaterComputation); + + // Test "T" unit + $this->assertGreaterThan(PhpIniSizes::parseShortHandSize('1G'), PhpIniSizes::parseShortHandSize('100T')); + $this->assertGreaterThan(1024 * 1024 * 1024 * 1024, PhpIniSizes::parseShortHandSize('1T') + 1); + + + // Test that decimals are trunked, as described here: + // https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes + $this->assertEquals(1024, PhpIniSizes::parseShortHandSize('1.5k')); + $this->assertEquals(0, PhpIniSizes::parseShortHandSize('0.5M')); + + + // Test syntax violations, which must result in parse error. + $this->assertFalse(PhpIniSizes::parseShortHandSize('0.5MM')); + $this->assertFalse(PhpIniSizes::parseShortHandSize('//5')); + } + + /* TODO... + public function testTestFilesizeRequirements() + { + $iniValue = ini_get('upload_max_filesize'); + + // could we call ini_set? instead of mocking ini_get ? + } + */ +} diff --git a/tests/Convert/TestConverters/ExposedConverter.php b/tests/Convert/TestConverters/ExposedConverter.php new file mode 100644 index 00000000..9dc10106 --- /dev/null +++ b/tests/Convert/TestConverters/ExposedConverter.php @@ -0,0 +1,34 @@ +destination, 'we-pretend-this-is-a-valid-webp!'); + } + +/* + public static function exposedGetMimeType($filePath) + { + $instance = self::createInstance( + $filePath, + $filePath . '.webp' + ); + return $instance->getMimeTypeOfSource(); + }*/ +} diff --git a/tests/Convert/TestConverters/ExtendedConverters/EwwwExtended.php b/tests/Convert/TestConverters/ExtendedConverters/EwwwExtended.php new file mode 100644 index 00000000..b3a7eb1d --- /dev/null +++ b/tests/Convert/TestConverters/ExtendedConverters/EwwwExtended.php @@ -0,0 +1,14 @@ +doActualConvert(); + } + +} diff --git a/tests/Convert/TestConverters/FailureGuaranteedConverter.php b/tests/Convert/TestConverters/FailureGuaranteedConverter.php new file mode 100644 index 00000000..472031e1 --- /dev/null +++ b/tests/Convert/TestConverters/FailureGuaranteedConverter.php @@ -0,0 +1,14 @@ +destination, 'we-pretend-this-is-a-valid-webp!'); + } +} diff --git a/tests/Converters/CwebpTest.php b/tests/Converters/CwebpTest.php deleted file mode 100644 index 5e11c43d..00000000 --- a/tests/Converters/CwebpTest.php +++ /dev/null @@ -1,110 +0,0 @@ -assertContains($key, Cwebp::$cwebpDefaultPaths); - } - }*/ - - /** - * @expectedException \Exception - */ - /* - public function testUpdateBinariesInvalidFile() - { - $array = []; - - Cwebp::updateBinaries('InvalidFile', 'Hash', $array); - }*/ - - /** - * @expectedException \Exception - */ - /* - public function testUpdateBinariesInvalidHash() - { - $array = []; - - Cwebp::updateBinaries('cwebp-linux', 'InvalidHash', $array); - } - - public function testUpdateBinaries() - { - $file = 'cwebp.exe'; - $filePath = realpath(__DIR__ . '/../../Converters/Binaries/' . $file); - $hash = hash_file('sha256', $filePath); - $array = []; - - $this->assertContains($filePath, Cwebp::updateBinaries($file, $hash, $array)); - } - - public function testEscapeFilename() - { - $wrong = '/path/to/file Na<>me."ext"'; - $right = '/path/to/file\\\ Name.\"ext\"'; - - $this->assertEquals($right, Cwebp::escapeFilename($wrong)); - } - - public function testHasNiceSupport() - { - $this->assertNotNull(Cwebp::hasNiceSupport()); - }*/ -/* - public function testConvert() - { - $source = realpath(__DIR__ . '/../test.jpg'); - $destination = realpath(__DIR__ . '/../test.webp'); - $quality = 85; - $stripMetadata = true; - - $this->assertTrue(Cwebp::convert($source, $destination, $quality, $stripMetadata)); - }*/ - - /** - * Test convert. - * - It must either make a successful conversion, or throw an ConverterNotOperationalException - * It may not throw a ConverterFailedException because if it is operational, then it should also - * be able to do the conversion. - * It may not throw a normal Exception either - * - It must not return anything - */ - public function testConvert() - { - try { - $source = (__DIR__ . '/../test.jpg'); - $destination = (__DIR__ . '/../test.webp'); - - $result = Cwebp::convert($source, $destination); - - $this->assertTrue(file_exists($destination)); - $this->assertEmpty($result); - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { - // The converter is not operational. - // and that is ok! - } - } -} diff --git a/tests/Converters/EwwwTest.php b/tests/Converters/EwwwTest.php deleted file mode 100644 index 79675af2..00000000 --- a/tests/Converters/EwwwTest.php +++ /dev/null @@ -1,51 +0,0 @@ -assertTrue(file_exists($destination)); - $this->assertEmpty($result); - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { - // The converter is not operational. - // and that is ok! - } - } - public function testIsValidKey() - { - $invalidKey = 'notvalidno'; - $this->assertFalse(Ewww::isValidKey($invalidKey)); - - $demoKey = 'abc123'; - $this->assertTrue(Ewww::isValidKey($demoKey)); - } - - public function testIsWorkingKey() - { - $invalidKey = 'notvalidno'; - $this->assertFalse(Ewww::isWorkingKey($invalidKey)); - } -} diff --git a/tests/Converters/GdTest.php b/tests/Converters/GdTest.php deleted file mode 100644 index 379f203c..00000000 --- a/tests/Converters/GdTest.php +++ /dev/null @@ -1,69 +0,0 @@ - true - )); - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { - // converter isn't operational, so we cannot make the unit test - return; - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { - // Yeah, this is what we want to test. - $this->expectException(\WebPConvert\Converters\Exceptions\ConversionDeclinedException::class); - Gd::convert($source, $destination, array( - 'skip-pngs' => true - )); - } - } - - public function testTargetNotFound() - { - - $this->expectException(\WebPConvert\Exceptions\TargetNotFoundException::class); - - Gd::convert(__DIR__ . '/i-dont-exist.jpg', __DIR__ . '/i-dont-exist.webp'); - } - - public function testConvert() - { - try { - $source = (__DIR__ . '/../test.jpg'); - $destination = (__DIR__ . '/../test.webp'); - - $result = Gd::convert($source, $destination); - - $this->assertTrue(file_exists($destination)); - $this->assertEmpty($result); - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { - // The converter is not operational. - // and that is ok! - } - } -} diff --git a/tests/Converters/ImagickTest.php b/tests/Converters/ImagickTest.php deleted file mode 100644 index 1991d371..00000000 --- a/tests/Converters/ImagickTest.php +++ /dev/null @@ -1,66 +0,0 @@ -assertTrue(file_exists($destination)); - $this->assertEmpty($result); - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { - // The converter is not operational. - // and that is ok! - } - } - - public function testInvalidDestinationFolder() - { - - try { - // We can only do this test, if the converter is operational. - // In order to test that, we first do a normal conversion - $source = (__DIR__ . '/../test.jpg'); - $destination = (__DIR__ . '/../test.webp'); - - Imagick::convert($source, $destination); - - // if we are here, it means that the converter is operational. - // Now do something that tests that the converter fails the way it should, - // when it cannot create the destination file - - $this->expectException(\WebPConvert\Converters\Exceptions\ConverterFailedException::class); - - // I here assume that no system grants write access to their root folder - // this is perhaps wrong to assume? - $destinationFolder = '/you-can-delete-me/'; - - Imagick::convert(__DIR__ . '/../test.jpg', $destinationFolder . 'you-can-delete-me.webp'); - } catch (\Exception $e) { - // its ok... - } - } -} diff --git a/tests/Converters/WPCTest.php b/tests/Converters/WPCTest.php deleted file mode 100644 index 7f5b7f7f..00000000 --- a/tests/Converters/WPCTest.php +++ /dev/null @@ -1,37 +0,0 @@ -expectException(\WebPConvert\Converters\Exceptions\ConverterNotOperationalException::class); - Wpc::convert(__DIR__ . '/../test.jpg', __DIR__ . '/../test.webp', [ - 'url' => '', - 'secret' => 'bad dog!', - ]); - } - - public function testBadURL() - { - $this->expectException(\WebPConvert\Converters\Exceptions\ConverterNotOperationalException::class); - Wpc::convert(__DIR__ . '/../test.jpg', __DIR__ . '/../test.webp', [ - 'url' => 'badurl!', - 'secret' => 'bad dog!', - ]); - } - - - -} diff --git a/tests/Helpers/SanitizeTest.php b/tests/Helpers/SanitizeTest.php new file mode 100644 index 00000000..5a4750da --- /dev/null +++ b/tests/Helpers/SanitizeTest.php @@ -0,0 +1,38 @@ +assertEquals( + 'a', + Sanitize::removeNUL("a\0") + ); + } + + /** + * @covers ::removeStreamWrappers + */ + public function testRemoveStreamWrappers() + { + $this->assertEquals( + 'dytdyt', + Sanitize::removeStreamWrappers("phar://dytdyt") + ); + } + +} diff --git a/tests/Helpers/SanityCheckTestNotReady.php b/tests/Helpers/SanityCheckTestNotReady.php new file mode 100644 index 00000000..27f91f80 --- /dev/null +++ b/tests/Helpers/SanityCheckTestNotReady.php @@ -0,0 +1,276 @@ +expectException(SanityException::class); + + SanityCheck::noNUL("here it comes: \0"); + } + + /** + * @covers ::noNUL + */ + public function testNoNUL2() + { + $this->expectException(SanityException::class); + + SanityCheck::noNUL("here it comes: " . chr(0)); + } + + /** + * @covers ::noNUL + */ + public function testNoNUL3() + { + SanityCheck::noNUL("here it does not come"); + } + + /** + * @covers ::noControlChars + */ + public function testNoControlChars() + { + $this->expectException(SanityException::class); + $sanitized = SanityCheck::noControlChars("..\1.."); + } + + /** + * @covers ::noControlChars + */ + public function testNoControlChars2() + { + $this->expectException(SanityException::class); + $sanitized = SanityCheck::noControlChars("..\n.."); + } + + /** + * @covers ::noControlChars + */ + public function testNoControlChars3() + { + //$this->expectException(SanityException::class); + $unsanitized = urldecode("%EF%B8%8F"); + //echo 'look:' . $unsanitized; + $sanitized = SanityCheck::noControlChars($unsanitized); + //echo $sanitized; + } + + /** + * @covers ::noControlChars + */ + public function testNoControlChars4() + { + //$this->expectException(SanityException::class); + //SanityCheck::noControlChars("Skærmbillede"); + //SanityCheck::noControlChars("Skrm"); + //SanityCheck::noControlChars("Skærmbillede-2018-10-12-kl.-11.26.38-e1539336533920.png.webp"); + SanityCheck::noControlChars("Skærmbillede-2018-10-12-kl.-11.26.38-e1539336533920.png.webp"); + $sanitized = SanityCheck::noControlChars("space is ok."); + echo $sanitized; + } + + + + /** + * @covers ::notEmpty + */ + public function testNotEmpty() + { + $this->expectException(SanityException::class); + SanityCheck::notEmpty(null); + } + + /** + * @covers ::notEmpty + */ + /* + public function testNotEmpty2() + { + $arr = []; + $this->expectException(SanityException::class); + SanityCheck::notEmpty($arr['not-exist']); + } + */ + + /** + * @covers ::notEmpty + */ + public function testNotEmpty2() + { + SanityCheck::notEmpty('..'); + } + + /** + * @covers ::noDirectoryTraversal + */ + public function testNoDirectoryTraversal() + { + $this->expectException(SanityException::class); + SanityCheck::noDirectoryTraversal('hello/../hi'); + } + + /** + * @covers ::noStreamWrappers + */ + public function testNoStreamWrappers() + { + $this->expectException(SanityException::class); + SanityCheck::noStreamWrappers('phar://aoeu'); + } + + /** + * @covers ::noStreamWrappers + */ + public function testNoStreamWrappers2() + { + $this->expectException(SanityException::class); + SanityCheck::noStreamWrappers("phar:\0//aoeu"); + } + + /** + * @covers ::mustBeString + */ + public function testMustBeString() + { + $this->expectException(SanityException::class); + SanityCheck::mustBeString(0); + } + + /** + * @covers ::mustBeString + */ + public function testMustBeString2() + { + SanityCheck::mustBeString(''); + SanityCheck::mustBeString('hello'); + } + + /** + * @covers ::isJSONArray + */ + public function testIsJSONArray() + { + $this->expectException(SanityException::class); + SanityCheck::isJSONArray(''); + } + + /** + * @covers ::isJSONArray + */ + public function testIsJSONArray2() + { + SanityCheck::isJSONArray('[]'); + SanityCheck::isJSONArray('["hello", "hi"]'); + } + + /** + * @covers ::pregMatch + */ + public function testPregMatch() + { + $this->expectException(SanityException::class); + SanityCheck::pregMatch('#\d#', 'a'); + } + + /** + * @covers ::pregMatch + */ + public function testPregMatch2() + { + SanityCheck::pregMatch('#\d#', '0'); + SanityCheck::pregMatch('#^[a-z]+$#', 'gd'); + } + + public function testPathBeginsWith() + { + SanityCheck::pathBeginsWith('/var/www/my-site/hello.php', '/var/www/'); + } + + public function testPathBeginsWith2() + { + $this->expectException(SanityException::class); + SanityCheck::pathBeginsWith('/var/bin/exec', '/var/www/'); + } + + public function testFindClosestExistingFolderSymLinksExpanded() + { + /* + $this->assertEquals( + '/var/www/webp-express-tests/we0/plugins-moved/webp-express', + SanityCheck::findClosestExistingFolderSymLinksExpanded( + '/var/www/webp-express-tests/we0/plugins-moved/webp-express/i/do/not/exist/test-pattern-tv.jpg' + ) + );*/ + + //echo dirname() + /* + echo 'dir:' . SanityCheck::findClosestExistingFolderSymLinksExpanded( + '/var/www/webp-express-tests/we19/wp-content/webp-express/webp-images/doc-root/wp-content/plugins/webp-express/test/test-pattern-tv.jpg.webp' + );*/ + } + +/* + public function testPathBeginsWithSymLinksExpanded() + { + $this->expectException(SanityException::class); + SanityCheck::pathBeginsWithSymLinksExpanded( + '/aoeu/var/www/webp-express-tests/we19/wp-content/webp-express/webp-images/doc-root/wp-content/plugins/webp-express/test/test-pattern-tv.jpg.webp', + '/var/www/webp-express-tests/' + ); + } + + public function testPathBeginsWithSymLinksExpanded2() + { + SanityCheck::pathBeginsWithSymLinksExpanded( + '/var/www/webp-express-tests/we19/wp-content/webp-express/webp-images/doc-root/wp-content/plugins/webp-express/test/test-pattern-tv.jpg.webp', + '/var/www/webp-express-tests/' + ); + } + + public function testPathBeginsWithSymLinksExpanded3() + { + $this->expectException(SanityException::class); + SanityCheck::pathBeginsWithSymLinksExpanded( + 'aoeu/var/www/webp-express-tests/we19/wp-content/webp-express/webp-images/doc-root/wp-content/plugins/webp-express/test/test-pattern-tv.jpg.webp', + '/var/www/webp-express-tests/' + ); + }*/ + + public function testAbsPathMicrosoftStyle() + { + SanityCheck::absPathMicrosoftStyle("C:\\"); + SanityCheck::absPathMicrosoftStyle("C:/"); + } + + public function testAbsPathMicrosoftStyle2() + { + $this->expectException(SanityException::class); + SanityCheck::absPathMicrosoftStyle("C:1"); + } + + public function testAbsPath() + { + SanityCheck::absPath('/var/bin/exec'); + SanityCheck::absPath('var/bin/exec'); + } + + + +} diff --git a/tests/Helpers/WarningsIntoExceptionsTest.php b/tests/Helpers/WarningsIntoExceptionsTest.php new file mode 100644 index 00000000..6206bc13 --- /dev/null +++ b/tests/Helpers/WarningsIntoExceptionsTest.php @@ -0,0 +1,64 @@ +assertTrue(true); + } +/* + private static $imgDir = __DIR__ . '/../../images'; + + public function testUserWarning() + { + WarningsIntoExceptions::activate(); + + $this->expectException(WarningException::class); + + // trigger user warning + trigger_error('warning test', E_USER_WARNING); + + WarningsIntoExceptions::deactivate(); + } + + + public function testWarning() + { + WarningsIntoExceptions::activate(); + + $this->expectException(WarningException::class); + + // trigger build-in warning (chmod expects exactly two parameters) + chmod('hth'); + WarningsIntoExceptions::deactivate(); + }*/ + + +/* + To suppress and capture output from exec calls, you need to redirect the stderr to stdout. + Otherwise it is "echoed to screen" + + + https://stackoverflow.com/questions/1606943/suppressing-output-from-exec-calls-in-php + public function testWarning2() + { + WarningsIntoExceptions::activate(); + + //$this->expectException(WarningException); + + //ob_start(); + exec('hahotehua 2>&1', $output, $returnCode); + //ob_end_clean(); + + WarningsIntoExceptions::deactivate(); + + + }*/ +} diff --git a/tests/Serve/HeaderTest.php b/tests/Serve/HeaderTest.php new file mode 100644 index 00000000..ded4e64a --- /dev/null +++ b/tests/Serve/HeaderTest.php @@ -0,0 +1,49 @@ +assertEquals('X-test: testing', $header0[0]); + $this->assertFalse($header0[1]); + + Header::addHeader('X-test: testing2'); + $header1 = MockedHeader::getHeaders()[1]; + $this->assertEquals('X-test: testing2', $header1[0]); + $this->assertFalse($header1[1]); + } + + public function testSetHeader() + { + MockedHeader::reset(); + + Header::setHeader('X-test: testing set header'); + $header0 = MockedHeader::getHeaders()[0]; + $this->assertEquals('X-test: testing set header', $header0[0]); + $this->assertTrue($header0[1]); + } + + public function testAddLogHeader() + { + MockedHeader::reset(); + Header::addLogHeader('test', new BufferLogger()); + $header0 = MockedHeader::getHeaders()[0]; + $this->assertEquals('X-WebP-Convert-Log: test', $header0[0]); + $this->assertFalse($header0[1]); + + } +} +require_once('mock-header.inc'); diff --git a/tests/Serve/ServeConvertedTest.php b/tests/Serve/ServeConvertedTest.php deleted file mode 100644 index 3e03dc92..00000000 --- a/tests/Serve/ServeConvertedTest.php +++ /dev/null @@ -1,85 +0,0 @@ -assertFalse($thisHappened); - return false; - } - - function testSourceIsLighter() - { - ServeConverted2::serveConverted( - self::$imageDir . 'test.jpg', - self::$imageDir . 'pre-converted/test-bigger.webp', - [ - 'aboutToServeImageCallBack' => function($servingWhat, $whyServingThis, $obj) { - $this->assertEquals($servingWhat, 'source'); - $this->assertEquals($whyServingThis, 'source-lighter'); - return false; - }, - 'aboutToPerformFailActionCallback' => array($this, 'mustNotHappen') - ] - ); - } - - function testServeOriginal() - { - ServeConverted2::serveConverted( - self::$imageDir . 'test.jpg', - self::$imageDir . 'test.webp', - [ - 'serve-original' => true, - 'aboutToServeImageCallBack' => function($servingWhat, $whyServingThis, $obj) { - $this->assertEquals($servingWhat, 'source'); - $this->assertEquals($whyServingThis, 'explicitly-told-to'); - return false; - }, - 'aboutToPerformFailActionCallback' => array($this, 'mustNotHappen') - ] - ); - } - - function testServeDestination() - { - ServeConverted2::serveConverted( - self::$imageDir . 'test.jpg', - self::$imageDir . 'pre-converted/test.webp', - [ - 'aboutToServeImageCallBack' => function($servingWhat, $whyServingThis, $obj) { - $this->assertEquals($servingWhat, 'destination'); - $this->assertEquals($whyServingThis, 'no-reason-not-to'); - return false; - }, - 'aboutToPerformFailActionCallback' => array($this, 'mustNotHappen') - ] - ); - } - -} diff --git a/tests/Serve/ServeConvertedWebPExposer.php b/tests/Serve/ServeConvertedWebPExposer.php new file mode 100644 index 00000000..e4e576ed --- /dev/null +++ b/tests/Serve/ServeConvertedWebPExposer.php @@ -0,0 +1,25 @@ +callPrivateFunction('serveDestination', null, $destination, $options); + }*/ +} diff --git a/tests/Serve/ServeConvertedWebPTest.php b/tests/Serve/ServeConvertedWebPTest.php new file mode 100644 index 00000000..8df947dd --- /dev/null +++ b/tests/Serve/ServeConvertedWebPTest.php @@ -0,0 +1,464 @@ +getImageFolder() . '/' . $image; + } + + + /** + * Call to serve and return result or exception + * + * The method takes care of closing output buffer in case of exception + * + * @return array First item: the output, second item: Exception, if thrown + */ + public static function callServe($filename, $destination, $options) + { + ob_start(); + try { + ServeConvertedWebP::serve($filename, $destination, $options); + } catch (\Exception $e) { + return [ob_get_clean(), $e]; + } catch (\Throwable $e) { + return [ob_get_clean(), $e]; + } + return [ob_get_clean(), null]; + } + + /** + * Call to serve and return result or exception + * + * The method takes care of closing output buffer in case of exception + * + * @return string the output + */ + public static function callServeWithThrow($filename, $destination, $options) + { + ob_start(); + try { + ServeConvertedWebP::serve($filename, $destination, $options); + } catch (\Exception $e) { + ob_get_clean(); + throw($e); + } catch (\Throwable $e) { + ob_get_clean(); + throw($e); + } + return ob_get_clean(); + } + + /** + * Call to serve and return result or exception + * + * The method takes care of closing output buffer in case of exception + * + * @return string the output + */ + public static function callServeOriginalWithThrow($filename, $options) + { + ob_start(); + try { + ServeConvertedWebP::serveOriginal($filename, $options); + } catch (\Exception $e) { + ob_get_clean(); + throw($e); + } catch (\Throwable $e) { + ob_get_clean(); + throw($e); + } + return ob_get_clean(); + } + + /** + * @covers ::serveOriginal + */ + public function testServeOriginal() + { + $source = $this->getImagePath('test.png'); + $this->assertTrue(file_exists($source), 'source file does not exist:' . $source); + + $destination = $source . '.webp'; + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeOriginalWithThrow($source, $options); + + // Test that headers were set as expected + //$this->assertTrue(MockedHeader::hasHeaderContaining('X-WebP-Convert-Action:')); + + $this->assertTrue(MockedHeader::hasHeader('Content-Type: image/png')); + $this->assertFalse(MockedHeader::hasHeader('Vary: Accept')); + $this->assertTrue(MockedHeader::hasHeaderContaining('Last-Modified:')); + $this->assertFalse(MockedHeader::hasHeaderContaining('Cache-Control:')); + } + + + /** + * @covers ::serveOriginal + */ + public function testServeOriginalNotAnImage() + { + //$this->expectException(InvalidImageTypeException::class); + $this->expectException(ServeFailedException::class); + + $source =$this->getImagePath('text.txt'); + $this->assertTrue(file_exists($source), 'source file does not exist'); + + $contentType = ImageMimeTypeGuesser::lenientGuess($source); + $this->assertSame(false, $contentType); + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeOriginalWithThrow($source, []); + $this->assertEquals('', $result); + } + + /** + * @covers ::serveOriginal + */ + public function testServeOriginalNotAnImage2() + { + //$this->expectException(InvalidImageTypeException::class); + $this->expectException(ServeFailedException::class); + + $source = $this->getImagePath('text'); + $this->assertTrue(file_exists($source), 'source file does not exist'); + + $contentType = ImageMimeTypeGuesser::lenientGuess($source); + $this->assertSame(null, $contentType); + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeOriginalWithThrow($source, $options); + $this->assertEquals('', $result); + } + + /** + * @covers ::serve + */ + public function testServeReconvert() + { + $source = $this->getImagePath('test.png'); + $this->assertTrue(file_exists($source)); + + $destination = $source . '.webp'; + + $options = [ + //'serve-original' => true, + 'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $destination, $options); + + // Test that headers were set as expected + //$this->assertTrue(MockedHeader::hasHeaderContaining('X-WebP-Convert-Action:')); + + $this->assertTrue(MockedHeader::hasHeader('Content-Type: image/webp')); + $this->assertFalse(MockedHeader::hasHeader('Vary: Accept')); + $this->assertTrue(MockedHeader::hasHeaderContaining('Last-Modified:')); + $this->assertFalse(MockedHeader::hasHeaderContaining('Cache-Control:')); + } + + /** + * @covers ::serve + */ + public function testServeServeOriginal() + { + $source = $this->getImagePath('test.png'); + $this->assertTrue(file_exists($source)); + + $destination = $source . '.webp'; + + $options = [ + 'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $destination, $options); + + // Test that headers were set as expected + //$this->assertTrue(MockedHeader::hasHeaderContaining('X-WebP-Convert-Action:')); + + $this->assertTrue(MockedHeader::hasHeader('Content-Type: image/png')); + $this->assertFalse(MockedHeader::hasHeader('Vary: Accept')); + $this->assertTrue(MockedHeader::hasHeaderContaining('Last-Modified:')); + $this->assertFalse(MockedHeader::hasHeaderContaining('Cache-Control:')); + } + + /** + * Testing when the "cached" image can be served + * @covers ::serve + */ + public function testServeDestination() + { + $source = $this->getImagePath('/test.png'); + $this->assertTrue(file_exists($source)); + + // create fake webp at destination + $destination = $source . '.webp'; + file_put_contents($destination, '1234'); + $this->assertTrue(file_exists($destination)); + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $destination, $options); + + + // Check that destination is output (it has the content "1234") + $this->assertEquals('1234', $result); + + // Test that headers were set as expected + //$this->assertTrue(MockedHeader::hasHeaderContaining('X-WebP-Convert-Action:')); + + $this->assertTrue(MockedHeader::hasHeader('Content-Type: image/webp')); + } + + /** + * @covers ::serve + */ + public function testEmptySourceArg() + { + $this->expectException(InvalidInputException::class); + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + + $source = ''; + $this->assertEmpty($source); + $result = self::callServeWithThrow($source, $this->getImagePath('test.png.webp'), $options); + } + + /** + * @covers ::serve + */ + public function testEmptyDestinationArg() + { + $this->expectException(InvalidInputException::class); + + $source = $this->getImagePath('test.png'); + $this->assertTrue(file_exists($source)); + + $destination = ''; + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $destination, $options); + } + + /** + * @covers ::serve + */ + public function testNoFileAtSource() + { + $this->expectException(TargetNotFoundException::class); + + $source = $this->getImagePath('i-do-not-exist.png'); + $this->assertFalse(file_exists($source)); + + $destination = ''; + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $destination, $options); + } + + /** + * @covers ::serve + */ + public function testServeReport() + { + $source = $this->getImagePath('test.png'); + $this->assertTrue(file_exists($source)); + $destination = $source . '.webp'; + + $options = [ + //'serve-original' => true, + //'reconvert' => true, + 'show-report' => true, + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $destination, $options); + + // Check that output looks like a report + $this->assertTrue(strpos($result, 'source:') !== false, 'The following does not contain "source:":' . $result); + + // Test that headers were set as expected + //$this->assertTrue(MockedHeader::hasHeaderContaining('X-WebP-Convert-Action:')); + + $this->assertTrue(MockedHeader::hasHeader('Content-Type: image/webp')); + } + + public function testSourceIsLighter() + { + $source = $this->getImagePath('plaintext-with-jpg-extension.jpg'); + + // create fake webp at destination, which is larger than the fake jpg + file_put_contents($source . '.webp', 'aotehu aotnehuatnoehutaoehu atonse uaoehu'); + + $this->assertTrue(file_exists($source)); + $this->assertTrue(file_exists($source . '.webp')); + + $options = [ + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $source . '.webp', $options); + + // the source file contains "text", so the next assert asserts that source was served + $this->assertMatchesRegularExpression2('#text#', $result); + } + + public function testExistingOutDated() + { + $source = $this->getImagePath('test.jpg'); + $this->assertTrue(file_exists($source)); + + $destination = $source . '.webp'; + @unlink($destination); + copy($this->getImagePath('pre-converted/test.webp'), $destination); + + // set modification date earlier than source + touch($destination, filemtime($source) - 1000); + + // check that it worked + $this->assertLessThan(filemtime($source), filemtime($destination)); + + $options = [ + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $source . '.webp', $options); + + unlink($destination); + + // Our success-converter always creates fake webps with the content: + // "we-pretend-this-is-a-valid-webp!". + // So testing that we got this back is the same as testing that a "conversion" was + // done and the converted file was served. It is btw smaller than the source. + + $this->assertMatchesRegularExpression2('#we-pretend-this-is-a-valid-webp!#', $result); + } + + public function testNoFileAtDestination() + { + $source = $this->getImagePath('test.jpg'); + $this->assertTrue(file_exists($source)); + + $destination = $source . '.webp'; + @unlink($destination); + + $options = [ + 'convert' => [ + 'converters' => [ + '\\WebPConvert\\Tests\\Convert\\TestConverters\\SuccessGuaranteedConverter' + ] + ] + ]; + $result = self::callServeWithThrow($source, $source . '.webp', $options); + + // Our success-converter always creates fake webps with the content: + // "we-pretend-this-is-a-valid-webp!". + // So testing that we got this back is the same as testing that a "convert" was + // done and the converted file was served. It is btw smaller than the source. + + $this->assertMatchesRegularExpression2('#we-pretend-this-is-a-valid-webp!#', $result); + } + +} +require_once('mock-header.inc'); diff --git a/tests/Serve/ServeFileTest.php b/tests/Serve/ServeFileTest.php new file mode 100644 index 00000000..26ed4457 --- /dev/null +++ b/tests/Serve/ServeFileTest.php @@ -0,0 +1,236 @@ +getImageFolder() . '/' . $image; + } + + + /** + * Call to serve and return result or exception + * + * The method takes care of closing output buffer in case of exception + * + * @return array First item: the output, second item: Exception, if thrown + */ + public static function callServe($filename, $mime, $options) + { + ob_start(); + try { + ServeFile::serve($filename, $mime, $options); + } catch (\Exception $e) { + return [ob_get_clean(), $e]; + } catch (\Throwable $e) { + return [ob_get_clean(), $e]; + } + return [ob_get_clean(), null]; + } + + /** + * Call to serve and return result or exception + * + * The method takes care of closing output buffer in case of exception + * + * @return string the output + */ + public static function callServeWithThrow($filename, $mime, $options) + { + ob_start(); + try { + ServeFile::serve($filename, $mime, $options); + } catch (\Exception $e) { + ob_get_clean(); + throw($e); + } catch (\Throwable $e) { + ob_get_clean(); + throw($e); + } + return ob_get_clean(); + } + + public function testServeDefaultOptions() + { + MockedHeader::reset(); + + $filename = self::getImagePath('plaintext-with-jpg-extension.jpg'); + $this->assertTrue(file_exists($filename)); + + $result = self::callServeWithThrow($filename, 'image/webp', []); + + // Test that content of file was send to output + $isWindows = preg_match('/^win/i', PHP_OS); + if ($isWindows) { + $this->assertEquals("text\r\n", $result); + } else { + $this->assertEquals("text\n", $result); + + } + + $headers = MockedHeader::getHeaders(); + $this->assertGreaterThanOrEqual(1, MockedHeader::getNumHeaders()); + + // Test that headers were set as expected + $this->assertTrue(MockedHeader::hasHeader('Content-Type: image/webp')); + $this->assertFalse(MockedHeader::hasHeader('Vary: Accept')); + + $this->assertTrue(MockedHeader::hasHeaderContaining('Content-Length:')); + + //$this->assertTrue(MockedHeader::hasHeader('Last-Modified: Mon, 29 Apr 2019 12:54:37 GMT')); + + // TODO:The following fails on travis. WHY??? + //$this->assertTrue(MockedHeader::hasHeaderContaining('Last-Modified:')); + + //$this->assertTrue(MockedHeader::hasHeader('Cache-Control: public, max-age=86400')); + //$this->assertTrue(MockedHeader::hasHeaderContaining('Expires:')); + } + + public function testServeVaryHeader() + { + MockedHeader::reset(); + + $this->assertEquals(0, MockedHeader::getNumHeaders()); + + $filename = self::getImagePath('plaintext-with-jpg-extension.jpg'); + $this->assertTrue(file_exists($filename)); + + $options = [ + 'headers' => [ + 'vary-accept' => true + ] + ]; + + $result = self::callServeWithThrow($filename, 'image/webp', $options); + + $this->assertTrue(MockedHeader::hasHeader('Vary: Accept')); + + } + + + public function testServeNoHeaders() + { + MockedHeader::reset(); + + $this->assertEquals(0, MockedHeader::getNumHeaders()); + + $filename = self::getImagePath('plaintext-with-jpg-extension.jpg'); + $this->assertTrue(file_exists($filename)); + + $options = [ + 'headers' => [ + 'cache-control' => false, + 'content-length' => false, + 'content-type' => false, + 'expires' => false, + 'last-modified' => false, + 'vary-accept' => false + ], + 'cache-control-header' => 'private, max-age=100', + ]; + + $result = self::callServeWithThrow($filename, 'image/webp', $options); + + // Test that content of file was send to output + $isWindows = preg_match('/^win/i', PHP_OS); + if ($isWindows) { + $this->assertEquals("text\r\n", $result); + } else { + $this->assertEquals("text\n", $result); + } + + // Test that headers were set as expected + // We actually expect that none are added. + + $headers = MockedHeader::getHeaders(); + $this->assertEquals(0, MockedHeader::getNumHeaders()); + + // TODO:The following fails on travis. WHY??? + //$this->assertFalse(MockedHeader::hasHeader('Content-Type: image/webp')); + + //$this->assertTrue(MockedHeader::hasHeader('Vary: Accept')); + //$this->assertFalse(MockedHeader::hasHeader('Last-Modified: Mon, 29 Apr 2019 12:54:37 GMT')); + + // TODO:The following fails on travis. WHY??? + //$this->assertTrue(MockedHeader::hasHeaderContaining('Last-Modified:')); + + + $this->assertFalse(MockedHeader::hasHeader('Cache-Control: public, max-age=86400')); + $this->assertFalse(MockedHeader::hasHeaderContaining('Expires:')); + } + + public function testServeCustomCacheControl() + { + MockedHeader::reset(); + $filename = self::getImagePath('plaintext-with-jpg-extension.jpg'); + $this->assertTrue(file_exists($filename)); + + $options = [ + 'headers' => [ + 'cache-control' => true, + 'expires' => true, + ], + 'cache-control-header' => 'private, max-age=100', + ]; + + $result = self::callServeWithThrow($filename, 'image/webp', $options); + $this->assertTrue(MockedHeader::hasHeader('Cache-Control: private, max-age=100')); + $this->assertTrue(MockedHeader::hasHeaderContaining('Expires:')); + } + + public function testServeCustomCacheControlNoMaxAge() + { + MockedHeader::reset(); + $filename = self::getImagePath('plaintext-with-jpg-extension.jpg'); + $this->assertTrue(file_exists($filename)); + $options = [ + 'headers' => [ + 'cache-control' => true, + ], + 'cache-control-header' => 'private', + ]; + $result = self::callServeWithThrow($filename, 'image/webp', $options); + + $this->assertTrue(MockedHeader::hasHeader('Cache-Control: private')); + + // When there is no max-age, there should neither be any Expires + $this->assertFalse(MockedHeader::hasHeaderContaining('Expires:')); + } + + + public function testServeNonexistantFile() + { + //MockedHeader::reset(); + + $filename = __DIR__ . '/i-dont-exist-no'; + $this->assertFalse(file_exists($filename)); + + //$this->expectException(TargetNotFoundException::class); + list($result, $e) = self::callServe($filename, 'image/webp', []); + $this->assertSame( + 'WebPConvert\Exceptions\InvalidInput\TargetNotFoundException', + get_class($e) + ); + $this->assertSame('', $result); + //$this->assertTrue(MockedHeader::hasHeader('X-WebP-Convert-Error: Could not read file')); + } + +} +require_once('mock-header.inc'); diff --git a/tests/Serve/mock-header.inc b/tests/Serve/mock-header.inc new file mode 100644 index 00000000..98edcb18 --- /dev/null +++ b/tests/Serve/mock-header.inc @@ -0,0 +1,52 @@ +expectException(\WebPConvert\Exceptions\NoOperationalConvertersException::class); + $this->expectException(ConverterNotOperationalException::class); $source = __DIR__ . '/test.jpg'; $destination = __DIR__ . '/test.jpg.webp'; $result = WebPConvert::convert($source, $destination, array( 'converters' => array() )); - $this->assertFalse($result); + //$this->assertFalse($result); } public function testTargetNotFound() { - $this->expectException(\WebPConvert\Exceptions\TargetNotFoundException::class); + $this->expectException(TargetNotFoundException::class); WebPConvert::convert(__DIR__ . '/i-dont-existno.jpg', __DIR__ . '/i-dont-exist.webp'); //$this->assertTrue($result); } + public function warningHandler($errno, $errstr, $errfile, $errline) + { + //echo 'warning handler here'; + //return false; + } + public function testInvalidDestinationFolder() { @@ -156,18 +167,49 @@ public function testInvalidDestinationFolder() // I have reconfigured php unit to not turn warnings into exceptions (phpunit.xml.dist) // - if I did not do that, the exception would not be CreateDestinationFolderException - $this->expectException(\WebPConvert\Exceptions\CreateDestinationFolderException::class); + $isWindows = preg_match('/^win/i', PHP_OS); + if ($isWindows) { + // The test doesnt work on windows: + // Failed asserting that exception of type "WebPConvert\Convert\Exceptions\ConversionFailed\FileSystemProblems\CreateDestinationFolderException" is thrown. + // Maybe it did not get converted into an exception + + $this->addToAssertionCount(1); + return; + } + + $this->expectException(CreateDestinationFolderException::class); + //$this->expectException(\Exception::class); + + + // Set error handler in order to suppress warnings. + // (we probably get a warning because mkdir() does not have permission to create the dir it is asked to) + $handler = set_error_handler( + array($this, "warningHandler"), + E_WARNING | E_USER_WARNING | E_NOTICE | E_USER_NOTICE | E_USER_ERROR + ); + //echo 'previously defined handler:' . print_r($handler, true);*/ + + /* + set_error_handler( + array($this, "warningHandler"), + E_ALL + ); + + chown();*/ // I here assume that no system grants write access to their root folder // this is perhaps wrong to assume? $destinationFolder = '/you-can-delete-me/'; WebPConvert::convert(__DIR__ . '/test.jpg', $destinationFolder . 'you-can-delete-me.webp'); + + restore_error_handler(); } /** - * Test ConversionDeclinedException by testing Gd. + * Test ConversionSkippedException by testing Gd. */ + /* public function testDeclined() { // only try Gd @@ -190,16 +232,16 @@ public function testDeclined() ); try { WebPConvert::convert($source, $destination, $options); - } catch (\WebPConvert\Converters\Exceptions\ConverterNotOperationalException $e) { + } catch (\WebPConvert\Convert\Exceptions\SystemRequirementsNotMetException $e) { // converter isn't operational, so we cannot make the unit test return; - } catch (\WebPConvert\Converters\Exceptions\ConversionDeclinedException $e) { + } catch (\WebPConvert\Convert\Exceptions\ConversionFailed\ConversionSkippedException $e) { // Yeah, this is what we want to test. - $this->expectException(\WebPConvert\Converters\Exceptions\ConversionDeclinedException::class); + $this->expectException(\WebPConvert\Convert\Exceptions\ConversionFailed\ConversionSkippedException::class); WebPConvert::convert($source, $destination, $options); } - } + }*/ // How to test CreateDestinationFileException ? diff --git a/tests/bootstrap-webp-convert-test.php b/tests/bootstrap-webp-convert-test.php new file mode 100644 index 00000000..e7bb440c --- /dev/null +++ b/tests/bootstrap-webp-convert-test.php @@ -0,0 +1,20 @@ +