PluginProbe
WebberZone Top 10 — Popular Posts / 4.5.0
WebberZone Top 10 — Popular Posts v4.5.0
4.5.2 4.5.1 4.5.0 4.4.3 4.4.2 4.4.1 4.4.0 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 trunk 1.0 1.0.1 1.1 1.2 1.3 1.4 1.4.1 1.5 1.5.1 1.5.2 1.5.3 1.6 All 118 releases
top-10 / includes / frontend / class-media-handler.php

class-media-handler.php in WebberZone Top 10 — Popular Posts 4.5.0, at includes/frontend/class-media-handler.php

1,189 lines 37.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Media handler
4 *
5 * @package WebberZone\Top_Ten\Frontend
6 */
7
8 namespace WebberZone\Top_Ten\Frontend;
9
10 if ( ! defined( 'WPINC' ) ) {
11 die;
12 }
13
14 /**
15 * Media Handler class.
16 *
17 * @since 3.5.0
18 */
19 class Media_Handler {
20
21 /**
22 * Prefix.
23 *
24 * @var string $prefix Prefix.
25 */
26 protected static $prefix = 'tptn';
27
28 /**
29 * Default thumbnail URL.
30 *
31 * @var string
32 */
33 protected static $default_thumb_url = TOP_TEN_DEFAULT_THUMBNAIL_URL;
34
35 /**
36 * Posts currently being processed to prevent infinite recursion.
37 *
38 * @var array
39 */
40 private static $processing_ids = array();
41
42 /**
43 * Retrieve a plugin option value.
44 *
45 * Calls {@see tptn_get_option()} directly. Subclasses for other plugins
46 * should override this method to point at their own options function.
47 *
48 * @param string $key Option key.
49 * @param mixed $fallback Default value if option is not set.
50 * @return mixed Option value.
51 */
52 protected static function get_option( string $key, $fallback = null ) {
53 return tptn_get_option( $key, $fallback );
54 }
55
56 /**
57 * Add custom image size of thumbnail. Filters `init`.
58 */
59 public static function add_image_sizes() {
60 if ( ! self::get_option( 'thumb_create_sizes' ) ) {
61 return;
62 }
63 $thumb_size = self::get_option( 'thumb_size' );
64 $thumb_size_name = self::$prefix . '_thumbnail';
65
66 if ( ! in_array( $thumb_size, get_intermediate_image_sizes() ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
67 $thumb_size = $thumb_size_name;
68 }
69
70 // Add image sizes if $thumb_size_name is selected or the selected thumbnail size is no longer valid.
71 if ( $thumb_size_name === $thumb_size ) {
72 $width = self::get_option( 'thumb_width', 150 );
73 $height = self::get_option( 'thumb_height', 150 );
74 $crop = self::get_option( 'thumb_crop', true );
75
76 add_image_size( $thumb_size_name, $width, $height, $crop );
77 }
78 }
79
80 /**
81 * Function to get the post thumbnail.
82 *
83 * @param string|array $args {
84 * Optional. Array or string of Query parameters.
85 *
86 * @type int|\WP_Post $post Post ID or \WP_Post object.
87 * @type string $size Thumbnail size. Should be a pre-defined image size.
88 * @type string $thumb_meta Meta field that is used to store the location of default thumbnail image.
89 * @type string $acf_field ACF field name (Image or Text field) to use as thumbnail source.
90 * @type string $thumb_html Accepted arguments are `html` or `css`.
91 * @type string $thumb_default Default thumbnail image.
92 * @type bool $thumb_default_show Show default thumb if none found.
93 * @type int $scan_images Get related posts for a specific post ID.
94 * @type string $class Class of the thumbnail.
95 * }
96 * @return string Image tag
97 */
98 public static function get_the_post_thumbnail( $args = array() ) {
99 $defaults = array(
100 'post' => '',
101 'size' => 'thumbnail',
102 'thumb_meta' => 'post-image',
103 'acf_field' => '',
104 'thumb_html' => 'html',
105 'thumb_default' => self::get_option( 'thumb_default', '' ),
106 'thumb_default_show' => true,
107 'scan_images' => true,
108 'use_site_icon' => true,
109 'class' => self::$prefix . '_thumb',
110 'style' => '',
111 );
112
113 // Parse incomming $args into an array and merge it with $defaults.
114 $args = wp_parse_args( $args, $defaults );
115
116 $result = get_post( $args['post'] );
117
118 if ( empty( $result ) ) {
119 return '';
120 }
121
122 // Recursion protection - prevent infinite loops when shortcodes trigger nested thumbnail generation.
123 if ( isset( self::$processing_ids[ $result->ID ] ) ) {
124 return '';
125 }
126 self::$processing_ids[ $result->ID ] = true;
127
128 try {
129
130 if ( is_string( $args['size'] ) ) {
131 list( $args['thumb_width'], $args['thumb_height'] ) = self::get_thumb_size( $args['size'] );
132 } else {
133 $args['thumb_width'] = $args['size'][0];
134 $args['thumb_height'] = $args['size'][1];
135 $args['size'] = self::get_appropriate_image_size( $args['size'][0], $args['size'][1] );
136 }
137
138 $post_title = esc_attr( $result->post_title );
139
140 $output = '';
141 $postimage = '';
142 $pick = '';
143 $attachment_id = 0;
144 $extracted_alt = '';
145 $alt = '';
146
147 $strategies = array(
148 fn() => self::get_thumbnail_from_meta( $result, $args ),
149 fn() => self::get_thumbnail_from_acf( $result, $args ),
150 fn() => self::get_thumbnail_from_fifu( $result ),
151 fn() => self::get_thumbnail_from_featured_image( $result, $args ),
152 fn() => self::get_thumbnail_from_content_scan( $result, $args ),
153 fn() => self::get_thumbnail_from_first_child( $result, $args ),
154 fn() => self::get_thumbnail_from_video_meta( $result ),
155 fn() => self::get_thumbnail_from_default_thumb( $args ),
156 fn() => self::get_thumbnail_from_site_icon( $args ),
157 );
158
159 foreach ( $strategies as $strategy ) {
160 $thumb = $strategy();
161 if ( ! empty( $thumb['postimage'] ) ) {
162 $postimage = $thumb['postimage'];
163 $attachment_id = $thumb['attachment_id'];
164 $pick = $thumb['pick'];
165 $extracted_alt = $thumb['extracted_alt'];
166 break;
167 }
168 }
169
170 // Hopefully, we've found a thumbnail by now. If so, run it through the custom filter, check for SSL and create the image tag.
171 if ( $postimage ) {
172
173 /**
174 * Filters the thumbnail image URL.
175 *
176 * Use this filter to modify the thumbnail URL that is automatically created
177 * Before v2.1 this was used for cropping the post image using timthumb
178 *
179 * @param string $postimage URL of the thumbnail image
180 * @param array $args Arguments array.
181 * @param \WP_Post $result Post Object
182 */
183 $postimage = apply_filters( self::$prefix . '_thumb_url', $postimage, $args, $result );
184
185 if ( is_ssl() ) {
186 $postimage = preg_replace( '~http://~', 'https://', $postimage );
187 }
188
189 $class = self::$prefix . "_{$pick} {$args['class']} {$args['size']}";
190
191 if ( empty( $attachment_id ) && ! in_array( $pick, array( 'video_thumb', 'default_thumb', 'site_icon_max', 'site_icon_min', 'fifu', 'acf', 'acfcorrect' ), true ) ) {
192 $attachment_id = self::get_cached_attachment_id( $postimage );
193 }
194
195 /**
196 * Flag to use the image's alt text as the thumbnail alt text.
197 *
198 * @param bool $use_image_alt Flag to use the image's alt text as the thumbnail alt text.
199 */
200 $use_image_alt = apply_filters( self::$prefix . '_thumb_use_image_alt', true );
201
202 /**
203 * Flag to use the post title as the thumbnail alt text if no alt text is found.
204 *
205 * @param bool $alt_fallback Flag to use the post title as the thumbnail alt text if no alt text is found.
206 */
207 $alt_fallback = apply_filters( self::$prefix . '_thumb_alt_fallback_post_title', true );
208
209 if ( ! empty( $attachment_id ) && $use_image_alt ) {
210 $alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
211 }
212
213 if ( empty( $alt ) && $extracted_alt ) {
214 $alt = $extracted_alt;
215 }
216
217 // If empty alt then try to get the title of the attachment.
218 if ( empty( $alt ) && ! empty( $attachment_id ) ) {
219 $alt = get_post_field( 'post_title', $attachment_id );
220 }
221
222 if ( empty( $alt ) ) {
223 $alt = $alt_fallback ? $post_title : '';
224 }
225
226 /**
227 * Filters the thumbnail styles attribute.
228 *
229 * @param string $styles Thumbnail styles
230 */
231 $attr['style'] = apply_filters( self::$prefix . '_thumb_styles', $args['style'] );
232
233 /**
234 * Filters the thumbnail classes and allows a filter function to add any more classes if needed.
235 *
236 * @param string $class Thumbnail Class
237 */
238 $attr['class'] = apply_filters( self::$prefix . '_thumb_class', $class );
239
240 /**
241 * Filters the thumbnail alt.
242 *
243 * @param string $alt Thumbnail alt attribute
244 */
245 $attr['alt'] = apply_filters( self::$prefix . '_thumb_alt', $alt );
246
247 /**
248 * Filters the thumbnail title.
249 *
250 * @param string $post_title Thumbnail title attribute
251 */
252 $attr['title'] = apply_filters( self::$prefix . '_thumb_title', $post_title );
253
254 $attr['thumb_html'] = $args['thumb_html'];
255 $attr['thumb_width'] = $args['thumb_width'];
256 $attr['thumb_height'] = $args['thumb_height'];
257
258 $output .= self::get_image_html( $postimage, $attr, $attachment_id, $args['size'] );
259
260 if ( function_exists( 'wp_img_tag_add_srcset_and_sizes_attr' ) && ! empty( $attachment_id ) ) {
261 $output = \wp_img_tag_add_srcset_and_sizes_attr( $output, $args['size'], $attachment_id );
262 }
263
264 if ( function_exists( 'wp_img_tag_add_loading_optimization_attrs' ) ) {
265 $output = \wp_img_tag_add_loading_optimization_attrs( $output, self::$prefix . '_thumbnail' );
266 }
267 }
268
269 /**
270 * Filters post thumbnail HTML.
271 *
272 * @param string $output HTML output.
273 * @param array $args Argument list
274 * @param string $postimage Thumbnail URL
275 */
276 return apply_filters( self::$prefix . '_get_the_post_thumbnail', $output, $args, $postimage );
277
278 } finally {
279 // Clean up recursion protection - guaranteed to run even if exceptions occur.
280 unset( self::$processing_ids[ $result->ID ] );
281 }
282 }
283
284 /**
285 * Resize external image when attachment ID is not found.
286 *
287 * @param string $image_url Original image URL.
288 * @param string $size Target image size.
289 * @return string|false Resized image URL or false on failure.
290 */
291 private static function resize_external_image( $image_url, $size ) {
292 // Check if this is a local image that can be resized.
293 $upload_dir = wp_upload_dir();
294 if ( empty( $upload_dir['baseurl'] ) ) {
295 return false;
296 }
297 $base_url = $upload_dir['baseurl'];
298
299 // Only attempt resizing for local uploads directory images.
300 if ( strpos( $image_url, $base_url ) !== 0 ) {
301 return false;
302 }
303
304 // Strip any existing size suffix to get the base/original image URL.
305 $base_image_url = self::get_base_image_url( $image_url );
306
307 // Convert URL to file path.
308 $image_path = str_replace( $base_url, $upload_dir['basedir'], $base_image_url );
309 $image_path = urldecode( $image_path ); // Handle URL-encoded characters (spaces, special chars).
310
311 // If base image doesn't exist, try the original URL path (might be the actual original).
312 if ( ! file_exists( $image_path ) ) {
313 $image_path = str_replace( $base_url, $upload_dir['basedir'], $image_url );
314 $image_path = urldecode( $image_path );
315
316 if ( ! file_exists( $image_path ) ) {
317 return false;
318 }
319 }
320
321 // Security: Validate path stays within uploads directory (after confirming file exists).
322 $real_image_path = realpath( $image_path );
323 $real_upload_dir = realpath( $upload_dir['basedir'] );
324
325 if ( false === $real_image_path || false === $real_upload_dir ||
326 0 !== strpos( $real_image_path, $real_upload_dir ) ) {
327 return false;
328 }
329 $image_path = $real_image_path;
330
331 // Get image dimensions for the target size.
332 $dimensions = self::get_thumb_size( $size );
333 $width = $dimensions[0];
334 $height = $dimensions[1];
335
336 // Generate resized filename from the BASE image (without any size suffix).
337 $path_info = pathinfo( $image_path );
338
339 // Strip any existing size suffix from the filename to ensure clean base name.
340 $base_filename = preg_replace( '/-\d+x\d+$/', '', $path_info['filename'] );
341 $resized_filename = $base_filename . "-{$width}x{$height}." . $path_info['extension'];
342 $resized_path = $path_info['dirname'] . '/' . $resized_filename;
343 $resized_url = str_replace( $upload_dir['basedir'], $upload_dir['baseurl'], $resized_path );
344
345 // Security: Validate resized output path stays within uploads directory.
346 $resized_dir = dirname( $resized_path );
347 $real_resized_dir = realpath( $resized_dir );
348 if ( false === $real_resized_dir ||
349 0 !== strpos( $real_resized_dir, $real_upload_dir ) ) {
350 return false;
351 }
352
353 // Return existing resized image if it exists.
354 if ( file_exists( $resized_path ) ) {
355 return $resized_url;
356 }
357
358 // Attempt to create resized image from the original/base image.
359 $image_editor = wp_get_image_editor( $image_path );
360 if ( is_wp_error( $image_editor ) ) {
361 return false;
362 }
363
364 // Security: Check original image dimensions to prevent memory exhaustion.
365 $original_size = $image_editor->get_size();
366
367 // Reject images larger than 10000x10000 pixels (adjustable via filter).
368 $max_dimension = apply_filters( self::$prefix . '_max_image_dimension', 10000 );
369 if ( $original_size['width'] > $max_dimension || $original_size['height'] > $max_dimension ) {
370 return false;
371 }
372
373 $resized = $image_editor->resize( $width, $height, true );
374 if ( is_wp_error( $resized ) ) {
375 return false;
376 }
377
378 $saved = $image_editor->save( $resized_path );
379 if ( is_wp_error( $saved ) ) {
380 return false;
381 }
382
383 return $resized_url;
384 }
385
386 /**
387 * Resolve a thumbnail from an ACF Image or Text field.
388 *
389 * Handles all three ACF Image field return formats (Image Array, Image ID,
390 * Image URL) as well as a plain Text field returning a URL string.
391 *
392 * @param string $acf_field ACF field name.
393 * @param int $post_id Post ID to retrieve the field value from.
394 * @param string $size Registered image size slug.
395 * @return array {
396 * @type string $postimage Image URL, or empty string if none found.
397 * @type int $attachment_id Attachment ID when resolvable, otherwise 0.
398 * @type string $pick Source identifier used for CSS class generation.
399 * }
400 */
401 protected static function get_acf_thumbnail( string $acf_field, int $post_id, string $size ): array {
402 $result = array(
403 'postimage' => '',
404 'attachment_id' => 0,
405 'pick' => '',
406 );
407
408 $acf_value = get_field( $acf_field, $post_id );
409
410 if ( empty( $acf_value ) ) {
411 return $result;
412 }
413
414 if ( is_array( $acf_value ) ) {
415 // Image Array return format — prefer the attachment ID for full srcset support.
416 if ( ! empty( $acf_value['id'] ) ) {
417 $attachment_id = (int) $acf_value['id'];
418 $postthumb = wp_get_attachment_image_src( $attachment_id, $size );
419 if ( false !== $postthumb ) {
420 $result['postimage'] = $postthumb[0];
421 $result['attachment_id'] = $attachment_id;
422 $result['pick'] = 'acf';
423 }
424 } elseif ( ! empty( $acf_value['url'] ) ) {
425 $validated = filter_var( $acf_value['url'], FILTER_VALIDATE_URL );
426 if ( $validated ) {
427 $result['postimage'] = $validated;
428 $result['pick'] = 'acf';
429 }
430 }
431 } elseif ( is_numeric( $acf_value ) ) {
432 // Image ID return format.
433 $attachment_id = (int) $acf_value;
434 $postthumb = wp_get_attachment_image_src( $attachment_id, $size );
435 if ( false !== $postthumb ) {
436 $result['postimage'] = $postthumb[0];
437 $result['attachment_id'] = $attachment_id;
438 $result['pick'] = 'acf';
439 }
440 } else {
441 // Image URL return format, or a Text field containing a URL.
442 $validated = filter_var( $acf_value, FILTER_VALIDATE_URL );
443 if ( $validated ) {
444 $attachment_id = self::get_cached_attachment_id( $validated );
445 $postthumb = wp_get_attachment_image_src( $attachment_id, $size );
446
447 $result['postimage'] = $postthumb ? $postthumb[0] : $validated;
448 $result['attachment_id'] = $attachment_id;
449 $result['pick'] = $postthumb ? 'acfcorrect' : 'acf';
450 }
451 }
452
453 return $result;
454 }
455
456 /**
457 * Get thumbnail from the post meta field.
458 *
459 * @param \WP_Post $post Post object.
460 * @param array $args Arguments array.
461 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
462 */
463 protected static function get_thumbnail_from_meta( \WP_Post $post, array $args ): array {
464 $postimage = get_post_meta( $post->ID, $args['thumb_meta'], true );
465 $postimage = filter_var( $postimage, FILTER_VALIDATE_URL );
466 if ( ! $postimage ) {
467 return array(
468 'postimage' => '',
469 'attachment_id' => 0,
470 'pick' => '',
471 'extracted_alt' => '',
472 );
473 }
474
475 $pick = 'meta';
476 $attachment_id = self::get_cached_attachment_id( $postimage );
477 $postthumb = wp_get_attachment_image_src( $attachment_id, $args['size'] );
478 if ( false !== $postthumb ) {
479 $postimage = $postthumb[0];
480 $pick .= 'correct';
481 }
482
483 return array(
484 'postimage' => $postimage,
485 'attachment_id' => $attachment_id,
486 'pick' => $pick,
487 'extracted_alt' => '',
488 );
489 }
490
491 /**
492 * Get thumbnail from an ACF field.
493 *
494 * @param \WP_Post $post Post object.
495 * @param array $args Arguments array.
496 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
497 */
498 protected static function get_thumbnail_from_acf( \WP_Post $post, array $args ): array {
499 if ( ! $args['acf_field'] || ! function_exists( 'get_field' ) ) {
500 return array(
501 'postimage' => '',
502 'attachment_id' => 0,
503 'pick' => '',
504 'extracted_alt' => '',
505 );
506 }
507
508 $result = self::get_acf_thumbnail( $args['acf_field'], $post->ID, $args['size'] );
509 $result['extracted_alt'] = '';
510 return $result;
511 }
512
513 /**
514 * Get thumbnail from the FIFU (Featured Image from URL) plugin.
515 *
516 * @param \WP_Post $post Post object.
517 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
518 */
519 protected static function get_thumbnail_from_fifu( \WP_Post $post ): array {
520 /**
521 * Filters the FIFU meta key used to store external image URLs.
522 *
523 * @param string $fifu_meta_key Meta key used by FIFU plugin.
524 */
525 $fifu_meta_key = apply_filters( self::$prefix . '_fifu_meta_key', 'fifu_image_url' );
526 $fifu_image_url = get_post_meta( $post->ID, $fifu_meta_key, true );
527 $fifu_image_url = filter_var( $fifu_image_url, FILTER_VALIDATE_URL );
528
529 if ( ! $fifu_image_url ) {
530 return array(
531 'postimage' => '',
532 'attachment_id' => 0,
533 'pick' => '',
534 'extracted_alt' => '',
535 );
536 }
537
538 return array(
539 'postimage' => $fifu_image_url,
540 'attachment_id' => 0,
541 'pick' => 'fifu',
542 'extracted_alt' => '',
543 );
544 }
545
546 /**
547 * Get thumbnail from the post's featured image.
548 *
549 * @param \WP_Post $post Post object.
550 * @param array $args Arguments array.
551 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
552 */
553 protected static function get_thumbnail_from_featured_image( \WP_Post $post, array $args ): array {
554 if ( false === get_post_thumbnail_id( $post->ID ) ) {
555 return array(
556 'postimage' => '',
557 'attachment_id' => 0,
558 'pick' => '',
559 'extracted_alt' => '',
560 );
561 }
562
563 $attachment_id = ( 'attachment' === $post->post_type ) ? $post->ID : get_post_thumbnail_id( $post->ID );
564 $postthumb = wp_get_attachment_image_src( $attachment_id, $args['size'] );
565
566 if ( false === $postthumb ) {
567 return array(
568 'postimage' => '',
569 'attachment_id' => 0,
570 'pick' => '',
571 'extracted_alt' => '',
572 );
573 }
574
575 return array(
576 'postimage' => $postthumb[0],
577 'attachment_id' => $attachment_id,
578 'pick' => 'featured',
579 'extracted_alt' => '',
580 );
581 }
582
583 /**
584 * Get thumbnail by scanning the post content for img tags.
585 *
586 * @param \WP_Post $post Post object.
587 * @param array $args Arguments array.
588 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
589 */
590 protected static function get_thumbnail_from_content_scan( \WP_Post $post, array $args ): array {
591 if ( ! $args['scan_images'] ) {
592 return array(
593 'postimage' => '',
594 'attachment_id' => 0,
595 'pick' => '',
596 'extracted_alt' => '',
597 );
598 }
599
600 // Skip content scanning for very large posts to prevent memory exhaustion.
601 if ( strlen( $post->post_content ) > 50000 ) { // 50KB limit.
602 $post_content = '';
603 } else {
604 /**
605 * Filters the post content that is used to scan for images.
606 *
607 * A filter function can be tapped into this to execute shortcodes, modify content, etc.
608 *
609 * @param string $post_content Post content.
610 * @param \WP_Post $post Post object.
611 */
612 $post_content = apply_filters( self::$prefix . '_thumb_post_content', $post->post_content, $post );
613 }
614
615 preg_match_all( '/<img\s[^>]*src=[\'"]([^\'"]+)[\'"][^>]*>/i', $post_content, $matches );
616 if ( ! isset( $matches[1][0] ) || ! $matches[1][0] ) {
617 return array(
618 'postimage' => '',
619 'attachment_id' => 0,
620 'pick' => '',
621 'extracted_alt' => '',
622 );
623 }
624
625 $postimage = $matches[1][0];
626 $extracted_alt = self::get_alt_from_img_tag( $matches[0][0] );
627 $pick = 'first';
628 $attachment_id = self::get_cached_attachment_id( $postimage );
629 $postthumb = wp_get_attachment_image_src( $attachment_id, $args['size'] );
630
631 if ( false !== $postthumb ) {
632 $postimage = $postthumb[0];
633 $pick .= 'correct';
634 } else {
635 // Fallback: Try to resize the original URL if no attachment found.
636 $resized_url = self::resize_external_image( $postimage, $args['size'] );
637 if ( $resized_url ) {
638 $postimage = $resized_url;
639 $pick .= 'resized';
640 }
641 }
642
643 return array(
644 'postimage' => $postimage,
645 'attachment_id' => $attachment_id,
646 'pick' => $pick,
647 'extracted_alt' => $extracted_alt,
648 );
649 }
650
651 /**
652 * Get thumbnail from the first attached child image.
653 *
654 * @param \WP_Post $post Post object.
655 * @param array $args Arguments array.
656 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
657 */
658 protected static function get_thumbnail_from_first_child( \WP_Post $post, array $args ): array {
659 $dimensions = self::get_thumb_size( $args['size'] );
660 $postimage = self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] );
661
662 if ( ! $postimage ) {
663 return array(
664 'postimage' => '',
665 'attachment_id' => 0,
666 'pick' => '',
667 'extracted_alt' => '',
668 );
669 }
670
671 return array(
672 'postimage' => $postimage,
673 'attachment_id' => 0,
674 'pick' => 'firstchild',
675 'extracted_alt' => '',
676 );
677 }
678
679 /**
680 * Get thumbnail from the Video Thumbnails plugin meta field.
681 *
682 * @param \WP_Post $post Post object.
683 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
684 */
685 protected static function get_thumbnail_from_video_meta( \WP_Post $post ): array {
686 $postimage = get_post_meta( $post->ID, '_video_thumbnail', true );
687 $postimage = filter_var( $postimage, FILTER_VALIDATE_URL );
688
689 if ( ! $postimage ) {
690 return array(
691 'postimage' => '',
692 'attachment_id' => 0,
693 'pick' => '',
694 'extracted_alt' => '',
695 );
696 }
697
698 return array(
699 'postimage' => $postimage,
700 'attachment_id' => 0,
701 'pick' => 'video_thumb',
702 'extracted_alt' => '',
703 );
704 }
705
706 /**
707 * Get thumbnail from the configured default thumbnail setting.
708 *
709 * @param array $args Arguments array.
710 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
711 */
712 protected static function get_thumbnail_from_default_thumb( array $args ): array {
713 if ( ! $args['thumb_default_show'] || ! $args['thumb_default'] ) {
714 return array(
715 'postimage' => '',
716 'attachment_id' => 0,
717 'pick' => '',
718 'extracted_alt' => '',
719 );
720 }
721
722 $postimage = $args['thumb_default'];
723 $pick = 'default_thumb';
724 $attachment_id = 0;
725
726 if ( self::$default_thumb_url !== $postimage ) {
727 $attachment_id = self::get_cached_attachment_id( $postimage );
728 $postthumb = wp_get_attachment_image_src( $attachment_id, $args['size'] );
729 if ( false !== $postthumb ) {
730 $postimage = $postthumb[0];
731 $pick .= 'correct';
732 }
733 }
734
735 return array(
736 'postimage' => $postimage,
737 'attachment_id' => $attachment_id,
738 'pick' => $pick,
739 'extracted_alt' => '',
740 );
741 }
742
743 /**
744 * Get thumbnail from the site icon.
745 *
746 * @param array $args Arguments array.
747 * @return array{postimage: string, attachment_id: int, pick: string, extracted_alt: string}
748 */
749 protected static function get_thumbnail_from_site_icon( array $args ): array {
750 if ( ! $args['use_site_icon'] ) {
751 return array(
752 'postimage' => '',
753 'attachment_id' => 0,
754 'pick' => '',
755 'extracted_alt' => '',
756 );
757 }
758
759 $postimage = get_site_icon_url( max( $args['thumb_width'], $args['thumb_height'] ) );
760 $pick = 'site_icon_max';
761
762 // Fallback to min size if max size not available.
763 if ( ! $postimage ) {
764 $postimage = get_site_icon_url( min( $args['thumb_width'], $args['thumb_height'] ) );
765 $pick = 'site_icon_min';
766 }
767
768 if ( ! $postimage ) {
769 return array(
770 'postimage' => '',
771 'attachment_id' => 0,
772 'pick' => '',
773 'extracted_alt' => '',
774 );
775 }
776
777 return array(
778 'postimage' => $postimage,
779 'attachment_id' => 0,
780 'pick' => $pick,
781 'extracted_alt' => '',
782 );
783 }
784
785 /**
786 * Get an HTML img element.
787 *
788 * Delegates to {@see wp_get_attachment_image()} when an attachment ID is available;
789 * otherwise builds the tag manually.
790 *
791 * @param string $attachment_url Image URL.
792 * @param array $attr Optional. Attributes for the image markup.
793 * @param int $attachment_id Optional. Attachment ID. Default 0.
794 * @param string|int[] $size Optional. Registered image size name or
795 * array of width and height values in pixels.
796 * Default empty string.
797 * @return string HTML img element or empty string on failure.
798 */
799 public static function get_image_html( $attachment_url, $attr = array(), $attachment_id = 0, $size = '' ) {
800 // If there is an attachment ID, delegate to wp_get_attachment_image().
801 if ( $attachment_id ) {
802 $attr = self::ensure_loading_and_decoding_attrs( $attr );
803 $attr = self::sanitize_image_attrs( $attr );
804
805 return wp_get_attachment_image( $attachment_id, $size, false, $attr );
806 }
807
808 // If there is no URL, return an empty string.
809 if ( empty( $attachment_url ) ) {
810 return '';
811 }
812
813 // Define default attributes.
814 $default_attr = array(
815 'src' => $attachment_url,
816 'alt' => '',
817 'thumb_html' => self::get_option( 'thumb_html', 'html' ),
818 'thumb_width' => self::get_option( 'thumb_width', 150 ),
819 'thumb_height' => self::get_option( 'thumb_height', 150 ),
820 'class' => "attachment-$size size-$size",
821 );
822
823 // Merge default attributes with provided attributes.
824 $attr = wp_parse_args( $attr, $default_attr );
825 $attr = self::ensure_loading_and_decoding_attrs( $attr );
826
827 // Generate width and height string before thumb_* keys are stripped.
828 $hwstring = self::get_image_hwstring( $attr );
829
830 // Add 'auto' to the sizes attribute for lazy-loaded images.
831 if (
832 isset( $attr['loading'] ) &&
833 'lazy' === $attr['loading'] &&
834 isset( $attr['sizes'] ) &&
835 function_exists( 'wp_sizes_attribute_includes_valid_auto' ) &&
836 ! wp_sizes_attribute_includes_valid_auto( $attr['sizes'] )
837 ) {
838 $attr['sizes'] = 'auto, ' . $attr['sizes'];
839 }
840
841 // Sanitise and strip internal attributes.
842 $attr = self::sanitize_image_attrs( $attr );
843
844 /**
845 * Filters the list of attachment image attributes.
846 *
847 * @param array $attr Attributes for the image markup.
848 * @param string $attachment_url Image URL.
849 */
850 $attr = apply_filters( self::$prefix . '_get_image_attributes', $attr, $attachment_url );
851 $attr = array_map( 'esc_attr', $attr );
852
853 // Construct the HTML img tag.
854 $html = '<img ' . rtrim( $hwstring, " \t\n\r\0\x0B" );
855 foreach ( $attr as $name => $value ) {
856 if ( '' !== $value ) {
857 $html .= " $name=" . '"' . $value . '"';
858 }
859 }
860 $html .= ' />';
861
862 /**
863 * Filters the img tag.
864 *
865 * @param string $html HTML img element or empty string on failure.
866 * @param string $attachment_url Image URL.
867 * @param array $attr Attributes for the image markup.
868 */
869 return apply_filters( self::$prefix . '_get_image_html', $html, $attachment_url, $attr );
870 }
871
872 /**
873 * Ensures the loading/decoding attributes are set consistently for all thumbnails.
874 *
875 * @param array $attr Attributes array.
876 * @return array
877 */
878 protected static function ensure_loading_and_decoding_attrs( array $attr ): array {
879 if ( empty( $attr['loading'] ) ) {
880 /**
881 * Filters the default loading attribute applied to Contextual Related Posts thumbnails.
882 *
883 * @param string|null $loading Loading attribute value or null to omit.
884 * @param array $attr Thumbnail attributes.
885 */
886 $attr['loading'] = apply_filters( self::$prefix . '_thumbnail_loading_attribute', 'lazy', $attr );
887 }
888
889 if ( empty( $attr['decoding'] ) ) {
890 /**
891 * Filters the default decoding attribute applied to Contextual Related Posts thumbnails.
892 *
893 * @param string|null $decoding Decoding attribute value or null to omit.
894 * @param array $attr Thumbnail attributes.
895 */
896 $attr['decoding'] = apply_filters( self::$prefix . '_thumbnail_decoding_attribute', 'async', $attr );
897 }
898
899 return $attr;
900 }
901
902 /**
903 * Sanitises optional image attributes before rendering.
904 *
905 * Removes `decoding`, `loading`, and `fetchpriority` when their values
906 * are empty or invalid, and strips internal `thumb_*` keys that must
907 * never appear in the final `<img>` tag.
908 *
909 * @param array $attr Image attributes.
910 * @return array Cleaned attributes.
911 */
912 private static function sanitize_image_attrs( array $attr ): array {
913 // Omit the `decoding` attribute if the value is invalid according to the spec.
914 if ( empty( $attr['decoding'] ) || ! in_array( $attr['decoding'], array( 'async', 'sync', 'auto' ), true ) ) {
915 unset( $attr['decoding'] );
916 }
917
918 /*
919 * If the default value of `lazy` for the `loading` attribute is overridden
920 * to omit the attribute for this image, ensure it is not included.
921 */
922 if ( isset( $attr['loading'] ) && ! $attr['loading'] ) {
923 unset( $attr['loading'] );
924 }
925
926 // If the `fetchpriority` attribute is overridden and set to false or an empty string.
927 if ( isset( $attr['fetchpriority'] ) && ! $attr['fetchpriority'] ) {
928 unset( $attr['fetchpriority'] );
929 }
930
931 // Strip internal keys that must not appear in the final <img> tag.
932 unset( $attr['thumb_html'], $attr['thumb_width'], $attr['thumb_height'] );
933
934 return $attr;
935 }
936
937 /**
938 * Extract alt text from an image tag string.
939 *
940 * @param string $img_tag Image tag HTML.
941 * @return string Sanitized alt text or empty string if none found.
942 */
943 private static function get_alt_from_img_tag( string $img_tag ): string {
944 if ( ! preg_match( '/\salt=(\"|\')(.*?)\1/i', $img_tag, $matches ) ) {
945 return '';
946 }
947
948 $alt = wp_specialchars_decode( $matches[2], ENT_QUOTES );
949 $alt = sanitize_text_field( $alt );
950
951 return $alt;
952 }
953
954 /**
955 * Retrieve width and height attributes using given width and height values.
956 *
957 * @param array $args Argument array.
958 * @return string Height-width string.
959 */
960 public static function get_image_hwstring( $args = array() ) {
961 $default_args = array(
962 'thumb_html' => self::get_option( 'thumb_html', 'html' ),
963 'thumb_width' => self::get_option( 'thumb_width', 150 ),
964 'thumb_height' => self::get_option( 'thumb_height', 150 ),
965 );
966
967 $args = wp_parse_args( $args, $default_args );
968
969 if ( 'css' === $args['thumb_html'] ) {
970 $thumb_html = ' style="max-width:' . $args['thumb_width'] . 'px;max-height:' . $args['thumb_height'] . 'px;" ';
971 } elseif ( 'html' === $args['thumb_html'] ) {
972 $thumb_html = ' width="' . $args['thumb_width'] . '" height="' . $args['thumb_height'] . '" ';
973 } else {
974 $thumb_html = '';
975 }
976
977 /**
978 * Filters the thumbnail HTML and allows a filter function to add any more HTML if needed.
979 *
980 * @param string $thumb_html Thumbnail HTML.
981 * @param array $args Argument array.
982 */
983 return apply_filters( self::$prefix . '_thumb_html', $thumb_html, $args );
984 }
985
986 /**
987 * Get the first child image in the post.
988 *
989 * @param int|\WP_Post $postid Post ID or WP_Post object.
990 * @param int $thumb_width Thumb width.
991 * @param int $thumb_height Thumb height.
992 * @return string Location of thumbnail.
993 */
994 public static function get_first_image( $postid, int $thumb_width, int $thumb_height ): string {
995 $args = array(
996 'numberposts' => 1,
997 'order' => 'ASC',
998 'post_mime_type' => 'image',
999 'post_parent' => $postid,
1000 'post_status' => 'inherit',
1001 'post_type' => 'attachment',
1002 );
1003
1004 $attachments = get_children( $args );
1005
1006 if ( empty( $attachments ) ) {
1007 return '';
1008 }
1009
1010 $attachment = reset( $attachments );
1011 $image_size = array( $thumb_width, $thumb_height );
1012
1013 if ( 0 < $attachment->ID ) {
1014 $image_attributes = wp_get_attachment_image_src( $attachment->ID, $image_size );
1015
1016 if ( empty( $image_attributes ) ) {
1017 $image_attributes = wp_get_attachment_image_src( $attachment->ID, 'full' );
1018 }
1019
1020 if ( ! empty( $image_attributes ) ) {
1021 /**
1022 * Filter the first child image URL.
1023 *
1024 * @param string $image_url URL of the image.
1025 * @param int|\WP_Post $postid Post ID or WP_Post object.
1026 * @param int $thumb_width Thumb width.
1027 * @param int $thumb_height Thumb height.
1028 */
1029 return apply_filters(
1030 self::$prefix . '_get_first_image',
1031 $image_attributes[0],
1032 $postid,
1033 $thumb_width,
1034 $thumb_height
1035 );
1036 }
1037 }
1038
1039 return '';
1040 }
1041
1042 /**
1043 * Get cached attachment ID from URL to prevent database exhaustion.
1044 *
1045 * @param string $attachment_url Attachment URL.
1046 * @return int Attachment ID.
1047 */
1048 public static function get_cached_attachment_id( $attachment_url = '' ) {
1049 $attachment_id = 0;
1050
1051 // If there is no URL, return.
1052 if ( ! $attachment_url ) {
1053 return $attachment_id;
1054 }
1055
1056 // Check cache first.
1057 $cache_key = self::$prefix . '_attachment_id_' . get_current_blog_id() . '_' . hash( 'sha256', $attachment_url );
1058 $cached_id = wp_cache_get( $cache_key, self::$prefix . '_media' );
1059
1060 if ( false !== $cached_id ) {
1061 return (int) $cached_id;
1062 }
1063
1064 // Attempt to retrieve the attachment ID from the URL.
1065 $attachment_id = attachment_url_to_postid( $attachment_url );
1066
1067 // If not found, try stripping the size suffix (e.g., -150x150, -1024x768) and lookup base URL.
1068 if ( 0 === $attachment_id ) {
1069 $base_url = self::get_base_image_url( $attachment_url );
1070 if ( $base_url !== $attachment_url ) {
1071 $attachment_id = attachment_url_to_postid( $base_url );
1072 }
1073 }
1074
1075 // Cache the result for 1 hour.
1076 wp_cache_set( $cache_key, $attachment_id, self::$prefix . '_media', HOUR_IN_SECONDS );
1077
1078 /**
1079 * Filter the cached attachment ID from the attachment URL.
1080 *
1081 * @param int $attachment_id Attachment ID.
1082 * @param string $attachment_url Attachment URL.
1083 */
1084 return apply_filters( self::$prefix . '_get_cached_attachment_id', $attachment_id, $attachment_url );
1085 }
1086
1087 /**
1088 * Get the base image URL by stripping WordPress size suffixes.
1089 *
1090 * Converts URLs like image-150x150.jpg or image-1024x768.jpg to image.jpg
1091 *
1092 * @param string $url Image URL potentially with size suffix.
1093 * @return string Base image URL without size suffix.
1094 */
1095 public static function get_base_image_url( $url ) {
1096 // Remove WordPress size suffix (e.g., -150x150) while retaining filename and extension.
1097 return preg_replace( '/-\d+x\d+(?=\.[^.]+$)/', '', $url );
1098 }
1099
1100 /**
1101 * Function to get the correct height and width of the thumbnail.
1102 *
1103 * @param string $size Image size.
1104 * @return array Width and height. If no width and height is found, then 150 is returned for each.
1105 */
1106 public static function get_thumb_size( $size = 'thumbnail' ) {
1107
1108 // Get thumbnail size.
1109 $thumb_size_array = self::get_all_image_sizes( $size );
1110
1111 if ( isset( $thumb_size_array['width'] ) ) {
1112 $thumb_width = $thumb_size_array['width'];
1113 $thumb_height = $thumb_size_array['height'];
1114 }
1115
1116 if ( isset( $thumb_width ) && isset( $thumb_height ) ) {
1117 $thumb_size = array( $thumb_width, $thumb_height );
1118 } else {
1119 $thumb_size = array( 150, 150 );
1120 }
1121
1122 /**
1123 * Filter array of thumbnail size.
1124 *
1125 * @param array $thumb_size Array with width and height of thumbnail.
1126 */
1127 return apply_filters( self::$prefix . '_get_thumb_size', $thumb_size );
1128 }
1129
1130 /**
1131 * Get all image sizes.
1132 *
1133 * @param string|int[] $size Image size.
1134 * @return array If a single size is specified, then the array with width, height and crop status
1135 * or an empty array if size is not found;
1136 * If no size is specified then an Associative array of the registered image sub-sizes.
1137 */
1138 public static function get_all_image_sizes( $size = '' ) {
1139
1140 if ( is_array( $size ) ) {
1141 $size = self::get_appropriate_image_size( $size[0], $size[1] );
1142 }
1143
1144 $sizes = wp_get_registered_image_subsizes();
1145
1146 /* Get only 1 size if found */
1147 if ( $size ) {
1148 if ( isset( $sizes[ $size ] ) ) {
1149 return $sizes[ $size ];
1150 }
1151 return array();
1152 }
1153
1154 /**
1155 * Filters array of image sizes.
1156 *
1157 * @param array $sizes Image sizes.
1158 */
1159 return apply_filters( self::$prefix . '_get_all_image_sizes', $sizes );
1160 }
1161
1162 /**
1163 * Get the most appropriate image size based on the given thumbnail width and height.
1164 *
1165 * @param int $thumb_width Thumbnail width.
1166 * @param int $thumb_height Thumbnail height.
1167 * @return string|bool Image size name if found, false otherwise.
1168 */
1169 public static function get_appropriate_image_size( $thumb_width, $thumb_height ) {
1170 $sizes = wp_get_registered_image_subsizes();
1171
1172 $closest_size = false;
1173 $closest_distance = PHP_INT_MAX;
1174
1175 foreach ( $sizes as $size_name => $size_info ) {
1176 $size_width = $size_info['width'];
1177 $size_height = $size_info['height'];
1178 $distance = sqrt( pow( $thumb_width - $size_width, 2 ) + pow( $thumb_height - $size_height, 2 ) );
1179
1180 if ( $distance < $closest_distance ) {
1181 $closest_distance = $distance;
1182 $closest_size = $size_name;
1183 }
1184 }
1185
1186 return $closest_size;
1187 }
1188 }
1189