PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.13
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.13
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-image-converter.php

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

688 lines 24.5 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 - support@searchatlas.com
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 private const MAX_CONVERT_BYTES = 10 * 1024 * 1024;
29
30 /**
31 * Minimum available memory (bytes) required before attempting a sub-size conversion.
32 * Remaining sub-sizes are skipped when available memory drops below this threshold.
33 */
34 private const MIN_MEMORY_FOR_SUBSIZE = 8 * 1024 * 1024;
35
36 /**
37 * Get file extension for a given format.
38 */
39 private static function get_format_extension(string $format): string {
40 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
41 }
42
43 public function __construct(array $settings) {
44 $this->settings = $settings;
45
46 // Fires after WP generates all thumbnail sizes
47 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
48
49 // If "alongside" strategy, rewrite <img> tags to <picture>
50 if ($settings['conversion_strategy'] === 'alongside') {
51 // Core WordPress
52 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
53 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
54 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
55
56 // WooCommerce frontend images
57 add_filter('woocommerce_single_product_image_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
58 add_filter('woocommerce_product_get_image', [$this, 'rewrite_to_picture_tags'], 20);
59 add_filter('woocommerce_cart_item_thumbnail', [$this, 'rewrite_to_picture_tags'], 20);
60 add_filter('woocommerce_placeholder_img', [$this, 'rewrite_to_picture_tags'], 20);
61
62 // Output buffer catch-all for themes/builders bypassing WP filters (Divi, Elementor, etc.)
63 add_action('template_redirect', [$this, 'start_output_buffer'], 1);
64 }
65 }
66
67 /**
68 * Hook into metadata generation to convert the main file and all sub-sizes.
69 */
70 public function convert_on_upload(array $metadata, int $attachment_id): array {
71 $file = get_attached_file($attachment_id);
72 $mime = get_post_mime_type($attachment_id);
73
74 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
75 return $metadata;
76 }
77
78 // Check exclusions
79 if ($this->is_excluded($file)) {
80 return $metadata;
81 }
82
83 $format = $this->settings['conversion_format'];
84 $quality = (int) $this->settings['conversion_quality'];
85 $strategy = $this->settings['conversion_strategy'];
86
87 // Convert main/full file
88 $converted = $this->convert_file($file, $format, $quality);
89
90 if ($converted && $strategy === 'replace') {
91 $this->replace_original($attachment_id, $file, $converted, $metadata, $format);
92 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
93 }
94
95 // Convert sub-sizes (thumbnails, medium, large, etc.) with memory management
96 if (!empty($this->settings['convert_existing_sizes']) && !empty($metadata['sizes'])) {
97 static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
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 with memory management
148 if (!empty($settings['convert_existing_sizes'])) {
149 $metadata = wp_get_attachment_metadata($attachment_id);
150 if ($metadata && !empty($metadata['sizes'])) {
151 static::convert_subsizes($metadata['sizes'], dirname($file), $format, $quality, $strategy);
152 if ($strategy === 'replace') {
153 wp_update_attachment_metadata($attachment_id, $metadata);
154 }
155 }
156 }
157
158 if ($strategy === 'replace') {
159 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
160 }
161
162 update_post_meta($attachment_id, '_metasync_converted_format', $format);
163 return true;
164 }
165
166 /**
167 * Check whether an optimized attachment can be reverted.
168 * Returns false when the original was replaced (no backup exists).
169 */
170 public static function can_revert(int $attachment_id): bool {
171 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
172 if (!$format) {
173 return false; // Not optimized
174 }
175
176 // If the original was replaced, revert is impossible
177 if (get_post_meta($attachment_id, '_metasync_replaced_original', true)) {
178 return false;
179 }
180
181 // Verify original file still exists on disk (alongside strategy)
182 $file = get_attached_file($attachment_id);
183 return $file && file_exists($file);
184 }
185
186 /**
187 * Revert an attachment's conversion (alongside strategy only).
188 * Deletes the converted files and removes the meta marker.
189 */
190 public static function revert_attachment(int $attachment_id): bool {
191 $format = get_post_meta($attachment_id, '_metasync_converted_format', true);
192 if (!$format) {
193 return false;
194 }
195
196 $file = get_attached_file($attachment_id);
197 if (!$file) {
198 return false;
199 }
200
201 // Check if original still exists (alongside strategy)
202 if (!file_exists($file)) {
203 return false; // Cannot revert replace strategy
204 }
205
206 $ext = self::get_format_extension($format);
207
208 // Delete converted full-size file
209 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
210 if ($converted_path && file_exists($converted_path)) {
211 @unlink($converted_path);
212 }
213
214 // Delete converted sub-sizes
215 $metadata = wp_get_attachment_metadata($attachment_id);
216 if ($metadata && !empty($metadata['sizes'])) {
217 $upload_dir = dirname($file);
218 foreach ($metadata['sizes'] as $size_data) {
219 $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $upload_dir . '/' . $size_data['file']);
220 if ($size_converted && file_exists($size_converted)) {
221 @unlink($size_converted);
222 }
223 }
224 }
225
226 delete_post_meta($attachment_id, '_metasync_converted_format');
227 delete_post_meta($attachment_id, '_metasync_original_filesize');
228 delete_post_meta($attachment_id, '_metasync_replaced_original');
229 return true;
230 }
231
232 // ── Core Conversion (static, reusable) ──
233
234 /**
235 * Get available PHP memory in bytes.
236 * Returns PHP_INT_MAX when no limit is set (-1) or unreadable.
237 * Returns 0 when memory_limit is "0" or empty.
238 */
239 protected static function get_available_memory(): int {
240 $limit = ini_get('memory_limit');
241
242 if ($limit === false || $limit === '-1') {
243 return PHP_INT_MAX;
244 }
245
246 $limit = trim($limit);
247 if ($limit === '' || $limit === '0') {
248 return 0;
249 }
250
251 $value = (int) $limit;
252 $unit = strtolower(substr($limit, -1));
253
254 $value = match ($unit) {
255 'g' => $value * 1024 * 1024 * 1024,
256 'm' => $value * 1024 * 1024,
257 'k' => $value * 1024,
258 default => $value,
259 };
260
261 return max(0, $value - memory_get_usage(true));
262 }
263
264 /**
265 * Convert sub-sizes with memory management.
266 *
267 * Runs gc_collect_cycles() between each sub-size to release memory and
268 * checks available memory before each iteration, skipping remaining
269 * sub-sizes when memory drops below MIN_MEMORY_FOR_SUBSIZE.
270 *
271 * @param array $sizes Reference to $metadata['sizes'].
272 * @param string $upload_dir Directory containing the sub-size files.
273 * @param string $format Target format (webp|avif).
274 * @param int $quality Compression quality.
275 * @param string $strategy Conversion strategy (replace|alongside).
276 */
277 protected static function convert_subsizes(array &$sizes, string $upload_dir, string $format, int $quality, string $strategy): void {
278 foreach ($sizes as $size_name => &$size_data) {
279 // Check available memory before each sub-size conversion
280 $available = static::get_available_memory();
281 if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
282 error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name);
283 break;
284 }
285
286 $size_file = $upload_dir . '/' . $size_data['file'];
287 $size_converted = static::do_convert_file($size_file, $format, $quality);
288
289 if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
290 @unlink($size_file);
291 $size_data['file'] = basename($size_converted);
292 $size_data['mime-type'] = "image/{$format}";
293 } elseif ($size_converted && $strategy === 'replace') {
294 error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
295 }
296
297 // Release cyclic references between sub-size conversions
298 gc_collect_cycles();
299 }
300 unset($size_data);
301 }
302
303 /**
304 * Convert a single image file. Returns path to converted file or null on failure.
305 */
306 protected static function do_convert_file(string $source, string $format, int $quality): ?string {
307 if (!file_exists($source)) {
308 return null;
309 }
310
311 if (filesize($source) > self::MAX_CONVERT_BYTES) {
312 error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
313 return null;
314 }
315
316 // Request WordPress's image processing memory limit
317 wp_raise_memory_limit('image');
318
319 // Pre-flight memory check using pixel dimensions when available
320 $info = getimagesize($source);
321 if ($info && $info[0] > 0 && $info[1] > 0) {
322 $bpp = ($info['mime'] === 'image/png') ? 4 : 3;
323 $estimated = (int) ($info[0] * $info[1] * $bpp * 1.8);
324 } else {
325 $estimated = filesize($source) * 3;
326 }
327 $available = self::get_available_memory();
328 if ($estimated > $available * 0.8) {
329 error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')');
330 return null;
331 }
332
333 $ext = self::get_format_extension($format);
334 $dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $source);
335
336 // Try Imagick first, fall back to GD if it fails (e.g. missing encode delegate)
337 if (extension_loaded('imagick')) {
338 try {
339 $result = self::do_convert_with_imagick($source, $dest, $format, $quality);
340 if ($result) {
341 return $result;
342 }
343 } catch (\Exception $e) {
344 error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
345 }
346 }
347
348 if (extension_loaded('gd')) {
349 try {
350 return self::do_convert_with_gd($source, $dest, $format, $quality);
351 } catch (\Exception $e) {
352 error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
353 }
354 }
355
356 return null;
357 }
358
359 private static function do_convert_with_imagick(string $src, string $dest, string $fmt, int $q): ?string {
360 $img = new \Imagick($src);
361 $img->setImageFormat($fmt === 'avif' ? 'avif' : 'webp');
362 $img->setImageCompressionQuality($q);
363 $img->stripImage();
364
365 if ($img->writeImage($dest)) {
366 if (!file_exists($dest) || !filesize($dest)) {
367 @unlink($dest);
368 error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
369 $img->destroy();
370 return null;
371 }
372 $img->destroy();
373 return $dest;
374 }
375
376 $img->destroy();
377 return null;
378 }
379
380 private static function do_convert_with_gd(string $src, string $dest, string $fmt, int $q): ?string {
381 $info = getimagesize($src);
382 if (!$info) {
383 return null;
384 }
385
386 $gd_img = match ($info['mime']) {
387 'image/jpeg' => imagecreatefromjpeg($src),
388 'image/png' => imagecreatefrompng($src),
389 default => null,
390 };
391
392 if (!$gd_img) {
393 return null;
394 }
395
396 if ($info['mime'] === 'image/png') {
397 imagepalettetotruecolor($gd_img);
398 imagealphablending($gd_img, true);
399 imagesavealpha($gd_img, true);
400 }
401
402 $success = match ($fmt) {
403 'webp' => imagewebp($gd_img, $dest, $q),
404 'avif' => function_exists('imageavif') ? imageavif($gd_img, $dest, $q) : false,
405 default => false,
406 };
407
408 imagedestroy($gd_img);
409
410 if (!$success || !file_exists($dest) || !filesize($dest)) {
411 @unlink($dest);
412 error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
413 return null;
414 }
415
416 return $dest;
417 }
418
419 /**
420 * Replace original file with converted version.
421 */
422 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
423 if (!file_exists($new_path) || !filesize($new_path)) {
424 error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
425 return;
426 }
427
428 // Capture old URL before deleting so we can rewrite post content references
429 $old_url = wp_get_attachment_url($id);
430
431 @unlink($old_path);
432
433 wp_update_post([
434 'ID' => $id,
435 'post_mime_type' => "image/{$fmt}",
436 ]);
437
438 update_attached_file($id, $new_path);
439 $meta['file'] = _wp_relative_upload_path($new_path);
440
441 // Rewrite hardcoded image URLs in post content to point to the new file
442 $new_url = wp_get_attachment_url($id);
443 if ($old_url && $new_url && $old_url !== $new_url) {
444 self::rewrite_content_urls($old_url, $new_url);
445 }
446 }
447
448 /**
449 * Rewrite image URLs in all post content that references the old file path.
450 * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
451 * of hostname changes (e.g. Cloudflare tunnel rotations).
452 */
453 private static function rewrite_content_urls(string $old_url, string $new_url): void {
454 global $wpdb;
455
456 // Extract path portions to be hostname-agnostic
457 $old_path = wp_parse_url($old_url, PHP_URL_PATH);
458 $new_path = wp_parse_url($new_url, PHP_URL_PATH);
459
460 if (!$old_path || !$new_path || $old_path === $new_path) {
461 return;
462 }
463
464 $wpdb->query($wpdb->prepare(
465 "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s",
466 $old_path,
467 $new_path,
468 '%' . $wpdb->esc_like($old_path) . '%'
469 ));
470 }
471
472 // ── Instance Wrappers (Upload Hook) ──
473
474 /**
475 * Instance wrapper around static conversion method.
476 */
477 private function convert_file(string $source, string $format, int $quality): ?string {
478 return self::do_convert_file($source, $format, $quality);
479 }
480
481 /**
482 * Instance wrapper around static replace method.
483 */
484 private function replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
485 self::do_replace_original($id, $old_path, $new_path, $meta, $fmt);
486 }
487
488 /**
489 * Start output buffering on frontend to catch images from themes/builders
490 * that bypass standard WordPress image filters (e.g. Divi, Elementor).
491 */
492 public function start_output_buffer(): void {
493 if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
494 return;
495 }
496
497 if (defined('REST_REQUEST') && REST_REQUEST) {
498 return;
499 }
500
501 if (is_feed() || is_robots() || is_trackback()) {
502 return;
503 }
504
505 ob_start([$this, 'rewrite_full_html']);
506 }
507
508 /**
509 * Output buffer callback: rewrite remaining <img> tags to <picture>.
510 * Protects existing <picture>, <script>, and <noscript> blocks from rewriting.
511 */
512 public function rewrite_full_html(string $html): string {
513 if (empty($html) || stripos($html, '</html>') === false) {
514 return $html;
515 }
516
517 // Protect blocks that must not be rewritten
518 $protected = [];
519 $counter = 0;
520
521 // Existing <picture> blocks (already wrapped by filter hooks)
522 $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', function ($m) use (&$protected, &$counter) {
523 $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
524 $protected[$key] = $m[0];
525 return $key;
526 }, $html);
527
528 // <script> blocks (JSON-LD contains image URLs)
529 $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', function ($m) use (&$protected, &$counter) {
530 $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
531 $protected[$key] = $m[0];
532 return $key;
533 }, $html);
534
535 // <noscript> blocks (lazy-loading fallbacks)
536 $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', function ($m) use (&$protected, &$counter) {
537 $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
538 $protected[$key] = $m[0];
539 return $key;
540 }, $html);
541
542 // Rewrite remaining <img> tags
543 $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
544 return $this->maybe_wrap_img_tag($matches[0]);
545 }, $html);
546
547 // Restore protected blocks
548 if (!empty($protected)) {
549 $html = strtr($html, $protected);
550 }
551
552 return $html;
553 }
554
555 /**
556 * Rewrite <img> tags to <picture> with next-gen source.
557 * Used by WordPress filter hooks for content fragments.
558 */
559 public function rewrite_to_picture_tags(string $content): string {
560 if (empty($content)) {
561 return $content;
562 }
563
564 // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
565 if (strpos($content, '<picture>') !== false) {
566 return $content;
567 }
568
569 return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
570 return $this->maybe_wrap_img_tag($matches[0]);
571 }, $content);
572 }
573
574 /**
575 * Wrap a single <img> tag in <picture> with next-gen <source>.
576 * Returns the original tag unchanged if conversion is not applicable.
577 */
578 private function maybe_wrap_img_tag(string $img_tag): string {
579 if ($this->is_tag_excluded($img_tag)) {
580 return $img_tag;
581 }
582
583 if (!preg_match('/src=["\']([^"\']+)["\']/i', $img_tag, $src_match)) {
584 return $img_tag;
585 }
586
587 $original_src = $src_match[1];
588 $format = $this->settings['conversion_format'];
589 $ext = self::get_format_extension($format);
590
591 $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
592
593 if ($converted_url === $original_src) {
594 return $img_tag;
595 }
596
597 $converted_path = $this->url_to_path($converted_url);
598 if (!$converted_path || !file_exists($converted_path)) {
599 return $img_tag;
600 }
601
602 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
603
604 $source_srcset = '';
605 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
606 $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
607 $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
608 }
609
610 $sizes_attr = '';
611 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
612 $sizes_attr = sprintf(' sizes="%s"', esc_attr($sizes_match[1]));
613 }
614
615 return sprintf(
616 '<picture><source type="%s"%s%s>%s</picture>',
617 esc_attr($mime),
618 $source_srcset ?: sprintf(' srcset="%s"', esc_attr($converted_url)),
619 $sizes_attr,
620 $img_tag
621 );
622 }
623
624 /**
625 * Convert a URL to a local file path. Returns null if URL is external.
626 * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation).
627 */
628 private function url_to_path(string $url): ?string {
629 $upload_dir = wp_get_upload_dir();
630 $base_url = $upload_dir['baseurl'];
631 $base_path = $upload_dir['basedir'];
632
633 // Direct match (same hostname)
634 if (strpos($url, $base_url) === 0) {
635 return str_replace($base_url, $base_path, $url);
636 }
637
638 // Path-based fallback: match uploads path regardless of hostname
639 $base_url_path = wp_parse_url($base_url, PHP_URL_PATH);
640 $url_path = wp_parse_url($url, PHP_URL_PATH);
641
642 if ($base_url_path && $url_path && strpos($url_path, $base_url_path) === 0) {
643 $relative = substr($url_path, strlen($base_url_path));
644 return $base_path . $relative;
645 }
646
647 return null;
648 }
649
650 /**
651 * Check if a file path matches exclusion patterns.
652 */
653 private function is_excluded(string $file): bool {
654 $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
655 if (empty($exclude_urls)) {
656 return false;
657 }
658
659 foreach ($exclude_urls as $pattern) {
660 if (stripos($file, $pattern) !== false) {
661 return true;
662 }
663 }
664 return false;
665 }
666
667 /**
668 * Check if an img tag has an excluded CSS class.
669 */
670 private function is_tag_excluded(string $tag): bool {
671 $exclude_classes = array_filter(array_map('trim', explode(',', $this->settings['exclude_classes'] ?? '')));
672 if (empty($exclude_classes)) {
673 return false;
674 }
675
676 if (preg_match('/class=["\']([^"\']+)["\']/i', $tag, $class_match)) {
677 $classes = explode(' ', $class_match[1]);
678 foreach ($exclude_classes as $excluded) {
679 if (in_array($excluded, $classes, true)) {
680 return true;
681 }
682 }
683 }
684 return false;
685 }
686
687 }
688