PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.7
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.7
2.7.1 2.7.2 2.7.0 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 All 141 releases
metasync / media-optimization / class-image-converter.php

class-image-converter.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.7, at media-optimization/class-image-converter.php

454 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Image_Converter
4 * Converts uploaded JPEG/PNG images to WebP or AVIF on upload.
5 * Supports "replace" and "alongside" strategies.
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - [email protected]
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Image_Converter {
17
18 private array $settings;
19
20 private const SUPPORTED_MIMES = [
21 'image/jpeg',
22 'image/png',
23 ];
24
25 private const EXT_AVIF = '.avif';
26 private const EXT_WEBP = '.webp';
27 private const ORIGINAL_EXT_PATTERN = '/\.(jpe?g|png)$/i';
28
29 /**
30 * Get file extension for a given format.
31 */
32 private static function get_format_extension(string $format): string {
33 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
34 }
35
36 public function __construct(array $settings) {
37 $this->settings = $settings;
38
39 // Fires after WP generates all thumbnail sizes
40 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
41
42 // If "alongside" strategy, rewrite <img> tags to <picture>
43 if ($settings['conversion_strategy'] === 'alongside') {
44 // Core WordPress
45 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
46 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
47 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
48
49 // WooCommerce frontend images
50 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
51 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
52 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
53 add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20);
54 }
55 }
56
57 /**
58 * Hook into metadata generation to convert the main file and all sub-sizes.
59 */
60 public function convert_on_upload(array $metadata, int $attachment_id): array {
61 $file = get_attached_file($attachment_id);
62 $mime = get_post_mime_type($attachment_id);
63
64 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
65 return $metadata;
66 }
67
68 // Check exclusions
69 if ($this->is_excluded($file)) {
70 return $metadata;
71 }
72
73 $format = $this->settings['conversion_format'];
74 $quality = (int) $this->settings['conversion_quality'];
75 $strategy = $this->settings['conversion_strategy'];
76
77 // Convert main/full file
78 $converted = $this->convert_file($file, $format, $quality);
79
80 if ($converted && $strategy === 'replace') {
81 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
82 }
83
84 // Convert sub-sizes (thumbnails, medium, large, etc.)
85 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
86 $upload_dir = dirname($file);
87 foreach ($metadata['sizes'] as $size_name => &$size_data) {
88 $size_file = $upload_dir . '/' . $size_data['file'];
89 $size_converted = $this->convert_file($size_file, $format, $quality);
90
91 if ($size_converted && $strategy === 'replace') {
92 @unlink($size_file);
93 $size_data['file'] = basename($size_converted);
94 $size_data['mime-type'] = "image/{$format}";
95 }
96 }
97 unset($size_data);
98 }
99
100 // Store converted format in meta for later use by picture tag rewriter
101 if ($converted && $strategy === 'alongside') {
102 update_post_meta($attachment_id, '_metasync_converted_format', $format);
103 }
104
105 return $metadata;
106 }
107
108 // ── Static Methods for External Use (Batch Optimizer, AJAX) ──
109
110 /**
111 * Convert an existing attachment to next-gen format.
112 * Used by batch optimizer and single-image AJAX actions.
113 */
114 public static function convert_attachment(int $attachment_id, array $settings): bool {
115 $file = get_attached_file($attachment_id);
116 $mime = get_post_mime_type($attachment_id);
117
118 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
119 return false;
120 }
121
122 $format = $settings['conversion_format'] ?? 'webp';
123 $quality = (int) ($settings['conversion_quality'] ?? 82);
124 $strategy = $settings['conversion_strategy'] ?? 'alongside';
125
126 // Store original file size before conversion for savings display
127 $original_size = filesize($file);
128
129 $converted = self::do_convert_file($file, $format, $quality);
130 if (!$converted) {
131 return false;
132 }
133
134 // Store original size meta for savings calculation
135 if ($original_size) {
136 update_post_meta($attachment_id, '_metasync_original_filesize', $original_size);
137 }
138
139 if ($strategy === 'replace') {
140 $metadata = wp_get_attachment_metadata($attachment_id);
141 if ($metadata) {
142 self::do_replace_original($attachment_id, $file, $converted, $metadata, $format);
143 wp_update_attachment_metadata($attachment_id, $metadata);
144 }
145 }
146
147 // Convert sub-sizes
148 if (!empty($settings['convert_existing_sizes'])) {
149 $metadata = wp_get_attachment_metadata($attachment_id);
150 if ($metadata && !empty($metadata['sizes'])) {
151 $upload_dir = dirname($file);
152 foreach ($metadata['sizes'] as $size_name => &$size_data) {
153 $size_file = $upload_dir . '/' . $size_data['file'];
154 $size_converted = self::do_convert_file($size_file, $format, $quality);
155
156 if ($size_converted && $strategy === 'replace') {
157 @unlink($size_file);
158 $size_data['file'] = basename($size_converted);
159 $size_data['mime-type'] = "image/{$format}";
160 }
161 }
162 unset($size_data);
163 if ($strategy === 'replace') {
164 wp_update_attachment_metadata($attachment_id, $metadata);
165 }
166 }
167 }
168
169 update_post_meta($attachment_id, '_metasync_converted_format', $format);
170 return true;
171 }
172
173 /**
174 * Revert an attachment's conversion (alongside strategy only).
175 * Deletes the converted files and removes the meta marker.
176 */
177 public static function revert_attachment(int $attachment_id): bool {
178 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
179 if (!$format) {
180 return false;
181 }
182
183 $file = get_attached_file($attachment_id);
184 if (!$file) {
185 return false;
186 }
187
188 // Check if original still exists (alongside strategy)
189 if (!file_exists($file)) {
190 return false; // Cannot revert replace strategy
191 }
192
193 $ext = self::get_format_extension($format);
194
195 // Delete converted full-size file
196 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
197 if ($converted_path && file_exists($converted_path)) {
198 @unlink($converted_path);
199 }
200
201 // Delete converted sub-sizes
202 $metadata = wp_get_attachment_metadata($attachment_id);
203 if ($metadata && !empty($metadata['sizes'])) {
204 $upload_dir = dirname($file);
205 foreach ($metadata['sizes'] as $size_data) {
206 $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
207 if ($size_converted && file_exists($size_converted)) {
208 @unlink($size_converted);
209 }
210 }
211 }
212
213 delete_post_meta($attachment_id, '_metasync_converted_format');
214 delete_post_meta($attachment_id, '_metasync_original_filesize');
215 return true;
216 }
217
218 // ── Core Conversion (static, reusable) ──
219
220 /**
221 * Convert a single image file. Returns path to converted file or null on failure.
222 */
223 protected static function do_convert_file(string $source, string $format, int $quality): ?string {
224 if (!file_exists($source)) {
225 return null;
226 }
227
228 $ext = self::get_format_extension($format);
229 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
230
231 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
232 if (extension_loaded('imagick')) {
233 try {
234 $result = self::do_convert_with_imagick($source, $dest, $format, $quality);
235 if ($result) {
236 return $result;
237 }
238 } catch (\Exception $e) {
239 error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
240 }
241 }
242
243 if (extension_loaded('gd')) {
244 try {
245 return self::do_convert_with_gd($source, $dest, $format, $quality);
246 } catch (\Exception $e) {
247 error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
248 }
249 }
250
251 return null;
252 }
253
254 private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
255 $img = new \Imagick($src);
256 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
257 $img->setImageCompressionQuality($q);
258 $img->stripImage();
259
260 if ($img->writeImage($dest)) {
261 $img->destroy();
262 return $dest;
263 }
264
265 $img->destroy();
266 return null;
267 }
268
269 private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q): ?string {
270 $info = getimagesize($src);
271 if (!$info) {
272 return null;
273 }
274
275 $gd_img = match ($info['mime']) {
276 'image/jpeg' => imagecreatefromjpeg($src),
277 'image/png' => imagecreatefrompng($src),
278 default => null,
279 };
280
281 if (!$gd_img) {
282 return null;
283 }
284
285 if ($info['mime'] === 'image/png') {
286 imagepalettetotruecolor($gd_img);
287 imagealphablending($gd_img, true);
288 imagesavealpha($gd_img, true);
289 }
290
291 $success = match ($fmt) {
292 'webp' => imagewebp($gd_img, $dest, $q),
293 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
294 default => false,
295 };
296
297 imagedestroy($gd_img);
298 return $success ? $dest : null;
299 }
300
301 /**
302 * Replace original file with converted version.
303 */
304 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
305 @unlink($old_path);
306
307 wp_update_post([
308 'ID' => $id,
309 'post_mime_type' => "image/{$fmt}",
310 ]);
311
312 update_attached_file($id, $new_path);
313 $meta['file'] = _wp_relative_upload_path($new_path);
314 }
315
316 // ── Instance Wrappers (Upload Hook) ──
317
318 /**
319 * Instance wrapper around static conversion method.
320 */
321 private function convert_file(string $source, string $format, int $quality): ?string {
322 return self::do_convert_file($source, $format, $quality);
323 }
324
325 /**
326 * Instance wrapper around static replace method.
327 */
328 private function replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
329 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
330 }
331
332 /**
333 * Rewrite <img> tags to <picture> with next-gen source.
334 */
335 public function rewrite_to_picture_tags(string $content): string {
336 if (empty($content)) {
337 return $content;
338 }
339
340 // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
341 if (strpos($content, '<picture>') !== false) {
342 return $content;
343 }
344
345 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
346 $img_tag = $matches[0];
347
348 // Check if this image should be excluded
349 if ($this->is_tag_excluded($img_tag)) {
350 return $img_tag;
351 }
352
353 // Extract src
354 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
355 return $img_tag;
356 }
357
358 $original_src = $src_match[1];
359 $format = $this->settings['conversion_format'];
360 $ext = self::get_format_extension($format);
361
362 // Build the converted file URL
363 $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
364
365 // Only wrap if the converted URL is different
366 if ($converted_url === $original_src) {
367 return $img_tag;
368 }
369
370 // Verify the converted file actually exists on disk before rewriting
371 $converted_path = $this->url_to_path($converted_url);
372 if (!$converted_path || !file_exists($converted_path)) {
373 return $img_tag;
374 }
375
376 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
377
378 // Also handle srcset if present
379 $source_srcset = '';
380 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
381 $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
382 $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
383 }
384
385 // Extract sizes attribute to pass to <source>
386 $sizes_attr = '';
387 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
388 $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1]));
389 }
390
391 return sprintf(
392 '<picture><source type="%s"%s%s>%s</picture>',
393 esc_attr($mime),
394 $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
395 $sizes_attr,
396 $img_tag
397 );
398 }, $content);
399 }
400
401 /**
402 * Convert a URL to a local file path. Returns null if URL is external.
403 */
404 private function url_to_path(string $url): ?string {
405 $upload_dir = wp_get_upload_dir();
406 $base_url = $upload_dir['baseurl'];
407 $base_path = $upload_dir['basedir'];
408
409 if (strpos($url, $base_url) === 0) {
410 return str_replace($base_url, $base_path, $url);
411 }
412
413 return null;
414 }
415
416 /**
417 * Check if a file path matches exclusion patterns.
418 */
419 private function is_excluded(string $file): bool {
420 $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
421 if (empty($exclude_urls)) {
422 return false;
423 }
424
425 foreach ($exclude_urls as $pattern) {
426 if (stripos($file, $pattern) !== false) {
427 return true;
428 }
429 }
430 return false;
431 }
432
433 /**
434 * Check if an img tag has an excluded CSS class.
435 */
436 private function is_tag_excluded(string $tag): bool {
437 $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? '')));
438 if (empty($exclude_classes)) {
439 return false;
440 }
441
442 if (preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) {
443 $classes = explode(' ', $class_match[1]);
444 foreach ($exclude_classes as $excluded) {
445 if (in_array($excluded, $classes, true)) {
446 return true;
447 }
448 }
449 }
450 return false;
451 }
452
453 }
454