diff --git a/plugins/dominant-color-images/assets/lqip.css b/plugins/dominant-color-images/assets/lqip.css new file mode 100644 index 0000000000..6bbae6c933 --- /dev/null +++ b/plugins/dominant-color-images/assets/lqip.css @@ -0,0 +1,182 @@ +/** + * CSS-only Low Quality Image Placeholder (LQIP) + * + * Elements with an inline --lqip custom property (e.g. style="--lqip:192900") + * automatically receive a multi-cell gradient background derived from the image's + * six dominant color regions. The image itself sits on top once loaded, hiding + * the placeholder. + * + * Ported from https://github.com/Kalabasa/leanrada.com/blob/bca158ebdc8488364ecac923413ca40b2326826d/main/site/common.css#L140-L337 + */ + +@property --lqip { + syntax: ""; + inherits: true; + initial-value: 0; +} + +[style*="--lqip:"] { + --lqip-ca: mod(round(down, calc((var(--lqip) + 524288) / 262144)), 4); + --lqip-cb: mod(round(down, calc((var(--lqip) + 524288) / 65536)), 4); + --lqip-cc: mod(round(down, calc((var(--lqip) + 524288) / 16384)), 4); + --lqip-cd: mod(round(down, calc((var(--lqip) + 524288) / 4096)), 4); + --lqip-ce: mod(round(down, calc((var(--lqip) + 524288) / 1024)), 4); + --lqip-cf: mod(round(down, calc((var(--lqip) + 524288) / 256)), 4); + --lqip-ll: mod(round(down, calc((var(--lqip) + 524288) / 64)), 4); + --lqip-aaa: mod(round(down, calc((var(--lqip) + 524288) / 8)), 8); + --lqip-bbb: mod(calc(var(--lqip) + 524288), 8); + + --lqip-ca-clr: hsl(0 0% calc(var(--lqip-ca) / 3 * 100%)); + --lqip-cb-clr: hsl(0 0% calc(var(--lqip-cb) / 3 * 100%)); + --lqip-cc-clr: hsl(0 0% calc(var(--lqip-cc) / 3 * 100%)); + --lqip-cd-clr: hsl(0 0% calc(var(--lqip-cd) / 3 * 100%)); + --lqip-ce-clr: hsl(0 0% calc(var(--lqip-ce) / 3 * 100%)); + --lqip-cf-clr: hsl(0 0% calc(var(--lqip-cf) / 3 * 100%)); + --lqip-base-clr: oklab( + calc(var(--lqip-ll) / 3 * 0.6 + 0.2) + calc(var(--lqip-aaa) / 8 * 0.7 - 0.35) + calc((var(--lqip-bbb) + 1) / 8 * 0.7 - 0.35) + ); + + --lqip-stop10: 2%; + --lqip-stop20: 8%; + --lqip-stop30: 18%; + --lqip-stop40: 32%; + background-blend-mode: + hard-light, hard-light, hard-light, hard-light, hard-light, hard-light, + overlay, overlay, overlay, overlay, overlay, overlay, + normal; + background-image: radial-gradient(50% 75% at 16.67% 25%, + rgb(from var(--lqip-ca-clr) r g b / 50%), + rgb(from var(--lqip-ca-clr) r g b / calc(50% - var(--lqip-stop10) / 2)) 10%, + rgb(from var(--lqip-ca-clr) r g b / calc(50% - var(--lqip-stop20) / 2)) 20%, + rgb(from var(--lqip-ca-clr) r g b / calc(50% - var(--lqip-stop30) / 2)) 30%, + rgb(from var(--lqip-ca-clr) r g b / calc(50% - var(--lqip-stop40) / 2)) 40%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop40) / 2)) 60%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop30) / 2)) 70%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop20) / 2)) 80%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop10) / 2)) 90%, + transparent), + radial-gradient(50% 75% at 83.33% 25%, + rgb(from var(--lqip-cc-clr) r g b / 50%), + rgb(from var(--lqip-cc-clr) r g b / calc(50% - var(--lqip-stop10) / 2)) 10%, + rgb(from var(--lqip-cc-clr) r g b / calc(50% - var(--lqip-stop20) / 2)) 20%, + rgb(from var(--lqip-cc-clr) r g b / calc(50% - var(--lqip-stop30) / 2)) 30%, + rgb(from var(--lqip-cc-clr) r g b / calc(50% - var(--lqip-stop40) / 2)) 40%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop40) / 2)) 60%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop30) / 2)) 70%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop20) / 2)) 80%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop10) / 2)) 90%, + transparent), + radial-gradient(50% 75% at 50% 25%, + rgb(from var(--lqip-cb-clr) r g b / 50%), + rgb(from var(--lqip-cb-clr) r g b / calc(50% - var(--lqip-stop10) / 2)) 10%, + rgb(from var(--lqip-cb-clr) r g b / calc(50% - var(--lqip-stop20) / 2)) 20%, + rgb(from var(--lqip-cb-clr) r g b / calc(50% - var(--lqip-stop30) / 2)) 30%, + rgb(from var(--lqip-cb-clr) r g b / calc(50% - var(--lqip-stop40) / 2)) 40%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop40) / 2)) 60%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop30) / 2)) 70%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop20) / 2)) 80%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop10) / 2)) 90%, + transparent), + radial-gradient(50% 75% at 16.67% 75%, + rgb(from var(--lqip-cd-clr) r g b / 50%), + rgb(from var(--lqip-cd-clr) r g b / calc(50% - var(--lqip-stop10) / 2)) 10%, + rgb(from var(--lqip-cd-clr) r g b / calc(50% - var(--lqip-stop20) / 2)) 20%, + rgb(from var(--lqip-cd-clr) r g b / calc(50% - var(--lqip-stop30) / 2)) 30%, + rgb(from var(--lqip-cd-clr) r g b / calc(50% - var(--lqip-stop40) / 2)) 40%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop40) / 2)) 60%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop30) / 2)) 70%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop20) / 2)) 80%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop10) / 2)) 90%, + transparent), + radial-gradient(50% 75% at 83.33% 75%, + rgb(from var(--lqip-cf-clr) r g b / 50%), + rgb(from var(--lqip-cf-clr) r g b / calc(50% - var(--lqip-stop10) / 2)) 10%, + rgb(from var(--lqip-cf-clr) r g b / calc(50% - var(--lqip-stop20) / 2)) 20%, + rgb(from var(--lqip-cf-clr) r g b / calc(50% - var(--lqip-stop30) / 2)) 30%, + rgb(from var(--lqip-cf-clr) r g b / calc(50% - var(--lqip-stop40) / 2)) 40%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop40) / 2)) 60%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop30) / 2)) 70%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop20) / 2)) 80%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop10) / 2)) 90%, + transparent), + radial-gradient(50% 75% at 50% 75%, + rgb(from var(--lqip-ce-clr) r g b / 50%), + rgb(from var(--lqip-ce-clr) r g b / calc(50% - var(--lqip-stop10) / 2)) 10%, + rgb(from var(--lqip-ce-clr) r g b / calc(50% - var(--lqip-stop20) / 2)) 20%, + rgb(from var(--lqip-ce-clr) r g b / calc(50% - var(--lqip-stop30) / 2)) 30%, + rgb(from var(--lqip-ce-clr) r g b / calc(50% - var(--lqip-stop40) / 2)) 40%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop40) / 2)) 60%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop30) / 2)) 70%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop20) / 2)) 80%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop10) / 2)) 90%, + transparent), + radial-gradient(50% 75% at 16.67% 25%, + var(--lqip-ca-clr), + rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop10))) 10%, + rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop20))) 20%, + rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop30))) 30%, + rgb(from var(--lqip-ca-clr) r g b / calc(100% - var(--lqip-stop40))) 40%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop40))) 60%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop30))) 70%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop20))) 80%, + rgb(from var(--lqip-ca-clr) r g b / calc(var(--lqip-stop10))) 90%, + transparent), + radial-gradient(50% 75% at 50% 25%, + var(--lqip-cb-clr), + rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop10))) 10%, + rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop20))) 20%, + rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop30))) 30%, + rgb(from var(--lqip-cb-clr) r g b / calc(100% - var(--lqip-stop40))) 40%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop40))) 60%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop30))) 70%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop20))) 80%, + rgb(from var(--lqip-cb-clr) r g b / calc(var(--lqip-stop10))) 90%, + transparent), + radial-gradient(50% 75% at 83.33% 25%, + var(--lqip-cc-clr), + rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop10))) 10%, + rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop20))) 20%, + rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop30))) 30%, + rgb(from var(--lqip-cc-clr) r g b / calc(100% - var(--lqip-stop40))) 40%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop40))) 60%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop30))) 70%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop20))) 80%, + rgb(from var(--lqip-cc-clr) r g b / calc(var(--lqip-stop10))) 90%, + transparent), + radial-gradient(50% 75% at 16.67% 75%, + var(--lqip-cd-clr), + rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop10))) 10%, + rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop20))) 20%, + rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop30))) 30%, + rgb(from var(--lqip-cd-clr) r g b / calc(100% - var(--lqip-stop40))) 40%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop40))) 60%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop30))) 70%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop20))) 80%, + rgb(from var(--lqip-cd-clr) r g b / calc(var(--lqip-stop10))) 90%, + transparent), + radial-gradient(50% 75% at 50% 75%, + var(--lqip-ce-clr), + rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop10))) 10%, + rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop20))) 20%, + rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop30))) 30%, + rgb(from var(--lqip-ce-clr) r g b / calc(100% - var(--lqip-stop40))) 40%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop40))) 60%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop30))) 70%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop20))) 80%, + rgb(from var(--lqip-ce-clr) r g b / calc(var(--lqip-stop10))) 90%, + transparent), + radial-gradient(50% 75% at 83.33% 75%, + var(--lqip-cf-clr), + rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop10))) 10%, + rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop20))) 20%, + rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop30))) 30%, + rgb(from var(--lqip-cf-clr) r g b / calc(100% - var(--lqip-stop40))) 40%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop40))) 60%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop30))) 70%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop20))) 80%, + rgb(from var(--lqip-cf-clr) r g b / calc(var(--lqip-stop10))) 90%, + transparent), + linear-gradient(0deg, var(--lqip-base-clr), var(--lqip-base-clr)); +} diff --git a/plugins/dominant-color-images/class-dominant-color-image-editor-gd.php b/plugins/dominant-color-images/class-dominant-color-image-editor-gd.php index df4be5633a..cd3ba3c2f5 100644 --- a/plugins/dominant-color-images/class-dominant-color-image-editor-gd.php +++ b/plugins/dominant-color-images/class-dominant-color-image-editor-gd.php @@ -21,43 +21,145 @@ class Dominant_Color_Image_Editor_GD extends WP_Image_Editor_GD { /** - * Get dominant color from a file. + * Get dominant color from a file as raw RGB values. + * + * Averages all pixels in linear light to avoid the gamma-skewed + * dominant color that results from averaging gamma-encoded sRGB values. * * @since 1.0.0 * - * @return string|WP_Error Dominant hex color string, or an error on failure. + * @return array{r: int, g: int, b: int}|WP_Error RGB values (0-255), or WP_Error on failure. */ public function get_dominant_color() { if ( ! (bool) $this->image ) { return new WP_Error( 'image_editor_dominant_color_error_no_image', __( 'Dominant color detection no image found.', 'dominant-color-images' ) ); } - // The logic here is resize the image to 1x1 pixel, then get the color of that pixel. - $shorted_image = imagecreatetruecolor( 1, 1 ); - if ( false === $shorted_image ) { - return new WP_Error( 'image_editor_dominant_color_error', __( 'Dominant color detection failed.', 'dominant-color-images' ) ); - } - // Note: These two functions only return integers, but they used to return int|false in PHP<8. This was changed in the PHP documentation in - // and . However, PhpStorm's stubs still think - // they return int|false. However, from looking at these functions - // apparently only ever returned integers. So the type casting is here for the possible sake PHP<8. + $image_width = (int) imagesx( $this->image ); // @phpstan-ignore cast.useless $image_height = (int) imagesy( $this->image ); // @phpstan-ignore cast.useless - imagecopyresampled( $shorted_image, $this->image, 0, 0, 0, 0, 1, 1, $image_width, $image_height ); - $rgb = imagecolorat( $shorted_image, 0, 0 ); - if ( false === $rgb ) { - return new WP_Error( 'image_editor_dominant_color_error', __( 'Dominant color detection failed.', 'dominant-color-images' ) ); + // Resize to a bounded working copy so the per-pixel PHP loop + // stays fast even when the source image is very large (e.g. when + // the 'medium' subsize is unavailable). The thumbnail is always + // truecolor, which simplifies the iteration code below. + $source_image = $this->image; + $max_dimension = 150; + if ( $image_width > $max_dimension || $image_height > $max_dimension ) { + $scale = min( $max_dimension / $image_width, $max_dimension / $image_height, 1.0 ); + $thumb_width = max( 1, (int) round( $image_width * $scale ) ); + $thumb_height = max( 1, (int) round( $image_height * $scale ) ); + $thumb = imagecreatetruecolor( $thumb_width, $thumb_height ); + if ( false !== $thumb ) { + // Preserve alpha during resize: save alpha on the source, + // resample, then save alpha on the thumbnail. + imagecopyresampled( $thumb, $this->image, 0, 0, 0, 0, $thumb_width, $thumb_height, $image_width, $image_height ); + $source_image = $thumb; + $image_width = $thumb_width; + $image_height = $thumb_height; + } + } + + // Build a 256-entry LUT for sRGB 8-bit → linear float conversion. + $srgb_to_linear = array(); + for ( $i = 0; $i < 256; $i++ ) { + $val = $i / 255; + if ( $val <= 0.04045 ) { + $srgb_to_linear[ $i ] = $val / 12.92; + } else { + $srgb_to_linear[ $i ] = pow( ( $val + 0.055 ) / 1.055, 2.4 ); + } + } + + // Helper: linear float (0.0–1.0) → sRGB 8-bit integer. + $linear_to_srgb = static function ( float $linear ): int { + if ( $linear <= 0.0031308 ) { + $srgb = 12.92 * $linear; + } else { + $srgb = 1.055 * pow( $linear, 1 / 2.4 ) - 0.055; + } + $clamped = max( 0.0, min( 1.0, $srgb ) ); + return (int) round( $clamped * 255 ); + }; + + // The working copy is always truecolor (imagecreatetruecolor) or the + // untouched original (which may be palette-based). If the original is + // palette-based and no resize was needed, cache the palette. + $is_truecolor = imageistruecolor( $source_image ); + $palette = null; + if ( ! $is_truecolor ) { + $palette = array(); + $num_colors = imagecolorstotal( $source_image ); + for ( $i = 0; $i < $num_colors; $i++ ) { + $palette[ $i ] = imagecolorsforindex( $source_image, $i ); + } } - $r = ( $rgb >> 16 ) & 0xFF; - $g = ( $rgb >> 8 ) & 0xFF; - $b = $rgb & 0xFF; - $hex = dominant_color_rgb_to_hex( $r, $g, $b ); - if ( null === $hex ) { + + // Iterate every pixel, decode to linear light, and accumulate. + // Skip transparent pixels on the first pass; if no opaque pixels + // are found, fall back to including all pixels. + $include_transparent = false; + + do { + $sum_r = 0.0; + $sum_g = 0.0; + $sum_b = 0.0; + $count = 0; + $loop_again = false; + + for ( $y = 0; $y < $image_height; $y++ ) { + for ( $x = 0; $x < $image_width; $x++ ) { + $rgb = imagecolorat( $source_image, $x, $y ); + if ( false === $rgb ) { + continue; + } + + if ( $is_truecolor ) { + $r = ( $rgb >> 16 ) & 0xFF; + $g = ( $rgb >> 8 ) & 0xFF; + $b = $rgb & 0xFF; + if ( ! $include_transparent ) { + $alpha = ( $rgb >> 24 ) & 0x7F; + if ( $alpha > 0 ) { + continue; + } + } + } else { + $index = $rgb; + if ( ! isset( $palette[ $index ] ) ) { + continue; + } + $rgba = $palette[ $index ]; + $r = $rgba['red']; + $g = $rgba['green']; + $b = $rgba['blue']; + if ( ! $include_transparent && $rgba['alpha'] > 0 ) { + continue; + } + } + + $sum_r += $srgb_to_linear[ $r ]; + $sum_g += $srgb_to_linear[ $g ]; + $sum_b += $srgb_to_linear[ $b ]; + ++$count; + } + } + + if ( 0 === $count && ! $include_transparent ) { + $include_transparent = true; + $loop_again = true; + } + } while ( $loop_again ); + + if ( 0 === $count ) { return new WP_Error( 'image_editor_dominant_color_error', __( 'Dominant color detection failed.', 'dominant-color-images' ) ); } - return $hex; + return array( + 'r' => $linear_to_srgb( $sum_r / $count ), + 'g' => $linear_to_srgb( $sum_g / $count ), + 'b' => $linear_to_srgb( $sum_b / $count ), + ); } /** @@ -99,4 +201,86 @@ public function has_transparency() { } return false; } + + /** + * Get the 3×2 grid pixel values from the image. + * + * The grid is a 3-column × 2-row sampling of the image, resized to + * exactly 6 pixels. Each cell's raw RGB values are returned for use + * in LQIP generation. + * + * @since n.e.x.t + * + * @return array|WP_Error 6 grid cells as ['r'=>R, 'g'=>G, 'b'=>B], or WP_Error on failure. + */ + public function get_lqip_grid_values() { + + // Skip LQIP generation for images with transparency (the gradient + // placeholder would show through transparent areas). + $has_transparency = $this->has_transparency(); + if ( is_wp_error( $has_transparency ) || $has_transparency ) { + return new WP_Error( 'image_editor_lqip_grid_error', __( 'LQIP grid values detection failed.', 'dominant-color-images' ) ); + } + + $small = imagecreatetruecolor( 3, 2 ); + if ( false === $small ) { + return new WP_Error( 'image_editor_lqip_grid_error', __( 'LQIP grid values detection failed.', 'dominant-color-images' ) ); + } + + // Fill with fully transparent white so imagecopyresampled can blend + // against it, then we will flatten below. + imagesavealpha( $small, true ); + $transparent = imagecolorallocatealpha( $small, 255, 255, 255, 127 ); + if ( false !== $transparent ) { + imagefill( $small, 0, 0, $transparent ); + } + + $image_width = (int) imagesx( $this->image ); // @phpstan-ignore cast.useless + $image_height = (int) imagesy( $this->image ); // @phpstan-ignore cast.useless + imagecopyresampled( $small, $this->image, 0, 0, 0, 0, 3, 2, $image_width, $image_height ); + + // Flatten any residual alpha against white background so the + // returned RGB values are always opaque. + $flat = imagecreatetruecolor( 3, 2 ); + if ( false !== $flat ) { + $white = imagecolorallocate( $flat, 255, 255, 255 ); + if ( false !== $white ) { + imagefill( $flat, 0, 0, $white ); + } + imagealphablending( $flat, true ); + imagesavealpha( $flat, false ); + imagecopy( $flat, $small, 0, 0, 0, 0, 3, 2 ); + $small = $flat; + } + + // Cell positions: left-to-right, top-to-bottom. + $cell_positions = array( + array( 0, 0 ), + array( 1, 0 ), + array( 2, 0 ), + array( 0, 1 ), + array( 1, 1 ), + array( 2, 1 ), + ); + + $values = array(); + + foreach ( $cell_positions as $pos ) { + $rgb = imagecolorat( $small, $pos[0], $pos[1] ); + if ( false === $rgb ) { + continue; + } + $values[] = array( + 'r' => ( $rgb >> 16 ) & 0xFF, + 'g' => ( $rgb >> 8 ) & 0xFF, + 'b' => $rgb & 0xFF, + ); + } + + if ( count( $values ) < 6 ) { + return new WP_Error( 'image_editor_lqip_grid_error', __( 'LQIP grid values detection failed.', 'dominant-color-images' ) ); + } + + return $values; + } } diff --git a/plugins/dominant-color-images/class-dominant-color-image-editor-imagick.php b/plugins/dominant-color-images/class-dominant-color-image-editor-imagick.php index f42c957142..1fb4c63bb7 100644 --- a/plugins/dominant-color-images/class-dominant-color-image-editor-imagick.php +++ b/plugins/dominant-color-images/class-dominant-color-image-editor-imagick.php @@ -21,11 +21,11 @@ class Dominant_Color_Image_Editor_Imagick extends WP_Image_Editor_Imagick { /** - * Get dominant color from a file. + * Get dominant color from a file as raw RGB values. * * @since 1.0.0 * - * @return string|WP_Error Dominant hex color string, or an error on failure. + * @return array{r: int, g: int, b: int}|WP_Error RGB values (0-255), or WP_Error on failure. */ public function get_dominant_color() { @@ -34,16 +34,22 @@ public function get_dominant_color() { } try { - // The logic here is resize the image to 1x1 pixel, then get the color of that pixel. - $this->image->resizeImage( 1, 1, Imagick::FILTER_LANCZOS, 1 ); - $pixel = $this->image->getImagePixelColor( 0, 0 ); + // Clone so $this->image is not mutated — otherwise subsequent + // calls (e.g. get_lqip_grid_values) would operate on a 1×1 image. + $thumb = clone $this->image; + + // Convert to linear RGB before resizing to avoid gamma-skewed averaging. + $thumb->transformImageColorspace( Imagick::COLORSPACE_RGB ); + $thumb->resizeImage( 1, 1, Imagick::FILTER_LANCZOS, 1 ); + $thumb->transformImageColorspace( Imagick::COLORSPACE_SRGB ); + $pixel = $thumb->getImagePixelColor( 0, 0 ); $color = $pixel->getColor(); - $hex = dominant_color_rgb_to_hex( $color['r'], $color['g'], $color['b'] ); - if ( null === $hex ) { - return new WP_Error( 'image_editor_dominant_color_error', __( 'Dominant color detection failed.', 'dominant-color-images' ) ); - } - return $hex; + return array( + 'r' => $color['r'], + 'g' => $color['g'], + 'b' => $color['b'], + ); } catch ( Exception $e ) { /* translators: %s is the error message. */ return new WP_Error( 'image_editor_dominant_color_error', sprintf( __( 'Dominant color detection failed: %s', 'dominant-color-images' ), $e->getMessage() ) ); @@ -96,4 +102,66 @@ public function has_transparency() { return new WP_Error( 'image_editor_has_transparency_error', sprintf( __( 'Transparency detection failed: %s', 'dominant-color-images' ), $e->getMessage() ) ); } } + + /** + * Get the 3×2 grid pixel values from the image. + * + * The grid is a 3-column × 2-row sampling of the image, resized to + * exactly 6 pixels. Each cell's raw RGB values are returned for use + * in LQIP generation. + * + * @since n.e.x.t + * + * @return array|WP_Error 6 grid cells as ['r'=>R, 'g'=>G, 'b'=>B], or WP_Error on failure. + */ + public function get_lqip_grid_values() { + + // Skip LQIP generation for images with transparency (the gradient + // placeholder would show through transparent areas). + $has_transparency = $this->has_transparency(); + if ( is_wp_error( $has_transparency ) || $has_transparency ) { + return new WP_Error( 'image_editor_lqip_grid_error', __( 'LQIP grid values detection failed.', 'dominant-color-images' ) ); + } + + try { + $small = clone $this->image; + + // Resize to 3×2. + $small->resizeImage( 3, 2, Imagick::FILTER_LANCZOS, 1 ); + $small->sharpenImage( 0, 0.5 ); + + // Flatten if the image has an alpha channel. + if ( is_callable( array( $small, 'getImageAlphaChannel' ) ) && $small->getImageAlphaChannel() ) { + $small->setImageBackgroundColor( 'white' ); + $small = $small->mergeImageLayers( Imagick::LAYERMETHOD_FLATTEN ); + } + + // Cell positions: left-to-right, top-to-bottom. + $cell_positions = array( + array( 0, 0 ), + array( 1, 0 ), + array( 2, 0 ), + array( 0, 1 ), + array( 1, 1 ), + array( 2, 1 ), + ); + + $values = array(); + + foreach ( $cell_positions as $pos ) { + $pixel = $small->getImagePixelColor( $pos[0], $pos[1] ); + $color = $pixel->getColor(); + $values[] = array( + 'r' => $color['r'], + 'g' => $color['g'], + 'b' => $color['b'], + ); + } + + return $values; + + } catch ( Exception $e ) { + return new WP_Error( 'image_editor_lqip_grid_error', __( 'LQIP grid values detection failed.', 'dominant-color-images' ) ); + } + } } diff --git a/plugins/dominant-color-images/helper.php b/plugins/dominant-color-images/helper.php index 4cd6254d95..991eec0736 100644 --- a/plugins/dominant-color-images/helper.php +++ b/plugins/dominant-color-images/helper.php @@ -61,7 +61,7 @@ function dominant_color_set_image_editors( array $editors ): array { * @access private * * @param int $attachment_id The attachment ID. - * @return array{ has_transparency?: bool, dominant_color?: string }|WP_Error Array with the dominant color and has transparency values or WP_Error on error. + * @return array{ has_transparency?: bool, dominant_color?: string, lqip?: int }|WP_Error Array with the dominant color, has transparency, and optional LQIP values or WP_Error on error. */ function dominant_color_get_dominant_color_data( int $attachment_id ) { $mime_type = get_post_mime_type( $attachment_id ); @@ -90,13 +90,9 @@ function dominant_color_get_dominant_color_data( int $attachment_id ) { } $file = dominant_color_get_attachment_file_path( $attachment_id ); - if ( false === $file ) { - $file = get_attached_file( $attachment_id ); - } if ( false === $file ) { return new WP_Error( 'no_image_found', __( 'Unable to load image.', 'dominant-color-images' ) ); } - add_filter( 'wp_image_editors', 'dominant_color_set_image_editors' ); /** * Editor. @@ -113,7 +109,6 @@ function dominant_color_get_dominant_color_data( int $attachment_id ) { ), ) ); - remove_filter( 'wp_image_editors', 'dominant_color_set_image_editors' ); if ( is_wp_error( $editor ) ) { return $editor; @@ -135,11 +130,53 @@ function dominant_color_get_dominant_color_data( int $attachment_id ) { if ( is_wp_error( $dominant_color ) ) { return $dominant_color; } - $dominant_color_data['dominant_color'] = $dominant_color; + + $hex = dominant_color_rgb_to_hex( $dominant_color['r'], $dominant_color['g'], $dominant_color['b'] ); + if ( null === $hex ) { + return new WP_Error( + 'image_editor_dominant_color_error', + __( 'Dominant color detection failed.', 'dominant-color-images' ) + ); + } + $dominant_color_data['dominant_color'] = $hex; + + // LQIP generation. + require_once __DIR__ . '/lqip-generator.php'; + + $grid = $editor->get_lqip_grid_values(); + if ( ! is_wp_error( $grid ) ) { + try { + $dominant_color_data['lqip'] = dominant_color_lqip_generate( $dominant_color, $grid ); + } catch ( \UnexpectedValueException $e ) { + wp_trigger_error( 'dominant_color_lqip_generate', $e->getMessage() ); + } + } return $dominant_color_data; } +/** + * Retrieves attachment metadata for an image attachment. + * + * @since n.e.x.t + * + * @param int $attachment_id Attachment ID. + * @return array|null Attachment metadata array, or null if not an image or metadata unavailable. + */ +function dominant_color_get_attachment_metadata( int $attachment_id ): ?array { + + if ( ! wp_attachment_is_image( $attachment_id ) ) { + return null; + } + + $image_meta = wp_get_attachment_metadata( $attachment_id ); + if ( ! is_array( $image_meta ) ) { + return null; + } + + return $image_meta; +} + /** * Gets file path of image based on size. * @@ -150,21 +187,17 @@ function dominant_color_get_dominant_color_data( int $attachment_id ) { * @return false|string Path to an image or false if not found. */ function dominant_color_get_attachment_file_path( int $attachment_id, string $size = 'medium' ) { - $imagedata = wp_get_attachment_metadata( $attachment_id ); - if ( ! is_array( $imagedata ) ) { - return false; - } - if ( ! isset( $imagedata['sizes'][ $size ] ) ) { + $filepath = get_attached_file( $attachment_id ); + if ( false === $filepath ) { return false; } - $file = get_attached_file( $attachment_id ); - if ( false === $file ) { - return false; - } + $image_meta = dominant_color_get_attachment_metadata( $attachment_id ); - $filepath = str_replace( wp_basename( $file ), $imagedata['sizes'][ $size ]['file'], $file ); + if ( isset( $image_meta['sizes'][ $size ] ) ) { + $filepath = str_replace( wp_basename( $filepath ), $image_meta['sizes'][ $size ]['file'], $filepath ); + } return $filepath; } @@ -178,13 +211,7 @@ function dominant_color_get_attachment_file_path( int $attachment_id, string $si * @return string|null Hex value of dominant color or null if not set. */ function dominant_color_get_dominant_color( int $attachment_id ): ?string { - if ( ! wp_attachment_is_image( $attachment_id ) ) { - return null; - } - $image_meta = wp_get_attachment_metadata( $attachment_id ); - if ( ! is_array( $image_meta ) ) { - return null; - } + $image_meta = dominant_color_get_attachment_metadata( $attachment_id ); if ( ! isset( $image_meta['dominant_color'] ) ) { return null; @@ -202,10 +229,7 @@ function dominant_color_get_dominant_color( int $attachment_id ): ?string { * @return bool|null Whether the image has transparency, or null if not set. */ function dominant_color_has_transparency( int $attachment_id ): ?bool { - $image_meta = wp_get_attachment_metadata( $attachment_id ); - if ( ! is_array( $image_meta ) ) { - return null; - } + $image_meta = dominant_color_get_attachment_metadata( $attachment_id ); if ( ! isset( $image_meta['has_transparency'] ) ) { return null; @@ -214,7 +238,6 @@ function dominant_color_has_transparency( int $attachment_id ): ?bool { return $image_meta['has_transparency']; } - /** * Gets hex color from RGB. * diff --git a/plugins/dominant-color-images/hooks.php b/plugins/dominant-color-images/hooks.php index 40e7b98f31..409f0e84c7 100644 --- a/plugins/dominant-color-images/hooks.php +++ b/plugins/dominant-color-images/hooks.php @@ -38,6 +38,10 @@ function dominant_color_metadata( $metadata, int $attachment_id ): array { if ( isset( $dominant_color_data['has_transparency'] ) ) { $metadata['has_transparency'] = $dominant_color_data['has_transparency']; } + + if ( isset( $dominant_color_data['lqip'] ) ) { + $metadata['lqip'] = $dominant_color_data['lqip']; + } } return $metadata; @@ -76,9 +80,16 @@ function dominant_color_update_attachment_image_attributes( $attr, WP_Post $atta } if ( isset( $image_meta['dominant_color'] ) && is_string( $image_meta['dominant_color'] ) && '' !== $image_meta['dominant_color'] ) { - $attr['data-dominant-color'] = esc_attr( $image_meta['dominant_color'] ); + $attr['data-dominant-color'] = $image_meta['dominant_color']; $style_attribute = isset( $attr['style'] ) && is_string( $attr['style'] ) ? $attr['style'] : ''; - $attr['style'] = '--dominant-color: #' . esc_attr( $image_meta['dominant_color'] ) . ';' . $style_attribute; + $style_attribute = '--dominant-color: #' . $image_meta['dominant_color'] . ';' . $style_attribute; + + // Append the LQIP custom property if available. + if ( isset( $image_meta['lqip'] ) && is_int( $image_meta['lqip'] ) ) { + $style_attribute = '--lqip:' . $image_meta['lqip'] . ';' . $style_attribute; + } + + $attr['style'] = $style_attribute; } return $attr; @@ -147,6 +158,12 @@ function dominant_color_img_tag_add_dominant_color( $filtered_image, string $con $processor->set_attribute( 'data-dominant-color', $image_meta['dominant_color'] ); $style_attribute = '--dominant-color: #' . $image_meta['dominant_color'] . '; '; + + // Append the LQIP custom property if available. + if ( isset( $image_meta['lqip'] ) && is_int( $image_meta['lqip'] ) ) { + $style_attribute = '--lqip:' . $image_meta['lqip'] . '; ' . $style_attribute; + } + if ( null !== $processor->get_attribute( 'style' ) ) { $style_attribute .= $processor->get_attribute( 'style' ); } @@ -179,6 +196,32 @@ function dominant_color_add_inline_style(): void { } add_action( 'wp_enqueue_scripts', 'dominant_color_add_inline_style' ); +/** + * Enqueue the LQIP CSS stylesheet on the frontend. + * + * When the DOMINANT_COLOR_LQIP_HOVER constant is defined and truthy, an + * additional inline rule hides the image content on hover (via + * object-position) so the gradient placeholder peeks through. This is + * intended for development and QA purposes — it allows verifying that + * the correct LQIP gradient is rendered behind each image without + * inspecting the DOM. + * + * @since n.e.x.t + */ +function dominant_color_enqueue_lqip_css(): void { + $css_path = __DIR__ . '/assets/lqip.css'; + + if ( ! file_exists( $css_path ) ) { + return; + } + + $suffix = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min'; + $css_url = plugin_dir_url( __FILE__ ) . 'assets/lqip' . $suffix . '.css'; + + wp_enqueue_style( 'dominant-color-lqip', $css_url, array(), 'n.e.x.t' ); +} +add_action( 'wp_enqueue_scripts', 'dominant_color_enqueue_lqip_css' ); + /** * Displays the HTML generator tag for the Image Placeholders plugin. * @@ -231,7 +274,8 @@ function dominant_color_admin_script(): void { replaced += ' data-dominant-color="{{ data.dominantColor }}"'; replaced += ' data-has-transparency="{{ data.hasTransparency }}"'; let hasStyleAttr = false; - const colorStyle = "{{ data.dominantColor ? '--dominant-color: #' + data.dominantColor + ';' : '' }}"; + const lqipStyle = "{{ data.lqip ? '--lqip:' + data.lqip + '; ' : '' }}"; + const colorStyle = lqipStyle + "{{ data.dominantColor ? '--dominant-color: #' + data.dominantColor + ';' : '' }}"; replaced = replaced.replace( /\sstyle="/, ( styleMatch ) => { hasStyleAttr = true; return styleMatch + colorStyle; @@ -282,6 +326,11 @@ function dominant_color_prepare_attachment_for_js( $response, WP_Post $attachmen $response['hasTransparency'] = (bool) $meta['has_transparency']; } + $response['lqip'] = 0; + if ( isset( $meta['lqip'] ) && is_int( $meta['lqip'] ) ) { + $response['lqip'] = $meta['lqip']; + } + return $response; } add_filter( 'wp_prepare_attachment_for_js', 'dominant_color_prepare_attachment_for_js', 10, 3 ); diff --git a/plugins/dominant-color-images/load.php b/plugins/dominant-color-images/load.php index 5640401957..35095bd85b 100644 --- a/plugins/dominant-color-images/load.php +++ b/plugins/dominant-color-images/load.php @@ -32,4 +32,6 @@ require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; + +add_filter( 'wp_image_editors', 'dominant_color_set_image_editors', 999, 1 ); // @codeCoverageIgnoreEnd diff --git a/plugins/dominant-color-images/lqip-generator.php b/plugins/dominant-color-images/lqip-generator.php new file mode 100644 index 0000000000..0e3b363247 --- /dev/null +++ b/plugins/dominant-color-images/lqip-generator.php @@ -0,0 +1,196 @@ + R, 'g' => G, 'b' => B] (0-255). + * @param array $grid_rgb 6 grid cell colours as ['r'=>R, 'g'=>G, 'b'=>B]. + * + * @return int LQIP integer in the range [-524288, 524287]. + */ +function dominant_color_lqip_generate( array $rgb, array $grid_rgb ): int { + $oklab = dominant_color_lqip_rgb_to_oklab( $rgb['r'], $rgb['g'], $rgb['b'] ); + $base_lab = array( + 'L' => $oklab['L'], + 'a' => $oklab['a'], + 'b' => $oklab['b'], + ); + $bits = dominant_color_lqip_find_oklab_bits( $base_lab['L'], $base_lab['a'], $base_lab['b'] ); + $ll = $bits['ll']; + $aaa = $bits['aaa']; + $bbb = $bits['bbb']; + + $base = dominant_color_lqip_bits_to_lab( $ll, $aaa, $bbb ); + + // Compute relative lightness for each grid cell. + $values = array(); + foreach ( $grid_rgb as $cell_rgb ) { + $cell_lab = dominant_color_lqip_rgb_to_oklab( $cell_rgb['r'], $cell_rgb['g'], $cell_rgb['b'] ); + $values[] = max( 0.0, min( 1.0, 0.5 + $cell_lab['L'] - $base['L'] ) ); + } + + // Pack 6 × 2-bit cell values + 8-bit base colour. + $ca = (int) round( $values[0] * 3 ); + $cb = (int) round( $values[1] * 3 ); + $cc = (int) round( $values[2] * 3 ); + $cd = (int) round( $values[3] * 3 ); + $ce = (int) round( $values[4] * 3 ); + $cf = (int) round( $values[5] * 3 ); + + $lqip = + -( 2 ** 19 ) + + ( ( $ca & 3 ) << 18 ) + + ( ( $cb & 3 ) << 16 ) + + ( ( $cc & 3 ) << 14 ) + + ( ( $cd & 3 ) << 12 ) + + ( ( $ce & 3 ) << 10 ) + + ( ( $cf & 3 ) << 8 ) + + ( ( $ll & 3 ) << 6 ) + + ( ( $aaa & 7 ) << 3 ) + + ( $bbb & 7 ); + + return $lqip; +} + +/** + * Convert an sRGB pixel [0-255] to OKLab. + * + * Applies proper sRGB gamma linearisation before the colour-space + * conversion. + * + * @param int $r Red channel [0-255]. + * @param int $g Green channel [0-255]. + * @param int $b Blue channel [0-255]. + * @return array{L: float, a: float, b: float} + */ +function dominant_color_lqip_rgb_to_oklab( int $r, int $g, int $b ): array { + $r_norm = $r / 255.0; + $g_norm = $g / 255.0; + $b_norm = $b / 255.0; + + // sRGB gamma decoding (linearise): IEC 61966-2-1. + $r_lin = $r_norm >= 0.04045 + ? ( ( $r_norm + 0.055 ) / 1.055 ) ** 2.4 + : $r_norm / 12.92; + $g_lin = $g_norm >= 0.04045 + ? ( ( $g_norm + 0.055 ) / 1.055 ) ** 2.4 + : $g_norm / 12.92; + $b_lin = $b_norm >= 0.04045 + ? ( ( $b_norm + 0.055 ) / 1.055 ) ** 2.4 + : $b_norm / 12.92; + + // Linear RGB → LMS (cube-root in one step). + $l = ( 0.4122214708 * $r_lin + 0.5363325363 * $g_lin + 0.0514459929 * $b_lin ) ** ( 1.0 / 3.0 ); + $m = ( 0.2119034982 * $r_lin + 0.6806995451 * $g_lin + 0.1073969566 * $b_lin ) ** ( 1.0 / 3.0 ); + $s = ( 0.0883024619 * $r_lin + 0.2817188376 * $g_lin + 0.6299787005 * $b_lin ) ** ( 1.0 / 3.0 ); + + return array( + 'L' => $l * 0.2104542553 + $m * 0.793617785 + $s * ( -0.0040720468 ), + 'a' => $l * 1.9779984951 + $m * ( -2.428592205 ) + $s * 0.4505937099, + 'b' => $l * 0.0259040371 + $m * 0.7827717662 + $s * ( -0.808675766 ), + ); +} + +/** + * Decode packed bits back to OKLab values. + * + * @param int $ll 2-bit lightness index [0-3]. + * @param int $aaa 3-bit a-axis index [0-7]. + * @param int $bbb 3-bit b-axis index [0-7]. + * @return array{L: float, a: float, b: float} + */ +function dominant_color_lqip_bits_to_lab( int $ll, int $aaa, int $bbb ): array { + return array( + 'L' => ( $ll / 3 ) * 0.6 + 0.2, + 'a' => ( $aaa / 8 ) * 0.7 - 0.35, + 'b' => ( ( $bbb + 1 ) / 8 ) * 0.7 - 0.35, + ); +} + +/** + * Scale a chroma component for perceptual difference calculation. + * + * @param float $x The component value. + * @param float $chroma The chroma (sqrt(a²+b²)) of the colour. + */ +function dominant_color_lqip_scale_component_for_diff( float $x, float $chroma ): float { + return $x / ( 1e-6 + $chroma ** 0.5 ); +} + +/** + * Brute-force search for the 8-bit (2+3+3) combination that best + * approximates the given OKLab target colour. + * + * @param float $target_l Target L channel. + * @param float $target_a Target a channel. + * @param float $target_b Target b channel. + * @return array{ll: int, aaa: int, bbb: int} + */ +function dominant_color_lqip_find_oklab_bits( float $target_l, float $target_a, float $target_b ): array { + $target_chroma = hypot( $target_a, $target_b ); + $scaled_target_a = dominant_color_lqip_scale_component_for_diff( $target_a, $target_chroma ); + $scaled_target_b = dominant_color_lqip_scale_component_for_diff( $target_b, $target_chroma ); + + $best_bits = array( 0, 0, 0 ); + $best_diff = INF; + + for ( $ll = 0; $ll <= 3; $ll++ ) { + for ( $aaa = 0; $aaa <= 7; $aaa++ ) { + for ( $bbb = 0; $bbb <= 7; $bbb++ ) { + $lab = dominant_color_lqip_bits_to_lab( $ll, $aaa, $bbb ); + $chroma = hypot( $lab['a'], $lab['b'] ); + + $gray_penalty = ( 4 === $aaa && 3 === $bbb ) ? 0.04 : 0.0; + + $scaled_a = dominant_color_lqip_scale_component_for_diff( $lab['a'], $chroma ); + $scaled_b = dominant_color_lqip_scale_component_for_diff( $lab['b'], $chroma ); + + $diff = $gray_penalty + sqrt( + ( $lab['L'] - $target_l ) ** 2 + + ( $scaled_a - $scaled_target_a ) ** 2 + + ( $scaled_b - $scaled_target_b ) ** 2 + ); + + if ( $diff < $best_diff ) { + $best_diff = $diff; + $best_bits = array( $ll, $aaa, $bbb ); + } + } + } + } + + return array( + 'll' => $best_bits[0], + 'aaa' => $best_bits[1], + 'bbb' => $best_bits[2], + ); +} diff --git a/plugins/dominant-color-images/tests/data/class-testcase.php b/plugins/dominant-color-images/tests/data/class-testcase.php index 2925664276..dc789d678c 100644 --- a/plugins/dominant-color-images/tests/data/class-testcase.php +++ b/plugins/dominant-color-images/tests/data/class-testcase.php @@ -37,98 +37,122 @@ private function reset_wp_dependencies(): void { */ public function provider_get_dominant_color(): array { return array( - 'animated_gif' => array( + 'animated_gif' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/animated.gif', - 'expected_color' => array( '874e4e', '864e4e', 'df7f7f' ), + 'expected_color' => array( 'e3a3a3', 'e3a5a5' ), 'expected_transparency' => true, + 'expected_lqip' => null, ), - 'red_jpg' => array( + 'red_jpg' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/red.jpg', 'expected_color' => array( 'ff0000', 'fe0000' ), 'expected_transparency' => false, + 'expected_lqip' => 174772, ), - 'green_jpg' => array( + 'green_jpg' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/green.jpg', 'expected_color' => array( '00ff00', '00ff01', '02ff01' ), 'expected_transparency' => false, + 'expected_lqip' => 174797, ), - 'white_jpg' => array( + 'white_jpg' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/white.jpg', 'expected_color' => array( 'ffffff' ), 'expected_transparency' => false, + 'expected_lqip' => 174819, ), - 'red_gif' => array( + 'red_gif' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/red.gif', 'expected_color' => array( 'ff0000' ), 'expected_transparency' => false, + 'expected_lqip' => 174781, ), - 'green_gif' => array( + 'green_gif' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/green.gif', 'expected_color' => array( '00ff00' ), 'expected_transparency' => false, + 'expected_lqip' => 174797, ), - 'white_gif' => array( + 'white_gif' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/white.gif', 'expected_color' => array( 'ffffff' ), 'expected_transparency' => false, + 'expected_lqip' => 174819, ), - 'trans_gif' => array( + 'trans_gif' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/trans.gif', - 'expected_color' => array( '5a5a5a', '020202' ), + 'expected_color' => array( '5a5a5a', '828282' ), 'expected_transparency' => true, + 'expected_lqip' => null, ), - 'red_png' => array( + 'red_png' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/red.png', 'expected_color' => array( 'ff0000' ), 'expected_transparency' => false, + 'expected_lqip' => 174781, ), - 'green_png' => array( + 'green_png' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/green.png', 'expected_color' => array( '00ff00' ), 'expected_transparency' => false, + 'expected_lqip' => 174797, ), - 'white_png' => array( + 'white_png' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/white.png', 'expected_color' => array( 'ffffff' ), 'expected_transparency' => false, + 'expected_lqip' => 174819, ), - 'trans_png' => array( + 'trans_png' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/trans.png', - 'expected_color' => array( '000000' ), + 'expected_color' => array( '000000', 'f7f7f7' ), 'expected_transparency' => true, + 'expected_lqip' => null, ), - 'red_webp' => array( + 'red_webp' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/red.webp', 'expected_color' => array( 'ff0000' ), 'expected_transparency' => false, + 'expected_lqip' => 174781, ), - 'green_webp' => array( + 'green_webp' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/green.webp', 'expected_color' => array( '00ff00' ), 'expected_transparency' => false, + 'expected_lqip' => 174797, ), - 'white_webp' => array( + 'white_webp' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/white.webp', 'expected_color' => array( 'ffffff' ), 'expected_transparency' => false, + 'expected_lqip' => 174819, ), - 'trans_webp' => array( + 'trans_webp' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/trans.webp', 'expected_color' => array( '000000' ), 'expected_transparency' => true, + 'expected_lqip' => null, ), - 'balloons_webp' => array( + 'balloons_webp' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/balloons.webp', - 'expected_color' => array( 'c1bbb9', 'c0bbb9', 'c0bab8', 'c3bdbd', 'bfbab8', 'c2bdbc' ), + 'expected_color' => array( 'c9c4c6', 'cac5c8' ), 'expected_transparency' => false, + 'expected_lqip' => 169443, ), - 'half_opaque' => array( + 'half_opaque' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/half-opaque.png', - 'expected_color' => array( '7e7e7e', 'ffffff' ), + 'expected_color' => array( '7e7e7e', 'ffffff', '000000' ), 'expected_transparency' => true, + 'expected_lqip' => null, + ), + 'checkerboard_bw_2x2' => array( + 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/checkerboard-bw-2x2.png', + 'expected_color' => array( 'bbbbbb', 'bcbcbc' ), + 'expected_transparency' => false, + 'expected_lqip' => 67299, ), ); } @@ -157,10 +181,10 @@ public function provider_get_dominant_color_invalid_images(): array { public function provider_get_dominant_color_none_images(): array { return array( 'pdf' => array( - 'files_path' => TESTS_PLUGIN_DIR . '/tests/data/images/wordpress-gsoc-flyer.pdf', + 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/wordpress-gsoc-flyer.pdf', ), 'mp4' => array( - 'files_path' => TESTS_PLUGIN_DIR . '/tests/data/images/small-video.mp4', + 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/small-video.mp4', ), ); } @@ -175,20 +199,26 @@ public function provider_get_dominant_color_none_images(): array { * * @phpstan-param string[] $expected_color */ - public function test_get_dominant_color_valid( string $image_path, array $expected_color, bool $expected_transparency ): void { + public function test_get_dominant_color_valid( string $image_path, array $expected_color, bool $expected_transparency, ?int $expected_lqip ): void { $mime_type = wp_check_filetype( $image_path )['type']; if ( ! wp_image_editor_supports( array( 'mime_type' => $mime_type ) ) ) { $this->markTestSkipped( "Mime type $mime_type is not supported." ); } $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); $dominant_color_data = dominant_color_get_dominant_color_data( $attachment_id ); $this->assertNotWPError( $dominant_color_data ); $this->assertContains( $dominant_color_data['dominant_color'], $expected_color ); $this->assertSame( $dominant_color_data['has_transparency'], $expected_transparency ); + + if ( null === $expected_lqip ) { + $this->assertArrayNotHasKey( 'lqip', $dominant_color_data ); + } else { + $this->assertArrayHasKey( 'lqip', $dominant_color_data ); + $this->assertSame( $expected_lqip, $dominant_color_data['lqip'] ); + } } /** @@ -208,35 +238,41 @@ public function test_get_dominant_color_invalid( string $image_path ): void { if ( ! wp_image_editor_supports( array( 'mime_type' => $mime_type ) ) ) { $this->markTestSkipped( "Mime type $mime_type is not supported." ); } - $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); + $attachment_id = self::factory()->attachment->create( + array( + 'post_mime_type' => $mime_type, + ) + ); $dominant_color_data = dominant_color_get_dominant_color_data( $attachment_id ); $this->assertWPError( $dominant_color_data ); - $this->assertStringContainsString( 'unsupported_attachment_type', $dominant_color_data->get_error_code() ); + $this->assertSame( 'unsupported_attachment_type', $dominant_color_data->get_error_code() ); } /** - * Test the function returns a WP_Error object for unsupported mime types. + * Tests dominant_color_get_dominant_color_data() returns a WP_Error when the + * dominant_color_supported_mime_types filter returns an empty array. * * @covers dominant_color_get_dominant_color_data */ public function test_get_dominant_color_data_unsupported_mime_type(): void { add_filter( 'dominant_color_supported_mime_types', '__return_empty_array' ); - $image_path = TESTS_PLUGIN_DIR . '/tests/data/images/red.jpg'; - $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); + $attachment_id = self::factory()->attachment->create( + array( + 'post_mime_type' => 'image/jpeg', + ) + ); $dominant_color_data = dominant_color_get_dominant_color_data( $attachment_id ); $this->assertWPError( $dominant_color_data ); - $this->assertStringContainsString( 'unsupported_attachment_type', $dominant_color_data->get_error_code() ); + $this->assertSame( 'unsupported_attachment_type', $dominant_color_data->get_error_code() ); } /** - * Test if the function returns the correct color. + * Tests dominant_color_get_dominant_color_data() returns a WP_Error for non-image file types. * * @covers Dominant_Color_Image_Editor_GD::get_dominant_color * @covers Dominant_Color_Image_Editor_Imagick::get_dominant_color @@ -244,11 +280,20 @@ public function test_get_dominant_color_data_unsupported_mime_type(): void { * @dataProvider provider_get_dominant_color_none_images */ public function test_get_dominant_color_none_images( string $image_path ): void { - $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); + $mime_type = wp_check_filetype( $image_path )['type']; + if ( false === $mime_type ) { + $this->markTestSkipped( 'Mime type is not supported.' ); + } + + $attachment_id = self::factory()->attachment->create( + array( + 'post_mime_type' => $mime_type, + ) + ); $dominant_color_data = dominant_color_get_dominant_color_data( $attachment_id ); $this->assertWPError( $dominant_color_data ); + $this->assertSame( 'unsupported_attachment_type', $dominant_color_data->get_error_code() ); } } diff --git a/plugins/dominant-color-images/tests/data/images/checkerboard-bw-2x2.png b/plugins/dominant-color-images/tests/data/images/checkerboard-bw-2x2.png new file mode 100644 index 0000000000..28e003ec92 Binary files /dev/null and b/plugins/dominant-color-images/tests/data/images/checkerboard-bw-2x2.png differ diff --git a/plugins/dominant-color-images/tests/test-dominant-color-image-editor-gd.php b/plugins/dominant-color-images/tests/test-dominant-color-image-editor-gd.php index cf67901541..b719f1f5b3 100644 --- a/plugins/dominant-color-images/tests/test-dominant-color-image-editor-gd.php +++ b/plugins/dominant-color-images/tests/test-dominant-color-image-editor-gd.php @@ -3,10 +3,14 @@ * Tests for Image Placeholders plugin. * * @package dominant-color-images + * @noinspection PhpComposerExtensionStubsInspection */ use Dominant_Color_Images\Tests\TestCase; +/** + * @coversDefaultClass Dominant_Color_Image_Editor_GD + */ class Test_Dominant_Color_Image_Editor_GD extends TestCase { /** @@ -19,6 +23,11 @@ public function set_up(): void { $this->markTestSkipped( 'The GD PHP extension is not loaded.' ); } + // ensure the GD editor is registered. Doesnt seem to be by the time this runs. + require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; + require_once ABSPATH . WPINC . '/class-wp-image-editor-gd.php'; + require_once __DIR__ . '/../class-dominant-color-image-editor-gd.php'; + add_filter( 'wp_image_editors', static function ( array $editors ): array { @@ -31,4 +40,141 @@ static function ( $editor ): bool { } ); } + + /** + * @covers ::get_dominant_color + */ + public function test_invalid_image_type(): void { + $editor = new Dominant_Color_Image_Editor_GD( '/invalid/type' ); + $result = $editor->get_dominant_color(); + $this->assertWPError( $result ); + $this->assertSame( 'image_editor_dominant_color_error_no_image', $result->get_error_code() ); + } + + /** + * @covers ::get_dominant_color + */ + public function test_get_dominant_color_no_image(): void { + $editor = new Dominant_Color_Image_Editor_GD( null ); + $result = $editor->get_dominant_color(); + + $this->assertWPError( $result ); + $this->assertSame( 'image_editor_dominant_color_error_no_image', $result->get_error_code() ); + } + + /** + * @covers ::has_transparency + */ + public function test_has_transparency_no_image(): void { + $editor = new Dominant_Color_Image_Editor_GD( null ); + $result = $editor->has_transparency(); + + $this->assertWPError( $result ); + $this->assertSame( 'image_editor_has_transparency_error_no_image', $result->get_error_code() ); + } + + /** + * @covers ::get_dominant_color + */ + public function test_get_dominant_color_success(): void { + $im = imagecreatetruecolor( 1, 1 ); + $red = imagecolorallocate( $im, 255, 0, 0 ); + imagefill( $im, 0, 0, $red ); + + $editor = new Dominant_Color_Image_Editor_GD( null ); + $reflection = new ReflectionClass( $editor ); + $property = $reflection->getProperty( 'image' ); + $property->setAccessible( true ); + $property->setValue( $editor, $im ); + + $result = $editor->get_dominant_color(); + + $this->assertIsArray( $result ); + $this->assertSame( + array( + 'r' => 255, + 'g' => 0, + 'b' => 0, + ), + $result + ); + } + + /** + * @covers ::get_dominant_color + */ + public function test_get_dominant_color_linear_light(): void { + /* + * A 2x2 black/white checkerboard has an equal split. + * Averaging in gamma-encoded sRGB gives a gray around 128. + * Averaging in linear light gives a much lighter gray around 188. + * Verify the editor uses linear-light averaging. + */ + $im = imagecreatetruecolor( 2, 2 ); + $white = imagecolorallocate( $im, 255, 255, 255 ); + $black = imagecolorallocate( $im, 0, 0, 0 ); + imagesetpixel( $im, 0, 0, $white ); + imagesetpixel( $im, 1, 0, $black ); + imagesetpixel( $im, 0, 1, $black ); + imagesetpixel( $im, 1, 1, $white ); + + $editor = new Dominant_Color_Image_Editor_GD( null ); + $reflection = new ReflectionClass( $editor ); + $property = $reflection->getProperty( 'image' ); + $property->setAccessible( true ); + $property->setValue( $editor, $im ); + + $result = $editor->get_dominant_color(); + + $this->assertIsArray( $result ); + // The result should be far from the gamma-space average (128) and + // close to the linear-light average (188). Allow ±1 for rounding. + $this->assertGreaterThan( 180, $result['r'] ); + $this->assertGreaterThan( 180, $result['g'] ); + $this->assertGreaterThan( 180, $result['b'] ); + $this->assertLessThan( 200, $result['r'] ); + $this->assertLessThan( 200, $result['g'] ); + $this->assertLessThan( 200, $result['b'] ); + // All channels should be equal for a neutral checkerboard. + $this->assertSame( $result['r'], $result['g'] ); + $this->assertSame( $result['g'], $result['b'] ); + } + + /** + * @covers ::has_transparency + */ + public function test_has_no_transparency(): void { + $im = imagecreatetruecolor( 1, 1 ); + $red = imagecolorallocate( $im, 255, 0, 0 ); + imagefill( $im, 0, 0, $red ); + + $editor = new Dominant_Color_Image_Editor_GD( null ); + $reflection = new ReflectionClass( $editor ); + $property = $reflection->getProperty( 'image' ); + $property->setAccessible( true ); + $property->setValue( $editor, $im ); + + $result = $editor->has_transparency(); + + $this->assertFalse( $result ); + } + + /** + * @covers ::has_transparency + */ + public function test_has_transparency_with_transparency(): void { + $im = imagecreatetruecolor( 1, 1 ); + $alpha_color = imagecolorallocatealpha( $im, 255, 0, 0, 64 ); + imagefill( $im, 0, 0, $alpha_color ); + + $editor = new Dominant_Color_Image_Editor_GD( null ); + $reflection = new ReflectionClass( $editor ); + $property = $reflection->getProperty( 'image' ); + $property->setAccessible( true ); + $property->setValue( $editor, $im ); + + $result = $editor->has_transparency(); + + $this->assertTrue( $result ); + } } diff --git a/plugins/dominant-color-images/tests/test-dominant-color-image-editor-imagick.php b/plugins/dominant-color-images/tests/test-dominant-color-image-editor-imagick.php index 5e30e89d51..81c96350e5 100644 --- a/plugins/dominant-color-images/tests/test-dominant-color-image-editor-imagick.php +++ b/plugins/dominant-color-images/tests/test-dominant-color-image-editor-imagick.php @@ -26,6 +26,11 @@ public function set_up(): void { $this->markTestSkipped( 'The Imagick PHP extension is not loaded.' ); } + // ensure the Imagick editor is registered, even though it seems to be by the time this runs. + require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; + require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php'; + require_once __DIR__ . '/../class-dominant-color-image-editor-imagick.php'; + add_filter( 'wp_image_editors', static function ( array $editors ): array { @@ -147,7 +152,56 @@ public function test_get_dominant_color_success(): void { $result = $editor->get_dominant_color(); - $this->assertEquals( 'ff0000', $result ); + $this->assertEquals( + array( + 'r' => 255, + 'g' => 0, + 'b' => 0, + ), + $result + ); + } + + /** + * @covers ::get_dominant_color + */ + public function test_get_dominant_color_linear_light(): void { + /* + * A 2x2 black/white checkerboard has an equal split. + * Averaging in gamma-encoded sRGB gives a mid-gray result. + * Averaging in linear light gives a much lighter result. + * Verify the editor uses linear-light averaging. + */ + $imagick = new Imagick(); + $imagick->newImage( 2, 2, new ImagickPixel( 'white' ) ); + + $draw = new ImagickDraw(); + $draw->setFillColor( new ImagickPixel( 'black' ) ); + $draw->point( 1, 0 ); + $draw->point( 0, 1 ); + // The top-left and bottom-right positions remain white. + $imagick->drawImage( $draw ); + + $editor = new Dominant_Color_Image_Editor_Imagick( null ); + $reflection = new ReflectionClass( $editor ); + $property = $reflection->getProperty( 'image' ); + $property->setAccessible( true ); + $property->setValue( $editor, $imagick ); + + $result = $editor->get_dominant_color(); + + $this->assertIsArray( $result ); + // The result should be far from the gamma-space average (128) and + // close to the linear-light average (188). Allow ±1 for rounding. + $this->assertGreaterThan( 180, $result['r'] ); + $this->assertGreaterThan( 180, $result['g'] ); + $this->assertGreaterThan( 180, $result['b'] ); + $this->assertLessThan( 200, $result['r'] ); + $this->assertLessThan( 200, $result['g'] ); + $this->assertLessThan( 200, $result['b'] ); + // All channels should be equal for a neutral checkerboard. + $this->assertSame( $result['r'], $result['g'] ); + $this->assertSame( $result['g'], $result['b'] ); } /** diff --git a/plugins/dominant-color-images/tests/test-dominant-color.php b/plugins/dominant-color-images/tests/test-dominant-color.php index 2603e9f356..c75d98df7d 100644 --- a/plugins/dominant-color-images/tests/test-dominant-color.php +++ b/plugins/dominant-color-images/tests/test-dominant-color.php @@ -17,8 +17,10 @@ class Test_Dominant_Color extends TestCase { * * @param string $image_path Image path. * @param string[] $expected_color Expected color. + * @param bool $expected_transparency Expected transparency. + * @param int|null $expected_lqip Expected LQIP value, or null if not expected. */ - public function test_dominant_color_metadata( string $image_path, array $expected_color ): void { + public function test_dominant_color_metadata( string $image_path, array $expected_color, bool $expected_transparency, ?int $expected_lqip ): void { $this->skip_if_mime_type_unsupported( $image_path ); // Non-existing attachment. @@ -26,12 +28,18 @@ public function test_dominant_color_metadata( string $image_path, array $expecte $this->assertEmpty( $dominant_color_metadata ); // Creating attachment. - $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); + $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); $dominant_color_metadata = dominant_color_metadata( array(), $attachment_id ); $this->assertArrayHasKey( 'dominant_color', $dominant_color_metadata ); $this->assertNotEmpty( $dominant_color_metadata['dominant_color'] ); $this->assertContains( $dominant_color_metadata['dominant_color'], $expected_color ); + + if ( null === $expected_lqip ) { + $this->assertArrayNotHasKey( 'lqip', $dominant_color_metadata ); + } else { + $this->assertArrayHasKey( 'lqip', $dominant_color_metadata ); + $this->assertSame( $expected_lqip, $dominant_color_metadata['lqip'] ); + } } /** @@ -69,19 +77,26 @@ public function test_dominant_color_get_dominant_color( string $image_path, arra * @param string $image_path Image path. * @param string[] $expected_color Expected color. * @param bool $expected_transparency Expected transparency. + * @param int|null $expected_lqip Expected LQIP value, or null if not expected. */ - public function test_has_transparency_metadata( string $image_path, array $expected_color, bool $expected_transparency ): void { + public function test_has_transparency_metadata( string $image_path, array $expected_color, bool $expected_transparency, ?int $expected_lqip ): void { $this->skip_if_mime_type_unsupported( $image_path ); // Non-existing attachment. $transparency_metadata = dominant_color_metadata( array(), 1 ); $this->assertEmpty( $transparency_metadata ); - $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); + $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); $transparency_metadata = dominant_color_metadata( array(), $attachment_id ); $this->assertArrayHasKey( 'has_transparency', $transparency_metadata ); $this->assertSame( $expected_transparency, $transparency_metadata['has_transparency'] ); + + if ( null === $expected_lqip ) { + $this->assertArrayNotHasKey( 'lqip', $transparency_metadata ); + } else { + $this->assertArrayHasKey( 'lqip', $transparency_metadata ); + $this->assertSame( $expected_lqip, $transparency_metadata['lqip'] ); + } } /** @@ -130,7 +145,6 @@ public function test_tag_add_adjust_to_image_attributes( string $image_path, arr $this->skip_if_mime_type_unsupported( $image_path ); $attachment_id = self::factory()->attachment->create_upload_object( $image_path ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); list( $src, $width, $height ) = wp_get_attachment_image_src( $attachment_id ); // Testing tag_add_adjust() with image being lazy load. @@ -140,9 +154,15 @@ public function test_tag_add_adjust_to_image_attributes( string $image_path, arr $this->assertStringContainsString( 'data-has-transparency="' . wp_json_encode( $expected_transparency ) . '"', $filtered_image_tags_added ); + $image_meta = wp_get_attachment_metadata( $attachment_id ); + foreach ( $expected_color as $color ) { if ( str_contains( $filtered_image_tags_added, $color ) ) { - $this->assertStringContainsString( 'style="--dominant-color: #' . $color . ';"', $filtered_image_tags_added ); + if ( $expected_transparency ) { + $this->assertStringNotContainsString( '--lqip:', $filtered_image_tags_added ); + } else { + $this->assertStringContainsString( '--lqip:' . $image_meta['lqip'] . '; --dominant-color: #' . $color . ';', $filtered_image_tags_added ); + } $this->assertStringContainsString( 'data-dominant-color="' . $color . '"', $filtered_image_tags_added ); break; } @@ -167,7 +187,6 @@ public function test_tag_add_adjust_to_image_attributes( string $image_path, arr */ public function test_dominant_color_img_tag_add_dominant_color_requires_proper_quotes( string $image, bool $expected ): void { $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/red.jpg' ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); $image_url = wp_get_attachment_image_url( $attachment_id ); $image = sprintf( $image, $image_url ); @@ -211,16 +230,13 @@ public function data_dominant_color_img_tag_add_dominant_color_requires_proper_q */ public function test_dominant_color_img_tag_add_dominant_color_should_add_dominant_color_inline_style( string $filtered_image, string $expected ): void { $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/red.jpg' ); - wp_maybe_generate_attachment_metadata( get_post( $attachment_id ) ); list( $src, $width, $height ) = wp_get_attachment_image_src( $attachment_id ); $filtered_image = sprintf( $filtered_image, $src, $width, $height ); - $this->assertStringContainsString( - $expected, - dominant_color_img_tag_add_dominant_color( $filtered_image, 'the_content', $attachment_id ) - ); + $result = dominant_color_img_tag_add_dominant_color( $filtered_image, 'the_content', $attachment_id ); + $this->assertStringContainsString( $expected, $result ); } /** @@ -232,11 +248,11 @@ public function data_provider_dominant_color_check_inline_style(): array { return array( 'no existing inline styles' => array( 'filtered_image' => '', - 'expected' => 'style="--dominant-color: #fe0000;"', + 'expected' => '--lqip:174772; --dominant-color: #fe0000;', ), 'existing inline styles' => array( 'filtered_image' => '', - 'expected' => 'style="--dominant-color: #fe0000; color: #ffffff;"', + 'expected' => '--lqip:174772; --dominant-color: #fe0000; color: #ffffff', ), ); } @@ -265,15 +281,15 @@ public function data_provider_dominant_color_filter_check_inline_style(): array return array( 'no inline styles' => array( 'style_attr' => '', - 'expected' => 'style="--dominant-color: #fe0000;"', + 'expected' => '--lqip:174772;--dominant-color: #fe0000;', ), 'inline style with end semicolon' => array( 'style_attr' => 'color: #ffffff;', - 'expected' => 'style="--dominant-color: #fe0000;color: #ffffff;"', + 'expected' => '--lqip:174772;--dominant-color: #fe0000;color: #ffffff;', ), 'inline style without end semicolon' => array( 'style_attr' => 'color: #ffffff', - 'expected' => 'style="--dominant-color: #fe0000;color: #ffffff"', + 'expected' => '--lqip:174772;--dominant-color: #fe0000;color: #ffffff', ), ); } @@ -413,26 +429,6 @@ public function test_dominant_color_render_generator(): void { $this->assertStringContainsString( 'dominant-color-images ' . DOMINANT_COLOR_IMAGES_VERSION, $tag ); } - /** - * @covers Dominant_Color_Image_Editor_GD::get_dominant_color - */ - public function test_invalid_image_type(): void { - $editor = new Dominant_Color_Image_Editor_GD( '/invalid/type' ); - $result = $editor->get_dominant_color(); - $this->assertWPError( $result ); - $this->assertEquals( 'image_editor_dominant_color_error_no_image', $result->get_error_code() ); - } - - /** - * @covers Dominant_Color_Image_Editor_GD::get_dominant_color - */ - public function test_corrupted_image_file(): void { - $editor = new Dominant_Color_Image_Editor_GD( 'path/to/corrupted/file.jpg' ); - $result = $editor->get_dominant_color(); - $this->assertWPError( $result ); - $this->assertEquals( 'image_editor_dominant_color_error_no_image', $result->get_error_code() ); - } - /** * @covers ::dominant_color_add_inline_style */ @@ -489,6 +485,7 @@ public function test_dominant_color_admin_script(): void { $this->assertStringContainsString( 'data-dominant-color', $output ); $this->assertStringContainsString( 'data-has-transparency', $output ); $this->assertStringContainsString( '--dominant-color', $output ); + $this->assertStringContainsString( '--lqip:', $output ); } /** @@ -500,14 +497,34 @@ public function test_dominant_color_prepare_attachment_for_js(): void { $meta = array( 'dominant_color' => 'ff0000', 'has_transparency' => true, + 'lqip' => 174772, ); $response = dominant_color_prepare_attachment_for_js( array(), $attachment, $meta ); $this->assertArrayHasKey( 'dominantColor', $response ); $this->assertArrayHasKey( 'hasTransparency', $response ); + $this->assertArrayHasKey( 'lqip', $response ); $this->assertEquals( 'ff0000', $response['dominantColor'] ); $this->assertTrue( $response['hasTransparency'] ); + $this->assertSame( 174772, $response['lqip'] ); + } + + /** + * @covers ::dominant_color_lqip_generate + */ + public function test_dominant_color_lqip_generate(): void { + require_once __DIR__ . '/../lqip-generator.php'; + + $rgb = array( + 'r' => 255, + 'g' => 0, + 'b' => 0, + ); + $grid_rgb = array_fill( 0, 6, $rgb ); + $result = dominant_color_lqip_generate( $rgb, $grid_rgb ); + + $this->assertSame( 174781, $result ); } /** diff --git a/webpack.config.js b/webpack.config.js index 407a250f1c..fdae095589 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -38,6 +38,7 @@ const sharedConfig = { // Store plugins that require build process. const pluginsWithBuild = [ 'performance-lab', + 'dominant-color-images', 'embed-optimizer', 'image-prioritizer', 'optimization-detective', @@ -79,6 +80,47 @@ const performanceLab = ( env ) => { }; }; +/** + * Webpack Config: Dominant Color Images + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const dominantColorImages = ( env ) => { + if ( env.plugin && env.plugin !== 'dominant-color-images' ) { + return defaultBuildConfig; + } + + const pluginDir = path.resolve( + __dirname, + 'plugins/dominant-color-images' + ); + + return { + ...sharedConfig, + name: 'dominant-color-images', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ pluginDir }/assets/lqip.css`, + to: `${ pluginDir }/assets/lqip.min.css`, + transform: { + transformer: cssMinifyTransformer, + cache: false, + }, + }, + ], + } ), + // @ts-expect-error TS2351: WebpackBar is constructable when using require(), type definitions might be geared towards ESM. + new WebpackBar( { + name: 'Building Dominant Color Images Assets', + color: '#2196f3', + } ), + ], + }; +}; + /** * Webpack Config: Embed Optimizer * @@ -415,6 +457,7 @@ const buildPlugin = ( env ) => { module.exports = [ performanceLab, + dominantColorImages, embedOptimizer, imagePrioritizer, optimizationDetective,