PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.18
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.18
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / media-optimization / class-dimension-injector.php

class-dimension-injector.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.18, at media-optimization/class-dimension-injector.php

342 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Dimension_Injector
4 * Scans HTML for <img> tags missing width/height and injects them
5 * based on file metadata to prevent Cumulative Layout Shift (CLS).
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Dimension_Injector {
17
18 /** In-memory cache to avoid repeated file lookups within a request. */
19 private array $cache = [];
20
21 /** Transient key prefix for persisted image dimensions. */
22 private const CACHE_PREFIX = 'ms_dim_';
23
24 /** How long resolved dimensions stay cached, in seconds (7 days). */
25 private const CACHE_TTL = 604800;
26
27 public function __construct() {
28 add_filter('the_content', [$this, 'inject_dimensions'], 30);
29 add_filter('post_thumbnail_html', [$this, 'inject_dimensions'], 30);
30 add_filter('widget_text', [$this, 'inject_dimensions'], 30);
31 }
32
33 /**
34 * Find <img> tags missing width or height and inject dimensions.
35 */
36 public function inject_dimensions(string $content): string {
37 if (empty($content) || is_admin()) {
38 return $content;
39 }
40
41 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
42 $tag = $matches[0];
43
44 $has_width = preg_match('/\swidth\s*=/i', $tag);
45 $has_height = preg_match('/\sheight\s*=/i', $tag);
46
47 // Both already present - nothing to do
48 if ($has_width && $has_height) {
49 return $tag;
50 }
51
52 $dims = $this->get_dimensions($tag);
53 if (!$dims) {
54 return $tag;
55 }
56
57 if (!$has_width) {
58 $tag = $this->add_attribute($tag, 'width', (string) $dims['width']);
59 }
60 if (!$has_height) {
61 $tag = $this->add_attribute($tag, 'height', (string) $dims['height']);
62 }
63
64 return $tag;
65 }, $content);
66 }
67
68 /**
69 * Try to determine image dimensions from multiple sources.
70 */
71 private function get_dimensions(string $img_tag): ?array {
72 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $m)) {
73 return null;
74 }
75 $src = $m[1];
76
77 // Request-scoped cache: avoids repeated work within a single render.
78 if (isset($this->cache[$src])) {
79 return $this->cache[$src];
80 }
81
82 // Persistent cache: skips disk I/O for images resolved on a prior request.
83 $cached = $this->get_cached_dimensions($src);
84 if ($cached !== null) {
85 $this->cache[$src] = $cached;
86 return $cached;
87 }
88
89 $dims = null;
90
91 // Strategy 1: Try to find WordPress attachment by URL
92 $dims = $this->get_dims_from_attachment($src);
93
94 // Strategy 2: Try to read the local file directly
95 if (!$dims) {
96 $dims = $this->get_dims_from_local_file($src);
97 }
98
99 // Strategy 3: For external images, try getimagesize with URL (slower)
100 if (!$dims) {
101 $dims = $this->get_dims_from_remote($src);
102 }
103
104 if ($dims) {
105 $this->cache[$src] = $dims;
106 $this->set_cached_dimensions($src, $dims);
107 }
108
109 return $dims;
110 }
111
112 /**
113 * Build the transient key for a given image src.
114 */
115 private static function cache_key(string $src): string {
116 return self::CACHE_PREFIX . md5($src);
117 }
118
119 /**
120 * Read previously resolved dimensions from the persistent transient cache.
121 * Returns null on a cache miss or a malformed/partial cached value.
122 */
123 private function get_cached_dimensions(string $src): ?array {
124 $cached = get_transient(self::cache_key($src));
125
126 if (is_array($cached)
127 && isset($cached['width'], $cached['height'])
128 && (int) $cached['width'] > 0
129 && (int) $cached['height'] > 0
130 ) {
131 return [
132 'width' => (int) $cached['width'],
133 'height' => (int) $cached['height'],
134 ];
135 }
136
137 return null;
138 }
139
140 /**
141 * Persist resolved dimensions so subsequent requests avoid file I/O.
142 */
143 private function set_cached_dimensions(string $src, array $dims): void {
144 set_transient(
145 self::cache_key($src),
146 [
147 'width' => (int) $dims['width'],
148 'height' => (int) $dims['height'],
149 ],
150 self::CACHE_TTL
151 );
152 }
153
154 /**
155 * Purge cached dimensions for every size URL of an attachment.
156 *
157 * Hooked on `delete_attachment` and `wp_update_attachment_metadata` so the
158 * cache can never outlive (or contradict) the image: deleting an image and
159 * re-uploading a different one under the same filename, or regenerating
160 * thumbnails to new dimensions, would otherwise serve stale width/height
161 * for up to CACHE_TTL. Registered unconditionally (even when dimension
162 * injection is disabled) so stale entries are always cleaned up.
163 *
164 * @param int $attachment_id Attachment whose cached dimensions to clear.
165 */
166 public static function purge_attachment_cache($attachment_id): void {
167 $attachment_id = (int) $attachment_id;
168 if ($attachment_id <= 0) {
169 return;
170 }
171
172 foreach (self::collect_attachment_urls($attachment_id) as $url) {
173 delete_transient(self::cache_key($url));
174 }
175 }
176
177 /**
178 * `wp_update_attachment_metadata` filter wrapper: purge the cache, then
179 * return the metadata untouched so the filter chain is unaffected.
180 *
181 * @param mixed $data Attachment metadata being saved.
182 * @param int $attachment_id Attachment ID.
183 * @return mixed The unmodified $data.
184 */
185 public static function purge_on_metadata_update($data, $attachment_id) {
186 self::purge_attachment_cache($attachment_id);
187 return $data;
188 }
189
190 /**
191 * Collect the full-size URL plus every registered sub-size URL for an
192 * attachment. Sub-size files live in the same directory as the full-size
193 * file, so each is the full URL with its basename swapped for the size
194 * filename.
195 *
196 * @param int $attachment_id Attachment ID.
197 * @return string[] Image URLs (possibly empty).
198 */
199 private static function collect_attachment_urls(int $attachment_id): array {
200 $full = wp_get_attachment_url($attachment_id);
201 if (!is_string($full) || $full === '') {
202 return [];
203 }
204
205 $urls = [$full];
206
207 $meta = wp_get_attachment_metadata($attachment_id);
208 if (is_array($meta) && !empty($meta['sizes'])) {
209 $slash = strrpos($full, '/');
210 $base = $slash === false ? '' : substr($full, 0, $slash + 1);
211 foreach ($meta['sizes'] as $size_data) {
212 if (!empty($size_data['file'])) {
213 $urls[] = $base . $size_data['file'];
214 }
215 }
216 }
217
218 return $urls;
219 }
220
221 /**
222 * Look up dimensions from WP attachment metadata.
223 */
224 private function get_dims_from_attachment(string $url): ?array {
225 $attachment_id = attachment_url_to_postid($url);
226
227 if (!$attachment_id) {
228 // Try without size suffix (e.g., image-300x200.jpg -> image.jpg)
229 $clean_url = preg_replace('/-\d+x\d+(\.\w+)$/', '$1', $url);
230 $attachment_id = attachment_url_to_postid($clean_url);
231 }
232
233 if (!$attachment_id) {
234 return null;
235 }
236
237 $meta = wp_get_attachment_metadata($attachment_id);
238 if (!$meta) {
239 return null;
240 }
241
242 // Check sub-sizes first
243 if (!empty($meta['sizes'])) {
244 $filename = wp_basename($url);
245 foreach ($meta['sizes'] as $size_data) {
246 if ($size_data['file'] === $filename) {
247 return [
248 'width' => (int) $size_data['width'],
249 'height' => (int) $size_data['height'],
250 ];
251 }
252 }
253 }
254
255 // Fall back to full size
256 if (!empty($meta['width']) && !empty($meta['height'])) {
257 return [
258 'width' => (int) $meta['width'],
259 'height' => (int) $meta['height'],
260 ];
261 }
262
263 return null;
264 }
265
266 /**
267 * Convert URL to local path and use getimagesize().
268 */
269 private function get_dims_from_local_file(string $url): ?array {
270 $upload_dir = wp_get_upload_dir();
271
272 if (strpos($url, $upload_dir['baseurl']) === false) {
273 return null;
274 }
275
276 $relative = str_replace($upload_dir['baseurl'], '', $url);
277 $file = $upload_dir['basedir'] . $relative;
278
279 if (!file_exists($file)) {
280 return null;
281 }
282
283 $info = @getimagesize($file);
284 if ($info && $info[0] > 0 && $info[1] > 0) {
285 return ['width' => $info[0], 'height' => $info[1]];
286 }
287
288 return null;
289 }
290
291 /**
292 * Fetch dimensions from a remote URL.
293 * Downloads just enough bytes to read image headers.
294 */
295 private function get_dims_from_remote(string $url): ?array {
296 if (strpos($url, 'http') !== 0) {
297 return null;
298 }
299
300 $response = wp_remote_get($url, [
301 'timeout' => 3,
302 'headers' => ['Range' => 'bytes=0-32767'],
303 ]);
304
305 if (is_wp_error($response)) {
306 return null;
307 }
308
309 $body = wp_remote_retrieve_body($response);
310 if (empty($body)) {
311 return null;
312 }
313
314 if (!function_exists('wp_tempnam')) {
315 require_once ABSPATH . 'wp-admin/includes/file.php';
316 }
317
318 $tmp = wp_tempnam($url);
319 file_put_contents($tmp, $body);
320 $info = @getimagesize($tmp);
321 @unlink($tmp);
322
323 if ($info && $info[0] > 0 && $info[1] > 0) {
324 return ['width' => $info[0], 'height' => $info[1]];
325 }
326
327 return null;
328 }
329
330 /**
331 * Insert an attribute into an <img> tag.
332 */
333 private function add_attribute(string $tag, string $name, string $value): string {
334 return preg_replace(
335 '/(<img\s)/i',
336 sprintf('$1%s="%s" ', esc_attr($name), esc_attr($value)),
337 $tag,
338 1
339 );
340 }
341 }
342