PluginProbe
WebberZone Top 10 — Popular Posts / 4.5.2
WebberZone Top 10 — Popular Posts v4.5.2
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.2, at includes/frontend/class-media-handler.php

1,201 lines 37.6 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'] = absint( $args['size'][0] );
134 $args['thumb_height'] = absint( $args['size'][1] );
135 $args['size'] = self::get_appropriate_image_size( $args['thumb_width'], $args['thumb_height'] );
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 $thumb_width = absint( $args['thumb_width'] );
970 $thumb_height = absint( $args['thumb_height'] );
971
972 if ( 'css' === $args['thumb_html'] ) {
973 $styles = array();
974 if ( $thumb_width ) {
975 $styles[] = 'max-width:' . $thumb_width . 'px';
976 }
977 if ( $thumb_height ) {
978 $styles[] = 'max-height:' . $thumb_height . 'px';
979 }
980 $thumb_html = $styles ? ' style="' . implode( ';', $styles ) . ';" ' : '';
981 } elseif ( 'html' === $args['thumb_html'] ) {
982 $thumb_html = $thumb_width ? ' width="' . $thumb_width . '"' : '';
983 $thumb_html .= $thumb_height ? ' height="' . $thumb_height . '"' : '';
984 $thumb_html = $thumb_html ? $thumb_html . ' ' : '';
985 } else {
986 $thumb_html = '';
987 }
988
989 /**
990 * Filters the thumbnail HTML and allows a filter function to add any more HTML if needed.
991 *
992 * @param string $thumb_html Thumbnail HTML.
993 * @param array $args Argument array.
994 */
995 return apply_filters( self::$prefix . '_thumb_html', $thumb_html, $args );
996 }
997
998 /**
999 * Get the first child image in the post.
1000 *
1001 * @param int|\WP_Post $postid Post ID or WP_Post object.
1002 * @param int $thumb_width Thumb width.
1003 * @param int $thumb_height Thumb height.
1004 * @return string Location of thumbnail.
1005 */
1006 public static function get_first_image( $postid, int $thumb_width, int $thumb_height ): string {
1007 $args = array(
1008 'numberposts' => 1,
1009 'order' => 'ASC',
1010 'post_mime_type' => 'image',
1011 'post_parent' => $postid,
1012 'post_status' => 'inherit',
1013 'post_type' => 'attachment',
1014 );
1015
1016 $attachments = get_children( $args );
1017
1018 if ( empty( $attachments ) ) {
1019 return '';
1020 }
1021
1022 $attachment = reset( $attachments );
1023 $image_size = array( $thumb_width, $thumb_height );
1024
1025 if ( 0 < $attachment->ID ) {
1026 $image_attributes = wp_get_attachment_image_src( $attachment->ID, $image_size );
1027
1028 if ( empty( $image_attributes ) ) {
1029 $image_attributes = wp_get_attachment_image_src( $attachment->ID, 'full' );
1030 }
1031
1032 if ( ! empty( $image_attributes ) ) {
1033 /**
1034 * Filter the first child image URL.
1035 *
1036 * @param string $image_url URL of the image.
1037 * @param int|\WP_Post $postid Post ID or WP_Post object.
1038 * @param int $thumb_width Thumb width.
1039 * @param int $thumb_height Thumb height.
1040 */
1041 return apply_filters(
1042 self::$prefix . '_get_first_image',
1043 $image_attributes[0],
1044 $postid,
1045 $thumb_width,
1046 $thumb_height
1047 );
1048 }
1049 }
1050
1051 return '';
1052 }
1053
1054 /**
1055 * Get cached attachment ID from URL to prevent database exhaustion.
1056 *
1057 * @param string $attachment_url Attachment URL.
1058 * @return int Attachment ID.
1059 */
1060 public static function get_cached_attachment_id( $attachment_url = '' ) {
1061 $attachment_id = 0;
1062
1063 // If there is no URL, return.
1064 if ( ! $attachment_url ) {
1065 return $attachment_id;
1066 }
1067
1068 // Check cache first.
1069 $cache_key = self::$prefix . '_attachment_id_' . get_current_blog_id() . '_' . hash( 'sha256', $attachment_url );
1070 $cached_id = wp_cache_get( $cache_key, self::$prefix . '_media' );
1071
1072 if ( false !== $cached_id ) {
1073 return (int) $cached_id;
1074 }
1075
1076 // Attempt to retrieve the attachment ID from the URL.
1077 $attachment_id = attachment_url_to_postid( $attachment_url );
1078
1079 // If not found, try stripping the size suffix (e.g., -150x150, -1024x768) and lookup base URL.
1080 if ( 0 === $attachment_id ) {
1081 $base_url = self::get_base_image_url( $attachment_url );
1082 if ( $base_url !== $attachment_url ) {
1083 $attachment_id = attachment_url_to_postid( $base_url );
1084 }
1085 }
1086
1087 // Cache the result for 1 hour.
1088 wp_cache_set( $cache_key, $attachment_id, self::$prefix . '_media', HOUR_IN_SECONDS );
1089
1090 /**
1091 * Filter the cached attachment ID from the attachment URL.
1092 *
1093 * @param int $attachment_id Attachment ID.
1094 * @param string $attachment_url Attachment URL.
1095 */
1096 return apply_filters( self::$prefix . '_get_cached_attachment_id', $attachment_id, $attachment_url );
1097 }
1098
1099 /**
1100 * Get the base image URL by stripping WordPress size suffixes.
1101 *
1102 * Converts URLs like image-150x150.jpg or image-1024x768.jpg to image.jpg
1103 *
1104 * @param string $url Image URL potentially with size suffix.
1105 * @return string Base image URL without size suffix.
1106 */
1107 public static function get_base_image_url( $url ) {
1108 // Remove WordPress size suffix (e.g., -150x150) while retaining filename and extension.
1109 return preg_replace( '/-\d+x\d+(?=\.[^.]+$)/', '', $url );
1110 }
1111
1112 /**
1113 * Function to get the correct height and width of the thumbnail.
1114 *
1115 * @param string $size Image size.
1116 * @return array Width and height. If no width and height is found, then 150 is returned for each.
1117 */
1118 public static function get_thumb_size( $size = 'thumbnail' ) {
1119
1120 // Get thumbnail size.
1121 $thumb_size_array = self::get_all_image_sizes( $size );
1122
1123 if ( isset( $thumb_size_array['width'] ) ) {
1124 $thumb_width = $thumb_size_array['width'];
1125 $thumb_height = $thumb_size_array['height'];
1126 }
1127
1128 if ( isset( $thumb_width ) && isset( $thumb_height ) ) {
1129 $thumb_size = array( $thumb_width, $thumb_height );
1130 } else {
1131 $thumb_size = array( 150, 150 );
1132 }
1133
1134 /**
1135 * Filter array of thumbnail size.
1136 *
1137 * @param array $thumb_size Array with width and height of thumbnail.
1138 */
1139 return apply_filters( self::$prefix . '_get_thumb_size', $thumb_size );
1140 }
1141
1142 /**
1143 * Get all image sizes.
1144 *
1145 * @param string|int[] $size Image size.
1146 * @return array If a single size is specified, then the array with width, height and crop status
1147 * or an empty array if size is not found;
1148 * If no size is specified then an Associative array of the registered image sub-sizes.
1149 */
1150 public static function get_all_image_sizes( $size = '' ) {
1151
1152 if ( is_array( $size ) ) {
1153 $size = self::get_appropriate_image_size( $size[0], $size[1] );
1154 }
1155
1156 $sizes = wp_get_registered_image_subsizes();
1157
1158 /* Get only 1 size if found */
1159 if ( $size ) {
1160 if ( isset( $sizes[ $size ] ) ) {
1161 return $sizes[ $size ];
1162 }
1163 return array();
1164 }
1165
1166 /**
1167 * Filters array of image sizes.
1168 *
1169 * @param array $sizes Image sizes.
1170 */
1171 return apply_filters( self::$prefix . '_get_all_image_sizes', $sizes );
1172 }
1173
1174 /**
1175 * Get the most appropriate image size based on the given thumbnail width and height.
1176 *
1177 * @param int $thumb_width Thumbnail width.
1178 * @param int $thumb_height Thumbnail height.
1179 * @return string|bool Image size name if found, false otherwise.
1180 */
1181 public static function get_appropriate_image_size( $thumb_width, $thumb_height ) {
1182 $sizes = wp_get_registered_image_subsizes();
1183
1184 $closest_size = false;
1185 $closest_distance = PHP_INT_MAX;
1186
1187 foreach ( $sizes as $size_name => $size_info ) {
1188 $size_width = $size_info['width'];
1189 $size_height = $size_info['height'];
1190 $distance = sqrt( pow( $thumb_width - $size_width, 2 ) + pow( $thumb_height - $size_height, 2 ) );
1191
1192 if ( $distance < $closest_distance ) {
1193 $closest_distance = $distance;
1194 $closest_size = $size_name;
1195 }
1196 }
1197
1198 return $closest_size;
1199 }
1200 }
1201