From 2afe98bc99f5b3797e890eb28e2b97a8fb4665b0 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Mon, 27 Apr 2026 12:43:26 -0500 Subject: [PATCH] Improve SportsPress Open Graph images --- assets/fonts/BebasNeue-OFL.txt | 93 +++ assets/fonts/BebasNeue-Regular.ttf | Bin 0 -> 61400 bytes includes/featured-image-generator.php | 1009 ++++++++++++++++++----- includes/open-graph-tags.php | 617 ++++++++++---- tests/test-featured-image-generator.php | 222 +++++ tests/test-open-graph-tags.php | 271 ++++++ 6 files changed, 1868 insertions(+), 344 deletions(-) create mode 100644 assets/fonts/BebasNeue-OFL.txt create mode 100644 assets/fonts/BebasNeue-Regular.ttf create mode 100644 tests/test-featured-image-generator.php create mode 100644 tests/test-open-graph-tags.php diff --git a/assets/fonts/BebasNeue-OFL.txt b/assets/fonts/BebasNeue-OFL.txt new file mode 100644 index 0000000..da95714 --- /dev/null +++ b/assets/fonts/BebasNeue-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2010 by Dharma Type. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/BebasNeue-Regular.ttf b/assets/fonts/BebasNeue-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c328c6e08b20a20a1de47d823e007ee73812a438 GIT binary patch literal 61400 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 literal 0 HcmV?d00001 diff --git a/includes/featured-image-generator.php b/includes/featured-image-generator.php index 7c19259..6d78b39 100644 --- a/includes/featured-image-generator.php +++ b/includes/featured-image-generator.php @@ -1,207 +1,844 @@ $max_width || $logo1_height > $max_height) { - $aspect_ratio1 = $logo1_width / $logo1_height; - if ($logo1_width / $max_width > $logo1_height / $max_height) { - $new_logo1_width = $max_width; - $new_logo1_height = $max_width / $aspect_ratio1; - } else { - $new_logo1_height = $max_height; - $new_logo1_width = $max_height * $aspect_ratio1; - } - } - - // Center logo 1 - $logo1_x = (int) ($width / 4) - ($new_logo1_width / 2); - $logo1_y = (int) ($height / 2) - ($new_logo1_height / 2); - imagecopyresampled($image, $logo1, $logo1_x, $logo1_y, 0, 0, $new_logo1_width, $new_logo1_height, $logo1_width, $logo1_height); - imagedestroy($logo1); - } - - if (!empty($logo2_path)) { - $logo2 = imagecreatefrompng($logo2_path); - $logo2_width = imagesx($logo2); - $logo2_height = imagesy($logo2); - - // Calculate max dimensions for logo 2 - $max_width = ($width / 2) - (2 * $x_margin); - $max_height = $height - (2 * $y_margin); - - // Resize logo 2 - $new_logo2_width = $logo2_width; - $new_logo2_height = $logo2_height; - if ($logo2_width > $max_width || $logo2_height > $max_height) { - $aspect_ratio2 = $logo2_width / $logo2_height; - if ($logo2_width / $max_width > $logo2_height / $max_height) { - $new_logo2_width = $max_width; - $new_logo2_height = $max_width / $aspect_ratio2; - } else { - $new_logo2_height = $max_height; - $new_logo2_width = $max_height * $aspect_ratio2; - } - } - - // Center logo 2 - $logo2_x = (int) (3 * $width / 4) - ($new_logo2_width / 2); - $logo2_y = (int) ($height / 2) - ($new_logo2_height / 2); - imagecopyresampled($image, $logo2, $logo2_x, $logo2_y, 0, 0, $new_logo2_width, $new_logo2_height, $logo2_width, $logo2_height); - imagedestroy($logo2); - } - - // Start output buffering to capture the image data - ob_start(); - imagepng($image); // Output the image as PNG - $image_data = ob_get_clean(); // Get the image data from the buffer - - // Clean up memory - imagedestroy($image); - - return $image_data; +/** + * Dynamic SportsPress event matchup image endpoint. + * + * @package Tonys_Sportspress_Enhancements + */ +if ( ! defined( 'ASC_SP_EVENT_IMAGE_OPTION_KEY' ) ) { + define( 'ASC_SP_EVENT_IMAGE_OPTION_KEY', 'asc_sp_event_image_settings' ); } +if ( ! defined( 'ASC_SP_EVENT_IMAGE_OPTION_GROUP' ) ) { + define( 'ASC_SP_EVENT_IMAGE_OPTION_GROUP', 'asc_sp_event_image_settings' ); +} + +if ( ! defined( 'ASC_SP_EVENT_IMAGE_SETTINGS_TAB' ) ) { + define( 'ASC_SP_EVENT_IMAGE_SETTINGS_TAB', 'open-graph-images' ); +} + +if ( ! defined( 'ASC_SP_EVENT_IMAGE_CACHE_VERSION' ) ) { + define( 'ASC_SP_EVENT_IMAGE_CACHE_VERSION', '7' ); +} + +/** + * Default image generator settings. + * + * @return array + */ +function asc_sp_event_image_default_settings() { + return array( + 'fallback_left_background' => '#4B5563', + 'fallback_right_background' => '#4B5563', + 'fallback_text_color' => '#F9FAFB', + 'fallback_shadow_color' => '#1F2937', + ); +} + +/** + * Get image generator settings. + * + * @return array + */ +function asc_sp_event_image_get_settings() { + return wp_parse_args( get_option( ASC_SP_EVENT_IMAGE_OPTION_KEY, array() ), asc_sp_event_image_default_settings() ); +} + +/** + * Sanitize and validate a hex color. + * + * @param string $color Color value. + * @param string $fallback Fallback color. + * @return string + */ +function asc_sp_event_image_color( $color, $fallback = '#4B5563' ) { + return is_string( $color ) && preg_match( '/^#[a-fA-F0-9]{6}$/', $color ) ? strtoupper( $color ) : strtoupper( $fallback ); +} + +/** + * Sanitize image generator settings. + * + * @param mixed $input Raw settings. + * @return array + */ +function asc_sp_event_image_sanitize_settings( $input ) { + $defaults = asc_sp_event_image_default_settings(); + $input = is_array( $input ) ? $input : array(); + + return array( + 'fallback_left_background' => asc_sp_event_image_color( isset( $input['fallback_left_background'] ) ? $input['fallback_left_background'] : '', $defaults['fallback_left_background'] ), + 'fallback_right_background' => asc_sp_event_image_color( isset( $input['fallback_right_background'] ) ? $input['fallback_right_background'] : '', $defaults['fallback_right_background'] ), + 'fallback_text_color' => asc_sp_event_image_color( isset( $input['fallback_text_color'] ) ? $input['fallback_text_color'] : '', $defaults['fallback_text_color'] ), + 'fallback_shadow_color' => asc_sp_event_image_color( isset( $input['fallback_shadow_color'] ) ? $input['fallback_shadow_color'] : '', $defaults['fallback_shadow_color'] ), + ); +} + +/** + * Allocate a GD color from a hex value. + * + * @param GdImage|resource $image Destination image. + * @param string $hex Hex color. + * @param string $fallback Fallback color. + * @return int|false + */ +function asc_sp_event_image_allocate_hex_color( $image, $hex, $fallback = '#4B5563' ) { + $rgb = sscanf( asc_sp_event_image_color( $hex, $fallback ), '#%02x%02x%02x' ); + + return imagecolorallocate( $image, $rgb[0], $rgb[1], $rgb[2] ); +} + +/** + * Get cache style hash for image settings and generator version. + * + * @return string + */ +function asc_sp_event_image_cache_style_hash() { + return substr( md5( wp_json_encode( asc_sp_event_image_get_settings() ) . '|' . ASC_SP_EVENT_IMAGE_CACHE_VERSION ), 0, 10 ); +} + +/** + * Public image URL version token. + * + * @return string + */ +function asc_sp_event_image_url_version() { + return ASC_SP_EVENT_IMAGE_CACHE_VERSION . '-' . asc_sp_event_image_cache_style_hash(); +} + +/** + * Supported image variants. + * + * @return array + */ +function asc_sp_event_image_variants() { + return array( + 'wide' => array( + 'width' => 1200, + 'height' => 628, + ), + 'square' => array( + 'width' => 1200, + 'height' => 1200, + ), + ); +} + +/** + * Sanitize an image variant. + * + * @param string $variant Variant. + * @return string + */ +function asc_sp_event_image_sanitize_variant( $variant ) { + $variant = sanitize_key( $variant ); + $variants = asc_sp_event_image_variants(); + + return isset( $variants[ $variant ] ) ? $variant : 'wide'; +} + +/** + * Get dimensions for an image variant. + * + * @param string $variant Variant. + * @return array{width:int,height:int} + */ +function asc_sp_event_image_variant_dimensions( $variant ) { + $variants = asc_sp_event_image_variants(); + $variant = asc_sp_event_image_sanitize_variant( $variant ); + + return $variants[ $variant ]; +} + +/** + * Register Tony's Settings tab. + * + * @param array $tabs Existing tabs. + * @return array + */ +function asc_sp_event_image_register_settings_tab( $tabs ) { + $tabs[ ASC_SP_EVENT_IMAGE_SETTINGS_TAB ] = __( 'Open Graph Images', 'tonys-sportspress-enhancements' ); + + return $tabs; +} +add_filter( 'tse_tonys_settings_tabs', 'asc_sp_event_image_register_settings_tab' ); + +/** + * Register image generator settings. + */ +function asc_sp_event_image_register_settings() { + register_setting( + ASC_SP_EVENT_IMAGE_OPTION_GROUP, + ASC_SP_EVENT_IMAGE_OPTION_KEY, + array( + 'type' => 'array', + 'sanitize_callback' => 'asc_sp_event_image_sanitize_settings', + 'default' => asc_sp_event_image_default_settings(), + ) + ); +} +add_action( 'admin_init', 'asc_sp_event_image_register_settings' ); + +/** + * Capability required to save image generator settings. + * + * @return string + */ +function asc_sp_event_image_settings_capability() { + return 'manage_sportspress'; +} +add_filter( 'option_page_capability_' . ASC_SP_EVENT_IMAGE_OPTION_GROUP, 'asc_sp_event_image_settings_capability' ); + +/** + * Render a color setting row. + * + * @param string $key Setting key. + * @param string $label Field label. + * @param string $help Help text. + * @param array $settings Current settings. + */ +function asc_sp_event_image_render_color_row( $key, $label, $help, array $settings ) { + $value = isset( $settings[ $key ] ) ? asc_sp_event_image_color( $settings[ $key ], asc_sp_event_image_default_settings()[ $key ] ) : asc_sp_event_image_default_settings()[ $key ]; + $name = ASC_SP_EVENT_IMAGE_OPTION_KEY . '[' . $key . ']'; + $id = 'asc-sp-event-image-' . str_replace( '_', '-', $key ); + + echo ''; + echo ''; + echo ''; + echo ''; + echo ' ' . esc_html( $value ) . ''; + echo '

' . esc_html( $help ) . '

'; + echo ''; + echo ''; +} + +/** + * Render Tony's Settings Open Graph image tab. + */ +function asc_sp_event_image_render_settings_tab() { + if ( ! current_user_can( 'manage_sportspress' ) ) { + return; + } + + $settings = asc_sp_event_image_get_settings(); + + settings_errors( ASC_SP_EVENT_IMAGE_OPTION_GROUP ); + + echo '
'; + settings_fields( ASC_SP_EVENT_IMAGE_OPTION_GROUP ); + + echo '

' . esc_html__( 'Open Graph Matchup Images', 'tonys-sportspress-enhancements' ) . '

'; + echo '

' . esc_html__( 'Control the generated social preview image used for SportsPress events when a team logo is missing or a team color is not available.', 'tonys-sportspress-enhancements' ) . '

'; + echo '

' . esc_html__( 'The image cache key includes these values, so saving changes forces future image requests to generate fresh files.', 'tonys-sportspress-enhancements' ) . '

'; + + echo ''; + asc_sp_event_image_render_color_row( 'fallback_left_background', __( 'Default Left Background', 'tonys-sportspress-enhancements' ), __( 'Neutral grey used when the first displayed team does not have a valid primary color.', 'tonys-sportspress-enhancements' ), $settings ); + asc_sp_event_image_render_color_row( 'fallback_right_background', __( 'Default Right Background', 'tonys-sportspress-enhancements' ), __( 'Neutral grey used when the second displayed team does not have a valid primary color.', 'tonys-sportspress-enhancements' ), $settings ); + asc_sp_event_image_render_color_row( 'fallback_text_color', __( 'Fallback Team Text', 'tonys-sportspress-enhancements' ), __( 'Large team-name text drawn when a logo is missing.', 'tonys-sportspress-enhancements' ), $settings ); + asc_sp_event_image_render_color_row( 'fallback_shadow_color', __( 'Text Shadow', 'tonys-sportspress-enhancements' ), __( 'A subtle offset shadow for readability. No heavy outline is drawn.', 'tonys-sportspress-enhancements' ), $settings ); + echo ''; + + echo '
'; + echo '
'; + echo '
'; + echo '' . esc_html__( 'HAWKS', 'tonys-sportspress-enhancements' ) . '' . esc_html__( 'ELECTRONS', 'tonys-sportspress-enhancements' ) . ''; + echo '
'; + + submit_button( __( 'Save Settings', 'tonys-sportspress-enhancements' ) ); + echo '
'; +} +add_action( 'tse_tonys_settings_render_tab_' . ASC_SP_EVENT_IMAGE_SETTINGS_TAB, 'asc_sp_event_image_render_settings_tab' ); + +/** + * Load a raster logo with GD when the local install supports the format. + * + * @param string $path Local image path. + * @return GdImage|resource|false + */ +function asc_sp_event_image_create_from_file( $path ) { + if ( ! is_string( $path ) || '' === $path || ! is_readable( $path ) ) { + return false; + } + + $image_type = function_exists( 'exif_imagetype' ) ? @exif_imagetype( $path ) : false; + + if ( IMAGETYPE_PNG === $image_type && function_exists( 'imagecreatefrompng' ) ) { + return @imagecreatefrompng( $path ); + } + + if ( IMAGETYPE_JPEG === $image_type && function_exists( 'imagecreatefromjpeg' ) ) { + return @imagecreatefromjpeg( $path ); + } + + if ( IMAGETYPE_GIF === $image_type && function_exists( 'imagecreatefromgif' ) ) { + return @imagecreatefromgif( $path ); + } + + if ( defined( 'IMAGETYPE_WEBP' ) && IMAGETYPE_WEBP === $image_type && function_exists( 'imagecreatefromwebp' ) ) { + return @imagecreatefromwebp( $path ); + } + + return false; +} + +/** + * Destroy a GD image on PHP versions where that still has an effect. + * + * @param GdImage|resource $image Image resource. + */ +function asc_sp_event_image_destroy( $image ) { + if ( defined( 'PHP_VERSION_ID' ) && PHP_VERSION_ID >= 80500 ) { + return; + } + + if ( $image ) { + imagedestroy( $image ); + } +} + +/** + * Get the bundled fallback font path. + * + * @return string + */ +function asc_sp_event_image_font_path() { + if ( defined( 'TONY_SPORTSPRESS_ENHANCEMENTS_DIR' ) ) { + return TONY_SPORTSPRESS_ENHANCEMENTS_DIR . 'assets/fonts/BebasNeue-Regular.ttf'; + } + + return dirname( __DIR__ ) . '/assets/fonts/BebasNeue-Regular.ttf'; +} + +/** + * Measure TrueType text dimensions. + * + * @param int $font_size Font size. + * @param string $font_path Font path. + * @param string $text Text. + * @return array{width:int,height:int} + */ +function asc_sp_event_image_ttf_text_size( $font_size, $font_path, $text ) { + $box = imagettfbbox( $font_size, 0, $font_path, $text ); + + if ( ! is_array( $box ) ) { + return array( + 'width' => 0, + 'height' => 0, + ); + } + + return array( + 'width' => absint( max( $box[2], $box[4] ) - min( $box[0], $box[6] ) ), + 'height' => absint( max( $box[1], $box[3] ) - min( $box[5], $box[7] ) ), + ); +} + +/** + * Wrap text for a TrueType bounding box. + * + * @param string $text Text. + * @param string $font_path Font path. + * @param int $font_size Font size. + * @param int $max_width Maximum line width. + * @return string[] + */ +function asc_sp_event_image_wrap_ttf_text( $text, $font_path, $font_size, $max_width ) { + $words = preg_split( '/\s+/', trim( $text ) ); + + if ( ! is_array( $words ) || empty( $words ) ) { + return array(); + } + + $lines = array(); + $line = ''; + + foreach ( $words as $word ) { + $candidate = '' === $line ? $word : "{$line} {$word}"; + $size = asc_sp_event_image_ttf_text_size( $font_size, $font_path, $candidate ); + + if ( $size['width'] <= $max_width || '' === $line ) { + $line = $candidate; + continue; + } + + $lines[] = $line; + $line = $word; + } + + if ( '' !== $line ) { + $lines[] = $line; + } + + return $lines; +} + +/** + * Draw large fallback team text with the bundled sporty font. + * + * @param GdImage|resource $image Destination image. + * @param string $text Text to draw. + * @param int $center Center x-coordinate. + * @param int $width Canvas width. + * @param int $height Canvas height. + * @param int|null $center_y Center y-coordinate. + * @return bool True when drawn. + */ +function asc_sp_event_image_draw_ttf_team_text( $image, $text, $center, $width, $height, $center_y = null ) { + $font_path = asc_sp_event_image_font_path(); + + if ( ! function_exists( 'imagettftext' ) || ! function_exists( 'imagettfbbox' ) || ! is_readable( $font_path ) ) { + return false; + } + + $text = strtoupper( trim( wp_strip_all_tags( (string) $text ) ) ); + + if ( '' === $text ) { + return false; + } + + $center_y = null === $center_y ? (int) ( $height / 2 ) : (int) $center_y; + $half_width = (int) ( $width / 2 ); + $min_x = $center < $half_width ? 48 : $half_width + 48; + $max_x = $center < $half_width ? $half_width - 48 : $width - 48; + $max_width = $max_x - $min_x; + $max_height = (int) ( $height * 0.68 ); + $font_size = 190; + $lines = array( $text ); + $line_gap = 12; + + while ( $font_size >= 42 ) { + $lines = asc_sp_event_image_wrap_ttf_text( $text, $font_path, $font_size, $max_width ); + $line_heights = array(); + $widest = 0; + + foreach ( $lines as $line ) { + $size = asc_sp_event_image_ttf_text_size( $font_size, $font_path, $line ); + $line_heights[] = $size['height']; + $widest = max( $widest, $size['width'] ); + } + + $total_height = array_sum( $line_heights ) + max( 0, count( $lines ) - 1 ) * $line_gap; + + if ( $widest <= $max_width && $total_height <= $max_height && count( $lines ) <= 3 ) { + break; + } + + $font_size -= 6; + } + + $line_heights = array(); + $total_height = 0; + + foreach ( $lines as $line ) { + $size = asc_sp_event_image_ttf_text_size( $font_size, $font_path, $line ); + $line_heights[] = $size['height']; + $total_height += $size['height']; + } + + $total_height += max( 0, count( $lines ) - 1 ) * $line_gap; + $y = (int) ( $center_y - ( $total_height / 2 ) ); + $settings = asc_sp_event_image_get_settings(); + $fill = asc_sp_event_image_allocate_hex_color( $image, $settings['fallback_text_color'], '#F9FAFB' ); + $shadow = asc_sp_event_image_allocate_hex_color( $image, $settings['fallback_shadow_color'], '#1F2937' ); + + foreach ( $lines as $index => $line ) { + $size = asc_sp_event_image_ttf_text_size( $font_size, $font_path, $line ); + $x = (int) ( $center - ( $size['width'] / 2 ) ); + $x = max( $min_x, min( $x, $max_x - $size['width'] ) ); + $y += $line_heights[ $index ]; + + imagettftext( $image, $font_size, 0, $x + 4, $y + 5, $shadow, $font_path, $line ); + imagettftext( $image, $font_size, 0, $x, $y, $fill, $font_path, $line ); + $y += $line_gap; + } + + return true; +} + +/** + * Draw fallback team text when no readable logo is available. + * + * @param GdImage|resource $image Destination image. + * @param string $text Text to draw. + * @param int $center Center x-coordinate. + * @param int $width Canvas width. + * @param int $height Canvas height. + * @param int|null $center_y Center y-coordinate. + */ +function asc_sp_event_image_draw_team_text( $image, $text, $center, $width, $height, $center_y = null ) { + $text = trim( wp_strip_all_tags( (string) $text ) ); + + if ( '' === $text ) { + return; + } + + if ( asc_sp_event_image_draw_ttf_team_text( $image, $text, $center, $width, $height, $center_y ) ) { + return; + } + + $center_y = null === $center_y ? (int) ( $height / 2 ) : (int) $center_y; + $font = 5; + $lines = explode( "\n", wordwrap( strtoupper( $text ), 14, "\n", true ) ); + $lines = array_slice( $lines, 0, 3 ); + + $line_height = imagefontheight( $font ) + 8; + $total = count( $lines ) * $line_height; + $y = (int) ( $center_y - ( $total / 2 ) ); + $settings = asc_sp_event_image_get_settings(); + $fill = asc_sp_event_image_allocate_hex_color( $image, $settings['fallback_text_color'], '#F9FAFB' ); + $shadow = asc_sp_event_image_allocate_hex_color( $image, $settings['fallback_shadow_color'], '#1F2937' ); + $half_width = (int) ( $width / 2 ); + + foreach ( $lines as $line ) { + $line = trim( $line ); + $text_width = imagefontwidth( $font ) * strlen( $line ); + $x = (int) ( $center - ( $text_width / 2 ) ); + $min_x = $center < $half_width ? 12 : $half_width + 12; + $max_x = $center < $half_width ? $half_width - $text_width - 12 : $width - $text_width - 12; + $x = max( $min_x, min( $x, $max_x ) ); + + imagestring( $image, $font, $x + 2, $y + 2, $line, $shadow ); + imagestring( $image, $font, $x, $y, $line, $fill ); + $y += $line_height; + } +} + +/** + * Place a logo on one half of the canvas, falling back to text. + * + * @param GdImage|resource $image Destination image. + * @param string $logo_path Logo path. + * @param string $fallback Fallback text. + * @param int $center Center x-coordinate. + * @param int $width Canvas width. + * @param int $height Canvas height. + * @param int|null $center_y Center y-coordinate. + */ +function asc_sp_event_image_place_logo_or_text( $image, $logo_path, $fallback, $center, $width, $height, $center_y = null ) { + $x_margin = 0.1 * ( $width / 2 ); + $y_margin = 0.1 * $height; + $logo = asc_sp_event_image_create_from_file( $logo_path ); + $center_y = null === $center_y ? (int) ( $height / 2 ) : (int) $center_y; + + if ( ! $logo ) { + asc_sp_event_image_draw_team_text( $image, $fallback, $center, $width, $height, $center_y ); + return; + } + + imagealphablending( $logo, true ); + imagesavealpha( $logo, true ); + + $logo_width = imagesx( $logo ); + $logo_height = imagesy( $logo ); + + if ( $logo_width <= 0 || $logo_height <= 0 ) { + asc_sp_event_image_destroy( $logo ); + asc_sp_event_image_draw_team_text( $image, $fallback, $center, $width, $height, $center_y ); + return; + } + + $max_width = ( $width / 2 ) - ( 2 * $x_margin ); + $max_height = $height - ( 2 * $y_margin ); + $new_width = $logo_width; + $new_height = $logo_height; + + if ( $logo_width > $max_width || $logo_height > $max_height ) { + $aspect_ratio = $logo_width / $logo_height; + + if ( $logo_width / $max_width > $logo_height / $max_height ) { + $new_width = $max_width; + $new_height = $max_width / $aspect_ratio; + } else { + $new_height = $max_height; + $new_width = $max_height * $aspect_ratio; + } + } + + $logo_x = (int) ( $center - ( $new_width / 2 ) ); + $logo_y = (int) ( $center_y - ( $new_height / 2 ) ); + + imagecopyresampled( $image, $logo, $logo_x, $logo_y, 0, 0, (int) $new_width, (int) $new_height, $logo_width, $logo_height ); + asc_sp_event_image_destroy( $logo ); +} + +/** + * Generate a PNG matchup image. + * + * @param string $color1 Left color. + * @param string $color2 Right color. + * @param string $logo1_path Left logo path. + * @param string $logo2_path Right logo path. + * @param string $team1_fallback Left fallback text. + * @param string $team2_fallback Right fallback text. + * @param int $width Image width. + * @param int $height Image height. + * @return string + */ +function generate_bisected_image( $color1, $color2, $logo1_path, $logo2_path, $team1_fallback = '', $team2_fallback = '', $width = 1200, $height = 628 ) { + $width = max( 1, absint( $width ) ); + $height = max( 1, absint( $height ) ); + $image = imagecreatetruecolor( $width, $height ); + $settings = asc_sp_event_image_get_settings(); + + imagealphablending( $image, true ); + imagesavealpha( $image, true ); + + $color1_alloc = asc_sp_event_image_allocate_hex_color( $image, $color1, $settings['fallback_left_background'] ); + $color2_alloc = asc_sp_event_image_allocate_hex_color( $image, $color2, $settings['fallback_right_background'] ); + + $points1 = array( + 0, + 0, + 0, + $height, + $width * .40, + $height, + $width * .60, + 0, + ); + $points2 = array( + $width, + 0, + $width, + $height, + $width * .40, + $height, + $width * .60, + 0, + ); + + imagefilledpolygon( $image, $points1, $color1_alloc ); + imagefilledpolygon( $image, $points2, $color2_alloc ); + + $left_center_y = (int) ( $height / 2 ); + $right_center_y = (int) ( $height / 2 ); + + if ( $width === $height ) { + $left_center_y = (int) ( $height * 0.28 ); + $right_center_y = (int) ( $height * 0.72 ); + } + + asc_sp_event_image_place_logo_or_text( $image, $logo1_path, $team1_fallback, (int) ( $width / 4 ), $width, $height, $left_center_y ); + asc_sp_event_image_place_logo_or_text( $image, $logo2_path, $team2_fallback, (int) ( 3 * $width / 4 ), $width, $height, $right_center_y ); + + ob_start(); + imagepng( $image ); + $image_data = ob_get_clean(); + + asc_sp_event_image_destroy( $image ); + + return $image_data; +} + +/** + * Register the image endpoint. + */ function add_image_generator_endpoint() { - add_rewrite_endpoint('head-to-head', EP_ROOT, true); + add_rewrite_endpoint( 'head-to-head', EP_ROOT, true ); } -add_action('init', 'add_image_generator_endpoint'); +add_action( 'init', 'add_image_generator_endpoint' ); +/** + * Return a clean 404 response for bad image requests. + * + * @param string $message Response body. + */ +function asc_sp_event_image_not_found( $message = 'Image not found.' ) { + status_header( 404 ); + nocache_headers(); + + while ( ob_get_level() ) { + ob_end_clean(); + } + + echo esc_html( $message ); + exit; +} + +/** + * Get a sanitized post ID from the request. + * + * @return int + */ +function asc_sp_event_image_request_post_id() { + if ( ! isset( $_GET['post'] ) ) { + return 0; + } + + $post_id = wp_unslash( $_GET['post'] ); + + if ( is_array( $post_id ) ) { + return 0; + } + + return absint( $post_id ); +} + +/** + * Get the requested image variant. + * + * @return string + */ +function asc_sp_event_image_request_variant() { + if ( ! isset( $_GET['variant'] ) ) { + return 'wide'; + } + + $variant = wp_unslash( $_GET['variant'] ); + + if ( is_array( $variant ) ) { + return 'wide'; + } + + return asc_sp_event_image_sanitize_variant( $variant ); +} + +/** + * Prepare image request data, or a WP_Error for 404 handling. + * + * @param int $post_id Event post ID. + * @param string $variant Image variant. + * @return array|WP_Error + */ +function asc_sp_event_prepare_image_request( $post_id, $variant = 'wide' ) { + $post_id = absint( $post_id ); + $post = get_post( $post_id ); + $variant = asc_sp_event_image_sanitize_variant( $variant ); + $dimensions = asc_sp_event_image_variant_dimensions( $variant ); + + if ( ! $post || 'sp_event' !== $post->post_type ) { + return new WP_Error( 'invalid_event', __( 'Invalid event image request.', 'tonys-sportspress-enhancements' ) ); + } + + if ( function_exists( 'asc_sp_event_team_ids' ) ) { + $team_ids = asc_sp_event_team_ids( $post ); + } else { + $team_ids = array(); + + foreach ( get_post_meta( $post_id, 'sp_team', false ) as $team_id ) { + while ( is_array( $team_id ) ) { + $team_id = array_shift( array_filter( $team_id ) ); + } + + $team_id = absint( $team_id ); + if ( $team_id > 0 ) { + $team_ids[] = $team_id; + } + } + } + + $team_ids = array_values( array_unique( $team_ids ) ); + + if ( count( $team_ids ) < 2 ) { + return new WP_Error( 'missing_teams', __( 'Event image request is missing teams.', 'tonys-sportspress-enhancements' ) ); + } + + $team1_id = $team_ids[0]; + $team2_id = $team_ids[1]; + $team1 = get_post( $team1_id ); + $team2 = get_post( $team2_id ); + + if ( ! $team1 || ! $team2 ) { + return new WP_Error( 'invalid_teams', __( 'Event image request has invalid teams.', 'tonys-sportspress-enhancements' ) ); + } + + $settings = asc_sp_event_image_get_settings(); + $team1_colors = get_post_meta( $team1_id, 'sp_colors', true ); + $team2_colors = get_post_meta( $team2_id, 'sp_colors', true ); + $team1_color = is_array( $team1_colors ) && ! empty( $team1_colors['primary'] ) ? $team1_colors['primary'] : $settings['fallback_left_background']; + $team2_color = is_array( $team2_colors ) && ! empty( $team2_colors['primary'] ) ? $team2_colors['primary'] : $settings['fallback_right_background']; + + $team1_logo_thumbnail_id = get_post_thumbnail_id( $team1_id ); + $team2_logo_thumbnail_id = get_post_thumbnail_id( $team2_id ); + $team1_logo = $team1_logo_thumbnail_id ? get_attached_file( $team1_logo_thumbnail_id ) : ''; + $team2_logo = $team2_logo_thumbnail_id ? get_attached_file( $team2_logo_thumbnail_id ) : ''; + $team1_modified = strtotime( $team1->post_modified ); + $team2_modified = strtotime( $team2->post_modified ); + + return array( + 'cache_key' => 'team_image_v' . ASC_SP_EVENT_IMAGE_CACHE_VERSION . '_' . asc_sp_event_image_cache_style_hash() . "_{$variant}_{$team1_id}_{$team1_modified}-{$team2_id}_{$team2_modified}", + 'variant' => $variant, + 'width' => $dimensions['width'], + 'height' => $dimensions['height'], + 'team1_color' => asc_sp_event_image_color( $team1_color, $settings['fallback_left_background'] ), + 'team2_color' => asc_sp_event_image_color( $team2_color, $settings['fallback_right_background'] ), + 'team1_logo' => $team1_logo, + 'team2_logo' => $team2_logo, + 'team1_fallback' => function_exists( 'asc_sp_team_short_name' ) ? asc_sp_team_short_name( $team1_id ) : get_the_title( $team1_id ), + 'team2_fallback' => function_exists( 'asc_sp_team_short_name' ) ? asc_sp_team_short_name( $team2_id ) : get_the_title( $team2_id ), + ); +} + +/** + * Handle the image endpoint request. + */ function handle_image_request() { - if (!isset($_GET['post'])) return; - - $post_id = $_GET['post']; - $post = get_post($post_id); + if ( ! isset( $_GET['post'] ) ) { + return; + } - // Verify post type - if (!$post && $post->post_type !== 'sp_event') return; + $request = asc_sp_event_prepare_image_request( asc_sp_event_image_request_post_id(), asc_sp_event_image_request_variant() ); - // Get associated teams from post meta - $team_ids = get_post_meta($post_id, 'sp_team', false); // false to get an array of values + if ( is_wp_error( $request ) ) { + asc_sp_event_image_not_found( $request->get_error_message() ); + } - // Ensure we have exactly two teams - if (count($team_ids) < 2) return; + $cached_image_path = get_transient( $request['cache_key'] ); - $team1_id = $team_ids[0]; - $team2_id = $team_ids[1]; + if ( $cached_image_path && file_exists( $cached_image_path ) ) { + serve_image( $cached_image_path ); + exit; + } - $team1 = get_post($team1_id); - $team2 = get_post($team2_id); - $team1_postmodified = strtotime($team1->post_modified); - $team2_postmodified = strtotime($team2->post_modified); + $image_data = generate_bisected_image( + $request['team1_color'], + $request['team2_color'], + $request['team1_logo'], + $request['team2_logo'], + $request['team1_fallback'], + $request['team2_fallback'], + $request['width'], + $request['height'] + ); + $image_path = save_image_to_cache( $image_data, $request['cache_key'] ); - $cache_key = "team_image_{$team1_id}_{$team1_postmodified}-{$team2_id}_{$team2_postmodified}"; - $cached_image_path = get_transient($cache_key); - - if ($cached_image_path && file_exists($cached_image_path)) { - serve_image($cached_image_path); - exit; - } - - // Get team colors and logos - $team1_colors = get_post_meta($team1_id, 'sp_colors', true); - $team2_colors = get_post_meta($team2_id, 'sp_colors', true); - - $default_color = '#FFFFFF'; // Default color (black) - $team1_color = !empty($team1_colors['primary']) ? $team1_colors['primary'] : $default_color; - $team2_color = !empty($team2_colors['primary']) ? $team2_colors['primary'] : $default_color; - - // Security check for hex color - $team1_color = preg_match('/^#[a-fA-F0-9]{6}$/', $team1_color) ? $team1_color : '#FFFFFF'; - $team2_color = preg_match('/^#[a-fA-F0-9]{6}$/', $team2_color) ? $team2_color : '#FFFFFF'; - - $team1_logo_url = get_the_post_thumbnail_url($team1_id, 'full'); - $team2_logo_url = get_the_post_thumbnail_url($team2_id, 'full'); - - // Check if both team colors are default and both logos are empty - if (($team1_color === $default_color && empty($team1_logo_url)) && ($team2_color === $default_color && empty($team2_logo_url))) { - return; // Do nothing if both teams have no valid color or logo - } - - $team1_logo_thumbnail_id = get_post_thumbnail_id($team1_id, 'full'); - $team2_logo_thumbnail_id = get_post_thumbnail_id($team2_id, 'full'); - $team1_logo = get_attached_file($team1_logo_thumbnail_id); - $team2_logo = get_attached_file($team2_logo_thumbnail_id); - - // Generate the image if no valid cache exists - $image_data = generate_bisected_image($team1_color, $team2_color, $team1_logo, $team2_logo); - $image_path = save_image_to_cache($image_data, $cache_key); - set_transient($cache_key, $image_path, DAY_IN_SECONDS * 30); // Cache for 30 days - - serve_image($image_path); - - exit; + set_transient( $request['cache_key'], $image_path, DAY_IN_SECONDS * 30 ); + serve_image( $image_path ); + exit; } -add_action('template_redirect', 'handle_image_request'); +add_action( 'template_redirect', 'handle_image_request' ); -function serve_image($image_path) { - header('Content-Type: image/png'); - if (file_exists($image_path)) { - status_header( 200 ); - } else { - status_header( 404 ); - die("Image not found."); - } +/** + * Serve a cached image file. + * + * @param string $image_path Local image path. + */ +function serve_image( $image_path ) { + if ( ! file_exists( $image_path ) ) { + asc_sp_event_image_not_found(); + } - // Clear all output buffering to prevent any extra output - while (ob_get_level()) { - ob_end_clean(); - } - readfile($image_path); + status_header( 200 ); + header( 'Content-Type: image/png' ); + + while ( ob_get_level() ) { + ob_end_clean(); + } + + readfile( $image_path ); } -function save_image_to_cache($image_data, $cache_key) { - $upload_dir = wp_get_upload_dir(); - $file_path = $upload_dir['path'] . '/' . $cache_key . '.png'; +/** + * Save generated image data to the upload cache. + * + * @param string $image_data Raw PNG bytes. + * @param string $cache_key Cache key. + * @return string + */ +function save_image_to_cache( $image_data, $cache_key ) { + $upload_dir = wp_get_upload_dir(); + $file_path = trailingslashit( $upload_dir['path'] ) . sanitize_file_name( $cache_key ) . '.png'; - // Assuming $image_data is raw image data - file_put_contents($file_path, $image_data); + file_put_contents( $file_path, $image_data ); - return $file_path; -} \ No newline at end of file + return $file_path; +} diff --git a/includes/open-graph-tags.php b/includes/open-graph-tags.php index e0f88f8..4574c1b 100644 --- a/includes/open-graph-tags.php +++ b/includes/open-graph-tags.php @@ -1,51 +1,156 @@ post_type ) { + if ( ! $post ) { return ''; } - return get_site_url() . '/head-to-head?post=' . $post->ID; + $args = array( + 'post' => $post->ID, + ); + + if ( 'wide' !== $variant ) { + $args['variant'] = $variant; + } + + if ( function_exists( 'asc_sp_event_image_url_version' ) ) { + $args['v'] = asc_sp_event_image_url_version(); + } + + return add_query_arg( $args, home_url( '/head-to-head' ) ); } -function asc_generate_sp_event_title( $post ) { - // See https://github.com/ThemeBoy/SportsPress/blob/770fa8c6654d7d6648791e877709c2428677635b/includes/admin/post-types/class-sp-admin-cpt-event.php#L99C40-L99C55 - if ( is_numeric( $post ) ) { - $post = get_post( $post ); - } - if ( ! $post || $post->post_type !== 'sp_event' ) { - return get_the_title(); +/** + * Build Open Graph image descriptors for an event. + * + * @param WP_Post $post Event post. + * @return array> + */ +function asc_sp_event_open_graph_images( WP_Post $post ) { + return array( + array( + 'url' => asc_sp_event_matchup_image_url( $post, 'wide' ), + 'width' => '1200', + 'height' => '628', + ), + array( + 'url' => asc_sp_event_matchup_image_url( $post, 'square' ), + 'width' => '1200', + 'height' => '1200', + ), + ); +} + +/** + * Normalize an event post argument. + * + * @param int|WP_Post|null $post Post object or ID. + * @return WP_Post|null + */ +function asc_sp_event_get_post( $post = null ) { + if ( null === $post ) { + $post = get_post(); + } elseif ( is_numeric( $post ) ) { + $post = get_post( absint( $post ) ); } - $teams = get_post_meta( $post->ID, 'sp_team', false ); - $teams = array_filter( $teams ); + if ( ! $post instanceof WP_Post || 'sp_event' !== $post->post_type ) { + return null; + } + + return $post; +} + +/** + * Get event team IDs in SportsPress display order. + * + * @param int|WP_Post $post Event post or ID. + * @return int[] + */ +function asc_sp_event_team_ids( $post ) { + $post = asc_sp_event_get_post( $post ); + + if ( ! $post ) { + return array(); + } + + $teams = get_post_meta( $post->ID, 'sp_team', false ); + $team_ids = array(); - $team_names = array(); foreach ( $teams as $team ) { while ( is_array( $team ) ) { $team = array_shift( array_filter( $team ) ); } + + $team = absint( $team ); if ( $team > 0 ) { - $team_names[] = sp_team_short_name( $team ); + $team_ids[] = $team; } } - $team_names = array_unique( $team_names ); + $team_ids = array_values( array_unique( $team_ids ) ); - if ( get_option( 'sportspress_event_reverse_teams', 'no' ) === 'yes' ) { - $team_names = array_reverse( $team_names ); + if ( 'yes' === get_option( 'sportspress_event_reverse_teams', 'no' ) ) { + $team_ids = array_reverse( $team_ids ); + } + + return $team_ids; +} + +/** + * Get a safe team short name with fallbacks for test and partial SportsPress environments. + * + * @param int $team_id Team post ID. + * @return string + */ +function asc_sp_team_short_name( $team_id ) { + $name = ''; + + if ( function_exists( 'sp_team_short_name' ) ) { + $name = (string) sp_team_short_name( $team_id ); + } + + if ( '' === trim( $name ) ) { + $name = get_the_title( $team_id ); + } + + return '' !== trim( $name ) ? $name : __( 'Team TBD', 'tonys-sportspress-enhancements' ); +} + +/** + * Generate a matchup title from event teams. + * + * @param int|WP_Post $post Event post or ID. + * @return string + */ +function asc_generate_sp_event_title( $post ) { + $post = asc_sp_event_get_post( $post ); + + if ( ! $post ) { + return get_the_title(); + } + + $team_names = array_map( 'asc_sp_team_short_name', asc_sp_event_team_ids( $post ) ); + $team_names = array_values( array_filter( array_unique( $team_names ) ) ); + + if ( empty( $team_names ) ) { + return get_the_title( $post ); } $delimiter = ' ' . get_option( 'sportspress_event_teams_delimiter', 'vs' ) . ' '; @@ -53,147 +158,343 @@ function asc_generate_sp_event_title( $post ) { return implode( $delimiter, $team_names ); } +/** + * Generate compact event date text. + * + * @param int|WP_Post $post Event post or ID. + * @param bool $withTime Include time. + * @return string + */ function asc_generate_short_date( $post, $withTime = true ) { - $formatted_date = get_the_date('D n/j/y', $post); + $post = asc_sp_event_get_post( $post ); - if (!$withTime){ - return $formatted_date; - } + if ( ! $post ) { + return ''; + } - if ( get_the_date('i', $post) == "00") { - $formatted_time = get_the_date('gA', $post); - } else { - $formatted_time = get_the_date('g:iA', $post); - } - return $formatted_date . " " . $formatted_time ; + $formatted_date = get_the_date( 'D n/j/y', $post ); + if ( ! $withTime ) { + return $formatted_date; + } + + $formatted_time = '00' === get_the_date( 'i', $post ) ? get_the_date( 'gA', $post ) : get_the_date( 'g:iA', $post ); + + return trim( $formatted_date . ' ' . $formatted_time ); } +/** + * Get venue name for an event. + * + * @param WP_Post $post Event post. + * @return string + */ +function asc_sp_event_venue_name( WP_Post $post ) { + $venue_terms = get_the_terms( $post->ID, 'sp_venue' ); + + if ( is_wp_error( $venue_terms ) || empty( $venue_terms ) ) { + return __( 'Venue TBD', 'tonys-sportspress-enhancements' ); + } + + return $venue_terms[0]->name; +} + +/** + * Normalize event body content for meta descriptions. + * + * @param WP_Post $post Event post. + * @return string + */ +function asc_sp_event_body_excerpt( WP_Post $post ) { + $content = strip_shortcodes( $post->post_content ); + $content = wp_strip_all_tags( $content, true ); + $content = html_entity_decode( $content, ENT_QUOTES, get_bloginfo( 'charset' ) ); + $content = preg_replace( '/\s+/', ' ', $content ); + $content = trim( (string) $content ); + + if ( '' === $content ) { + return ''; + } + + return wp_trim_words( $content, 35, '' ); +} + +/** + * Safely instantiate a SportsPress event object. + * + * @param WP_Post $post Event post. + * @return object|null + */ +function asc_sp_event_object( WP_Post $post ) { + if ( ! class_exists( 'SP_Event' ) ) { + return null; + } + + try { + return new SP_Event( $post->ID ); + } catch ( Throwable $e ) { + return null; + } +} + +/** + * Get the SportsPress event status with fallbacks. + * + * @param WP_Post $post Event post. + * @param object|null $event SportsPress event object. + * @return string + */ +function asc_sp_event_status( WP_Post $post, $event = null ) { + if ( $event && is_callable( array( $event, 'status' ) ) ) { + try { + $status = (string) $event->status(); + if ( '' !== $status ) { + return $status; + } + } catch ( Throwable $e ) { + return ''; + } + } + + return 'future' === $post->post_status ? 'future' : ''; +} + +/** + * Get SportsPress result rows safely. + * + * @param object|null $event SportsPress event object. + * @return array + */ +function asc_sp_event_results( $event = null ) { + if ( ! $event || ! is_callable( array( $event, 'results' ) ) ) { + return array(); + } + + try { + $results = $event->results(); + return is_array( $results ) ? $results : array(); + } catch ( Throwable $e ) { + return array(); + } +} + +/** + * Convert a result row into outcome labels. + * + * @param array $result Result row. + * @return array + */ +function asc_sp_event_result_outcomes( array $result ) { + $result_outcome = isset( $result['outcome'] ) ? $result['outcome'] : null; + + if ( ! is_array( $result_outcome ) ) { + return array(); + } + + $outcomes = array(); + + foreach ( $result_outcome as $outcome ) { + $the_outcome = get_page_by_path( $outcome, OBJECT, 'sp_outcome' ); + + if ( $the_outcome instanceof WP_Post ) { + $outcome_abbreviation = get_post_meta( $the_outcome->ID, 'sp_abbreviation', true ); + if ( ! $outcome_abbreviation ) { + $outcome_abbreviation = function_exists( 'sp_substr' ) ? sp_substr( $the_outcome->post_title, 0, 1 ) : substr( $the_outcome->post_title, 0, 1 ); + } + + $outcomes[] = array( + 'title' => $the_outcome->post_title, + 'abbreviation' => $outcome_abbreviation, + ); + } + } + + return $outcomes; +} + +/** + * Build a result title/description from SportsPress result data. + * + * @param WP_Post $post Event post. + * @param array $results Event results data. + * @param string $description Existing description. + * @return array{title:string,description:string}|null + */ +function asc_sp_event_result_meta( WP_Post $post, array $results, $description ) { + unset( $results[0] ); + + $results = array_filter( $results ); + + if ( count( $results ) < 2 ) { + return null; + } + + if ( 'yes' === get_option( 'sportspress_event_reverse_teams', 'no' ) ) { + $results = array_reverse( $results, true ); + } + + $teams_result_array = array(); + + foreach ( $results as $team_id => $result ) { + if ( ! is_array( $result ) ) { + continue; + } + + $outcomes = asc_sp_event_result_outcomes( $result ); + $first_outcome = ! empty( $outcomes ) ? $outcomes[0] : array( 'title' => __( 'Result', 'tonys-sportspress-enhancements' ), 'abbreviation' => '' ); + $team_name = asc_sp_team_short_name( $team_id ); + $team_score = isset( $result['r'] ) && '' !== $result['r'] ? $result['r'] : null; + $team_score = null !== $team_score ? (string) $team_score : ''; + + if ( '' === $team_score ) { + continue; + } + + $teams_result_array[] = array( + 'score' => $team_score, + 'outcome' => $first_outcome['title'], + 'outcome_abbreviation' => $first_outcome['abbreviation'], + 'team_name' => $team_name, + ); + } + + if ( count( $teams_result_array ) < 2 ) { + return null; + } + + $special_result_suffix_abbreviation = ''; + $special_result_suffix = ''; + + foreach ( $teams_result_array as $team ) { + $outcome_abbreviation = strtoupper( (string) $team['outcome_abbreviation'] ); + + if ( 'TF-W' === $outcome_abbreviation ) { + $special_result_suffix_abbreviation = 'TF-W'; + $special_result_suffix = 'Technical Forfeit Win'; + break; + } + + if ( 'TF-L' === $outcome_abbreviation ) { + $special_result_suffix_abbreviation = 'TF'; + $special_result_suffix = 'Technical Forfeit'; + break; + } + + if ( 'F-W' === $outcome_abbreviation || 'F-L' === $outcome_abbreviation ) { + $special_result_suffix_abbreviation = 'Forfeit'; + $special_result_suffix = 'Forfeit'; + break; + } + } + + $publish_date = asc_generate_short_date( $post, false ); + $title = sprintf( + '%1$s %2$s-%3$s %4$s%s', + $teams_result_array[0]['team_name'], + $teams_result_array[0]['score'], + $teams_result_array[1]['score'], + $teams_result_array[1]['team_name'], + $publish_date ? ' - ' . $publish_date : '' + ); + + if ( $special_result_suffix ) { + $title .= " ({$special_result_suffix_abbreviation})"; + } + + $description .= sprintf( + ' %1$s (%2$s), %3$s (%4$s).', + $teams_result_array[0]['team_name'], + $teams_result_array[0]['outcome'], + $teams_result_array[1]['team_name'], + $teams_result_array[1]['outcome'] + ); + + return array( + 'title' => $title, + 'description' => $description, + ); +} + +/** + * Build all Open Graph values for an event. + * + * @param int|WP_Post $post Event post or ID. + * @return array + */ +function asc_sp_event_open_graph_data( $post ) { + $post = asc_sp_event_get_post( $post ); + + if ( ! $post ) { + return array(); + } + + $event = asc_sp_event_object( $post ); + $venue_name = asc_sp_event_venue_name( $post ); + $publish_date_and_time = get_the_date( 'F j, Y g:i A', $post ); + $description = trim( "{$publish_date_and_time} at {$venue_name}." ); + $title = asc_generate_sp_event_title( $post ); + $sp_status = get_post_meta( $post->ID, 'sp_status', true ); + $status = asc_sp_event_status( $post, $event ); + + if ( in_array( $sp_status, array( 'postponed', 'cancelled', 'tbd' ), true ) ) { + $status_label = strtoupper( $sp_status ); + $description = "{$status_label} - {$description}"; + $title = trim( "{$status_label} - {$title} - " . asc_generate_short_date( $post ) . " - {$venue_name}", ' -' ); + } elseif ( 'future' === $status ) { + $title = trim( $title . ' - ' . asc_generate_short_date( $post ) . " - {$venue_name}", ' -' ); + } elseif ( 'results' === $status ) { + $result_meta = asc_sp_event_result_meta( $post, asc_sp_event_results( $event ), $description ); + + if ( $result_meta ) { + $title = $result_meta['title']; + $description = $result_meta['description']; + } + } + + $body_excerpt = asc_sp_event_body_excerpt( $post ); + if ( '' !== $body_excerpt ) { + $description = trim( $description . ' ' . $body_excerpt ); + } + + return array( + 'type' => 'article', + 'images' => asc_sp_event_open_graph_images( $post ), + 'image' => asc_sp_event_matchup_image_url( $post, 'wide' ), + 'image_width' => '1200', + 'image_height' => '628', + 'title' => $title, + 'description' => $description, + 'url' => get_permalink( $post ), + ); +} + +/** + * Echo Open Graph meta tags for single SportsPress events. + */ function custom_open_graph_tags_with_sportspress_integration() { - if (is_single()) { - global $post; - if ($post->post_type === 'sp_event') { - // Instantiate SP_Event object - $event = new SP_Event($post->ID); + if ( ! is_single() ) { + return; + } - // Fetch details using SP_Event methods - $publish_date = get_the_date('F j, Y', $post); - $venue_terms = get_the_terms($post->ID, 'sp_venue'); - $venue_name = $venue_terms ? $venue_terms[0]->name : 'Venue TBD'; - $results = $event->results(); // Using SP_Event method - $title = asc_generate_sp_event_title($post); - $sp_status = get_post_meta( $post->ID, 'sp_status', true ); - $status = $event->status(); // Using SP_Event method - $publish_date_and_time = get_the_date('F j, Y g:i A', $post); - $description = "{$publish_date_and_time} at {$venue_name}."; - - if ( 'postponed' == $sp_status || 'cancelled' == $sp_status || 'tbd' == $sp_status) { - $description = strtoupper($sp_status) . " — " . $description; - $title = strtoupper($sp_status) . " — " . $title . " — " . asc_generate_short_date($post) . " — " . $venue_name; - } + $post = asc_sp_event_get_post(); - if ( 'future' == $status ) { - $description = $description; - $title = $title . " — " . asc_generate_short_date($post) . " — " . $venue_name; - } + if ( ! $post ) { + return; + } - if ( 'results' == $status ) { // checks if there is a final score - // Get event result data - $data = $event->results(); + $meta = asc_sp_event_open_graph_data( $post ); - // The first row should be column labels - $labels = $data[0]; + if ( empty( $meta ) ) { + return; + } - // Remove the first row to leave us with the actual data - unset( $data[0] ); - - $data = array_filter( $data ); - - if ( empty( $data ) ) { - return false; - } - - // Initialize - $i = 0; - $result_string = ''; - $title_string = ''; - - // Reverse teams order if the option "Events > Teams > Order > Reverse order" is enabled. - $reverse_teams = get_option( 'sportspress_event_reverse_teams', 'no' ) === 'yes' ? true : false; - if ( $reverse_teams ) { - $data = array_reverse( $data, true ); - } - - $teams_result_array = []; - - foreach ( $data as $team_id => $result ) : - $outcomes = array(); - $result_outcome = sp_array_value( $result, 'outcome' ); - if ( ! is_array( $result_outcome ) ) : - $outcomes = array( '—' ); - else : - foreach ( $result_outcome as $outcome ) : - $the_outcome = get_page_by_path( $outcome, OBJECT, 'sp_outcome' ); - if ( is_object( $the_outcome ) ) : - $outcomes[] = $the_outcome->post_title; - endif; - endforeach; - endif; - - unset( $result['outcome'] ); - - $team_name = sp_team_short_name( $team_id ); - $team_abbreviation = sp_team_abbreviation( $team_id ); - - $outcome_abbreviation = get_post_meta( $the_outcome->ID, 'sp_abbreviation', true ); - if ( ! $outcome_abbreviation ) { - $outcome_abbreviation = sp_substr( $the_outcome->post_title, 0, 1 ); - } - - array_push($teams_result_array, [ - "result" => $result, - "outcome" => $the_outcome->post_title, - "outcome_abbreviation" => $outcome_abbreviation, - "team_name" => $team_name, - "team_abbreviation" => $team_abbreviation - ] - ); - $i++; - endforeach; - $publish_date = asc_generate_short_date($post, false); - - $special_result_suffix_abbreviation = ''; - $special_result_suffix= ''; - - foreach ( $teams_result_array as $team ) { - $outcome_abbreviation = strtoupper( $team['outcome_abbreviation'] ); // Normalize case - - if ( $outcome_abbreviation === 'TF-W' ) { - $special_result_suffix_abbreviation = 'TF-W'; - $special_result_suffix = 'Technical Forfeit Win'; - break; - } elseif ( $outcome_abbreviation === 'TF-L' ) { - $special_result_suffix_abbreviation = 'TF'; - $special_result_suffix = 'Technical Forfeit'; - break; - } elseif ( $outcome_abbreviation === 'F-W' || $outcome_abbreviation === 'F-L' ) { - $special_result_suffix_abbreviation = 'Forfeit'; - $special_result_suffix = 'Forfeit'; - break; - } - } - - $title = "{$teams_result_array[0]['team_name']} {$teams_result_array[0]['result']['r']}-{$teams_result_array[1]['result']['r']} {$teams_result_array[1]['team_name']} — {$publish_date}" . ($special_result_suffix ? "({$special_result_suffix_abbreviation})" : ""); - $description .= " " . "{$teams_result_array[0]['team_name']} ({$teams_result_array[0]['outcome']}), {$teams_result_array[1]['team_name']} ({$teams_result_array[1]['outcome']})." ; - } - $description .= " " . $post->post_content; - $image = asc_sp_event_matchup_image_url( $post ); - echo '' . "\n"; - echo '' . "\n"; - echo '' . "\n"; - echo '' . "\n"; - echo '' . "\n"; - } - } + echo '' . "\n"; + foreach ( $meta['images'] as $image ) { + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + } + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; } -?> diff --git a/tests/test-featured-image-generator.php b/tests/test-featured-image-generator.php new file mode 100644 index 0000000..d6d3534 --- /dev/null +++ b/tests/test-featured-image-generator.php @@ -0,0 +1,222 @@ +temp_files as $file ) { + if ( file_exists( $file ) ) { + unlink( $file ); + } + } + + $this->temp_files = array(); + parent::tear_down(); + } + + /** + * Create a post. + * + * @param string $type Post type. + * @param string $title Title. + * @return int + */ + private function create_post_of_type( $type, $title ) { + return self::factory()->post->create( + array( + 'post_type' => $type, + 'post_title' => $title, + 'post_status' => 'publish', + ) + ); + } + + /** + * Create a small raster fixture. + * + * @param string $extension File extension. + * @return string + */ + private function create_raster_fixture( $extension ) { + $image = imagecreatetruecolor( 24, 24 ); + $red = imagecolorallocate( $image, 200, 0, 0 ); + imagefilledrectangle( $image, 0, 0, 23, 23, $red ); + + $file = tempnam( sys_get_temp_dir(), 'sp-img-' ); + $path = $file . '.' . $extension; + rename( $file, $path ); + + switch ( $extension ) { + case 'jpg': + imagejpeg( $image, $path ); + break; + case 'gif': + imagegif( $image, $path ); + break; + case 'webp': + imagewebp( $image, $path ); + break; + case 'png': + default: + imagepng( $image, $path ); + break; + } + + asc_sp_event_image_destroy( $image ); + + $this->temp_files[] = $path; + + return $path; + } + + /** + * Invalid IDs and non-event posts produce request errors. + */ + public function test_invalid_and_non_event_requests_prepare_404_errors() { + $this->assertWPError( asc_sp_event_prepare_image_request( 999999 ) ); + + $post_id = $this->create_post_of_type( 'post', 'Regular Post' ); + $error = asc_sp_event_prepare_image_request( $post_id ); + + $this->assertWPError( $error ); + $this->assertSame( 'invalid_event', $error->get_error_code() ); + } + + /** + * Missing team logo paths fall back to generated text and valid dimensions. + */ + public function test_missing_logo_path_generates_png_with_expected_dimensions() { + $image_data = generate_bisected_image( '#123456', '#abcdef', '/missing-left.png', '/missing-right.png', 'Hawks', 'Electrons' ); + $image = imagecreatefromstring( $image_data ); + + $this->assertNotFalse( $image ); + $this->assertSame( 1200, imagesx( $image ) ); + $this->assertSame( 628, imagesy( $image ) ); + + asc_sp_event_image_destroy( $image ); + } + + /** + * Square image variant generates square PNG dimensions. + */ + public function test_square_variant_generates_expected_dimensions() { + $dimensions = asc_sp_event_image_variant_dimensions( 'square' ); + $image_data = generate_bisected_image( '#123456', '#abcdef', '/missing-left.png', '/missing-right.png', 'Hawks', 'Electrons', $dimensions['width'], $dimensions['height'] ); + $image = imagecreatefromstring( $image_data ); + + $this->assertNotFalse( $image ); + $this->assertSame( 1200, imagesx( $image ) ); + $this->assertSame( 1200, imagesy( $image ) ); + + asc_sp_event_image_destroy( $image ); + } + + /** + * Raster loader supports common GD-backed formats. + */ + public function test_raster_loader_supports_common_formats_when_available() { + $formats = array( + 'png' => 'imagecreatefrompng', + 'jpg' => 'imagecreatefromjpeg', + 'gif' => 'imagecreatefromgif', + ); + + if ( function_exists( 'imagewebp' ) && function_exists( 'imagecreatefromwebp' ) ) { + $formats['webp'] = 'imagecreatefromwebp'; + } + + foreach ( $formats as $extension => $function ) { + if ( ! function_exists( $function ) ) { + continue; + } + + $path = $this->create_raster_fixture( $extension ); + $image = asc_sp_event_image_create_from_file( $path ); + + $this->assertNotFalse( $image, "Failed loading {$extension}" ); + $this->assertSame( 24, imagesx( $image ) ); + $this->assertSame( 24, imagesy( $image ) ); + asc_sp_event_image_destroy( $image ); + } + } + + /** + * Bundled sporty font is available for fallback text. + */ + public function test_bundled_bebas_neue_font_is_available() { + $this->assertFileExists( asc_sp_event_image_font_path() ); + $this->assertIsReadable( asc_sp_event_image_font_path() ); + } + + /** + * Prepared event request includes fallback text for missing logos. + */ + public function test_prepare_image_request_uses_team_short_name_fallbacks() { + $team1 = $this->create_post_of_type( 'sp_team', 'Hawks' ); + $team2 = $this->create_post_of_type( 'sp_team', 'Electrons' ); + $event = $this->create_post_of_type( 'sp_event', 'Hawks vs Electrons' ); + + add_post_meta( $event, 'sp_team', $team1 ); + add_post_meta( $event, 'sp_team', $team2 ); + + $request = asc_sp_event_prepare_image_request( $event ); + + $this->assertIsArray( $request ); + $this->assertSame( 'Hawks', $request['team1_fallback'] ); + $this->assertSame( 'Electrons', $request['team2_fallback'] ); + $this->assertSame( '', $request['team1_logo'] ); + $this->assertSame( '', $request['team2_logo'] ); + } + + /** + * Invalid colors are safely normalized. + */ + public function test_invalid_colors_fall_back_to_configured_defaults() { + $this->assertSame( '#4B5563', asc_sp_event_image_color( 'not-a-color' ) ); + $this->assertSame( '#6B7280', asc_sp_event_image_color( 'not-a-color', '#6B7280' ) ); + $this->assertSame( '#112233', asc_sp_event_image_color( '#112233' ) ); + } + + /** + * Image cache keys include the generator version and style hash. + */ + public function test_prepare_image_request_uses_versioned_style_cache_key() { + $team1 = $this->create_post_of_type( 'sp_team', 'Hawks' ); + $team2 = $this->create_post_of_type( 'sp_team', 'Electrons' ); + $event = $this->create_post_of_type( 'sp_event', 'Hawks vs Electrons' ); + + add_post_meta( $event, 'sp_team', $team1 ); + add_post_meta( $event, 'sp_team', $team2 ); + + $request = asc_sp_event_prepare_image_request( $event ); + + $this->assertStringStartsWith( 'team_image_v' . ASC_SP_EVENT_IMAGE_CACHE_VERSION . '_' . asc_sp_event_image_cache_style_hash(), $request['cache_key'] ); + $this->assertSame( 'wide', $request['variant'] ); + $this->assertSame( 1200, $request['width'] ); + $this->assertSame( 628, $request['height'] ); + + $square_request = asc_sp_event_prepare_image_request( $event, 'square' ); + + $this->assertStringContainsString( '_square_', $square_request['cache_key'] ); + $this->assertSame( 'square', $square_request['variant'] ); + $this->assertSame( 1200, $square_request['width'] ); + $this->assertSame( 1200, $square_request['height'] ); + } +} diff --git a/tests/test-open-graph-tags.php b/tests/test-open-graph-tags.php new file mode 100644 index 0000000..58dfbd7 --- /dev/null +++ b/tests/test-open-graph-tags.php @@ -0,0 +1,271 @@ + + */ + public static $statuses = array(); + + /** + * Result values by event ID. + * + * @var array + */ + public static $results = array(); + + /** + * Constructor. + * + * @param int $id Event post ID. + */ + public function __construct( $id ) { + $this->id = absint( $id ); + } + + /** + * Get event status. + * + * @return string + */ + public function status() { + return self::$statuses[ $this->id ] ?? ''; + } + + /** + * Get event results. + * + * @return array + */ + public function results() { + return self::$results[ $this->id ] ?? array(); + } + } +} + +/** + * Open Graph tests. + */ +class Test_Open_Graph_Tags extends WP_UnitTestCase { + + /** + * Reset mock SportsPress state. + */ + public function set_up(): void { + parent::set_up(); + + if ( property_exists( 'SP_Event', 'statuses' ) ) { + SP_Event::$statuses = array(); + SP_Event::$results = array(); + } + + update_option( 'sportspress_event_reverse_teams', 'no' ); + update_option( 'sportspress_event_teams_delimiter', 'vs' ); + } + + /** + * Create a team. + * + * @param string $name Team name. + * @return int + */ + private function create_team( $name ) { + return self::factory()->post->create( + array( + 'post_type' => 'sp_team', + 'post_title' => $name, + ) + ); + } + + /** + * Create an event. + * + * @param array $args Post args. + * @return int + */ + private function create_event( array $args = array() ) { + return self::factory()->post->create( + wp_parse_args( + $args, + array( + 'post_type' => 'sp_event', + 'post_title' => 'Test Event', + 'post_status' => 'future', + 'post_date' => '2026-05-02 13:00:00', + 'post_content' => 'First pitch at one.', + ) + ) + ); + } + + /** + * Future event emits complete Open Graph data. + */ + public function test_future_event_emits_core_open_graph_values() { + $home = $this->create_team( 'Hawks' ); + $away = $this->create_team( 'Electrons' ); + $event = $this->create_event(); + + add_post_meta( $event, 'sp_team', $home ); + add_post_meta( $event, 'sp_team', $away ); + + if ( property_exists( 'SP_Event', 'statuses' ) ) { + SP_Event::$statuses[ $event ] = 'future'; + } + + $meta = asc_sp_event_open_graph_data( $event ); + + $this->assertSame( 'article', $meta['type'] ); + $this->assertStringContainsString( 'Hawks vs Electrons', $meta['title'] ); + $this->assertStringContainsString( 'First pitch at one.', $meta['description'] ); + $this->assertCount( 2, $meta['images'] ); + $this->assertSame( '1200', $meta['images'][0]['width'] ); + $this->assertSame( '628', $meta['images'][0]['height'] ); + $this->assertSame( '1200', $meta['images'][1]['width'] ); + $this->assertSame( '1200', $meta['images'][1]['height'] ); + $this->assertSame( '1200', $meta['image_width'] ); + $this->assertSame( '628', $meta['image_height'] ); + $this->assertStringContainsString( '/head-to-head?post=' . $event, $meta['image'] ); + $this->assertStringContainsString( 'variant=square', $meta['images'][1]['url'] ); + $this->assertNotEmpty( $meta['url'] ); + } + + /** + * Postponed, cancelled, and TBD labels appear in title and description. + * + * @dataProvider status_provider + * + * @param string $status Status slug. + */ + public function test_schedule_status_appears_in_title_and_description( $status ) { + $home = $this->create_team( 'Hawks' ); + $away = $this->create_team( 'Electrons' ); + $event = $this->create_event(); + + add_post_meta( $event, 'sp_team', $home ); + add_post_meta( $event, 'sp_team', $away ); + update_post_meta( $event, 'sp_status', $status ); + + $meta = asc_sp_event_open_graph_data( $event ); + $label = strtoupper( $status ); + + $this->assertStringStartsWith( $label, $meta['title'] ); + $this->assertStringStartsWith( $label, $meta['description'] ); + } + + /** + * Status provider. + * + * @return array + */ + public function status_provider() { + return array( + array( 'postponed' ), + array( 'cancelled' ), + array( 'tbd' ), + ); + } + + /** + * Result events with scores emit score titles. + */ + public function test_result_event_with_scores_emits_score_title() { + $home = $this->create_team( 'Hawks' ); + $away = $this->create_team( 'Electrons' ); + $event = $this->create_event( array( 'post_status' => 'publish' ) ); + + add_post_meta( $event, 'sp_team', $home ); + add_post_meta( $event, 'sp_team', $away ); + + if ( property_exists( 'SP_Event', 'statuses' ) ) { + SP_Event::$statuses[ $event ] = 'results'; + SP_Event::$results[ $event ] = array( + 0 => array( 'r' => 'R' ), + $home => array( 'r' => '7' ), + $away => array( 'r' => '4' ), + ); + } + + $meta = asc_sp_event_open_graph_data( $event ); + + $this->assertStringContainsString( 'Hawks 7-4 Electrons', $meta['title'] ); + } + + /** + * Missing teams/results/outcomes still produce valid data. + */ + public function test_missing_sportspress_data_does_not_break_meta_generation() { + $event = $this->create_event( + array( + 'post_title' => 'Sparse Event', + 'post_content' => '', + ) + ); + + if ( property_exists( 'SP_Event', 'statuses' ) ) { + SP_Event::$statuses[ $event ] = 'results'; + SP_Event::$results[ $event ] = array(); + } + + $meta = asc_sp_event_open_graph_data( $event ); + + $this->assertSame( 'Sparse Event', $meta['title'] ); + $this->assertNotEmpty( $meta['description'] ); + $this->assertSame( '1200', $meta['image_width'] ); + } + + /** + * HTML-heavy post content is stripped and escaped in rendered tags. + */ + public function test_description_strips_html_and_rendered_tags_are_escaped() { + $home = $this->create_team( 'Hawks "A"' ); + $away = $this->create_team( 'Electrons ' ); + $event = $this->create_event( + array( + 'post_content' => '

Bring bats & gloves.

', + ) + ); + + add_post_meta( $event, 'sp_team', $home ); + add_post_meta( $event, 'sp_team', $away ); + + $meta = asc_sp_event_open_graph_data( $event ); + + $this->assertStringNotContainsString( 'assertStringContainsString( 'Bring bats & gloves.', $meta['description'] ); + + $GLOBALS['post'] = get_post( $event ); + $GLOBALS['wp_query']->is_single = true; + + ob_start(); + custom_open_graph_tags_with_sportspress_integration(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'og:image:width', $output ); + $this->assertSame( 2, substr_count( $output, 'property="og:image" content=' ) ); + $this->assertStringContainsString( 'content="628"', $output ); + $this->assertStringContainsString( 'content="1200"', $output ); + $this->assertStringContainsString( 'variant=square', $output ); + $this->assertStringContainsString( 'Hawks "A"', $output ); + $this->assertStringNotContainsString( '', $output ); + $this->assertStringNotContainsString( '