├── .eslintrc ├── .babelrc ├── .eslintignore ├── .gitignore ├── preview.gif ├── CHANGELOG.md ├── assets ├── blank.gif ├── index.js └── src │ ├── throttle.js │ ├── gaussholder.js │ ├── handlers │ ├── intersection-handler.js │ ├── scroll-handler.js │ └── handle-element.js │ ├── reconstitute-image.js │ ├── render-image-into-canvas.js │ ├── load-original.js │ └── stackblur.js ├── .webpack ├── webpack.config.prod.js ├── webpack.config.common.js └── webpack.config.dev.js ├── .circleci ├── deploy-exclude.txt ├── config.yml └── deploy.sh ├── composer.json ├── CONTRIBUTING.md ├── package.json ├── gaussholder.php ├── LICENSE.md ├── inc ├── class-wp-cli-command.php ├── frontend │ └── namespace.php ├── jpeg │ └── namespace.php └── class-plugin.php └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@humanmade" 3 | } 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | assets/src/stackblur.js 3 | dist 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer 2 | /vendor 3 | /node_modules 4 | /dist 5 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanmade/Gaussholder/HEAD/preview.gif -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | - Bug: Fix stray characters in img tag attributes #47 4 | -------------------------------------------------------------------------------- /assets/blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humanmade/Gaussholder/HEAD/assets/blank.gif -------------------------------------------------------------------------------- /.webpack/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const common = require( './webpack.config.common' ); 2 | 3 | module.exports = { 4 | ...common, 5 | mode: 'production', 6 | }; 7 | -------------------------------------------------------------------------------- /assets/index.js: -------------------------------------------------------------------------------- 1 | import Gaussholder from './src/gaussholder'; 2 | 3 | document.addEventListener( 'DOMContentLoaded', Gaussholder ); 4 | 5 | // Add the Gaussholder function to the window object. 6 | window.GaussHolder = Gaussholder; 7 | -------------------------------------------------------------------------------- /.circleci/deploy-exclude.txt: -------------------------------------------------------------------------------- 1 | # Add files or patterns to exclude from the built branch here. 2 | # Consult the "INCLUDE/EXCLUDE PATTERN RULES" section of the rsync manual for 3 | # supported patterns. 4 | # 5 | # Note: Excluding ".circleci" will cause your branch to fail. Use the 6 | # `branches` option in config.yml instead. 7 | 8 | .git 9 | .gitignore 10 | Gruntfile.js 11 | node_modules 12 | package.json 13 | package-lock.json 14 | -------------------------------------------------------------------------------- /.webpack/webpack.config.common.js: -------------------------------------------------------------------------------- 1 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 2 | 3 | module.exports = { 4 | entry: [ './assets/index.js' ], 5 | output: { 6 | filename: 'gaussholder.min.js', 7 | }, 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.js$/, 12 | exclude: /(node_modules)/, 13 | loader: 'babel-loader', 14 | }, 15 | ], 16 | }, 17 | plugins: [ 18 | new CleanWebpackPlugin(), 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /.webpack/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const common = require( './webpack.config.common' ); 2 | 3 | module.exports = { 4 | ...common, 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | module: { 8 | ...common.module, 9 | rules: [ 10 | ...common.module.rules, 11 | { 12 | test: /\.js?$/, 13 | exclude: /(node_modules|bower_components)/, 14 | enforce: 'pre', 15 | loader: require.resolve( 'eslint-loader' ), 16 | options: {}, 17 | }, 18 | ] 19 | 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /assets/src/throttle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://stackoverflow.com/questions/27078285/simple-throttle-in-js 3 | * 4 | * @param {Function} callback Function to throttle. 5 | * @param {number} limit Throttle time 6 | * 7 | * @returns {function(): void} throttleled callback. 8 | */ 9 | const throttle = function ( callback, limit ) { 10 | let waiting = false; 11 | return function () { 12 | if ( ! waiting ) { 13 | callback.apply( this, arguments ); 14 | waiting = true; 15 | setTimeout( function () { 16 | waiting = false; 17 | }, limit ); 18 | } 19 | }; 20 | }; 21 | 22 | export default throttle; 23 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/php:7.3-node 6 | 7 | branches: 8 | only: 9 | # Whitelist branches to build for. 10 | - master 11 | steps: 12 | # Checkout repo & subs: 13 | - checkout 14 | 15 | # Get npm cache: 16 | - restore_cache: 17 | key: npm 18 | 19 | # Build steps: 20 | - run: npm install 21 | - run: npm run build 22 | 23 | # Make fast: 24 | - save_cache: 25 | key: npm 26 | paths: 27 | - ~/.npm 28 | 29 | # Run the deploy: 30 | - deploy: 31 | command: .circleci/deploy.sh 32 | -------------------------------------------------------------------------------- /assets/src/gaussholder.js: -------------------------------------------------------------------------------- 1 | import handleElement from './handlers/handle-element'; 2 | import intersectionHandler from './handlers/intersection-handler'; 3 | import scrollHandler from './handlers/scroll-handler'; 4 | 5 | /** 6 | * Initializes Gaussholder. 7 | */ 8 | export default function () { 9 | const images = document.getElementsByTagName( 'img' ); 10 | 11 | if ( typeof IntersectionObserver === 'undefined' ) { 12 | // Old browser. Handle events based on scrolling. 13 | scrollHandler( images ); 14 | } else { 15 | // Use the Intersection Observer API. 16 | intersectionHandler( images ); 17 | } 18 | 19 | // Initialize all images. 20 | Array.prototype.slice.call( images ).forEach( handleElement ); 21 | } 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/gaussholder", 3 | "description": "Fast and lightweight image previews for WordPress", 4 | "license": "GPL-2.0-or-later", 5 | "authors": [ 6 | { 7 | "name": "Contributors", 8 | "homepage": "https://github.com/humanmade/Gaussholder/graphs/contributors" 9 | } 10 | ], 11 | "type": "wordpress-plugin", 12 | "minimum-stability": "dev", 13 | "prefer-stable": true, 14 | "support": { 15 | "wiki": "https://github.com/humanmade/Gaussholder/wiki", 16 | "source": "https://github.com/humanmade/Gaussholder/releases", 17 | "issues": "https://github.com/humanmade/Gaussholder/issues" 18 | }, 19 | "keywords": [ 20 | "wordpress", 21 | "plugin", 22 | "images" 23 | ], 24 | "require": { 25 | "php": ">=5.3", 26 | "composer/installers": "~1.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/src/handlers/intersection-handler.js: -------------------------------------------------------------------------------- 1 | import loadImageCallback from '../load-original'; 2 | 3 | /** 4 | * Handles the images on screen by using the Intersection Observer API. 5 | * 6 | * @param {NodeList} images List of images in DOM to handle. 7 | */ 8 | const intersectionHandler = function ( images ) { 9 | const options = { 10 | rootMargin: '1200px', // Threshold that Intersection API uses to detect the intersection between the image and the main element in the page. 11 | }; 12 | 13 | const imagesObserver = new IntersectionObserver( entries => { 14 | const visibleImages = entries.filter( ( { isIntersecting } ) => isIntersecting === true ); 15 | 16 | visibleImages.forEach( ( { target } ) => { 17 | loadImageCallback( target ); 18 | imagesObserver.unobserve( target ); 19 | } ); 20 | }, options ); 21 | 22 | Array.from( images ).forEach( img => { 23 | imagesObserver.observe( img ); 24 | } ); 25 | }; 26 | 27 | export default intersectionHandler; 28 | -------------------------------------------------------------------------------- /assets/src/handlers/scroll-handler.js: -------------------------------------------------------------------------------- 1 | import loadImageCallback from '../load-original'; 2 | import throttle from '../throttle'; 3 | 4 | let loadLazily = []; 5 | 6 | /** 7 | * Handle images when scrolling. Suitable for older browsers. 8 | */ 9 | const scrollHandler = function () { 10 | let threshold = 1200; 11 | let next = []; 12 | for ( let i = loadLazily.length - 1; i >= 0; i-- ) { 13 | let img = loadLazily[i]; 14 | let shouldShow = img.getBoundingClientRect().top <= ( window.innerHeight + threshold ); 15 | if ( ! shouldShow ) { 16 | next.push( img ); 17 | continue; 18 | } 19 | 20 | loadImageCallback( img ); 21 | } 22 | loadLazily = next; 23 | }; 24 | 25 | /** 26 | * Scroll handle initialization. 27 | * 28 | * @param {NodeList} images List of images on screen. 29 | */ 30 | const init = function ( images ) { 31 | loadLazily = images; 32 | 33 | const throttledHandler = throttle( scrollHandler, 40 ); 34 | scrollHandler(); 35 | window.addEventListener( 'scroll', throttledHandler ); 36 | 37 | const finishedTimeoutCheck = window.setInterval( function () { 38 | if ( loadLazily.length < 1 ) { 39 | window.removeEventListener( 'scroll', throttledHandler ); 40 | window.clearInterval( finishedTimeoutCheck ); 41 | } 42 | }, 1000 ); 43 | }; 44 | 45 | export default init; 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To get started follow these steps: 4 | 5 | 1. `git clone git@github.com:humanmade/gaussholder.git` or fork the repository and clone your fork. 6 | 1. `cd gaussholder` 7 | 1. `npm install` 8 | 1. `npm run build` (to build the assets) 9 | 10 | You should then start working on your fork or branch. 11 | 12 | ## Making a pull request 13 | 14 | When you make a pull request it will be reviewed. You should also update the `CHANGELOG.md` - add lines after the title such as `- Bug: Fixed a typo #33` to describe each change and link to the issues if applicable. 15 | 16 | ## Making a new release 17 | 18 | 1. Create a new branch 19 | 2. Update the version number in the comment header of `plugin.php` to reflect the nature of the changes, this plugin follows semver versioning. 20 | - For small changes like bug fixes update the patch version 21 | - For changes that add functionality without changing existing functionality update the minor version 22 | - For breaking or highly significant changes update the major version 23 | 3. Add a title heading for the version number above the latest updates in `CHANGELOG.md` 24 | 4. Create a pull request for the branch 25 | 5. Once merged a release will be built and deployed by CircleCI corresponding to the version number in `plugin.php` 26 | -------------------------------------------------------------------------------- /assets/src/reconstitute-image.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {number} buffer Buffer Size. 3 | * 4 | * @returns {string} Base64 string. 5 | */ 6 | function arrayBufferToBase64( buffer ) { 7 | let binary = ''; 8 | let bytes = new Uint8Array( buffer ); 9 | let len = bytes.byteLength; 10 | for ( let i = 0; i < len; i++ ) { 11 | binary += String.fromCharCode( bytes[ i ] ); 12 | } 13 | return window.btoa( binary ); 14 | } 15 | 16 | /** 17 | * @param {*} header Gaussholder header. 18 | * @param {Array} image Image node. 19 | * 20 | * @returns {string} Base 64 string 21 | */ 22 | function reconstituteImage( header, image ) { 23 | let image_data = image[0], 24 | width = parseInt( image[1] ), 25 | height = parseInt( image[2] ); 26 | 27 | let full = atob( header.header ) + atob( image_data ); 28 | let bytes = new Uint8Array( full.length ); 29 | for ( let i = 0; i < full.length; i++ ) { 30 | bytes[i] = full.charCodeAt( i ); 31 | } 32 | 33 | // Poke the bits. 34 | bytes[ header.height_offset ] = ( ( height >> 8 ) & 0xFF ); 35 | bytes[ header.height_offset + 1 ] = ( height & 0xFF ); 36 | bytes[ header.length_offset ] = ( ( width >> 8 ) & 0xFF ); 37 | bytes[ header.length_offset + 1] = ( width & 0xFF ); 38 | 39 | // Back to a full JPEG now. 40 | return arrayBufferToBase64( bytes ); 41 | } 42 | 43 | export default reconstituteImage; 44 | -------------------------------------------------------------------------------- /assets/src/render-image-into-canvas.js: -------------------------------------------------------------------------------- 1 | import reconstituteImage from './reconstitute-image'; 2 | import StackBlur from './stackblur'; 3 | 4 | const { GaussholderHeader } = window; 5 | 6 | /** 7 | * Render an image into a Canvas 8 | * 9 | * @param {HTMLCanvasElement} canvas Canvas element to render into 10 | * @param {Array} image 3-tuple of base64-encoded image data, width, height 11 | * @param {Array} final Final width and height 12 | * @param {Function} cb Callback 13 | */ 14 | function renderImageIntoCanvas( canvas, image, final, cb ) { 15 | let ctx = canvas.getContext( '2d' ), 16 | width = parseInt( final[0] ), 17 | height = parseInt( final[1] ), 18 | radius = parseInt( final[2] ); 19 | 20 | // Ensure smoothing is off 21 | ctx.mozImageSmoothingEnabled = false; 22 | ctx.webkitImageSmoothingEnabled = false; 23 | ctx.msImageSmoothingEnabled = false; 24 | ctx.imageSmoothingEnabled = false; 25 | 26 | let img = new Image(); 27 | img.src = 'data:image/jpg;base64,' + reconstituteImage( GaussholderHeader, image ); 28 | /** 29 | * 30 | */ 31 | img.onload = function () { 32 | canvas.width = width; 33 | canvas.height = height; 34 | 35 | ctx.drawImage( img, 0, 0, width, height ); 36 | StackBlur.canvasRGB( canvas, 0, 0, width, height, radius ); 37 | cb(); 38 | }; 39 | } 40 | 41 | export default renderImageIntoCanvas; 42 | -------------------------------------------------------------------------------- /assets/src/handlers/handle-element.js: -------------------------------------------------------------------------------- 1 | import renderImageIntoCanvas from '../render-image-into-canvas'; 2 | 3 | /** 4 | * Render placeholder for an image 5 | * 6 | * @param {HTMLImageElement} element Element to render placeholder for 7 | */ 8 | let handleElement = function ( element ) { 9 | if ( ! ( 'gaussholder' in element.dataset ) ) { 10 | return; 11 | } 12 | 13 | let canvas = document.createElement( 'canvas' ); 14 | let final = element.dataset.gaussholderSize.split( ',' ); 15 | 16 | // Set the dimensions... 17 | element.style.width = final[0] + 'px'; 18 | element.style.height = final[1] + 'px'; 19 | 20 | // ...then recalculate based on what it actually renders as 21 | let original = [ final[0], final[1] ]; 22 | if ( element.width < final[0] ) { 23 | // Rescale, keeping the aspect ratio 24 | final[0] = element.width; 25 | final[1] = final[1] * ( final[0] / original[0] ); 26 | } else if ( element.height < final[1] ) { 27 | // Rescale, keeping the aspect ratio 28 | final[1] = element.height; 29 | final[0] = final[0] * ( final[1] / original[1] ); 30 | } 31 | 32 | // Set dimensions, _again_ 33 | element.style.width = final[0] + 'px'; 34 | element.style.height = final[1] + 'px'; 35 | 36 | renderImageIntoCanvas( canvas, element.dataset.gaussholder.split( ',' ), final, function () { 37 | // Load in as our background image 38 | element.style.backgroundImage = 'url("' + canvas.toDataURL() + '")'; 39 | element.style.backgroundRepeat = 'no-repeat'; 40 | } ); 41 | }; 42 | 43 | export default handleElement; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gaussholder", 3 | "version": "1.1.4", 4 | "description": "Quick and beautiful image placeholders using Gaussian blur.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint:js": "eslint ./assets", 8 | "build": "webpack --config=./.webpack/webpack.config.prod.js", 9 | "start": "webpack --watch --config=./.webpack/webpack.config.dev.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/humanmade/Gaussholder.git" 14 | }, 15 | "author": "Human Made", 16 | "license": "GPL-2.0+", 17 | "bugs": { 18 | "url": "https://github.com/humanmade/Gaussholder/issues" 19 | }, 20 | "homepage": "https://github.com/humanmade/Gaussholder#readme", 21 | "devDependencies": { 22 | "@babel/core": "^7.19.6", 23 | "@babel/preset-env": "^7.12.1", 24 | "@humanmade/eslint-config": "^1.1.1", 25 | "babel-core": "^6.26.3", 26 | "babel-eslint": "^10.1.0", 27 | "babel-loader": "^8.2.1", 28 | "clean-webpack-plugin": "^3.0.0", 29 | "eslint": "^5.16.0", 30 | "eslint-config-react-app": "^3.0.8", 31 | "eslint-loader": "^4.0.2", 32 | "eslint-plugin-flowtype": "^3.13.0", 33 | "eslint-plugin-import": "^2.22.1", 34 | "eslint-plugin-jsdoc": "^29.2.0", 35 | "eslint-plugin-jsx-a11y": "^6.4.1", 36 | "eslint-plugin-react": "^7.21.5", 37 | "eslint-plugin-react-hooks": "^4.2.0", 38 | "eslint-plugin-sort-destructure-keys": "^1.3.5", 39 | "source-map-loader": "^1.1.2", 40 | "webpack": "^5.76.0", 41 | "webpack-cli": "^4.10.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /gaussholder.php: -------------------------------------------------------------------------------- 1 | [data-slug="gaussholder"] .plugin-version-author-uri:after { content: "Made with \002764\00FE0F, just for you."; font-size: 0.8em; opacity: 0; float: right; transition: 300ms opacity; } [data-slug="gaussholder"]:hover .plugin-version-author-uri:after { opacity: 0.3; }'; 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /assets/src/load-original.js: -------------------------------------------------------------------------------- 1 | // Fade duration in ms when the image loads in. 2 | const FADE_DURATION = 800; 3 | 4 | /** 5 | * Load the original image. Triggered once the image is on the viewport. 6 | * 7 | * @param {Node} element Image element 8 | */ 9 | let loadOriginal = function ( element ) { 10 | if ( ! ( 'originalsrc' in element.dataset ) && ! ( 'originalsrcset' in element.dataset ) ) { 11 | return; 12 | } 13 | 14 | let data = element.dataset.gaussholderSize.split( ',' ), 15 | radius = parseInt( data[2] ); 16 | 17 | // Load our image now 18 | let img = new Image(); 19 | 20 | if ( element.dataset.originalsrc ) { 21 | img.src = element.dataset.originalsrc; 22 | } 23 | if ( element.dataset.originalsrcset ) { 24 | img.srcset = element.dataset.originalsrcset; 25 | } 26 | 27 | /** 28 | * 29 | */ 30 | img.onload = function () { 31 | // Filter property to use 32 | let filterProp = ( 'webkitFilter' in element.style ) ? 'webkitFilter' : 'filter'; 33 | element.style[ filterProp ] = 'blur(' + radius * 0.5 + 'px)'; 34 | 35 | // Ensure blur doesn't bleed past image border 36 | element.style.clipPath = 'url(#gaussclip)'; // Current FF 37 | element.style.clipPath = 'inset(0)'; // Standard 38 | element.style.webkitClipPath = 'inset(0)'; // WebKit 39 | 40 | // Set the actual source 41 | element.src = img.src; 42 | element.srcset = img.srcset; 43 | 44 | // Cleaning source 45 | element.dataset.originalsrc = ''; 46 | element.dataset.originalsrcset = ''; 47 | 48 | // Clear placeholder temporary image 49 | // (We do this after setting the source, as doing it before can 50 | // cause a tiny flicker) 51 | element.style.backgroundImage = ''; 52 | element.style.backgroundRepeat = ''; 53 | 54 | let start = 0; 55 | 56 | /** 57 | * @param {number} ts Timestamp. 58 | */ 59 | const anim = function ( ts ) { 60 | if ( ! start ) start = ts; 61 | let diff = ts - start; 62 | if ( diff > FADE_DURATION ) { 63 | element.style[ filterProp ] = ''; 64 | element.style.clipPath = ''; 65 | element.style.webkitClipPath = ''; 66 | return; 67 | } 68 | 69 | let effectiveRadius = radius * ( 1 - ( diff / FADE_DURATION ) ); 70 | 71 | element.style[ filterProp ] = 'blur(' + effectiveRadius * 0.5 + 'px)'; 72 | window.requestAnimationFrame( anim ); 73 | }; 74 | window.requestAnimationFrame( anim ); 75 | }; 76 | }; 77 | 78 | export default loadOriginal; 79 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ## StackBlur 4 | 5 | Gaussholder uses [StackBlur for Canvas][stackblur] by Mario Klingemann; modified 6 | by [Fabien Loison][stackblur-gh]. Licensed under the MIT License. 7 | 8 | [stackblur]: http://www.quasimondo.com/StackBlurForCanvas/StackBlurDemo.html 9 | [stackblur-gh]: https://github.com/flozz/StackBlur 10 | 11 | > Copyright (c) 2010 Mario Klingemann 12 | > 13 | > Permission is hereby granted, free of charge, to any person 14 | > obtaining a copy of this software and associated documentation 15 | > files (the "Software"), to deal in the Software without 16 | > restriction, including without limitation the rights to use, 17 | > copy, modify, merge, publish, distribute, sublicense, and/or sell 18 | > copies of the Software, and to permit persons to whom the 19 | > Software is furnished to do so, subject to the following 20 | > conditions: 21 | > 22 | > The above copyright notice and this permission notice shall be 23 | > included in all copies or substantial portions of the Software. 24 | > 25 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 26 | > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 27 | > OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 28 | > NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 29 | > HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 30 | > WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 31 | > FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 32 | > OTHER DEALINGS IN THE SOFTWARE. 33 | 34 | ## Gaussholder 35 | 36 | Gaussholder is a plugin for (and hence, derivative work of) WordPress. Licensed 37 | under the GNU General Public License version 2 or later. 38 | 39 | > WordPress - Web publishing software 40 | > 41 | > Copyright (C) 2003-2010 by the contributors 42 | > 43 | > This program is free software; you can redistribute it and/or modify 44 | > it under the terms of the GNU General Public License as published by 45 | > the Free Software Foundation; either version 2 of the License, or 46 | > (at your option) any later version. 47 | > 48 | > This program is distributed in the hope that it will be useful, 49 | > but WITHOUT ANY WARRANTY; without even the implied warranty of 50 | > MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 51 | > GNU General Public License for more details. 52 | > 53 | > You should have received a copy of the GNU General Public License along 54 | > with this program; if not, write to the Free Software Foundation, Inc., 55 | > 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 56 | -------------------------------------------------------------------------------- /.circleci/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # 3 | # Deploy your branch. 4 | # 5 | 6 | DEPLOY_SUFFIX="${DEPLOY_SUFFIX:--built}" 7 | GIT_USER="${DEPLOY_GIT_USER:-CircleCI}" 8 | GIT_EMAIL="${DEPLOY_GIT_EMAIL:-rob+platform-ui-build@hmn.md}" 9 | 10 | BRANCH="${CIRCLE_BRANCH}" 11 | SRC_DIR="$PWD" 12 | BUILD_DIR="/tmp/hm-build" 13 | 14 | if [[ -z "$BRANCH" ]]; then 15 | echo "No branch specified!" 16 | exit 1 17 | fi 18 | 19 | if [[ -d "$BUILD_DIR" ]]; then 20 | echo "WARNING: ${BUILD_DIR} already exists. You may have accidentally cached this" 21 | echo "directory. This will cause issues with deploying." 22 | exit 1 23 | fi 24 | 25 | COMMIT=$(git rev-parse HEAD) 26 | VERSION=$(grep 'Version: ' gaussholder.php | grep -oEi '[0-9\.a-z\+-]+$') 27 | 28 | if [[ $VERSION != "null" ]]; then 29 | DEPLOY_BRANCH="${VERSION}--branch" 30 | DEPLOY_AS_RELEASE="${DEPLOY_AS_RELEASE:-yes}" 31 | else 32 | DEPLOY_BRANCH="${BRANCH}${DEPLOY_SUFFIX}" 33 | DEPLOY_AS_RELEASE="${DEPLOY_AS_RELEASE:-no}" 34 | fi 35 | 36 | echo "Deploying $BRANCH to $DEPLOY_BRANCH" 37 | 38 | # If the deploy branch doesn't already exist, create it from the empty root. 39 | if ! git rev-parse --verify "remotes/origin/$DEPLOY_BRANCH" >/dev/null 2>&1; then 40 | echo -e "\nCreating $DEPLOY_BRANCH..." 41 | git worktree add --detach "$BUILD_DIR" 42 | cd "$BUILD_DIR" 43 | git checkout --orphan "$DEPLOY_BRANCH" 44 | else 45 | echo "Using existing $DEPLOY_BRANCH" 46 | git worktree add --detach "$BUILD_DIR" "remotes/origin/$DEPLOY_BRANCH" 47 | cd "$BUILD_DIR" 48 | git checkout "$DEPLOY_BRANCH" 49 | fi 50 | 51 | # Ensure we're in the right dir 52 | cd "$BUILD_DIR" 53 | 54 | # Remove existing files 55 | git rm -rfq . 56 | 57 | # Sync built files 58 | echo -e "\nSyncing files..." 59 | if ! command -v 'rsync'; then 60 | sudo apt-get update 61 | sudo apt-get install -q -y rsync 62 | fi 63 | 64 | rsync -av "$SRC_DIR/" "$BUILD_DIR" --exclude-from "$SRC_DIR/.circleci/deploy-exclude.txt" 65 | 66 | # Add changed files 67 | git add . 68 | 69 | if [ -z "$(git status --porcelain)" ]; then 70 | echo "No changes to built files." 71 | exit 72 | fi 73 | 74 | # Print status! 75 | echo -e "\nSynced files. Changed:" 76 | git status -s 77 | 78 | # Double-check our user/email config 79 | if ! git config user.email; then 80 | git config user.name "$GIT_USER" 81 | git config user.email "$GIT_EMAIL" 82 | fi 83 | 84 | # Commit it. 85 | MESSAGE=$( printf 'Build changes from %s\n\n%s' "${COMMIT}" "${CIRCLE_BUILD_URL}" ) 86 | git commit -m "$MESSAGE" 87 | 88 | # Push it (real good). 89 | git push origin "$DEPLOY_BRANCH" 90 | 91 | # Make a release if one doesn't exist. 92 | if [[ $DEPLOY_AS_RELEASE = "yes" && $(git tag -l "$VERSION") != $VERSION ]]; then 93 | git tag "$VERSION" 94 | git push origin "$VERSION" 95 | fi 96 | -------------------------------------------------------------------------------- /inc/class-wp-cli-command.php: -------------------------------------------------------------------------------- 1 | [--dry-run] [--verbose] [--regenerate] 17 | */ 18 | public function process( $args, $args_assoc ) { 19 | 20 | $args_assoc = wp_parse_args( $args_assoc, array( 21 | 'verbose' => true, 22 | 'dry-run' => false, 23 | 'regenerate' => false, 24 | ) ); 25 | 26 | $attachment_id = absint( $args[0] ); 27 | $metadata = wp_get_attachment_metadata( $attachment_id ); 28 | 29 | if ( ! $args_assoc['regenerate'] ) { 30 | return; 31 | } 32 | 33 | // Unless regenerating, skip attachments that already have data. 34 | $has_placeholder = false; 35 | if ( ! $args_assoc['regenerate'] && $has_placeholder ) { 36 | 37 | if ( $args_assoc['verbose'] ) { 38 | WP_CLI::line( sprintf( 'Skipping attachment %d. Data already exists.', $attachment_id ) ); 39 | } 40 | 41 | return; 42 | 43 | } 44 | 45 | if ( ! $args_assoc['dry-run'] ) { 46 | $result = generate_placeholders( $attachment_id ); 47 | } 48 | 49 | if ( is_wp_error( $result ) ) { 50 | WP_CLI::error( implode( "\n", $result->get_error_messages() ) ); 51 | } 52 | 53 | if ( $args_assoc['verbose'] ) { 54 | WP_CLI::line( sprintf( 'Updated caclulated colors for attachment %d.', $attachment_id ) ); 55 | } 56 | 57 | } 58 | 59 | /** 60 | * Process image color data for all attachments. 61 | * 62 | * @subcommand process-all 63 | * @synopsis [--dry-run] [--count=] [--offset=] [--regenerate] 64 | */ 65 | public function process_all( $args, $args_assoc ) { 66 | 67 | $args_assoc = wp_parse_args( $args_assoc, array( 68 | 'count' => 1, 69 | 'offset' => 0, 70 | 'dry-run' => false, 71 | 'regenerate' => false, 72 | ) ); 73 | 74 | if ( empty( $page ) ) { 75 | $page = absint( $args_assoc['offset'] ) / absint( $args_assoc['count'] ); 76 | $page = ceil( $page ); 77 | if ( $page < 1 ) { 78 | $page = 1; 79 | } 80 | } 81 | 82 | while ( empty( $no_more_posts ) ) { 83 | 84 | $query = new WP_Query( array( 85 | 'post_type' => 'attachment', 86 | 'post_status' => 'inherit', 87 | 'fields' => 'ids', 88 | 'posts_per_page' => $args_assoc['count'], 89 | 'paged' => $page, 90 | ) ); 91 | 92 | if ( empty( $progress_bar ) ) { 93 | $progress_bar = new cli\progress\Bar( sprintf( 'Processing images [Total: %d]', absint( $query->found_posts ) ), absint( $query->found_posts ), 100 ); 94 | $progress_bar->display(); 95 | } 96 | 97 | foreach ( $query->posts as $post_id ) { 98 | 99 | $progress_bar->tick( 1, sprintf( 'Processing images [Total: %d / Processing ID: %d]', absint( $query->found_posts ), $post_id ) ); 100 | 101 | $this->process( 102 | array( $post_id ), 103 | array( 104 | 'verbose' => false, 105 | 'dry-run' => $args_assoc['dry-run'], 106 | 'regenerate' => $args_assoc['regenerate'] 107 | ) 108 | ); 109 | 110 | } 111 | 112 | if ( $query->get('paged') >= $query->max_num_pages ) { 113 | $no_more_posts = true; 114 | } 115 | 116 | if ( $query->get('paged') === 0 ) { 117 | $page = 2; 118 | } else { 119 | $page = absint( $query->get('paged') ) + 1; 120 | } 121 | 122 | } 123 | 124 | $progress_bar->finish(); 125 | 126 | } 127 | 128 | /** 129 | * Check how big the placeholder will be for an image or file with a given 130 | * radius. 131 | * 132 | * @subcommand check-size 133 | * @synopsis 134 | * @param array $args 135 | */ 136 | public function check_size( $args ) { 137 | if ( is_numeric( $args[0] ) ) { 138 | $attachment_id = absint( $args[0] ); 139 | $file = get_attached_file( $attachment_id ); 140 | if ( empty( $file ) ) { 141 | WP_CLI::error( __( 'Attachment does not exist', 'gaussholder' ) ); 142 | } 143 | } else { 144 | $file = $args[1]; 145 | } 146 | 147 | if ( ! file_exists( $file ) ) { 148 | WP_CLI::error( sprintf( __( 'File %s does not exist', 'gaussholder' ), $file ) ); 149 | } 150 | 151 | // Generate a placeholder with the radius 152 | $radius = absint( $args[1] ); 153 | $data = JPEG\data_for_file( $file, $radius ); 154 | WP_CLI::line( sprintf( '%s: %dB (%dpx radius)', basename( $file ), strlen( $data[0] ), $radius ) ); 155 | } 156 | 157 | } 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 17 | 18 | 19 | 22 | 25 | 26 |
4 | Gaussholder
5 | Fast and lightweight image previews, using Gaussian blur. 6 |
8 | 16 |
20 | A Human Made project. Maintained by @rmccue. 21 | 23 | 24 |
27 | 28 | Gaussholder is an image placeholder utility, generating accurate preview images using an amazingly small amount of data. 29 | 30 | 31 | 32 | That's a **800 byte** preview image, for a **109 kilobyte** image. 800 bytes still too big? Tune the size to your liking in your configuration. 33 | 34 | **Please note:** This is still in development, and we're working on getting this production-ready, so things might not be settled yet. In particular, we're still working on tweaking the placeholder size and improving the lazyloading code. Avoid using this in production. 35 | 36 | ## How does it work? 37 | 38 | Gaussholder is inspired by [Facebook Engineering's fantastic post][fbeng] on generating tiny preview images. Gaussholder takes the concepts from this post and applies them to the wild world of WordPress. 39 | 40 | In a nutshell, Gaussholder takes a Gaussian blur and applies it to an image to generate a preview image. Gaussian blurs work as a low-pass filter, allowing us to throw away a lot of the data. We then further reduce the amount of data per image by removing the JPEG header and rebuilding it on the client side (this eliminates ~800 bytes from each image). 41 | 42 | We further reduce the amount of data for some requests by lazyloading images. 43 | 44 | [fbeng]: https://code.facebook.com/posts/991252547593574 45 | 46 | ## How do I use it? 47 | 48 | Gaussholder is designed for high-volume sites for seriously advanced users. Do _not_ install this on your regular WP site. 49 | 50 | 1. Download and activate the plugin from this repo. 51 | 2. Select the image sizes to use Gaussholder on, and add them to the array on the `gaussholder.image_sizes` filter. 52 | 3. If you have existing images, regenerate the image thumbnails. 53 | 54 | Your filter should look something like this: 55 | 56 | ```php 57 | add_filter( 'gaussholder.image_sizes', function ( $sizes ) { 58 | $sizes['medium'] = 16; 59 | $sizes['large'] = 32; 60 | $sizes['full'] = 84; 61 | return $sizes; 62 | } ); 63 | ``` 64 | 65 | The keys are registered image sizes (plus `full` for the original size), with the value as the desired blur radius in pixels. 66 | 67 | By default, Gaussholder won't generate any placeholders, and you need to opt-in to using it. Simply filter here, and add the size names for what you want generated. 68 | 69 | Be aware that for every size you add, a placeholder will be generated and stored in the database. If you have a lot of sizes, this will be a _lot_ of data. 70 | 71 | By default Gaussholder is initialized with the `DOMContentLoaded` event. Should you need to reinitialize Gaussholder after the page had loaded, this can be achieved with `GaussHolder();`. 72 | 73 | ### Blur radius 74 | 75 | The blur radius controls how much blur we use. The image is pre-scaled down by this factor, and this is really the key to how the placeholders work. Increasing radius decreases the required data quadratically: a radius of 2 uses a quarter as much data as the full image; a radius of 8 uses 1/64 the amount of data. (Due to compression, the final result will *not* follow this scaling.) 76 | 77 | Be careful tuning this, as decreasing the radius too much will cause a huge amount of data in the body; increasing it will end up with not enough data to be an effective placeholder. 78 | 79 | The radius needs to be tuned to each size individually. Facebook uses about 200 bytes of data for their placeholders, but you may want higher quality placeholders. There's no ideal radius, as you simply want to balance having a useful placeholder with the extra time needed to process the data on the page. 80 | 81 | Gaussholder includes a CLI command to help you tune the radius: pick a representative attachment or image file and use `wp gaussholder check-size `. Adjust the radius until you get to roughly 200B, then check against other attachments to ensure they're in the ballpark. 82 | 83 | Note: changing the radius requires regenerating the placeholder data. Run `wp gaussholder process-all --regenerate` after changing radii or adding new sizes. 84 | 85 | ## License 86 | Gaussholder is licensed under the GPLv2 or later. 87 | 88 | Gaussholder uses StackBlur, licensed under the MIT license. 89 | 90 | See [LICENSE.md](LICENSE.md) for further details. 91 | 92 | ## Credits 93 | Created by Human Made for high volume and large-scale sites. 94 | 95 | Written and maintained by [Ryan McCue](https://github.com/rmccue). Thanks to all our [contributors](https://github.com/humanmade/Gaussholder/graphs/contributors). (Thanks also to fellow humans Matt and Paul for the initial placeholder code.) 96 | 97 | Gaussholder is heavily inspired by [Facebook Engineering's post][fbeng], and would not have been possible without it. In particular, the techniques of downscaling before blurring and extracting the JPEG header are particularly novel, and the key to why Gaussholder exists. 98 | 99 | Interested in joining in on the fun? [Join us, and become human!](https://hmn.md/is/hiring/) 100 | -------------------------------------------------------------------------------- /inc/frontend/namespace.php: -------------------------------------------------------------------------------- 1 | '; 23 | 24 | // Output header onto the page 25 | $header = JPEG\build_header(); 26 | $header['header'] = base64_encode( $header['header'] ); 27 | echo 'var GaussholderHeader = ' . json_encode( $header ) . ";\n"; 28 | 29 | echo file_get_contents( Gaussholder\PLUGIN_DIR . '/dist/gaussholder.min.js' ) . "\n"; 30 | 31 | echo ''; 32 | 33 | // Clipping path for Firefox compatibility on fade in 34 | echo ''; 35 | } 36 | 37 | /** 38 | * Mangle tags in the post content. 39 | * 40 | * Replaces the tag src to stop browsers loading the source early, as well 41 | * as adding the Gaussholder data. 42 | * @param [type] $content [description] 43 | * @return [type] [description] 44 | */ 45 | function mangle_images( $content ) { 46 | // Find images 47 | $searcher = '#]+(?:class=[\'"]([^\'"]*wp-image-(\d+)[^\'"]*)|data-gaussholder-id="(\d+)")[^>]+>#x'; 48 | $preg_match_result = preg_match_all( $searcher, $content, $images, PREG_SET_ORDER ); 49 | /** 50 | * Filter the regexp results when looking for images in a post content. 51 | * 52 | * By default, Gaussholder applies the $searcher regexp inside the_content filter callback. Some page builders 53 | * manage images in different ways so the result could be false. 54 | * 55 | * This filter allows to change that result but also the images list generated by preg_match_all( $searcher, $content, $images, PREG_SET_ORDER ) 56 | * The $images parameter must be returned with the same format. That is: 57 | * 58 | * [ 59 | * 0 => Image tag () 60 | * 1 => Image tag class 61 | * 2 => Attachment ID 62 | * ] 63 | */ 64 | $preg_match_result = apply_filters_ref_array( 'gaussholder.mangle_images_regexp_results', [ $preg_match_result, &$images, $content, $searcher ] ); 65 | if ( ! $preg_match_result ) { 66 | return $content; 67 | } 68 | 69 | $blank = file_get_contents( Gaussholder\PLUGIN_DIR . '/assets/blank.gif' ); 70 | $blank_url = 'data:image/gif;base64,' . base64_encode( $blank ); 71 | 72 | foreach ( $images as $image ) { 73 | $tag = $image[0]; 74 | if ( ! empty( $image[2] ) ) { 75 | // Singular image, using `class="wp-image-"` 76 | $id = $image[2]; 77 | $class = $image[1]; 78 | 79 | // Attempt to get the image size from a size class. 80 | if ( ! preg_match( '#\bsize-([\w-]+)\b#', $class, $size_match ) ) { 81 | // If we don't have a size class, the only other option is to search 82 | // all the URLs for image sizes that we support, and see if the src 83 | // attribute matches. 84 | preg_match( '#\bsrc=[\'|"]([^\'"]*)#', $tag, $src_match ); 85 | $all_sizes = array_keys( Gaussholder\get_enabled_sizes() ); 86 | foreach ( $all_sizes as $single_size ) { 87 | $url = wp_get_attachment_image_src( $id, $single_size ); 88 | // WordPress applies esc_attr (and sometimes esc_url) to all image attributes, 89 | // so we have decode entities when making a comparison. 90 | if ( $url[0] === html_entity_decode( $src_match[1] ) ) { 91 | $size = $single_size; 92 | break; 93 | } 94 | } 95 | // If we still were not able to find the image size from the src 96 | // attribute, then skip this image. 97 | if ( ! isset( $size ) ) { 98 | continue; 99 | } 100 | } else { 101 | $size = $size_match[1]; 102 | } 103 | 104 | } else { 105 | // Gallery, using `data-gaussholder-id=""` 106 | $id = $image[3]; 107 | if ( ! preg_match( '# class=[\'"][^\'"]*\battachment-([\w-]+)\b#', $tag, $size_match ) ) { 108 | continue; 109 | } 110 | $size = $size_match[1]; 111 | } 112 | 113 | if ( ! Gaussholder\is_enabled_size( $size ) ) { 114 | continue; 115 | } 116 | 117 | $new_attrs = array(); 118 | 119 | // Replace src with our blank GIF 120 | $new_attrs[] = 'src="' . esc_attr( $blank_url ) . '"'; 121 | 122 | // Remove srcset 123 | $new_attrs[] = 'srcset=""'; 124 | 125 | // Add the actual placeholder 126 | $placeholder = Gaussholder\get_placeholder( $id, $size ); 127 | $new_attrs[] = 'data-gaussholder="' . esc_attr( $placeholder ) . '"'; 128 | 129 | // Add final size 130 | $image_data = wp_get_attachment_image_src( $id, $size ); 131 | $size_data = [ 132 | 'width' => $image_data[1], 133 | 'height' => $image_data[2], 134 | ]; 135 | $radius = Gaussholder\get_blur_radius_for_size( $size ); 136 | 137 | // Has the size been overridden? 138 | if ( preg_match( '#height=[\'"](\d+)[\'"]#i', $tag, $matches ) ) { 139 | $size_data['height'] = absint( $matches[1] ); 140 | } 141 | if ( preg_match( '#width=[\'"](\d+)[\'"]#i', $tag, $matches ) ) { 142 | $size_data['width'] = absint( $matches[1] ); 143 | } 144 | $new_attrs[] = sprintf( 145 | 'data-gaussholder-size="%s,%s,%s"', 146 | $size_data['width'], 147 | $size_data['height'], 148 | $radius 149 | ); 150 | 151 | $mangled_tag = str_replace( 152 | array( 153 | ' srcset="', 154 | ' src="', 155 | ), 156 | array( 157 | ' data-originalsrcset="', 158 | ' ' . implode( ' ', $new_attrs ) . ' data-originalsrc="', 159 | ), 160 | $tag 161 | ); 162 | 163 | $content = str_replace( $tag, $mangled_tag, $content ); 164 | } 165 | 166 | return $content; 167 | } 168 | 169 | /** 170 | * Adds a style attribute to image HTML. 171 | * 172 | * @param $attr 173 | * @param $attachment 174 | * @param $size 175 | * 176 | * @return mixed 177 | */ 178 | function add_placeholder_to_attributes( $attr, $attachment, $size ) { 179 | // Are placeholders enabled for this size? 180 | if ( ! Gaussholder\is_enabled_size( $size ) ) { 181 | return $attr; 182 | } 183 | 184 | $attr['data-gaussholder-id'] = $attachment->ID; 185 | 186 | return $attr; 187 | } 188 | -------------------------------------------------------------------------------- /inc/jpeg/namespace.php: -------------------------------------------------------------------------------- 1 | readImageBlob( file_get_contents( $file ) ); 193 | } else { 194 | $editor = new Imagick( $file ); 195 | } 196 | 197 | $size = $editor->getImageGeometry(); 198 | 199 | // Normalise the density to 72dpi 200 | $editor->setImageResolution( 72, 72 ); 201 | 202 | // Set sampling factors to constant 203 | $editor->setSamplingFactors(array('1x1', '1x1', '1x1')); 204 | 205 | // Ensure we use default Huffman tables 206 | $editor->setOption('jpeg:optimize-coding', false); 207 | 208 | // Strip unnecessary header data 209 | $editor->stripImage(); 210 | 211 | // Adjust by scaling factor 212 | $width = floor( $size['width'] / $radius ); 213 | $height = floor( $size['height'] / $radius ); 214 | $editor->scaleImage( $width, $height ); 215 | 216 | $scaled = $editor->getImageBlob(); 217 | 218 | // Strip the header 219 | $scaled_stripped = substr( $scaled, strpos( $scaled, "\xFF\xDA" ) + 2 ); 220 | 221 | return array( $scaled_stripped, $width, $height ); 222 | } 223 | -------------------------------------------------------------------------------- /inc/class-plugin.php: -------------------------------------------------------------------------------- 1 | blur radius. 19 | * 20 | * By default, Gaussholder won't generate any placeholders, and you need to 21 | * opt-in to using it. Simply filter here, and add the size names for what 22 | * you want generated. 23 | * 24 | * Be aware that for every size you add, a placeholder will be generated and 25 | * stored in the database. If you have a lot of sizes, this will be a _lot_ 26 | * of data. 27 | * 28 | * The blur radius controls how much blur we use. The image is pre-scaled 29 | * down by this factor, and this is really the key to how the placeholders 30 | * work. Increasing radius decreases the required data quadratically: a 31 | * radius of 2 uses a quarter as much data as the full image; a radius of 32 | * 8 uses 1/64 the amount of data. (Due to compression, the final result 33 | * will _not_ follow this scaling.) 34 | * 35 | * Be careful tuning this, as decreasing the radius too much will cause a 36 | * huge amount of data in the body; increasing it will end up with not 37 | * enough data to be an effective placeholder. 38 | * 39 | * The radius needs to be tuned to each size individually. Ideally, you want 40 | * to keep it to about 200 bytes of data for the placeholder. 41 | * 42 | * (Also note: changing the radius requires regenerating the 43 | * placeholder data.) 44 | * 45 | * @param string[] $enabled Enabled sizes. 46 | */ 47 | return apply_filters( 'gaussholder.image_sizes', array() ); 48 | } 49 | 50 | function get_blur_radius() { 51 | /** 52 | * Filter the blur radius. 53 | * 54 | * The blur radius controls how much blur we use. The image is pre-scaled 55 | * down by this factor, and this is really the key to how the placeholders 56 | * work. Increasing radius decreases the required data quadratically: a 57 | * radius of 2 uses a quarter as much data as the full image; a radius of 58 | * 8 uses 1/64 the amount of data. (Due to compression, the final result 59 | * will _not_ follow this scaling.) 60 | * 61 | * Be careful tuning this, as decreasing the radius too much will cause a 62 | * huge amount of data in the body; increasing it will end up with not 63 | * enough data to be an effective placeholder. 64 | * 65 | * (Also note: changing this requires regenerating the placeholder data.) 66 | * 67 | * @param int $radius Blur radius in pixels. 68 | */ 69 | return apply_filters( 'gaussholder.blur_radius', 16 ); 70 | } 71 | 72 | /** 73 | * Get the blur radius for a given size. 74 | * 75 | * @param string $size Image size to get radius for. 76 | * @return int|null Radius in pixels if enabled, null if size isn't enabled. 77 | */ 78 | function get_blur_radius_for_size( $size ) { 79 | $sizes = get_enabled_sizes(); 80 | if ( ! isset( $sizes[ $size ] ) ) { 81 | return null; 82 | } 83 | 84 | return absint( $sizes[ $size ] ); 85 | } 86 | 87 | /** 88 | * Is the size enabled for placeholders? 89 | * 90 | * @param string $size Image size to check. 91 | * @return boolean True if enabled, false if not. Simple. 92 | */ 93 | function is_enabled_size( $size ) { 94 | return in_array( $size, array_keys( get_enabled_sizes() ) ); 95 | } 96 | 97 | /** 98 | * Get a placeholder for an image. 99 | * 100 | * @param int $id Attachment ID. 101 | * @param string $size Image size. 102 | * @return string 103 | */ 104 | function get_placeholder( $id, $size ) { 105 | if ( ! is_enabled_size( $size ) ) { 106 | return null; 107 | } 108 | 109 | $meta = get_post_meta( $id, META_PREFIX . $size, true ); 110 | if ( empty( $meta ) ) { 111 | return null; 112 | } 113 | 114 | return $meta; 115 | } 116 | 117 | /** 118 | * Schedule a background task to generate placeholders. 119 | * 120 | * @param array $metadata 121 | * @param int $attachment_id 122 | * @return array 123 | */ 124 | function queue_generate_placeholders_on_save( $metadata, $attachment_id ) { 125 | // Is this a JPEG? 126 | $mime_type = get_post_mime_type( $attachment_id ); 127 | if ( ! in_array( $mime_type, array( 'image/jpg', 'image/jpeg' ) ) ) { 128 | return $metadata; 129 | } 130 | 131 | wp_schedule_single_event( time() + 5, 'gaussholder.generate_placeholders', [ $attachment_id ] ); 132 | 133 | return $metadata; 134 | } 135 | 136 | /** 137 | * Save extracted colors to image metadata 138 | * 139 | * @param $metadata 140 | * @param $attachment_id 141 | * 142 | * @return WP_Error|bool 143 | */ 144 | function generate_placeholders( $attachment_id ) { 145 | // Is this a JPEG? 146 | $mime_type = get_post_mime_type( $attachment_id ); 147 | if ( ! in_array( $mime_type, array( 'image/jpg', 'image/jpeg' ) ) ) { 148 | return new WP_Error( 'image-not-jpg', 'Image is not a JPEG.' ); 149 | } 150 | 151 | $errors = new WP_Error; 152 | 153 | $sizes = get_enabled_sizes(); 154 | foreach ( $sizes as $size => $radius ) { 155 | try { 156 | $data = generate_placeholder( $attachment_id, $size, $radius ); 157 | } catch ( \ImagickException $e ) { 158 | $errors->add( $size, sprintf( 'Unable to generate placeholder for %s (Imagick exception - %s)', $size, $e->getMessage() ) ); 159 | continue; 160 | } 161 | 162 | if ( empty( $data ) ) { 163 | $errors->add( $size, sprintf( 'Unable to generate placeholder for %s', $size ) ); 164 | continue; 165 | } 166 | 167 | // Comma-separated data, width, and height 168 | $for_database = sprintf( '%s,%d,%d', base64_encode( $data[0] ), $data[1], $data[2] ); 169 | update_post_meta( $attachment_id, META_PREFIX . $size, $for_database ); 170 | } 171 | 172 | if ( $errors->has_errors() ) { 173 | return $errors; 174 | } 175 | 176 | return true; 177 | } 178 | 179 | /** 180 | * Get data for a given image size. 181 | * 182 | * @param string $size Image size. 183 | * @return array|null Image size data (with `width`, `height`, `crop` keys) on success, null if image size is invalid. 184 | */ 185 | function get_size_data( $size ) { 186 | global $_wp_additional_image_sizes; 187 | 188 | switch ( $size ) { 189 | case 'thumbnail': 190 | case 'medium': 191 | case 'large': 192 | $size_data = array( 193 | 'width' => get_option( "{$size}_size_w" ), 194 | 'height' => get_option( "{$size}_size_h" ), 195 | 'crop' => get_option( "{$size}_crop" ), 196 | ); 197 | break; 198 | 199 | default: 200 | if ( ! isset( $_wp_additional_image_sizes[ $size ] ) ) { 201 | return null; 202 | } 203 | 204 | $size_data = $_wp_additional_image_sizes[ $size ]; 205 | break; 206 | } 207 | 208 | return $size_data; 209 | } 210 | 211 | /** 212 | * Generate a placeholder at a given size. 213 | * 214 | * @param int $id Attachment ID. 215 | * @param string $size Image size. 216 | * @param int $radius Blur radius. 217 | * @return array|null 3-tuple of binary image data (string), width (int), height (int) on success; null on error. 218 | */ 219 | function generate_placeholder( $id, $size, $radius ) { 220 | $size_data = get_size_data( $size ); 221 | if ( $size !== 'full' && empty( $size_data ) ) { 222 | _doing_it_wrong( __FUNCTION__, __( 'Invalid image size enabled for placeholders', 'gaussholder' ), '1.0.0' ); 223 | return null; 224 | } 225 | 226 | $uploads = wp_upload_dir(); 227 | $img = wp_get_attachment_image_src( $id, $size ); 228 | 229 | // Pass image paths directly to data_for_file. 230 | if ( strpos( $img[0], $uploads['baseurl'] ) === 0 ) { 231 | $path = str_replace( $uploads['baseurl'], $uploads['basedir'], $img[0] ); 232 | return JPEG\data_for_file( $path, $radius ); 233 | } 234 | 235 | // If the image url wp_get_attachment_image_src is not a local url (for example), 236 | // using Tachyon or Photon, download the file to temp before passing it to data_for_file. 237 | // This is needed because IMagick can not handle remote files, and we specifically want 238 | // to use the remote file rather than mapping it to an image on disk, as the remote 239 | // service such as Tachyon may look different (smart dropping, image filters) etc. 240 | $path = download_url( $img[0] ); 241 | if ( is_wp_error( $path ) ) { 242 | trigger_error( sprintf( 'Error downloading image from %s: ', $img[0], $path->get_error_message() ), E_USER_WARNING ); 243 | return; 244 | } 245 | $data = JPEG\data_for_file( $path, $radius ); 246 | unlink( $path ); 247 | return $data; 248 | } 249 | -------------------------------------------------------------------------------- /assets/src/stackblur.js: -------------------------------------------------------------------------------- 1 | /* 2 | StackBlur - a fast almost Gaussian Blur For Canvas 3 | 4 | Version: 0.5 5 | Author: Mario Klingemann 6 | Contact: mario@quasimondo.com 7 | Website: http://www.quasimondo.com/StackBlurForCanvas 8 | Twitter: @quasimondo 9 | 10 | In case you find this class useful - especially in commercial projects - 11 | I am not totally unhappy for a small donation to my PayPal account 12 | mario@quasimondo.de 13 | 14 | Or support me on flattr: 15 | https://flattr.com/thing/72791/StackBlur-a-fast-almost-Gaussian-Blur-Effect-for-CanvasJavascript 16 | 17 | Copyright (c) 2010 Mario Klingemann 18 | 19 | Permission is hereby granted, free of charge, to any person 20 | obtaining a copy of this software and associated documentation 21 | files (the "Software"), to deal in the Software without 22 | restriction, including without limitation the rights to use, 23 | copy, modify, merge, publish, distribute, sublicense, and/or sell 24 | copies of the Software, and to permit persons to whom the 25 | Software is furnished to do so, subject to the following 26 | conditions: 27 | 28 | The above copyright notice and this permission notice shall be 29 | included in all copies or substantial portions of the Software. 30 | 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 32 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 33 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 34 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 35 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 36 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 37 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 38 | OTHER DEALINGS IN THE SOFTWARE. 39 | */ 40 | 41 | var StackBlur = (function () { 42 | var mul_table = [ 43 | 512,512,456,512,328,456,335,512,405,328,271,456,388,335,292,512, 44 | 454,405,364,328,298,271,496,456,420,388,360,335,312,292,273,512, 45 | 482,454,428,405,383,364,345,328,312,298,284,271,259,496,475,456, 46 | 437,420,404,388,374,360,347,335,323,312,302,292,282,273,265,512, 47 | 497,482,468,454,441,428,417,405,394,383,373,364,354,345,337,328, 48 | 320,312,305,298,291,284,278,271,265,259,507,496,485,475,465,456, 49 | 446,437,428,420,412,404,396,388,381,374,367,360,354,347,341,335, 50 | 329,323,318,312,307,302,297,292,287,282,278,273,269,265,261,512, 51 | 505,497,489,482,475,468,461,454,447,441,435,428,422,417,411,405, 52 | 399,394,389,383,378,373,368,364,359,354,350,345,341,337,332,328, 53 | 324,320,316,312,309,305,301,298,294,291,287,284,281,278,274,271, 54 | 268,265,262,259,257,507,501,496,491,485,480,475,470,465,460,456, 55 | 451,446,442,437,433,428,424,420,416,412,408,404,400,396,392,388, 56 | 385,381,377,374,370,367,363,360,357,354,350,347,344,341,338,335, 57 | 332,329,326,323,320,318,315,312,310,307,304,302,299,297,294,292, 58 | 289,287,285,282,280,278,275,273,271,269,267,265,263,261,259]; 59 | 60 | 61 | var shg_table = [ 62 | 9, 11, 12, 13, 13, 14, 14, 15, 15, 15, 15, 16, 16, 16, 16, 17, 63 | 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 19, 64 | 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20, 65 | 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21, 66 | 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 67 | 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, 68 | 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 69 | 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 23, 70 | 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 71 | 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 72 | 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 73 | 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 74 | 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 75 | 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 76 | 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 77 | 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24 ]; 78 | 79 | 80 | function getImageDataFromCanvas(canvas, top_x, top_y, width, height) 81 | { 82 | if (typeof(canvas) == 'string') 83 | var canvas = document.getElementById(canvas); 84 | else if (!canvas instanceof HTMLCanvasElement) 85 | return; 86 | 87 | var context = canvas.getContext('2d'); 88 | var imageData; 89 | 90 | try { 91 | // try { 92 | imageData = context.getImageData(top_x, top_y, width, height); 93 | /*} catch(e) { 94 | 95 | // NOTE: this part is supposedly only needed if you want to work with local files 96 | // so it might be okay to remove the whole try/catch block and just use 97 | // imageData = context.getImageData(top_x, top_y, width, height); 98 | try { 99 | netscape.security.PrivilegeManager.enablePrivilege("UniversalBrowserRead"); 100 | imageData = context.getImageData(top_x, top_y, width, height); 101 | } catch(e) { 102 | alert("Cannot access local image"); 103 | throw new Error("unable to access local image data: " + e); 104 | return; 105 | } 106 | }*/ 107 | } catch(e) { 108 | throw new Error("unable to access image data: " + e); 109 | } 110 | 111 | return imageData; 112 | } 113 | 114 | function processCanvasRGB(canvas, top_x, top_y, width, height, radius) 115 | { 116 | if (isNaN(radius) || radius < 1) return; 117 | radius |= 0; 118 | 119 | var imageData = getImageDataFromCanvas(canvas, top_x, top_y, width, height); 120 | imageData = processImageDataRGB(imageData, top_x, top_y, width, height, radius); 121 | 122 | canvas.getContext('2d').putImageData(imageData, top_x, top_y); 123 | } 124 | 125 | function processImageDataRGB(imageData, top_x, top_y, width, height, radius) 126 | { 127 | var pixels = imageData.data; 128 | 129 | var x, y, i, p, yp, yi, yw, r_sum, g_sum, b_sum, 130 | r_out_sum, g_out_sum, b_out_sum, 131 | r_in_sum, g_in_sum, b_in_sum, 132 | pr, pg, pb, rbs; 133 | 134 | var div = radius + radius + 1; 135 | var w4 = width << 2; 136 | var widthMinus1 = width - 1; 137 | var heightMinus1 = height - 1; 138 | var radiusPlus1 = radius + 1; 139 | var sumFactor = radiusPlus1 * (radiusPlus1 + 1) / 2; 140 | 141 | var stackStart = new BlurStack(); 142 | var stack = stackStart; 143 | for (i = 1; i < div; i++) 144 | { 145 | stack = stack.next = new BlurStack(); 146 | if (i == radiusPlus1) var stackEnd = stack; 147 | } 148 | stack.next = stackStart; 149 | var stackIn = null; 150 | var stackOut = null; 151 | 152 | yw = yi = 0; 153 | 154 | var mul_sum = mul_table[radius]; 155 | var shg_sum = shg_table[radius]; 156 | 157 | for (y = 0; y < height; y++) 158 | { 159 | r_in_sum = g_in_sum = b_in_sum = r_sum = g_sum = b_sum = 0; 160 | 161 | r_out_sum = radiusPlus1 * (pr = pixels[yi]); 162 | g_out_sum = radiusPlus1 * (pg = pixels[yi+1]); 163 | b_out_sum = radiusPlus1 * (pb = pixels[yi+2]); 164 | 165 | r_sum += sumFactor * pr; 166 | g_sum += sumFactor * pg; 167 | b_sum += sumFactor * pb; 168 | 169 | stack = stackStart; 170 | 171 | for (i = 0; i < radiusPlus1; i++) 172 | { 173 | stack.r = pr; 174 | stack.g = pg; 175 | stack.b = pb; 176 | stack = stack.next; 177 | } 178 | 179 | for (i = 1; i < radiusPlus1; i++) 180 | { 181 | p = yi + ((widthMinus1 < i ? widthMinus1 : i) << 2); 182 | r_sum += (stack.r = (pr = pixels[p])) * (rbs = radiusPlus1 - i); 183 | g_sum += (stack.g = (pg = pixels[p+1])) * rbs; 184 | b_sum += (stack.b = (pb = pixels[p+2])) * rbs; 185 | 186 | r_in_sum += pr; 187 | g_in_sum += pg; 188 | b_in_sum += pb; 189 | 190 | stack = stack.next; 191 | } 192 | 193 | 194 | stackIn = stackStart; 195 | stackOut = stackEnd; 196 | for (x = 0; x < width; x++) 197 | { 198 | pixels[yi] = (r_sum * mul_sum) >> shg_sum; 199 | pixels[yi+1] = (g_sum * mul_sum) >> shg_sum; 200 | pixels[yi+2] = (b_sum * mul_sum) >> shg_sum; 201 | 202 | r_sum -= r_out_sum; 203 | g_sum -= g_out_sum; 204 | b_sum -= b_out_sum; 205 | 206 | r_out_sum -= stackIn.r; 207 | g_out_sum -= stackIn.g; 208 | b_out_sum -= stackIn.b; 209 | 210 | p = (yw + ((p = x + radius + 1) < widthMinus1 ? p : widthMinus1)) << 2; 211 | 212 | r_in_sum += (stackIn.r = pixels[p]); 213 | g_in_sum += (stackIn.g = pixels[p+1]); 214 | b_in_sum += (stackIn.b = pixels[p+2]); 215 | 216 | r_sum += r_in_sum; 217 | g_sum += g_in_sum; 218 | b_sum += b_in_sum; 219 | 220 | stackIn = stackIn.next; 221 | 222 | r_out_sum += (pr = stackOut.r); 223 | g_out_sum += (pg = stackOut.g); 224 | b_out_sum += (pb = stackOut.b); 225 | 226 | r_in_sum -= pr; 227 | g_in_sum -= pg; 228 | b_in_sum -= pb; 229 | 230 | stackOut = stackOut.next; 231 | 232 | yi += 4; 233 | } 234 | yw += width; 235 | } 236 | 237 | 238 | for (x = 0; x < width; x++) 239 | { 240 | g_in_sum = b_in_sum = r_in_sum = g_sum = b_sum = r_sum = 0; 241 | 242 | yi = x << 2; 243 | r_out_sum = radiusPlus1 * (pr = pixels[yi]); 244 | g_out_sum = radiusPlus1 * (pg = pixels[yi+1]); 245 | b_out_sum = radiusPlus1 * (pb = pixels[yi+2]); 246 | 247 | r_sum += sumFactor * pr; 248 | g_sum += sumFactor * pg; 249 | b_sum += sumFactor * pb; 250 | 251 | stack = stackStart; 252 | 253 | for (i = 0; i < radiusPlus1; i++) 254 | { 255 | stack.r = pr; 256 | stack.g = pg; 257 | stack.b = pb; 258 | stack = stack.next; 259 | } 260 | 261 | yp = width; 262 | 263 | for (i = 1; i <= radius; i++) 264 | { 265 | yi = (yp + x) << 2; 266 | 267 | r_sum += (stack.r = (pr = pixels[yi])) * (rbs = radiusPlus1 - i); 268 | g_sum += (stack.g = (pg = pixels[yi+1])) * rbs; 269 | b_sum += (stack.b = (pb = pixels[yi+2])) * rbs; 270 | 271 | r_in_sum += pr; 272 | g_in_sum += pg; 273 | b_in_sum += pb; 274 | 275 | stack = stack.next; 276 | 277 | if(i < heightMinus1) 278 | { 279 | yp += width; 280 | } 281 | } 282 | 283 | yi = x; 284 | stackIn = stackStart; 285 | stackOut = stackEnd; 286 | for (y = 0; y < height; y++) 287 | { 288 | p = yi << 2; 289 | pixels[p] = (r_sum * mul_sum) >> shg_sum; 290 | pixels[p+1] = (g_sum * mul_sum) >> shg_sum; 291 | pixels[p+2] = (b_sum * mul_sum) >> shg_sum; 292 | 293 | r_sum -= r_out_sum; 294 | g_sum -= g_out_sum; 295 | b_sum -= b_out_sum; 296 | 297 | r_out_sum -= stackIn.r; 298 | g_out_sum -= stackIn.g; 299 | b_out_sum -= stackIn.b; 300 | 301 | p = (x + (((p = y + radiusPlus1) < heightMinus1 ? p : heightMinus1) * width)) << 2; 302 | 303 | r_sum += (r_in_sum += (stackIn.r = pixels[p])); 304 | g_sum += (g_in_sum += (stackIn.g = pixels[p+1])); 305 | b_sum += (b_in_sum += (stackIn.b = pixels[p+2])); 306 | 307 | stackIn = stackIn.next; 308 | 309 | r_out_sum += (pr = stackOut.r); 310 | g_out_sum += (pg = stackOut.g); 311 | b_out_sum += (pb = stackOut.b); 312 | 313 | r_in_sum -= pr; 314 | g_in_sum -= pg; 315 | b_in_sum -= pb; 316 | 317 | stackOut = stackOut.next; 318 | 319 | yi += width; 320 | } 321 | } 322 | 323 | return imageData; 324 | } 325 | 326 | function BlurStack() 327 | { 328 | this.r = 0; 329 | this.g = 0; 330 | this.b = 0; 331 | this.a = 0; 332 | this.next = null; 333 | } 334 | 335 | return { 336 | canvasRGB: processCanvasRGB 337 | }; 338 | }); 339 | 340 | export default StackBlur; 341 | --------------------------------------------------------------------------------