PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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
← All changes | media-optimization/class-image-converter.php +376 -92 2.6.19 → 2.7.1 View file →
@@ -51,8 +51,40 @@
51 51 */
52 52 private static float $timer_started_at = 0.0;
53 53
54 54 /**
55 + * While a batch run is in flight, per-image cache purges are suppressed
56 + * (a thousand-image batch would otherwise trigger a thousand full-site
57 + * purges) and a single purge fires once the batch finishes. See
58 + * set_cache_purge_suppressed().
59 + */
60 + private static bool $cache_purge_suppressed = false;
61 +
62 + /**
63 + * Strip server-identifying absolute paths from log messages, keeping the
64 + * upload-relative portion that actually identifies the file.
65 + */
66 + private static function redact_path(string $path): string {
67 + $replacements = [];
68 +
69 + $upload_dir = wp_get_upload_dir();
70 + if (!empty($upload_dir['basedir'])) {
71 + $replacements[] = [$upload_dir['basedir'] . '/', 'uploads/'];
72 + }
73 + if (defined('ABSPATH')) {
74 + $replacements[] = [ABSPATH, ''];
75 + }
76 +
77 + foreach ($replacements as [$needle, $replacement]) {
78 + if (strpos($path, $needle) === 0) {
79 + return $replacement . substr($path, strlen($needle));
80 + }
81 + }
82 +
83 + return basename($path);
84 + }
85 +
86 + /**
55 87 * Get file extension for a given format.
56 88 */
57 89 private static function get_format_extension(string $format): string {
58 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
@@ -57,8 +89,43 @@
57 89 private static function get_format_extension(string $format): string {
58 90 return $format === 'avif' ? self::EXT_AVIF : self::EXT_WEBP;
59 91 }
60 92
93 + /**
94 + * Suppress (or re-enable) the per-image page-cache purge. The batch
95 + * optimizer suppresses around its ticks and purges once at completion.
96 + */
97 + public static function set_cache_purge_suppressed(bool $suppressed): void {
98 + self::$cache_purge_suppressed = $suppressed;
99 + }
100 +
101 + /**
102 + * Purge page caches after an image's on-disk filenames changed (replace
103 + * conversion or revert), so cached/edge pages cannot keep serving
104 + * references to deleted files.
105 + */
106 + public static function purge_page_caches(): void {
107 + if (self::$cache_purge_suppressed) {
108 + return;
109 + }
110 +
111 + // Indirect guard so static analysis cannot narrow the class name and
112 + // flag the check as redundant — the purger is plugin-loaded, but the
113 + // converter can be exercised standalone (tests, tooling).
114 + if (!self::class_is_available('Metasync_Cache_Purge')) {
115 + return;
116 + }
117 +
118 + Metasync_Cache_Purge::purge_all('media_optimization');
119 + }
120 +
121 + /**
122 + * @param string $class Class name to check.
123 + */
124 + private static function class_is_available(string $class): bool {
125 + return class_exists($class);
126 + }
127 +
61 128 public function __construct(array $settings) {
62 129 $this->settings = $settings;
63 130
64 131 // Fires after WP generates all thumbnail sizes
@@ -64,9 +131,9 @@
64 131 // Fires after WP generates all thumbnail sizes
65 132 add_filter('wp_generate_attachment_metadata', [$this, 'convert_on_upload'], 10, 2);
66 133
67 134 // If "alongside" strategy, rewrite <img> tags to <picture>
68 - if ($settings['conversion_strategy'] === 'alongside') {
135 + if (($settings['conversion_strategy'] ?? '') === 'alongside') {
69 136 // Core WordPress
70 137 add_filter('the_content', [$this, 'rewrite_to_picture_tags'], 20);
71 138 add_filter('post_thumbnail_html', [$this, 'rewrite_to_picture_tags'], 20);
72 139 add_filter('widget_text', [$this, 'rewrite_to_picture_tags'], 20);
@@ -150,8 +217,18 @@
150 217 if (!$file || !in_array($mime, self::SUPPORTED_MIMES, true)) {
151 218 return false;
152 219 }
153 220
221 + // The exclusion list must gate batch/bulk/single-image conversions
222 + // too, not just uploads — otherwise a bulk run converts files the
223 + // site owner explicitly asked the module to leave alone. Both the
224 + // stored path and the attachment URL are matched because exclusion
225 + // entries may be written as either.
226 + $exclusion_check = new self($settings);
227 + if ($exclusion_check->is_excluded($file) || $exclusion_check->is_url_excluded((string) wp_get_attachment_url($attachment_id))) {
228 + return false;
229 + }
230 +
154 231 // Request WordPress's image processing memory limit (typically 256MB) before heavy work
155 232 wp_raise_memory_limit('image');
156 233
157 234 // Give a heavy multi-size conversion a fresh execution budget.
@@ -195,11 +272,15 @@
195 272 }
196 273
197 274 if ($strategy === 'replace') {
198 275 update_post_meta($attachment_id, '_metasync_replaced_original', '1');
276 + // The original files are gone from disk now — cached pages still
277 + // referencing them would serve broken images until their TTL.
278 + self::purge_page_caches();
199 279 }
200 280
201 281 update_post_meta($attachment_id, '_metasync_converted_format', $format);
282 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
202 283 return true;
203 284 }
204 285
205 286 /**
@@ -256,9 +337,9 @@
256 337 // own file when the extension pattern doesn't match (e.g. the attached file is
257 338 // already .webp/.avif and preg_replace returns the path unchanged).
258 339 $converted_path = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $file);
259 340 if ($converted_path && $converted_path !== $file && file_exists($converted_path)) {
260 - @unlink($converted_path);
341 + @wp_delete_file($converted_path);
261 342 }
262 343
263 344 // Delete converted sub-sizes
264 345 $metadata = wp_get_attachment_metadata($attachment_id);
@@ -267,9 +348,9 @@
267 348 foreach ($metadata['sizes'] as $size_data) {
268 349 $size_path = $upload_dir . '/' . $size_data['file'];
269 350 $size_converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $size_path);
270 351 if ($size_converted && $size_converted !== $size_path && file_exists($size_converted)) {
271 - @unlink($size_converted);
352 + @wp_delete_file($size_converted);
272 353 }
273 354 }
274 355 }
275 356
@@ -275,8 +356,13 @@
275 356
276 357 delete_post_meta($attachment_id, '_metasync_converted_format');
277 358 delete_post_meta($attachment_id, '_metasync_original_filesize');
278 359 delete_post_meta($attachment_id, '_metasync_replaced_original');
360 +
361 + // Cached pages may still contain <picture> markup referencing the
362 + // converted files just deleted — flush them.
363 + self::purge_page_caches();
364 + Metasync_Media_Batch_Optimizer::flush_stats_cache();
279 365 return true;
280 366 }
281 367
282 368 /**
@@ -323,9 +409,9 @@
323 409 foreach ($paths as $path) {
324 410 foreach ($exts as $ext) {
325 411 $converted = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $path);
326 412 if ($converted && $converted !== $path && file_exists($converted)) {
327 - @unlink($converted);
413 + @wp_delete_file($converted);
328 414 }
329 415 }
330 416 }
331 417 }
@@ -368,9 +454,9 @@
368 454 * reset; false on hosts where set_time_limit() is disabled, in which
369 455 * case callers must rely on get_remaining_time() to bail out early.
370 456 */
371 457 protected static function reset_time_limit(): bool {
372 - if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) {
458 + if (function_exists('set_time_limit') && @set_time_limit(self::UPLOAD_TIME_LIMIT)) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running batch job; WordPress has no equivalent API
373 459 self::$timer_started_at = microtime(true);
374 460 return true;
375 461 }
376 462 return false;
@@ -434,9 +520,9 @@
434 520 // Cap the total synchronous sub-size pass so the
435 521 // per-iteration timer resets below cannot keep one request busy
436 522 // indefinitely.
437 523 if ((microtime(true) - $pass_started) >= self::UPLOAD_TIME_LIMIT) {
438 - error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name);
524 + error_log('[MetaSync Media Opt] Sub-size time budget exceeded, skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
439 525 break;
440 526 }
441 527
442 528 // Refresh the execution timer between encodes; when the host
@@ -442,9 +528,9 @@
442 528 // Refresh the execution timer between encodes; when the host
443 529 // disables set_time_limit(), bail out before PHP's limit kills
444 530 // the request.
445 531 if (!static::reset_time_limit() && static::get_remaining_time() <= 0) {
446 - error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name);
532 + error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
447 533 break;
448 534 }
449 535
450 536 // Check available memory before each sub-size conversion
@@ -449,21 +535,36 @@
449 535
450 536 // Check available memory before each sub-size conversion
451 537 $available = static::get_available_memory();
452 538 if ($available < self::MIN_MEMORY_FOR_SUBSIZE) {
453 - error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name);
539 + error_log('[MetaSync Media Opt] Low memory (' . size_format($available) . '), skipping remaining sub-sizes from: ' . $size_name); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
454 540 break;
455 541 }
456 542
457 543 $size_file = $upload_dir . '/' . $size_data['file'];
544 +
545 + // A sub-size that is already in the target format (e.g. a prior
546 + // replace run) maps onto itself: converting would rewrite the file
547 + // in place and the unlink below would then delete it. Skip.
548 + $size_ext = self::get_format_extension($format);
549 + $size_dest = preg_replace(self::ORIGINAL_EXT_PATTERN, $size_ext, $size_file);
550 + if ($size_dest === $size_file) {
551 + continue;
552 + }
553 +
458 554 $size_converted = static::do_convert_file($size_file, $format, $quality);
459 555
460 - if ($size_converted && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
461 - @unlink($size_file);
556 + if ($size_converted && $size_converted !== $size_file && $strategy === 'replace' && file_exists($size_converted) && filesize($size_converted) > 0) {
557 + // Rewrite content references BEFORE unlinking — galleries,
558 + // widgets and hardcoded <img> tags point at sub-size URLs
559 + // (photo-1024x768.jpg), and the original is unrecoverable
560 + // once deleted.
561 + self::rewrite_content_paths($size_file, $size_converted);
562 + @wp_delete_file($size_file);
462 563 $size_data['file'] = basename($size_converted);
463 564 $size_data['mime-type'] = "image/{$format}";
464 565 } elseif ($size_converted && $strategy === 'replace') {
465 - error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . $size_file);
566 + error_log('[MetaSync Media Opt] Sub-size conversion produced invalid output, original preserved: ' . self::redact_path($size_file)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
466 567 }
467 568
468 569 // Release cyclic references between sub-size conversions
469 570 gc_collect_cycles();
@@ -512,9 +613,9 @@
512 613 return null;
513 614 }
514 615
515 616 if (filesize($source) > self::MAX_CONVERT_BYTES) {
516 - error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . $source);
617 + error_log('[MetaSync Media Opt] Source file exceeds MAX_CONVERT_BYTES limit, skipping: ' . self::redact_path($source)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
517 618 return null;
518 619 }
519 620
520 621 // Bail out instead of starting an encode PHP may kill mid-write
@@ -520,9 +621,9 @@
520 621 // Bail out instead of starting an encode PHP may kill mid-write
521 622 // On hosts where set_time_limit() is disabled the request
522 623 // keeps its original cap, and the fatal reported by Sentry fired here.
523 624 if (static::get_remaining_time() <= 0) {
524 - error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source));
625 + error_log('[MetaSync Media Opt] Execution time nearly exhausted, skipping conversion: ' . basename($source)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
525 626 return null;
526 627 }
527 628
528 629 // Request WordPress's image processing memory limit
@@ -540,9 +641,9 @@
540 641 $estimated = filesize($source) * 3;
541 642 }
542 643 $available = self::get_available_memory();
543 644 if ($estimated > $available * 0.8) {
544 - error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')');
645 + error_log('[MetaSync Media Opt] Skipping ' . basename($source) . ': estimated memory (' . size_format($estimated) . ') exceeds 80% of available (' . size_format($available) . ')'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
545 646 return null;
546 647 }
547 648
548 649 // Determine target dimensions when the source exceeds the configured cap.
@@ -560,9 +661,9 @@
560 661 if ($result) {
561 662 return $result;
562 663 }
563 664 } catch (\Exception $e) {
564 - error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage());
665 + error_log('[MetaSync Media Opt] Imagick conversion failed, trying GD: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
565 666 }
566 667 }
567 668
568 669 if (extension_loaded('gd')) {
@@ -568,9 +669,9 @@
568 669 if (extension_loaded('gd')) {
569 670 try {
570 671 return self::do_convert_with_gd($source, $dest, $format, $quality, $target_dimensions);
571 672 } catch (\Exception $e) {
572 - error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage());
673 + error_log('[MetaSync Media Opt] GD conversion failed: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
573 674 }
574 675 }
575 676
576 677 return null;
@@ -593,10 +694,10 @@
593 694 $img->stripImage();
594 695
595 696 if ($img->writeImage($dest)) {
596 697 if (!file_exists($dest) || !filesize($dest)) {
597 - @unlink($dest);
598 - error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . $dest);
698 + @wp_delete_file($dest);
699 + error_log('[MetaSync Media Opt] Imagick wrote 0-byte or missing output, discarding: ' . self::redact_path($dest)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
599 700 $img->destroy();
600 701 return null;
601 702 }
602 703 $img->destroy();
@@ -662,10 +763,10 @@
662 763
663 764 imagedestroy($gd_img);
664 765
665 766 if (!$success || !file_exists($dest) || !filesize($dest)) {
666 - @unlink($dest);
667 - error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . $dest);
767 + @wp_delete_file($dest);
768 + error_log('[MetaSync Media Opt] GD produced empty or missing output, discarding: ' . self::redact_path($dest)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
668 769 return null;
669 770 }
670 771
671 772 return $dest;
@@ -675,17 +776,16 @@
675 776 * Replace original file with converted version.
676 777 */
677 778 private static function do_replace_original(int $id, string $old_path, string $new_path, array &$meta, string $fmt): void {
678 779 if (!file_exists($new_path) || !filesize($new_path)) {
679 - error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . $old_path);
780 + error_log('[MetaSync Media Opt] Converted file is missing or empty, original preserved: ' . self::redact_path($old_path)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
680 781 return;
681 782 }
682 783
683 - // Capture old URL before deleting so we can rewrite post content references
684 - $old_url = wp_get_attachment_url($id);
685 -
686 - @unlink($old_path);
687 -
784 + // Update the DB pointers and rewrite post content FIRST — only once
785 + // every content reference points at the new file is it safe to unlink
786 + // the original. Deleting first left a window where a failed rewrite
787 + // meant permanent 404s with no original left to fall back to.
688 788 wp_update_post([
689 789 'ID' => $id,
690 790 'post_mime_type' => "image/{$fmt}",
691 791 ]);
@@ -701,42 +801,92 @@
701 801 $meta['width'] = (int) $new_dims[0];
702 802 $meta['height'] = (int) $new_dims[1];
703 803 }
704 804
705 - // Rewrite hardcoded image URLs in post content to point to the new file
706 - $new_url = wp_get_attachment_url($id);
707 - if ($old_url && $new_url && $old_url !== $new_url) {
708 - self::rewrite_content_urls($old_url, $new_url);
805 + // Rewrite hardcoded image URLs in post content to point to the new file.
806 + // Path-based, so it matches regardless of the hostname in the stored URL.
807 + self::rewrite_content_paths($old_path, $new_path);
808 +
809 + // Content now points at the converted file — the original is redundant.
810 + @wp_delete_file($old_path);
811 + }
812 +
813 + /**
814 + * Rewrite image references in all post content from one upload file to
815 + * another.
816 + *
817 + * Works on the URL path portion (e.g. /wp-content/uploads/2026/08/photo.jpg)
818 + * derived from the absolute file paths, so rewrites are hostname-agnostic
819 + * (surviving e.g. Cloudflare tunnel rotations) and can be driven by the
820 + * file paths the converter already has — no need for the attachment URL,
821 + * which is only trustworthy before the DB pointer flips.
822 + *
823 + * References with URL-encoded special characters (e.g. "my%20photo.jpg")
824 + * never match the raw path, so the encoded variant is rewritten too when
825 + * it differs.
826 + *
827 + * @param string $old_abspath Absolute path of the file being replaced.
828 + * @param string $new_abspath Absolute path of its replacement.
829 + */
830 + private static function rewrite_content_paths(string $old_abspath, string $new_abspath): void {
831 + $old_path = self::abspath_to_url_path($old_abspath);
832 + $new_path = self::abspath_to_url_path($new_abspath);
833 +
834 + if (!$old_path || !$new_path || $old_path === $new_path) {
835 + return;
709 836 }
837 +
838 + self::rewrite_content_path_pair($old_path, $new_path);
839 +
840 + // Also rewrite URL-encoded references ("photo%20name-300x200.jpg")
841 + // when encoding changes the string.
842 + $old_encoded = implode('/', array_map('rawurlencode', explode('/', $old_path)));
843 + $new_encoded = implode('/', array_map('rawurlencode', explode('/', $new_path)));
844 + if ($old_encoded !== $old_path) {
845 + self::rewrite_content_path_pair($old_encoded, $new_encoded);
846 + }
710 847 }
711 848
712 849 /**
713 - * Rewrite image URLs in all post content that references the old file path.
714 - * Uses the path portion (e.g. /wp-content/uploads/…) so it works regardless
715 - * of hostname changes (e.g. Cloudflare tunnel rotations).
850 + * Map an absolute upload file path to its URL path portion
851 + * (e.g. /wp-content/uploads/2026/08/photo.jpg). Returns null when the
852 + * file lives outside the uploads directory.
716 853 */
717 - private static function rewrite_content_urls(string $old_url, string $new_url): void {
718 - global $wpdb;
854 + private static function abspath_to_url_path(string $abspath): ?string {
855 + $upload_dir = wp_get_upload_dir();
856 + $base_path = $base_url_path = null;
719 857
720 - // Extract path portions to be hostname-agnostic
721 - $old_path = wp_parse_url($old_url, PHP_URL_PATH);
722 - $new_path = wp_parse_url($new_url, PHP_URL_PATH);
858 + if (strpos($abspath, $upload_dir['basedir']) === 0) {
859 + $base_path = $upload_dir['basedir'];
860 + $base_url_path = wp_parse_url($upload_dir['baseurl'], PHP_URL_PATH);
861 + }
723 862
724 - if (!$old_path || !$new_path || $old_path === $new_path) {
725 - return;
863 + if (!$base_path || !is_string($base_url_path) || $base_url_path === '') {
864 + return null;
726 865 }
727 866
728 - // Batch the UPDATE so a large wp_posts table is never locked by a single
729 - // unbounded REPLACE. Each batch only rewrites rows that still
730 - // contain the old path; once replaced they no longer match the LIKE, so
731 - // the loop converges. The batch ceiling is a safety net against an
732 - // unexpected non-converging loop (e.g. a DB-level error returning false).
867 + $relative = substr($abspath, strlen($base_path));
868 + return rtrim($base_url_path, '/') . '/' . ltrim($relative, '/');
869 + }
870 +
871 + /**
872 + * Batched REPLACE of one URL path for another across wp_posts.
873 + *
874 + * Batching keeps a large wp_posts table from being locked by a single
875 + * unbounded REPLACE. Each batch only rewrites rows that still contain the
876 + * old path; once replaced they no longer match the LIKE, so the loop
877 + * converges. The batch ceiling is a safety net against an unexpected
878 + * non-converging loop (e.g. a DB-level error returning false).
879 + */
880 + private static function rewrite_content_path_pair(string $old_path, string $new_path): void {
881 + global $wpdb;
882 +
733 883 $batch_size = 500;
734 884 $like = '%' . $wpdb->esc_like($old_path) . '%';
735 885 $max_batches = 100000;
736 886
737 887 for ($batch = 0; $batch < $max_batches; $batch++) {
738 - $affected = $wpdb->query($wpdb->prepare(
888 + $affected = $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- batched bulk post_content rewrite — no bulk WordPress API; chunked by LIMIT to bound lock time
739 889 "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)
740 890 WHERE post_content LIKE %s ORDER BY ID LIMIT %d",
741 891 $old_path,
742 892 $new_path,
@@ -792,48 +942,23 @@
792 942 * Output buffer callback: rewrite remaining <img> tags to <picture>.
793 943 * Protects existing <picture>, <script>, and <noscript> blocks from rewriting.
794 944 */
795 945 public function rewrite_full_html(string $html): string {
796 - if (empty($html) || stripos($html, '</html>') === false) {
946 + if (empty($html) || stripos($html, '</html>') === false || $this->is_amp_request()) {
797 947 return $html;
798 948 }
799 949
800 - // Protect blocks that must not be rewritten
801 - $protected = [];
802 - $counter = 0;
950 + // Protect blocks that must not be rewritten (shared with the
951 + // content-filter path): existing <picture>, <script> (JSON-LD holds
952 + // image URLs), and <noscript> (lazy-loading fallbacks).
953 + [$html, $protected] = $this->protect_rewritable_blocks($html);
803 954
804 - // Existing <picture> blocks (already wrapped by filter hooks)
805 - $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', function ($m) use (&$protected, &$counter) {
806 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
807 - $protected[$key] = $m[0];
808 - return $key;
809 - }, $html);
810 -
811 - // <script> blocks (JSON-LD contains image URLs)
812 - $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', function ($m) use (&$protected, &$counter) {
813 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
814 - $protected[$key] = $m[0];
815 - return $key;
816 - }, $html);
817 -
818 - // <noscript> blocks (lazy-loading fallbacks)
819 - $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', function ($m) use (&$protected, &$counter) {
820 - $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
821 - $protected[$key] = $m[0];
822 - return $key;
823 - }, $html);
824 -
825 955 // Rewrite remaining <img> tags
826 956 $html = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
827 957 return $this->maybe_wrap_img_tag($matches[0]);
828 958 }, $html);
829 959
830 - // Restore protected blocks
831 - if (!empty($protected)) {
832 - $html = strtr($html, $protected);
833 - }
834 -
835 - return $html;
960 + return $this->restore_protected_blocks($html, $protected);
836 961 }
837 962
838 963 /**
839 964 * Rewrite <img> tags to <picture> with next-gen source.
@@ -839,23 +964,79 @@
839 964 * Rewrite <img> tags to <picture> with next-gen source.
840 965 * Used by WordPress filter hooks for content fragments.
841 966 */
842 967 public function rewrite_to_picture_tags(string $content): string {
843 - if (empty($content)) {
968 + if (empty($content) || is_feed() || $this->is_amp_request()) {
844 969 return $content;
845 970 }
846 971
847 - // Skip if already wrapped in <picture> (avoid double-wrapping from multiple filters)
848 - if (strpos($content, '<picture>') !== false) {
849 - return $content;
850 - }
972 + // Existing <picture> blocks (attributed or not) are protected per
973 + // block below; a single earlier wrap no longer disables rewriting
974 + // for the whole fragment, and <img>s inside script/noscript blocks
975 + // are never touched.
976 + [$content, $protected] = $this->protect_rewritable_blocks($content);
851 977
852 - return preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
978 + $content = preg_replace_callback('/<img\s[^>]+>/i', function ($matches) {
853 979 return $this->maybe_wrap_img_tag($matches[0]);
854 980 }, $content);
981 +
982 + return $this->restore_protected_blocks($content, $protected);
855 983 }
856 984
857 985 /**
986 + * Extract blocks that must never be rewritten (existing <picture>,
987 + * <script>, <noscript>) into placeholders so the <img> rewrite cannot
988 + * reach inside them.
989 + *
990 + * @return array{0:string, 1:array<string, string>} Rewritten HTML and placeholder map.
991 + */
992 + private function protect_rewritable_blocks(string $html): array {
993 + $protected = [];
994 + $counter = 0;
995 + $extract = function ($m) use (&$protected, &$counter) {
996 + $key = '<!--METASYNC_PROTECTED_' . $counter++ . '-->';
997 + $protected[$key] = $m[0];
998 + return $key;
999 + };
1000 +
1001 + $html = preg_replace_callback('/<picture\b[^>]*>.*?<\/picture>/is', $extract, $html);
1002 + $html = preg_replace_callback('/<script\b[^>]*>.*?<\/script>/is', $extract, $html);
1003 + $html = preg_replace_callback('/<noscript\b[^>]*>.*?<\/noscript>/is', $extract, $html);
1004 +
1005 + return [$html, $protected];
1006 + }
1007 +
1008 + /**
1009 + * Restore blocks extracted by protect_rewritable_blocks().
1010 + */
1011 + private function restore_protected_blocks(string $html, array $protected): string {
1012 + if (empty($protected)) {
1013 + return $html;
1014 + }
1015 + return strtr($html, $protected);
1016 + }
1017 +
1018 + /**
1019 + * True when the current request renders an AMP page. The AMP runtime
1020 + * validates markup, so the <picture> wrapper is skipped there. The
1021 + * helper indirection keeps static analysis from assuming the AMP
1022 + * plugin's functions always exist.
1023 + */
1024 + private function is_amp_request(): bool {
1025 + if (self::func_is_available('amp_is_request')) {
1026 + return (bool) amp_is_request();
1027 + }
1028 + if (self::func_is_available('is_amp_endpoint')) {
1029 + return (bool) is_amp_endpoint();
1030 + }
1031 + return false;
1032 + }
1033 +
1034 + private static function func_is_available(string $function): bool {
1035 + return function_exists($function);
1036 + }
1037 +
1038 + /**
858 1039 * Wrap a single <img> tag in <picture> with next-gen <source>.
859 1040 * Returns the original tag unchanged if conversion is not applicable.
860 1041 */
861 1042 private function maybe_wrap_img_tag(string $img_tag): string {
@@ -867,28 +1048,29 @@
867 1048 return $img_tag;
868 1049 }
869 1050
870 1051 $original_src = $src_match[1];
871 - $format = $this->settings['conversion_format'];
872 - $ext = self::get_format_extension($format);
873 1052
874 - $converted_url = preg_replace(self::ORIGINAL_EXT_PATTERN, $ext, $original_src);
875 -
876 - if ($converted_url === $original_src) {
1053 + // Exclusion entries may target the URL (full URL or path fragment).
1054 + if ($this->is_url_excluded($original_src)) {
877 1055 return $img_tag;
878 1056 }
879 1057
880 - $converted_path = $this->url_to_path($converted_url);
881 - if (!$converted_path || !file_exists($converted_path)) {
1058 + // Serve whichever converted file actually exists: the current format
1059 + // first, then the other one. This keeps legacy conversions alive when
1060 + // the configured format flips (webp → avif or back) instead of
1061 + // silently orphaning every previously converted image.
1062 + $resolved = $this->resolve_converted_url($original_src);
1063 + if (!$resolved) {
882 1064 return $img_tag;
883 1065 }
884 1066
1067 + [$converted_url, $format] = $resolved;
885 1068 $mime = $format === 'avif' ? 'image/avif' : 'image/webp';
886 1069
887 1070 $source_srcset = '';
888 1071 if (preg_match('/srcset=["\']([^"\']+)["\']/i', $img_tag, $srcset_match)) {
889 - $converted_srcset = preg_replace('/\.(jpe?g|png)/i', $ext, $srcset_match[1]);
890 - $source_srcset = sprintf(' srcset="%s"', esc_attr($converted_srcset));
1072 + $source_srcset = sprintf(' srcset="%s"', esc_attr($this->resolve_srcset_candidates($srcset_match[1])));
891 1073 }
892 1074
893 1075 $sizes_attr = '';
894 1076 if (preg_match('/sizes=["\']([^"\']+)["\']/i', $img_tag, $sizes_match)) {
@@ -904,8 +1086,79 @@
904 1086 );
905 1087 }
906 1088
907 1089 /**
1090 + * Resolve the converted counterpart of an image URL.
1091 + *
1092 + * Tries the currently configured format first, then the other format, and
1093 + * only returns a URL whose file provably exists on disk. Null means "no
1094 + * converted file for this URL" — the caller leaves the tag untouched.
1095 + *
1096 + * @return array{0:string,1:string}|null [converted URL, format] or null.
1097 + */
1098 + private function resolve_converted_url(string $url): ?array {
1099 + $current = $this->settings['conversion_format'] ?? 'webp';
1100 + $formats = [$current];
1101 + $other = $current === 'webp' ? 'avif' : 'webp';
1102 + $formats[] = $other;
1103 +
1104 + foreach ($formats as $format) {
1105 + $candidate = preg_replace(self::ORIGINAL_EXT_PATTERN, self::get_format_extension($format), $url);
1106 +
1107 + if ($candidate === null || $candidate === $url) {
1108 + continue;
1109 + }
1110 +
1111 + $candidate_path = $this->url_to_path($candidate);
1112 + if ($candidate_path && file_exists($candidate_path)) {
1113 + return [$candidate, $format];
1114 + }
1115 + }
1116 +
1117 + return null;
1118 + }
1119 +
1120 + /**
1121 + * Build the <source> srcset with per-candidate file-existence checks.
1122 + *
1123 + * A srcset whose candidates were blindly extension-swapped serves 404s
1124 + * whenever a sub-size was never converted (conversion of sub-sizes off, a
1125 + * size added after conversion, a format switch) — and browsers do not fall
1126 + * back to the <img> when a <source> candidate fails to fetch. Candidates
1127 + * without a converted file on disk keep their ORIGINAL URL instead, which
1128 + * still loads, just unoptimized. When no candidate has a converted file
1129 + * the original srcset is returned unchanged.
1130 + */
1131 + private function resolve_srcset_candidates(string $srcset): string {
1132 + $candidates = preg_split('/\s*,\s*/', trim($srcset));
1133 + if (!$candidates) {
1134 + return $srcset;
1135 + }
1136 +
1137 + $any_converted = false;
1138 + $out = [];
1139 +
1140 + foreach ($candidates as $candidate) {
1141 + // Candidate is "URL [descriptor]" — rewrite only the URL part.
1142 + if (!preg_match('/^(\S+)(.*)$/', $candidate, $parts)) {
1143 + $out[] = $candidate;
1144 + continue;
1145 + }
1146 +
1147 + $resolved = $this->resolve_converted_url($parts[1]);
1148 + if ($resolved) {
1149 + $any_converted = true;
1150 + $out[] = $resolved[0] . $parts[2];
1151 + } else {
1152 + // Keep the original URL so this descriptor still resolves.
1153 + $out[] = $candidate;
1154 + }
1155 + }
1156 +
1157 + return $any_converted ? implode(', ', $out) : $srcset;
1158 + }
1159 +
1160 + /**
908 1161 * Convert a URL to a local file path. Returns null if URL is external.
909 1162 * Falls back to path-portion matching when hostnames differ (e.g. Cloudflare tunnel rotation).
910 1163 */
911 1164 private function url_to_path(string $url): ?string {
@@ -912,8 +1165,14 @@
912 1165 $upload_dir = wp_get_upload_dir();
913 1166 $base_url = $upload_dir['baseurl'];
914 1167 $base_path = $upload_dir['basedir'];
915 1168
1169 + // Filenames with URL-encoded characters (spaces, accents, UTF-8)
1170 + // must be decoded before mapping to a disk path — the existence
1171 + // checks that gate serving silently reject them otherwise and a
1172 + // successfully converted file is never served.
1173 + $url = rawurldecode($url);
1174 +
916 1175 // Direct match (same hostname)
917 1176 if (strpos($url, $base_url) === 0) {
918 1177 return str_replace($base_url, $base_path, $url);
919 1178 }
@@ -941,8 +1200,33 @@
941 1200
942 1201 foreach ($exclude_urls as $pattern) {
943 1202 if (stripos($file, $pattern) !== false) {
944 1203 return true;
1204 + }
1205 + }
1206 + return false;
1207 + }
1208 +
1209 + /**
1210 + * Check if a URL matches the URL-based exclusion patterns.
1211 + *
1212 + * Entries may be full URLs (scheme + host) or bare path fragments; the
1213 + * full-URL form never matches the path-only check in is_excluded(), so
1214 + * both the raw URL and its path portion are tested here.
1215 + */
1216 + private function is_url_excluded(string $url): bool {
1217 + $exclude_urls = array_filter(array_map('trim', explode(',', $this->settings['exclude_urls'] ?? '')));
1218 + if (empty($exclude_urls)) {
1219 + return false;
1220 + }
1221 +
1222 + $candidates = [$url, (string) wp_parse_url($url, PHP_URL_PATH)];
1223 +
1224 + foreach ($exclude_urls as $pattern) {
1225 + foreach ($candidates as $candidate) {
1226 + if ($candidate !== '' && stripos($candidate, $pattern) !== false) {
1227 + return true;
1228 + }
945 1229 }
946 1230 }
947 1231 return false;
948 1232 }