PluginProbe
WebberZone Top 10 — Popular Posts / 4.3.4
WebberZone Top 10 — Popular Posts v4.3.4
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 1.6.1 All 117 releases
top-10 / includes / frontend / class-media-handler.php

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

1,194 lines 37.4 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 * When an attachment ID is available the function delegates to
789 * {@see wp_get_attachment_image()} so that srcset, sizes and all
790 * core image optimisations are applied automatically.
791 *
792 * When only a URL is available (external images, meta-key URLs, etc.)
793 * the function builds the `<img>` tag manually using the configured
794 * thumbnail dimensions and attributes.
795 *
796 * @param string $attachment_url Image URL.
797 * @param array $attr Optional. Attributes for the image markup.
798 * @param int $attachment_id Optional. Attachment ID. Default 0.
799 * @param string|int[] $size Optional. Registered image size name or
800 * array of width and height values in pixels.
801 * Default empty string.
802 * @return string HTML img element or empty string on failure.
803 */
804 public static function get_image_html( $attachment_url, $attr = array(), $attachment_id = 0, $size = '' ) {
805 // If there is an attachment ID, delegate to wp_get_attachment_image().
806 if ( $attachment_id ) {
807 $attr = self::ensure_loading_and_decoding_attrs( $attr );
808 $attr = self::sanitize_image_attrs( $attr );
809
810 return wp_get_attachment_image( $attachment_id, $size, false, $attr );
811 }
812
813 // If there is no URL, return an empty string.
814 if ( empty( $attachment_url ) ) {
815 return '';
816 }
817
818 // Define default attributes.
819 $default_attr = array(
820 'src' => $attachment_url,
821 'alt' => '',
822 'thumb_html' => self::get_option( 'thumb_html', 'html' ),
823 'thumb_width' => self::get_option( 'thumb_width', 150 ),
824 'thumb_height' => self::get_option( 'thumb_height', 150 ),
825 'class' => "attachment-$size size-$size",
826 );
827
828 // Merge default attributes with provided attributes.
829 $attr = wp_parse_args( $attr, $default_attr );
830 $attr = self::ensure_loading_and_decoding_attrs( $attr );
831
832 // Generate width and height string before thumb_* keys are stripped.
833 $hwstring = self::get_image_hwstring( $attr );
834
835 // Add 'auto' to the sizes attribute for lazy-loaded images.
836 if (
837 isset( $attr['loading'] ) &&
838 'lazy' === $attr['loading'] &&
839 isset( $attr['sizes'] ) &&
840 function_exists( 'wp_sizes_attribute_includes_valid_auto' ) &&
841 ! wp_sizes_attribute_includes_valid_auto( $attr['sizes'] )
842 ) {
843 $attr['sizes'] = 'auto, ' . $attr['sizes'];
844 }
845
846 // Sanitise and strip internal attributes.
847 $attr = self::sanitize_image_attrs( $attr );
848
849 /**
850 * Filters the list of attachment image attributes.
851 *
852 * @param array $attr Attributes for the image markup.
853 * @param string $attachment_url Image URL.
854 */
855 $attr = apply_filters( self::$prefix . '_get_image_attributes', $attr, $attachment_url );
856 $attr = array_map( 'esc_attr', $attr );
857
858 // Construct the HTML img tag.
859 $html = '<img ' . rtrim( $hwstring );
860 foreach ( $attr as $name => $value ) {
861 if ( '' !== $value ) {
862 $html .= " $name=" . '"' . $value . '"';
863 }
864 }
865 $html .= ' />';
866
867 /**
868 * Filters the img tag.
869 *
870 * @param string $html HTML img element or empty string on failure.
871 * @param string $attachment_url Image URL.
872 * @param array $attr Attributes for the image markup.
873 */
874 return apply_filters( self::$prefix . '_get_image_html', $html, $attachment_url, $attr );
875 }
876
877 /**
878 * Ensures the loading/decoding attributes are set consistently for all thumbnails.
879 *
880 * @param array $attr Attributes array.
881 * @return array
882 */
883 protected static function ensure_loading_and_decoding_attrs( array $attr ): array {
884 if ( empty( $attr['loading'] ) ) {
885 /**
886 * Filters the default loading attribute applied to Contextual Related Posts thumbnails.
887 *
888 * @param string|null $loading Loading attribute value or null to omit.
889 * @param array $attr Thumbnail attributes.
890 */
891 $attr['loading'] = apply_filters( self::$prefix . '_thumbnail_loading_attribute', 'lazy', $attr );
892 }
893
894 if ( empty( $attr['decoding'] ) ) {
895 /**
896 * Filters the default decoding attribute applied to Contextual Related Posts thumbnails.
897 *
898 * @param string|null $decoding Decoding attribute value or null to omit.
899 * @param array $attr Thumbnail attributes.
900 */
901 $attr['decoding'] = apply_filters( self::$prefix . '_thumbnail_decoding_attribute', 'async', $attr );
902 }
903
904 return $attr;
905 }
906
907 /**
908 * Sanitises optional image attributes before rendering.
909 *
910 * Removes `decoding`, `loading`, and `fetchpriority` when their values
911 * are empty or invalid, and strips internal `thumb_*` keys that must
912 * never appear in the final `<img>` tag.
913 *
914 * @param array $attr Image attributes.
915 * @return array Cleaned attributes.
916 */
917 private static function sanitize_image_attrs( array $attr ): array {
918 // Omit the `decoding` attribute if the value is invalid according to the spec.
919 if ( empty( $attr['decoding'] ) || ! in_array( $attr['decoding'], array( 'async', 'sync', 'auto' ), true ) ) {
920 unset( $attr['decoding'] );
921 }
922
923 /*
924 * If the default value of `lazy` for the `loading` attribute is overridden
925 * to omit the attribute for this image, ensure it is not included.
926 */
927 if ( isset( $attr['loading'] ) && ! $attr['loading'] ) {
928 unset( $attr['loading'] );
929 }
930
931 // If the `fetchpriority` attribute is overridden and set to false or an empty string.
932 if ( isset( $attr['fetchpriority'] ) && ! $attr['fetchpriority'] ) {
933 unset( $attr['fetchpriority'] );
934 }
935
936 // Strip internal keys that must not appear in the final <img> tag.
937 unset( $attr['thumb_html'], $attr['thumb_width'], $attr['thumb_height'] );
938
939 return $attr;
940 }
941
942 /**
943 * Extract alt text from an image tag string.
944 *
945 * @param string $img_tag Image tag HTML.
946 * @return string Sanitized alt text or empty string if none found.
947 */
948 private static function get_alt_from_img_tag( string $img_tag ): string {
949 if ( ! preg_match( '/\salt=(\"|\')(.*?)\1/i', $img_tag, $matches ) ) {
950 return '';
951 }
952
953 $alt = wp_specialchars_decode( $matches[2], ENT_QUOTES );
954 $alt = sanitize_text_field( $alt );
955
956 return $alt;
957 }
958
959 /**
960 * Retrieve width and height attributes using given width and height values.
961 *
962 * @param array $args Argument array.
963 * @return string Height-width string.
964 */
965 public static function get_image_hwstring( $args = array() ) {
966 $default_args = array(
967 'thumb_html' => self::get_option( 'thumb_html', 'html' ),
968 'thumb_width' => self::get_option( 'thumb_width', 150 ),
969 'thumb_height' => self::get_option( 'thumb_height', 150 ),
970 );
971
972 $args = wp_parse_args( $args, $default_args );
973
974 if ( 'css' === $args['thumb_html'] ) {
975 $thumb_html = ' style="max-width:' . $args['thumb_width'] . 'px;max-height:' . $args['thumb_height'] . 'px;" ';
976 } elseif ( 'html' === $args['thumb_html'] ) {
977 $thumb_html = ' width="' . $args['thumb_width'] . '" height="' . $args['thumb_height'] . '" ';
978 } else {
979 $thumb_html = '';
980 }
981
982 /**
983 * Filters the thumbnail HTML and allows a filter function to add any more HTML if needed.
984 *
985 * @param string $thumb_html Thumbnail HTML.
986 * @param array $args Argument array.
987 */
988 return apply_filters( self::$prefix . '_thumb_html', $thumb_html, $args );
989 }
990
991 /**
992 * Get the first child image in the post.
993 *
994 * @param int|\WP_Post $postid Post ID or WP_Post object.
995 * @param int $thumb_width Thumb width.
996 * @param int $thumb_height Thumb height.
997 * @return string Location of thumbnail.
998 */
999 public static function get_first_image( $postid, int $thumb_width, int $thumb_height ): string {
1000 $args = array(
1001 'numberposts' => 1,
1002 'order' => 'ASC',
1003 'post_mime_type' => 'image',
1004 'post_parent' => $postid,
1005 'post_status' => 'inherit',
1006 'post_type' => 'attachment',
1007 );
1008
1009 $attachments = get_children( $args );
1010
1011 if ( empty( $attachments ) ) {
1012 return '';
1013 }
1014
1015 $attachment = reset( $attachments );
1016 $image_size = array( $thumb_width, $thumb_height );
1017
1018 if ( 0 < $attachment->ID ) {
1019 $image_attributes = wp_get_attachment_image_src( $attachment->ID, $image_size );
1020
1021 if ( empty( $image_attributes ) ) {
1022 $image_attributes = wp_get_attachment_image_src( $attachment->ID, 'full' );
1023 }
1024
1025 if ( ! empty( $image_attributes ) ) {
1026 /**
1027 * Filter the first child image URL.
1028 *
1029 * @param string $image_url URL of the image.
1030 * @param int|\WP_Post $postid Post ID or WP_Post object.
1031 * @param int $thumb_width Thumb width.
1032 * @param int $thumb_height Thumb height.
1033 */
1034 return apply_filters(
1035 self::$prefix . '_get_first_image',
1036 $image_attributes[0],
1037 $postid,
1038 $thumb_width,
1039 $thumb_height
1040 );
1041 }
1042 }
1043
1044 return '';
1045 }
1046
1047 /**
1048 * Get cached attachment ID from URL to prevent database exhaustion.
1049 *
1050 * @param string $attachment_url Attachment URL.
1051 * @return int Attachment ID.
1052 */
1053 public static function get_cached_attachment_id( $attachment_url = '' ) {
1054 $attachment_id = 0;
1055
1056 // If there is no URL, return.
1057 if ( ! $attachment_url ) {
1058 return $attachment_id;
1059 }
1060
1061 // Check cache first.
1062 $cache_key = self::$prefix . '_attachment_id_' . get_current_blog_id() . '_' . hash( 'sha256', $attachment_url );
1063 $cached_id = wp_cache_get( $cache_key, self::$prefix . '_media' );
1064
1065 if ( false !== $cached_id ) {
1066 return (int) $cached_id;
1067 }
1068
1069 // Attempt to retrieve the attachment ID from the URL.
1070 $attachment_id = attachment_url_to_postid( $attachment_url );
1071
1072 // If not found, try stripping the size suffix (e.g., -150x150, -1024x768) and lookup base URL.
1073 if ( 0 === $attachment_id ) {
1074 $base_url = self::get_base_image_url( $attachment_url );
1075 if ( $base_url !== $attachment_url ) {
1076 $attachment_id = attachment_url_to_postid( $base_url );
1077 }
1078 }
1079
1080 // Cache the result for 1 hour.
1081 wp_cache_set( $cache_key, $attachment_id, self::$prefix . '_media', HOUR_IN_SECONDS );
1082
1083 /**
1084 * Filter the cached attachment ID from the attachment URL.
1085 *
1086 * @param int $attachment_id Attachment ID.
1087 * @param string $attachment_url Attachment URL.
1088 */
1089 return apply_filters( self::$prefix . '_get_cached_attachment_id', $attachment_id, $attachment_url );
1090 }
1091
1092 /**
1093 * Get the base image URL by stripping WordPress size suffixes.
1094 *
1095 * Converts URLs like image-150x150.jpg or image-1024x768.jpg to image.jpg
1096 *
1097 * @param string $url Image URL potentially with size suffix.
1098 * @return string Base image URL without size suffix.
1099 */
1100 public static function get_base_image_url( $url ) {
1101 // Remove WordPress size suffix (e.g., -150x150) while retaining filename and extension.
1102 return preg_replace( '/-\d+x\d+(?=\.[^.]+$)/', '', $url );
1103 }
1104
1105 /**
1106 * Function to get the correct height and width of the thumbnail.
1107 *
1108 * @param string $size Image size.
1109 * @return array Width and height. If no width and height is found, then 150 is returned for each.
1110 */
1111 public static function get_thumb_size( $size = 'thumbnail' ) {
1112
1113 // Get thumbnail size.
1114 $thumb_size_array = self::get_all_image_sizes( $size );
1115
1116 if ( isset( $thumb_size_array['width'] ) ) {
1117 $thumb_width = $thumb_size_array['width'];
1118 $thumb_height = $thumb_size_array['height'];
1119 }
1120
1121 if ( isset( $thumb_width ) && isset( $thumb_height ) ) {
1122 $thumb_size = array( $thumb_width, $thumb_height );
1123 } else {
1124 $thumb_size = array( 150, 150 );
1125 }
1126
1127 /**
1128 * Filter array of thumbnail size.
1129 *
1130 * @param array $thumb_size Array with width and height of thumbnail.
1131 */
1132 return apply_filters( self::$prefix . '_get_thumb_size', $thumb_size );
1133 }
1134
1135 /**
1136 * Get all image sizes.
1137 *
1138 * @param string|int[] $size Image size.
1139 * @return array If a single size is specified, then the array with width, height and crop status
1140 * or an empty array if size is not found;
1141 * If no size is specified then an Associative array of the registered image sub-sizes.
1142 */
1143 public static function get_all_image_sizes( $size = '' ) {
1144
1145 if ( is_array( $size ) ) {
1146 $size = self::get_appropriate_image_size( $size[0], $size[1] );
1147 }
1148
1149 $sizes = wp_get_registered_image_subsizes();
1150
1151 /* Get only 1 size if found */
1152 if ( $size ) {
1153 if ( isset( $sizes[ $size ] ) ) {
1154 return $sizes[ $size ];
1155 }
1156 return array();
1157 }
1158
1159 /**
1160 * Filters array of image sizes.
1161 *
1162 * @param array $sizes Image sizes.
1163 */
1164 return apply_filters( self::$prefix . '_get_all_image_sizes', $sizes );
1165 }
1166
1167 /**
1168 * Get the most appropriate image size based on the given thumbnail width and height.
1169 *
1170 * @param int $thumb_width Thumbnail width.
1171 * @param int $thumb_height Thumbnail height.
1172 * @return string|bool Image size name if found, false otherwise.
1173 */
1174 public static function get_appropriate_image_size( $thumb_width, $thumb_height ) {
1175 $sizes = wp_get_registered_image_subsizes();
1176
1177 $closest_size = false;
1178 $closest_distance = PHP_INT_MAX;
1179
1180 foreach ( $sizes as $size_name => $size_info ) {
1181 $size_width = $size_info['width'];
1182 $size_height = $size_info['height'];
1183 $distance = sqrt( pow( $thumb_width - $size_width, 2 ) + pow( $thumb_height - $size_height, 2 ) );
1184
1185 if ( $distance < $closest_distance ) {
1186 $closest_distance = $distance;
1187 $closest_size = $size_name;
1188 }
1189 }
1190
1191 return $closest_size;
1192 }
1193 }
1194