`. Adjust the radius until you get to roughly 200B, then check against other attachments to ensure they're in the ballpark.
+
+Note: changing the radius requires regenerating the placeholder data. Run `wp gaussholder process-all --regenerate` after changing radii or adding new sizes.
+
+## License
+Gaussholder is licensed under the GPLv2 or later.
+
+Gaussholder uses StackBlur, licensed under the MIT license.
+
+See [LICENSE.md](LICENSE.md) for further details.
+
+## Credits
+Created by Human Made for high volume and large-scale sites.
+
+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.)
+
+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.
+
+Interested in joining in on the fun? [Join us, and become human!](https://hmn.md/is/hiring/)
diff --git a/assets/blank.gif b/assets/blank.gif
new file mode 100644
index 0000000..f191b28
Binary files /dev/null and b/assets/blank.gif differ
diff --git a/assets/index.js b/assets/index.js
new file mode 100644
index 0000000..342dfb6
--- /dev/null
+++ b/assets/index.js
@@ -0,0 +1,6 @@
+import Gaussholder from './src/gaussholder';
+
+document.addEventListener( 'DOMContentLoaded', Gaussholder );
+
+// Add the Gaussholder function to the window object.
+window.GaussHolder = Gaussholder;
diff --git a/assets/src/gaussholder.js b/assets/src/gaussholder.js
new file mode 100644
index 0000000..ade4483
--- /dev/null
+++ b/assets/src/gaussholder.js
@@ -0,0 +1,21 @@
+import handleElement from './handlers/handle-element';
+import intersectionHandler from './handlers/intersection-handler';
+import scrollHandler from './handlers/scroll-handler';
+
+/**
+ * Initializes Gaussholder.
+ */
+export default function () {
+ const images = document.getElementsByTagName( 'img' );
+
+ if ( typeof IntersectionObserver === 'undefined' ) {
+ // Old browser. Handle events based on scrolling.
+ scrollHandler( images );
+ } else {
+ // Use the Intersection Observer API.
+ intersectionHandler( images );
+ }
+
+ // Initialize all images.
+ Array.prototype.slice.call( images ).forEach( handleElement );
+}
diff --git a/assets/src/handlers/handle-element.js b/assets/src/handlers/handle-element.js
new file mode 100644
index 0000000..91d9fe6
--- /dev/null
+++ b/assets/src/handlers/handle-element.js
@@ -0,0 +1,43 @@
+import renderImageIntoCanvas from '../render-image-into-canvas';
+
+/**
+ * Render placeholder for an image
+ *
+ * @param {HTMLImageElement} element Element to render placeholder for
+ */
+let handleElement = function ( element ) {
+ if ( ! ( 'gaussholder' in element.dataset ) ) {
+ return;
+ }
+
+ let canvas = document.createElement( 'canvas' );
+ let final = element.dataset.gaussholderSize.split( ',' );
+
+ // Set the dimensions...
+ element.style.width = final[0] + 'px';
+ element.style.height = final[1] + 'px';
+
+ // ...then recalculate based on what it actually renders as
+ let original = [ final[0], final[1] ];
+ if ( element.width < final[0] ) {
+ // Rescale, keeping the aspect ratio
+ final[0] = element.width;
+ final[1] = final[1] * ( final[0] / original[0] );
+ } else if ( element.height < final[1] ) {
+ // Rescale, keeping the aspect ratio
+ final[1] = element.height;
+ final[0] = final[0] * ( final[1] / original[1] );
+ }
+
+ // Set dimensions, _again_
+ element.style.width = final[0] + 'px';
+ element.style.height = final[1] + 'px';
+
+ renderImageIntoCanvas( canvas, element.dataset.gaussholder.split( ',' ), final, function () {
+ // Load in as our background image
+ element.style.backgroundImage = 'url("' + canvas.toDataURL() + '")';
+ element.style.backgroundRepeat = 'no-repeat';
+ } );
+};
+
+export default handleElement;
diff --git a/assets/src/handlers/intersection-handler.js b/assets/src/handlers/intersection-handler.js
new file mode 100644
index 0000000..cf294f8
--- /dev/null
+++ b/assets/src/handlers/intersection-handler.js
@@ -0,0 +1,27 @@
+import loadImageCallback from '../load-original';
+
+/**
+ * Handles the images on screen by using the Intersection Observer API.
+ *
+ * @param {NodeList} images List of images in DOM to handle.
+ */
+const intersectionHandler = function ( images ) {
+ const options = {
+ rootMargin: '1200px', // Threshold that Intersection API uses to detect the intersection between the image and the main element in the page.
+ };
+
+ const imagesObserver = new IntersectionObserver( entries => {
+ const visibleImages = entries.filter( ( { isIntersecting } ) => isIntersecting === true );
+
+ visibleImages.forEach( ( { target } ) => {
+ loadImageCallback( target );
+ imagesObserver.unobserve( target );
+ } );
+ }, options );
+
+ Array.from( images ).forEach( img => {
+ imagesObserver.observe( img );
+ } );
+};
+
+export default intersectionHandler;
diff --git a/assets/src/handlers/scroll-handler.js b/assets/src/handlers/scroll-handler.js
new file mode 100644
index 0000000..a074804
--- /dev/null
+++ b/assets/src/handlers/scroll-handler.js
@@ -0,0 +1,45 @@
+import loadImageCallback from '../load-original';
+import throttle from '../throttle';
+
+let loadLazily = [];
+
+/**
+ * Handle images when scrolling. Suitable for older browsers.
+ */
+const scrollHandler = function () {
+ let threshold = 1200;
+ let next = [];
+ for ( let i = loadLazily.length - 1; i >= 0; i-- ) {
+ let img = loadLazily[i];
+ let shouldShow = img.getBoundingClientRect().top <= ( window.innerHeight + threshold );
+ if ( ! shouldShow ) {
+ next.push( img );
+ continue;
+ }
+
+ loadImageCallback( img );
+ }
+ loadLazily = next;
+};
+
+/**
+ * Scroll handle initialization.
+ *
+ * @param {NodeList} images List of images on screen.
+ */
+const init = function ( images ) {
+ loadLazily = images;
+
+ const throttledHandler = throttle( scrollHandler, 40 );
+ scrollHandler();
+ window.addEventListener( 'scroll', throttledHandler );
+
+ const finishedTimeoutCheck = window.setInterval( function () {
+ if ( loadLazily.length < 1 ) {
+ window.removeEventListener( 'scroll', throttledHandler );
+ window.clearInterval( finishedTimeoutCheck );
+ }
+ }, 1000 );
+};
+
+export default init;
diff --git a/assets/src/load-original.js b/assets/src/load-original.js
new file mode 100644
index 0000000..bce576c
--- /dev/null
+++ b/assets/src/load-original.js
@@ -0,0 +1,78 @@
+// Fade duration in ms when the image loads in.
+const FADE_DURATION = 800;
+
+/**
+ * Load the original image. Triggered once the image is on the viewport.
+ *
+ * @param {Node} element Image element
+ */
+let loadOriginal = function ( element ) {
+ if ( ! ( 'originalsrc' in element.dataset ) && ! ( 'originalsrcset' in element.dataset ) ) {
+ return;
+ }
+
+ let data = element.dataset.gaussholderSize.split( ',' ),
+ radius = parseInt( data[2] );
+
+ // Load our image now
+ let img = new Image();
+
+ if ( element.dataset.originalsrc ) {
+ img.src = element.dataset.originalsrc;
+ }
+ if ( element.dataset.originalsrcset ) {
+ img.srcset = element.dataset.originalsrcset;
+ }
+
+ /**
+ *
+ */
+ img.onload = function () {
+ // Filter property to use
+ let filterProp = ( 'webkitFilter' in element.style ) ? 'webkitFilter' : 'filter';
+ element.style[ filterProp ] = 'blur(' + radius * 0.5 + 'px)';
+
+ // Ensure blur doesn't bleed past image border
+ element.style.clipPath = 'url(#gaussclip)'; // Current FF
+ element.style.clipPath = 'inset(0)'; // Standard
+ element.style.webkitClipPath = 'inset(0)'; // WebKit
+
+ // Set the actual source
+ element.src = img.src;
+ element.srcset = img.srcset;
+
+ // Cleaning source
+ element.dataset.originalsrc = '';
+ element.dataset.originalsrcset = '';
+
+ // Clear placeholder temporary image
+ // (We do this after setting the source, as doing it before can
+ // cause a tiny flicker)
+ element.style.backgroundImage = '';
+ element.style.backgroundRepeat = '';
+
+ let start = 0;
+
+ /**
+ * @param {number} ts Timestamp.
+ */
+ const anim = function ( ts ) {
+ if ( ! start ) start = ts;
+ let diff = ts - start;
+ if ( diff > FADE_DURATION ) {
+ element.style[ filterProp ] = '';
+ element.style.clipPath = '';
+ element.style.webkitClipPath = '';
+ return;
+ }
+
+ let effectiveRadius = radius * ( 1 - ( diff / FADE_DURATION ) );
+
+ element.style[ filterProp ] = 'blur(' + effectiveRadius * 0.5 + 'px)';
+ window.requestAnimationFrame( anim );
+ };
+ window.requestAnimationFrame( anim );
+ };
+};
+
+export default loadOriginal;
diff --git a/assets/src/reconstitute-image.js b/assets/src/reconstitute-image.js
new file mode 100644
index 0000000..5b1f9ac
--- /dev/null
+++ b/assets/src/reconstitute-image.js
@@ -0,0 +1,43 @@
+/**
+ * @param {number} buffer Buffer Size.
+ *
+ * @returns {string} Base64 string.
+ */
+function arrayBufferToBase64( buffer ) {
+ let binary = '';
+ let bytes = new Uint8Array( buffer );
+ let len = bytes.byteLength;
+ for ( let i = 0; i < len; i++ ) {
+ binary += String.fromCharCode( bytes[ i ] );
+ }
+ return window.btoa( binary );
+}
+
+/**
+ * @param {*} header Gaussholder header.
+ * @param {Array} image Image node.
+ *
+ * @returns {string} Base 64 string
+ */
+function reconstituteImage( header, image ) {
+ let image_data = image[0],
+ width = parseInt( image[1] ),
+ height = parseInt( image[2] );
+
+ let full = atob( header.header ) + atob( image_data );
+ let bytes = new Uint8Array( full.length );
+ for ( let i = 0; i < full.length; i++ ) {
+ bytes[i] = full.charCodeAt( i );
+ }
+
+ // Poke the bits.
+ bytes[ header.height_offset ] = ( ( height >> 8 ) & 0xFF );
+ bytes[ header.height_offset + 1 ] = ( height & 0xFF );
+ bytes[ header.length_offset ] = ( ( width >> 8 ) & 0xFF );
+ bytes[ header.length_offset + 1] = ( width & 0xFF );
+
+ // Back to a full JPEG now.
+ return arrayBufferToBase64( bytes );
+}
+
+export default reconstituteImage;
diff --git a/assets/src/render-image-into-canvas.js b/assets/src/render-image-into-canvas.js
new file mode 100644
index 0000000..d2cb860
--- /dev/null
+++ b/assets/src/render-image-into-canvas.js
@@ -0,0 +1,41 @@
+import reconstituteImage from './reconstitute-image';
+import StackBlur from './stackblur';
+
+const { GaussholderHeader } = window;
+
+/**
+ * Render an image into a Canvas
+ *
+ * @param {HTMLCanvasElement} canvas Canvas element to render into
+ * @param {Array} image 3-tuple of base64-encoded image data, width, height
+ * @param {Array} final Final width and height
+ * @param {Function} cb Callback
+ */
+function renderImageIntoCanvas( canvas, image, final, cb ) {
+ let ctx = canvas.getContext( '2d' ),
+ width = parseInt( final[0] ),
+ height = parseInt( final[1] ),
+ radius = parseInt( final[2] );
+
+ // Ensure smoothing is off
+ ctx.mozImageSmoothingEnabled = false;
+ ctx.webkitImageSmoothingEnabled = false;
+ ctx.msImageSmoothingEnabled = false;
+ ctx.imageSmoothingEnabled = false;
+
+ let img = new Image();
+ img.src = 'data:image/jpg;base64,' + reconstituteImage( GaussholderHeader, image );
+ /**
+ *
+ */
+ img.onload = function () {
+ canvas.width = width;
+ canvas.height = height;
+
+ ctx.drawImage( img, 0, 0, width, height );
+ StackBlur.canvasRGB( canvas, 0, 0, width, height, radius );
+ cb();
+ };
+}
+
+export default renderImageIntoCanvas;
diff --git a/assets/src/stackblur.js b/assets/src/stackblur.js
new file mode 100644
index 0000000..299aa0b
--- /dev/null
+++ b/assets/src/stackblur.js
@@ -0,0 +1,340 @@
+/*
+ StackBlur - a fast almost Gaussian Blur For Canvas
+
+ Version: 0.5
+ Author: Mario Klingemann
+ Contact: mario@quasimondo.com
+ Website: http://www.quasimondo.com/StackBlurForCanvas
+ Twitter: @quasimondo
+
+ In case you find this class useful - especially in commercial projects -
+ I am not totally unhappy for a small donation to my PayPal account
+ mario@quasimondo.de
+
+ Or support me on flattr:
+ https://flattr.com/thing/72791/StackBlur-a-fast-almost-Gaussian-Blur-Effect-for-CanvasJavascript
+
+ Copyright (c) 2010 Mario Klingemann
+
+ Permission is hereby granted, free of charge, to any person
+ obtaining a copy of this software and associated documentation
+ files (the "Software"), to deal in the Software without
+ restriction, including without limitation the rights to use,
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the
+ Software is furnished to do so, subject to the following
+ conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+var StackBlur = (function () {
+ var mul_table = [
+ 512,512,456,512,328,456,335,512,405,328,271,456,388,335,292,512,
+ 454,405,364,328,298,271,496,456,420,388,360,335,312,292,273,512,
+ 482,454,428,405,383,364,345,328,312,298,284,271,259,496,475,456,
+ 437,420,404,388,374,360,347,335,323,312,302,292,282,273,265,512,
+ 497,482,468,454,441,428,417,405,394,383,373,364,354,345,337,328,
+ 320,312,305,298,291,284,278,271,265,259,507,496,485,475,465,456,
+ 446,437,428,420,412,404,396,388,381,374,367,360,354,347,341,335,
+ 329,323,318,312,307,302,297,292,287,282,278,273,269,265,261,512,
+ 505,497,489,482,475,468,461,454,447,441,435,428,422,417,411,405,
+ 399,394,389,383,378,373,368,364,359,354,350,345,341,337,332,328,
+ 324,320,316,312,309,305,301,298,294,291,287,284,281,278,274,271,
+ 268,265,262,259,257,507,501,496,491,485,480,475,470,465,460,456,
+ 451,446,442,437,433,428,424,420,416,412,408,404,400,396,392,388,
+ 385,381,377,374,370,367,363,360,357,354,350,347,344,341,338,335,
+ 332,329,326,323,320,318,315,312,310,307,304,302,299,297,294,292,
+ 289,287,285,282,280,278,275,273,271,269,267,265,263,261,259];
+
+
+ var shg_table = [
+ 9, 11, 12, 13, 13, 14, 14, 15, 15, 15, 15, 16, 16, 16, 16, 17,
+ 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 19,
+ 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20,
+ 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21,
+ 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21,
+ 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22,
+ 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22,
+ 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 23,
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
+ 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24 ];
+
+
+ function getImageDataFromCanvas(canvas, top_x, top_y, width, height)
+ {
+ if (typeof(canvas) == 'string')
+ var canvas = document.getElementById(canvas);
+ else if (!canvas instanceof HTMLCanvasElement)
+ return;
+
+ var context = canvas.getContext('2d');
+ var imageData;
+
+ try {
+ // try {
+ imageData = context.getImageData(top_x, top_y, width, height);
+ /*} catch(e) {
+
+ // NOTE: this part is supposedly only needed if you want to work with local files
+ // so it might be okay to remove the whole try/catch block and just use
+ // imageData = context.getImageData(top_x, top_y, width, height);
+ try {
+ netscape.security.PrivilegeManager.enablePrivilege("UniversalBrowserRead");
+ imageData = context.getImageData(top_x, top_y, width, height);
+ } catch(e) {
+ alert("Cannot access local image");
+ throw new Error("unable to access local image data: " + e);
+ return;
+ }
+ }*/
+ } catch(e) {
+ throw new Error("unable to access image data: " + e);
+ }
+
+ return imageData;
+ }
+
+ function processCanvasRGB(canvas, top_x, top_y, width, height, radius)
+ {
+ if (isNaN(radius) || radius < 1) return;
+ radius |= 0;
+
+ var imageData = getImageDataFromCanvas(canvas, top_x, top_y, width, height);
+ imageData = processImageDataRGB(imageData, top_x, top_y, width, height, radius);
+
+ canvas.getContext('2d').putImageData(imageData, top_x, top_y);
+ }
+
+ function processImageDataRGB(imageData, top_x, top_y, width, height, radius)
+ {
+ var pixels = imageData.data;
+
+ var x, y, i, p, yp, yi, yw, r_sum, g_sum, b_sum,
+ r_out_sum, g_out_sum, b_out_sum,
+ r_in_sum, g_in_sum, b_in_sum,
+ pr, pg, pb, rbs;
+
+ var div = radius + radius + 1;
+ var w4 = width << 2;
+ var widthMinus1 = width - 1;
+ var heightMinus1 = height - 1;
+ var radiusPlus1 = radius + 1;
+ var sumFactor = radiusPlus1 * (radiusPlus1 + 1) / 2;
+
+ var stackStart = new BlurStack();
+ var stack = stackStart;
+ for (i = 1; i < div; i++)
+ {
+ stack = stack.next = new BlurStack();
+ if (i == radiusPlus1) var stackEnd = stack;
+ }
+ stack.next = stackStart;
+ var stackIn = null;
+ var stackOut = null;
+
+ yw = yi = 0;
+
+ var mul_sum = mul_table[radius];
+ var shg_sum = shg_table[radius];
+
+ for (y = 0; y < height; y++)
+ {
+ r_in_sum = g_in_sum = b_in_sum = r_sum = g_sum = b_sum = 0;
+
+ r_out_sum = radiusPlus1 * (pr = pixels[yi]);
+ g_out_sum = radiusPlus1 * (pg = pixels[yi+1]);
+ b_out_sum = radiusPlus1 * (pb = pixels[yi+2]);
+
+ r_sum += sumFactor * pr;
+ g_sum += sumFactor * pg;
+ b_sum += sumFactor * pb;
+
+ stack = stackStart;
+
+ for (i = 0; i < radiusPlus1; i++)
+ {
+ stack.r = pr;
+ stack.g = pg;
+ stack.b = pb;
+ stack = stack.next;
+ }
+
+ for (i = 1; i < radiusPlus1; i++)
+ {
+ p = yi + ((widthMinus1 < i ? widthMinus1 : i) << 2);
+ r_sum += (stack.r = (pr = pixels[p])) * (rbs = radiusPlus1 - i);
+ g_sum += (stack.g = (pg = pixels[p+1])) * rbs;
+ b_sum += (stack.b = (pb = pixels[p+2])) * rbs;
+
+ r_in_sum += pr;
+ g_in_sum += pg;
+ b_in_sum += pb;
+
+ stack = stack.next;
+ }
+
+
+ stackIn = stackStart;
+ stackOut = stackEnd;
+ for (x = 0; x < width; x++)
+ {
+ pixels[yi] = (r_sum * mul_sum) >> shg_sum;
+ pixels[yi+1] = (g_sum * mul_sum) >> shg_sum;
+ pixels[yi+2] = (b_sum * mul_sum) >> shg_sum;
+
+ r_sum -= r_out_sum;
+ g_sum -= g_out_sum;
+ b_sum -= b_out_sum;
+
+ r_out_sum -= stackIn.r;
+ g_out_sum -= stackIn.g;
+ b_out_sum -= stackIn.b;
+
+ p = (yw + ((p = x + radius + 1) < widthMinus1 ? p : widthMinus1)) << 2;
+
+ r_in_sum += (stackIn.r = pixels[p]);
+ g_in_sum += (stackIn.g = pixels[p+1]);
+ b_in_sum += (stackIn.b = pixels[p+2]);
+
+ r_sum += r_in_sum;
+ g_sum += g_in_sum;
+ b_sum += b_in_sum;
+
+ stackIn = stackIn.next;
+
+ r_out_sum += (pr = stackOut.r);
+ g_out_sum += (pg = stackOut.g);
+ b_out_sum += (pb = stackOut.b);
+
+ r_in_sum -= pr;
+ g_in_sum -= pg;
+ b_in_sum -= pb;
+
+ stackOut = stackOut.next;
+
+ yi += 4;
+ }
+ yw += width;
+ }
+
+
+ for (x = 0; x < width; x++)
+ {
+ g_in_sum = b_in_sum = r_in_sum = g_sum = b_sum = r_sum = 0;
+
+ yi = x << 2;
+ r_out_sum = radiusPlus1 * (pr = pixels[yi]);
+ g_out_sum = radiusPlus1 * (pg = pixels[yi+1]);
+ b_out_sum = radiusPlus1 * (pb = pixels[yi+2]);
+
+ r_sum += sumFactor * pr;
+ g_sum += sumFactor * pg;
+ b_sum += sumFactor * pb;
+
+ stack = stackStart;
+
+ for (i = 0; i < radiusPlus1; i++)
+ {
+ stack.r = pr;
+ stack.g = pg;
+ stack.b = pb;
+ stack = stack.next;
+ }
+
+ yp = width;
+
+ for (i = 1; i <= radius; i++)
+ {
+ yi = (yp + x) << 2;
+
+ r_sum += (stack.r = (pr = pixels[yi])) * (rbs = radiusPlus1 - i);
+ g_sum += (stack.g = (pg = pixels[yi+1])) * rbs;
+ b_sum += (stack.b = (pb = pixels[yi+2])) * rbs;
+
+ r_in_sum += pr;
+ g_in_sum += pg;
+ b_in_sum += pb;
+
+ stack = stack.next;
+
+ if(i < heightMinus1)
+ {
+ yp += width;
+ }
+ }
+
+ yi = x;
+ stackIn = stackStart;
+ stackOut = stackEnd;
+ for (y = 0; y < height; y++)
+ {
+ p = yi << 2;
+ pixels[p] = (r_sum * mul_sum) >> shg_sum;
+ pixels[p+1] = (g_sum * mul_sum) >> shg_sum;
+ pixels[p+2] = (b_sum * mul_sum) >> shg_sum;
+
+ r_sum -= r_out_sum;
+ g_sum -= g_out_sum;
+ b_sum -= b_out_sum;
+
+ r_out_sum -= stackIn.r;
+ g_out_sum -= stackIn.g;
+ b_out_sum -= stackIn.b;
+
+ p = (x + (((p = y + radiusPlus1) < heightMinus1 ? p : heightMinus1) * width)) << 2;
+
+ r_sum += (r_in_sum += (stackIn.r = pixels[p]));
+ g_sum += (g_in_sum += (stackIn.g = pixels[p+1]));
+ b_sum += (b_in_sum += (stackIn.b = pixels[p+2]));
+
+ stackIn = stackIn.next;
+
+ r_out_sum += (pr = stackOut.r);
+ g_out_sum += (pg = stackOut.g);
+ b_out_sum += (pb = stackOut.b);
+
+ r_in_sum -= pr;
+ g_in_sum -= pg;
+ b_in_sum -= pb;
+
+ stackOut = stackOut.next;
+
+ yi += width;
+ }
+ }
+
+ return imageData;
+ }
+
+ function BlurStack()
+ {
+ this.r = 0;
+ this.g = 0;
+ this.b = 0;
+ this.a = 0;
+ this.next = null;
+ }
+
+ return {
+ canvasRGB: processCanvasRGB
+ };
+});
+
+export default StackBlur;
diff --git a/assets/src/throttle.js b/assets/src/throttle.js
new file mode 100644
index 0000000..d041352
--- /dev/null
+++ b/assets/src/throttle.js
@@ -0,0 +1,22 @@
+/**
+ * See https://stackoverflow.com/questions/27078285/simple-throttle-in-js
+ *
+ * @param {Function} callback Function to throttle.
+ * @param {number} limit Throttle time
+ *
+ * @returns {function(): void} throttleled callback.
+ */
+const throttle = function ( callback, limit ) {
+ let waiting = false;
+ return function () {
+ if ( ! waiting ) {
+ callback.apply( this, arguments );
+ waiting = true;
+ setTimeout( function () {
+ waiting = false;
+ }, limit );
+ }
+ };
+};
+
+export default throttle;
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..bb20bfc
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,28 @@
+{
+ "name": "humanmade/gaussholder",
+ "description": "Fast and lightweight image previews for WordPress",
+ "license": "GPL-2.0-or-later",
+ "authors": [
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/humanmade/Gaussholder/graphs/contributors"
+ }
+ ],
+ "type": "wordpress-plugin",
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "support": {
+ "wiki": "https://github.com/humanmade/Gaussholder/wiki",
+ "source": "https://github.com/humanmade/Gaussholder/releases",
+ "issues": "https://github.com/humanmade/Gaussholder/issues"
+ },
+ "keywords": [
+ "wordpress",
+ "plugin",
+ "images"
+ ],
+ "require": {
+ "php": ">=5.3",
+ "composer/installers": "~1.0"
+ }
+}
diff --git a/dist/gaussholder.min.js b/dist/gaussholder.min.js
new file mode 100644
index 0000000..cfa10bf
--- /dev/null
+++ b/dist/gaussholder.min.js
@@ -0,0 +1 @@
+(()=>{"use strict";const t=function(){var t=[512,512,456,512,328,456,335,512,405,328,271,456,388,335,292,512,454,405,364,328,298,271,496,456,420,388,360,335,312,292,273,512,482,454,428,405,383,364,345,328,312,298,284,271,259,496,475,456,437,420,404,388,374,360,347,335,323,312,302,292,282,273,265,512,497,482,468,454,441,428,417,405,394,383,373,364,354,345,337,328,320,312,305,298,291,284,278,271,265,259,507,496,485,475,465,456,446,437,428,420,412,404,396,388,381,374,367,360,354,347,341,335,329,323,318,312,307,302,297,292,287,282,278,273,269,265,261,512,505,497,489,482,475,468,461,454,447,441,435,428,422,417,411,405,399,394,389,383,378,373,368,364,359,354,350,345,341,337,332,328,324,320,316,312,309,305,301,298,294,291,287,284,281,278,274,271,268,265,262,259,257,507,501,496,491,485,480,475,470,465,460,456,451,446,442,437,433,428,424,420,416,412,408,404,400,396,392,388,385,381,377,374,370,367,363,360,357,354,350,347,344,341,338,335,332,329,326,323,320,318,315,312,310,307,304,302,299,297,294,292,289,287,285,282,280,278,275,273,271,269,267,265,263,261,259],e=[9,11,12,13,13,14,14,15,15,15,15,16,16,16,16,17,17,17,17,17,17,17,18,18,18,18,18,18,18,18,18,19,19,19,19,19,19,19,19,19,19,19,19,19,19,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,20,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,21,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,22,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,23,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24,24];function n(){this.r=0,this.g=0,this.b=0,this.a=0,this.next=null}return{canvasRGB:function(r,a,i,o,s,l){if(!(isNaN(l)||l<1)){l|=0;var c=function(t,e,n,r,a){if("string"==typeof t)t=document.getElementById(t);else if(!t instanceof HTMLCanvasElement)return;var i,o=t.getContext("2d");try{i=o.getImageData(e,n,r,a)}catch(t){throw new Error("unable to access image data: "+t)}return i}(r,a,i,o,s);c=function(r,a,i,o,s,l){var c,g,d,u,f,h,v,w,m,b,p,y,I,x,E,C,k,A,L,S,R=r.data,B=l+l+1,P=o-1,D=s-1,F=l+1,G=F*(F+1)/2,H=new n,_=H;for(d=1;d>T,R[h+1]=m*O>>T,R[h+2]=b*O>>T,w-=p,m-=y,b-=I,p-=M.r,y-=M.g,I-=M.b,u=v+((u=c+l+1) >T,R[u+1]=m*O>>T,R[u+2]=b*O>>T,w-=p,m-=y,b-=I,p-=M.r,y-=M.g,I-=M.b,u=c+((u=g+F)>8&255,o[t.height_offset+1]=255&a,o[t.length_offset]=r>>8&255,o[t.length_offset+1]=255&r,function(t){for(var e="",n=new Uint8Array(t),r=n.byteLength,a=0;a800)return t.style[e]="",t.style.clipPath="",void(t.style.webkitClipPath="");var s=n*(1-o/800);t.style[e]="blur("+.5*s+"px)",window.requestAnimationFrame(r)}))}}};var i=[],o=function(){for(var t=[],e=i.length-1;e>=0;e--){var n=i[e];n.getBoundingClientRect().top<=window.innerHeight+1200?a(n):t.push(n)}i=t};const s=function(t){i=t;var e,n,r,a=(e=o,n=40,r=!1,function(){r||(e.apply(this,arguments),r=!0,setTimeout((function(){r=!1}),n))});o(),window.addEventListener("scroll",a);var s=window.setInterval((function(){i.length<1&&(window.removeEventListener("scroll",a),window.clearInterval(s))}),1e3)};function l(){var t=document.getElementsByTagName("img");"undefined"==typeof IntersectionObserver?s(t):function(t){var e=new IntersectionObserver((function(t){t.filter((function(t){return!0===t.isIntersecting})).forEach((function(t){var n=t.target;a(n),e.unobserve(n)}))}),{rootMargin:"1200px"});Array.from(t).forEach((function(t){e.observe(t)}))}(t),Array.prototype.slice.call(t).forEach(r)}document.addEventListener("DOMContentLoaded",l),window.GaussHolder=l})();
\ No newline at end of file
diff --git a/gaussholder.php b/gaussholder.php
new file mode 100644
index 0000000..862b507
--- /dev/null
+++ b/gaussholder.php
@@ -0,0 +1,37 @@
+[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; }';
+ });
+}
diff --git a/inc/class-plugin.php b/inc/class-plugin.php
new file mode 100644
index 0000000..5ab48de
--- /dev/null
+++ b/inc/class-plugin.php
@@ -0,0 +1,248 @@
+ blur radius.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.)
+ *
+ * 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.
+ *
+ * The radius needs to be tuned to each size individually. Ideally, you want
+ * to keep it to about 200 bytes of data for the placeholder.
+ *
+ * (Also note: changing the radius requires regenerating the
+ * placeholder data.)
+ *
+ * @param string[] $enabled Enabled sizes.
+ */
+ return apply_filters( 'gaussholder.image_sizes', array() );
+}
+
+function get_blur_radius() {
+ /**
+ * Filter the blur radius.
+ *
+ * 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.)
+ *
+ * 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.
+ *
+ * (Also note: changing this requires regenerating the placeholder data.)
+ *
+ * @param int $radius Blur radius in pixels.
+ */
+ return apply_filters( 'gaussholder.blur_radius', 16 );
+}
+
+/**
+ * Get the blur radius for a given size.
+ *
+ * @param string $size Image size to get radius for.
+ * @return int|null Radius in pixels if enabled, null if size isn't enabled.
+ */
+function get_blur_radius_for_size( $size ) {
+ $sizes = get_enabled_sizes();
+ if ( ! isset( $sizes[ $size ] ) ) {
+ return null;
+ }
+
+ return absint( $sizes[ $size ] );
+}
+
+/**
+ * Is the size enabled for placeholders?
+ *
+ * @param string $size Image size to check.
+ * @return boolean True if enabled, false if not. Simple.
+ */
+function is_enabled_size( $size ) {
+ return in_array( $size, array_keys( get_enabled_sizes() ) );
+}
+
+/**
+ * Get a placeholder for an image.
+ *
+ * @param int $id Attachment ID.
+ * @param string $size Image size.
+ * @return string
+ */
+function get_placeholder( $id, $size ) {
+ if ( ! is_enabled_size( $size ) ) {
+ return null;
+ }
+
+ $meta = get_post_meta( $id, META_PREFIX . $size, true );
+ if ( empty( $meta ) ) {
+ return null;
+ }
+
+ return $meta;
+}
+
+/**
+ * Schedule a background task to generate placeholders.
+ *
+ * @param array $metadata
+ * @param int $attachment_id
+ * @return array
+ */
+function queue_generate_placeholders_on_save( $metadata, $attachment_id ) {
+ // Is this a JPEG?
+ $mime_type = get_post_mime_type( $attachment_id );
+ if ( ! in_array( $mime_type, array( 'image/jpg', 'image/jpeg' ) ) ) {
+ return $metadata;
+ }
+
+ wp_schedule_single_event( time() + 5, 'gaussholder.generate_placeholders', [ $attachment_id ] );
+
+ return $metadata;
+}
+
+/**
+ * Save extracted colors to image metadata
+ *
+ * @param $metadata
+ * @param $attachment_id
+ *
+ * @return WP_Error|bool
+ */
+function generate_placeholders( $attachment_id ) {
+ // Is this a JPEG?
+ $mime_type = get_post_mime_type( $attachment_id );
+ if ( ! in_array( $mime_type, array( 'image/jpg', 'image/jpeg' ) ) ) {
+ return new WP_Error( 'image-not-jpg', 'Image is not a JPEG.' );
+ }
+
+ $errors = new WP_Error;
+
+ $sizes = get_enabled_sizes();
+ foreach ( $sizes as $size => $radius ) {
+ try {
+ $data = generate_placeholder( $attachment_id, $size, $radius );
+ } catch ( \ImagickException $e ) {
+ $errors->add( $size, sprintf( 'Unable to generate placeholder for %s (Imagick exception - %s)', $size, $e->getMessage() ) );
+ continue;
+ }
+
+ if ( empty( $data ) ) {
+ $errors->add( $size, sprintf( 'Unable to generate placeholder for %s', $size ) );
+ continue;
+ }
+
+ // Comma-separated data, width, and height
+ $for_database = sprintf( '%s,%d,%d', base64_encode( $data[0] ), $data[1], $data[2] );
+ update_post_meta( $attachment_id, META_PREFIX . $size, $for_database );
+ }
+
+ if ( $errors->has_errors() ) {
+ return $errors;
+ }
+
+ return true;
+}
+
+/**
+ * Get data for a given image size.
+ *
+ * @param string $size Image size.
+ * @return array|null Image size data (with `width`, `height`, `crop` keys) on success, null if image size is invalid.
+ */
+function get_size_data( $size ) {
+ global $_wp_additional_image_sizes;
+
+ switch ( $size ) {
+ case 'thumbnail':
+ case 'medium':
+ case 'large':
+ $size_data = array(
+ 'width' => get_option( "{$size}_size_w" ),
+ 'height' => get_option( "{$size}_size_h" ),
+ 'crop' => get_option( "{$size}_crop" ),
+ );
+ break;
+
+ default:
+ if ( ! isset( $_wp_additional_image_sizes[ $size ] ) ) {
+ return null;
+ }
+
+ $size_data = $_wp_additional_image_sizes[ $size ];
+ break;
+ }
+
+ return $size_data;
+}
+
+/**
+ * Generate a placeholder at a given size.
+ *
+ * @param int $id Attachment ID.
+ * @param string $size Image size.
+ * @param int $radius Blur radius.
+ * @return array|null 3-tuple of binary image data (string), width (int), height (int) on success; null on error.
+ */
+function generate_placeholder( $id, $size, $radius ) {
+ $size_data = get_size_data( $size );
+ if ( $size !== 'full' && empty( $size_data ) ) {
+ _doing_it_wrong( __FUNCTION__, __( 'Invalid image size enabled for placeholders', 'gaussholder' ), '1.0.0' );
+ return null;
+ }
+
+ $uploads = wp_upload_dir();
+ $img = wp_get_attachment_image_src( $id, $size );
+
+ // Pass image paths directly to data_for_file.
+ if ( strpos( $img[0], $uploads['baseurl'] ) === 0 ) {
+ $path = str_replace( $uploads['baseurl'], $uploads['basedir'], $img[0] );
+ return JPEG\data_for_file( $path, $radius );
+ }
+
+ // If the image url wp_get_attachment_image_src is not a local url (for example),
+ // using Tachyon or Photon, download the file to temp before passing it to data_for_file.
+ // This is needed because IMagick can not handle remote files, and we specifically want
+ // to use the remote file rather than mapping it to an image on disk, as the remote
+ // service such as Tachyon may look different (smart dropping, image filters) etc.
+ $path = download_url( $img[0] );
+ if ( is_wp_error( $path ) ) {
+ trigger_error( sprintf( 'Error downloading image from %s: ', $img[0], $path->get_error_message() ), E_USER_WARNING );
+ return;
+ }
+ $data = JPEG\data_for_file( $path, $radius );
+ unlink( $path );
+ return $data;
+}
diff --git a/inc/class-wp-cli-command.php b/inc/class-wp-cli-command.php
new file mode 100644
index 0000000..04fdacd
--- /dev/null
+++ b/inc/class-wp-cli-command.php
@@ -0,0 +1,157 @@
+ [--dry-run] [--verbose] [--regenerate]
+ */
+ public function process( $args, $args_assoc ) {
+
+ $args_assoc = wp_parse_args( $args_assoc, array(
+ 'verbose' => true,
+ 'dry-run' => false,
+ 'regenerate' => false,
+ ) );
+
+ $attachment_id = absint( $args[0] );
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+
+ if ( ! $args_assoc['regenerate'] ) {
+ return;
+ }
+
+ // Unless regenerating, skip attachments that already have data.
+ $has_placeholder = false;
+ if ( ! $args_assoc['regenerate'] && $has_placeholder ) {
+
+ if ( $args_assoc['verbose'] ) {
+ WP_CLI::line( sprintf( 'Skipping attachment %d. Data already exists.', $attachment_id ) );
+ }
+
+ return;
+
+ }
+
+ if ( ! $args_assoc['dry-run'] ) {
+ $result = generate_placeholders( $attachment_id );
+ }
+
+ if ( is_wp_error( $result ) ) {
+ WP_CLI::error( implode( "\n", $result->get_error_messages() ) );
+ }
+
+ if ( $args_assoc['verbose'] ) {
+ WP_CLI::line( sprintf( 'Updated caclulated colors for attachment %d.', $attachment_id ) );
+ }
+
+ }
+
+ /**
+ * Process image color data for all attachments.
+ *
+ * @subcommand process-all
+ * @synopsis [--dry-run] [--count=] [--offset=] [--regenerate]
+ */
+ public function process_all( $args, $args_assoc ) {
+
+ $args_assoc = wp_parse_args( $args_assoc, array(
+ 'count' => 1,
+ 'offset' => 0,
+ 'dry-run' => false,
+ 'regenerate' => false,
+ ) );
+
+ if ( empty( $page ) ) {
+ $page = absint( $args_assoc['offset'] ) / absint( $args_assoc['count'] );
+ $page = ceil( $page );
+ if ( $page < 1 ) {
+ $page = 1;
+ }
+ }
+
+ while ( empty( $no_more_posts ) ) {
+
+ $query = new WP_Query( array(
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'fields' => 'ids',
+ 'posts_per_page' => $args_assoc['count'],
+ 'paged' => $page,
+ ) );
+
+ if ( empty( $progress_bar ) ) {
+ $progress_bar = new cli\progress\Bar( sprintf( 'Processing images [Total: %d]', absint( $query->found_posts ) ), absint( $query->found_posts ), 100 );
+ $progress_bar->display();
+ }
+
+ foreach ( $query->posts as $post_id ) {
+
+ $progress_bar->tick( 1, sprintf( 'Processing images [Total: %d / Processing ID: %d]', absint( $query->found_posts ), $post_id ) );
+
+ $this->process(
+ array( $post_id ),
+ array(
+ 'verbose' => false,
+ 'dry-run' => $args_assoc['dry-run'],
+ 'regenerate' => $args_assoc['regenerate']
+ )
+ );
+
+ }
+
+ if ( $query->get('paged') >= $query->max_num_pages ) {
+ $no_more_posts = true;
+ }
+
+ if ( $query->get('paged') === 0 ) {
+ $page = 2;
+ } else {
+ $page = absint( $query->get('paged') ) + 1;
+ }
+
+ }
+
+ $progress_bar->finish();
+
+ }
+
+ /**
+ * Check how big the placeholder will be for an image or file with a given
+ * radius.
+ *
+ * @subcommand check-size
+ * @synopsis
+ * @param array $args
+ */
+ public function check_size( $args ) {
+ if ( is_numeric( $args[0] ) ) {
+ $attachment_id = absint( $args[0] );
+ $file = get_attached_file( $attachment_id );
+ if ( empty( $file ) ) {
+ WP_CLI::error( __( 'Attachment does not exist', 'gaussholder' ) );
+ }
+ } else {
+ $file = $args[1];
+ }
+
+ if ( ! file_exists( $file ) ) {
+ WP_CLI::error( sprintf( __( 'File %s does not exist', 'gaussholder' ), $file ) );
+ }
+
+ // Generate a placeholder with the radius
+ $radius = absint( $args[1] );
+ $data = JPEG\data_for_file( $file, $radius );
+ WP_CLI::line( sprintf( '%s: %dB (%dpx radius)', basename( $file ), strlen( $data[0] ), $radius ) );
+ }
+
+}
diff --git a/inc/frontend/namespace.php b/inc/frontend/namespace.php
new file mode 100644
index 0000000..045001c
--- /dev/null
+++ b/inc/frontend/namespace.php
@@ -0,0 +1,187 @@
+';
+
+ // Output header onto the page
+ $header = JPEG\build_header();
+ $header['header'] = base64_encode( $header['header'] );
+ echo 'var GaussholderHeader = ' . json_encode( $header ) . ";\n";
+
+ echo file_get_contents( Gaussholder\PLUGIN_DIR . '/dist/gaussholder.min.js' ) . "\n";
+
+ echo '';
+
+ // Clipping path for Firefox compatibility on fade in
+ echo ' ';
+}
+
+/**
+ * Mangle tags in the post content.
+ *
+ * Replaces the tag src to stop browsers loading the source early, as well
+ * as adding the Gaussholder data.
+ * @param [type] $content [description]
+ * @return [type] [description]
+ */
+function mangle_images( $content ) {
+ // Find images
+ $searcher = '# ]+(?:class=[\'"]([^\'"]*wp-image-(\d+)[^\'"]*)|data-gaussholder-id="(\d+)")[^>]+>#x';
+ $preg_match_result = preg_match_all( $searcher, $content, $images, PREG_SET_ORDER );
+ /**
+ * Filter the regexp results when looking for images in a post content.
+ *
+ * By default, Gaussholder applies the $searcher regexp inside the_content filter callback. Some page builders
+ * manage images in different ways so the result could be false.
+ *
+ * This filter allows to change that result but also the images list generated by preg_match_all( $searcher, $content, $images, PREG_SET_ORDER )
+ * The $images parameter must be returned with the same format. That is:
+ *
+ * [
+ * 0 => Image tag ( )
+ * 1 => Image tag class
+ * 2 => Attachment ID
+ * ]
+ */
+ $preg_match_result = apply_filters_ref_array( 'gaussholder.mangle_images_regexp_results', [ $preg_match_result, &$images, $content, $searcher ] );
+ if ( ! $preg_match_result ) {
+ return $content;
+ }
+
+ $blank = file_get_contents( Gaussholder\PLUGIN_DIR . '/assets/blank.gif' );
+ $blank_url = 'data:image/gif;base64,' . base64_encode( $blank );
+
+ foreach ( $images as $image ) {
+ $tag = $image[0];
+ if ( ! empty( $image[2] ) ) {
+ // Singular image, using `class="wp-image-"`
+ $id = $image[2];
+ $class = $image[1];
+
+ // Attempt to get the image size from a size class.
+ if ( ! preg_match( '#\bsize-([\w-]+)\b#', $class, $size_match ) ) {
+ // If we don't have a size class, the only other option is to search
+ // all the URLs for image sizes that we support, and see if the src
+ // attribute matches.
+ preg_match( '#\bsrc=[\'|"]([^\'"]*)#', $tag, $src_match );
+ $all_sizes = array_keys( Gaussholder\get_enabled_sizes() );
+ foreach ( $all_sizes as $single_size ) {
+ $url = wp_get_attachment_image_src( $id, $single_size );
+ // WordPress applies esc_attr (and sometimes esc_url) to all image attributes,
+ // so we have decode entities when making a comparison.
+ if ( $url[0] === html_entity_decode( $src_match[1] ) ) {
+ $size = $single_size;
+ break;
+ }
+ }
+ // If we still were not able to find the image size from the src
+ // attribute, then skip this image.
+ if ( ! isset( $size ) ) {
+ continue;
+ }
+ } else {
+ $size = $size_match[1];
+ }
+
+ } else {
+ // Gallery, using `data-gaussholder-id=""`
+ $id = $image[3];
+ if ( ! preg_match( '# class=[\'"][^\'"]*\battachment-([\w-]+)\b#', $tag, $size_match ) ) {
+ continue;
+ }
+ $size = $size_match[1];
+ }
+
+ if ( ! Gaussholder\is_enabled_size( $size ) ) {
+ continue;
+ }
+
+ $new_attrs = array();
+
+ // Replace src with our blank GIF
+ $new_attrs[] = 'src="' . esc_attr( $blank_url ) . '"';
+
+ // Remove srcset
+ $new_attrs[] = 'srcset=""';
+
+ // Add the actual placeholder
+ $placeholder = Gaussholder\get_placeholder( $id, $size );
+ $new_attrs[] = 'data-gaussholder="' . esc_attr( $placeholder ) . '"';
+
+ // Add final size
+ $image_data = wp_get_attachment_image_src( $id, $size );
+ $size_data = [
+ 'width' => $image_data[1],
+ 'height' => $image_data[2],
+ ];
+ $radius = Gaussholder\get_blur_radius_for_size( $size );
+
+ // Has the size been overridden?
+ if ( preg_match( '#height=[\'"](\d+)[\'"]#i', $tag, $matches ) ) {
+ $size_data['height'] = absint( $matches[1] );
+ }
+ if ( preg_match( '#width=[\'"](\d+)[\'"]#i', $tag, $matches ) ) {
+ $size_data['width'] = absint( $matches[1] );
+ }
+ $new_attrs[] = sprintf(
+ 'data-gaussholder-size="%s,%s,%s"',
+ $size_data['width'],
+ $size_data['height'],
+ $radius
+ );
+
+ $mangled_tag = str_replace(
+ array(
+ ' srcset="',
+ ' src="',
+ ),
+ array(
+ ' data-originalsrcset="',
+ ' ' . implode( ' ', $new_attrs ) . ' data-originalsrc="',
+ ),
+ $tag
+ );
+
+ $content = str_replace( $tag, $mangled_tag, $content );
+ }
+
+ return $content;
+}
+
+/**
+ * Adds a style attribute to image HTML.
+ *
+ * @param $attr
+ * @param $attachment
+ * @param $size
+ *
+ * @return mixed
+ */
+function add_placeholder_to_attributes( $attr, $attachment, $size ) {
+ // Are placeholders enabled for this size?
+ if ( ! Gaussholder\is_enabled_size( $size ) ) {
+ return $attr;
+ }
+
+ $attr['data-gaussholder-id'] = $attachment->ID;
+
+ return $attr;
+}
diff --git a/inc/jpeg/namespace.php b/inc/jpeg/namespace.php
new file mode 100644
index 0000000..f38e01e
--- /dev/null
+++ b/inc/jpeg/namespace.php
@@ -0,0 +1,222 @@
+readImageBlob( file_get_contents( $file ) );
+ } else {
+ $editor = new Imagick( $file );
+ }
+
+ $size = $editor->getImageGeometry();
+
+ // Normalise the density to 72dpi
+ $editor->setImageResolution( 72, 72 );
+
+ // Set sampling factors to constant
+ $editor->setSamplingFactors(array('1x1', '1x1', '1x1'));
+
+ // Ensure we use default Huffman tables
+ $editor->setOption('jpeg:optimize-coding', false);
+
+ // Strip unnecessary header data
+ $editor->stripImage();
+
+ // Adjust by scaling factor
+ $width = floor( $size['width'] / $radius );
+ $height = floor( $size['height'] / $radius );
+ $editor->scaleImage( $width, $height );
+
+ $scaled = $editor->getImageBlob();
+
+ // Strip the header
+ $scaled_stripped = substr( $scaled, strpos( $scaled, "\xFF\xDA" ) + 2 );
+
+ return array( $scaled_stripped, $width, $height );
+}
diff --git a/preview.gif b/preview.gif
new file mode 100644
index 0000000..5c89275
Binary files /dev/null and b/preview.gif differ