PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 All 78 releases
ablocks / includes / performance / image-optimizer.php

image-optimizer.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder trunk, at includes/performance/image-optimizer.php

274 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Performance;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Helper;
9
10 /**
11 * Performance Suite — image loading optimizations for aBlocks blocks.
12 *
13 * Two independent, opt-in transforms applied to rendered aBlocks block HTML:
14 * - `perf_lazy_images`: `loading="lazy"` + `decoding="async"`, keeping the first
15 * N images eager with `fetchpriority="high"` so the LCP image isn't deferred.
16 * - `perf_image_dimensions`: inject intrinsic `width`/`height` on images that
17 * have neither, so the browser reserves space and Cumulative Layout Shift
18 * (CLS) drops. Dimensions are resolved cheaply — from the `wp-image-{id}`
19 * class (attachment metadata) or the WordPress `-WIDTHxHEIGHT` filename
20 * suffix — with no per-request filesystem reads.
21 *
22 * Operates via the `render_block` filter (scoped to aBlocks blocks) so no
23 * per-block markup changes are needed.
24 */
25 class ImageOptimizer {
26
27 private $image_index = 0;
28 private $eager_count = 1;
29 private $do_lazy = false;
30 private $do_dimensions = false;
31 private $do_responsive = false;
32
33 public static function init() {
34 if ( is_admin() ) {
35 return;
36 }
37 $self = new self();
38 $self->do_lazy = (bool) apply_filters(
39 'ablocks/perf/perf_lazy_images',
40 (bool) Helper::get_settings( 'perf_lazy_images', true )
41 );
42 $self->do_dimensions = (bool) apply_filters(
43 'ablocks/perf/perf_image_dimensions',
44 (bool) Helper::get_settings( 'perf_image_dimensions', true )
45 );
46 $self->do_responsive = (bool) apply_filters(
47 'ablocks/perf/perf_responsive_images',
48 (bool) Helper::get_settings( 'perf_responsive_images', true )
49 );
50 if ( ! $self->do_lazy && ! $self->do_dimensions && ! $self->do_responsive ) {
51 return;
52 }
53 $self->eager_count = (int) apply_filters(
54 'ablocks/perf/lcp_eager_count',
55 (int) Helper::get_settings( 'perf_lcp_eager_count', 1 )
56 );
57 add_filter( 'render_block', [ $self, 'process' ], 20, 2 );
58 }
59
60 public function process( $content, $block ) {
61 if ( empty( $block['blockName'] ) || false === strpos( $block['blockName'], 'ablocks' ) ) {
62 return $content;
63 }
64 if ( false === strpos( $content, '<img' ) ) {
65 return $content;
66 }
67
68 // aBlocks image blocks store the attachment id in their attributes but
69 // don't emit a wp-image-{id} class, and URL→id lookups fail for
70 // intermediate sizes of -scaled images. Use the block's own id as the
71 // authoritative source for its image.
72 $attrs = isset( $block['attrs'] ) ? $block['attrs'] : [];
73 $this->block_image_id = 0;
74 foreach ( [ 'imgId', 'imgIdMobile', 'imgIdTablet' ] as $key ) {
75 if ( ! empty( $attrs[ $key ] ) ) {
76 $this->block_image_id = (int) $attrs[ $key ];
77 break;
78 }
79 }
80
81 return preg_replace_callback(
82 '/<img\b[^>]*>/i',
83 [ $this, 'rewrite_img' ],
84 $content
85 );
86 }
87
88 private function rewrite_img( $matches ) {
89 $tag = $matches[0];
90 $this->image_index++;
91 $attrs = '';
92
93 // Lazy-loading / priority hints.
94 if ( $this->do_lazy ) {
95 $has_loading = false !== stripos( $tag, 'loading=' );
96 $is_eager = $this->image_index <= $this->eager_count;
97
98 if ( ! $has_loading ) {
99 // No loading hint yet — add one (idempotent, never overrides markup
100 // that set its own).
101 $attrs .= $is_eager ? ' loading="eager" fetchpriority="high"' : ' loading="lazy"';
102 if ( false === stripos( $tag, 'decoding=' ) ) {
103 $attrs .= ' decoding="async"';
104 }
105 } elseif ( $is_eager && false === stripos( $tag, 'fetchpriority=' ) ) {
106 // Above-the-fold image that markup hardcoded as loading="lazy" (e.g.
107 // aBlocks image save output) — upgrade it to eager + high priority so
108 // the likely-LCP image isn't deferred.
109 $tag = preg_replace(
110 '/\bloading=(["\'])(?:lazy|auto)\1/i',
111 'loading="eager" fetchpriority="high"',
112 $tag,
113 1
114 );
115 }
116 }
117
118 // CLS fix — make sure the image always carries BOTH width and height so the
119 // browser has an intrinsic aspect ratio. A missing dimension is as harmful as
120 // missing both: an <img> with width but no height (e.g. a block with a custom
121 // width) has no aspect ratio, so once core's "sizes=auto" is added the
122 // `contain-intrinsic-size:3000px 1500px` fallback stretches it tall. When only
123 // one dimension is present we derive the other from the intrinsic ratio rather
124 // than skipping. This runs when the dimensions feature is on OR responsive
125 // images are on — adding a srcset is what triggers "sizes=auto", so any image
126 // that gets a srcset must also carry both dimensions.
127 if ( $this->do_dimensions || $this->do_responsive ) {
128 $has_w = false !== stripos( $tag, 'width=' );
129 $has_h = false !== stripos( $tag, 'height=' );
130 if ( ! $has_w || ! $has_h ) {
131 $dim = $this->resolve_dimensions( $tag );
132 if ( $dim && $dim[0] > 0 && $dim[1] > 0 ) {
133 if ( ! $has_w && ! $has_h ) {
134 $attrs .= ' width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"';
135 } elseif ( $has_w ) {
136 // Width present, height missing → height = width × (natH / natW).
137 $w = $this->get_attr( $tag, 'width' );
138 if ( preg_match( '/^\d+$/', $w ) && (int) $w > 0 ) {
139 $attrs .= ' height="' . (int) round( (int) $w * $dim[1] / $dim[0] ) . '"';
140 }
141 } else {
142 // Height present, width missing → width = height × (natW / natH).
143 $h = $this->get_attr( $tag, 'height' );
144 if ( preg_match( '/^\d+$/', $h ) && (int) $h > 0 ) {
145 $attrs .= ' width="' . (int) round( (int) $h * $dim[0] / $dim[1] ) . '"';
146 }
147 }
148 }
149 }
150 }
151
152 // Responsive delivery — add a width-descriptor srcset + sizes so the
153 // browser downloads a right-sized file instead of the full image (the
154 // single biggest mobile payload win). Only when the image maps to an
155 // attachment and doesn't already declare srcset.
156 if ( $this->do_responsive && false === stripos( $tag, 'srcset=' ) ) {
157 $attrs .= $this->responsive_attrs( $tag );
158 }
159
160 if ( '' === $attrs ) {
161 return $tag;
162 }
163 return preg_replace( '/^<img\b/', '<img' . $attrs, $tag, 1 );
164 }
165
166 /**
167 * Build ` srcset="…" sizes="…"` for an image that maps to an attachment,
168 * using WordPress core's generators (which read the already-stored metadata,
169 * no filesystem work). Returns '' when the image can't be mapped or has no
170 * alternate sizes.
171 */
172 private function responsive_attrs( $tag ) {
173 $id = $this->resolve_attachment_id( $tag );
174 if ( ! $id ) {
175 return '';
176 }
177 $size = $this->resolve_dimensions( $tag );
178 $size = $size ? $size : 'full';
179
180 $srcset = wp_get_attachment_image_srcset( $id, $size );
181 if ( ! $srcset ) {
182 return '';
183 }
184 $sizes = wp_get_attachment_image_sizes( $id, $size );
185 $out = ' srcset="' . esc_attr( $srcset ) . '"';
186 if ( $sizes && false === stripos( $tag, 'sizes=' ) ) {
187 $out .= ' sizes="' . esc_attr( $sizes ) . '"';
188 }
189 return $out;
190 }
191
192 /**
193 * Resolve an image's intrinsic [width, height] without touching the
194 * filesystem: first from the attachment metadata (via the wp-image-{id}
195 * class, matching the exact rendered size), then from WordPress's
196 * `-WIDTHxHEIGHT` resized-filename convention. Returns null when unknown.
197 */
198 private function resolve_dimensions( $tag ) {
199 $src = $this->get_attr( $tag, 'src' );
200
201 $id = $this->resolve_attachment_id( $tag );
202 if ( $id ) {
203 $dim = $this->dimensions_from_attachment( $id, $src );
204 if ( $dim ) {
205 return $dim;
206 }
207 }
208
209 if ( $src && preg_match( '/-(\d+)x(\d+)\.(?:jpe?g|png|gif|webp|avif|bmp)(?:\?.*)?$/i', $src, $m ) ) {
210 return [ (int) $m[1], (int) $m[2] ];
211 }
212
213 return null;
214 }
215
216 /**
217 * Resolve the attachment id for an <img>: prefer the WordPress `wp-image-{id}`
218 * class, otherwise map the src URL back to an attachment (aBlocks image blocks
219 * don't emit the class). URL lookups are cached per request — and only run
220 * when the class is absent — so the DB is hit at most once per unique URL.
221 */
222 private $id_cache = [];
223 private $block_image_id = 0;
224 private function resolve_attachment_id( $tag ) {
225 if ( preg_match( '/wp-image-(\d+)/', $tag, $m ) ) {
226 return (int) $m[1];
227 }
228 // The current block's stored attachment id (authoritative for aBlocks
229 // image blocks, which don't emit the class).
230 if ( $this->block_image_id ) {
231 return $this->block_image_id;
232 }
233 $src = $this->get_attr( $tag, 'src' );
234 if ( ! $src ) {
235 return 0;
236 }
237 if ( ! array_key_exists( $src, $this->id_cache ) ) {
238 $this->id_cache[ $src ] = (int) attachment_url_to_postid( $src );
239 }
240 return $this->id_cache[ $src ];
241 }
242
243 /**
244 * Pull width/height from an attachment's stored metadata, preferring the
245 * registered sub-size whose file matches the rendered src, falling back to
246 * the full-size dimensions.
247 */
248 private function dimensions_from_attachment( $id, $src ) {
249 if ( ! $id ) {
250 return null;
251 }
252 $meta = wp_get_attachment_metadata( $id );
253 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
254 return null;
255 }
256 if ( $src && ! empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
257 $base = wp_basename( strtok( $src, '?' ) );
258 foreach ( $meta['sizes'] as $size ) {
259 if ( isset( $size['file'], $size['width'], $size['height'] ) && $size['file'] === $base ) {
260 return [ (int) $size['width'], (int) $size['height'] ];
261 }
262 }
263 }
264 return [ (int) $meta['width'], (int) $meta['height'] ];
265 }
266
267 private function get_attr( $tag, $name ) {
268 if ( preg_match( '/\b' . preg_quote( $name, '/' ) . '=(["\'])(.*?)\1/i', $tag, $m ) ) {
269 return $m[2];
270 }
271 return '';
272 }
273 }
274